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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ unit-testable with plain `cargo test`.
| Input | Action |
| ------------ | ------------------------------------------- |
| Click / drag | Paint the selected material |
| Brush slider | Radius 1–8 |
| Pause / Step | Freeze the sim; advance one tick |
| Space / `.` | Pause toggle / single-step |
| Sand | Falls and piles; sinks through water |
| Water | Flows and spreads |
| Stone | Static obstacle |
Expand Down
139 changes: 113 additions & 26 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,43 @@
image-rendering: pixelated;
border: 1px solid #333;
cursor: crosshair;
touch-action: none;
}
#palette {
.bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: center;
}
#palette button {
padding: 6px 14px;
button {
padding: 6px 12px;
border: 1px solid #444;
background: #1a1a24;
color: #ddd;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
#palette button.active {
button.active {
border-color: #e0c060;
background: #2a2a1a;
}
.swatch {
width: 12px;
height: 12px;
border-radius: 2px;
border: 1px solid #0006;
flex-shrink: 0;
}
label.brush {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
#fps {
font-variant-numeric: tabular-nums;
opacity: 0.7;
Expand All @@ -65,13 +85,34 @@
</head>
<body>
<h1>Sandboxed</h1>
<div id="palette">
<button data-mat="1" class="active">Sand</button>
<button data-mat="2">Water</button>
<button data-mat="3">Stone</button>
<button data-mat="4">Fire</button>
<button data-mat="5">Wood</button>
<button data-mat="0">Erase</button>
<div class="bar" id="palette">
<button data-mat="1" class="active">
<span class="swatch" style="background: #c2a860"></span>Sand
</button>
<button data-mat="2">
<span class="swatch" style="background: #285ac8"></span>Water
</button>
<button data-mat="3">
<span class="swatch" style="background: #6e6e76"></span>Stone
</button>
<button data-mat="4">
<span class="swatch" style="background: #ff7820"></span>Fire
</button>
<button data-mat="5">
<span class="swatch" style="background: #764824"></span>Wood
</button>
<button data-mat="0">
<span class="swatch" style="background: #1a1a24"></span>Erase
</button>
</div>
<div class="bar">
<label class="brush">
Brush
<input id="brush" type="range" min="1" max="8" value="3" />
<span id="brush-val">3</span>
</label>
<button id="pause" aria-pressed="false">Pause</button>
<button id="step">Step</button>
<button id="clear">Clear</button>
</div>
<canvas id="view"></canvas>
Expand All @@ -83,7 +124,6 @@ <h1>Sandboxed</h1>
const W = 320;
const H = 200;
const SCALE = 3;
const BRUSH = 3;

const wasm = await init();

Expand All @@ -96,6 +136,10 @@ <h1>Sandboxed</h1>
const ctx = canvas.getContext("2d");

let material = 1;
let brush = 3;
let paused = false;
let pendingStep = false;

document
.querySelectorAll("#palette button[data-mat]")
.forEach((btn) => {
Expand All @@ -107,9 +151,40 @@ <h1>Sandboxed</h1>
btn.classList.add("active");
});
});
document
.getElementById("clear")
.addEventListener("click", () => sim.clear());

const brushInput = document.getElementById("brush");
const brushVal = document.getElementById("brush-val");
brushInput.addEventListener("input", () => {
brush = Number(brushInput.value);
brushVal.textContent = String(brush);
});

const pauseBtn = document.getElementById("pause");
const setPaused = (next) => {
paused = next;
pauseBtn.classList.toggle("active", paused);
pauseBtn.setAttribute("aria-pressed", String(paused));
pauseBtn.textContent = paused ? "Resume" : "Pause";
};
pauseBtn.addEventListener("click", () => setPaused(!paused));
document.getElementById("step").addEventListener("click", () => {
setPaused(true);
pendingStep = true;
});
document.getElementById("clear").addEventListener("click", () => {
sim.clear();
});

window.addEventListener("keydown", (e) => {
if (e.target instanceof HTMLInputElement) return;
if (e.code === "Space") {
e.preventDefault();
setPaused(!paused);
} else if (e.code === "Period") {
setPaused(true);
pendingStep = true;
}
});

let painting = false;
const toGrid = (e) => {
Expand All @@ -119,25 +194,27 @@ <h1>Sandboxed</h1>
Math.floor(((e.clientY - r.top) / r.height) * H),
];
};
const paintAt = (e) => {
const [x, y] = toGrid(e);
sim.paint(x, y, material, brush);
};
canvas.addEventListener("pointerdown", (e) => {
painting = true;
const [x, y] = toGrid(e);
sim.paint(x, y, material, BRUSH);
canvas.setPointerCapture(e.pointerId);
paintAt(e);
});
canvas.addEventListener("pointermove", (e) => {
if (!painting) return;
const [x, y] = toGrid(e);
sim.paint(x, y, material, BRUSH);
paintAt(e);
});
window.addEventListener("pointerup", () => (painting = false));
canvas.addEventListener("pointerup", () => (painting = false));
canvas.addEventListener("pointercancel", () => (painting = false));

const fpsEl = document.getElementById("fps");
let frames = 0;
let last = performance.now();

function loop() {
sim.tick();

function blit() {
// Re-create the view every frame: memory.buffer is
// invalidated if WASM memory grows.
const pixels = new Uint8ClampedArray(
Expand All @@ -146,13 +223,23 @@ <h1>Sandboxed</h1>
W * H * 4,
);
ctx.putImageData(new ImageData(pixels, W, H), 0, 0);
}

function loop() {
if (!paused || pendingStep) {
sim.tick();
pendingStep = false;
} else {
sim.redraw();
}
blit();

frames++;
const now = performance.now();
if (now - last >= 500) {
fpsEl.textContent = `${Math.round(
(frames * 1000) / (now - last),
)} fps`;
fpsEl.textContent = paused
? "paused"
: `${Math.round((frames * 1000) / (now - last))} fps`;
frames = 0;
last = now;
}
Expand Down
28 changes: 28 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ impl Simulation {
/// Advance one tick and re-render into the frame buffer.
pub fn tick(&mut self) {
self.world.step();
self.redraw();
}

/// Re-render without stepping. Used while the UI is paused.
pub fn redraw(&mut self) {
renderer::draw(&self.world, &mut self.frame);
}

Expand Down Expand Up @@ -55,3 +60,26 @@ impl Simulation {
self.world = World::new(self.world.width, self.world.height);
}
}

#[cfg(test)]
mod tests {
use super::*;

fn frame_bytes(sim: &Simulation) -> Vec<u8> {
let n = sim.width() * sim.height() * 4;
// SAFETY: frame_ptr is sim.frame.as_ptr(); sim outlives this slice.
unsafe { std::slice::from_raw_parts(sim.frame_ptr(), n).to_vec() }
}

#[test]
fn redraw_does_not_advance_the_world() {
let mut sim = Simulation::new(4, 4);
sim.paint(1, 0, 1, 0);
sim.redraw();
let paused = frame_bytes(&sim);
sim.redraw();
assert_eq!(paused, frame_bytes(&sim));
sim.tick();
assert_ne!(paused, frame_bytes(&sim));
}
}
Loading