From 80982b71f7eedb77d0b201dcafc3a0888b64b963 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 20:29:32 +0100 Subject: [PATCH] Particle effects: falling trails behind projectiles, exploding fireballs and cannonballs, arrows stuck in walls and floors - projectiles get styles: arrows (shaft and fletching), cannonballs and fireballs fly in an arc, shadow, holy, arcane, rune and magic bolts with their own glow - trails of embers, sparks, motes or smoke that drift down to the floor - fireballs explode on the ground (flash, shock wave, embers, smoke, scorch mark), cannonballs burst into stones, dirt, sparks and smoke - arrows stay stuck in walls (or glance off into the floor) for five minutes, at most 400; they drop out when the wall is dug away - spells: lightning sprays sparks and scorches the floor, healing lifts green motes, level-ups and summoning rain sparkles; digging throws earth, gold pops coins - particles are never saved and use their own random numbers Co-Authored-By: Claude Opus 5.5 --- src/game.rs | 48 +++++-- src/main.rs | 1 + src/particles.rs | 353 +++++++++++++++++++++++++++++++++++++++++++++++ src/render3d.rs | 95 +++++++++++++ 4 files changed, 487 insertions(+), 10 deletions(-) create mode 100644 src/particles.rs diff --git a/src/game.rs b/src/game.rs index 09699cf..1458659 100644 --- a/src/game.rs +++ b/src/game.rs @@ -379,6 +379,7 @@ pub struct Fx { pub h: f32, // height of the start point (3D) pub h2: f32, // height of the end point (projectiles) pub size: f32, // 1 = normal; attacks of higher levels look bigger + pub style: u8, // projectiles: arrow, cannonball, fireball, ... (see particles.rs) } #[derive(Serialize, Deserialize)] @@ -446,6 +447,12 @@ pub struct Game { #[serde(skip)] pub springs: usize, // claimed mana springs (recounted) #[serde(skip)] + pub parts: Vec, // visual particles (never saved) + #[serde(skip)] + pub stuck: Vec, // arrows stuck in walls and floors, scorch marks + #[serde(skip, default = "crate::particles::vrng")] + pub vrng: Rng, // random numbers for the particles only + #[serde(skip)] pub demo: Option, // the endless bridge battle behind the title menu #[serde(default)] pub dwarf_caves: Vec, @@ -606,6 +613,9 @@ impl Game { springs: 0, freed: 0, demo: None, + parts: Vec::new(), + stuck: Vec::new(), + vrng: crate::particles::vrng(), dwarf_caves: Vec::new(), dwarf_t: 30.0, rune_free: 0, @@ -922,8 +932,9 @@ impl Game { pub fn fx(&mut self, kind: u8, x: f32, y: f32, x2: f32, y2: f32, max: f32, col: u32) { let h = if kind == 0 { 0.45 } else { 0.4 }; + self.fx_particles(kind, x, y, h, col); if self.fx.len() < 400 { - self.fx.push(Fx { kind, x, y, x2, y2, t: 0.0, max, col, h, h2: 0.45, size: 1.0 }); + self.fx.push(Fx { kind, x, y, x2, y2, t: 0.0, max, col, h, h2: 0.45, size: 1.0, style: 0 }); } } @@ -931,7 +942,7 @@ impl Game { #[allow(clippy::too_many_arguments)] pub fn shot(&mut self, x: f32, y: f32, h: f32, x2: f32, y2: f32, h2: f32, max: f32, col: u32) { if self.fx.len() < 400 { - self.fx.push(Fx { kind: 0, x, y, x2, y2, t: 0.0, max, col, h, h2, size: 1.0 }); + self.fx.push(Fx { kind: 0, x, y, x2, y2, t: 0.0, max, col, h, h2, size: 1.0, style: 0 }); } } @@ -944,8 +955,9 @@ impl Game { // effect at a given height pub fn fx_at(&mut self, kind: u8, x: f32, y: f32, h: f32, max: f32, col: u32) { + self.fx_particles(kind, x, y, h, col); if self.fx.len() < 400 { - self.fx.push(Fx { kind, x, y, x2: 0.0, y2: 0.0, t: 0.0, max, col, h, h2: h, size: 1.0 }); + self.fx.push(Fx { kind, x, y, x2: 0.0, y2: 0.0, t: 0.0, max, col, h, h2: h, size: 1.0, style: 0 }); } } @@ -961,6 +973,7 @@ impl Game { self.step(dt); } self.sync_treasury(); + self.update_particles(dt); for f in self.fx.iter_mut() { f.t += dt; } @@ -1875,6 +1888,15 @@ impl Game { let h = if flies(e.kind) { 0.8 } else { 0.45 }; self.shot(e.x, e.y, h, tx, ty, 0.45, 0.25, col); self.grow_last_fx(lv); + use crate::particles::*; + self.style_last_fx(match e.kind { + EK::Archer => ST_ARROW, + EK::Mage => ST_SHADOW, + EK::Priest => ST_HOLY, + EK::Priestess | EK::Wyrm => ST_FIRE, + EK::Owl => ST_ARCANE, + _ => ST_PLAIN, + }); self.sfx(Sfx::Shoot, e.x, e.y); } else { self.fx(2, tx, ty, 0.0, 0.0, 0.25, if e.level >= 9 { 0xff60e0ff } else { 0xffffffff }); @@ -1904,8 +1926,6 @@ impl Game { } if e.kind == EK::Priestess || e.kind == EK::Wyrm { // fireball / fire breath: half damage to enemies around the target - self.fx(3, tx, ty, 0.0, 0.0, 0.5, 0xff3080ff); - self.grow_last_fx(lv); let me = e.side(); let splash: Vec = self .ents @@ -4050,7 +4070,12 @@ impl Game { TowerKind::Rune => 0xff40c0ff, }; self.shot(mx, my, mh, ex, ey, eh, 0.3, col); - self.fx_at(2, ex, ey, eh, 0.3, 0xffffffff); + self.style_last_fx(match t.kind { + TowerKind::Arrow => crate::particles::ST_ARROW, + TowerKind::Cannon => crate::particles::ST_CANNON, + TowerKind::Magic => crate::particles::ST_MAGIC, + TowerKind::Rune => crate::particles::ST_RUNE, + }); self.sfx(match t.kind { TowerKind::Arrow => Sfx::Shoot, TowerKind::Cannon => Sfx::Cannon, @@ -4123,8 +4148,9 @@ impl Game { TowerKind::Arrow => { // the arrow pierces up to two more heroes behind the target let dir = norm(ex - px, ey - py); - let end = (px + dir.0 * t.range(), py + dir.1 * t.range()); - self.shot(mx, my, mh, end.0, end.1, 0.45, 0.3, 0xffd0e0f0); + let (endx, endy, wall) = self.arrow_end(px, py, dir.0, dir.1, t.range()); + self.shot(mx, my, mh, endx, endy, 0.45, 0.3, 0xffd0e0f0); + self.style_last_fx(if wall { crate::particles::ST_ARROW_WALL } else { crate::particles::ST_ARROW }); self.sfx(Sfx::Shoot, px, py); let mut hits = vec![(j, dmg)]; for (o, e) in self.ents.iter().enumerate() { @@ -4147,8 +4173,7 @@ impl Game { TowerKind::Cannon => { // splash damage and knockback around the impact self.shot(mx, my, mh, ex, ey, 0.4, 0.35, 0xff303030); - self.fx(3, ex, ey, 0.0, 0.0, 0.6, 0xff4070a0); - self.fx(2, ex, ey, 0.0, 0.0, 0.3, 0xff60c0ff); + self.style_last_fx(crate::particles::ST_CANNON); self.sfx(Sfx::Cannon, px, py); let mut hits = Vec::new(); for (o, e) in self.ents.iter().enumerate() { @@ -4176,6 +4201,7 @@ impl Game { TowerKind::Magic => { // sure hit, slows, jumps to a second hero self.shot(mx, my, mh, ex, ey, 0.45, 0.2, 0xffff60d0); + self.style_last_fx(crate::particles::ST_MAGIC); self.sfx(Sfx::Magic, px, py); self.ents[j].slow = 2.0; let chain = self @@ -4202,6 +4228,7 @@ impl Game { TowerKind::Rune => { // a rune bolt that jumps on to up to three more enemies nearby self.shot(mx, my, mh, ex, ey, 0.45, 0.25, 0xff40c0ff); + self.style_last_fx(crate::particles::ST_RUNE); self.fx(2, ex, ey, 0.0, 0.0, 0.35, 0xffffc040); self.sfx(Sfx::Magic, px, py); let mut hit = vec![j]; @@ -4255,6 +4282,7 @@ impl Game { let dmg = e.atk() * (0.85 + self.rng.f() * 0.3); if s.range > 1.5 { self.shot(e.x, e.y, 0.45, tx, ty, hit_h, 0.25, 0xff70b0d0); + self.style_last_fx(if e.kind == EK::Archer { crate::particles::ST_ARROW } else { crate::particles::ST_PLAIN }); } else { self.fx_at(2, tx, ty, hit_h, 0.25, 0xffffffff); } diff --git a/src/main.rs b/src/main.rs index 6693c3d..a6e48db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod i18n; mod map; mod menu; mod platform; +mod particles; mod props; mod rival; mod render3d; diff --git a/src/particles.rs b/src/particles.rs new file mode 100644 index 0000000..b4a274d --- /dev/null +++ b/src/particles.rs @@ -0,0 +1,353 @@ +// Particles: trails that fall to the ground behind projectiles, explosions of fireballs and +// cannonballs, bursts of the spells, debris and coins, scorch marks, and arrows that stay stuck +// in walls and the ground for five minutes. Purely visual: never saved, with their own random +// numbers (the game's random sequence stays untouched). + +use crate::game::*; +use crate::map::*; + +pub const STUCK_MAX: usize = 400; // arrows and scorch marks +pub const STUCK_LIFE: f32 = 300.0; // seconds an arrow stays +const PARTS_MAX: usize = 900; +const GRAVITY: f32 = 6.0; + +// particle kinds +pub const EMBER: u8 = 0; // glowing, falls +pub const SMOKE: u8 = 1; // rises and grows +pub const SPARK: u8 = 2; // glowing, falls fast, bounces once +pub const DEBRIS: u8 = 3; // solid chunk, falls and rests a moment +pub const MOTE: u8 = 4; // glowing, floats up slowly +pub const COIN: u8 = 5; // solid gold, bounces +pub const ARROW: u8 = 6; // stuck arrow (vx, vy, vh = its direction) +pub const SCORCH: u8 = 7; // dark mark on the floor +pub const FLASH: u8 = 8; // expanding fireball +pub const RING: u8 = 9; // shock wave + +// projectile styles (Fx::style) +pub const ST_PLAIN: u8 = 0; +pub const ST_ARROW: u8 = 1; +pub const ST_CANNON: u8 = 2; +pub const ST_FIRE: u8 = 3; +pub const ST_SHADOW: u8 = 4; +pub const ST_HOLY: u8 = 5; +pub const ST_ARCANE: u8 = 6; +pub const ST_RUNE: u8 = 7; +pub const ST_MAGIC: u8 = 8; +pub const ST_ARROW_WALL: u8 = 9; // an arrow that ends in a wall + +#[derive(Clone, Copy, Debug)] +pub struct Part { + pub kind: u8, + pub x: f32, + pub y: f32, + pub h: f32, + pub vx: f32, + pub vy: f32, + pub vh: f32, + pub t: f32, + pub max: f32, + pub col: u32, + pub size: f32, +} + +pub fn vrng() -> Rng { + Rng(0x2545_f491) +} + +// colour as 0xAABBGGRR (like Fx::col) +pub const fn abgr(r: u8, g: u8, b: u8) -> u32 { + 0xff00_0000 | (b as u32) << 16 | (g as u32) << 8 | r as u32 +} + +// position of a projectile at progress p (cannonballs and fireballs fly in an arc) +pub fn fx_pos(f: &Fx, p: f32) -> (f32, f32, f32) { + let p = p.clamp(0.0, 1.0); + let x = f.x + (f.x2 - f.x) * p; + let y = f.y + (f.y2 - f.y) * p; + let dist = ((f.x2 - f.x).powi(2) + (f.y2 - f.y).powi(2)).sqrt(); + let arc = match f.style { + ST_CANNON => 0.22, + ST_FIRE => 0.12, + ST_ARROW | ST_ARROW_WALL => 0.04, + _ => 0.0, + } * dist; + let h = f.h + (f.h2 - f.h) * p + arc * (p * std::f32::consts::PI).sin(); + (x, y, h) +} + +impl Game { + fn part(&mut self, kind: u8, (x, y, h): (f32, f32, f32), (vx, vy, vh): (f32, f32, f32), max: f32, col: u32, size: f32) { + if self.parts.len() < PARTS_MAX { + self.parts.push(Part { kind, x, y, h, vx, vy, vh, t: 0.0, max, col, size }); + } + } + + fn stick(&mut self, p: Part) { + if self.stuck.len() >= STUCK_MAX { + self.stuck.remove(0); + } + self.stuck.push(p); + } + + // a random direction with the given speed (upwards bias `up`) + fn burst_vel(&mut self, speed: f32, up: f32) -> (f32, f32, f32) { + let a = self.vrng.f() * std::f32::consts::TAU; + let s = speed * (0.4 + 0.6 * self.vrng.f()); + (a.cos() * s, a.sin() * s, up * (0.5 + self.vrng.f())) + } + + fn burst(&mut self, kind: u8, at: (f32, f32, f32), n: usize, speed: f32, up: f32, max: f32, col: u32, size: f32) { + for _ in 0..n { + let v = self.burst_vel(speed, up); + let m = max * (0.6 + 0.6 * self.vrng.f()); + let sz = size * (0.7 + 0.6 * self.vrng.f()); + self.part(kind, at, v, m, col, sz); + } + } + + // particles that come with a new effect (spells, hits, digging, gold, ...) + pub(crate) fn fx_particles(&mut self, kind: u8, x: f32, y: f32, h: f32, col: u32) { + match kind { + // lightning: a shower of sparks, smoke and a burnt spot + 1 => { + self.burst(SPARK, (x, y, 0.1), 40, 3.5, 3.5, 1.0, abgr(255, 250, 200), 0.05); + self.burst(SMOKE, (x, y, 0.2), 6, 0.4, 0.6, 2.0, abgr(90, 90, 100), 0.25); + self.part(FLASH, (x, y, 0.3), (0.0, 0.0, 0.0), 0.35, abgr(255, 255, 220), 1.0); + self.stick(Part { kind: SCORCH, x, y, h: 0.0, vx: 0.0, vy: 0.0, vh: 0.0, t: 0.0, max: 20.0, col: 0, size: 0.8 }); + } + // hit: a few sparks of the effect's colour + 2 => self.burst(SPARK, (x, y, h.max(0.3)), 5, 1.5, 1.5, 0.5, col, 0.035), + 3 => self.burst(SMOKE, (x, y, 0.3), 4, 0.3, 0.5, 1.6, abgr(80, 80, 90), 0.2), + // healing: green motes rising in a spiral, sparkles falling + 4 => { + for k in 0..12 { + let a = k as f32 * 0.52; + self.part(MOTE, (x + a.cos() * 0.3, y + a.sin() * 0.3, 0.1 + k as f32 * 0.03), (-a.sin() * 0.3, a.cos() * 0.3, 0.9), 1.3, abgr(120, 255, 140), 0.05); + } + self.burst(EMBER, (x, y, 0.9), 6, 0.6, 0.5, 1.0, abgr(200, 255, 200), 0.03); + } + // gold: coins pop up and fall + 5 => self.burst(COIN, (x, y, 0.4), 5, 1.2, 2.5, 1.6, abgr(255, 210, 60), 0.06), + // digging: earth chunks + 6 => self.burst(DEBRIS, (x, y, 0.5), 4, 1.2, 1.5, 1.4, abgr(110, 80, 56), 0.06), + // level up, spells, specials: sparkles in the effect's colour shoot up and rain down + 7 => self.burst(EMBER, (x, y, 0.5), 18, 1.6, 3.0, 1.4, col, 0.04), + // claiming: a puff of dust + 8 => self.burst(SMOKE, (x, y, 0.1), 2, 0.3, 0.2, 0.8, abgr(120, 60, 60), 0.12), + _ => {} + } + } + + // advances projectiles (trails, impacts) and particles + pub(crate) fn update_particles(&mut self, dt: f32) { + // trails and impacts of the projectiles + let mut trails: Vec<(u8, (f32, f32, f32), f32)> = Vec::new(); + let mut impacts: Vec = Vec::new(); + for f in self.fx.iter() { + if f.kind != 0 { + continue; + } + let p0 = f.t / f.max; + let p1 = (f.t + dt) / f.max; + if p0 < 1.0 { + trails.push((f.style, fx_pos(f, p1.min(1.0)), f.size)); + } + if p0 < 1.0 && p1 >= 1.0 { + impacts.push(*f); + } + } + for (style, at, size) in trails { + let (rate, kind, col, psize, life) = match style { + ST_FIRE => (70.0, EMBER, abgr(255, 150, 40), 0.045, 0.9), + ST_SHADOW => (45.0, EMBER, abgr(200, 70, 255), 0.035, 1.0), + ST_HOLY => (35.0, MOTE, abgr(255, 240, 150), 0.03, 0.8), + ST_ARCANE => (40.0, EMBER, abgr(255, 170, 90), 0.03, 0.9), + ST_RUNE => (45.0, SPARK, abgr(90, 200, 255), 0.03, 0.7), + ST_MAGIC => (45.0, EMBER, abgr(255, 90, 220), 0.035, 0.9), + ST_CANNON => (30.0, SMOKE, abgr(70, 70, 76), 0.1, 0.9), + ST_ARROW | ST_ARROW_WALL => (0.0, EMBER, 0, 0.0, 0.0), + _ => (12.0, EMBER, abgr(180, 210, 230), 0.03, 0.6), + }; + let n = rate * dt * size; + let mut count = n.floor() as usize; + if self.vrng.f() < n.fract() { + count += 1; + } + for _ in 0..count { + let v = (self.vrng.f() * 0.6 - 0.3, self.vrng.f() * 0.6 - 0.3, if kind == SMOKE { 0.3 } else { 0.2 }); + let j = (self.vrng.f() * 0.06 - 0.03, self.vrng.f() * 0.06 - 0.03); + let m = life * (0.7 + 0.6 * self.vrng.f()); + self.part(kind, (at.0 + j.0, at.1 + j.1, at.2), v, m, col, psize * size.sqrt()); + } + if style == ST_FIRE && self.vrng.f() < 12.0 * dt { + self.part(SMOKE, at, (0.0, 0.0, 0.4), 1.2, abgr(60, 50, 50), 0.12); + } + } + for f in impacts { + self.impact(&f); + } + // particles + for p in self.parts.iter_mut() { + p.t += dt; + match p.kind { + SMOKE => { + p.x += p.vx * dt; + p.y += p.vy * dt; + p.h += p.vh * dt; + p.vx *= 1.0 - dt; + p.vy *= 1.0 - dt; + p.size += dt * 0.35; + } + MOTE => { + p.x += p.vx * dt; + p.y += p.vy * dt; + p.h += p.vh * dt; + } + FLASH | RING => {} + _ => { + // falling: embers drift down slower than sparks and debris + let g = if p.kind == EMBER { GRAVITY * 0.35 } else { GRAVITY }; + p.vh -= g * dt; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.h += p.vh * dt; + if p.h <= 0.02 { + p.h = 0.02; + if matches!(p.kind, SPARK | COIN) && p.vh < -1.0 { + p.vh = -p.vh * 0.35; + p.vx *= 0.5; + p.vy *= 0.5; + } else { + p.vh = 0.0; + p.vx *= 0.8; + p.vy *= 0.8; + } + } + } + } + } + self.parts.retain(|p| p.t < p.max); + // stuck arrows fall out when their wall is dug away; everything fades in the end + let map = &self.map; + for p in self.stuck.iter_mut() { + p.t += dt; + if p.kind == ARROW && p.h > 0.1 { + let (tx, ty) = ((p.x + p.vx * 0.08) as i32, (p.y + p.vy * 0.08) as i32); + if !map.get(tx, ty).map_or(false, |t| t.solid()) { + p.t = p.max; + } + } + } + self.stuck.retain(|p| p.t < p.max); + } + + fn impact(&mut self, f: &Fx) { + let size = f.size; + let (x, y) = (f.x2, f.y2); + match f.style { + ST_FIRE => { + // the fireball explodes on the ground + let at = (x, y, 0.15); + self.part(FLASH, at, (0.0, 0.0, 0.0), 0.45, abgr(255, 170, 60), 0.9 * size); + self.part(RING, (x, y, 0.05), (0.0, 0.0, 0.0), 0.5, abgr(255, 120, 40), 1.4 * size); + self.burst(EMBER, at, (24.0 * size) as usize, 2.8 * size, 3.0, 1.2, abgr(255, 140, 40), 0.05); + self.burst(SPARK, at, 10, 3.5, 2.5, 0.7, abgr(255, 230, 120), 0.035); + self.burst(SMOKE, (x, y, 0.3), 8, 0.6, 0.7, 2.2, abgr(70, 60, 60), 0.22); + self.stick(Part { kind: SCORCH, x, y, h: 0.0, vx: 0.0, vy: 0.0, vh: 0.0, t: 0.0, max: 25.0, col: 0, size: 0.7 * size }); + } + ST_CANNON => { + // the ball explodes: flash, stones and dirt flying, smoke + let at = (x, y, 0.2); + self.part(FLASH, at, (0.0, 0.0, 0.0), 0.3, abgr(255, 200, 120), 0.7 * size); + self.part(RING, (x, y, 0.05), (0.0, 0.0, 0.0), 0.4, abgr(220, 200, 170), 1.6); + self.burst(DEBRIS, at, 16, 3.0, 3.5, 1.6, abgr(84, 70, 64), 0.07); + self.burst(DEBRIS, at, 6, 2.5, 3.0, 1.4, abgr(120, 92, 70), 0.05); + self.burst(SPARK, at, 12, 4.0, 2.5, 0.6, abgr(255, 200, 100), 0.03); + self.burst(SMOKE, (x, y, 0.3), 10, 0.8, 0.6, 2.4, abgr(80, 80, 86), 0.25); + self.stick(Part { kind: SCORCH, x, y, h: 0.0, vx: 0.0, vy: 0.0, vh: 0.0, t: 0.0, max: 20.0, col: 0, size: 0.6 }); + } + ST_ARROW | ST_ARROW_WALL => { + let (dx, dy) = (f.x2 - f.x, f.y2 - f.y); + let l = (dx * dx + dy * dy).sqrt().max(0.01); + let (dx, dy) = (dx / l, dy / l); + if f.style == ST_ARROW_WALL { + self.burst(DEBRIS, (x, y, f.h2), 3, 0.8, 1.0, 0.8, abgr(110, 90, 80), 0.04); + let h = f.h2 + self.vrng.f() * 0.2; + self.stick(Part { kind: ARROW, x, y, h, vx: dx, vy: dy, vh: -0.08, t: 0.0, max: STUCK_LIFE, col: 0, size: 1.0 }); + } else if self.vrng.f() < 0.35 { + // glanced off: stuck in the floor next to the target + let (ox, oy) = (self.vrng.f() * 0.7 - 0.35, self.vrng.f() * 0.7 - 0.35); + let tilt = -0.6 - self.vrng.f() * 0.6; + let n = (1.0 + tilt * tilt).sqrt(); + self.stick(Part { kind: ARROW, x: x + ox, y: y + oy, h: 0.04, vx: dx / n, vy: dy / n, vh: tilt / n, t: 0.0, max: STUCK_LIFE, col: 0, size: 1.0 }); + } + } + ST_SHADOW => self.burst(EMBER, (x, y, f.h2), 14, 1.6, 1.5, 0.9, abgr(190, 70, 255), 0.04), + ST_HOLY => self.burst(MOTE, (x, y, f.h2), 12, 0.8, 0.8, 0.8, abgr(255, 240, 160), 0.035), + ST_ARCANE => self.burst(EMBER, (x, y, f.h2), 14, 1.8, 1.5, 0.8, abgr(255, 180, 100), 0.035), + ST_RUNE => { + self.burst(SPARK, (x, y, f.h2), 16, 2.5, 2.0, 0.7, abgr(110, 210, 255), 0.035); + self.part(FLASH, (x, y, f.h2), (0.0, 0.0, 0.0), 0.2, abgr(150, 220, 255), 0.4); + } + ST_MAGIC => self.burst(EMBER, (x, y, f.h2), 14, 1.8, 1.5, 0.9, abgr(255, 100, 220), 0.04), + _ => self.burst(SPARK, (x, y, f.h2), 6, 1.5, 1.2, 0.5, f.col, 0.03), + } + } + + // the last projectile gets a style + pub(crate) fn style_last_fx(&mut self, style: u8) { + if let Some(f) = self.fx.last_mut() { + f.style = style; + } + } + + // where an arrow flying from (x, y) along (dx, dy) ends: the first wall within `max` + pub(crate) fn arrow_end(&self, x: f32, y: f32, dx: f32, dy: f32, max: f32) -> (f32, f32, bool) { + let mut d = 0.0; + while d < max { + d += 0.1; + let (px, py) = (x + dx * d, y + dy * d); + if self.map.get(px as i32, py as i32).map_or(true, |t| t.solid()) { + let back = (d - 0.12).max(0.0); + return (x + dx * back, y + dy * back, true); + } + } + (x + dx * max, y + dy * max, false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projectiles_trail_and_explode_and_arrows_stick() { + let mut g = Game::new(3, 64, 64); + let (cx, cy) = g.map.core; + let (x, y) = (cx as f32 + 1.5, cy as f32 - 1.5); + g.shot(x, y, 0.5, x + 3.0, y, 0.45, 0.3, 0xffffffff); + g.style_last_fx(ST_FIRE); + let (ex, ey, wall) = g.arrow_end(x, y, -1.0, 0.0, 20.0); + assert!(wall); + g.shot(x, y, 0.5, ex, ey, 0.45, 0.3, 0xffffffff); + g.style_last_fx(ST_ARROW_WALL); + let mut trail = 0; + for _ in 0..4 { + g.update(0.05); + trail = trail.max(g.parts.len()); + } + assert!(trail > 3, "fire trail: {}", trail); + for _ in 0..6 { + g.update(0.05); + } + assert!(g.parts.iter().any(|p| p.kind == FLASH) || g.stuck.iter().any(|p| p.kind == SCORCH), "explosion"); + assert!(g.stuck.iter().any(|p| p.kind == ARROW), "arrow stuck"); + // particles fall to the ground and vanish + for _ in 0..200 { + g.update(0.05); + } + assert!(g.parts.iter().all(|p| p.h >= 0.0)); + assert!(g.parts.len() < 5); + assert!(g.stuck.iter().any(|p| p.kind == ARROW), "arrows stay for minutes"); + } +} diff --git a/src/render3d.rs b/src/render3d.rs index d905798..9f96dfb 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1026,6 +1026,7 @@ impl R3d { draw_landmarks_solid(g, t); draw_towers_solid(g); crate::props::draw_props_solid(g, cam, t); + draw_parts_solid(g); // ---- unlit glow / overlays gl_use_material(&self.glow); @@ -1062,6 +1063,7 @@ impl R3d { for f in &g.fx { draw_fx(f, t); } + draw_parts_glow(g); gl_use_default_material(); crate::ui::begin_ui(); if let (true, Some((rt, tw, th))) = (div > 1, self.target.as_ref()) { @@ -2070,12 +2072,105 @@ fn draw_flag(f: usize, t: f32) { draw_mesh(&Mesh { vertices: v, indices: vec![0, 1, 2], texture: None }); } +// projectiles with a look of their own (styles in particles.rs) +fn draw_projectile(f: &Fx, p: f32, t: f32) { + use crate::particles::*; + let at = |q: f32| { + let (x, y, h) = fx_pos(f, q); + vec3(x, h, y) + }; + let head = at(p); + let c = rgba(f.col); + let sz = f.size; + match f.style { + ST_ARROW | ST_ARROW_WALL => { + let back = at((p - 0.08).max(0.0)); + let dir = (head - back).normalize_or_zero(); + line3(head - dir * 0.3, head, 0.03, Color::from_rgba(120, 84, 48, 255)); + draw_cube(head, Vec3::splat(0.035), None, Color::from_rgba(200, 200, 210, 255)); + draw_cube(head - dir * 0.28, Vec3::splat(0.05), None, Color::from_rgba(230, 230, 230, 255)); + } + ST_CANNON => { + draw_sphere(head, 0.1 * sz, None, Color::from_rgba(24, 24, 28, 255)); + draw_sphere(head, 0.14 * sz, None, Color::new(1.0, 0.6, 0.2, 0.15)); + } + ST_FIRE => { + let flick = 1.0 + (t * 30.0 + f.x).sin() * 0.12; + draw_sphere(head, 0.08 * sz * flick, None, Color::new(1.0, 0.95, 0.6, 1.0)); + draw_sphere(head, 0.14 * sz * flick, None, Color::new(1.0, 0.55, 0.12, 0.8)); + draw_sphere(head, 0.24 * sz * flick, None, Color::new(1.0, 0.3, 0.05, 0.25)); + } + _ => { + let tail = at((p - 0.3).max(0.0)); + line3(tail, head, 0.06 * sz, Color::new(c.r, c.g, c.b, 0.7)); + draw_sphere(head, 0.08 * sz, None, c); + draw_sphere(head, 0.17 * sz, None, Color::new(c.r, c.g, c.b, 0.25)); + } + } +} + +// particles that glow (embers, sparks, smoke, flashes, shock waves) +fn draw_parts_glow(g: &Game) { + use crate::particles::*; + for p in &g.parts { + let c = rgba(p.col); + let life = (p.t / p.max).clamp(0.0, 1.0); + let at = vec3(p.x, p.h, p.y); + match p.kind { + EMBER | SPARK | MOTE => { + let s = p.size * (1.0 - life * 0.6); + draw_cube(at, Vec3::splat(s), None, Color::new(c.r, c.g, c.b, 1.0 - life * life)); + } + SMOKE => draw_sphere(at, p.size, None, Color::new(c.r, c.g, c.b, 0.35 * (1.0 - life))), + FLASH => { + let r = p.size * (0.3 + life * 0.9); + draw_sphere(at, r * 0.6, None, Color::new(1.0, 0.95, 0.75, 1.0 - life)); + draw_sphere(at, r, None, Color::new(c.r, c.g, c.b, 0.6 * (1.0 - life))); + } + RING => ring(at, p.size * (0.2 + life), Color::new(c.r, c.g, c.b, 0.8 * (1.0 - life))), + _ => {} + } + } + // scorch marks on the floor + for p in g.stuck.iter().filter(|p| p.kind == SCORCH) { + let fade = 1.0 - (p.t / p.max).clamp(0.0, 1.0); + disc(vec3(p.x, 0.015, p.y), p.size, Color::new(0.03, 0.02, 0.02, 0.55 * fade), 14); + } +} + +// solid particles: debris, coins and the arrows stuck in walls and floors +fn draw_parts_solid(g: &Game) { + use crate::particles::*; + for p in &g.parts { + let c = rgba(p.col); + let at = vec3(p.x, p.h, p.y); + match p.kind { + DEBRIS | COIN => { + let life = (p.t / p.max).clamp(0.0, 1.0); + let s = p.size * if life > 0.75 { (1.0 - life) * 4.0 } else { 1.0 }; + draw_cube(at, vec3(s, if p.kind == COIN { s * 0.4 } else { s }, s), None, c); + } + _ => {} + } + } + for p in g.stuck.iter().filter(|p| p.kind == ARROW) { + let left = p.max - p.t; + let k = if left < 10.0 { left / 10.0 } else { 1.0 }; + let tip = vec3(p.x, p.h, p.y); + let dir = vec3(p.vx, p.vh, p.vy).normalize_or_zero(); + let tail = tip - dir * (0.32 * k); + line3(tail, tip + dir * 0.03, 0.03, Color::from_rgba(116, 80, 46, 255)); + draw_cube(tail, vec3(0.05, 0.05, 0.05) * k, None, Color::from_rgba(226, 226, 226, 255)); + } +} + fn draw_fx(f: &Fx, t: f32) { let p = (f.t / f.max).clamp(0.0, 1.0); let c = rgba(f.col); let a = |alpha: f32| Color::new(c.r, c.g, c.b, alpha); let at = vec3(f.x, f.h, f.y); match f.kind { + 0 if f.style != crate::particles::ST_PLAIN => draw_projectile(f, p, t), 0 => { let from = vec3(f.x, f.h, f.y); let to = vec3(f.x2, f.h2, f.y2);