From 53580b9136c192ca1f11f89349a3bca2e5d01b34 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 21:30:28 -0400 Subject: [PATCH 1/8] feat: add fan and gunpowder cell variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserve ids 7–11 and lock fans like stone so the air pass can paint walls without them being overwritten by sand. --- src/renderer.rs | 7 +++++++ src/world.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/renderer.rs b/src/renderer.rs index 50d8211..ca7839f 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -57,6 +57,13 @@ 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 | Cell::FanLeft | Cell::FanUp | Cell::FanDown => { + put(frame, i, 90, 110, 140); + } + Cell::Gunpowder => { + let (r, g, b) = warm(40 + j / 2, 36 + j / 2, 32, h); + put(frame, i, r, g, b); + } } } } diff --git a/src/world.rs b/src/world.rs index 437a3d2..f893661 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,26 @@ 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. @@ -145,9 +170,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); @@ -891,7 +916,26 @@ 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] From 4b644312acfee802a2e91e6bafcefeb7c40e0e39 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 21:33:01 -0400 Subject: [PATCH 2/8] feat: add coarse integer air field and fan inject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run a 4×4 Eulerian pv/vx/vy step before particles so static fans keep blowing even when their chunk is idle. --- src/world.rs | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 3 deletions(-) diff --git a/src/world.rs b/src/world.rs index f893661..a12ccff 100644 --- a/src/world.rs +++ b/src/world.rs @@ -47,9 +47,12 @@ impl Cell { Cell::Water => 1, Cell::Gunpowder | Cell::Sand => 2, Cell::Fire | Cell::Steam => 0, - Cell::Stone | Cell::Wood | Cell::FanRight | Cell::FanLeft | Cell::FanUp | Cell::FanDown => { - 255 - } + Cell::Stone + | Cell::Wood + | Cell::FanRight + | Cell::FanLeft + | Cell::FanUp + | Cell::FanDown => 255, } } @@ -75,10 +78,24 @@ 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; +#[allow(dead_code)] +const GAS_PUSH: i16 = 24; +#[allow(dead_code)] +const WATER_PUSH: i16 = 64; +#[allow(dead_code)] +const BOIL_PV: i16 = 80; +#[allow(dead_code)] +const GUN_PV: i16 = 400; +#[allow(dead_code)] +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, @@ -96,6 +113,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 { @@ -103,9 +126,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], @@ -116,6 +144,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], } } @@ -144,6 +178,100 @@ 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] + #[allow(dead_code)] + pub fn pv(&self) -> &[i16] { + &self.pv + } + + #[inline] + #[allow(dead_code)] + pub fn vx(&self) -> &[i16] { + &self.vx + } + + #[inline] + #[allow(dead_code)] + 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 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 { + let i = self.air_idx_of(x, y); + match self.get(x, y) { + Cell::FanRight => self.vx[i] = FAN_SPEED, + Cell::FanLeft => self.vx[i] = -FAN_SPEED, + Cell::FanUp => self.vy[i] = -FAN_SPEED, + Cell::FanDown => 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); + let pv_r = Self::air_sample(&self.pv, aw, ah, ax + 1, ay); + let pv_u = Self::air_sample(&self.pv, aw, ah, ax, ay - 1); + let pv_d = Self::air_sample(&self.pv, aw, ah, ax, ay + 1); + let vx_l = Self::air_sample(&self.vx, aw, ah, ax - 1, ay); + let vx_r = Self::air_sample(&self.vx, aw, ah, ax + 1, ay); + let vy_u = Self::air_sample(&self.vy, aw, ah, ax, ay - 1); + let vy_d = Self::air_sample(&self.vy, aw, ah, ax, ay + 1); + let nvx = self.vx[i].saturating_add((pv_l - pv_r) / 4); + let nvy = self.vy[i].saturating_add((pv_u - pv_d) / 4); + let npv = self.pv[i].saturating_add((vx_l - vx_r + vy_u - vy_d) / 4); + self.vx_prev[i] = Self::damp_i16(nvx, 7, 8); + self.vy_prev[i] = Self::damp_i16(nvy, 7, 8); + self.pv_prev[i] = Self::damp_i16(npv, 15, 16); + } + } + std::mem::swap(&mut self.vx, &mut self.vx_prev); + std::mem::swap(&mut self.vy, &mut self.vy_prev); + std::mem::swap(&mut self.pv, &mut self.pv_prev); + } + fn next_rand(&mut self) -> u64 { let mut x = self.rng; x ^= x << 13; @@ -262,6 +390,7 @@ impl World { /// Advance the simulation by one tick. pub fn step(&mut self) { self.frame += 1; + self.step_air(); self.clear_moved_in_active_chunks(); self.next_active.fill(false); @@ -938,6 +1067,42 @@ mod tests { 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 steam_rises() { let mut w = World::new(8, 8); From db5ba1abb3b17b7067e9c8482753a8fdb16d9889 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 21:35:56 -0400 Subject: [PATCH 3/8] fix: last fan in a 4x4 owns the air vector Orthogonal fans sharing an air cell were composing because inject_fans only set the facing axis. Each fan now assigns both vx and vy so the last pixel in scan order fully wins the shared vector. --- src/world.rs | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/world.rs b/src/world.rs index a12ccff..3aac1fd 100644 --- a/src/world.rs +++ b/src/world.rs @@ -232,10 +232,22 @@ impl World { for x in 0..self.width { let i = self.air_idx_of(x, y); match self.get(x, y) { - Cell::FanRight => self.vx[i] = FAN_SPEED, - Cell::FanLeft => self.vx[i] = -FAN_SPEED, - Cell::FanUp => self.vy[i] = -FAN_SPEED, - Cell::FanDown => self.vy[i] = FAN_SPEED, + Cell::FanRight => { + self.vx[i] = FAN_SPEED; + self.vy[i] = 0; + } + Cell::FanLeft => { + self.vx[i] = -FAN_SPEED; + self.vy[i] = 0; + } + Cell::FanUp => { + self.vx[i] = 0; + self.vy[i] = -FAN_SPEED; + } + Cell::FanDown => { + self.vx[i] = 0; + self.vy[i] = FAN_SPEED; + } _ => {} } } @@ -1103,6 +1115,17 @@ mod tests { ); } + #[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_rises() { let mut w = World::new(8, 8); From 243c5d3c3a681a829de140c26e1eeeb4cedaf03a Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 21:40:12 -0400 Subject: [PATCH 4/8] feat: let air velocity shove gases and weakly shove water MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steam and fire take one air-axis move before they rise so a fan in the same 4×4 can blow them sideways. --- src/world.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/world.rs b/src/world.rs index 3aac1fd..691aef4 100644 --- a/src/world.rs +++ b/src/world.rs @@ -593,6 +593,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; } @@ -629,6 +632,9 @@ impl World { self.wake(x, y, true); return; } + if self.try_air_push(x, y, GAS_PUSH) { + return; + } if y == 0 { return; } @@ -650,6 +656,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 { @@ -729,6 +758,7 @@ impl World { return; } } + self.try_air_push(x, y, WATER_PUSH); } } @@ -1123,7 +1153,53 @@ mod tests { 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]); + 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] From 9dc44b1818ce05c302202f7568037e13359960da Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 21:43:33 -0400 Subject: [PATCH 5/8] feat: add boil pressure pulses and exploding gunpowder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gunpowder falls like sand and turns a 3×3 into fire, leaving stone and fans, so shockwaves have a source that is not the fan field. --- src/world.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/world.rs b/src/world.rs index 691aef4..b58b197 100644 --- a/src/world.rs +++ b/src/world.rs @@ -441,6 +441,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), @@ -463,6 +464,11 @@ 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); + } + #[inline] fn add_heat(&mut self, x: usize, y: usize, amount: u8) { if x >= self.width || y >= self.height { @@ -479,6 +485,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); } _ => {} } @@ -716,6 +723,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); @@ -1041,6 +1079,50 @@ 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.step(); + assert_eq!(w.get(2, 2), Cell::Stone, "stone 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_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); From d650527d23a6158d7b91650e3bfddcf2dbfeebca Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 21:47:46 -0400 Subject: [PATCH 6/8] feat: tint empty cells by air and add fan/gunpowder UI Show fan flow on empty pixels and expose facing-cycle plus gunpowder on the palette without exporting air buffers to JS. --- README.md | 2 ++ index.html | 28 ++++++++++++++++++++++++---- src/lib.rs | 3 ++- src/renderer.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 72 insertions(+), 10 deletions(-) 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 ca7839f..c8ffd1d 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, 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,9 +62,10 @@ 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 | Cell::FanLeft | Cell::FanUp | Cell::FanDown => { - put(frame, i, 90, 110, 140); - } + 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); @@ -114,4 +120,37 @@ 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; + assert_ne!( + &fb[o..o + 3], + &fs[o..o + 3], + "empty air in a fan cell should differ from still air" + ); + 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}" + ); + } } From ad2baed0719eef26431d225a5d48b8e54d4d9a2b Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 21:50:35 -0400 Subject: [PATCH 7/8] fix: encode horizontal air as red-green empty tint --- src/renderer.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/renderer.rs b/src/renderer.rs index c8ffd1d..a5d50f3 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -36,7 +36,7 @@ pub fn draw(world: &World, frame: &mut [u8]) { 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, 18 + avy / 2); + 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); @@ -133,10 +133,11 @@ mod tests { 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; - assert_ne!( - &fb[o..o + 3], - &fs[o..o + 3], - "empty air in a fan cell should differ from still air" + 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); } From 8f8bca951ea9427c5cf3727fe6ceebaa90d84a59 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 22:11:15 -0400 Subject: [PATCH 8/8] fix: stabilize air Euler with two-pass update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressure from last tick's velocity amplified the 4-cell mode. Compute velocity first, then pressure from the new field, and widen diffs to i32. Bench 960×600 native (300 steps, after this change): sparse 839 steps/s, dense top-quarter 353 steps/s. --- src/world.rs | 151 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 129 insertions(+), 22 deletions(-) diff --git a/src/world.rs b/src/world.rs index b58b197..60e001b 100644 --- a/src/world.rs +++ b/src/world.rs @@ -80,15 +80,10 @@ const STEAM_PAINT_HEAT: u8 = 120; const CHUNK: usize = 16; pub(crate) const AIR_CELL: usize = 4; const FAN_SPEED: i16 = 40; -#[allow(dead_code)] const GAS_PUSH: i16 = 24; -#[allow(dead_code)] const WATER_PUSH: i16 = 64; -#[allow(dead_code)] const BOIL_PV: i16 = 80; -#[allow(dead_code)] const GUN_PV: i16 = 400; -#[allow(dead_code)] const GUN_IGNITE: u8 = 80; pub struct World { @@ -189,19 +184,17 @@ impl World { } #[inline] - #[allow(dead_code)] + #[cfg(test)] pub fn pv(&self) -> &[i16] { &self.pv } #[inline] - #[allow(dead_code)] pub fn vx(&self) -> &[i16] { &self.vx } #[inline] - #[allow(dead_code)] pub fn vy(&self) -> &[i16] { &self.vy } @@ -211,6 +204,11 @@ impl World { 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 @@ -230,21 +228,24 @@ impl World { fn inject_fans(&mut self) { for y in 0..self.height { for x in 0..self.width { - let i = self.air_idx_of(x, y); 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; } @@ -263,25 +264,40 @@ impl World { 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); - let pv_r = Self::air_sample(&self.pv, aw, ah, ax + 1, ay); - let pv_u = Self::air_sample(&self.pv, aw, ah, ax, ay - 1); - let pv_d = Self::air_sample(&self.pv, aw, ah, ax, ay + 1); - let vx_l = Self::air_sample(&self.vx, aw, ah, ax - 1, ay); - let vx_r = Self::air_sample(&self.vx, aw, ah, ax + 1, ay); - let vy_u = Self::air_sample(&self.vy, aw, ah, ax, ay - 1); - let vy_d = Self::air_sample(&self.vy, aw, ah, ax, ay + 1); - let nvx = self.vx[i].saturating_add((pv_l - pv_r) / 4); - let nvy = self.vy[i].saturating_add((pv_u - pv_d) / 4); - let npv = self.pv[i].saturating_add((vx_l - vx_r + vy_u - vy_d) / 4); + 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); - self.pv_prev[i] = Self::damp_i16(npv, 15, 16); } } 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 { @@ -403,8 +419,13 @@ impl World { 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() { @@ -467,6 +488,7 @@ impl World { 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] @@ -1100,8 +1122,10 @@ mod tests { 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); @@ -1113,6 +1137,89 @@ mod tests { ); } + #[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);