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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ <h1>Sandboxed</h1>
<button data-mat="14">
<span class="swatch" style="background: #b4dcf0"></span>Ice
</button>
<button data-mat="15">
<span class="swatch" style="background: #5a6e58"></span>Glass
</button>
<button data-mat="0">
<span class="swatch" style="background: #1a1a24"></span>Erase
</button>
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
Expand Down
173 changes: 156 additions & 17 deletions src/world.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub enum Cell {
Smoke = 12,
Oil = 13,
Ice = 14,
Glass = 15,
}

impl Cell {
Expand All @@ -42,6 +43,7 @@ impl Cell {
12 => Some(Cell::Smoke),
13 => Some(Cell::Oil),
14 => Some(Cell::Ice),
15 => Some(Cell::Glass),
_ => None,
}
}
Expand All @@ -56,6 +58,7 @@ impl Cell {
Cell::Stone
| Cell::Wood
| Cell::Ice
| Cell::Glass
| Cell::FanRight
| Cell::FanLeft
| Cell::FanUp
Expand All @@ -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)
}
}

Expand All @@ -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;
Expand Down Expand Up @@ -137,6 +141,8 @@ pub struct World {
vy_prev: Vec<i16>,
/// Air cells that contain a locked pixel; velocity is zeroed after advection.
blocked: Vec<bool>,
/// Coarse ambient heat on the air grid. Empty pixels stay at heat 0.
ah: Vec<u8>,
/// Per-pixel inertia. Empty and locked cells are always 0.
pvx: Vec<i8>,
pvy: Vec<i8>,
Expand Down Expand Up @@ -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],
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
_ => {}
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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);
Expand All @@ -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]
Expand Down Expand Up @@ -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);
}

Expand All @@ -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]
Expand Down
Loading