From 25e38b6275119c0813e14822caa64b2d978fd682 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 16:19:59 -0400 Subject: [PATCH] feat: add fire that rises, flickers, and burns out Fire is a gas with a per-cell TTL. It rises into empty cells, flicker-despawns near the end of its life, and ignites nothing yet. --- README.md | 9 ++--- index.html | 1 + src/lib.rs | 2 +- src/renderer.rs | 21 ++++++++++++ src/world.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9df288c..679fc85 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ WebAssembly, and rendered in the browser. **[Live demo](https://chowjiaming.github.io/sandboxed/)** -Paint sand, water, and stone onto a 320×200 grid. The simulation -runs in WASM: a flat `Vec`, no per-frame allocation, and a -thin `wasm-bindgen` boundary so the core stays unit-testable with -plain `cargo test`. +Paint sand, water, stone, and fire onto a 320×200 grid. The +simulation runs in WASM: a flat `Vec`, no per-frame +allocation, and a thin `wasm-bindgen` boundary so the core stays +unit-testable with plain `cargo test`. ## Why Rust → WASM @@ -27,6 +27,7 @@ plain `cargo test`. | Sand | Falls and piles; sinks through water | | Water | Flows and spreads | | Stone | Static obstacle | +| Fire | Rises, flickers, burns out | | Erase / Clear | Remove cells | ## Quickstart diff --git a/index.html b/index.html index 5fa0d64..b7261df 100644 --- a/index.html +++ b/index.html @@ -69,6 +69,7 @@

Sandboxed

+ diff --git a/src/lib.rs b/src/lib.rs index efa0d50..a0214d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,7 +44,7 @@ impl Simulation { self.world.height } - /// Paint `material` (0=erase, 1=sand, 2=water, 3=stone) at (x, y). + /// Paint `material` (0=erase, 1=sand, 2=water, 3=stone, 4=fire) 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 c804514..9b0936b 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -30,6 +30,27 @@ pub fn draw(world: &World, frame: &mut [u8]) { 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::Fire => { + let t = world.ttl()[i] as i16; + put(frame, i, 255, 32 + t * 4, 16); + } } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::world::{Cell, World}; + + #[test] + fn fire_pixels_are_orange_red() { + let mut world = World::new(2, 1); + world.paint(0, 0, Cell::Fire, 0); + let mut frame = vec![0u8; 8]; + draw(&world, &mut frame); + let (r, g, b) = (frame[0], frame[1], frame[2]); + assert!(r > g && g > b, "expected orange/red, got {r},{g},{b}"); + assert_eq!(frame[3], 255); + } +} diff --git a/src/world.rs b/src/world.rs index 25b3bcb..1e5ddef 100644 --- a/src/world.rs +++ b/src/world.rs @@ -11,6 +11,7 @@ pub enum Cell { Sand = 1, Water = 2, Stone = 3, + Fire = 4, } impl Cell { @@ -20,6 +21,7 @@ impl Cell { 1 => Some(Cell::Sand), 2 => Some(Cell::Water), 3 => Some(Cell::Stone), + 4 => Some(Cell::Fire), _ => None, } } @@ -30,15 +32,21 @@ impl Cell { Cell::Empty => 0, Cell::Water => 1, Cell::Sand => 2, + Cell::Fire => 0, Cell::Stone => 255, // immovable } } } +/// Ticks a painted fire cell lives before it burns out. +const FIRE_LIFETIME: u8 = 48; + pub struct World { pub width: usize, pub height: usize, cells: Vec, + /// Per-cell remaining lifetime. Only `Fire` uses nonzero values. + ttl: Vec, /// Scratch buffer marking cells that already moved this tick. moved: Vec, /// Tiny xorshift PRNG — no external crates needed in WASM. @@ -53,6 +61,7 @@ impl World { width, height, cells: vec![Cell::Empty; width * height], + ttl: vec![0; width * height], moved: vec![false; width * height], rng: 0x853c_49e6_748f_ea9b, frame: 0, @@ -74,6 +83,11 @@ impl World { &self.cells } + #[inline] + pub fn ttl(&self) -> &[u8] { + &self.ttl + } + fn next_rand(&mut self) -> u64 { let mut x = self.rng; x ^= x << 13; @@ -107,6 +121,7 @@ impl World { } let i = self.idx(px, py); self.cells[i] = cell; + self.ttl[i] = if cell == Cell::Fire { FIRE_LIFETIME } else { 0 }; } } } @@ -115,6 +130,7 @@ impl World { let a = self.idx(x1, y1); let b = self.idx(x2, y2); self.cells.swap(a, b); + self.ttl.swap(a, b); self.moved[b] = true; } @@ -148,12 +164,53 @@ impl World { match self.cells[i] { Cell::Sand => self.step_sand(x, y), Cell::Water => self.step_water(x, y), + Cell::Fire => self.step_fire(x, y), _ => {} } } } } + fn extinguish(&mut self, x: usize, y: usize) { + let i = self.idx(x, y); + self.cells[i] = Cell::Empty; + self.ttl[i] = 0; + } + + fn step_fire(&mut self, x: usize, y: usize) { + let i = self.idx(x, y); + let life = self.ttl[i]; + if life <= 1 { + self.extinguish(x, y); + return; + } + // Flicker: extra chance to die near the end of life. + if life <= 4 && self.next_rand() & 7 == 0 { + self.extinguish(x, y); + return; + } + self.ttl[i] = life - 1; + 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 { @@ -378,4 +435,38 @@ mod tests { "left={left} right={right} drifted after 1000 ticks" ); } + + #[test] + fn fire_does_not_fall() { + let mut w = World::new(8, 8); + w.paint(4, 3, Cell::Fire, 0); + for _ in 0..32 { + w.step(); + for y in 4..8 { + for x in 0..8 { + assert_ne!(w.get(x, y), Cell::Fire, "fire fell to ({x},{y})"); + } + } + } + } + + #[test] + fn fire_eventually_disappears() { + let mut w = World::new(8, 8); + w.paint(4, 3, Cell::Fire, 0); + assert_eq!(w.get(4, 3), Cell::Fire); + for _ in 0..256 { + w.step(); + } + assert_eq!(count(&w, Cell::Fire), 0); + } + + #[test] + fn fire_rises() { + let mut w = World::new(8, 8); + w.paint(4, 5, Cell::Fire, 0); + w.step(); + assert_eq!(w.get(4, 4), Cell::Fire); + assert_eq!(w.get(4, 5), Cell::Empty); + } }