diff --git a/.gitignore b/.gitignore index 1b5e147..e8511e9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ node_modules AGENTS.md DESIGN.md TASKS.md +docs/ +.agents +skills-lock.json diff --git a/README.md b/README.md index 08dae89..a2dbd23 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ unit-testable with plain `cargo test`. | Sand | Falls and piles; sinks through water | | Water | Flows and spreads | | Stone | Static obstacle | -| Fire | Rises, flickers, burns out | -| Wood | Static; ignites from adjacent fire | -| Erase / Clear | Remove cells | +| Fire | Rises, flickers, burns out; heats neighbors | +| Wood | Static; ignites when heat reaches 80 | +| Steam | Rises; condenses when cool; water boils next to fire | +| Erase / Clear | Remove cells | ## Quickstart diff --git a/index.html b/index.html index 6a71999..d4ee3ef 100644 --- a/index.html +++ b/index.html @@ -101,6 +101,9 @@

Sandboxed

+ diff --git a/src/lib.rs b/src/lib.rs index cf3f29e..9794952 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,7 +49,7 @@ impl Simulation { self.world.height } - /// Paint `material` (0=erase, 1=sand, 2=water, 3=stone, 4=fire, 5=wood) at (x, y). + /// Paint `material` (0=erase, 1=sand, 2=water, 3=stone, 4=fire, 5=wood, 6=steam) at (x, y). pub fn paint(&mut self, x: usize, y: usize, material: u8, radius: usize) { if let Some(cell) = Cell::from_u8(material) { self.world.paint(x, y, cell, radius); diff --git a/src/renderer.rs b/src/renderer.rs index 72b52d1..50d8211 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -19,22 +19,44 @@ fn put(frame: &mut [u8], i: usize, r: i16, g: i16, b: i16) { frame[o + 3] = 255; } +fn warm(r: i16, g: i16, b: i16, heat: u8) -> (i16, i16, i16) { + let t = heat as i16; + (r + t / 2, g, b.saturating_sub(t / 3)) +} + /// Write the whole world into `frame` (must be width*height*4 bytes). pub fn draw(world: &World, frame: &mut [u8]) { for (i, cell) in world.cells().iter().enumerate() { let x = i % world.width; let y = i / world.width; let j = jitter(x, y); + let h = world.heat()[i]; match cell { Cell::Empty => put(frame, i, 12, 12, 18), - Cell::Sand => put(frame, i, 194 + j, 168 + j, 96 + j / 2), - Cell::Water => put(frame, i, 40, 90 + j, 200 + j), - Cell::Stone => put(frame, i, 110 + j / 2, 110 + j / 2, 118), + Cell::Sand => { + let (r, g, b) = warm(194 + j, 168 + j, 96 + j / 2, h); + put(frame, i, r, g, b); + } + Cell::Water => { + let (r, g, b) = warm(40, 90 + j, 200 + j, h); + put(frame, i, r, g, b); + } + Cell::Stone => { + let (r, g, b) = warm(110 + j / 2, 110 + j / 2, 118, h); + put(frame, i, r, g, b); + } Cell::Fire => { let t = world.ttl()[i] as i16; put(frame, i, 255, 32 + t * 4, 16); } - Cell::Wood => put(frame, i, 118 + j / 2, 72 + j / 2, 36), + Cell::Wood => { + let (r, g, b) = warm(118 + j / 2, 72 + j / 2, 36, h); + put(frame, i, r, g, b); + } + Cell::Steam => { + let (r, g, b) = warm(216 + j / 2, 220 + j / 2, 232, h); + put(frame, i, r, g, b); + } } } } @@ -44,6 +66,37 @@ mod tests { use super::*; use crate::world::{Cell, World}; + #[test] + fn steam_pixels_are_pale() { + let mut world = World::new(1, 1); + world.paint(0, 0, Cell::Steam, 0); + let mut frame = vec![0u8; 4]; + draw(&world, &mut frame); + let (r, g, b) = (frame[0], frame[1], frame[2]); + assert!( + r > 180 && g > 180 && b > 180, + "expected pale steam, got {r},{g},{b}" + ); + assert_eq!(frame[3], 255); + } + + #[test] + fn hot_wood_is_warmer_than_cold_wood() { + let mut cold = World::new(1, 1); + cold.paint(0, 0, Cell::Wood, 0); + let mut hot = World::new(1, 1); + hot.paint(0, 0, Cell::Wood, 0); + hot.add_heat_for_test(0, 0, 70); + let mut fc = vec![0u8; 4]; + let mut fh = vec![0u8; 4]; + draw(&cold, &mut fc); + draw(&hot, &mut fh); + assert!( + fh[0] > fc[0] || fh[2] < fc[2], + "hot wood should be redder or less blue: cold={fc:?} hot={fh:?}" + ); + } + #[test] fn fire_pixels_are_orange_red() { let mut world = World::new(2, 1); diff --git a/src/world.rs b/src/world.rs index 6d1670c..437a3d2 100644 --- a/src/world.rs +++ b/src/world.rs @@ -13,6 +13,7 @@ pub enum Cell { Stone = 3, Fire = 4, Wood = 5, + Steam = 6, } impl Cell { @@ -24,6 +25,7 @@ impl Cell { 3 => Some(Cell::Stone), 4 => Some(Cell::Fire), 5 => Some(Cell::Wood), + 6 => Some(Cell::Steam), _ => None, } } @@ -34,7 +36,7 @@ impl Cell { Cell::Empty => 0, Cell::Water => 1, Cell::Sand => 2, - Cell::Fire => 0, + Cell::Fire | Cell::Steam => 0, Cell::Stone | Cell::Wood => 255, // immovable } } @@ -42,6 +44,11 @@ impl Cell { /// Ticks a painted fire cell lives before it burns out. const FIRE_LIFETIME: u8 = 48; +const FIRE_HEAT: u8 = 200; +const WOOD_IGNITE: u8 = 80; +const WATER_BOIL: u8 = 100; +const STEAM_CONDENSE: u8 = 40; +const STEAM_PAINT_HEAT: u8 = 120; const CHUNK: usize = 16; pub struct World { @@ -50,6 +57,8 @@ pub struct World { cells: Vec, /// Per-cell remaining lifetime. Only `Fire` uses nonzero values. ttl: Vec, + /// Per-cell heat for conduction and phase changes. + heat: Vec, /// Scratch buffer marking cells that already moved this tick. moved: Vec, /// Tiny xorshift PRNG — no external crates needed in WASM. @@ -74,6 +83,7 @@ impl World { height, cells: vec![Cell::Empty; width * height], ttl: vec![0; width * height], + heat: vec![0; width * height], moved: vec![false; width * height], rng: 0x853c_49e6_748f_ea9b, frame: 0, @@ -104,6 +114,11 @@ impl World { &self.ttl } + #[inline] + pub fn heat(&self) -> &[u8] { + &self.heat + } + fn next_rand(&mut self) -> u64 { let mut x = self.rng; x ^= x << 13; @@ -138,6 +153,11 @@ impl World { let i = self.idx(px, py); self.cells[i] = cell; self.ttl[i] = if cell == Cell::Fire { FIRE_LIFETIME } else { 0 }; + self.heat[i] = match cell { + Cell::Fire => FIRE_HEAT, + Cell::Steam => STEAM_PAINT_HEAT, + _ => 0, + }; self.wake(px, py, false); } } @@ -148,6 +168,7 @@ impl World { let b = self.idx(x2, y2); self.cells.swap(a, b); self.ttl.swap(a, b); + self.heat.swap(a, b); self.moved[b] = true; self.wake(x1, y1, true); self.wake(x2, y2, true); @@ -256,6 +277,7 @@ impl World { Cell::Sand => self.step_sand(x, y), Cell::Water => self.step_water(x, y), Cell::Fire => self.step_fire(x, y), + Cell::Steam => self.step_steam(x, y), _ => {} } } @@ -270,17 +292,41 @@ impl World { let i = self.idx(x, y); self.cells[i] = Cell::Fire; self.ttl[i] = FIRE_LIFETIME; + self.heat[i] = FIRE_HEAT; self.moved[i] = true; self.wake(x, y, true); } - fn has_adjacent_wood(&self, x: usize, y: usize) -> bool { - self.for_each_cardinal(x, y, |cell| cell == Cell::Wood) + #[inline] + fn add_heat(&mut self, x: usize, y: usize, amount: u8) { + if x >= self.width || y >= self.height { + return; + } + let i = self.idx(x, y); + if self.cells[i] == Cell::Empty { + return; + } + self.heat[i] = self.heat[i].saturating_add(amount); + self.wake(x, y, true); + match self.cells[i] { + Cell::Wood if self.heat[i] >= WOOD_IGNITE => self.ignite(x, y), + Cell::Water if self.heat[i] >= WATER_BOIL => { + self.cells[i] = Cell::Steam; + self.ttl[i] = 0; + } + _ => {} + } } - fn try_ignite_neighbors(&mut self, x: usize, y: usize) { + #[inline] + fn conduct(&mut self, x: usize, y: usize) { const DIRS: [(isize, isize); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; + let i = self.idx(x, y); for (dx, dy) in DIRS { + let src = self.heat[i]; + if src == 0 { + return; + } let nx = x as isize + dx; let ny = y as isize + dy; if nx < 0 || ny < 0 { @@ -290,12 +336,24 @@ impl World { if nx >= self.width || ny >= self.height { continue; } - if self.get(nx, ny) == Cell::Wood && self.next_rand().is_multiple_of(10) { - self.ignite(nx, ny); + if self.get(nx, ny) == Cell::Empty { + continue; } + let ni = self.idx(nx, ny); + let dst = self.heat[ni]; + if src <= dst { + continue; + } + let give = ((src - dst) / 4).max(1); + self.heat[i] = self.heat[i].saturating_sub(give); + self.add_heat(nx, ny, give); } } + fn has_adjacent_wood(&self, x: usize, y: usize) -> bool { + self.for_each_cardinal(x, y, |cell| cell == Cell::Wood) + } + fn for_each_cardinal(&self, x: usize, y: usize, mut pred: impl FnMut(Cell) -> bool) -> bool { const DIRS: [(isize, isize); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; for (dx, dy) in DIRS { @@ -315,17 +373,45 @@ impl World { false } + fn first_cardinal(&self, x: usize, y: usize, want: Cell) -> Option<(usize, usize)> { + const DIRS: [(isize, isize); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; + for (dx, dy) in DIRS { + let nx = x as isize + dx; + let ny = y as isize + dy; + if nx < 0 || ny < 0 { + continue; + } + let (nx, ny) = (nx as usize, ny as usize); + if nx >= self.width || ny >= self.height { + continue; + } + if self.get(nx, ny) == want { + return Some((nx, ny)); + } + } + None + } + fn extinguish(&mut self, x: usize, y: usize) { let i = self.idx(x, y); self.cells[i] = Cell::Empty; self.ttl[i] = 0; + self.heat[i] = 0; self.wake(x, y, true); } fn step_fire(&mut self, x: usize, y: usize) { self.wake(x, y, true); - self.try_ignite_neighbors(x, y); + if let Some((wx, wy)) = self.first_cardinal(x, y, Cell::Water) { + let wi = self.idx(wx, wy); + let bump = WATER_BOIL.saturating_sub(self.heat[wi]); + self.add_heat(wx, wy, bump); + self.extinguish(x, y); + return; + } let i = self.idx(x, y); + self.heat[i] = FIRE_HEAT; + self.conduct(x, y); let life = self.ttl[i]; if life <= 1 { self.extinguish(x, y); @@ -362,6 +448,42 @@ impl World { } } + fn step_steam(&mut self, x: usize, y: usize) { + self.wake(x, y, true); + let i = self.idx(x, y); + self.heat[i] = self.heat[i].saturating_sub(1); + self.conduct(x, y); + let i = self.idx(x, y); + if self.cells[i] != Cell::Steam { + return; + } + if self.heat[i] < STEAM_CONDENSE { + self.cells[i] = Cell::Water; + self.ttl[i] = 0; + self.wake(x, y, true); + return; + } + if y == 0 { + return; + } + let above = y - 1; + if self.try_move(x, y, x, above) { + return; + } + let left_first = self.next_rand() & 1 == 0; + let diagonals: [(isize, usize); 2] = [(-1, above), (1, above)]; + for k in 0..2 { + let (dx, ny) = diagonals[if left_first { k } else { 1 - k }]; + let nx = x as isize + dx; + if nx < 0 { + continue; + } + if self.try_move(x, y, nx as usize, ny) { + return; + } + } + } + fn try_move(&mut self, x: usize, y: usize, nx: usize, ny: usize) -> bool { let mover = self.get(x, y); if nx >= self.width || ny >= self.height { @@ -399,7 +521,30 @@ impl World { } } + #[cfg(test)] + pub(crate) fn add_heat_for_test(&mut self, x: usize, y: usize, amount: u8) { + self.add_heat(x, y, amount); + } + + #[cfg(test)] + pub(crate) fn swap_for_test(&mut self, x1: usize, y1: usize, x2: usize, y2: usize) { + self.swap(x1, y1, x2, y2); + } + fn step_water(&mut self, x: usize, y: usize) { + let i = self.idx(x, y); + if self.heat[i] > 0 { + self.conduct(x, y); + if self.get(x, y) != Cell::Water { + return; + } + } + if let Some((fx, fy)) = self.first_cardinal(x, y, Cell::Fire) { + let bump = WATER_BOIL.saturating_sub(self.heat[i]); + self.add_heat(x, y, bump); + self.extinguish(fx, fy); + return; + } let below = y + 1; if below < self.height && self.try_move(x, y, x, below) { return; @@ -631,6 +776,22 @@ mod tests { assert_eq!(w.get(4, 2), Cell::Wood); } + #[test] + fn wood_ignites_when_heat_reaches_threshold() { + let mut w = World::new(8, 8); + w.paint(4, 4, Cell::Wood, 0); + w.paint(3, 4, Cell::Fire, 0); + for _ in 0..8 { + w.step(); + } + assert_ne!( + w.get(4, 4), + Cell::Wood, + "wood did not ignite from conducted heat" + ); + assert_eq!(count(&w, Cell::Wood), 0); + } + #[test] fn wood_block_burns_within_n_ticks() { let mut w = World::new(8, 8); @@ -658,6 +819,110 @@ mod tests { assert_eq!(w.get(32, 63), Cell::Sand); } + #[test] + fn heating_wood_stays_active_across_chunks() { + let mut w = World::new(64, 64); + w.paint(40, 40, Cell::Wood, 0); + w.paint(39, 40, Cell::Fire, 0); + w.step(); + let hi = 40 * 64 + 40; + assert!( + w.heat()[hi] > 0 || w.get(40, 40) == Cell::Fire, + "wood in a sparse world did not receive heat (chunk slept)" + ); + for _ in 0..8 { + w.step(); + } + assert_ne!(w.get(40, 40), Cell::Wood); + } + + #[test] + fn water_at_boil_becomes_steam() { + let mut w = World::new(4, 4); + w.paint(1, 1, Cell::Water, 0); + w.add_heat_for_test(1, 1, WATER_BOIL); + assert_eq!(w.get(1, 1), Cell::Steam); + assert!(w.heat()[1 * 4 + 1] >= WATER_BOIL); + } + + #[test] + fn fire_touching_water_extinguishes_and_boils() { + let mut w = World::new(8, 8); + w.paint(4, 4, Cell::Water, 0); + w.paint(3, 4, Cell::Fire, 0); + w.step(); + assert_eq!(count(&w, Cell::Fire), 0, "fire survived contact with water"); + assert_eq!(count(&w, Cell::Steam), 1); + let steam_i = w + .cells() + .iter() + .position(|&c| c == Cell::Steam) + .expect("steam"); + assert!(w.heat()[steam_i] >= WATER_BOIL); + } + + #[test] + fn painted_steam_has_heat_120() { + let mut w = World::new(4, 4); + w.paint(1, 1, Cell::Steam, 0); + assert_eq!(w.get(1, 1), Cell::Steam); + assert_eq!(w.heat()[1 * 4 + 1], 120); + } + + #[test] + fn empty_cells_have_zero_heat() { + let w = World::new(2, 2); + assert!(w.heat().iter().all(|&h| h == 0)); + assert!(w.ttl().iter().all(|&t| t == 0)); + } + + #[test] + fn swap_moves_heat_with_the_cell() { + let mut w = World::new(4, 4); + w.paint(0, 0, Cell::Steam, 0); + assert_eq!(w.heat()[0], 120); + w.swap_for_test(0, 0, 1, 0); + assert_eq!(w.get(1, 0), Cell::Steam); + assert_eq!(w.heat()[1], 120); + assert_eq!(w.get(0, 0), Cell::Empty); + assert_eq!(w.heat()[0], 0); + } + + #[test] + fn from_u8_maps_steam() { + assert_eq!(Cell::from_u8(6), Some(Cell::Steam)); + assert_eq!(Cell::from_u8(7), None); + } + + #[test] + fn steam_rises() { + let mut w = World::new(8, 8); + w.paint(4, 5, Cell::Steam, 0); + w.step(); + assert_eq!(w.get(4, 4), Cell::Steam); + assert_eq!(w.get(4, 5), Cell::Empty); + assert_eq!(w.heat()[4 * 8 + 4], 119); + assert_eq!(w.heat()[4 * 8 + 5], 0); + } + + #[test] + fn steam_condenses_when_heat_drops() { + let mut w = World::new(4, 4); + w.paint(1, 1, Cell::Steam, 0); + // 120 -> 39 takes 81 cools; isolated steam only loses 1/tick. + for _ in 0..81 { + w.step(); + } + assert_eq!(count(&w, Cell::Steam), 0); + assert_eq!(count(&w, Cell::Water), 1); + let wi = w + .cells() + .iter() + .position(|&c| c == Cell::Water) + .expect("water"); + assert!(w.heat()[wi] < STEAM_CONDENSE); + } + #[test] #[ignore] fn bench_960x600_step_rate() {