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 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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));
+ }
+}