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() {
|