From 7de9c4cd10e94beacd155940fa0cabab53ca06ce Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 16:38:00 -0400 Subject: [PATCH] feat: add brush slider, pause/step, and palette swatches Pause redraws without stepping so painted cells stay visible. Step advances one tick for debugging rules. --- README.md | 3 ++ index.html | 139 +++++++++++++++++++++++++++++++++++++++++++---------- src/lib.rs | 28 +++++++++++ 3 files changed, 144 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2af0d91..08dae89 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/index.html b/index.html index 035a1f5..6a71999 100644 --- a/index.html +++ b/index.html @@ -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; @@ -65,13 +85,34 @@

Sandboxed

-
- - - - - - +
+ + + + + + +
+
+ + +
@@ -83,7 +124,6 @@

Sandboxed

const W = 320; const H = 200; const SCALE = 3; - const BRUSH = 3; const wasm = await init(); @@ -96,6 +136,10 @@

Sandboxed

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) => { @@ -107,9 +151,40 @@

Sandboxed

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) => { @@ -119,25 +194,27 @@

Sandboxed

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( @@ -146,13 +223,23 @@

Sandboxed

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; } diff --git a/src/lib.rs b/src/lib.rs index 2c942cb..cf3f29e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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); } @@ -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 { + 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)); + } +}