From ddc10500f0cc26c95b347a5c4fb71822fa066e98 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 22:38:40 -0400 Subject: [PATCH] feat: add oil and ice with density-based layering Oil floats on water and ignites from fire; ice is a locked wall that melts on heat with freeze hysteresis so painted water stays liquid. --- README.md | 2 + index.html | 6 +++ src/lib.rs | 3 +- src/renderer.rs | 8 +++ src/world.rs | 134 ++++++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 142 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index edf1a72..79f113b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ unit-testable with plain `cargo test`. | Fan | Static; click the swatch to cycle facing; blows gases in that 4×4 | | Gunpowder | Falls like sand; explodes from fire or heat 80 | | 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 | | Erase / Clear | Remove cells | ## Quickstart diff --git a/index.html b/index.html index 1e9fe1a..41dd678 100644 --- a/index.html +++ b/index.html @@ -113,6 +113,12 @@

Sandboxed

+ + diff --git a/src/lib.rs b/src/lib.rs index bb78c4c..f42c276 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,7 +50,8 @@ 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) at (x, y). + /// 6=steam, 7=fan→, 8=fan←, 9=fan↑, 10=fan↓, 11=gunpowder, 12=smoke, + /// 13=oil, 14=ice) 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 2764e17..a36e1b7 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -74,6 +74,14 @@ pub fn draw(world: &World, frame: &mut [u8]) { let (r, g, b) = warm(96 + j / 2, 96 + j / 2, 100, h); put(frame, i, r, g, b); } + Cell::Oil => { + let (r, g, b) = warm(180 + j / 2, 110 + j / 3, 24, h); + put(frame, i, r, g, b); + } + Cell::Ice => { + let (r, g, b) = warm(180 + j / 3, 220 + j / 2, 240, h); + put(frame, i, r, g, b); + } } } } diff --git a/src/world.rs b/src/world.rs index 36c362f..4463770 100644 --- a/src/world.rs +++ b/src/world.rs @@ -20,6 +20,8 @@ pub enum Cell { FanDown = 10, Gunpowder = 11, Smoke = 12, + Oil = 13, + Ice = 14, } impl Cell { @@ -38,6 +40,8 @@ impl Cell { 10 => Some(Cell::FanDown), 11 => Some(Cell::Gunpowder), 12 => Some(Cell::Smoke), + 13 => Some(Cell::Oil), + 14 => Some(Cell::Ice), _ => None, } } @@ -45,12 +49,13 @@ impl Cell { /// "Density" used for sinking behavior: sand sinks through water. fn density(self) -> u8 { match self { - Cell::Empty => 0, - Cell::Water => 1, - Cell::Gunpowder | Cell::Sand => 2, - Cell::Fire | Cell::Steam | Cell::Smoke => 0, + Cell::Empty | Cell::Fire | Cell::Steam | Cell::Smoke => 0, + Cell::Oil => 1, + Cell::Water => 2, + Cell::Gunpowder | Cell::Sand => 3, Cell::Stone | Cell::Wood + | Cell::Ice | Cell::FanRight | Cell::FanLeft | Cell::FanUp @@ -68,12 +73,12 @@ impl Cell { #[inline] fn is_locked(self) -> bool { - matches!(self, Cell::Stone | Cell::Wood) || self.is_fan() + matches!(self, Cell::Stone | Cell::Wood | Cell::Ice) || self.is_fan() } #[inline] fn blocks_air(self) -> bool { - matches!(self, Cell::Stone | Cell::Wood) + matches!(self, Cell::Stone | Cell::Wood | Cell::Ice) } } @@ -86,6 +91,10 @@ const STEAM_CONDENSE: u8 = 40; const STEAM_PAINT_HEAT: u8 = 120; const SMOKE_PAINT_HEAT: u8 = 80; const SMOKE_DISPERSE: u8 = 10; +const ROOM_HEAT: u8 = 50; +const OIL_IGNITE: u8 = 60; +const ICE_FREEZE: u8 = 15; +const ICE_MELT: u8 = 25; const CHUNK: usize = 16; pub(crate) const AIR_CELL: usize = 4; const FAN_SPEED: i16 = 40; @@ -510,6 +519,7 @@ impl World { Cell::Fire => FIRE_HEAT, Cell::Steam => STEAM_PAINT_HEAT, Cell::Smoke => SMOKE_PAINT_HEAT, + Cell::Water | Cell::Oil => ROOM_HEAT, _ => 0, }; self.wake(px, py, false); @@ -637,6 +647,7 @@ impl World { Cell::Sand => self.step_sand(x, y), Cell::Gunpowder => self.step_gunpowder(x, y), Cell::Water => self.step_water(x, y), + Cell::Oil => self.step_oil(x, y), Cell::Fire => self.step_fire(x, y), Cell::Steam => self.step_steam(x, y), Cell::Smoke => self.step_smoke(x, y), @@ -678,11 +689,16 @@ impl World { self.wake(x, y, true); 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 => { self.cells[i] = Cell::Steam; self.ttl[i] = 0; self.add_pv(x, y, BOIL_PV); } + Cell::Ice if self.heat[i] >= ICE_MELT => { + self.cells[i] = Cell::Water; + self.ttl[i] = 0; + } _ => {} } } @@ -705,7 +721,8 @@ impl World { if nx >= self.width || ny >= self.height { continue; } - if self.get(nx, ny) == Cell::Empty { + let n = self.get(nx, ny); + if n == Cell::Empty || n == Cell::Stone || n.is_fan() { continue; } let ni = self.idx(nx, ny); @@ -982,7 +999,7 @@ impl World { continue; } let cell = self.get(nx, ny); - if cell == Cell::Stone || cell.is_fan() { + if cell == Cell::Stone || cell == Cell::Ice || cell.is_fan() { continue; } self.ignite(nx, ny); @@ -1018,18 +1035,43 @@ impl World { return; } } + let i = self.idx(x, y); + if self.heat[i] < ICE_FREEZE { + self.cells[i] = Cell::Ice; + self.ttl[i] = 0; + self.wake(x, y, true); + return; + } if let Some((fx, fy)) = self.first_cardinal(x, y, Cell::Fire) { let bump = WATER_BOIL.saturating_sub(self.heat[i]); self.add_heat(x, y, bump); self.extinguish(fx, fy); return; } + self.slide_liquid(x, y); + } + + fn step_oil(&mut self, x: usize, y: usize) { + let i = self.idx(x, y); + if self.heat[i] > 0 { + self.conduct(x, y); + if self.get(x, y) != Cell::Oil { + return; + } + } + if self.heat[i] >= OIL_IGNITE || self.first_cardinal(x, y, Cell::Fire).is_some() { + self.ignite(x, y); + return; + } + self.slide_liquid(x, y); + } + + fn slide_liquid(&mut self, x: usize, y: usize) { let below = y + 1; if below < self.height && self.try_move(x, y, x, below) { return; } let left_first = self.next_rand() & 1 == 0; - // Diagonals, then horizontal slide (water spreads). let moves: [(isize, isize); 4] = [(-1, 1), (1, 1), (-1, 0), (1, 0)]; for k in 0..4 { let (dx, dy) = moves[if left_first { k } else { 3 - k }]; @@ -1554,7 +1596,9 @@ mod tests { assert_eq!(Cell::from_u8(10), Some(Cell::FanDown)); assert_eq!(Cell::from_u8(11), Some(Cell::Gunpowder)); assert_eq!(Cell::from_u8(12), Some(Cell::Smoke)); - assert_eq!(Cell::from_u8(13), None); + 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); } #[test] @@ -1567,6 +1611,76 @@ mod tests { assert_eq!(w.get(1, 1), Cell::Empty); } + #[test] + fn paint_cannot_overwrite_ice_except_eraser() { + let mut w = World::new(4, 4); + w.paint(1, 1, Cell::Ice, 0); + w.paint(1, 1, Cell::Sand, 0); + assert_eq!(w.get(1, 1), Cell::Ice); + w.paint(1, 1, Cell::Empty, 0); + assert_eq!(w.get(1, 1), Cell::Empty); + } + + #[test] + fn oil_floats_on_water() { + let mut w = World::new(4, 8); + for y in 0..8 { + w.paint(1, y, Cell::Stone, 0); + w.paint(3, y, Cell::Stone, 0); + } + w.paint(2, 0, Cell::Oil, 0); + w.paint(2, 1, Cell::Water, 0); + for _ in 0..16 { + w.step(); + } + let oil_y = (0..8) + .find(|&y| (0..4).any(|x| w.get(x, y) == Cell::Oil)) + .expect("oil"); + let water_y = (0..8) + .find(|&y| (0..4).any(|x| w.get(x, y) == Cell::Water)) + .expect("water"); + assert!( + oil_y < water_y, + "oil should rest above water: oil_y={oil_y} water_y={water_y}" + ); + } + + #[test] + fn oil_ignites_from_fire() { + let mut w = World::new(8, 8); + w.paint(4, 4, Cell::Fire, 0); + w.paint(5, 4, Cell::Oil, 0); + w.step(); + assert_eq!(count(&w, Cell::Oil), 0); + assert!(count(&w, Cell::Fire) > 0); + } + + #[test] + fn painted_water_does_not_freeze() { + let mut w = World::new(4, 4); + w.paint(1, 1, Cell::Water, 0); + for _ in 0..16 { + w.step(); + } + assert_eq!(count(&w, Cell::Ice), 0); + assert_eq!(count(&w, Cell::Water), 1); + } + + #[test] + fn ice_melts_from_adjacent_fire() { + let mut w = World::new(8, 8); + w.paint(4, 3, Cell::Ice, 0); + w.paint(4, 4, Cell::Fire, 0); + for _ in 0..32 { + w.step(); + if count(&w, Cell::Ice) == 0 { + break; + } + } + assert_eq!(count(&w, Cell::Ice), 0); + assert!(count(&w, Cell::Water) + count(&w, Cell::Steam) > 0); + } + #[test] fn air_buffers_match_grid() { let w = World::new(320, 200);