From 14588d0c8941e58d45bc45fefb1c9dcaf5785b83 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 22:48:25 -0400 Subject: [PATCH] feat: add ambient air heat, pressure boil, and glass Occupied cells couple to a coarse air-heat field so hot air rises; pressure raises water's boil point, and sand bakes into locked glass. --- README.md | 1 + index.html | 3 + src/lib.rs | 2 +- src/renderer.rs | 4 ++ src/world.rs | 173 +++++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 165 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 79f113b..5db1408 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ unit-testable with plain `cargo test`. | Smoke | Rises slower than steam; leftover from burned-out fire | | Oil | Floats on water; ignites from fire or heat 60 | | Ice | Static; melts to water at heat 25; water freezes below 15 | +| Glass | Sand baked at heat 180; locked like stone | | Erase / Clear | Remove cells | ## Quickstart diff --git a/index.html b/index.html index 41dd678..4ed4602 100644 --- a/index.html +++ b/index.html @@ -119,6 +119,9 @@

Sandboxed

+ diff --git a/src/lib.rs b/src/lib.rs index f42c276..d3b0ea6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,7 +51,7 @@ impl Simulation { /// 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, 12=smoke, - /// 13=oil, 14=ice) at (x, y). + /// 13=oil, 14=ice, 15=glass) 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 a36e1b7..151e008 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -82,6 +82,10 @@ pub fn draw(world: &World, frame: &mut [u8]) { let (r, g, b) = warm(180 + j / 3, 220 + j / 2, 240, h); put(frame, i, r, g, b); } + Cell::Glass => { + let (r, g, b) = warm(90 + j / 2, 110 + j / 3, 88, h); + put(frame, i, r, g, b); + } } } } diff --git a/src/world.rs b/src/world.rs index ef97d36..387b965 100644 --- a/src/world.rs +++ b/src/world.rs @@ -22,6 +22,7 @@ pub enum Cell { Smoke = 12, Oil = 13, Ice = 14, + Glass = 15, } impl Cell { @@ -42,6 +43,7 @@ impl Cell { 12 => Some(Cell::Smoke), 13 => Some(Cell::Oil), 14 => Some(Cell::Ice), + 15 => Some(Cell::Glass), _ => None, } } @@ -56,6 +58,7 @@ impl Cell { Cell::Stone | Cell::Wood | Cell::Ice + | Cell::Glass | Cell::FanRight | Cell::FanLeft | Cell::FanUp @@ -73,12 +76,12 @@ impl Cell { #[inline] fn is_locked(self) -> bool { - matches!(self, Cell::Stone | Cell::Wood | Cell::Ice) || self.is_fan() + matches!(self, Cell::Stone | Cell::Wood | Cell::Ice | Cell::Glass) || self.is_fan() } #[inline] fn blocks_air(self) -> bool { - matches!(self, Cell::Stone | Cell::Wood | Cell::Ice) + matches!(self, Cell::Stone | Cell::Wood | Cell::Ice | Cell::Glass) } } @@ -95,6 +98,7 @@ const ROOM_HEAT: u8 = 50; const OIL_IGNITE: u8 = 60; const ICE_FREEZE: u8 = 15; const ICE_MELT: u8 = 25; +const GLASS_HEAT: u8 = 180; const CHUNK: usize = 16; pub(crate) const AIR_CELL: usize = 4; const FAN_SPEED: i16 = 40; @@ -137,6 +141,8 @@ pub struct World { vy_prev: Vec, /// Air cells that contain a locked pixel; velocity is zeroed after advection. blocked: Vec, + /// Coarse ambient heat on the air grid. Empty pixels stay at heat 0. + ah: Vec, /// Per-pixel inertia. Empty and locked cells are always 0. pvx: Vec, pvy: Vec, @@ -172,6 +178,7 @@ impl World { vx_prev: vec![0; nair], vy_prev: vec![0; nair], blocked: vec![false; nair], + ah: vec![ROOM_HEAT; nair], pvx: vec![0; width * height], pvy: vec![0; width * height], } @@ -282,14 +289,44 @@ impl World { self.blocked.fill(false); for y in 0..self.height { for x in 0..self.width { - if self.get(x, y).blocks_air() { + let cell = self.get(x, y); + if cell.blocks_air() { let i = self.air_idx_of(x, y); self.blocked[i] = true; } + if cell != Cell::Empty { + self.apply_phase(x, y); + if self.get(x, y) != Cell::Empty { + self.exchange_ambient(x, y); + } + } } } } + fn exchange_ambient(&mut self, x: usize, y: usize) { + let i = self.idx(x, y); + let ai = self.air_idx_of(x, y); + let h = self.heat[i]; + let a = self.ah[ai]; + if h == a { + return; + } + let give = (h.abs_diff(a) / 8).max(1); + let gas = matches!(self.cells[i], Cell::Fire | Cell::Steam | Cell::Smoke); + if h > a && gas { + self.heat[i] = h.saturating_sub(give); + self.ah[ai] = a.saturating_add(give); + } else if h < a && !gas { + self.heat[i] = h.saturating_add(give); + self.ah[ai] = a.saturating_sub(give); + } else { + return; + } + self.wake(x, y, true); + self.apply_phase(x, y); + } + fn inject_fans(&mut self) { for y in 0..self.height { for x in 0..self.width { @@ -436,6 +473,7 @@ impl World { } } std::mem::swap(&mut self.pv, &mut self.pv_prev); + self.apply_boussinesq(); self.blur_velocity(); self.advect_velocity(); self.zero_blocked_velocity(); @@ -451,6 +489,13 @@ impl World { } } + fn apply_boussinesq(&mut self) { + for i in 0..self.ah.len() { + let lift = self.ah[i].saturating_sub(ROOM_HEAT) / 32; + self.vy[i] = self.vy[i].saturating_sub(lift as i16); + } + } + fn carry_jets(&mut self) { self.vx_prev.copy_from_slice(&self.vx); self.vy_prev.copy_from_slice(&self.vy); @@ -721,10 +766,25 @@ impl World { } self.heat[i] = self.heat[i].saturating_add(amount); self.wake(x, y, true); + self.apply_phase(x, y); + } + + fn boil_threshold(&self, x: usize, y: usize) -> u8 { + let pv = self.pv[self.air_idx_of(x, y)].max(0); + WATER_BOIL.saturating_add((pv / 40) as u8) + } + + fn condense_threshold(&self, x: usize, y: usize) -> u8 { + let pv = self.pv[self.air_idx_of(x, y)].max(0); + STEAM_CONDENSE.saturating_sub((pv / 40) as u8) + } + + fn apply_phase(&mut self, x: usize, y: usize) { + let i = self.idx(x, y); match self.cells[i] { Cell::Wood if self.heat[i] >= WOOD_IGNITE => self.ignite(x, y), - Cell::Oil if self.heat[i] >= OIL_IGNITE => self.ignite(x, y), - Cell::Water if self.heat[i] >= WATER_BOIL => { + Cell::Gunpowder if self.heat[i] >= GUN_IGNITE => self.explode_gunpowder(x, y), + Cell::Water if self.heat[i] >= self.boil_threshold(x, y) => { self.cells[i] = Cell::Steam; self.ttl[i] = 0; self.add_pv(x, y, BOIL_PV); @@ -733,6 +793,12 @@ impl World { self.cells[i] = Cell::Water; self.ttl[i] = 0; } + Cell::Sand if self.heat[i] >= GLASS_HEAT => { + self.cells[i] = Cell::Glass; + self.ttl[i] = 0; + self.pvx[i] = 0; + self.pvy[i] = 0; + } _ => {} } } @@ -833,7 +899,7 @@ impl World { self.wake(x, y, true); 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]); + let bump = self.boil_threshold(wx, wy).saturating_sub(self.heat[wi]); self.add_heat(wx, wy, bump); self.extinguish(x, y); return; @@ -869,7 +935,7 @@ impl World { if self.cells[i] != Cell::Steam { return; } - if self.heat[i] < STEAM_CONDENSE { + if self.heat[i] < self.condense_threshold(x, y) { self.cells[i] = Cell::Water; self.ttl[i] = 0; self.wake(x, y, true); @@ -1062,7 +1128,8 @@ impl World { continue; } let cell = self.get(nx, ny); - if cell == Cell::Stone || cell == Cell::Ice || cell.is_fan() { + if cell == Cell::Stone || cell == Cell::Ice || cell == Cell::Glass || cell.is_fan() + { continue; } self.ignite(nx, ny); @@ -1097,6 +1164,12 @@ impl World { self.pvy[i] = vy; } + #[cfg(test)] + pub(crate) fn set_pv_for_test(&mut self, x: usize, y: usize, pv: i16) { + let i = self.air_idx_of(x, y); + self.pv[i] = pv; + } + fn step_water(&mut self, x: usize, y: usize) { let i = self.idx(x, y); if self.heat[i] > 0 { @@ -1115,7 +1188,7 @@ impl World { return; } if let Some((fx, fy)) = self.first_cardinal(x, y, Cell::Fire) { - let bump = WATER_BOIL.saturating_sub(self.heat[i]); + let bump = self.boil_threshold(x, y).saturating_sub(self.heat[i]); self.add_heat(x, y, bump); self.extinguish(fx, fy); return; @@ -1680,7 +1753,8 @@ mod tests { assert_eq!(Cell::from_u8(12), Some(Cell::Smoke)); assert_eq!(Cell::from_u8(13), Some(Cell::Oil)); assert_eq!(Cell::from_u8(14), Some(Cell::Ice)); - assert_eq!(Cell::from_u8(15), None); + assert_eq!(Cell::from_u8(15), Some(Cell::Glass)); + assert_eq!(Cell::from_u8(16), None); } #[test] @@ -1816,6 +1890,73 @@ mod tests { assert_eq!(w.pvy().len(), w.cells().len()); } + #[test] + fn high_pressure_raises_boil_point() { + let mut still = World::new(4, 4); + still.paint(1, 1, Cell::Water, 0); + still.add_heat_for_test(1, 1, WATER_BOIL - ROOM_HEAT); + assert_eq!(still.get(1, 1), Cell::Steam); + + let mut pressurized = World::new(4, 4); + pressurized.paint(1, 1, Cell::Water, 0); + pressurized.set_pv_for_test(1, 1, 400); + pressurized.add_heat_for_test(1, 1, WATER_BOIL - ROOM_HEAT); + assert_eq!(pressurized.get(1, 1), Cell::Water); + pressurized.add_heat_for_test(1, 1, 10); + assert_eq!(pressurized.get(1, 1), Cell::Steam); + } + + #[test] + fn sand_bakes_into_glass() { + let mut w = World::new(8, 8); + w.paint(4, 6, Cell::Sand, 0); + w.add_heat_for_test(4, 6, GLASS_HEAT); + assert_eq!(w.get(4, 6), Cell::Glass); + + let mut fire = World::new(8, 8); + for x in 2..=5 { + fire.paint(x, 5, Cell::Stone, 0); + fire.paint(x, 7, Cell::Stone, 0); + } + fire.paint(2, 6, Cell::Stone, 0); + fire.paint(5, 6, Cell::Stone, 0); + fire.paint(3, 6, Cell::Fire, 0); + fire.paint(4, 6, Cell::Sand, 0); + for _ in 0..64 { + fire.step(); + if count(&fire, Cell::Glass) > 0 { + break; + } + } + assert!(count(&fire, Cell::Glass) > 0); + } + + #[test] + fn empty_heat_stays_zero_next_to_fire() { + let mut w = World::new(8, 8); + w.paint(4, 4, Cell::Fire, 0); + for _ in 0..16 { + w.step(); + } + for (i, &cell) in w.cells().iter().enumerate() { + if cell == Cell::Empty { + assert_eq!(w.heat()[i], 0); + } + } + } + + #[test] + fn paint_cannot_overwrite_glass_except_eraser() { + let mut w = World::new(4, 4); + w.paint(1, 1, Cell::Sand, 0); + w.add_heat_for_test(1, 1, GLASS_HEAT); + assert_eq!(w.get(1, 1), Cell::Glass); + w.paint(1, 1, Cell::Water, 0); + assert_eq!(w.get(1, 1), Cell::Glass); + 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); @@ -1824,6 +1965,7 @@ mod tests { assert_eq!(w.pv().len(), 80 * 50); assert_eq!(w.vx().len(), w.pv().len()); assert_eq!(w.vy().len(), w.pv().len()); + assert_eq!(w.pv().len(), w.air_w * w.air_h); } #[test] @@ -1968,7 +2110,10 @@ mod tests { 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!( + w.heat()[4 * 8 + 4] < STEAM_PAINT_HEAT, + "steam should cool as it rises" + ); assert_eq!(w.heat()[4 * 8 + 5], 0); } @@ -1982,12 +2127,6 @@ mod tests { } 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]