From f0eb33a6b10e2e843af7ca36f8fed3a05d3ab8b2 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Thu, 5 Nov 2026 14:50:24 +0100 Subject: [PATCH] Rework arrows falling into water and lava Hits no longer splash; glancing and spent arrows splash where they land, drift burning on lava, and arrows on a removed bridge sink. Co-Authored-By: Claude Opus 5.5 --- src/game.rs | 2 +- src/particles.rs | 131 +++++++++++++++++++++++++++++++++++++++++------ src/render3d.rs | 19 ++++++- 3 files changed, 135 insertions(+), 17 deletions(-) diff --git a/src/game.rs b/src/game.rs index 32393a3..76f5f08 100644 --- a/src/game.rs +++ b/src/game.rs @@ -4888,7 +4888,7 @@ impl Game { let dir = norm(ex - px, ey - py); 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.style_last_fx(if wall { crate::particles::ST_ARROW_WALL } else { crate::particles::ST_ARROW_FALL }); self.sfx(Sfx::Shoot, px, py); let mut hits = vec![(j, dmg)]; for (o, e) in self.ents.iter().enumerate() { diff --git a/src/particles.rs b/src/particles.rs index e78dc84..fc522fa 100644 --- a/src/particles.rs +++ b/src/particles.rs @@ -24,6 +24,7 @@ pub const SCORCH: u8 = 7; // dark mark on the floor pub const FLASH: u8 = 8; // expanding fireball pub const RING: u8 = 9; // shock wave pub const SPLASH: u8 = 10; // liquid droplets, bounce on the river surface +pub const BURNING_ARROW: u8 = 11; // arrow drifting on lava until it burns up (vx, vy = its direction) // projectile styles (Fx::style) pub const ST_PLAIN: u8 = 0; @@ -36,6 +37,7 @@ 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 +pub const ST_ARROW_FALL: u8 = 10; // an arrow at the end of its range: it falls a little further #[derive(Clone, Copy, Debug)] pub struct Part { @@ -70,7 +72,7 @@ pub fn fx_pos(f: &Fx, p: f32) -> (f32, f32, f32) { let arc = match f.style { ST_CANNON => 0.22, ST_FIRE => 0.12, - ST_ARROW | ST_ARROW_WALL => 0.04, + ST_ARROW | ST_ARROW_WALL | ST_ARROW_FALL => 0.04, _ => 0.0, } * dist; let h = f.h + (f.h2 - f.h) * p + arc * (p * std::f32::consts::PI).sin(); @@ -167,7 +169,7 @@ impl Game { 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), + ST_ARROW | ST_ARROW_WALL | ST_ARROW_FALL => (0.0, EMBER, 0, 0.0, 0.0), _ => (12.0, EMBER, abgr(180, 210, 230), 0.03, 0.6), }; let n = rate * dt * size; @@ -207,6 +209,11 @@ impl Game { p.h += p.vh * dt; } FLASH | RING => {} + BURNING_ARROW => { + // drifts slowly with the lava + p.x += p.vx * 0.05 * dt; + p.y += p.vy * 0.05 * dt; + } SPLASH => { p.vh -= GRAVITY * 0.75 * dt; p.x += p.vx * dt; @@ -247,9 +254,25 @@ impl Game { } } } + // burning arrows on lava: embers and smoke while they burn, a puff when they are gone + let burning: Vec<(f32, f32, bool)> = self.parts.iter().filter(|p| p.kind == BURNING_ARROW).map(|p| (p.x, p.y, p.t >= p.max)).collect(); + for (x, y, gone) in burning { + let at = (x, y, LIQ_H + 0.06); + if gone { + self.burst(EMBER, at, 5, 0.5, 1.2, 0.7, abgr(255, 150, 50), 0.03); + self.part(SMOKE, at, (0.0, 0.0, 0.5), 0.8, abgr(70, 45, 35), 0.08); + } else if self.vrng.f() < 9.0 * dt { + self.burst(EMBER, at, 1, 0.2, 0.9, 0.6, abgr(255, 140, 40), 0.025); + if self.vrng.f() < 0.35 { + self.part(SMOKE, at, (0.0, 0.0, 0.45), 0.7, abgr(90, 60, 45), 0.06); + } + } + } self.parts.retain(|p| p.t < p.max); - // stuck arrows fall out when their wall is dug away; everything fades in the end + // stuck arrows fall out when their wall is dug away; arrows and scorch marks on the floor + // sink when the ground under them turns into river (a bridge torn down); everything fades in the end let map = &self.map; + let mut sunk: Vec<(Kind, f32, f32, f32, f32)> = Vec::new(); for p in self.stuck.iter_mut() { p.t += dt; if p.kind == ARROW && p.h > 0.1 { @@ -257,9 +280,17 @@ impl Game { if !map.get(tx, ty).map_or(false, |t| t.solid()) { p.t = p.max; } + } else if let Some(t) = map.get(p.x as i32, p.y as i32).filter(|t| t.liquid()) { + if p.kind == ARROW { + sunk.push((t.kind, p.x, p.y, p.vx, p.vy)); + } + p.t = p.max; } } self.stuck.retain(|p| p.t < p.max); + for (kind, x, y, dx, dy) in sunk { + self.liquid_impact(kind, x, y, 0.5, (dx, dy)); + } } fn impact(&mut self, f: &Fx) { @@ -287,25 +318,39 @@ impl Game { self.burst(SMOKE, (x, y, 0.45), 10, 0.8, 0.6, 2.4, abgr(80, 80, 86), 0.23); 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 => { - if let Some(kind) = self.liquid_kind_at(x, y) { - self.liquid_impact(kind, x, y, size); - return; - } + ST_ARROW | ST_ARROW_WALL | ST_ARROW_FALL => { 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 { + // ends in a wall (also above a river) 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 }); + return; } + let spot = if f.style == ST_ARROW_FALL { + // spent at the end of its range: it sails on a little and comes down there + // (a wall in the way stops it in front of the wall) + let reach = 0.35 + f.h2.max(0.0) * 1.5; + let (fx, fy) = (x + dx * reach, y + dy * reach); + if self.map.get(fx as i32, fy as i32).map_or(true, |t| t.solid()) { (x, y) } else { (fx, fy) } + } else if self.vrng.f() < 0.35 { + // a hit that glanced off: it comes down next to the target + (x + self.vrng.f() * 0.7 - 0.35, y + self.vrng.f() * 0.7 - 0.35) + } else { + return; // a hit: the arrow stays in the target + }; + if let Some(kind) = self.liquid_kind_at(spot.0, spot.1) { + self.liquid_impact(kind, spot.0, spot.1, size, (dx, dy)); + return; + } + if self.map.get(spot.0 as i32, spot.1 as i32).map_or(true, |t| t.solid()) { + return; + } + let tilt = -0.6 - self.vrng.f() * 0.6; + let n = (1.0 + tilt * tilt).sqrt(); + self.stick(Part { kind: ARROW, x: spot.0, y: spot.1, 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), @@ -319,14 +364,18 @@ impl Game { } } + // open river under (x, y): bridges and their decks catch the arrow fn liquid_kind_at(&self, x: f32, y: f32) -> Option { self.map.get(x as i32, y as i32).filter(|t| t.liquid()).map(|t| t.kind) } - fn liquid_impact(&mut self, kind: Kind, x: f32, y: f32, size: f32) { + // an arrow comes down into a river: a splash; on lava it drifts on, burning, for a moment + fn liquid_impact(&mut self, kind: Kind, x: f32, y: f32, size: f32, (dx, dy): (f32, f32)) { let at = (x, y, LIQ_H + 0.04); match kind { Kind::Lava => { + let life = 1.0 + self.vrng.f() * 1.0; + self.part(BURNING_ARROW, (x, y, LIQ_H + 0.02), (dx, dy, 0.0), life, abgr(255, 120, 30), 1.0); self.burst(SPLASH, at, 8.max((10.0 * size) as usize), 1.3, 1.9, 0.38, abgr(255, 110, 25), 0.045); self.burst(MOTE, at, 4, 0.7, 1.2, 0.7, abgr(255, 150, 45), 0.035); self.part(FLASH, at, (0.0, 0.0, 0.0), 0.12, abgr(255, 155, 45), 0.18 * size.max(0.7)); @@ -440,4 +489,56 @@ mod tests { assert!(g.parts.len() < 5); assert!(g.stuck.iter().any(|p| p.kind == ARROW), "arrows stay for minutes"); } + + // a little lava and water east of the core + fn river_spot(g: &mut Game, kind: Kind) -> (f32, f32) { + let (cx, cy) = g.map.core; + let (x, y) = (cx + 3, cy - 3); + let t = &mut g.map.t[idx(x, y)]; + t.kind = kind; + t.owner = Owner::None; + (x as f32 + 0.5, y as f32 + 0.5) + } + + #[test] + fn spent_arrows_drift_burning_on_lava_and_hits_do_not_splash() { + let mut g = Game::new(3, 64, 64); + let (lx, ly) = river_spot(&mut g, Kind::Lava); + // a spent tower arrow comes down in the lava: it drifts there, burning, then is gone + g.shot(lx - 2.0, ly, 0.5, lx - 0.4, ly, 0.0, 0.3, 0xffffffff); + g.style_last_fx(ST_ARROW_FALL); + for _ in 0..8 { + g.update(0.05); + } + assert!(g.parts.iter().any(|p| p.kind == BURNING_ARROW), "burning on the lava"); + assert!(!g.stuck.iter().any(|p| p.kind == ARROW), "nothing stuck in the lava"); + for _ in 0..60 { + g.update(0.05); + } + assert!(!g.parts.iter().any(|p| p.kind == BURNING_ARROW), "burnt up"); + // archer arrows that hit something above the lava mostly stay in the target: never a stuck arrow there + for _ in 0..20 { + g.shot(lx - 3.0, ly, 0.5, lx, ly, 0.8, 0.3, 0xffffffff); + g.style_last_fx(ST_ARROW); + } + for _ in 0..8 { + g.update(0.05); + } + let burning = g.parts.iter().filter(|p| p.kind == BURNING_ARROW).count(); + assert!(burning < 20, "not every hit falls into the lava: {}", burning); + assert!(!g.stuck.iter().any(|p| g.map.t[idx(p.x as i32, p.y as i32)].liquid())); + } + + #[test] + fn arrows_on_a_bridge_sink_when_it_is_gone() { + let mut g = Game::new(3, 64, 64); + let (wx, wy) = river_spot(&mut g, Kind::Bridge); + g.stick(Part { kind: ARROW, x: wx, y: wy, h: 0.04, vx: 1.0, vy: 0.0, vh: -0.5, t: 0.0, max: STUCK_LIFE, col: 0, size: 1.0 }); + g.update(0.05); + assert_eq!(g.stuck.len(), 1, "it stays on the deck"); + g.map.t[idx(wx as i32, wy as i32)].kind = Kind::Water; + g.update(0.05); + assert!(g.stuck.is_empty(), "sunk with the bridge"); + assert!(g.parts.iter().any(|p| p.kind == SPLASH)); + } } diff --git a/src/render3d.rs b/src/render3d.rs index a2573a9..ff85847 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -2257,7 +2257,7 @@ fn draw_projectile(f: &Fx, p: f32, t: f32) { let c = rgba(f.col); let sz = f.size; match f.style { - ST_ARROW | ST_ARROW_WALL => { + ST_ARROW | ST_ARROW_WALL | ST_ARROW_FALL => { 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)); @@ -2504,6 +2504,23 @@ fn draw_parts_solid(g: &Game) { _ => {} } } + // arrows drifting on lava: the shaft glows and chars, then crumbles + for p in g.parts.iter().filter(|p| p.kind == BURNING_ARROW) { + let life = (p.t / p.max).clamp(0.0, 1.0); + let k = 1.0 - life * life; + let bob = (p.t * 3.0 + p.x * 2.0).sin() * 0.01; + let mid = vec3(p.x, -LIQ_D + 0.025 + bob, p.y); + let dir = vec3(p.vx, 0.0, p.vy).normalize_or_zero(); + let half = dir * (0.17 * k); + let wood = vec3(0.45, 0.31, 0.18); + let glow = vec3(1.0, 0.45, 0.1); + let char_ = vec3(0.08, 0.05, 0.04); + let c = if life < 0.5 { wood.lerp(glow, life * 2.0) } else { glow.lerp(char_, (life - 0.5) * 2.0) }; + line3(mid - half, mid + half, 0.03, Color::new(c.x, c.y, c.z, 1.0)); + if life < 0.6 { + draw_cube(mid + half, Vec3::splat(0.035), None, Color::from_rgba(200, 200, 210, 255)); + } + } 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 };