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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 24 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ <h1>Sandboxed</h1>
<button data-mat="6">
<span class="swatch" style="background: #d8dce8"></span>Steam
</button>
<button id="fan" data-mat="7">
<span class="swatch" style="background: #5a6e96"></span>Fan →
</button>
<button data-mat="11">
<span class="swatch" style="background: #28241e"></span>Gunpowder
</button>
<button data-mat="0">
<span class="swatch" style="background: #1a1a24"></span>Erase
</button>
Expand Down Expand Up @@ -143,15 +149,29 @@ <h1>Sandboxed</h1>
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 = `<span class="swatch" style="background: #5a6e96"></span>${FAN_LABELS[n]}`;
}
material = Number(btn.dataset.mat);
document
.querySelectorAll("#palette button")
.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
setActive(btn);
});
});

Expand Down
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
51 changes: 49 additions & 2 deletions src/renderer.rs
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
}
}
}
Expand Down Expand Up @@ -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}"
);
}
}
Loading
Loading