basin / commit
Add loading overlay with progress bar for large board generation
index.html | 33 ++++++++++++--- readme.md | 5 +++ src/game.ts | 71 +++++++++++++++++++++++-------- src/main.ts | 64 ++++++++++++++++++++++++++-- src/model/puzzle.ts | 42 ++++++++++++++++-- src/model/puzzle.worker.ts | 21 +++++++++ src/style.css | 103 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 308 insertions(+), 31 deletions(-) diff --git a/index.html b/index.html index 2a21fd6..4e1cae0 100644 --- a/index.html +++ b/index.html @@ -15,26 +15,41 @@ </header> <section id="config" class="config"> <label class="ctl"> - Size <span id="cfg-size-val" class="val"></span> + <span class="ctl-head"> + <span>Size <span id="cfg-size-val" class="val"></span></span> + <button type="button" class="lock" data-lock="size" aria-pressed="false">🔓</button> + </span> <input type="range" id="cfg-size" min="5" max="20" step="1" /> </label> <label class="ctl"> - Springs + <span class="ctl-head"> + <span>Springs</span> + <button type="button" class="lock" data-lock="springCount" aria-pressed="false">🔓</button> + </span> <input type="number" id="cfg-springs" min="1" max="6" step="1" /> </label> <fieldset class="ctl edges"> - <legend>Walls</legend> + <legend> + <span>Walls</span> + <button type="button" class="lock" data-lock="edges" aria-pressed="false">🔓</button> + </legend> <label><input type="checkbox" id="cfg-wall-n" /> N</label> <label><input type="checkbox" id="cfg-wall-e" /> E</label> <label><input type="checkbox" id="cfg-wall-s" /> S</label> <label><input type="checkbox" id="cfg-wall-w" /> W</label> </fieldset> <label class="ctl" title="Simulated erosion: how weathered the terrain is before you arrive. 0 is a pristine dome; higher values carve grooves and change the edge pattern."> - Aging <span id="cfg-erosion-val" class="val"></span> + <span class="ctl-head"> + <span>Aging <span id="cfg-erosion-val" class="val"></span></span> + <button type="button" class="lock" data-lock="erosion" aria-pressed="false">🔓</button> + </span> <input type="range" id="cfg-erosion" min="0" max="15" step="1" /> </label> <label class="ctl"> - Terrain + <span class="ctl-head"> + <span>Terrain</span> + <button type="button" class="lock" data-lock="terrainSeed" aria-pressed="false">🔓</button> + </span> <span class="seedrow"> <input type="text" @@ -68,6 +83,14 @@ <canvas id="board"></canvas> </main> </div> + <div id="loading" class="loading" aria-live="polite" aria-label="Generating board…"> + <div class="loading-box"> + <p class="loading-label">Generating…</p> + <div class="progress-track"> + <div id="progress-bar" class="progress-bar"></div> + </div> + </div> + </div> <script type="module" src="/src/main.ts"></script> </body> </html> diff --git a/readme.md b/readme.md index dd6eb3f..be31e3a 100644 --- a/readme.md +++ b/readme.md @@ -56,6 +56,11 @@ The code also lives in the URL as `?code=<code>`, so a link reproduces the exact board. **🎲 Random** rolls a whole new config; the 🎲 next to Terrain rerolls just the terrain while keeping the other settings. +Each axis also has a **lock** (🔓/🔒) toggle. Locking an axis pins it: **🎲 Random** +then rerolls every *other* setting while leaving the locked ones untouched — so you +can, say, freeze the size and walls and keep rolling only the terrain and springs. +Locks are a session convenience and aren't part of the shared code. + ## Develop ```bash diff --git a/src/game.ts b/src/game.ts index 5e15310..37f150b 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,53 +1,87 @@ -import { generatePuzzle, isSolutionSpring } from './model/puzzle'; import type { Config, Puzzle } from './model/puzzle'; +import { isSolutionSpring } from './model/puzzle'; import { simulateFlows } from './model/flow'; import type { Cell } from './model/flow'; import { Renderer } from './render/renderer'; +import type { WorkerRequest, WorkerResponse } from './model/puzzle.worker'; export class Game { - private puzzle!: Puzzle; - /** Locked springs found so far, in discovery order. */ + private puzzle: Puzzle | null = null; private found: Cell[] = []; - /** Current transient exploration tap (replaced by the next non-spring tap). */ private probe: Cell | null = null; private solved = false; private renderer: Renderer; + private worker!: Worker; + private seq = 0; + private pendingResolve: (() => void) | null = null; + constructor( private canvas: HTMLCanvasElement, private statusEl: HTMLElement, + private onProgress?: (fraction: number) => void, ) { this.renderer = new Renderer(canvas); canvas.addEventListener('click', (e) => this.onClick(e)); - window.addEventListener('resize', () => this.draw()); + window.addEventListener('resize', () => { if (this.puzzle) this.draw(); }); + this.spawnWorker(); } - /** Load a board from a config, clearing any progress. */ - load(config: Config): void { - this.puzzle = generatePuzzle(config); - this.found = []; - this.probe = null; - this.solved = false; - this.updateStatus(); - this.draw(); + private spawnWorker(): void { + this.worker = new Worker(new URL('./model/puzzle.worker.ts', import.meta.url), { + type: 'module', + }); + this.worker.addEventListener('message', (e: MessageEvent<WorkerResponse>) => { + this.onWorkerMessage(e.data); + }); + } + + /** Load a board from a config, clearing any progress. Returns when the board is ready. */ + load(config: Config): Promise<void> { + // Terminate any in-flight generation to avoid stale results. + this.worker.terminate(); + this.pendingResolve?.(); + this.pendingResolve = null; + this.spawnWorker(); + + const seq = ++this.seq; + this.worker.postMessage({ type: 'generate', seq, config } satisfies WorkerRequest); + + return new Promise((resolve) => { + this.pendingResolve = resolve; + }); + } + + private onWorkerMessage(data: WorkerResponse): void { + if (data.seq !== this.seq) return; + + if (data.type === 'progress') { + this.onProgress?.(data.value); + } else if (data.type === 'done') { + this.puzzle = data.puzzle; + this.found = []; + this.probe = null; + this.solved = false; + this.updateStatus(); + this.draw(); + this.pendingResolve?.(); + this.pendingResolve = null; + } } private onClick(e: MouseEvent): void { - if (this.solved) return; + if (!this.puzzle || this.solved) return; const rect = this.canvas.getBoundingClientRect(); const cell = this.renderer.pickCell(this.puzzle.hm, e.clientX - rect.left, e.clientY - rect.top); if (!cell) return; - // Found springs are locked (immutable): tapping one again does nothing. if (this.found.some((f) => f.x === cell.x && f.y === cell.y)) return; if (isSolutionSpring(this.puzzle, cell)) { - // Correct position — lock it in and clear the transient probe. this.found.push(cell); this.probe = null; if (this.found.length === this.puzzle.solution.length) this.solved = true; } else { - // Exploration only: one probe at a time, alongside the locked springs. this.probe = cell; } @@ -55,12 +89,12 @@ export class Game { this.draw(); } - /** Springs driving the currently-shown flow: locked + the live probe. */ private currentSprings(): Cell[] { return this.probe ? [...this.found, this.probe] : this.found; } private updateStatus(): void { + if (!this.puzzle) return; const n = this.puzzle.solution.length; const springs = n === 1 ? 'spring' : 'springs'; this.statusEl.textContent = this.solved @@ -69,6 +103,7 @@ export class Game { } private draw(): void { + if (!this.puzzle) return; const springs = this.currentSprings(); const flow = springs.length ? simulateFlows(this.puzzle.hm, springs) : null; this.renderer.render(this.puzzle.hm, flow, { diff --git a/src/main.ts b/src/main.ts index 40f9aad..5afee59 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import './style.css'; import logoUrl from '../water.png'; import { Game } from './game'; import { + SIZE_MAX, decodeConfig, encodeConfig, normalizeConfig, @@ -20,10 +21,23 @@ const $ = <T extends HTMLElement>(sel: string): T => document.querySelector<T>(s const canvas = $<HTMLCanvasElement>('#board'); const status = $<HTMLElement>('#status'); -const game = new Game(canvas, status); +const loadingEl = $<HTMLElement>('#loading'); +const progressBarEl = $<HTMLElement>('#progress-bar'); + +function setProgress(fraction: number): void { + progressBarEl.style.width = `${Math.round(fraction * 100)}%`; +} + +function showLoading(on: boolean): void { + loadingEl.classList.toggle('loading--visible', on); + if (on) setProgress(0); +} + +const game = new Game(canvas, status, setProgress); // Config controls. const sizeInput = $<HTMLInputElement>('#cfg-size'); +sizeInput.max = String(SIZE_MAX); const sizeVal = $<HTMLElement>('#cfg-size-val'); const springsInput = $<HTMLInputElement>('#cfg-springs'); const erosionInput = $<HTMLInputElement>('#cfg-erosion'); @@ -61,14 +75,56 @@ function syncControls(): void { } /** Adopt a new config: normalise it, rebuild the board, resync the UI. */ -function apply(next: Config): void { +async function apply(next: Config): Promise<void> { config = normalizeConfig(next); - game.load(config); syncControls(); + showLoading(true); + await game.load(config); + showLoading(false); } const edgeOf = (checked: boolean) => (checked ? 'wall' : 'ocean'); +// Which config axes are pinned against Randomize. Session-only: locks aren't part +// of a board (the code encodes the config, not what you froze while rolling). +type LockAxis = 'size' | 'springCount' | 'edges' | 'erosion' | 'terrainSeed'; +const locks: Record<LockAxis, boolean> = { + size: false, + springCount: false, + edges: false, + erosion: false, + terrainSeed: false, +}; + +/** Reflect a lock button's state into its icon, label, and its control's styling. */ +function syncLock(btn: HTMLButtonElement, on: boolean): void { + btn.textContent = on ? '🔒' : '🔓'; + btn.setAttribute('aria-pressed', String(on)); + btn.title = on ? 'Locked — Random won’t change this' : 'Lock this against Random'; + btn.closest('.ctl')?.classList.toggle('locked', on); +} + +for (const btn of document.querySelectorAll<HTMLButtonElement>('.lock')) { + const axis = btn.dataset.lock as LockAxis; + syncLock(btn, locks[axis]); + btn.addEventListener('click', () => { + locks[axis] = !locks[axis]; + syncLock(btn, locks[axis]); + }); +} + +/** A fresh random config, but every locked axis keeps its current value. */ +function randomizeUnlocked(): Config { + const r = randomConfig(); + return { + size: locks.size ? config.size : r.size, + springCount: locks.springCount ? config.springCount : r.springCount, + edges: locks.edges ? { ...config.edges } : r.edges, + erosion: locks.erosion ? config.erosion : r.erosion, + terrainSeed: locks.terrainSeed ? config.terrainSeed : r.terrainSeed, + }; +} + /** Human tag for an erosion level, so the slider reads as more than a number. */ function agingLabel(n: number): string { const word = n === 0 ? 'pristine' : n <= 4 ? 'weathered' : n <= 9 ? 'eroded' : 'dissected'; @@ -104,7 +160,7 @@ codeInput.addEventListener('change', () => { if (decoded) apply(decoded); else syncControls(); // reject: snap back to the current config }); -$<HTMLButtonElement>('#randomize').addEventListener('click', () => apply(randomConfig())); +$<HTMLButtonElement>('#randomize').addEventListener('click', () => apply(randomizeUnlocked())); // Start from a shared code if present, otherwise a fresh random board. const codeParam = new URLSearchParams(location.search).get('code'); diff --git a/src/model/puzzle.ts b/src/model/puzzle.ts index 50f42f3..e3230e6 100644 --- a/src/model/puzzle.ts +++ b/src/model/puzzle.ts @@ -28,7 +28,7 @@ export interface Config { } export const SIZE_MIN = 5; -export const SIZE_MAX = 20; +export const SIZE_MAX = 100; export const SPRINGS_MIN = 1; export const SPRINGS_MAX = 6; export const EROSION_MIN = 0; @@ -65,11 +65,16 @@ export interface Puzzle { * {@link isUniqueSolution}). Boards that can't be made unique fall back to the * richest set found, so generation never fails. */ -export function generatePuzzle(config: Config): Puzzle { +export function generatePuzzle( + config: Config, + onProgress?: (fraction: number) => void, +): Puzzle { + onProgress?.(0); const norm = normalizeConfig(config); const { size, springCount, edges, erosion, terrainSeed } = norm; const rng = mulberry32(terrainSeed); const hm = generateDome(size, size, rng, edges, erosion); + onProgress?.(0.1); // Candidates: interior (non-edge) land cells. After drainage resolution every // interior cell has height > 0 and a strictly-decreasing path to an ocean edge. @@ -84,13 +89,40 @@ export function generatePuzzle(config: Config): Puzzle { // one once as a bitmask over border-cell indices. Because wet sets (and hence // outflow) union across springs, a spring set's target signature is simply the // OR of its members' masks — which is what makes the uniqueness check cheap. - const sig = candidates.map((c) => outflowMask(simulateFlows(hm, [c]).outflow, size)); + const sigStart = performance.now(); + const sig: bigint[] = []; + for (let i = 0; i < candidates.length; i++) { + sig.push(outflowMask(simulateFlows(hm, [candidates[i]]).outflow, size)); + if ((i & 63) === 0) onProgress?.(0.1 + 0.1 * ((i + 1) / candidates.length)); + } + const sigMs = Math.max(1, performance.now() - sigStart); + + // countCovers (the uniqueness DFS) uses bigint arithmetic, which is empirically + // ~10× slower than the float32 array ops in simulateFlows. Use the measured sig + // rate to estimate how long the 128 uniqueness-check attempts will take, then + // give each phase its proportional share of the progress bar. + const BIGINT_FACTOR = 10; + const perSigMs = sigMs / Math.max(1, candidates.length); + const compatEst = Math.floor(candidates.length * 0.4); // ~40% of candidates fit any target + const BUDGET = 200_000; + const attemptsOpsEst = Math.min(BUDGET, Math.pow(compatEst, springCount) / springCount); + const ATTEMPTS = 128; + const perAttemptMs = (attemptsOpsEst * perSigMs * BIGINT_FACTOR) / Math.max(1, (size * size) / 2); + const attemptsEstMs = Math.max(1, ATTEMPTS * perAttemptMs); + + // Snap bar to sig's real proportional share, then fill the rest during attempts. + const totalEstMs = sigMs + attemptsEstMs; + const P_SIG_END = 0.1 + 0.88 * (sigMs / totalEstMs); + onProgress?.(P_SIG_END); const wanted = Math.max(3, springCount); // prefer a signature at least this wide let bestUnique: Candidate | null = null; let bestAny: Candidate | null = null; - for (let attempt = 0; attempt < 128; attempt++) { + const attemptsStart = performance.now(); + for (let attempt = 0; attempt < ATTEMPTS; attempt++) { + const elapsed = performance.now() - attemptsStart; + onProgress?.(P_SIG_END + (0.99 - P_SIG_END) * Math.min(0.98, elapsed / attemptsEstMs)); // Relax the spacing requirement if the board is too cramped to satisfy it. const minGap = attempt < 96 ? 3 : attempt < 112 ? 2 : 1; const idx = pickSpringIdx(candidates, springCount, rng, minGap); @@ -110,6 +142,7 @@ export function generatePuzzle(config: Config): Puzzle { if (richness >= wanted) break; // unique *and* rich enough — good to go } } + onProgress?.(0.99); // Prefer an unambiguous board; fall back to the richest set otherwise. `bestAny` // is always set: normalizeConfig guarantees at least `springCount` interior @@ -119,6 +152,7 @@ export function generatePuzzle(config: Config): Puzzle { const targetOutflow = simulateFlows(hm, chosen.solution).outflow; const target = borderSignature(targetOutflow, size, size); + onProgress?.(1); return { config: norm, hm, diff --git a/src/model/puzzle.worker.ts b/src/model/puzzle.worker.ts new file mode 100644 index 0000000..e1c103f --- /dev/null +++ b/src/model/puzzle.worker.ts @@ -0,0 +1,21 @@ +import { generatePuzzle } from './puzzle'; +import type { Config, Puzzle } from './puzzle'; + +export type WorkerRequest = { type: 'generate'; seq: number; config: Config }; +export type WorkerResponse = + | { type: 'progress'; seq: number; value: number } + | { type: 'done'; seq: number; puzzle: Puzzle }; + +self.addEventListener('message', (e: MessageEvent<WorkerRequest>) => { + const { type, seq, config } = e.data; + if (type !== 'generate') return; + + const puzzle = generatePuzzle(config, (value) => { + (self as unknown as Worker).postMessage({ type: 'progress', seq, value } satisfies WorkerResponse); + }); + + (self as unknown as Worker).postMessage( + { type: 'done', seq, puzzle } satisfies WorkerResponse, + [puzzle.hm.data.buffer, puzzle.target.buffer], + ); +}); diff --git a/src/style.css b/src/style.css index 3735dd7..1388a1c 100644 --- a/src/style.css +++ b/src/style.css @@ -101,6 +101,43 @@ header button { opacity: 0.85; } +/* Header row of a control: its label on the left, its lock toggle on the right. */ +.ctl-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +/* Small, unobtrusive icon button that pins a config axis against Randomize. */ +.lock { + padding: 0 3px; + background: none; + color: inherit; + font-size: 0.85rem; + line-height: 1.2; + border-radius: 4px; + opacity: 0.4; +} + +.lock:hover { + background: rgba(255, 255, 255, 0.08); + opacity: 0.85; +} + +.lock[aria-pressed='true'] { + opacity: 1; +} + +/* Locked axes read at full strength and get an accent rail, so it's obvious at a + glance which settings Randomize will leave untouched. */ +.ctl.locked { + opacity: 1; + border-left: 2px solid #2d6cdf; + margin-left: -8px; + padding-left: 6px; +} + .ctl .val { opacity: 0.6; font-variant-numeric: tabular-nums; @@ -157,7 +194,17 @@ header button { } .edges legend { + /* Full-width so the lock toggle can sit at the far right, beside a dimmed + "Walls" label (dimming lives on the label span, not the whole legend). */ + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; padding: 0 4px; + box-sizing: border-box; +} + +.edges legend > span { opacity: 0.6; } @@ -198,6 +245,62 @@ canvas { color: inherit; } +/* Loading overlay — covers the whole viewport while the worker generates a board. */ +.loading { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(11, 14, 20, 0.75); + backdrop-filter: blur(4px); + z-index: 100; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} + +.loading--visible { + opacity: 1; + pointer-events: all; +} + +.loading-box { + background: #1a2030; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 14px; + padding: 24px 32px; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + min-width: 220px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.loading-label { + margin: 0; + font-size: 0.95rem; + opacity: 0.8; + letter-spacing: 0.02em; +} + +.progress-track { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + width: 0%; + background: #2d6cdf; + border-radius: 3px; + transition: width 0.1s linear; +} + /* Narrow screens: fall back to the stacked layout, controls above the board. */ @media (max-width: 720px) { #app {