Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cell>`, 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<Cell>`, no per-frame
allocation, and a thin `wasm-bindgen` boundary so the core stays
unit-testable with plain `cargo test`.

## Why Rust → WASM

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ <h1>Sandboxed</h1>
<button data-mat="1" class="active">Sand</button>
<button data-mat="2">Water</button>
<button data-mat="3">Stone</button>
<button data-mat="4">Fire</button>
<button data-mat="0">Erase</button>
<button id="clear">Clear</button>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
91 changes: 91 additions & 0 deletions src/world.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub enum Cell {
Sand = 1,
Water = 2,
Stone = 3,
Fire = 4,
}

impl Cell {
Expand All @@ -20,6 +21,7 @@ impl Cell {
1 => Some(Cell::Sand),
2 => Some(Cell::Water),
3 => Some(Cell::Stone),
4 => Some(Cell::Fire),
_ => None,
}
}
Expand All @@ -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<Cell>,
/// Per-cell remaining lifetime. Only `Fire` uses nonzero values.
ttl: Vec<u8>,
/// Scratch buffer marking cells that already moved this tick.
moved: Vec<bool>,
/// Tiny xorshift PRNG — no external crates needed in WASM.
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
}
}
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
Loading