diff --git a/README.md b/README.md
index a2dbd23..74c1398 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,8 @@ unit-testable with plain `cargo test`.
| Fire | Rises, flickers, burns out; heats neighbors |
| Wood | Static; ignites when heat reaches 80 |
| Steam | Rises; condenses when cool; water boils next to fire |
+| Fan | Static; click the swatch to cycle facing; blows gases in that 4×4 |
+| Gunpowder | Falls like sand; explodes from fire or heat 80 |
| Erase / Clear | Remove cells |
## Quickstart
diff --git a/index.html b/index.html
index d4ee3ef..7d4b522 100644
--- a/index.html
+++ b/index.html
@@ -104,6 +104,12 @@
Sandboxed
+
+
@@ -143,15 +149,29 @@ Sandboxed
let paused = false;
let pendingStep = false;
+ const FAN_MATS = [7, 8, 9, 10];
+ const FAN_LABELS = ["Fan →", "Fan ←", "Fan ↑", "Fan ↓"];
+ const fanBtn = document.getElementById("fan");
+
+ const setActive = (btn) => {
+ document
+ .querySelectorAll("#palette button")
+ .forEach((b) => b.classList.remove("active"));
+ btn.classList.add("active");
+ };
+
document
.querySelectorAll("#palette button[data-mat]")
.forEach((btn) => {
btn.addEventListener("click", () => {
+ if (btn === fanBtn && Number(fanBtn.dataset.mat) === material) {
+ const i = FAN_MATS.indexOf(material);
+ const n = (i + 1) % 4;
+ fanBtn.dataset.mat = String(FAN_MATS[n]);
+ fanBtn.innerHTML = `${FAN_LABELS[n]}`;
+ }
material = Number(btn.dataset.mat);
- document
- .querySelectorAll("#palette button")
- .forEach((b) => b.classList.remove("active"));
- btn.classList.add("active");
+ setActive(btn);
});
});
diff --git a/src/lib.rs b/src/lib.rs
index 9794952..aef0e2c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -49,7 +49,8 @@ impl Simulation {
self.world.height
}
- /// Paint `material` (0=erase, 1=sand, 2=water, 3=stone, 4=fire, 5=wood, 6=steam) at (x, y).
+ /// Paint `material` (0=erase, 1=sand, 2=water, 3=stone, 4=fire, 5=wood,
+ /// 6=steam, 7=fan→, 8=fan←, 9=fan↑, 10=fan↓, 11=gunpowder) 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 50d8211..a5d50f3 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -1,6 +1,6 @@
//! Converts the world grid into an RGBA frame buffer for the browser.
-use crate::world::{Cell, World};
+use crate::world::{Cell, World, AIR_CELL};
/// Deterministic per-pixel color jitter so large piles look textured
/// instead of flat. Uses a cheap integer hash of (x, y).
@@ -32,7 +32,12 @@ pub fn draw(world: &World, frame: &mut [u8]) {
let j = jitter(x, y);
let h = world.heat()[i];
match cell {
- Cell::Empty => put(frame, i, 12, 12, 18),
+ Cell::Empty => {
+ let ai = (y / AIR_CELL) * world.air_w + (x / AIR_CELL);
+ let avx = world.vx()[ai];
+ let avy = world.vy()[ai];
+ put(frame, i, 12 + avx / 2, 12 - avx / 2, 18 + avy / 2);
+ }
Cell::Sand => {
let (r, g, b) = warm(194 + j, 168 + j, 96 + j / 2, h);
put(frame, i, r, g, b);
@@ -57,6 +62,14 @@ pub fn draw(world: &World, frame: &mut [u8]) {
let (r, g, b) = warm(216 + j / 2, 220 + j / 2, 232, h);
put(frame, i, r, g, b);
}
+ Cell::FanRight => put(frame, i, 110, 120, 150),
+ Cell::FanLeft => put(frame, i, 70, 100, 140),
+ Cell::FanUp => put(frame, i, 90, 130, 160),
+ Cell::FanDown => put(frame, i, 90, 100, 120),
+ Cell::Gunpowder => {
+ let (r, g, b) = warm(40 + j / 2, 36 + j / 2, 32, h);
+ put(frame, i, r, g, b);
+ }
}
}
}
@@ -107,4 +120,38 @@ mod tests {
assert!(r > g && g > b, "expected orange/red, got {r},{g},{b}");
assert_eq!(frame[3], 255);
}
+
+ #[test]
+ fn empty_pixel_tints_when_fan_blows() {
+ let still = World::new(8, 8);
+ let mut blown = World::new(8, 8);
+ blown.paint(0, 0, Cell::FanRight, 0);
+ blown.step();
+ let mut fs = vec![0u8; 8 * 8 * 4];
+ let mut fb = vec![0u8; 8 * 8 * 4];
+ draw(&still, &mut fs);
+ draw(&blown, &mut fb);
+ // Pixel (3, 0) is Empty in both; in blown it shares the fan's air cell.
+ let o = (0 * 8 + 3) * 4;
+ let (sr, sg, sb) = (fs[o], fs[o + 1], fs[o + 2]);
+ let (br, bg, bb) = (fb[o], fb[o + 1], fb[o + 2]);
+ assert!(
+ br > sr && bg < sg,
+ "FanRight should redden (+vx) and green-shift down (−vx): still=({sr},{sg},{sb}) blown=({br},{bg},{bb})"
+ );
+ assert_eq!(fb[o + 3], 255);
+ }
+
+ #[test]
+ fn gunpowder_pixels_are_dark() {
+ let mut world = World::new(1, 1);
+ world.paint(0, 0, Cell::Gunpowder, 0);
+ let mut frame = vec![0u8; 4];
+ draw(&world, &mut frame);
+ let (r, g, b) = (frame[0], frame[1], frame[2]);
+ assert!(
+ r < 80 && g < 80 && b < 80,
+ "expected charcoal, got {r},{g},{b}"
+ );
+ }
}
diff --git a/src/world.rs b/src/world.rs
index 437a3d2..60e001b 100644
--- a/src/world.rs
+++ b/src/world.rs
@@ -14,6 +14,11 @@ pub enum Cell {
Fire = 4,
Wood = 5,
Steam = 6,
+ FanRight = 7,
+ FanLeft = 8,
+ FanUp = 9,
+ FanDown = 10,
+ Gunpowder = 11,
}
impl Cell {
@@ -26,6 +31,11 @@ impl Cell {
4 => Some(Cell::Fire),
5 => Some(Cell::Wood),
6 => Some(Cell::Steam),
+ 7 => Some(Cell::FanRight),
+ 8 => Some(Cell::FanLeft),
+ 9 => Some(Cell::FanUp),
+ 10 => Some(Cell::FanDown),
+ 11 => Some(Cell::Gunpowder),
_ => None,
}
}
@@ -35,11 +45,29 @@ impl Cell {
match self {
Cell::Empty => 0,
Cell::Water => 1,
- Cell::Sand => 2,
+ Cell::Gunpowder | Cell::Sand => 2,
Cell::Fire | Cell::Steam => 0,
- Cell::Stone | Cell::Wood => 255, // immovable
+ Cell::Stone
+ | Cell::Wood
+ | Cell::FanRight
+ | Cell::FanLeft
+ | Cell::FanUp
+ | Cell::FanDown => 255,
}
}
+
+ #[inline]
+ fn is_fan(self) -> bool {
+ matches!(
+ self,
+ Cell::FanRight | Cell::FanLeft | Cell::FanUp | Cell::FanDown
+ )
+ }
+
+ #[inline]
+ fn is_locked(self) -> bool {
+ matches!(self, Cell::Stone | Cell::Wood) || self.is_fan()
+ }
}
/// Ticks a painted fire cell lives before it burns out.
@@ -50,10 +78,19 @@ const WATER_BOIL: u8 = 100;
const STEAM_CONDENSE: u8 = 40;
const STEAM_PAINT_HEAT: u8 = 120;
const CHUNK: usize = 16;
+pub(crate) const AIR_CELL: usize = 4;
+const FAN_SPEED: i16 = 40;
+const GAS_PUSH: i16 = 24;
+const WATER_PUSH: i16 = 64;
+const BOIL_PV: i16 = 80;
+const GUN_PV: i16 = 400;
+const GUN_IGNITE: u8 = 80;
pub struct World {
pub width: usize,
pub height: usize,
+ pub air_w: usize,
+ pub air_h: usize,
cells: Vec,
/// Per-cell remaining lifetime. Only `Fire` uses nonzero values.
ttl: Vec,
@@ -71,6 +108,12 @@ pub struct World {
active: Vec,
/// Chunks woken by motion this tick; becomes `active` after the step.
next_active: Vec,
+ pv: Vec,
+ vx: Vec,
+ vy: Vec,
+ pv_prev: Vec,
+ vx_prev: Vec,
+ vy_prev: Vec,
}
impl World {
@@ -78,9 +121,14 @@ impl World {
let chunks_x = width.div_ceil(CHUNK);
let chunks_y = height.div_ceil(CHUNK);
let nchunks = chunks_x * chunks_y;
+ let air_w = width.div_ceil(AIR_CELL);
+ let air_h = height.div_ceil(AIR_CELL);
+ let nair = air_w * air_h;
Self {
width,
height,
+ air_w,
+ air_h,
cells: vec![Cell::Empty; width * height],
ttl: vec![0; width * height],
heat: vec![0; width * height],
@@ -91,6 +139,12 @@ impl World {
chunks_y,
active: vec![false; nchunks],
next_active: vec![false; nchunks],
+ pv: vec![0; nair],
+ vx: vec![0; nair],
+ vy: vec![0; nair],
+ pv_prev: vec![0; nair],
+ vx_prev: vec![0; nair],
+ vy_prev: vec![0; nair],
}
}
@@ -119,6 +173,133 @@ impl World {
&self.heat
}
+ #[inline]
+ fn air_idx(&self, ax: usize, ay: usize) -> usize {
+ ay * self.air_w + ax
+ }
+
+ #[inline]
+ fn air_idx_of(&self, x: usize, y: usize) -> usize {
+ self.air_idx(x / AIR_CELL, y / AIR_CELL)
+ }
+
+ #[inline]
+ #[cfg(test)]
+ pub fn pv(&self) -> &[i16] {
+ &self.pv
+ }
+
+ #[inline]
+ pub fn vx(&self) -> &[i16] {
+ &self.vx
+ }
+
+ #[inline]
+ pub fn vy(&self) -> &[i16] {
+ &self.vy
+ }
+
+ #[cfg(test)]
+ pub(crate) fn air_idx_of_for_test(&self, x: usize, y: usize) -> usize {
+ self.air_idx_of(x, y)
+ }
+
+ #[inline]
+ fn clamp_i16(v: i32) -> i16 {
+ v.clamp(i16::MIN as i32, i16::MAX as i32) as i16
+ }
+
+ #[inline]
+ fn damp_i16(v: i16, num: i32, den: i32) -> i16 {
+ ((v as i32) * num / den) as i16
+ }
+
+ fn air_sample(buf: &[i16], air_w: usize, air_h: usize, ax: isize, ay: isize) -> i16 {
+ if ax < 0 || ay < 0 {
+ return 0;
+ }
+ let (ax, ay) = (ax as usize, ay as usize);
+ if ax >= air_w || ay >= air_h {
+ return 0;
+ }
+ buf[ay * air_w + ax]
+ }
+
+ fn inject_fans(&mut self) {
+ for y in 0..self.height {
+ for x in 0..self.width {
+ match self.get(x, y) {
+ Cell::FanRight => {
+ let i = self.air_idx_of(x, y);
+ self.vx[i] = FAN_SPEED;
+ self.vy[i] = 0;
+ }
+ Cell::FanLeft => {
+ let i = self.air_idx_of(x, y);
+ self.vx[i] = -FAN_SPEED;
+ self.vy[i] = 0;
+ }
+ Cell::FanUp => {
+ let i = self.air_idx_of(x, y);
+ self.vx[i] = 0;
+ self.vy[i] = -FAN_SPEED;
+ }
+ Cell::FanDown => {
+ let i = self.air_idx_of(x, y);
+ self.vx[i] = 0;
+ self.vy[i] = FAN_SPEED;
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+
+ fn step_air(&mut self) {
+ self.inject_fans();
+ let aw = self.air_w;
+ let ah = self.air_h;
+ for ay in 0..ah {
+ for ax in 0..aw {
+ let i = self.air_idx(ax, ay);
+ let ax = ax as isize;
+ let ay = ay as isize;
+ let pv_l = Self::air_sample(&self.pv, aw, ah, ax - 1, ay) as i32;
+ let pv_r = Self::air_sample(&self.pv, aw, ah, ax + 1, ay) as i32;
+ let pv_u = Self::air_sample(&self.pv, aw, ah, ax, ay - 1) as i32;
+ let pv_d = Self::air_sample(&self.pv, aw, ah, ax, ay + 1) as i32;
+ let nvx = Self::clamp_i16(self.vx[i] as i32 + (pv_l - pv_r) / 4);
+ let nvy = Self::clamp_i16(self.vy[i] as i32 + (pv_u - pv_d) / 4);
+ self.vx_prev[i] = Self::damp_i16(nvx, 7, 8);
+ self.vy_prev[i] = Self::damp_i16(nvy, 7, 8);
+ }
+ }
+ std::mem::swap(&mut self.vx, &mut self.vx_prev);
+ std::mem::swap(&mut self.vy, &mut self.vy_prev);
+ for ay in 0..ah {
+ for ax in 0..aw {
+ let i = self.air_idx(ax, ay);
+ let ax = ax as isize;
+ let ay = ay as isize;
+ let vx_l = Self::air_sample(&self.vx, aw, ah, ax - 1, ay) as i32;
+ let vx_r = Self::air_sample(&self.vx, aw, ah, ax + 1, ay) as i32;
+ let vy_u = Self::air_sample(&self.vy, aw, ah, ax, ay - 1) as i32;
+ let vy_d = Self::air_sample(&self.vy, aw, ah, ax, ay + 1) as i32;
+ let npv = Self::clamp_i16(self.pv[i] as i32 + (vx_l - vx_r + vy_u - vy_d) / 4);
+ self.pv_prev[i] = Self::damp_i16(npv, 15, 16);
+ }
+ }
+ std::mem::swap(&mut self.pv, &mut self.pv_prev);
+ for ay in 0..ah {
+ for ax in 0..aw {
+ let i = self.air_idx(ax, ay);
+ if self.vx[i].abs() >= GAS_PUSH || self.vy[i].abs() >= GAS_PUSH {
+ self.wake(ax * AIR_CELL, ay * AIR_CELL, true);
+ }
+ }
+ }
+ }
+
fn next_rand(&mut self) -> u64 {
let mut x = self.rng;
x ^= x << 13;
@@ -145,9 +326,9 @@ impl World {
if px >= self.width || py >= self.height {
continue;
}
- // Don't overwrite stone or wood with loose material (eraser can).
+ // Don't overwrite locked cells with loose material (eraser can).
let existing = self.get(px, py);
- if matches!(existing, Cell::Stone | Cell::Wood) && cell != Cell::Empty {
+ if existing.is_locked() && cell != Cell::Empty {
continue;
}
let i = self.idx(px, py);
@@ -237,8 +418,14 @@ impl World {
/// Advance the simulation by one tick.
pub fn step(&mut self) {
self.frame += 1;
+ self.step_air();
+ // Air wakes land in next_active; the particle scan this tick uses active.
+ for i in 0..self.active.len() {
+ if self.next_active[i] {
+ self.active[i] = true;
+ }
+ }
self.clear_moved_in_active_chunks();
- self.next_active.fill(false);
let left_to_right = self.frame.is_multiple_of(2);
for cy in (0..self.chunks_y).rev() {
@@ -275,6 +462,7 @@ impl World {
}
match self.cells[i] {
Cell::Sand => self.step_sand(x, y),
+ Cell::Gunpowder => self.step_gunpowder(x, y),
Cell::Water => self.step_water(x, y),
Cell::Fire => self.step_fire(x, y),
Cell::Steam => self.step_steam(x, y),
@@ -297,6 +485,12 @@ impl World {
self.wake(x, y, true);
}
+ fn add_pv(&mut self, x: usize, y: usize, amount: i16) {
+ let i = self.air_idx_of(x, y);
+ self.pv[i] = self.pv[i].saturating_add(amount);
+ self.wake(x, y, true);
+ }
+
#[inline]
fn add_heat(&mut self, x: usize, y: usize, amount: u8) {
if x >= self.width || y >= self.height {
@@ -313,6 +507,7 @@ impl World {
Cell::Water if self.heat[i] >= WATER_BOIL => {
self.cells[i] = Cell::Steam;
self.ttl[i] = 0;
+ self.add_pv(x, y, BOIL_PV);
}
_ => {}
}
@@ -427,6 +622,9 @@ impl World {
if self.has_adjacent_wood(x, y) {
return;
}
+ if self.try_air_push(x, y, GAS_PUSH) {
+ return;
+ }
if y == 0 {
return;
}
@@ -463,6 +661,9 @@ impl World {
self.wake(x, y, true);
return;
}
+ if self.try_air_push(x, y, GAS_PUSH) {
+ return;
+ }
if y == 0 {
return;
}
@@ -484,6 +685,29 @@ impl World {
}
}
+ fn try_air_push(&mut self, x: usize, y: usize, threshold: i16) -> bool {
+ let i = self.air_idx_of(x, y);
+ let vx = self.vx[i];
+ let vy = self.vy[i];
+ let (dx, dy) = if vx.abs() >= vy.abs() {
+ if vx.abs() < threshold {
+ return false;
+ }
+ (vx.signum() as isize, 0)
+ } else {
+ if vy.abs() < threshold {
+ return false;
+ }
+ (0, vy.signum() as isize)
+ };
+ let nx = x as isize + dx;
+ let ny = y as isize + dy;
+ if nx < 0 || ny < 0 {
+ return false;
+ }
+ self.try_move(x, y, nx as usize, ny as usize)
+ }
+
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 {
@@ -521,6 +745,37 @@ impl World {
}
}
+ fn explode_gunpowder(&mut self, x: usize, y: usize) {
+ for dy in -1isize..=1 {
+ for dx in -1isize..=1 {
+ 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;
+ }
+ let cell = self.get(nx, ny);
+ if cell == Cell::Stone || cell.is_fan() {
+ continue;
+ }
+ self.ignite(nx, ny);
+ }
+ }
+ self.add_pv(x, y, GUN_PV);
+ }
+
+ fn step_gunpowder(&mut self, x: usize, y: usize) {
+ let i = self.idx(x, y);
+ if self.heat[i] >= GUN_IGNITE || self.first_cardinal(x, y, Cell::Fire).is_some() {
+ self.explode_gunpowder(x, y);
+ return;
+ }
+ self.step_sand(x, y);
+ }
+
#[cfg(test)]
pub(crate) fn add_heat_for_test(&mut self, x: usize, y: usize, amount: u8) {
self.add_heat(x, y, amount);
@@ -563,6 +818,7 @@ impl World {
return;
}
}
+ self.try_air_push(x, y, WATER_PUSH);
}
}
@@ -845,6 +1101,135 @@ mod tests {
assert!(w.heat()[1 * 4 + 1] >= WATER_BOIL);
}
+ #[test]
+ fn boil_adds_pressure() {
+ let mut w = World::new(4, 4);
+ w.paint(1, 1, Cell::Water, 0);
+ let i = w.air_idx_of_for_test(1, 1);
+ let before = w.pv()[i];
+ w.add_heat_for_test(1, 1, WATER_BOIL);
+ assert_eq!(w.get(1, 1), Cell::Steam);
+ assert!(
+ w.pv()[i] >= before + BOIL_PV,
+ "boil should add pv: before={before} after={}",
+ w.pv()[i]
+ );
+ }
+
+ #[test]
+ fn gunpowder_explodes_from_cardinal_fire() {
+ let mut w = World::new(8, 8);
+ w.paint(3, 3, Cell::Gunpowder, 0);
+ w.paint(3, 2, Cell::Fire, 0);
+ w.paint(2, 2, Cell::Stone, 0);
+ w.paint(2, 3, Cell::FanRight, 0);
+ w.step();
+ assert_eq!(w.get(2, 2), Cell::Stone, "stone in the 3×3 must remain");
+ assert_eq!(w.get(2, 3), Cell::FanRight, "fan in the 3×3 must remain");
+ assert_eq!(w.get(3, 3), Cell::Fire);
+ assert_eq!(w.get(4, 3), Cell::Fire);
+ assert_eq!(w.get(3, 4), Cell::Fire);
+ let i = w.air_idx_of_for_test(3, 3);
+ assert!(
+ w.pv()[i] >= GUN_PV,
+ "explode is after this tick's Euler, pv should still be >= GUN_PV, got {}",
+ w.pv()[i]
+ );
+ }
+
+ #[test]
+ fn gunpowder_explodes_from_heat_without_fire() {
+ let mut w = World::new(8, 8);
+ w.paint(3, 3, Cell::Gunpowder, 0);
+ w.add_heat_for_test(3, 3, GUN_IGNITE);
+ w.step();
+ assert_eq!(w.get(3, 3), Cell::Fire);
+ assert_eq!(
+ count(&w, Cell::Fire),
+ 9,
+ "3×3 empty neighborhood becomes fire"
+ );
+ }
+
+ #[test]
+ fn fan_velocity_stays_bounded_over_long_run() {
+ let mut w = World::new(32, 32);
+ w.paint(0, 0, Cell::FanRight, 0);
+ for _ in 0..600 {
+ w.step();
+ }
+ let peak = w
+ .vx()
+ .iter()
+ .chain(w.vy().iter())
+ .map(|v| v.abs())
+ .max()
+ .unwrap();
+ assert!(
+ peak <= FAN_SPEED,
+ "air |v| ran away: peak={peak} FAN_SPEED={FAN_SPEED}"
+ );
+ }
+
+ #[test]
+ fn gunpowder_pressure_impulse_decays() {
+ let mut w = World::new(32, 32);
+ w.paint(8, 8, Cell::Gunpowder, 0);
+ w.paint(8, 7, Cell::Fire, 0);
+ w.step(); // explodes this tick; pv >= GUN_PV
+ for _ in 0..600 {
+ w.step();
+ }
+ let peak_pv = w.pv().iter().map(|v| v.abs()).max().unwrap();
+ let peak_v = w
+ .vx()
+ .iter()
+ .chain(w.vy().iter())
+ .map(|v| v.abs())
+ .max()
+ .unwrap();
+ assert!(
+ peak_pv < 8 && peak_v < 8,
+ "shockwave did not decay: peak_pv={peak_pv} peak_v={peak_v}"
+ );
+ }
+
+ #[test]
+ fn gunpowder_shockwave_displaces_nearby_water() {
+ let mut w = World::new(32, 32);
+ // Pit so water cannot slide on its own; empty above so |vy| can lift it.
+ w.paint(7, 8, Cell::Stone, 0);
+ w.paint(9, 8, Cell::Stone, 0);
+ w.paint(7, 9, Cell::Stone, 0);
+ w.paint(8, 9, Cell::Stone, 0);
+ w.paint(9, 9, Cell::Stone, 0);
+ w.paint(8, 8, Cell::Water, 0);
+ // Neighboring air cell below (AIR_CELL=4); outside the 3×3 fireball.
+ w.paint(8, 12, Cell::Gunpowder, 0);
+ w.paint(8, 11, Cell::Fire, 0);
+ // Gravity pulls the grain back into the pit after the hop, so
+ // assert it left spawn while pv→vx coupling is still strong.
+ let mut displaced = false;
+ for _ in 0..32 {
+ w.step();
+ if w.get(8, 8) != Cell::Water {
+ displaced = true;
+ break;
+ }
+ }
+ assert!(displaced, "shockwave should displace water out of (8, 8)");
+ }
+
+ #[test]
+ fn gunpowder_falls_like_sand() {
+ let mut w = World::new(8, 8);
+ w.paint(4, 0, Cell::Gunpowder, 0);
+ for _ in 0..16 {
+ w.step();
+ }
+ assert_eq!(w.get(4, 7), Cell::Gunpowder);
+ }
+
#[test]
fn fire_touching_water_extinguishes_and_boils() {
let mut w = World::new(8, 8);
@@ -891,7 +1276,119 @@ mod tests {
#[test]
fn from_u8_maps_steam() {
assert_eq!(Cell::from_u8(6), Some(Cell::Steam));
- assert_eq!(Cell::from_u8(7), None);
+ }
+
+ #[test]
+ fn from_u8_maps_fans_and_gunpowder() {
+ assert_eq!(Cell::from_u8(7), Some(Cell::FanRight));
+ assert_eq!(Cell::from_u8(8), Some(Cell::FanLeft));
+ assert_eq!(Cell::from_u8(9), Some(Cell::FanUp));
+ assert_eq!(Cell::from_u8(10), Some(Cell::FanDown));
+ assert_eq!(Cell::from_u8(11), Some(Cell::Gunpowder));
+ assert_eq!(Cell::from_u8(12), None);
+ }
+
+ #[test]
+ fn paint_cannot_overwrite_fan_except_eraser() {
+ let mut w = World::new(4, 4);
+ w.paint(1, 1, Cell::FanRight, 0);
+ w.paint(1, 1, Cell::Sand, 0);
+ assert_eq!(w.get(1, 1), Cell::FanRight);
+ w.paint(1, 1, Cell::Empty, 0);
+ assert_eq!(w.get(1, 1), Cell::Empty);
+ }
+
+ #[test]
+ fn air_buffers_match_grid() {
+ let w = World::new(320, 200);
+ assert_eq!(w.air_w, 80);
+ assert_eq!(w.air_h, 50);
+ assert_eq!(w.pv().len(), 80 * 50);
+ assert_eq!(w.vx().len(), w.pv().len());
+ assert_eq!(w.vy().len(), w.pv().len());
+ }
+
+ #[test]
+ fn fan_injects_velocity_on_facing_axis() {
+ let mut w = World::new(8, 8);
+ w.paint(0, 0, Cell::FanRight, 0);
+ w.step();
+ let i = w.air_idx_of_for_test(0, 0);
+ assert!(w.vx()[i] > 0, "expected +vx, got {}", w.vx()[i]);
+ assert_eq!(w.vy()[i], 0);
+ assert_eq!(w.get(0, 0), Cell::FanRight);
+ }
+
+ #[test]
+ fn idle_fan_still_blows_after_chunks_sleep() {
+ let mut w = World::new(8, 8);
+ w.paint(0, 0, Cell::FanDown, 0);
+ for _ in 0..8 {
+ w.step();
+ }
+ let i = w.air_idx_of_for_test(0, 0);
+ assert!(
+ w.vy()[i] > 0,
+ "idle fan lost vy (inject skipped sleeping chunk?): {}",
+ w.vy()[i]
+ );
+ }
+
+ #[test]
+ fn orthogonal_fans_last_in_scan_order_wins() {
+ let mut w = World::new(8, 8);
+ w.paint(0, 0, Cell::FanRight, 0);
+ w.paint(1, 0, Cell::FanDown, 0);
+ w.step();
+ let i = w.air_idx_of_for_test(0, 0);
+ assert_eq!(w.vx()[i], 0, "FanDown should zero vx from earlier FanRight");
+ assert!(
+ w.vy()[i] > 0,
+ "FanDown should inject +vy, got {}",
+ w.vy()[i]
+ );
+ }
+
+ #[test]
+ fn steam_in_front_of_fan_moves_right() {
+ let mut blown = World::new(24, 8);
+ blown.paint(0, 4, Cell::FanRight, 0);
+ blown.paint(1, 4, Cell::Steam, 0);
+ let mut still = World::new(24, 8);
+ still.paint(1, 4, Cell::Steam, 0);
+ for _ in 0..16 {
+ blown.step();
+ still.step();
+ }
+ let blown_x = (0..24)
+ .find(|&x| (0..8).any(|y| blown.get(x, y) == Cell::Steam))
+ .expect("steam still in blown world");
+ let still_x = (0..24)
+ .find(|&x| (0..8).any(|y| still.get(x, y) == Cell::Steam))
+ .expect("steam still in control world");
+ assert!(
+ blown_x > still_x,
+ "fan should push steam right: blown_x={blown_x} still_x={still_x}"
+ );
+ }
+
+ #[test]
+ fn sand_next_to_fan_does_not_blow_away() {
+ let mut w = World::new(8, 8);
+ w.paint(0, 6, Cell::FanRight, 0);
+ for x in 0..8 {
+ w.paint(x, 7, Cell::Stone, 0);
+ }
+ w.paint(1, 6, Cell::Sand, 0);
+ for _ in 0..16 {
+ w.step();
+ }
+ assert_eq!(w.get(1, 6), Cell::Sand);
+ for x in 2..8 {
+ for y in 0..7 {
+ assert_ne!(w.get(x, y), Cell::Sand, "sand blown to ({x},{y})");
+ }
+ }
}
#[test]
|