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 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 14:50:24 +01:00
1 parent 5adf3d0e00
commit f0eb33a6b1
3 files changed
+135 -17

No files matched your search

+1 -1
View File
@@ -4888,7 +4888,7 @@ impl Game {
let dir = norm(ex - px, ey - py); let dir = norm(ex - px, ey - py);
let (endx, endy, wall) = self.arrow_end(px, py, dir.0, dir.1, t.range()); 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.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); self.sfx(Sfx::Shoot, px, py);
let mut hits = vec![(j, dmg)]; let mut hits = vec![(j, dmg)];
for (o, e) in self.ents.iter().enumerate() { for (o, e) in self.ents.iter().enumerate() {
+116 -15
View File
@@ -24,6 +24,7 @@ pub const SCORCH: u8 = 7; // dark mark on the floor
pub const FLASH: u8 = 8; // expanding fireball pub const FLASH: u8 = 8; // expanding fireball
pub const RING: u8 = 9; // shock wave pub const RING: u8 = 9; // shock wave
pub const SPLASH: u8 = 10; // liquid droplets, bounce on the river surface 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) // projectile styles (Fx::style)
pub const ST_PLAIN: u8 = 0; 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_RUNE: u8 = 7;
pub const ST_MAGIC: u8 = 8; pub const ST_MAGIC: u8 = 8;
pub const ST_ARROW_WALL: u8 = 9; // an arrow that ends in a wall 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)] #[derive(Clone, Copy, Debug)]
pub struct Part { pub struct Part {
@@ -70,7 +72,7 @@ pub fn fx_pos(f: &Fx, p: f32) -> (f32, f32, f32) {
let arc = match f.style { let arc = match f.style {
ST_CANNON => 0.22, ST_CANNON => 0.22,
ST_FIRE => 0.12, ST_FIRE => 0.12,
ST_ARROW | ST_ARROW_WALL => 0.04, ST_ARROW | ST_ARROW_WALL | ST_ARROW_FALL => 0.04,
_ => 0.0, _ => 0.0,
} * dist; } * dist;
let h = f.h + (f.h2 - f.h) * p + arc * (p * std::f32::consts::PI).sin(); 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_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_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_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), _ => (12.0, EMBER, abgr(180, 210, 230), 0.03, 0.6),
}; };
let n = rate * dt * size; let n = rate * dt * size;
@@ -207,6 +209,11 @@ impl Game {
p.h += p.vh * dt; p.h += p.vh * dt;
} }
FLASH | RING => {} FLASH | RING => {}
BURNING_ARROW => {
// drifts slowly with the lava
p.x += p.vx * 0.05 * dt;
p.y += p.vy * 0.05 * dt;
}
SPLASH => { SPLASH => {
p.vh -= GRAVITY * 0.75 * dt; p.vh -= GRAVITY * 0.75 * dt;
p.x += p.vx * 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); 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 map = &self.map;
let mut sunk: Vec<(Kind, f32, f32, f32, f32)> = Vec::new();
for p in self.stuck.iter_mut() { for p in self.stuck.iter_mut() {
p.t += dt; p.t += dt;
if p.kind == ARROW && p.h > 0.1 { 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()) { if !map.get(tx, ty).map_or(false, |t| t.solid()) {
p.t = p.max; 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); 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) { 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.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 }); 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 => { ST_ARROW | ST_ARROW_WALL | ST_ARROW_FALL => {
if let Some(kind) = self.liquid_kind_at(x, y) {
self.liquid_impact(kind, x, y, size);
return;
}
let (dx, dy) = (f.x2 - f.x, f.y2 - f.y); let (dx, dy) = (f.x2 - f.x, f.y2 - f.y);
let l = (dx * dx + dy * dy).sqrt().max(0.01); let l = (dx * dx + dy * dy).sqrt().max(0.01);
let (dx, dy) = (dx / l, dy / l); let (dx, dy) = (dx / l, dy / l);
if f.style == ST_ARROW_WALL { 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); 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; 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 }); 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 { return;
// 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 });
} }
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_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_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<Kind> { fn liquid_kind_at(&self, x: f32, y: f32) -> Option<Kind> {
self.map.get(x as i32, y as i32).filter(|t| t.liquid()).map(|t| t.kind) 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); let at = (x, y, LIQ_H + 0.04);
match kind { match kind {
Kind::Lava => { 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(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.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)); 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.parts.len() < 5);
assert!(g.stuck.iter().any(|p| p.kind == ARROW), "arrows stay for minutes"); 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));
}
} }
+18 -1
View File
@@ -2257,7 +2257,7 @@ fn draw_projectile(f: &Fx, p: f32, t: f32) {
let c = rgba(f.col); let c = rgba(f.col);
let sz = f.size; let sz = f.size;
match f.style { 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 back = at((p - 0.08).max(0.0));
let dir = (head - back).normalize_or_zero(); let dir = (head - back).normalize_or_zero();
line3(head - dir * 0.3, head, 0.03, Color::from_rgba(120, 84, 48, 255)); 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) { for p in g.stuck.iter().filter(|p| p.kind == ARROW) {
let left = p.max - p.t; let left = p.max - p.t;
let k = if left < 10.0 { left / 10.0 } else { 1.0 }; let k = if left < 10.0 { left / 10.0 } else { 1.0 };