From c5378d07746ceaab4ec5d6ea1fd8445443336fdf Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Thu, 5 Nov 2026 16:10:58 +0100 Subject: [PATCH] Imps set up torch stands in big rooms on their own ground Co-Authored-By: Claude Opus 5.5 --- src/game.rs | 99 ++++++++++++++++++++++++++++++++++++++++++++++-- src/particles.rs | 8 ++-- src/render3d.rs | 27 ++++++++++--- 3 files changed, 122 insertions(+), 12 deletions(-) diff --git a/src/game.rs b/src/game.rs index 3894962..64c936c 100644 --- a/src/game.rs +++ b/src/game.rs @@ -662,20 +662,43 @@ pub const STEP: f32 = 1.0 / 50.0; // 50 steps a second // one frame never runs away into the future (a lagging tab catches up at most this much) const MAX_STEPS_PER_FRAME: usize = 12; -// torch on a wall face: wall tile + direction (index into DIR4) towards the open side +// torch on a wall face: wall tile + direction (index into DIR4) towards the open side; +// or a torch on a little stand in the middle of a floor tile (stand: wall = that tile) #[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Torch { pub wall: usize, pub dir: usize, + #[serde(default)] + pub stand: bool, } +// rooms where imps may set up a torch stand (no beds, hoards, mushrooms or crates in the way) +pub fn stand_ground(k: Kind) -> bool { + matches!(k, Kind::Floor | Kind::Training | Kind::Library | Kind::Torture | Kind::Temple | Kind::Prison | Kind::Graveyard | Kind::GuardPost | Kind::Arena | Kind::Altar) +} + +// a torch stand goes where no torch shines closer than this (tiles) +pub const STAND_GAP: f32 = 4.5; + impl Torch { // world position of the flame (x, z) on the ground plane pub fn pos(&self) -> (f32, f32) { let (x, y) = xy(self.wall); + if self.stand { + return (x as f32 + 0.5, y as f32 + 0.5); + } let (dx, dy) = DIR4[self.dir]; (x as f32 + 0.5 + dx as f32 * 0.58, y as f32 + 0.5 + dy as f32 * 0.58) } + + // height of the bottom of the flame + pub fn flame_h(&self) -> f32 { + if self.stand { + 0.62 + } else { + 0.72 + } + } } pub const N_ROOMS: usize = 14; @@ -890,7 +913,7 @@ impl Game { pub fn place_all_torches(&mut self) { // torches on walls that are gone (rivers, fortresses) are removed let map = &self.map; - self.torches.retain(|t| map.t[t.wall].solid()); + self.torches.retain(|t| if t.stand { stand_ground(map.t[t.wall].kind) } else { map.t[t.wall].solid() }); for i in 0..n() { if self.map.t[i].owner == self.me { self.place_torches(i); @@ -998,7 +1021,7 @@ impl Game { continue; } // the direction from the wall to the floor is the opposite one - let t = Torch { wall: idx(wx, wy), dir: (d + 2) % 4 }; + let t = Torch { wall: idx(wx, wy), dir: (d + 2) % 4, stand: false }; let (px, pz) = t.pos(); let near = self.torches.iter().any(|o| { let (ox, oz) = o.pos(); @@ -1008,6 +1031,26 @@ impl Game { self.torches.push(t); } } + // a big room the wall torches do not light: a torch on a stand in the middle of it, + // on the master's own ground only (never neutral or foreign ground) and not next to a wall + let tile = self.map.t[i]; + let open_around = (-1..=1).all(|dy| (-1..=1).all(|dx| self.map.get(x + dx, y + dy).map_or(false, |t| !t.solid()))); + if tile.owner == self.me && stand_ground(tile.kind) && open_around { + let (px, pz) = (x as f32 + 0.5, y as f32 + 0.5); + let near = self.torches.iter().any(|o| { + let (ox, oz) = o.pos(); + (ox - px).powi(2) + (oz - pz).powi(2) < STAND_GAP * STAND_GAP + }); + if !near { + self.torches.push(Torch { wall: i, dir: 0, stand: true }); + } + } + } + + // torch stands go when their ground is lost or becomes a room they do not fit in + fn tidy_torch_stands(&mut self) { + let map = &self.map; + self.torches.retain(|t| !t.stand || (stand_ground(map.t[t.wall].kind) && !matches!(map.t[t.wall].owner, Owner::None | Owner::Hero))); } pub(crate) fn hint(&mut self, slot: usize, key: &'static str) { @@ -1563,6 +1606,7 @@ impl Game { // every two seconds: corners between reinforced walls are closed if (self.time / 2.0).floor() != ((self.time - dt) / 2.0).floor() { self.reinforce_corners(); + self.tidy_torch_stands(); } self.update_forge(dt); self.update_research(); @@ -5940,6 +5984,55 @@ mod tests { assert!(best <= 2.0); } + #[test] + fn imps_set_up_torch_stands_in_big_rooms_on_their_own_ground() { + let mut g = Game::new(5, 96, 96); + let (cx, cy) = g.map.core; + // a big hall of own ground east of the core, and as big a neutral cave south of it + let hall = (cx + 8, cy - 7, cx + 22, cy + 7); + let cave = (cx - 7, cy + 12, cx + 7, cy + 26); + for (x0, y0, x1, y1, own) in [(hall.0, hall.1, hall.2, hall.3, true), (cave.0, cave.1, cave.2, cave.3, false)] { + for y in y0..=y1 { + for x in x0..=x1 { + let t = &mut g.map.t[idx(x, y)]; + t.kind = Kind::Floor; + t.owner = if own { g.me } else { Owner::None }; + } + } + } + g.torches.clear(); + g.place_all_torches(); + let inside = |t: &Torch, r: (i32, i32, i32, i32)| { + let (x, y) = xy(t.wall); + x >= r.0 && x <= r.2 && y >= r.1 && y <= r.3 + }; + let stands: Vec = g.torches.iter().copied().filter(|t| t.stand).collect(); + assert!(stands.iter().any(|t| inside(t, hall)), "the big hall gets torch stands"); + assert!(!stands.iter().any(|t| inside(t, cave)), "never on neutral ground"); + for t in &stands { + assert_eq!(g.map.t[t.wall].owner, g.me, "only on own ground"); + let (x, y) = xy(t.wall); + assert!((-1..=1).all(|dy| (-1..=1).all(|dx| !g.map.t[idx(x + dx, y + dy)].solid())), "not next to a wall"); + } + // every tile of the hall's middle is within reach of some torch + for y in hall.1 + 2..=hall.3 - 2 { + for x in hall.0 + 2..=hall.2 - 2 { + let lit = g.torches.iter().any(|t| { + let (px, pz) = t.pos(); + (px - x as f32 - 0.5).powi(2) + (pz - y as f32 - 0.5).powi(2) <= (STAND_GAP + 1.0).powi(2) + }); + assert!(lit, "({}, {}) lies in the dark", x, y); + } + } + // ground lost: the stand goes + let s0 = stands.iter().find(|t| inside(t, hall)).unwrap().wall; + g.map.t[s0].owner = Owner::Hero; + for _ in 0..60 { + g.update(0.05); + } + assert!(!g.torches.iter().any(|t| t.stand && t.wall == s0), "a stand on lost ground is removed"); + } + #[test] fn flyers_take_every_way_across_rivers() { let (mut g, rx) = river_game(false); diff --git a/src/particles.rs b/src/particles.rs index 0b8eff4..ecf4bd2 100644 --- a/src/particles.rs +++ b/src/particles.rs @@ -398,19 +398,19 @@ impl Game { // a thin wisp of smoke above every known torch, now and then a spark fn spawn_torch_smoke(&mut self, dt: f32) { - let spots: Vec<(f32, f32)> = self.torches.iter().filter(|t| self.map.shown(t.wall)).map(|t| t.pos()).collect(); - for (x, y) in spots { + let spots: Vec<(f32, f32, f32)> = self.torches.iter().filter(|t| self.map.shown(t.wall)).map(|t| (t.pos().0, t.pos().1, t.flame_h())).collect(); + for (x, y, fh) in spots { if self.parts.len() >= PARTS_MAX - 100 { break; } if self.vrng.f() < 1.1 * dt { let v = ((self.vrng.f() - 0.5) * 0.08, (self.vrng.f() - 0.5) * 0.08, 0.32 + self.vrng.f() * 0.12); let life = 1.8 + self.vrng.f() * 0.6; - self.part(SMOKE, (x, y, 1.0), v, life, abgr(70, 62, 58), 0.05); + self.part(SMOKE, (x, y, fh + 0.28), v, life, abgr(70, 62, 58), 0.05); } if self.vrng.f() < 0.25 * dt { let v = ((self.vrng.f() - 0.5) * 0.3, (self.vrng.f() - 0.5) * 0.3, 0.9); - self.part(EMBER, (x, y, 0.95), v, 0.9, abgr(255, 170, 60), 0.025); + self.part(EMBER, (x, y, fh + 0.23), v, 0.9, abgr(255, 170, 60), 0.025); } } } diff --git a/src/render3d.rs b/src/render3d.rs index adf2af5..92537f7 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1106,7 +1106,7 @@ impl R3d { for (k, tr) in g.torches.iter().enumerate() { let (x, z) = tr.pos(); let fl = 0.85 + 0.1 * (t * 9.0 + k as f32 * 1.7).sin() + 0.05 * (t * 23.0 + k as f32).sin(); - push(vec3(x, 0.85, z), 5.5, vec3(1.0, 0.62, 0.28), 1.5 * fl, 0.0); + push(vec3(x, tr.flame_h() + 0.13, z), 5.5, vec3(1.0, 0.62, 0.28), 1.5 * fl, 0.0); } let (cx, cy) = g.map.core; let hp = (g.core_hp / CORE_HP).clamp(0.0, 1.0); @@ -1304,7 +1304,11 @@ impl R3d { gl_use_default_material(); for tr in g.torches.iter().filter(|tr| g.map.shown(tr.wall)) { let (x, z) = tr.pos(); - draw_cube(vec3(x, 0.55, z), vec3(0.07, 0.3, 0.07), None, Color::from_rgba(70, 45, 28, 255)); + if tr.stand { + draw_torch_stand(x, z); + } else { + draw_cube(vec3(x, 0.55, z), vec3(0.07, 0.3, 0.07), None, Color::from_rgba(70, 45, 28, 255)); + } } draw_landmarks_solid(g, t); draw_towers_solid(g); @@ -1328,7 +1332,7 @@ impl R3d { } } for (k, tr) in g.torches.iter().enumerate().filter(|(_, tr)| g.map.shown(tr.wall)) { - draw_torch_flame(tr.pos(), k as f32, t); + draw_torch_flame(tr.pos(), tr.flame_h(), k as f32, t); } draw_shadows(g); if let Some(f) = g.flag { @@ -2676,10 +2680,23 @@ fn draw_traps_glow(g: &Game, t: f32) { } } +// a torch on a little iron stand: three splayed legs, a ring and the fire bowl on top +fn draw_torch_stand(x: f32, z: f32) { + let iron = Color::from_rgba(52, 46, 44, 255); + let top = vec3(x, 0.5, z); + for k in 0..3 { + let a = k as f32 * std::f32::consts::TAU / 3.0 + 0.4; + let foot = vec3(x + a.cos() * 0.2, 0.0, z + a.sin() * 0.2); + line3(foot, top, 0.03, iron); + } + draw_cube(vec3(x, 0.26, z), vec3(0.16, 0.025, 0.16), None, iron); + draw_cube(vec3(x, 0.55, z), vec3(0.17, 0.09, 0.17), None, Color::from_rgba(70, 58, 50, 255)); + draw_cube(vec3(x, 0.6, z), vec3(0.12, 0.02, 0.12), None, Color::from_rgba(255, 140, 40, 255)); +} + // a torch flame: stacked glowing blocks, hot and white at the bottom, red at the tip, that // sway and flicker; a soft halo around it (the smoke comes from particles.rs) -fn draw_torch_flame((x, z): (f32, f32), k: f32, t: f32) { - let base = 0.72; +fn draw_torch_flame((x, z): (f32, f32), base: f32, k: f32, t: f32) { let flick = 0.8 + 0.2 * (t * 13.0 + k * 2.1).sin() + 0.1 * (t * 29.0 + k * 0.7).sin(); draw_sphere(vec3(x, base + 0.1, z), 0.24 * flick, None, Color::new(1.0, 0.55, 0.15, 0.16)); let n = 6;