From 9320b46f68e57489efa3e760c626b6d203b11f21 Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Sun, 19 Jul 2026 12:44:07 +0300 Subject: [PATCH] add particles --- src/app.zig | 180 +++++++++++++++++++++++++++++++++++++++++++++-- src/color.zig | 9 +++ src/graphics.zig | 8 ++- 3 files changed, 191 insertions(+), 6 deletions(-) diff --git a/src/app.zig b/src/app.zig index 1261529..6668ea7 100644 --- a/src/app.zig +++ b/src/app.zig @@ -6,6 +6,7 @@ const assert = std.debug.assert; const Math = @import("math"); const Vec2 = Math.Vec2; +const Range = Math.Range; const STBImage = @import("stb_image"); const STBTrueType = @import("stb_truetype"); @@ -26,6 +27,8 @@ const Prng = std.Random.DefaultPrng; const max_entities = 2048; +const max_particles = 4096; + gpa: Allocator, camera_pos: Vec2, prng: Prng, @@ -39,6 +42,9 @@ eternal_flame_id: EntityId, minions_buffer: [max_entities]EntityId, minions: std.ArrayList(EntityId), +particles_buffer: [max_particles]Particle, +particles: std.ArrayList(Particle), + upgrades: Upgrades, shop: Shop, @@ -118,6 +124,18 @@ const Tilesheet = struct { } }; +const Particle = struct { + size: Vec2, + pos: Vec2, + rotation: f32, + vel: Vec2, + angular_vel: f32, + + color: Color, + created_at: Nanoseconds, + dissapear_at: Nanoseconds, +}; + const Animation = struct { frames: []Gfx.Sprite.Id, frame_duration: Platform.Nanoseconds, @@ -199,6 +217,7 @@ const Entity = struct { tree_health: u32 = 0, tree_visual_health: f32 = 0, tree_max_health: u32 = 0, + tree_trunk_height: u32 = 0, station_spawn_tree_cooldown: Nanoseconds = 0, station_owned_tree: ?EntityId = null, @@ -668,6 +687,9 @@ pub fn init(self: *App, plt: Platform.Init) !?u8 { .minions_buffer = undefined, .minions = .initBuffer(&self.minions_buffer), + .particles_buffer = undefined, + .particles = .initBuffer(&self.particles_buffer), + .terrain_sheet = terrain_sheet, .terrain_tiles = terrain_tiles, }; @@ -949,7 +971,8 @@ fn spawnTree(self: *App, station_id: EntityId) void { .tree_max_health = health, .seed = rng.int(u64), - .pos = station.pos.add(.init((rng.float(f32)-0.5) * terrain_tile_size.x, 0)) + .pos = station.pos.add(.init((rng.float(f32)-0.5) * terrain_tile_size.x, 0)), + .tree_trunk_height = rng.uintAtMost(u32, 4) + 2 })) |id| { station.station_owned_tree = id; } @@ -1670,6 +1693,7 @@ fn showDebug(self: *App, plt: Platform.Frame) !void { } _ = ImGUI.checkbox("Minion state", &self.show_minion_state); + _ = ImGUI.text("Particle count {}", .{self.particles.items.len}); ImGUI.separator(); _ = ImGUI.text("Minion upgrades", .{}); @@ -1767,7 +1791,7 @@ fn showDebug(self: *App, plt: Platform.Frame) !void { } } -fn drawTree(self: *App, seed: u64, pos: Vec2, health_percent: f32) void { +fn drawTree(self: *App, plt: Platform.Frame, seed: u64, pos: Vec2, trunk_height: u32, health_percent: f32) void { var prng = Prng.init(seed); const rng = prng.random(); @@ -1789,8 +1813,6 @@ fn drawTree(self: *App, seed: u64, pos: Vec2, health_percent: f32) void { self.terrain_sheet.get(125, 39), }; - const trunk_height = rng.uintAtMost(u32, 4) + 2; - Gfx.drawSprite(self.terrain_sheet.get(125, 42), .init(0, 0), .init(1, 1), .white); for (0..trunk_height) |i| { const sprite = tree_middle[rng.uintLessThan(u32, tree_middle.len)]; @@ -1831,6 +1853,19 @@ fn drawTree(self: *App, seed: u64, pos: Vec2, health_percent: f32) void { Gfx.drawSprite(corner_leaves, .init(2, -1), .init(-1, -1), .white); } + self.spawnParticles(plt, .{ + .pos = pos.add(.init(0, -terrain_tile_size.y*(@as(f32, @floatFromInt(trunk_height))+1.5))), + .size = .init(4, 4), + .radius = .init(10, 40), + .count = .init(0, 0.03), + .duration_s = .init(0.5, 2.5), + .angle = .init(0, std.math.pi), + .color_variance = 0.1, + .color = .rgb(40, 150, 40), + .vel_y = .init(10, 60), + .angular_vel = .init(-4, 4) + }); + if (health_percent < 1) { drawProgressBar(1, .init(0, 20), .init(terrain_tile_size.x, 10), .black); drawProgressBar(health_percent, .init(0, 20), .init(terrain_tile_size.x, 10), .white); @@ -1859,6 +1894,61 @@ fn drawEntityAnimation(self: *App, e: *Entity, dt: Nanoseconds) void { } } +const ParticleOptions = struct { + pos: Vec2, + size: Vec2, + count: Range, + radius: Range, + angle: Range = .init(0, std.math.pi*2), + duration_s: Range = .init(0.5, 1), + vel_x: Range = .init(0, 0), + vel_y: Range = .init(0, 0), + angular_vel: Range = .init(0, 0), + + color: Color = .white, + color_variance: f32 = 0 +}; + +fn spawnParticles(self: *App, plt: Platform.Frame, opts: ParticleOptions) void { + const rng = self.prng.random(); + + var count_f32: f32 = 0; + if (opts.count.to < 1) { + if (opts.count.to > rng.float(f32)) { + count_f32 = 1; + } + } else { + count_f32 = opts.count.random(rng); + } + for (0..@intFromFloat(count_f32)) |_| { + const radius = opts.radius.random(rng); + const angle = opts.angle.random(rng); + const duration_s = opts.duration_s.random(rng); + + const color = Color{ + .r = opts.color.r + ((rng.float(f32)-0.5)*2) * opts.color_variance, + .g = opts.color.g + ((rng.float(f32)-0.5)*2) * opts.color_variance, + .b = opts.color.b + ((rng.float(f32)-0.5)*2) * opts.color_variance, + .a = opts.color.a + ((rng.float(f32)-0.5)*2) * opts.color_variance, + }; + + const offset = Vec2.init(radius, 0).rotate(angle); + self.particles.appendBounded(Particle{ + .color = color, + .pos = opts.pos.add(offset), + .size = opts.size, + .rotation = 0, + .created_at = plt.t, + .dissapear_at = plt.t + @as(Nanoseconds,@intFromFloat(duration_s * std.time.ns_per_s)), + .vel = .init(opts.vel_x.random(rng), opts.vel_y.random(rng)), + .angular_vel = opts.angular_vel.random(rng) + }) catch { + log.warn("Particle limit reached", .{}); + return; + }; + } +} + pub fn frame(self: *App, plt: Platform.Frame) !void { const input = plt.input; _ = input; // autofix @@ -2168,6 +2258,19 @@ pub fn frame(self: *App, plt: Platform.Frame) !void { break :blk animations.right; } }; + + if (minion.minion_animation == .walk) { + self.spawnParticles(plt, .{ + .pos = minion.pos, + .size = .init(8, 8), + .radius = .init(5, 30), + .count = .init(0, 0.03), + .duration_s = .init(0.5, 1.5), + .angle = .init(0, std.math.pi), + .color_variance = 0.1, + .color = .rgb(40, 150, 40), + }); + } } } @@ -2249,17 +2352,59 @@ pub fn frame(self: *App, plt: Platform.Frame) !void { const health_percent = e.tree_visual_health / @as(f32, @floatFromInt(e.tree_max_health)); self.drawTree( + plt, e.seed, e.pos, + e.tree_trunk_height, health_percent ); if (e.tree_visual_health < 0.1) { self.destroyTree(id); + + for (0..e.tree_trunk_height) |i| { + self.spawnParticles(plt, .{ + .pos = e.pos.add(.init(0, -terrain_tile_size.y*@as(f32,@floatFromInt(i+1)))), + .size = .init(4, 6), + .radius = .init(1, 16), + .count = .init(20, 70), + .duration_s = .init(0.2, 1.0), + .angle = .init(0, std.math.pi*2), + .color_variance = 0.1, + .color = .rgb(200, 120, 40), + .angular_vel = .init(-8, 8) + }); + } } } } + { // Draw particles + var i: usize = 0; + while (i < self.particles.items.len) { + const particle = &self.particles.items[i]; + if (plt.t >= particle.dissapear_at) { + _ = self.particles.swapRemove(i); + continue; + } + + const duration = @as(f64, @floatFromInt(particle.dissapear_at - particle.created_at)); + const progress = @as(f64, @floatFromInt(plt.t - particle.created_at)) / duration; + + particle.pos = particle.pos.add(particle.vel.multiplyScalar(dt)); + particle.rotation += particle.angular_vel*dt; + + var color = particle.color; + color.a *= (1-@as(f32, @floatCast(progress))); + + var quad = Gfx.Quad.initRect(particle.size.divideScalar(-2), particle.size); + quad.applyRotate(particle.rotation, .init(0, 0)); + quad.applyOffset(particle.pos); + Gfx.drawQuad(quad, color); + i += 1; + } + } + { // Draw eternal flame var eternal_flame_iter = self.initEntityIteratorByType(.eternal_flame); while (eternal_flame_iter.next()) |id| { @@ -2300,25 +2445,52 @@ pub fn frame(self: *App, plt: Platform.Frame) !void { eternal_flame.eternal_flame_size = .large; } + var particle_radius: f32 = 0; + var particle_count: f32 = 0; + var particle_vel: f32 = 0; + switch (eternal_flame.eternal_flame_size) { .small => { const flame_size = Vec2.init(16, 32).multiplyScalar(4); eternal_flame.animation = self.fire1_animation; eternal_flame.animation_rect = .init(-flame_size.x/2, -flame_size.y, flame_size.x, flame_size.y); + + particle_radius = 40; + particle_count = 0.05; + particle_vel = 70; }, .medium => { const flame_size = Vec2.init(32, 48).multiplyScalar(4); eternal_flame.animation = self.fire2_animation; eternal_flame.animation_rect = .init(-flame_size.x/2, -flame_size.y, flame_size.x, flame_size.y); + + particle_radius = 60; + particle_count = 0.1; + particle_vel = 100; }, .large => { const flame_size = Vec2.init(32, 48).multiplyScalar(4); eternal_flame.animation = self.fire5_animation; eternal_flame.animation_rect = .init(-flame_size.x/2, -flame_size.y, flame_size.x, flame_size.y); + + particle_radius = 80; + particle_count = 0.3; + particle_vel = 120; } } self.drawEntityAnimation(eternal_flame, plt.dt); + self.spawnParticles(plt, .{ + .pos = eternal_flame.pos.sub(.init(0, 70)), + .size = .init(4, 12), + .radius = .init(0, particle_radius), + .count = .init(0, particle_count), + .duration_s = .init(0.5, 3), + .color_variance = 0.15, + .color = .rgb(200, 40, 40), + .vel_y = .init(-1, -particle_vel), + .angular_vel = .init(-4, 4) + }); } } diff --git a/src/color.zig b/src/color.zig index 5feaa54..524f3c0 100644 --- a/src/color.zig +++ b/src/color.zig @@ -41,3 +41,12 @@ pub fn rgb_hex(text: []const u8) ?Color { const b = std.fmt.parseInt(u8, text[5..7], 16) catch return null; return rgb(r, g, b); } + +pub fn lerp(self: Color, other: Color, t: f32) Color { + return Color{ + .r = std.math.lerp(self.r, other.r, t), + .g = std.math.lerp(self.g, other.g, t), + .b = std.math.lerp(self.b, other.b, t), + .a = std.math.lerp(self.a, other.a, t), + }; +} diff --git a/src/graphics.zig b/src/graphics.zig index c6ec261..487e3f6 100644 --- a/src/graphics.zig +++ b/src/graphics.zig @@ -39,7 +39,7 @@ const State = struct { nearest_sampler: sg.Sampler, default_sampler: Sampler, - quads_buffer: [2048]VertexQuad, + quads_buffer: [8192]VertexQuad, quads: std.ArrayList(VertexQuad), clear_color: Color, @@ -530,9 +530,13 @@ pub fn endFrame() void { } pub fn drawRectangle(pos: Vec2, size: Vec2, color: Color) void { + drawQuad(Quad.initRect(pos, size), color); +} + +pub fn drawQuad(quad: Quad, color: Color) void { const self = &g_state; - drawSprite(self.default_sprite, pos, size, color); + drawSpriteQuad(self.default_sprite, quad, color); } pub fn drawRectangleOutline(pos: Vec2, size: Vec2, color: Color, width: f32, alignment: f32) void {