clone

an offline sliding-block puzzle game in gleam and lustre, with an endless ladder of puzzles ranked by search

Carry the design systems across instead of describing them

Dami committed 9c4a2dd
The port had the shape of the app and none of its systems. A catalogue of
thirty-two hand-written levels stood in for an open-ended ladder; CSS class
names stood in for eight screens at five rulings; a hash of two indices stood
in for a seeded look; a fixed palette stood in for four themes with pools. The
survey worker swept a state space and then threw the distance field away,
answering one question with it. None of that is a smaller version of the
mockup. It is a different app that resembles it in a screenshot.

So the systems are now in Gleam, and the two the platform genuinely cannot
express are in FFI behind a Gleam API rather than absent:

- prng.gleam is mulberry32 and FNV-1a, checked against the reference to the
  bit. What is in FFI is the 32-bit multiply and the logical shift, because a
  Gleam Int here is a double. Streams are threaded, so determinism is
  structural rather than conventional.
- render_ffi.mjs is the pixel pipeline: compose, diffuse or threshold with true
  area coverage, reverse the goal block's mark, reprint the tints and their
  second plate, paint the ground into the holes, blit. It is handed a threshold
  table and a cell size and has never heard of a theme, a family or a rung.

Everything else came across as Gleam: the eight packings, the seven ground
stencils, the eight screens and their rulings with fit_ruling's coarseness and
register rules, the four themes and combo_for, the depth schedule, the XP curve
and its exact inverse, trace_stats, the versioned save, the layout search over
both arrangements at every upscale, and the seven painters as pure functions
from state to scene operations.

The ladder is open-ended again. The worker keeps a sample of states at every
depth and measures candidates with an eps-greedy walker; which depth a rung
asks for, how wide the window is and every seed it runs under are decided in
Gleam and arrive in the message. TO GOAL is an exact distance after every legal
move, and the record screen's ridge is measured rather than inferred.

window.klotski.solve() walks the board through the game's own input path, which
is how a screenshot of a real run gets taken, and how the flows above were
checked in a browser.
commit
9c4a2ddfcc250b3f6771b877b3b5773ac4166d92
parent
77d6d0748cb520b2ee8c2c85dc3b5a3c3692eb01
author
Dami <dami.etoile@gmail.com>
42 files changed, +9702 -2211
MREADME.md114+-
Massets/bootstrap.js62+-
Massets/solver-worker.js474++++-
Massets/styles.css498++---
Massets/sw.js2+-
Mgleam.toml3+-
Mmanifest.toml1+
Msrc/klotski.gleam3113+++++++++++++++++++-----------
Msrc/klotski/browser.gleam340+++-
Msrc/klotski/browser_ffi.mjs431+++-
Dsrc/klotski/catalog.gleam109-
Asrc/klotski/difficulty.gleam84+
Msrc/klotski/domain.gleam6+
Asrc/klotski/families.gleam143+
Asrc/klotski/ground.gleam156++
Asrc/klotski/layout.gleam585++++++
Asrc/klotski/paint.gleam1545+++++++++++++++
Dsrc/klotski/persistence.gleam349---
Asrc/klotski/prefs.gleam26+
Asrc/klotski/prng.gleam126+
Asrc/klotski/prng_ffi.mjs34+
Asrc/klotski/progress.gleam130+
Asrc/klotski/render.gleam76+
Asrc/klotski/render_ffi.mjs808++++++++
Asrc/klotski/scene.gleam287+++
Asrc/klotski/shading.gleam386++++
Asrc/klotski/shading_ffi.mjs7+
Asrc/klotski/store.gleam568+++++
Asrc/klotski/theme.gleam403++++
Asrc/klotski/typeface.gleam64+
Asrc/klotski/typeface_ffi.mjs24+
Asrc/klotski/walk.gleam174++
Asrc/klotski_ffi.mjs7+
Mtest/klotski/domain_test.gleam78+-
Atest/klotski/layout_test.gleam119+
Dtest/klotski/persistence_test.gleam20-
Atest/klotski/prng_test.gleam29+
Atest/klotski/progress_test.gleam98+
Atest/klotski/shading_test.gleam153+
Atest/klotski/store_test.gleam59+
Atest/klotski/theme_test.gleam147+
Atest/klotski/walk_test.gleam75+
M README.md +80 -34
@@ -2,15 +2,16 @@
22 33 An installable, offline sliding-block puzzle game built with Gleam and Lustre.44 5-The production application now lives at the repository root. `mockup/` is locked as a working6-proof of concept and design record: it remains playable, but production code does not import it7-or extend it. The port preserves its interaction model and visual language while treating its8-implementation and forward-looking technical claims as historical context rather than contracts.5+The production application lives at the repository root. `mockup/` is locked as a working proof of6+concept and design record: it remains playable, but production code does not import it or extend7+it. The port carries the proof of concept's interaction model, visual language and design systems8+across intact — the same tables, the same seeds, the same arithmetic — and changes the9+*architecture* where a Lustre application wants a different boundary.910 1011 ## Production app1112 12-The app is a client-side Lustre application targeting JavaScript. It requires no backend and no13-network connection after the first load.13+A client-side Lustre application targeting JavaScript. No backend, and no network after the first14+load.1415 1516 ```sh1617 # Development server at http://localhost:1234
@@ -24,34 +25,79 @@ gleam test --target javascript
2425 gleam run -m lustre/dev build2526 ```2627 27-The production architecture keeps the mockup's UI/UX as its acceptance reference while changing28-the implementation where a Lustre application benefits from a different boundary:29-30-- `src/klotski/domain.gleam` is the pure puzzle core. Movement, collision, undo, and goal checks31- have no browser or rendering dependencies.32-- `src/klotski.gleam` is the Lustre Model–View–Update application. Screens, settings, pointer and33- keyboard input, progress, and effects are explicit messages and model transitions.34-- `src/klotski/catalog.gleam` contains 32 published boards across the eight piece families. The35- family graph is surveyed on demand by `assets/solver-worker.js`, off the UI thread, so `TO GOAL`36- remains an exact distance after every legal move and the survey screen stays responsive.37-- `src/klotski/persistence.gleam` owns versioned local progress and preferences. The small38- `src/klotski/browser_ffi.mjs` adapter owns browser-only capabilities: local storage, the survey39- worker, install prompting, tones, and haptics.40-- `assets/` contains the UI stylesheet and PWA shell. The service worker precaches the shell and41- compiled Lustre bundle—including the survey worker—so an installed app and a previously loaded42- page can cold-reload and calculate exact distances offline.43-- The print treatments are CSS screens applied to declarative block elements. The port keeps the44- one-bit/print character without coupling Lustre updates to a full-frame imperative canvas and45- pixel diffusion pipeline. "Atkinson" is therefore a visual treatment in production, not a claim46- that the browser is running the mockup's exact error-diffusion compositor.47-- The proof of concept's open-ended ladder is treated as a product-direction experiment rather48- than an architectural requirement. This production version currently publishes four curated49- stages per family; its minimums and live remaining distances are nevertheless solver-derived.50-51-The UI supports direct dragging, the mockup's single-exit tap affordance, arrow-key movement,52-undo/reset, exact hints, eight puzzle families, four published stages per family, the survey/card/53-play/solved/record flow, install prompting, versioned local records, palettes, screen treatments,54-reduced-motion behavior, tones, and haptics.28+### What is Gleam, and what is not29+30+The rule is that every *decision* is Gleam and every *capability* is not. A capability is something31+the platform owns and no expression can stand in for: a key-value store, an oscillator, a vibration32+motor, a media query, a frame callback, a worker, and the per-pixel loop of a whole-image threshold.33+A decision is which board a rung serves, what it is printed in, how hard it is, where it goes, and34+what a run was made of. Nothing in the second list is in a `.mjs` file, and nothing in the first is35+in a `.gleam` one.36+37+Two of those capabilities are load-bearing enough to name, because they are the two places the38+proof of concept's design systems have to reach across the boundary rather than living entirely39+inside it:40+41+**The generators.** `klotski/prng.gleam` is mulberry32 and FNV-1a, written out in Gleam and checked42+against the proof of concept's output to the bit. What is in FFI is the 32-bit wrap-around multiply43+and the logical shift, because a Gleam `Int` on this target is a double and cannot express either.44+The streams themselves are threaded rather than closed over, which makes their determinism45+structural instead of conventional: a rung is the same board and the same look because the same46+pair of numbers went in, and there is no hidden state that could have made it otherwise.47+48+**The pixel pipeline.** `klotski/render_ffi.mjs` composes the greyscale scene, error-diffuses or49+thresholds it, reverses the goal block's mark, reprints the tinted regions, paints the ground into50+the holes nobody drew over, and blits the sprites and the type. A one-bit picture is a whole-image51+operation — diffusion pays its error forward across the entire buffer — and there is no declarative52+way to say that. So the canvas is a managed side effect: Lustre owns the elements and the state, and53+the pipeline owns the pixels inside them. What crosses the boundary is `klotski/scene.gleam`, a54+list of drawing operations in which every choice has already been made. The renderer is handed a55+threshold table and a cell size; it has never heard of a theme, a family or a rung.56+57+### The modules58+59+- `klotski/families.gleam`, `ground.gleam`, `shading.gleam`, `theme.gleam` — the tables. Eight piece60+ sets, seven backdrop stencils, eight screen shapes at five rulings, four themes with pools for61+ each, and `combo_for`, which draws one of each seeded on the family and the rung. About twenty62+ written-down numbers per theme, and a couple of hundred looks out of them.63+- `klotski/progress.gleam`, `difficulty.gleam` — the ladder and what difficulty means. The depth64+ schedule, the XP curve and its exact inverse, how the walker's four measurements are weighed, and65+ where the five marks fall.66+- `klotski/walk.gleam` — what a run was made of. Setbacks, idle moves, detours and the depth of the67+ worst one, which are what the record screen is actually about.68+- `klotski/layout.gleam` — the arrangement, chosen rather than calculated. Both arrangements at69+ every upscale are built and costed, and the winner is whichever gives the largest board on whole70+ device pixels.71+- `klotski/paint.gleam` — the seven screens, as pure functions from layout and state to scene72+ operations. Nothing here reaches for a canvas, a document or a clock.73+- `klotski/store.gleam` — progression in a versioned envelope with a monotonic counter, lifetime74+ totals counted as they happen, day streaks, and a capped log.75+- `klotski.gleam` — the Lustre application. Screens, settings, pointer and keyboard input, the76+ quantised clocks, and the effects.77+- `assets/solver-worker.js` — traversal, and only traversal. It sweeps a family's state space,78+ keeps a sample of states at every depth, and measures a candidate with an eps-greedy walker. Which79+ depth a rung asks for, how wide the window is, where in the band it starts and every seed it runs80+ under are decided in Gleam and arrive in the message. It never judges how hard a board is; it81+ measures one and says what it found.82+83+### The ladder is open-ended84+85+There is no list of levels anywhere, because a family carries a state space rather than a set of86+plates. One breadth-first sweep outward from the seated goal labels every reachable configuration87+with its exact distance, so a rung is nothing more than "a board at depth D" — and `TO GOAL` is an88+exact distance after every legal move, not an estimate. Rung 400 is not deeper than rung 40; it is a89+different board that is just as deep, and there are thousands of those.90+91+Both halves of the loop hold: motion is quantised to the shading period so the pattern never lands92+out of phase, the block under your thumb is shaded once into a sprite and carried so nothing boils93+while it slides, and the frame is composed only when a stepped value moves rather than when time94+passes.95+96+The UI supports direct dragging, the single-exit tap affordance, arrow-key movement, undo and reset,97+three hint levels, eight piece families, the survey/atlas/card/play/record flow, install prompting,98+versioned local records, four palettes, eight screens at five rulings, reduced-motion behaviour,99+tones and haptics. `window.klotski.solve()` walks the board down the distance field through the100+game's own input path, which is how a screenshot of a real run gets taken.55101 56102 ## Offline PWA57103
M assets/bootstrap.js +57 -5
@@ -1,5 +1,57 @@
1-if ("serviceWorker" in navigator) {2- window.addEventListener("load", () => {3- navigator.serviceWorker.register("/sw.js").catch(() => {});4- });5-}1+// The two values per theme, and nothing else.2+//3+// The stylesheet has to declare *some* default, and the app cannot run before4+// first paint, so a saved dark theme would show a full frame of white before the5+// palette reached it. Survivable on a tinted paper; on the black theme it is a6+// flashbulb, which is the one thing a black theme exists to avoid.7+//8+// This is the smallest thing that can run early enough. Kept in step with9+// theme.gleam by hand — it is four pairs, and the alternative is blocking paint10+// on a module graph.11+12+(function () {13+ // The picture covers the whole safe area, which it can only do once the page14+ // is told to lay out under the hardware's cutouts — and the board owns every15+ // gesture, so the browser must not be offered a pinch. Written here rather16+ // than in the generated head because the generator owns that tag.17+ var viewport = document.querySelector('meta[name="viewport"]');18+ if (viewport) {19+ viewport.setAttribute(20+ "content",21+ "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover",22+ );23+ }24+25+ var INK = {26+ macintosh: "#2B2B22",27+ paper: "#2E3037",28+ night: "#DCE4E2",29+ amoled: "#DCF2FF",30+ };31+ var PAPER = {32+ macintosh: "#E8F4F2",33+ paper: "#EBE5CE",34+ night: "#1C2028",35+ amoled: "#000000",36+ };37+ try {38+ var raw = JSON.parse(localStorage.getItem("klotski.save"));39+ var id = raw && raw.payload && raw.payload.palette;40+ // AUTO is stored as AUTO and resolved against the system, which is a41+ // question this can ask without loading anything.42+ if (id === "auto") {43+ id = window.matchMedia("(prefers-color-scheme: dark)").matches44+ ? "night"45+ : "macintosh";46+ }47+ if (!PAPER[id]) return;48+ var style = document.documentElement.style;49+ style.setProperty("--ink", INK[id]);50+ style.setProperty("--paper", PAPER[id]);51+ style.setProperty("--ground", PAPER[id]);52+ var meta = document.querySelector('meta[name="theme-color"]');53+ if (meta) meta.setAttribute("content", PAPER[id]);54+ } catch (e) {55+ // Private mode, or nothing saved yet: the default stands.56+ }57+})();
M assets/solver-worker.js +394 -80
@@ -1,157 +1,471 @@
1+// The sweep, and everything that has to stand next to a distance field.2+//3+// Sliding-block moves are reversible, so the configuration graph is undirected.4+// That single fact is what makes generation cheap: a breadth-first search5+// *outward from the goal* labels every reachable configuration with its exact6+// distance to a solved board, and every state sitting at depth k is, by7+// construction, a puzzle whose optimal solution is exactly k unit steps. One8+// sweep per family yields the family's whole difficulty-stratified catalogue at9+// once — which is what makes an open-ended ladder possible from a single pass.10+//11+// Pass 1 discovers the component the family's packing lives in. Pass 2 is a12+// multi-source BFS from every seated-goal state in that component.13+//14+// This runs off the UI thread because it has to: the classic board is about 26k15+// states and the deepest family an order more, and a survey screen that cannot16+// repaint is a frozen app rather than a paced one.17+//18+// What is *here* is traversal. What is not here is policy: which depth a rung19+// asks for, how the four measurements are weighed, and every seed used along20+// the way are decided in Gleam and arrive in the message. This worker never21+// decides how hard a board is; it measures one and says what it found.22+123 const SHAPE_W = [0, 1, 1, 2, 2];224 const SHAPE_H = [0, 1, 2, 1, 2];3-const DIRS = [[0, -1], [0, 1], [-1, 0], [1, 0]];25+const DIRS = [26+ [0, -1],27+ [0, 1],28+ [-1, 0],29+ [1, 0],30+];31+32+// A configuration is stored as the grid of *shape classes*, not of piece33+// identities, because two pieces of the same shape are interchangeable. Packing34+// those cells in base five gives a plain number that is both an exact key and35+// its own hash. A key has to stay under 2^53, and 5^22 fits where 5^23 does36+// not, so 22 cells is the ceiling — which is what bounds the board sizes on37+// offer.38+const CAP = 400000;39+const BUCKET_MAX = 96;40+const ROLL_CAP = 2500;41+442 const surveys = new Map();43+const pending = new Map();544 6-function board(width, height) {45+function makeBoard(width, height) {746 const n = width * height;847 return { width, height, n, scratch: new Uint8Array(n), seen: new Uint8Array(n) };948 }1049 1150 function encode(b, grid) {1251 let key = 0;13- for (let index = b.n - 1; index >= 0; index--) key = key * 5 + grid[index];52+ for (let i = b.n - 1; i >= 0; i--) key = key * 5 + grid[i];1453 return key;1554 }1655 17-function decode(b, key, output) {18- for (let index = 0; index < b.n; index++) {19- const code = key % 5;20- output[index] = code;21- key = (key - code) / 5;56+function decode(b, key, out) {57+ for (let i = 0; i < b.n; i++) {58+ const r = key % 5;59+ out[i] = r;60+ key = (key - r) / 5;2261 }23- return output;62+ return out;63+}64+65+function gridOf(b, key) {66+ return Array.from(decode(b, key, new Uint8Array(b.n)));2467 }2568 26-function piecesOf(b, grid, output) {27- output.length = 0;69+// Row-major scanning reaches a piece's top-left corner first, so the first70+// unclaimed cell of each piece names it.71+function piecesOf(b, grid, out) {72+ out.length = 0;2873 b.seen.fill(0);29- for (let y = 0; y < b.height; y++) for (let x = 0; x < b.width; x++) {30- const index = y * b.width + x;31- const code = grid[index];32- if (!code || b.seen[index]) continue;33- const width = SHAPE_W[code], height = SHAPE_H[code];34- for (let dy = 0; dy < height; dy++) for (let dx = 0; dx < width; dx++) {35- b.seen[(y + dy) * b.width + x + dx] = 1;74+ for (let y = 0; y < b.height; y++) {75+ for (let x = 0; x < b.width; x++) {76+ const i = y * b.width + x;77+ const c = grid[i];78+ if (!c || b.seen[i]) continue;79+ const w = SHAPE_W[c];80+ const h = SHAPE_H[c];81+ for (let dy = 0; dy < h; dy++) {82+ for (let dx = 0; dx < w; dx++) b.seen[(y + dy) * b.width + x + dx] = 1;83+ }84+ out.push({ x, y, w, h, c });3685 }37- output.push({ x, y, width, height, code });3886 }39- return output;87+ return out;4088 }4189 42-function canMove(b, grid, piece, dx, dy) {43- const nx = piece.x + dx, ny = piece.y + dy;44- if (nx < 0 || ny < 0 || nx + piece.width > b.width || ny + piece.height > b.height) return false;45- for (let y = 0; y < piece.height; y++) for (let x = 0; x < piece.width; x++) {46- const cx = nx + x, cy = ny + y;47- if (cx >= piece.x && cx < piece.x + piece.width && cy >= piece.y && cy < piece.y + piece.height) continue;48- if (grid[cy * b.width + cx]) return false;90+function canMove(b, grid, p, dx, dy) {91+ const nx = p.x + dx;92+ const ny = p.y + dy;93+ if (nx < 0 || ny < 0 || nx + p.w > b.width || ny + p.h > b.height) return false;94+ for (let y = 0; y < p.h; y++) {95+ for (let x = 0; x < p.w; x++) {96+ const cx = nx + x;97+ const cy = ny + y;98+ // Still self-covered: a piece never collides with the cells it is itself99+ // vacating.100+ if (cx >= p.x && cx < p.x + p.w && cy >= p.y && cy < p.y + p.h) continue;101+ if (grid[cy * b.width + cx]) return false;102+ }49103 }50104 return true;51105 }52106 53-function applyMove(b, grid, piece, dx, dy) {54- for (let y = 0; y < piece.height; y++) for (let x = 0; x < piece.width; x++) {55- grid[(piece.y + y) * b.width + piece.x + x] = 0;107+function applyMove(b, grid, p, dx, dy) {108+ for (let y = 0; y < p.h; y++) {109+ for (let x = 0; x < p.w; x++) grid[(p.y + y) * b.width + p.x + x] = 0;56110 }57- for (let y = 0; y < piece.height; y++) for (let x = 0; x < piece.width; x++) {58- grid[(piece.y + dy + y) * b.width + piece.x + dx + x] = piece.code;111+ for (let y = 0; y < p.h; y++) {112+ for (let x = 0; x < p.w; x++) {113+ grid[(p.y + dy + y) * b.width + p.x + dx + x] = p.c;114+ }59115 }60116 }61117 62-function neighbours(b, key, output, pieces) {63- output.length = 0;118+// Unit-step neighbours. One cell, one direction — the convention the whole119+// distance field is measured in.120+function neighbours(b, key, out, pieceBuffer) {121+ out.length = 0;64122 const grid = decode(b, key, b.scratch);65- piecesOf(b, grid, pieces);66- for (const piece of pieces) for (const [dx, dy] of DIRS) {67- if (!canMove(b, grid, piece, dx, dy)) continue;68- applyMove(b, grid, piece, dx, dy);69- output.push(encode(b, grid));70- applyMove(b, grid, { ...piece, x: piece.x + dx, y: piece.y + dy }, -dx, -dy);123+ const pieces = piecesOf(b, grid, pieceBuffer);124+ for (const p of pieces) {125+ for (const [dx, dy] of DIRS) {126+ if (!canMove(b, grid, p, dx, dy)) continue;127+ applyMove(b, grid, p, dx, dy);128+ out.push(encode(b, grid));129+ applyMove(b, grid, { x: p.x + dx, y: p.y + dy, w: p.w, h: p.h, c: p.c }, -dx, -dy);130+ }71131 }72- return output;132+ return out;73133 }74134 75135 function seated(b, key, goalX, goalY) {76- const grid = decode(b, key, b.scratch);77- return grid[goalY * b.width + goalX] === 478- && grid[goalY * b.width + goalX + 1] === 479- && grid[(goalY + 1) * b.width + goalX] === 480- && grid[(goalY + 1) * b.width + goalX + 1] === 4;136+ const g = decode(b, key, b.scratch);137+ return (138+ g[goalY * b.width + goalX] === 4 &&139+ g[goalY * b.width + goalX + 1] === 4 &&140+ g[(goalY + 1) * b.width + goalX] === 4 &&141+ g[(goalY + 1) * b.width + goalX + 1] === 4142+ );143+}144+145+function goalXY(b, key) {146+ const g = decode(b, key, b.scratch);147+ for (let y = 0; y < b.height - 1; y++) {148+ for (let x = 0; x < b.width - 1; x++) {149+ if (150+ g[y * b.width + x] === 4 &&151+ g[y * b.width + x + 1] === 4 &&152+ g[(y + 1) * b.width + x] === 4 &&153+ g[(y + 1) * b.width + x + 1] === 4154+ ) {155+ return { x, y };156+ }157+ }158+ }159+ return null;81160 }82161 162+// The same generator the rest of the app is seeded by. It is here rather than163+// imported because a worker is its own module graph, and it takes its seeds164+// from the message: every number it produces was decided upstream.165+function mulberry(a) {166+ return function () {167+ a |= 0;168+ a = (a + 0x6d2b79f5) | 0;169+ let t = Math.imul(a ^ (a >>> 15), 1 | a);170+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;171+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;172+ };173+}174+175+// ------------------------------------------------------------------- sweep176+83177 function runSurvey(message) {84- const b = board(message.width, message.height);85- const start = encode(b, Uint8Array.from(message.solvedGrid));178+ const b = makeBoard(message.width, message.height);179+ const start = encode(b, Uint8Array.from(message.grid));86180 const seen = new Set([start]);87181 const dist = new Map();88- const neighboursBuffer = [], piecesBuffer = [];89- let queue = [start], head = 0, phase = 1, eccentricity = 0;90- let lastReport = 0;182+ // depth -> a sample of states at that depth, and how many sit there.183+ const buckets = [];184+ const widths = [];185+ const nb = [];186+ const pieceBuffer = [];187+188+ let queue = [start];189+ let head = 0;190+ let phase = 1;191+ let ecc = 0;192+ let reported = 0;91193 92194 const tick = () => {93195 const deadline = performance.now() + 10;196+94197 if (phase === 1) {95198 while (head < queue.length && performance.now() < deadline) {96199 const key = queue[head++];97- neighbours(b, key, neighboursBuffer, piecesBuffer);98- for (const next of neighboursBuffer) if (!seen.has(next)) {99- seen.add(next);100- queue.push(next);200+ neighbours(b, key, nb, pieceBuffer);201+ for (const next of nb) {202+ if (!seen.has(next)) {203+ seen.add(next);204+ queue.push(next);205+ }101206 }207+ if (seen.size > CAP) break;102208 }103- if (head >= queue.length) {209+ if (head >= queue.length || seen.size > CAP) {104210 queue = [];105211 head = 0;106- for (const key of seen) if (seated(b, key, message.goalX, message.goalY)) {107- dist.set(key, 0);108- queue.push(key);212+ for (const key of seen) {213+ if (seated(b, key, message.goalX, message.goalY)) {214+ dist.set(key, 0);215+ queue.push(key);216+ }109217 }218+ buckets[0] = queue.slice(0, BUCKET_MAX);219+ widths[0] = queue.length;110220 phase = 2;111221 }112222 } else {113223 while (head < queue.length && performance.now() < deadline) {114224 const key = queue[head++];115225 const depth = dist.get(key) + 1;116- neighbours(b, key, neighboursBuffer, piecesBuffer);117- for (const next of neighboursBuffer) if (seen.has(next) && !dist.has(next)) {226+ neighbours(b, key, nb, pieceBuffer);227+ for (const next of nb) {228+ if (dist.has(next) || !seen.has(next)) continue;118229 dist.set(next, depth);119230 queue.push(next);120- if (depth > eccentricity) eccentricity = depth;231+ if (depth > ecc) ecc = depth;232+ widths[depth] = (widths[depth] || 0) + 1;233+ const bucket = buckets[depth] || (buckets[depth] = []);234+ if (bucket.length < BUCKET_MAX) bucket.push(next);121235 }122236 }123237 if (head >= queue.length) {124- surveys.set(message.familyId, { dist, eccentricity, count: seen.size });238+ const survey = { board: b, dist, buckets, widths, ecc, count: seen.size, start };239+ surveys.set(message.familyId, survey);125240 self.postMessage({126- type: "ready",241+ type: "surveyed",127242 familyId: message.familyId,128- distance: dist.get(message.startState) ?? 0,129- eccentricity,243+ ecc,130244 count: seen.size,131245 });246+ drain(message.familyId);132247 return;133248 }134249 }135- if (performance.now() - lastReport > 50) {136- lastReport = performance.now();137- self.postMessage({ type: "progress", familyId: message.familyId, count: phase === 1 ? seen.size : dist.size, phase });250+251+ // The count is a progress bar, not decoration: hiding it would leave a252+ // still screen during a second and a half of work.253+ if (performance.now() - reported > 50) {254+ reported = performance.now();255+ self.postMessage({256+ type: "sweeping",257+ familyId: message.familyId,258+ count: phase === 1 ? seen.size : dist.size,259+ total: seen.size,260+ phase,261+ });138262 }139263 setTimeout(tick, 0);140264 };265+141266 tick();142267 }143268 144-self.addEventListener("message", ({ data }) => {145- if (data.type === "survey") {146- const cached = surveys.get(data.familyId);147- if (cached) {148- self.postMessage({ type: "ready", familyId: data.familyId, distance: cached.dist.get(data.startState) ?? 0, eccentricity: cached.eccentricity, count: cached.count });149- } else {150- runSurvey(data);269+// --------------------------------------------------------------- the ladder270+//271+// The band a rung draws from, and a measurement of every candidate in it. Which272+// depth was asked for, how wide the window is and where it starts were all273+// decided upstream; this only knows how to reach into the field and walk it.274+275+function candidatesFor(survey, message) {276+ const { buckets, ecc } = survey;277+ const want = message.want;278+ const pool = [];279+ const take = (d) => {280+ if (d < 2 || d > ecc) return;281+ for (const state of buckets[d] || []) pool.push(state);282+ };283+284+ if (message.ceiling) {285+ // At the ceiling, draw from the deep band rather than the single deepest286+ // depth. States at the exact maximum distance are rare — often a handful in287+ // a graph of eighty thousand — so insisting on them would serve the same two288+ // boards forever. A few steps of slack buys real variety and costs nothing289+ // anyone can feel.290+ for (let d = ecc; d >= ecc - 3; d--) take(d);291+ } else {292+ // Take the wanted depth alone if it has anything in it, so a rung's board is293+ // exactly as long as the ladder says. Widening is a fallback for a thin band294+ // near the ceiling, not a way to make the pool bigger — mixing depths and295+ // then picking on score quietly serves a shallower board than advertised.296+ take(want);297+ }298+ for (let spread = 1; spread <= 6 && !pool.length; spread++) {299+ take(want - spread);300+ take(want + spread);301+ }302+ if (!pool.length) for (let d = ecc; d >= 1 && !pool.length; d--) take(d);303+ if (!pool.length) return [];304+305+ // Past the ceiling the band is the same every time, so rotate through it —306+ // otherwise every rung from here on would serve the identical board.307+ const window = Math.min(message.window, pool.length);308+ const offset = Math.abs(message.offset) % pool.length;309+ const rng = mulberry(message.seed);310+ const out = [];311+ for (let i = 0; i < window; i++) {312+ const state = pool[(offset + i) % pool.length];313+ out.push(analyse(survey, state, ((rng() * 1e6) | 0) + i, message));314+ }315+ return out;316+}317+318+// Expected steps for an eps-greedy walker to reach the goal, plus the shape of319+// one shortest path. Both are properties of the state space rather than of the320+// solution, which is the whole point: machine difficulty is not human321+// difficulty, and a long solution down a wide, well-signposted corridor stays322+// easy while a short one that makes you retreat does not.323+function analyse(survey, state, seed, message) {324+ const b = survey.board;325+ const dist = survey.dist;326+ const rng = mulberry(seed);327+ const nb = [];328+ const pieceBuffer = [];329+ const optimal = dist.get(state) ?? 0;330+331+ let total = 0;332+ for (let roll = 0; roll < message.rollouts; roll++) {333+ let s = state;334+ let steps = 0;335+ while (dist.get(s) > 0 && steps < ROLL_CAP) {336+ neighbours(b, s, nb, pieceBuffer);337+ if (!nb.length) break;338+ let pick;339+ if (rng() < message.stray) {340+ pick = nb[(rng() * nb.length) | 0];341+ } else {342+ const here = dist.get(s);343+ let downhill = 0;344+ for (const next of nb) if (dist.get(next) < here) downhill++;345+ if (downhill) {346+ let j = (rng() * downhill) | 0;347+ for (const next of nb) {348+ if (dist.get(next) < here && j-- === 0) {349+ pick = next;350+ break;351+ }352+ }353+ } else {354+ pick = nb[(rng() * nb.length) | 0];355+ }356+ }357+ s = pick;358+ steps++;359+ }360+ total += steps;361+ }362+ const wander = total / Math.max(1, message.rollouts);363+364+ // One shortest path, walked greedily down the distance field, and the count of365+ // counter-intuitive steps along it — moves that carry the goal block *away*366+ // from its exit, which is the move nobody finds.367+ const path = [state];368+ let s = state;369+ let guard = 0;370+ while (dist.get(s) > 0 && guard++ < 4000) {371+ neighbours(b, s, nb, pieceBuffer);372+ const here = dist.get(s);373+ const next = nb.find((k) => dist.get(k) === here - 1);374+ if (next === undefined) break;375+ s = next;376+ path.push(s);377+ }378+379+ let retreats = 0;380+ let branch = 0;381+ for (let i = 0; i < path.length; i++) {382+ neighbours(b, path[i], nb, pieceBuffer);383+ branch += nb.length;384+ if (!i) continue;385+ const was = goalXY(b, path[i - 1]);386+ const now = goalXY(b, path[i]);387+ if (!was || !now) continue;388+ const before = Math.abs(was.x - message.goalX) + Math.abs(was.y - message.goalY);389+ const after = Math.abs(now.x - message.goalX) + Math.abs(now.y - message.goalY);390+ if (after > before) retreats++;391+ }392+ branch /= Math.max(1, path.length);393+394+ return { state, grid: gridOf(b, state), optimal, wander, retreats, branch };395+}396+397+// A survey may not have finished when a screen asks for a board. Queue the398+// question rather than answering it wrongly.399+function drain(familyId) {400+ const waiting = pending.get(familyId);401+ if (!waiting) return;402+ pending.delete(familyId);403+ for (const message of waiting) handle(message);404+}405+406+function defer(message) {407+ const waiting = pending.get(message.familyId) || [];408+ waiting.push(message);409+ pending.set(message.familyId, waiting);410+}411+412+function handle(message) {413+ if (message.type === "survey") {414+ const known = surveys.get(message.familyId);415+ if (known) {416+ self.postMessage({417+ type: "surveyed",418+ familyId: message.familyId,419+ ecc: known.ecc,420+ count: known.count,421+ });422+ return;423+ }424+ // Already sweeping: queue the question rather than starting a second sweep425+ // of the same graph. It is answered the moment the first one lands.426+ if (pending.has(message.familyId)) {427+ defer(message);428+ return;151429 }430+ pending.set(message.familyId, []);431+ runSurvey(message);432+ return;152433 }153- if (data.type === "distance") {154- const survey = surveys.get(data.familyId);155- self.postMessage({ type: "distance", requestId: data.requestId, state: data.state, distance: survey?.dist.get(data.state) ?? 0 });434+435+ const survey = surveys.get(message.familyId);436+ if (!survey) {437+ defer(message);438+ return;156439 }157-});440+441+ if (message.type === "puzzle") {442+ self.postMessage({443+ type: "puzzled",444+ familyId: message.familyId,445+ rung: message.rung,446+ candidates: candidatesFor(survey, message),447+ });448+ }449+450+ // The harness's question: how far is each of these, with no claim that they451+ // are a run. Kept apart from "distances" so a probe can never be spliced into452+ // a trace.453+ if (message.type === "probe") {454+ self.postMessage({455+ type: "probed",456+ familyId: message.familyId,457+ distances: message.states.map((state) => survey.dist.get(state) ?? -1),458+ });459+ }460+461+ if (message.type === "distances") {462+ self.postMessage({463+ type: "measured",464+ familyId: message.familyId,465+ seq: message.seq,466+ distances: message.states.map((state) => survey.dist.get(state) ?? -1),467+ });468+ }469+}470+471+self.addEventListener("message", ({ data }) => handle(data));
M assets/styles.css +184 -314
@@ -1,333 +1,203 @@
1-@font-face { font-family: Silk; src: url("/fonts/silk-bold.woff2") format("woff2"); font-display: swap; }2-@font-face { font-family: SilkR; src: url("/fonts/silk.woff2") format("woff2"); font-display: swap; }3-@font-face { font-family: Mono; src: url("/fonts/mono.woff2") format("woff2"); font-display: swap; }1+/* Chrome only. Everything that looks like art is composed into the canvas and2+ shaded there; this file keeps small type crisp and stops the browser behaving3+ like a browser.4+5+ The Macintosh was black on white, so the page is paper with ink on it. A6+ "night" palette is just the two values swapped — nothing here needs to know7+ which way round they are. */8+9+@font-face { font-family: Silk; src: url(/fonts/silk-bold.woff2) format('woff2'); font-display: block; }10+@font-face { font-family: SilkR; src: url(/fonts/silk.woff2) format('woff2'); font-display: block; }11+@font-face { font-family: Mono; src: url(/fonts/mono.woff2) format('woff2'); font-display: block; }412 513 :root {6- font-family: Mono, ui-monospace, monospace;7- color-scheme: light dark;8- --ink: #2b2b22;9- --paper: #e8f4f2;10- --ground: #c9e6e1;11- --ground-dot: #78918d;12- --red: #bd4037;13- --green: #328456;14- --blue: #3169a5;15- --orange: #c4772e;16- --unit: clamp(1px, 0.24vw, 2px);14+ --ink: #2B2B22;15+ --paper: #E8F4F2;16+ --ground: #E8F4F2; /* the backdrop's field; varies per board */17+ --s: 2; /* the upscale from the picture buffer */1718 }1819 19-* { box-sizing: border-box; }20-html, body, #app { width: 100%; height: 100%; margin: 0; }21-body {22- overflow: hidden;23- overscroll-behavior: none;20+* { margin: 0; padding: 0; box-sizing: border-box; }21+22+html, body {23+ height: 100%;24+ /* The picture covers the whole safe area, so what is left of the page is the25+ strip the hardware took — a notch, a rounded corner, a home bar. It carries26+ the backdrop rather than the paper, or the edge of the canvas becomes a27+ visible seam the moment the two stop being the same colour. */2428 background: var(--ground);2529 color: var(--ink);26- user-select: none;27- -webkit-user-select: none;30+ font-family: Mono, ui-monospace, monospace;31+ overflow: hidden;32+ overscroll-behavior: none; /* no pull-to-refresh mid-drag */33+ touch-action: none; /* the board owns every gesture */34+ -webkit-user-select: none; user-select: none;2835 -webkit-touch-callout: none;2936 -webkit-tap-highlight-color: transparent;3037 }31-button { -webkit-tap-highlight-color: transparent; }32-33-.app {34- width: 100%;35- height: 100dvh;36- overflow: hidden;37- color: var(--ink);38- background-color: var(--ground);39- background-image: radial-gradient(circle, var(--ground-dot) 0 1px, transparent 1.25px);40- background-size: 8px 8px;41-}42-43-.palette-macintosh { --ink:#2b2b22; --paper:#e8f4f2; --ground:#c9e6e1; --ground-dot:#78918d; }44-.palette-paper { --ink:#302c21; --paper:#ebe5ce; --ground:#d8cfad; --ground-dot:#8e8568; }45-.palette-night { --ink:#dce4e2; --paper:#1c2028; --ground:#12161d; --ground-dot:#54616a; --red:#ef6a5c; --green:#55b783; --blue:#5f9bdb; --orange:#e69a50; }46-.palette-amoled { --ink:#dcf2ff; --paper:#000; --ground:#000; --ground-dot:#27404d; --red:#ff6358; --green:#56ca85; --blue:#65a8ff; --orange:#f3a248; }47-.palette-auto { --ink:#2b2b22; --paper:#e8f4f2; --ground:#c9e6e1; --ground-dot:#78918d; }48-@media (prefers-color-scheme: dark) {49- .palette-auto { --ink:#dce4e2; --paper:#1c2028; --ground:#12161d; --ground-dot:#54616a; --red:#ef6a5c; --green:#55b783; --blue:#5f9bdb; --orange:#e69a50; }50-}51-52-.screen { width: 100%; height: 100%; overflow: hidden; }53-.window {54- color: var(--ink);55- background: var(--paper);56- border: calc(1px + var(--unit)) solid var(--ink);57-}58-38+body {39+ position: fixed; inset: 0;40+ width: 100%; height: 100dvh;41+ padding: env(safe-area-inset-top) env(safe-area-inset-right)42+ env(safe-area-inset-bottom) env(safe-area-inset-left);43+}44+45+#app { position: absolute; inset: 0; }46+#stage { position: absolute; left: 0; top: 0; }47+48+#board {49+ display: block;50+ image-rendering: pixelated; /* the upscale lands on whole device pixels */51+ image-rendering: crisp-edges;52+}53+54+/* Type layer: same box, full device resolution, never pixelated. */55+#type { position: absolute; left: 0; top: 0; pointer-events: none; }56+57+#ui { position: absolute; inset: 0; pointer-events: none; }58+#ui button { pointer-events: auto; }59+60+/* Left and width are written by the view in picture units: a zone belongs to the61+ panel it sits in, and only the menu bar still spans the screen. */62+.zone {63+ position: absolute;64+ display: flex; align-items: center; justify-content: center;65+ gap: calc(13px * var(--s));66+}67+/* Beside the board rather than under it, the panel is a third of the width it68+ had, and the spacing that set three readouts across a phone has to come in69+ with it. The type does not change size — it is the same panel at the same70+ scale, just narrower. */71+[data-mode="split"] .zone { gap: calc(6px * var(--s)); }72+[data-mode="split"] #acts { gap: calc(5px * var(--s)); }73+/* The menu bar is already white; a button background would punch a hole in its74+ rule. */75+#top { justify-content: flex-start; gap: calc(5px * var(--s)); padding: 0 calc(8px * var(--s)); }76+#top button { background: none; padding: calc(2px * var(--s)) calc(4px * var(--s)); }77+#top .spacer { flex: 1; }78+79+/* A boxed icon: square, hard-ruled, with the glyph struck inside it. The box is80+ the point — an unboxed glyph on a menu bar reads as a picture of something81+ rather than as a thing to press, which is why systems of this vintage drew the82+ rectangle every time. */83+.iconbtn {84+ display: flex; align-items: center; justify-content: center;85+ /* Two units smaller than it wants to be, taken off the padding rather than the86+ glyph: the glyph is drawn on a 9-unit grid and only stays on whole pixels87+ while its box is a whole multiple of 9. The two units buy clearance from the88+ menu bar's own rule below — both are drawn in ink, and a box that comes89+ within a pixel of a line of the same colour stops reading as a box near a90+ line and starts reading as one shape that has gone wrong. */91+ padding: calc(1.5px * var(--s)) !important;92+ border-color: var(--ink) !important;93+}94+.iconbtn svg {95+ display: block;96+ width: calc(9px * var(--s)); height: calc(9px * var(--s));97+ fill: currentColor;98+ shape-rendering: crispEdges; /* whole pixels, like the rest of the picture */99+}100+/* Inverts on press. The plain menu-bar buttons deliberately do not take a101+ background, but these already carry a box, so filling it is the QuickDraw102+ answer rather than a hole in the rule. */103+#top .iconbtn:active { background: var(--ink); color: var(--paper); }104+#acts { gap: calc(6px * var(--s)); }105+106+.stat { display: flex; flex-direction: column; align-items: center; gap: calc(2px * var(--s)); min-width: 0; }107+.val { font-family: Silk, monospace; font-size: calc(10px * var(--s)); }108+.lbl { font-size: calc(5px * var(--s)); letter-spacing: calc(1px * var(--s)); opacity: .72; }109+#gauge { justify-content: space-between; gap: 0; }110+#gauge .spacer { flex: 1; }111+112+/* Buttons are QuickDraw buttons: a hard rectangle, inverted while pressed. */59113 button {60- appearance: none;114+ font-family: Mono, monospace;115+ font-size: calc(6px * var(--s));116+ letter-spacing: calc(1.2px * var(--s));61117 color: var(--ink);62118 background: var(--paper);63- border: 2px solid var(--ink);119+ border: calc(1px * var(--s)) solid var(--ink);64120 border-radius: 0;65- padding: 0.62rem 0.85rem;66- font: 0.72rem/1 Mono, monospace;67- letter-spacing: 0.1em;121+ padding: calc(3.5px * var(--s)) calc(6.5px * var(--s));68122 cursor: pointer;69123 }70-button:active, button.armed { color: var(--paper); background: var(--ink); }71-button:focus-visible { outline: 3px double var(--ink); outline-offset: 3px; }72-button:disabled { opacity: 0.32; cursor: default; }73-button.ghost { border-color: transparent; background: transparent; }74-button.compact { padding: 0.35rem 0.5rem; }75-76-.landing, .card-screen {77- width: 100%; height: 100%; border: 0; padding: max(1.2rem, env(safe-area-inset-top)) 1.2rem max(1.2rem, env(safe-area-inset-bottom));78- background: transparent; display: grid; place-items: center;79-}80-.landing:active, .card-screen:active { color: var(--ink); background: transparent; }81-.landing-window {82- width: min(88vw, 342px); height: min(66dvh, 558px);83- display: flex; flex-direction: column; align-items: center; justify-content: flex-start;84- padding: 2.7rem 1.2rem 2.8rem;85- transform:translateY(-1.15rem);86-}87-.landing h1, .stage-card h1, .solved-window h1 {88- margin: 0; font: clamp(2.35rem, 11vw, 4.6rem)/1 Silk, monospace; letter-spacing: 0.07em;89-}90-.landing h1 { font-size:clamp(2.1rem,10.8vw,2.65rem); letter-spacing:0.035em; }91-.subtitle { margin: 0.42rem 0 0; font: 0.62rem SilkR, monospace; letter-spacing: 0.15em; }92-.hairline { flex:0 0 2px; width: 100%; height: 2px; background: var(--ink); opacity: 0.55; }93-.landing-window .hairline { margin-top:1.15rem; }94-.tap-prompt { font: 0.9rem Silk, monospace; animation: blink 1.4s steps(1, end) infinite; }95-@keyframes blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }96-@media (prefers-reduced-motion: reduce) { .tap-prompt { animation: none; } }97-98-.menu-bar {99- height: max(2.65rem, calc(2rem + env(safe-area-inset-top)));100- padding: env(safe-area-inset-top) max(0.9rem, env(safe-area-inset-right)) 0 max(0.9rem, env(safe-area-inset-left));101- display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;102- background: var(--paper); border-bottom: 1px solid var(--ink);103-}104-.menu-bar > :first-child { justify-self:start; }105-.menu-brand { display:flex; align-items:center; gap:0.45rem; }106-.menu-brand strong { font: 1.02rem Silk, monospace; letter-spacing: 0.1em; }107-.signature { position:relative; display:block; width:1.7rem; height:1.7rem; border:2px solid var(--ink); border-radius:50%; }108-.signature-mark { position:absolute; width:0; height:0; left:0.45rem; top:0.35rem; border-left:0.34rem solid transparent; border-right:0.34rem solid transparent; border-bottom:0.62rem solid var(--red); transform:rotate(32deg); }109-.menu-title { min-width:1px; }110-.menu-title { font:1.2rem Silk,monospace; letter-spacing:0.18em; }111-.menu-actions { display: flex; justify-content: flex-end; gap: 0.4rem; }112-.icon-button { width: 1.8rem; height: 1.8rem; padding: 0; display: grid; place-items: center; font-size: 0.8rem; }113-.icon-button::before, .icon-button::after { content:""; display:block; background:currentColor; }114-.icon-record::before { width:2px; height:8px; box-shadow:4px -3px 0 currentColor,8px -6px 0 currentColor; transform:translate(-4px,3px); }115-.icon-settings::before { width:10px; height:1px; box-shadow:0 5px 0 currentColor; }116-.icon-settings::after { position:absolute; width:2px; height:5px; box-shadow:4px 5px 0 currentColor; transform:translate(-2px,-3px); }117-.icon-button { position:relative; }118-119-.atlas {120- height: calc(100% - max(2.65rem, calc(2rem + env(safe-area-inset-top))));121- overflow-y: auto; scrollbar-width: thin; overscroll-behavior: contain;122- display: grid; grid-template-columns: minmax(0, 470px); align-content: start; justify-content: center;123- gap: 0.75rem; padding: 0.85rem max(0.85rem, env(safe-area-inset-right)) max(1.5rem, env(safe-area-inset-bottom));124-}125-.family-card {126- width: 100%; min-height: 8.1rem; padding: 0.4rem 0.7rem; text-align: left;127- display: grid; grid-template-columns: 39% 1fr; gap: 0.65rem; align-items: center;128-}129-.family-card:active { color: var(--ink); background: var(--paper); }130-.family-copy h2 { margin: 0 0 0.75rem; font: clamp(1rem, 5vw, 1.35rem) Silk, monospace; letter-spacing: 0.08em; }131-.family-copy p { margin: 0 0 0.6rem; display: flex; justify-content: space-between; gap: 1rem; font: 0.68rem SilkR, monospace; }132-.family-copy p > :last-child { margin-left:auto; }133-.family-copy small { display: block; margin-top: 0.4rem; font-size: 0.56rem; opacity: 0.65; }134-.family-copy .begin-label { margin-top:1.15rem; font-size:0.64rem; }135-.family-copy:has(.begin-label) { align-self:stretch; position:relative; }136-.family-copy:has(.begin-label) h2 { position:absolute; top:0.55rem; left:0; }137-.family-copy:has(.begin-label) p { position:absolute; top:2.8rem; left:0; right:0; }138-.family-copy:has(.begin-label) .begin-label { position:absolute; top:4.7rem; left:0; margin:0; }139-.mini-board-wrap { display: grid; place-items: center; min-width: 0; }140-.mini-board-wrap .board { width:min(100%,7rem,calc(7.15rem * var(--cols) / var(--rows))); border:0; box-shadow:none; padding:0; background:transparent; }141-.mini-board-wrap .exit { display:none; }142-.depth-track { position: relative; height: 9px; border-top: 2px solid var(--ink); }143-.depth-track::before, .depth-track::after { content:""; position:absolute; top:-5px; width:2px; height:7px; background:var(--ink); }144-.depth-track::after { right:0; }145-.depth-marker { position: absolute; top: -7px; left: calc((100% - 5px) * var(--progress) / var(--total)); width: 5px; height: 11px; background: var(--ink); }146-147-.stage-card {148- width: min(88vw, 342px); height:min(25dvh,211px); padding: 1.25rem;149- display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;150-}151-.stage-card p { margin: 0; font: 0.8rem SilkR, monospace; letter-spacing: 0.12em; }152-.stage-card h1 { font-size: clamp(2rem, 10vw, 3.8rem); }153-154-.survey-layout { width:100%; height:100%; display:grid; place-items:center; padding:1rem; }155-.survey-window { width:min(88vw,342px); min-height:18rem; padding:2rem; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1.2rem; text-align:center; }156-.survey-window p, .survey-window strong { margin:0; }157-.survey-window strong { font:clamp(1.7rem,9vw,3rem)/1 Silk,monospace; }158-.survey-window > p:last-child { font:0.62rem SilkR,monospace; letter-spacing:0.1em; opacity:0.7; }159-.survey-pulse { width:3rem; height:3rem; border:2px solid var(--ink); animation:survey-pulse 0.8s steps(4,end) infinite; }160-@keyframes survey-pulse { 50% { background:var(--ink); transform:rotate(90deg); } }161-@media (prefers-reduced-motion:reduce) { .survey-pulse { animation:none; } }162-163-.game-layout {164- height: calc(100% - max(2.65rem, calc(2rem + env(safe-area-inset-top))));165- padding: clamp(1.25rem, 9.8dvh, 5.2rem) max(0.85rem, env(safe-area-inset-right)) max(0.8rem, env(safe-area-inset-bottom));166- display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 2.15rem; justify-items: center;167-}168-.board-window {169- min-height: 0; width: min(87vw, 470px); padding: clamp(0.8rem, 2vw, 1.15rem);170- display: flex; flex-direction: column; align-items: center; justify-content: center;171- box-shadow:inset 0 0 0 4px var(--paper),inset 0 0 0 6px var(--ink),inset 0 0 0 9px var(--paper);124+button:active, button.armed { background: var(--ink); color: var(--paper); }125+button:disabled { opacity: .35; border-color: transparent; }126+button.ghost { border-color: transparent; }127+128+/* Discrete steps, never a smooth fade: an opacity ramp would put in-between129+ frames in the middle of a one-bit game. */130+@keyframes beat { 0%, 55% { opacity: 1 } 56%, 100% { opacity: 0 } }131+.beat { animation: beat 1.4s steps(1, end) infinite; }132+/* The one animation the canvas does not own. Told to stop by the same preference133+ that stops all the others — and left legible rather than left mid-blink, since134+ the prompt is the only thing telling anyone what to do. */135+@media (prefers-reduced-motion: reduce) {136+ .beat { animation: none; opacity: 1; }137+}138+139+#sheet {140+ position: absolute; inset: 0;141+ display: flex; flex-direction: column;142+ align-items: center; justify-content: center;143+ gap: calc(11px * var(--s));144+ padding: calc(16px * var(--s));145+ background: var(--paper);146+ pointer-events: auto;172147 }173-.board-title { margin: 0.8rem 0 0; font: clamp(1rem, 4vw, 1.5rem) Silk, monospace; letter-spacing: 0.12em; }174-.control-window { width: min(93vw, 470px); min-height:14.4rem; padding:0.45rem 1rem 0.85rem; display:grid; grid-template-rows:2.75rem 2.5rem 2.5rem; gap:1.85rem; align-content:start; }175-.hud { display: flex; align-items: center; justify-content: space-evenly; gap: 0.65rem; }176-.stat { min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 0.25rem; text-align: center; }177-.stat strong { font: clamp(1.1rem, 4vw, 1.5rem) Silk, monospace; }178-.stat span { font-size: clamp(0.47rem, 1.8vw, 0.58rem); letter-spacing: 0.08em; opacity: 0.72; }179-.actions { display: flex; justify-content: center; gap: 0.75rem; }180-.interaction-hint { display:none; }181-.gauge { position:relative; height:2.75rem; font-size:0.48rem; opacity:0.72; }182-.gauge > span { position:absolute; top:2rem; }183-.gauge > span:first-child { left:13%; }184-.gauge > span:last-child { right:13%; }185-.gauge > div { position:absolute; left:12%; right:12%; top:0.45rem; height:9px; border-top:2px solid var(--ink); background-image:linear-gradient(var(--ink),var(--ink)),linear-gradient(var(--ink),var(--ink)),linear-gradient(var(--ink),var(--ink)),linear-gradient(var(--ink),var(--ink)); background-size:1px 5px; background-repeat:no-repeat; background-position:25% 0,50% 0,75% 0,100% 0; }186-.gauge i { position:absolute; top:-6px; left:max(0px,calc((100% - 5px) * (var(--start) - var(--gap)) / var(--start))); width:5px; height:10px; background:var(--ink); }187-.game-screen .hud { transform:translateY(-0.75rem); }188-.game-screen .actions { transform:translateY(0.65rem); }189-190-.board {191- --gap: 3px;192- position: relative; isolation: isolate;193- width: min(72vw, 42dvh, 350px);194- aspect-ratio: var(--cols) / var(--rows);148+/* On a short screen the stacked groups are taller than the screen is. Two149+ columns rather than a scroll: a settings sheet you have to scroll to reach150+ CLOSE is a dialog that has stopped being a dialog. */151+[data-mode="split"] #sheet {195152 display: grid;196- grid-template-columns: repeat(var(--cols), minmax(0, 1fr));197- grid-template-rows: repeat(var(--rows), minmax(0, 1fr));153+ grid-template-columns: auto auto;154+ justify-content: center; align-content: center;155+ gap: calc(7px * var(--s)) calc(16px * var(--s));156+ padding: calc(10px * var(--s));157+}158+[data-mode="split"] #sheet .credit,159+[data-mode="split"] #sheet > button { grid-column: 1 / -1; justify-self: center; }160+.group { display: flex; flex-direction: column; align-items: center; }161+.grouplbl {162+ font-size: calc(5px * var(--s)); letter-spacing: calc(1.6px * var(--s));163+ opacity: .6; margin-bottom: calc(5px * var(--s));164+}165+.row { display: flex; gap: calc(4px * var(--s)); flex-wrap: wrap; justify-content: center; }166+167+/* A pull-down, drawn the way the rest of the chrome is drawn.168+169+ A native <select> is the one control the platform will not let you style past170+ a point, and one rounded system-blue menu in a screen of hard rectangles171+ undoes the whole conceit. This is the QuickDraw menu instead: a hard box, a172+ hairline frame, and inversion to show the highlight. */173+.menu { position: relative; }174+.menutitle { min-width: calc(84px * var(--s)); text-align: left; }175+.menutitle::after { content: ' \25BE'; float: right; } /* the little triangle */176+.menulist {177+ display: none;178+ position: absolute; left: 0; top: calc(100% - 1px * var(--s));179+ z-index: 5; min-width: 100%;198180 background: var(--paper);199- border: 2px solid var(--ink);200- box-shadow: inset 0 0 0 3px var(--paper);201- padding: 4px;202- touch-action: none;203-}204-.exit { position:absolute; z-index:3; left:calc((100% - 8px) * var(--goal-x) / var(--cols) + 4px); bottom:-7px; width:calc((100% - 8px) * 2 / var(--cols)); height:10px; background:var(--paper); border-left:2px solid var(--ink); border-right:2px solid var(--ink); }205-.piece {206- --piece: var(--orange);207- position: relative; z-index: 2; min-width: 0; min-height: 0;208- grid-column: calc(var(--x) + 1) / span var(--w);209- grid-row: calc(var(--y) + 1) / span var(--h);210- margin: var(--gap); padding: 0;211- overflow: hidden; border: 2px solid var(--ink); background-color: var(--paper);212- box-shadow: 3px 3px 0 var(--ink); touch-action: none;213- transform:translate(var(--drag-x,0),var(--drag-y,0));214- will-change:transform;215-}216-.piece-single { --piece:var(--orange); }217-.piece-upright { --piece:var(--blue); }218-.piece-recumbent { --piece:var(--green); }219-.piece-goal { --piece:var(--red); }220-.piece::before { content:""; position:absolute; inset:0; opacity:0.9; pointer-events:none; }221-.goal-mark { position:absolute; z-index:2; left:50%; top:50%; width:52%; aspect-ratio:1; transform:translate(-50%,-50%); border:clamp(2px,0.65vw,4px) solid var(--piece); border-radius:50%; background-color:var(--paper); background-image:radial-gradient(circle,var(--piece) 0 1px,transparent 1.4px); background-size:5px 5px; box-shadow:inset 0 0 0 2px var(--paper); }222-.interactive .piece { cursor: grab; }223-.interactive .piece:active { cursor: grabbing; color:inherit; background-color:var(--paper); box-shadow:2px 2px 0 var(--ink); }224-.mini-board-wrap .piece { --gap:1px; border-width:1px; box-shadow:1px 1px 0 var(--ink); }225-.mini-board-wrap .goal-mark { border-width:1px; }226-.landing-window .board { width:min(56vw, 218px); margin-top:1.85rem; }227-.landing-window .tap-prompt { margin-top:3.65rem; }228-229-[data-density="low"] .piece { --grain: 5px; }230-[data-density="default"] .piece { --grain: 3px; }231-[data-shading="atkinson"] .piece::before { background-image:radial-gradient(circle at 25% 25%, var(--piece) 0 1px, transparent 1.4px); }232-[data-shading="atkinson"] .piece { background-size: var(--grain, 3px) var(--grain, 3px); }233-[data-shading="atkinson"] .piece::before { background-size:var(--grain,3px) var(--grain,3px); }234-[data-shading="ordered"] .piece::before { background-image:conic-gradient(from 90deg, var(--piece) 25%, transparent 0 75%, var(--piece) 0); }235-[data-shading="ordered"] .piece { background-size: calc(var(--grain, 3px) * 2) calc(var(--grain, 3px) * 2); }236-[data-shading="ordered"] .piece::before { background-size:calc(var(--grain,3px) * 2) calc(var(--grain,3px) * 2); }237-[data-shading="benday"] .piece::before { background-image:radial-gradient(circle, var(--piece) 0 30%, transparent 34%); }238-[data-shading="benday"] .piece { background-size: calc(var(--grain, 3px) * 3) calc(var(--grain, 3px) * 3); }239-[data-shading="benday"] .piece::before { background-size:calc(var(--grain,3px) * 3) calc(var(--grain,3px) * 3); }240-[data-shading="lines"] .piece::before { background-image:repeating-linear-gradient(0deg, var(--piece) 0 1px, transparent 1px calc(var(--grain, 3px) * 2)); }241-[data-shading="diagonal"] .piece::before { background-image:repeating-linear-gradient(45deg, var(--piece) 0 1px, transparent 1px calc(var(--grain, 3px) * 2)); }242-[data-shading="crosshatch"] .piece::before { background-image:repeating-linear-gradient(45deg, var(--piece) 0 1px, transparent 1px 7px), repeating-linear-gradient(-45deg, var(--piece) 0 1px, transparent 1px 7px); }243-[data-shading="comic"] .piece::before { background-image:radial-gradient(circle, var(--piece) 0 27%, transparent 31%), radial-gradient(circle, var(--paper) 0 18%, transparent 22%); }244-[data-shading="comic"] .piece { background-size: 9px 9px, 11px 11px; background-position:0 0, 4px 3px; }245-[data-shading="comic"] .piece::before { background-size:9px 9px,11px 11px; background-position:0 0,4px 3px; }246-[data-shading="solid"] .piece::before { background:var(--piece); }247-248-.solved-window { position:relative; min-height:0; width:min(87vw,470px); padding:1rem; overflow:hidden; box-shadow:inset 0 0 0 4px var(--paper),inset 0 0 0 6px var(--ink),inset 0 0 0 9px var(--paper); }249-.solved-window p, .solved-window h1, .solved-window small { position:absolute; margin:0; left:0; right:0; text-align:center; }250-.solved-window > p { top:4.55rem; font:0.8rem SilkR,monospace; letter-spacing:0.45em; }251-.solved-window h1 { top:6.1rem; font-size:clamp(1.5rem,7vw,2.2rem); }252-.solved-window small { bottom:1.35rem; font:clamp(0.9rem,5vw,1.3rem) Silk,monospace; letter-spacing:0.13em; }253-.trophy { position:absolute; left:50%; top:1rem; width:2.6rem; height:2.7rem; transform:translateX(-50%); }254-.trophy::before,.trophy::after { content:""; position:absolute; z-index:0; top:0.35rem; width:0.65rem; height:0.9rem; border:3px solid var(--ink); background:var(--paper); }255-.trophy::before { left:0; border-right:0; }256-.trophy::after { right:0; border-left:0; }257-.trophy-cup { position:absolute; z-index:1; left:0.48rem; top:0; width:1.65rem; height:1.65rem; background:#c99725; border:3px solid var(--ink); clip-path:polygon(0 0,100% 0,82% 72%,62% 88%,38% 88%,18% 72%); }258-.trophy-stem { position:absolute; left:1.15rem; top:1.55rem; width:0.32rem; height:0.62rem; background:var(--ink); }259-.trophy-base { position:absolute; left:0.72rem; top:2.08rem; width:1.2rem; height:0.38rem; background:#c99725; border:3px solid var(--ink); }260-.run-chart { position:absolute; left:11%; right:11%; top:8.55rem; height:14.25rem; display:flex; align-items:end; border-bottom:2px solid var(--ink); overflow:hidden; }261-.run-chart span { flex:1 1 1px; min-width:1px; height:calc(100% * var(--bar) / var(--peak)); max-height:100%; background:var(--ink); }262-.run-chart span { box-shadow:1px 0 0 var(--ink); }263-.run-chart::after { content:""; position:absolute; z-index:2; left:0; top:0; width:135%; border-top:2px dashed #c99725; transform-origin:left center; transform:rotate(42deg); }264-.record-hud { flex-wrap:wrap; }265-.won-screen .control-window { position:relative; display:block; padding:1rem; }266-.won-screen .record-hud { position:absolute; left:1rem; right:1rem; top:2.15rem; justify-content:space-between; }267-.won-screen .control-window > .actions { position:absolute; left:0; right:0; bottom:2rem; transform:none; }268-269-.stats-grid { height:calc(100% - max(2.65rem, calc(2rem + env(safe-area-inset-top)))); overflow:auto; padding:0.85rem; display:grid; gap:0.75rem; align-content:start; justify-content:center; grid-template-columns:minmax(0,470px); }270-.level-panel { position:relative; height:6.9rem; padding:1rem; }271-.level-panel p { margin:0; position:absolute; top:1rem; font:0.68rem SilkR,monospace; letter-spacing:0.18em; opacity:0.7; }272-.level-panel .level-label { left:1.2rem; }273-.level-panel .xp-label { right:1.2rem; }274-.level-panel > strong { position:absolute; left:1.2rem; top:2.8rem; font:2.45rem Silk,monospace; }275-.level-track { position:absolute; left:38%; right:9%; top:3.8rem; height:1rem; border:2px solid var(--ink); }276-.level-track::before { content:""; display:block; width:calc(100% * var(--level-progress) / 45); height:100%; background:var(--ink); }277-.level-panel small { position:absolute; right:9%; top:5.35rem; font-size:0.55rem; opacity:0.6; letter-spacing:0.1em; }278-.lifetime-panel { height:8.45rem; padding:0.8rem; }279-.record-cells { width:100%; height:100%; display:grid; grid-template-columns:repeat(3,1fr); grid-template-rows:repeat(2,1fr); }280-.record-cells .stat strong { font-size:1.05rem; }281-.record-cells .stat span { font-size:0.58rem; }282-.family-table { min-height:13.8rem; padding:0.75rem 0.95rem; }283-.family-table-head,.family-table-body > div { display:grid; grid-template-columns:1fr 4rem 4rem 6.4rem; align-items:center; }284-.family-table-head { margin-bottom:0.65rem; font-size:0.57rem; letter-spacing:0.15em; opacity:0.6; }285-.family-table-head span:not(:first-child),.family-table-body span { text-align:right; }286-.family-table-body { display:grid; gap:0.35rem; }287-.family-table-body > div { font:0.62rem SilkR,monospace; opacity:0.48; }288-.family-table-body strong { font:0.65rem Silk,monospace; letter-spacing:0.08em; }289-.recent-panel { position:relative; height:14rem; padding:0.8rem 0.95rem; }290-.recent-panel p { margin:0; font-size:0.58rem; letter-spacing:0.15em; opacity:0.6; }291-.recent-panel span { position:absolute; left:0; right:0; top:58%; text-align:center; font-size:0.58rem; letter-spacing:0.1em; opacity:0.48; }292-.recent-chart { position:absolute; left:0.95rem; right:0.95rem; top:2rem; bottom:1.25rem; display:flex; align-items:end; border-bottom:2px solid var(--ink); gap:2px; }293-.recent-chart i { width:6px; height:calc(100% * var(--depth) / 42); min-height:2px; background:var(--ink); }294-.record-footer { min-height:2.2rem; display:grid; place-items:center; font-size:0.57rem; letter-spacing:0.1em; color:color-mix(in srgb,var(--ink) 65%,var(--paper)); }295-296-.sheet-scrim { position:fixed; z-index:20; inset:0; display:grid; place-items:center; background:var(--paper); }297-.settings-sheet { position:relative; width:100%; height:100dvh; overflow:auto; border:0; padding:max(2.2rem,env(safe-area-inset-top)) 1rem max(1.2rem,env(safe-area-inset-bottom)); display:flex; flex-direction:column; gap:1.35rem; align-items:center; justify-content:center; transform:translateY(-0.55rem); }298-.setting-group { display:grid; gap:0.7rem; justify-items:center; }299-.setting-group > p { margin:0; font-size:0.58rem; letter-spacing:0.16em; opacity:0.65; }300-.setting-row { display:flex; flex-wrap:wrap; justify-content:center; gap:0.45rem; }301-.setting-group:first-child .setting-row { max-width:17.8rem; }302-.setting-row button { font-size:0.68rem; padding:0.72rem 1rem; }303-.setting-group:first-child .setting-row button { padding-inline:1.1rem; }304-.shade-menu { position:relative; }305-.shade-menu > button { width:10.45rem; padding:0.72rem 0.9rem; text-align:left; }306-.shade-list { position:absolute; z-index:2; left:50%; transform:translateX(-50%); top:calc(100% - 2px); width:max-content; min-width:10rem; display:grid; background:var(--paper); border:2px solid var(--ink); box-shadow:3px 3px 0 var(--ink); }307-.shade-list button { border:0; text-align:left; }308-.credit { margin:0; font-size:0.48rem; line-height:1.8; letter-spacing:0.06em; opacity:0.55; text-align:center; }309-310-@media (min-width: 760px) {311- .atlas { grid-template-columns:repeat(2,minmax(300px,470px)); }312- .stats-grid { grid-template-columns:minmax(0,470px) minmax(0,470px); }313- .settings-sheet { width:100%; }314-}315-316-@media (orientation: landscape) and (max-height: 800px) and (min-width:760px) {317- .game-layout { grid-template-columns:minmax(0,1.4fr) minmax(260px,0.75fr); grid-template-rows:1fr; align-items:stretch; }318- .board-window, .control-window { width:100%; }319- .board { width:min(48vw,65dvh,330px); }320- .landing-window { width:min(64vw,768px); height:min(66dvh,462px); transform:translateY(-0.75rem); padding:1.38rem 1.75rem 2.25rem; display:flex; text-align:center; }321- .landing-window h1 { font-size:4.7rem; }322- .landing-window .subtitle { margin-top:1.08rem; font-size:1.5rem; }323- .landing-window .hairline { margin-top:2.5rem; }324- .landing-window .board { width:min(10vw,120px); margin-top:1.38rem; }325- .landing-window .tap-prompt { margin-top:1.2rem; }326- .solved-window { width:100%; }327-}328-329-@media (max-height: 620px) and (orientation: portrait) {330- .interaction-hint { display:none; }331- .control-window { gap:0.45rem; padding:0.55rem; }332- .game-layout { gap:0.55rem; }181+ border: calc(1px * var(--s)) solid var(--ink);182+ /* The drop shadow QuickDraw menus cast: solid ink, offset, never blurred. */183+ box-shadow: calc(2px * var(--s)) calc(2px * var(--s)) 0 var(--ink);184+}185+.menu.open .menulist { display: block; }186+.menulist button {187+ display: block; width: 100%; text-align: left;188+ border: 0; background: none;189+ padding: calc(3px * var(--s)) calc(7px * var(--s));190+ white-space: nowrap;191+}192+.menulist button:hover, .menulist button.armed { background: var(--ink); color: var(--paper); }193+/* A rule under AUTO, because it is a different kind of answer from the rest:194+ everything below it is a screen, AUTO is "let the board decide". */195+.menulist button[data-shading="auto"] {196+ border-bottom: calc(1px * var(--s)) solid var(--ink);197+}198+.credit {199+ font-size: calc(4.4px * var(--s)); line-height: 1.9;200+ letter-spacing: calc(.5px * var(--s));201+ opacity: .5; text-align: center;202+ max-width: calc(180px * var(--s));333203 }
M assets/sw.js +1 -1
M gleam.toml +2 -1
M manifest.toml +1 -0
M src/klotski.gleam +2001 -1112
@@ -1,20 +1,46 @@
1+/// Klotski.2+///3+/// Flow, input and chrome. The screens are paced rather than instant: a title4+/// that has to be touched, a sweep you watch happen, a card naming the set and5+/// its optimal length, the board rising into place, and a record when it is6+/// done. None of it is long, but none of it cuts.7+///8+/// Sweeps happen when a family is first opened rather than at boot. Eight9+/// families is a second and a half of breadth-first search, and making someone10+/// wait through all of it to play one board is the wrong trade — especially when11+/// they may only ever open two of them.12+///13+/// Nothing in this app moves smoothly: motion is quantised so the shading never14+/// lands out of phase, which means most ticks of a clock produce the same15+/// picture as the last one. So the loop keeps both — the clock, and the stepped16+/// value the painters actually draw with — and asks for a picture when a stepped17+/// value moves rather than when time passes. Composing a screen is not cheap and18+/// cannot be: every pixel is drawn, read back, thresholded, tinted and written19+/// out, because diffusion depends on the whole image. Doing that five times for20+/// the same picture is what a phone feels as a stutter.21+import gleam/dynamic.{type Dynamic}122 import gleam/dynamic/decode223 import gleam/float324 import gleam/int425 import gleam/list526 import gleam/option.{type Option, None, Some}627 import gleam/result7-import gleam/string828 import klotski/browser9-import klotski/catalog.{type Family, type Puzzle}10-import klotski/domain.{11- type Direction, type Move, type Piece, Down, Left, Move, Right, Up,12-}13-import klotski/persistence.{14- type Density, type Hints, type Palette, type Save, type Shading, Amoled,15- Atkinson, Auto, Benday, Best, Comic, Crosshatch, Default, Diagonal, Full,16- Lines, Low, Macintosh, Night, Off, Ordered, Paper, Save, ShadeAuto, Solid,17-}29+import klotski/difficulty30+import klotski/domain.{type Direction, type Piece, Down, Left, Right, Up}31+import klotski/families.{type Family}32+import klotski/ground33+import klotski/layout.{type BoardLayout, type Layout, Split}34+import klotski/paint35+import klotski/prefs.{type Preference}36+import klotski/prng37+import klotski/progress38+import klotski/render39+import klotski/scene.{type Screen as Shade}40+import klotski/shading.{type Pattern}41+import klotski/store.{type Save, Best, Full, OffHints}42+import klotski/theme.{type Combo, type Theme}43+import klotski/walk1844 import lustre1945 import lustre/attribute2046 import lustre/effect.{type Effect}
@@ -22,77 +48,165 @@ import lustre/element.{type Element}
2248 import lustre/element/html2349 import lustre/event2450 51+// ------------------------------------------------------------------- model52+2553 pub type Screen {26- Landing54+ Boot55+ Sweeping2756 Atlas28- Survey2957 Card3058 Playing3159 Won32- Statistics60+ Record61+}62+63+pub type Axis {64+ Horizontal65+ Vertical3366 }3467 3568 pub type Drag {3669 Drag(37- piece_id: Int,38- start_x: Float,39- start_y: Float,40- current_x: Float,41- current_y: Float,42- cell_px: Float,70+ piece: Int,71+ origin_x: Float,72+ origin_y: Float,73+ axis: Option(Axis),74+ offset: Int,75+ reach_positive: Int,76+ reach_negative: Int,77+ )78+}79+80+/// A block sliding to where it was sent.81+///82+/// Under reduced motion it does not slide: it is already there. The move itself83+/// is not the animation — the board changes either way, the sound plays either84+/// way — so the still path arrives at the same position without the tween in85+/// between. Which is only true because a block's ink is cut at its resting86+/// square rather than at wherever it happens to be passing.87+pub type Anim {88+ Anim(89+ piece: Int,90+ axis: Axis,91+ from: Float,92+ to: Float,93+ current: Int,94+ elapsed: Float,95+ duration: Float,96+ then: Option(Pending),4397 )4498 }4599 100+pub type Pending {101+ Pending(piece: Int, direction: Direction, cells: Int)102+}103+104+pub type Step {105+ Step(piece: Int, direction: Direction, cells: Int)106+}107+108+pub type Sweep {109+ Sweep(family: String, count: Int, total: Int, phase: Int)110+}111+112+pub type Surveyed {113+ Surveyed(family: String, ecc: Int, count: Int)114+}115+116+/// The board a rung serves, and what the walker found in it.117+pub type Puzzle {118+ Puzzle(family: String, rung: Int, grid: List(Int), optimal: Int, score: Float)119+}120+46121 pub type Model {47122 Model(48123 screen: Screen,49- families: List(Family),50- family_index: Int,51- puzzle_index: Int,124+ save: Save,125+ system: Preference,126+ layout: Layout,127+ board: BoardLayout,128+ family: Family,129+ rung: Int,130+ surveys: List(Surveyed),131+ sweep: Option(Sweep),132+ puzzle: Option(Puzzle),133+ /// The rung a family will next serve, cached so the atlas can show what the134+ /// next board costs without re-walking it every frame.135+ previews: List(Puzzle),52136 pieces: List(Piece),53- initial_pieces: List(Piece),137+ start_pieces: List(Piece),54138 steps: Int,55- history: List(Move),56- save: Save,139+ history: List(Step),140+ /// Distance to the goal after each step, starting with the distance before141+ /// any — the descent, and what the record screen draws.142+ trace: List(Int),143+ /// How many measurements are still in flight. A run is only finished when144+ /// the last of them has landed.145+ awaiting: Int,146+ started_at: Int,147+ drag: Option(Drag),148+ anim: Option(Anim),149+ scroll: Option(#(Float, Int, Float)),150+ atlas_scroll: Int,151+ // The clocks, and beside each the stepped value the painters draw with.152+ last_tick: Float,153+ shake: Float,154+ shake_off: Int,155+ shake_piece: Option(Int),156+ card_t: Float,157+ card_step: Int,158+ rise_t: Float,159+ rise: Int,160+ flash: Float,161+ wipe: Float,162+ /// When the solve may be written down: after the wipe, and never before the163+ /// last measurement has landed.164+ finishing: Option(Float),165+ elapsed: Float,57166 settings_open: Bool,58167 shading_open: Bool,59- drag: Option(Drag),60- distance: Option(Int),61- survey_count: Int,62- survey_phase: Int,63- survey_eccentricity: Int,64168 install_available: Bool,65- run_detours: Int,169+ last_frame: String,170+ /// The moves a harness probe is waiting on an answer for, in the order they171+ /// were asked about, and whether a walk is still under way.172+ probing: List(#(Int, Direction)),173+ walking: Bool,66174 )67175 }68176 69177 pub type Message {70- UserTappedLanding71- UserSelectedFamily(Int)72- UserTappedCard73- UserTappedBack74- UserTappedStats75- UserTappedSettings76- UserClosedSettings77- UserToggledShadingMenu78- UserSelectedPalette(Palette)79- UserSelectedShading(Shading)80- UserSelectedHints(Hints)81- UserSelectedDensity(Density)82- UserToggledSound83- UserToggledHaptics84- UserPressedPiece(Int, Float, Float, Float)85- UserMovedBoard(Float, Float)86- UserReleasedBoard(Float, Float)87- UserPressedPieceKey(Int, String)88- UserTappedUndo89- UserTappedReset90- UserTappedNext91- SurveyAdvanced(Int, Int)92- SurveyReady(Int, Int, Int)93- DistanceUpdated(Int, Int)94- InstallAvailable95- UserTappedInstall178+ Ticked(Float)179+ ViewportChanged180+ SystemChanged181+ FontsReady182+ PointerDown(Float, Float)183+ PointerMoved(Float, Float)184+ PointerUp(Float, Float)185+ PointerCancelled186+ KeyPressed(String)187+ FamilyOpened(Int)188+ Swept(String, Int, Int, Int)189+ SurveyFinished(String, Int, Int)190+ PuzzleOffered(String, Int, List(Dynamic))191+ Measured(String, Int, List(Int))192+ Probed(String, List(Int))193+ HarnessWalked194+ UndoPressed195+ ResetPressed196+ BackPressed197+ StatsPressed198+ NextPressed199+ SettingsOpened200+ SettingsClosed201+ ShadingMenuToggled202+ PaletteChosen(String)203+ ShadingChosen(Result(Pattern, Nil))204+ HintsChosen(store.Hints)205+ DensityChosen(store.Density)206+ SoundToggled207+ HapticsToggled208+ InstallOffered209+ InstallPressed96210 InstallFinished97211 }98212
@@ -102,198 +216,194 @@ pub fn main() {
102216 Nil103217 }104218 105-fn init(raw_save: String) -> #(Model, Effect(Message)) {106- let families = catalog.all()107- #(219+fn init(raw: String) -> #(Model, Effect(Message)) {220+ let save = store.decode(raw)221+ let system = browser.preference()222+ let family = families.at(0)223+ let l =224+ layout.choose(225+ layout.Box(0, 0, 400, 800),226+ render.device_pixel_ratio(),227+ store.density_units(save.density),228+ )229+ let model =108230 Model(109- screen: Landing,110- families:,111- family_index: 0,112- puzzle_index: 0,231+ screen: Boot,232+ save:,233+ system:,234+ layout: l,235+ board: layout.board_layout(l, family),236+ family:,237+ rung: 1,238+ surveys: [],239+ sweep: None,240+ puzzle: None,241+ previews: [],113242 pieces: [],114- initial_pieces: [],243+ start_pieces: [],115244 steps: 0,116245 history: [],117- save: persistence.decode(raw_save, families),246+ trace: [],247+ awaiting: 0,248+ started_at: 0,249+ drag: None,250+ anim: None,251+ scroll: None,252+ atlas_scroll: 0,253+ last_tick: 0.0,254+ shake: 0.0,255+ shake_off: 0,256+ shake_piece: None,257+ card_t: 0.0,258+ card_step: 0,259+ rise_t: 1.0,260+ rise: 0,261+ flash: 0.0,262+ wipe: -1.0,263+ finishing: None,264+ elapsed: 0.0,118265 settings_open: False,119266 shading_open: False,120- drag: None,121- distance: None,122- survey_count: 0,123- survey_phase: 1,124- survey_eccentricity: 0,125267 install_available: False,126- run_detours: 0,127- ),128- browser.watch_install(InstallAvailable),268+ last_frame: "",269+ probing: [],270+ walking: False,271+ )272+ #(273+ model,274+ effect.batch([275+ browser.watch_frame(Ticked),276+ browser.watch_viewport(ViewportChanged),277+ browser.watch_system(SystemChanged),278+ browser.watch_keys(KeyPressed),279+ browser.watch_install(InstallOffered),280+ browser.watch_solver(281+ Swept,282+ SurveyFinished,283+ PuzzleOffered,284+ Measured,285+ Probed,286+ ),287+ browser.expose_harness(HarnessWalked),288+ render.watch_pointer(289+ PointerDown,290+ PointerMoved,291+ PointerUp,292+ PointerCancelled,293+ ),294+ browser.load_fonts(FontsReady),295+ browser.register_service_worker(),296+ browser.request_persistence(),297+ ]),129298 )130299 }131300 301+// ------------------------------------------------------------------ update302+132303 fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {133304 case message {134- UserTappedLanding -> #(135- Model(..model, screen: Atlas),136- feedback(model, "page"),137- )138- UserSelectedFamily(index) -> open_family(model, index)139- UserTappedCard -> #(140- Model(141- ..model,142- screen: Playing,143- pieces: model.initial_pieces,144- steps: 0,145- history: [],146- drag: None,147- run_detours: 0,148- ),149- feedback(model, "page"),150- )151- UserTappedBack -> #(152- Model(153- ..model,154- screen: Atlas,155- settings_open: False,156- shading_open: False,157- drag: None,158- ),159- feedback(model, "page"),160- )161- UserTappedStats -> #(162- Model(..model, screen: Statistics),163- feedback(model, "page"),305+ Ticked(now) -> tick(model, now)306+ ViewportChanged | FontsReady -> {307+ // Painted from the *new* layout, not the one that has just been replaced:308+ // the canvas takes its backing store from the frame it is handed, and its309+ // CSS size from the view, so a frame drawn at the old size is a picture310+ // stretched to fit the new one.311+ let model = relayout(model)312+ #(model, repaint(model))313+ }314+ SystemChanged -> {315+ let model = Model(..model, system: browser.preference())316+ // The look is resolved from the theme, and AUTO is not a theme: a phone317+ // that goes dark at sunset has changed which one is in force, so the318+ // sprite cache is no longer describing this palette.319+ #(model, effect.batch([render.clear_sprites(), repaint(model)]))320+ }321+322+ PointerDown(x, y) -> pointer_down(model, x, y)323+ PointerMoved(x, y) -> pointer_moved(model, x, y)324+ PointerUp(x, y) -> pointer_up(model, x, y)325+ PointerCancelled -> #(Model(..model, drag: None, scroll: None), effect.none())326+ KeyPressed(key) -> key_pressed(model, key)327+328+ FamilyOpened(index) -> open_family(model, families.at(index))329+ Swept(family, count, total, phase) -> #(330+ Model(..model, sweep: Some(Sweep(family, count, total, phase))),331+ effect.none(),164332 )165- UserTappedSettings -> #(333+ SurveyFinished(family, ecc, count) -> survey_finished(model, family, ecc, count)334+ PuzzleOffered(family, rung, candidates) ->335+ puzzle_offered(model, family, rung, candidates)336+ Measured(family, seq, distances) -> measured(model, family, seq, distances)337+ HarnessWalked -> harness_step(model)338+ Probed(family, distances) -> harness_took(model, family, distances)339+340+ UndoPressed -> undo(model)341+ ResetPressed -> begin_play(model)342+ BackPressed -> go(Model(..model, settings_open: False), Atlas)343+ StatsPressed -> go(model, Record)344+ NextPressed -> open_rung(model, model.family, rung_of(model, model.family))345+ SettingsOpened -> #(166346 Model(..model, settings_open: True, shading_open: False),167347 effect.none(),168348 )169- UserClosedSettings -> #(349+ SettingsClosed -> #(170350 Model(..model, settings_open: False, shading_open: False),171351 effect.none(),172352 )173- UserToggledShadingMenu -> #(353+ ShadingMenuToggled -> #(174354 Model(..model, shading_open: !model.shading_open),175355 effect.none(),176356 )177- UserSelectedPalette(palette) -> {178- let preferences =179- persistence.Preferences(..model.save.preferences, palette:)180- persist(Model(..model, save: Save(..model.save, preferences:)))357+358+ PaletteChosen(palette) -> {359+ let model = Model(..model, save: store.Save(..model.save, palette:))360+ #(361+ model,362+ effect.batch([persist(model), render.clear_sprites(), repaint(model)]),363+ )181364 }182- UserSelectedShading(shading) -> {183- let preferences =184- persistence.Preferences(..model.save.preferences, shading:)185- persist(365+ ShadingChosen(pattern) -> {366+ let model =186367 Model(187368 ..model,188- save: Save(..model.save, preferences:),369+ save: store.Save(..model.save, shading: pattern),189370 shading_open: False,190- ),191- )192- }193- UserSelectedHints(hints) -> {194- let preferences =195- persistence.Preferences(..model.save.preferences, hints:)196- persist(Model(..model, save: Save(..model.save, preferences:)))197- }198- UserSelectedDensity(density) -> {199- let preferences =200- persistence.Preferences(..model.save.preferences, density:)201- persist(Model(..model, save: Save(..model.save, preferences:)))202- }203- UserToggledSound -> {204- let preferences =205- persistence.Preferences(206- ..model.save.preferences,207- sound: !model.save.preferences.sound,208371 )209- persist(Model(..model, save: Save(..model.save, preferences:)))372+ // The screen is baked into the sprites.373+ #(374+ model,375+ effect.batch([persist(model), render.clear_sprites(), repaint(model)]),376+ )210377 }211- UserToggledHaptics -> {212- let preferences =213- persistence.Preferences(214- ..model.save.preferences,215- haptics: !model.save.preferences.haptics,216- )217- persist(Model(..model, save: Save(..model.save, preferences:)))378+ HintsChosen(hints) -> {379+ let model = Model(..model, save: store.Save(..model.save, hints:))380+ #(model, effect.batch([persist(model), repaint(model)]))218381 }219- UserPressedPiece(piece_id, x, y, piece_width) -> {220- let piece = domain.piece_by_id(model.pieces, piece_id)221- let cell_px = case piece {222- Ok(piece) -> piece_width /. int.to_float(piece.width)223- Error(_) -> piece_width224- }382+ DensityChosen(density) -> {383+ // Re-cuts every pixel the app owns: the backing stores are sized in buffer384+ // pixels and the sprite cache is baked at the old ones.385+ let model = relayout(Model(..model, save: store.Save(..model.save, density:)))225386 #(226- Model(..model, drag: Some(Drag(piece_id, x, y, x, y, cell_px))),227- effect.none(),387+ model,388+ effect.batch([persist(model), render.clear_sprites(), repaint(model)]),228389 )229390 }230- UserMovedBoard(x, y) -> {231- let drag = case model.drag {232- None -> None233- Some(drag) -> Some(Drag(..drag, current_x: x, current_y: y))234- }235- #(Model(..model, drag:), effect.none())391+ SoundToggled -> {392+ let model =393+ Model(..model, save: store.Save(..model.save, sound: !model.save.sound))394+ #(model, persist(model))236395 }237- UserReleasedBoard(x, y) -> release_piece(model, x, y)238- UserPressedPieceKey(piece_id, key) ->239- case key_direction(key) {240- None -> #(model, effect.none())241- Some(direction) -> perform_move(model, piece_id, direction, 1)242- }243- UserTappedUndo -> undo(model)244- UserTappedReset -> #(245- Model(246- ..model,247- pieces: model.initial_pieces,248- steps: 0,249- history: [],250- drag: None,251- distance: Some(current_puzzle(model).optimal),252- run_detours: 0,253- ),254- feedback(model, "page"),255- )256- UserTappedNext -> next_puzzle(model)257- SurveyAdvanced(count, phase) -> #(258- Model(..model, survey_count: count, survey_phase: phase),259- effect.none(),260- )261- SurveyReady(distance, eccentricity, count) -> #(262- Model(263- ..model,264- screen: Card,265- distance: Some(distance),266- survey_eccentricity: eccentricity,267- survey_count: count,268- ),269- feedback(model, "page"),270- )271- DistanceUpdated(state, distance) -> {272- let current =273- domain.encode_state(current_family(model).board, model.pieces)274- case current == state {275- True -> {276- let added = case model.distance {277- Some(previous) if distance > previous -> distance - previous278- _ -> 0279- }280- #(281- Model(282- ..model,283- distance: Some(distance),284- run_detours: model.run_detours + added,285- ),286- effect.none(),287- )288- }289- False -> #(model, effect.none())290- }396+ HapticsToggled -> {397+ let model =398+ Model(399+ ..model,400+ save: store.Save(..model.save, haptics: !model.save.haptics),401+ )402+ #(model, persist(model))291403 }292- InstallAvailable -> #(293- Model(..model, install_available: True),294- effect.none(),295- )296- UserTappedInstall -> #(404+405+ InstallOffered -> #(Model(..model, install_available: True), effect.none())406+ InstallPressed -> #(297407 Model(..model, install_available: False),298408 browser.prompt_install(InstallFinished),299409 )
@@ -301,1031 +411,1810 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {
301411 }302412 }303413 304-fn open_family(model: Model, index: Int) -> #(Model, Effect(Message)) {305- let family = family_at(model, index)306- let progress = persistence.progress_for(model.save, family.id)307- let puzzle_index = int.min(catalog.puzzle_count(family) - 1, progress.cleared)308- let puzzle = catalog.puzzle_at(family, puzzle_index)309- let pieces = domain.pieces_from_grid(family.board, puzzle.grid)310- #(414+// -------------------------------------------------------------------- clock415+416+fn tick(model: Model, now: Float) -> #(Model, Effect(Message)) {417+ let dt = case model.last_tick {418+ 0.0 -> 16.0419+ last -> float.min(48.0, float.max(0.0, now -. last))420+ }421+ let model = Model(..model, last_tick: now, elapsed: model.elapsed +. dt)422+423+ let #(model, arrived) = advance_anim(model, dt)424+ let model =311425 Model(312426 ..model,313- screen: Survey,314- family_index: index,315- puzzle_index:,316- pieces:,317- initial_pieces: pieces,318- steps: 0,319- history: [],320- drag: None,321- distance: None,322- survey_count: 1,323- survey_phase: 1,324- run_detours: 0,325- ),326- effect.batch([327- feedback(model, "page"),328- browser.begin_survey(329- family.id,330- family.board.width,331- family.board.height,332- family.board.goal_x,333- family.board.goal_y,334- grid_string(catalog.preview_grid(family)),335- domain.encode_state(family.board, pieces),336- SurveyAdvanced,337- SurveyReady,338- ),339- ]),340- )341-}342-343-fn release_piece(344- model: Model,345- x: Float,346- y: Float,347-) -> #(Model, Effect(Message)) {348- case model.drag {349- None -> #(model, effect.none())350- Some(Drag(piece_id, start_x, start_y, _, _, cell_px)) -> {351- let dx = x -. start_x352- let dy = y -. start_y353- let distance =354- float.max(float.absolute_value(dx), float.absolute_value(dy))355- let released = Model(..model, drag: None)356-357- case distance <. float.max(7.0, cell_px *. 0.2) {358- True ->427+ shake: float.max(0.0, model.shake -. dt *. 0.05),428+ rise_t: float.min(1.0, model.rise_t +. dt /. 300.0),429+ flash: float.max(0.0, model.flash -. dt),430+ wipe: case model.wipe >=. 0.0 {431+ True -> {432+ let next = model.wipe +. dt *. 0.9359433 case360- domain.move_options(361- current_family(model).board,362- model.pieces,363- piece_id,364- )434+ next435+ >. int.to_float(int.max(model.layout.vw, model.layout.vh))365436 {366- [direction] -> perform_move(released, piece_id, direction, 1)367- [] -> #(released, feedback(model, "blocked"))368- _ -> #(released, effect.none())437+ True -> -1.0438+ False -> next369439 }370- False -> {371- let direction = case372- float.absolute_value(dx) >. float.absolute_value(dy)373- {374- True ->375- case dx >. 0.0 {376- True -> Right377- False -> Left378- }379- False ->380- case dy >. 0.0 {381- True -> Down382- False -> Up383- }384- }385- let requested = int.max(1, float.round(distance /. cell_px))386- perform_move(released, piece_id, direction, requested)387440 }388- }441+ False -> -1.0442+ },443+ card_t: case model.screen == Card {444+ True -> model.card_t +. dt445+ False -> model.card_t446+ },447+ )448+449+ // The stepped form of each clock, worked out here rather than in the painter:450+ // the loop has to know what the picture will be before it decides whether to451+ // draw one, and two places rounding the same clock their own way is how they452+ // drift apart.453+ let model =454+ Model(455+ ..model,456+ rise: case model.rise_t <. 1.0 {457+ True -> float.round({ 1.0 -. model.rise_t } *. 12.0 /. 2.0) * 2458+ False -> 0459+ },460+ shake_off: case model.shake >. 0.0 {461+ True -> float.round(sine(model.shake *. 0.9) *. 2.0)462+ False -> 0463+ },464+ // The card's title arrives in five steps, or has already arrived. The card465+ // itself keeps its pause either way: it is a screen naming the board, not466+ // an effect, and cutting it would be cutting information rather than467+ // motion.468+ card_step: case model.system.still {469+ True -> 4470+ False ->471+ int.min(4, float.truncate(float.min(1.0, model.card_t /. 280.0) *. 5.0))472+ },473+ )474+475+ // The card times out into the board; a solve waits for the wipe *and* for the476+ // last measurement, so the record it writes down is the run that happened.477+ let #(model, staged) = case model.screen, model.finishing {478+ Card, _ if model.card_t >. 1300.0 -> {479+ let #(m, e) = begin_play(model)480+ #(m, e)389481 }482+ _, Some(deadline) if model.elapsed >=. deadline && model.awaiting == 0 ->483+ finish(model)484+ _, _ -> #(model, effect.none())485+ }486+487+ let key = frame_key(model)488+ case key == model.last_frame {489+ True -> #(model, effect.batch([arrived, staged]))490+ False -> #(491+ Model(..model, last_frame: key),492+ effect.batch([arrived, staged, paint(model)]),493+ )390494 }391495 }392496 393-fn perform_move(394- model: Model,395- piece_id: Int,396- direction: Direction,397- requested: Int,398-) -> #(Model, Effect(Message)) {399- let family = current_family(model)400- let #(pieces, moved) =401- domain.move_many(family.board, model.pieces, piece_id, direction, requested)497+/// Everything the next picture will be made of, as one value to compare against498+/// the last one. The clocks advance every frame; the picture does not.499+fn frame_key(model: Model) -> String {500+ let anim = case model.anim {501+ Some(a) -> axis_id(a.axis) <> int.to_string(a.current)502+ None -> ""503+ }504+ let drag = case model.drag {505+ Some(d) -> int.to_string(d.piece) <> ":" <> int.to_string(d.offset)506+ None -> ""507+ }508+ let sweep = case model.sweep {509+ Some(s) -> s.family <> int.to_string(s.count) <> int.to_string(s.phase)510+ None -> ""511+ }512+ [513+ screen_id(model.screen),514+ int.to_string(model.rise),515+ int.to_string(model.shake_off),516+ anim,517+ drag,518+ int.to_string(model.card_step),519+ case model.card_t >. 240.0 {520+ True -> "1"521+ False -> "0"522+ },523+ case model.flash >. 0.0 {524+ True -> "1"525+ False -> "0"526+ },527+ case model.wipe >=. 0.0 {528+ True -> int.to_string(float.round(model.wipe))529+ False -> "-"530+ },531+ sweep,532+ int.to_string(model.atlas_scroll),533+ int.to_string(model.steps),534+ int.to_string(list.length(model.trace)),535+ int.to_string(list.length(model.previews)),536+ ]537+ |> list.fold("", fn(acc, part) { acc <> "|" <> part })538+}402539 403- case moved == 0 {404- True -> #(Model(..model, drag: None), feedback(model, "blocked"))405- False -> {406- let next =407- Model(408- ..model,409- pieces:,410- steps: model.steps + moved,411- history: [Move(piece_id, direction, moved), ..model.history],412- drag: None,540+fn advance_anim(model: Model, dt: Float) -> #(Model, Effect(Message)) {541+ case model.anim {542+ None -> #(model, effect.none())543+ Some(a) -> {544+ let elapsed = a.elapsed +. dt545+ let k = float.min(1.0, elapsed /. a.duration)546+ let eased = 1.0 -. cube(1.0 -. k)547+ // Quantised to the shading period, so the pattern never lands out of548+ // phase.549+ let current =550+ float.round(551+ { a.from +. { a.to -. a.from } *. eased }552+ /. int.to_float(shading.period),413553 )414- case domain.is_solved(family.board, pieces) {554+ * shading.period555+ case k >=. 1.0 {415556 False -> #(416- next,417- effect.batch([418- feedback(model, "move"),419- distance_effect(next),420- ]),557+ Model(..model, anim: Some(Anim(..a, elapsed:, current:))),558+ effect.none(),421559 )422- True -> {423- let save =424- persistence.record_solve(425- next.save,426- family.id,427- next.puzzle_index,428- catalog.puzzle_count(family),429- next.steps,430- current_puzzle(model).optimal,431- next.run_detours,432- )433- let won = Model(..next, screen: Won, save:)434- #(435- won,436- effect.batch([437- browser.persist(persistence.encode(save)),438- feedback(won, "solve"),439- ]),440- )441- }560+ True ->561+ case a.then {562+ None -> #(Model(..model, anim: None), effect.none())563+ Some(p) ->564+ perform_move(Model(..model, anim: None), p.piece, p.direction, p.cells)565+ }442566 }443567 }444568 }445569 }446570 447-fn undo(model: Model) -> #(Model, Effect(Message)) {448- case model.history {449- [] -> #(model, effect.none())450- [Move(piece_id, direction, cells), ..rest] -> {451- let pieces =452- indices(cells)453- |> list.fold(model.pieces, fn(pieces, _) {454- domain.move_unchecked(pieces, piece_id, domain.opposite(direction))455- })456- #(571+/// One through `count`, which is the shape every "for each cell travelled" loop572+/// in here wants.573+fn counting(count: Int) -> List(Int) {574+ int.range(from: 1, to: count + 1, with: [], run: fn(items, n) { [n, ..items] })575+ |> list.reverse576+}577+578+fn cube(v: Float) -> Float {579+ v *. v *. v580+}581+582+@external(javascript, "./klotski_ffi.mjs", "sine")583+fn sine(v: Float) -> Float584+585+// --------------------------------------------------------------------- flow586+587+fn go(model: Model, screen: Screen) -> #(Model, Effect(Message)) {588+ let model = Model(..model, screen:, settings_open: False, shading_open: False)589+ #(model, effect.batch([page(model), refresh_previews(model), paint(model)]))590+}591+592+fn rung_of(model: Model, family: Family) -> Int {593+ store.record_for(model.save, family.id).rung594+}595+596+fn survey_of(model: Model, id: String) -> Option(Surveyed) {597+ model.surveys598+ |> list.find(fn(s) { s.family == id })599+ |> option.from_result600+}601+602+/// Open a family at whichever rung it has reached, sweeping it first if new.603+fn open_family(model: Model, family: Family) -> #(Model, Effect(Message)) {604+ let model = Model(..model, family:, rung: rung_of(model, family))605+ case survey_of(model, family.id) {606+ Some(_) -> #(607+ Model(..model, settings_open: False),608+ effect.batch([page(model), ask_for_puzzle(model, family, model.rung)]),609+ )610+ None -> {611+ let model =457612 Model(458613 ..model,459- pieces:,460- steps: int.max(0, model.steps - cells),461- history: rest,462- drag: None,463- distance: None,464- ),614+ screen: Sweeping,615+ settings_open: False,616+ sweep: Some(Sweep(family.id, 1, 1, 1)),617+ )618+ #(619+ model,465620 effect.batch([466- feedback(model, "page"),467- browser.query_distance(468- current_family(model).id,469- domain.encode_state(current_family(model).board, pieces),470- DistanceUpdated,621+ page(model),622+ browser.survey(623+ family.id,624+ family.width,625+ family.height,626+ family.goal_x,627+ family.goal_y,628+ family.grid,471629 ),630+ paint(model),472631 ]),473632 )474633 }475634 }476635 }477636 478-fn next_puzzle(model: Model) -> #(Model, Effect(Message)) {479- let family = current_family(model)480- case model.puzzle_index + 1 < catalog.puzzle_count(family) {481- False -> #(Model(..model, screen: Atlas), feedback(model, "page"))482- True -> {483- let puzzle_index = model.puzzle_index + 1484- let puzzle = catalog.puzzle_at(family, puzzle_index)485- let pieces = domain.pieces_from_grid(family.board, puzzle.grid)486- #(487- Model(488- ..model,489- screen: Card,490- puzzle_index:,491- pieces:,492- initial_pieces: pieces,493- steps: 0,494- history: [],495- distance: Some(puzzle.optimal),496- run_detours: 0,497- ),498- feedback(model, "page"),499- )500- }637+fn survey_finished(638+ model: Model,639+ id: String,640+ ecc: Int,641+ count: Int,642+) -> #(Model, Effect(Message)) {643+ let surveys = [644+ Surveyed(id, ecc, count),645+ ..list.filter(model.surveys, fn(s) { s.family != id })646+ ]647+ let model = Model(..model, surveys:, sweep: None)648+ case model.family.id == id && model.screen == Sweeping {649+ True -> #(model, ask_for_puzzle(model, model.family, model.rung))650+ False -> #(model, refresh_previews(model))501651 }502652 }503653 504-fn persist(model: Model) -> #(Model, Effect(Message)) {505- #(model, browser.persist(persistence.encode(model.save)))654+/// Which candidates a rung may be drawn from, and under what seeds.655+///656+/// The whole of the schedule is here: the depth the ladder asks for, whether it657+/// has run out of depth, how wide the window of candidates is, where in the band658+/// it starts, and the stream the walker is run under. The worker is handed those659+/// numbers and hands back measurements.660+fn ask_for_puzzle(model: Model, family: Family, rung: Int) -> Effect(Message) {661+ case survey_of(model, family.id) {662+ None -> effect.none()663+ Some(sv) -> {664+ let want = progress.depth_for_rung(rung, sv.ecc)665+ let ceiling = want >= sv.ecc - 1666+ let #(seed, _) = prng.next_int(prng.seeded_at(family.id, rung * 101), 1_000_000)667+ browser.request_puzzle(668+ family_id: family.id,669+ rung:,670+ want:,671+ ceiling:,672+ // Past the ceiling the band is the same every time, so the window673+ // narrows: weighing twelve overlapping candidates and keeping the best674+ // simply re-elects the same winner rung after rung.675+ window: case ceiling {676+ True -> progress.ceiling_pool677+ False -> progress.pool678+ },679+ offset: prng.hash_string(family.id)680+ + rung681+ * case ceiling {682+ True -> 5683+ False -> 7684+ },685+ seed:,686+ stray: difficulty.stray,687+ rollouts: difficulty.rollouts,688+ goal_x: family.goal_x,689+ goal_y: family.goal_y,690+ )691+ }692+ }506693 }507694 508-fn feedback(model: Model, kind: String) -> Effect(Message) {509- browser.feedback(510- kind,511- model.save.preferences.sound,512- model.save.preferences.haptics,695+type Candidate {696+ Candidate(697+ grid: List(Int),698+ optimal: Int,699+ wander: Float,700+ retreats: Int,701+ branch: Float,513702 )514703 }515704 516-fn family_at(model: Model, index: Int) -> Family {517- model.families518- |> list.drop(index)519- |> list.first520- |> result.unwrap(catalog.family_at(0))705+fn candidate_decoder() -> decode.Decoder(Candidate) {706+ use grid <- decode.field("grid", decode.list(decode.int))707+ use optimal <- decode.field("optimal", decode.int)708+ use wander <- decode.field("wander", decode.float)709+ use retreats <- decode.field("retreats", decode.int)710+ use branch <- decode.field("branch", decode.float)711+ decode.success(Candidate(grid:, optimal:, wander:, retreats:, branch:))521712 }522713 523-fn current_family(model: Model) -> Family {524- family_at(model, model.family_index)714+/// Keep the candidate whose state space punishes a strayed walker hardest.715+fn puzzle_offered(716+ model: Model,717+ id: String,718+ rung: Int,719+ candidates: List(Dynamic),720+) -> #(Model, Effect(Message)) {721+ let scored =722+ candidates723+ |> list.filter_map(fn(raw) {724+ decode.run(raw, candidate_decoder())725+ |> result.map(fn(c) {726+ Puzzle(727+ family: id,728+ rung:,729+ grid: c.grid,730+ optimal: c.optimal,731+ score: difficulty.score(732+ optimal: c.optimal,733+ wander: c.wander,734+ retreats: c.retreats,735+ branch: c.branch,736+ ),737+ )738+ })739+ |> result.replace_error(Nil)740+ })741+ let best = hardest(scored, None)742+743+ case best {744+ None -> #(model, effect.none())745+ Some(puzzle) -> {746+ let previews = [747+ puzzle,748+ ..list.filter(model.previews, fn(p) { p.family != id })749+ ]750+ let model = Model(..model, previews:)751+ // A puzzle asked for because a family was opened becomes the card; one752+ // asked for to fill an atlas row is only ever a number on a card.753+ case model.family.id == id && model.rung == rung && wants_card(model) {754+ False -> #(model, paint(model))755+ True -> {756+ let model = Model(..model, puzzle: Some(puzzle), card_t: 0.0, card_step: 0)757+ go(model, Card)758+ }759+ }760+ }761+ }525762 }526763 527-fn current_puzzle(model: Model) -> Puzzle {528- catalog.puzzle_at(current_family(model), model.puzzle_index)764+fn hardest(candidates: List(Puzzle), best: Option(Puzzle)) -> Option(Puzzle) {765+ case candidates, best {766+ [], _ -> best767+ [next, ..rest], Some(held) ->768+ case next.score >. held.score {769+ True -> hardest(rest, Some(next))770+ False -> hardest(rest, best)771+ }772+ [next, ..rest], None -> hardest(rest, Some(next))773+ }529774 }530775 531-fn key_direction(key: String) -> Option(Direction) {532- case key {533- "ArrowUp" -> Some(Up)534- "ArrowDown" -> Some(Down)535- "ArrowLeft" -> Some(Left)536- "ArrowRight" -> Some(Right)537- _ -> None776+fn wants_card(model: Model) -> Bool {777+ case model.screen {778+ Sweeping -> True779+ Atlas -> True780+ Won -> True781+ _ -> False538782 }539783 }540784 541-fn view(model: Model) -> Element(Message) {542- let preferences = model.save.preferences543- html.div(544- [545- attribute.class(546- "app palette-" <> persistence.palette_id(preferences.palette),547- ),548- attribute.attribute("data-shading", resolved_shading(model)),549- attribute.attribute(550- "data-density",551- persistence.density_id(preferences.density),552- ),553- ],554- [555- view_screen(model),556- case model.settings_open {557- True -> settings_sheet(model)558- False -> element.none()559- },560- ],561- )785+fn open_rung(model: Model, family: Family, rung: Int) -> #(Model, Effect(Message)) {786+ let model = Model(..model, family:, rung:, puzzle: None)787+ #(model, ask_for_puzzle(model, family, rung))562788 }563789 564-fn view_screen(model: Model) -> Element(Message) {790+/// A family's atlas row shows what its next board costs, which means asking for791+/// it. Only for families that have been swept, and only when what is cached is792+/// for a rung that is no longer next.793+fn refresh_previews(model: Model) -> Effect(Message) {565794 case model.screen {566- Landing -> landing_view()567- Atlas -> atlas_view(model)568- Survey -> survey_view(model)569- Card -> card_view(model)570- Playing -> play_view(model)571- Won -> won_view(model)572- Statistics -> statistics_view(model)795+ Atlas ->796+ families.all()797+ |> list.filter_map(fn(family) {798+ let rung = rung_of(model, family)799+ let known =800+ model.previews801+ |> list.find(fn(p) { p.family == family.id && p.rung == rung })802+ case survey_of(model, family.id), known {803+ Some(_), Error(_) -> Ok(ask_for_puzzle(model, family, rung))804+ _, _ -> Error(Nil)805+ }806+ })807+ |> effect.batch808+ _ -> effect.none()573809 }574810 }575811 576-fn survey_view(model: Model) -> Element(Message) {577- let family = current_family(model)578- let phase_label = case model.survey_phase {579- 1 -> "CONFIGURATIONS REACHED"580- _ -> "MEASURING DISTANCE TO GOAL"812+fn begin_play(model: Model) -> #(Model, Effect(Message)) {813+ case model.puzzle {814+ None -> #(model, effect.none())815+ Some(puzzle) -> {816+ let board = families.board(model.family)817+ let pieces = domain.pieces_from_grid(board, puzzle.grid)818+ let model =819+ Model(820+ ..model,821+ screen: Playing,822+ settings_open: False,823+ walking: False,824+ pieces:,825+ start_pieces: pieces,826+ steps: 0,827+ history: [],828+ trace: [puzzle.optimal],829+ awaiting: 0,830+ drag: None,831+ anim: None,832+ shake: 0.0,833+ shake_piece: None,834+ finishing: None,835+ // The board falls into place, unless the system asked for stillness —836+ // then it is simply in place, which is where the fall was always going.837+ rise_t: case model.system.still {838+ True -> 1.0839+ False -> 0.0840+ },841+ started_at: browser.now(),842+ board: layout.board_layout(model.layout, model.family),843+ )844+ #(model, effect.batch([page(model), paint(model)]))845+ }581846 }582- html.div([attribute.class("screen survey-screen")], [583- html.main([attribute.class("survey-layout")], [584- html.section([attribute.class("window survey-window")], [585- html.p([attribute.class("eyebrow")], [html.text(family.label)]),586- html.div([attribute.class("survey-pulse")], []),587- html.strong([], [html.text(int.to_string(model.survey_count))]),588- html.p([], [html.text(phase_label)]),589- ]),590- ]),591- ])592847 }593848 594-fn landing_view() -> Element(Message) {595- let family = catalog.family_at(0)596- let pieces =597- domain.pieces_from_grid(family.board, catalog.preview_grid(family))598- html.button([attribute.class("landing"), event.on_click(UserTappedLanding)], [599- html.div([attribute.class("window landing-window")], [600- html.h1([], [html.text("KLOTSKI")]),601- html.p([attribute.class("subtitle")], [html.text("SLIDING BLOCK PUZZLES")]),602- html.div([attribute.class("hairline")], []),603- board_element(family, pieces, False, None),604- html.span([attribute.class("tap-prompt")], [html.text("TAP TO BEGIN")]),605- ]),606- ])607-}608-609-fn top_bar(model: Model, title: String, home: Bool) -> Element(Message) {610- html.header([attribute.class("menu-bar")], [611- case home {612- True ->613- html.div([attribute.class("menu-brand")], [614- html.span([attribute.class("signature")], [615- html.span([attribute.class("signature-mark")], []),616- ]),617- html.strong([], [html.text(title)]),618- ])619- False ->620- html.button(621- [attribute.class("ghost compact"), event.on_click(UserTappedBack)],622- [html.text("BACK")],623- )624- },625- case home {626- True -> element.none()627- False -> html.span([attribute.class("menu-title")], [html.text(title)])628- },629- html.div([attribute.class("menu-actions")], [630- case model.screen == Atlas {631- True ->632- case model.install_available {633- True ->634- html.button(635- [attribute.class("compact"), event.on_click(UserTappedInstall)],636- [html.text("INSTALL")],637- )638- False -> element.none()639- }640- False -> element.none()641- },642- case model.screen == Atlas {643- True ->644- html.button(645- [646- attribute.class("icon-button icon-record"),647- attribute.attribute("aria-label", "Record"),648- event.on_click(UserTappedStats),649- ],650- [],651- )652- False -> element.none()653- },654- case model.screen == Atlas {655- True ->656- html.button(657- [658- attribute.class("icon-button icon-settings"),659- attribute.attribute("aria-label", "Settings"),660- event.on_click(UserTappedSettings),661- ],662- [],663- )664- False -> element.none()665- },849+/// Write the solve down.850+///851+/// Waited for on purpose: the shape of a run is only knowable once every step's852+/// distance has been measured, and the trace is a session thing while the log is853+/// a capped ring buffer, so there is no later moment to recover it from.854+fn finish(model: Model) -> #(Model, Effect(Message)) {855+ let stats = walk.trace_stats(model.trace)856+ let optimal = case model.puzzle {857+ Some(p) -> p.optimal858+ None -> stats.optimal859+ }860+ let score = case model.puzzle {861+ Some(p) -> p.score862+ None -> 1.6863+ }864+ let #(save, gain) =865+ store.record(866+ save: model.save,867+ family_id: model.family.id,868+ rung: model.rung,869+ optimal:,870+ steps: model.steps,871+ ms: int.max(0, browser.now() - model.started_at),872+ score:,873+ detours: stats.detours,874+ worst_detour: stats.worst_detour,875+ now: browser.now(),876+ today: browser.today(),877+ )878+ let model = Model(..model, save:, screen: Won, finishing: None, walking: False)879+ #(880+ model,881+ effect.batch([882+ browser.persist(store.encode(save)),883+ browser.buzz(884+ case gain.levelled {885+ True -> [24, 40, 90, 40, 120]886+ False -> [24, 40, 90]887+ },888+ model.save.haptics,889+ ),890+ paint(model),666891 ]),667- ])892+ )668893 }669894 670-fn atlas_view(model: Model) -> Element(Message) {671- html.div([attribute.class("screen atlas-screen")], [672- top_bar(model, "PUZZLES", True),673- html.main(674- [attribute.class("atlas")],675- list.index_map(model.families, fn(family, index) {676- let progress = persistence.progress_for(model.save, family.id)677- let puzzle_index =678- int.min(catalog.puzzle_count(family) - 1, progress.cleared)679- let puzzle = catalog.puzzle_at(family, puzzle_index)680- let preview_grid = case progress.cleared == 0 {681- True -> catalog.preview_grid(family)682- False -> puzzle.grid683- }684- let pieces = domain.pieces_from_grid(family.board, preview_grid)685- html.button(686- [687- attribute.class("window family-card"),688- event.on_click(UserSelectedFamily(index)),689- ],690- [691- html.div([attribute.class("mini-board-wrap")], [692- board_element(family, pieces, False, None),693- ]),694- html.div([attribute.class("family-copy")], [695- html.h2([], [html.text(family.label)]),696- html.p([], [697- case progress.cleared == 0 {698- True -> element.none()699- False ->700- html.span([], [701- html.text(int.to_string(puzzle.optimal) <> " STEPS"),702- ])703- },704- html.span([], [705- html.text("STAGE " <> int.to_string(puzzle.stage)),706- ]),707- ]),708- case progress.cleared == 0 {709- True ->710- html.small([attribute.class("begin-label")], [711- html.text("TAP TO BEGIN"),712- ])713- False ->714- html.div([], [715- html.div([attribute.class("depth-track")], [716- html.span(717- [718- attribute.class("depth-marker"),719- attribute.attribute(720- "style",721- "--progress:"722- <> int.to_string(puzzle.optimal)723- <> ";--total:"724- <> int.to_string(family.ceiling),725- ),726- ],727- [],728- ),729- ]),730- html.small([], [731- html.text("TO " <> int.to_string(family.ceiling)),732- ]),733- ])895+// -------------------------------------------------------------------- moves896+897+fn perform_move(898+ model: Model,899+ piece_id: Int,900+ direction: Direction,901+ cells: Int,902+) -> #(Model, Effect(Message)) {903+ let board = families.board(model.family)904+ let #(pieces, moved) =905+ domain.move_many(board, model.pieces, piece_id, direction, cells)906+ case moved {907+ 0 -> refuse(model, piece_id)908+ _ -> {909+ // Every configuration the move passed through, so the trace is measured910+ // rather than inferred: a unit move changes the distance by exactly -1, 0911+ // or +1, and a slide of four cells is four of those.912+ let states =913+ counting(moved)914+ |> list.map(fn(n) {915+ let #(at, _) =916+ domain.move_many(board, model.pieces, piece_id, direction, n)917+ domain.encode_state(board, at)918+ })919+ let seq = list.length(model.trace)920+ let model =921+ Model(922+ ..model,923+ pieces:,924+ steps: model.steps + moved,925+ history: [Step(piece_id, direction, moved), ..model.history],926+ drag: None,927+ awaiting: model.awaiting + 1,928+ )929+ let solved = domain.is_solved(board, pieces)930+ let sound =931+ effect.batch([932+ browser.steps(moved, model.save.sound),933+ browser.buzz([8], model.save.haptics),934+ ])935+ let measure =936+ browser.request_distances(model.family.id, states, seq)937+ case solved {938+ False -> #(model, effect.batch([sound, measure, paint(model)]))939+ True -> {940+ // The inverting wipe, spent once per solve — and skipped entirely941+ // under reduced motion, along with the pause it exists to fill. It is942+ // the whole screen turning over; there is no gentler version of it943+ // that is still it.944+ let still = model.system.still945+ let model =946+ Model(947+ ..model,948+ wipe: case still {949+ True -> -1.0950+ False -> 0.0734951 },952+ finishing: Some(953+ model.elapsed954+ +. case still {955+ True -> 0.0956+ False -> 330.0957+ },958+ ),959+ )960+ #(961+ model,962+ effect.batch([963+ sound,964+ measure,965+ browser.cue("solve", model.save.sound),966+ paint(model),735967 ]),736- ],737- )738- }),739- ),740- ])968+ )969+ }970+ }971+ }972+ }741973 }742974 743-fn card_view(model: Model) -> Element(Message) {744- let family = current_family(model)745- let puzzle = current_puzzle(model)746- html.button(747- [attribute.class("screen card-screen"), event.on_click(UserTappedCard)],748- [749- html.div([attribute.class("window stage-card")], [750- html.h1([], [html.text(family.label)]),751- html.p([], [html.text("STAGE " <> int.to_string(puzzle.stage))]),752- html.p([], [753- html.text(int.to_string(puzzle.optimal) <> " STEPS MINIMUM"),754- ]),755- ]),756- ],975+fn refuse(model: Model, piece_id: Int) -> #(Model, Effect(Message)) {976+ // The jolt that says a piece has nowhere to go. Under reduced motion the thud977+ // and the buzz say it on their own; they were never movement.978+ let model = case model.system.still {979+ True -> Model(..model, drag: None)980+ False ->981+ Model(..model, drag: None, shake: 12.0, shake_piece: Some(piece_id))982+ }983+ #(984+ model,985+ effect.batch([986+ browser.cue("thud", model.save.sound),987+ browser.buzz([10, 26, 10], model.save.haptics),988+ paint(model),989+ ]),757990 )758991 }759992 760-fn play_view(model: Model) -> Element(Message) {761- let family = current_family(model)762- let puzzle = current_puzzle(model)763- let distance = model.distance |> option.unwrap(puzzle.optimal)764- let start_distance = int.max(1, puzzle.optimal)765- html.div([attribute.class("screen game-screen")], [766- top_bar(model, "", False),767- html.main([attribute.class("game-layout")], [768- html.section([attribute.class("window board-window")], [769- board_element(family, model.pieces, True, model.drag),770- html.h1([attribute.class("board-title")], [html.text(family.label)]),771- ]),772- html.aside([attribute.class("window control-window")], [773- case model.save.preferences.hints == Off {774- True -> element.none()775- False -> gap_gauge(distance, start_distance)776- },777- html.div([attribute.class("hud")], [778- stat(int.to_string(model.steps), "STEPS"),779- case model.save.preferences.hints == Full {780- True -> stat(int.to_string(distance), "TO GOAL")781- False -> element.none()993+fn undo(model: Model) -> #(Model, Effect(Message)) {994+ case model.history {995+ [] -> #(model, effect.none())996+ [Step(piece, direction, cells), ..rest] -> {997+ let pieces =998+ counting(cells)999+ |> list.fold(model.pieces, fn(pieces, _) {1000+ domain.move_unchecked(pieces, piece, domain.opposite(direction))1001+ })1002+ let keep = int.max(1, list.length(model.trace) - cells)1003+ let model =1004+ Model(1005+ ..model,1006+ pieces:,1007+ steps: int.max(0, model.steps - cells),1008+ history: rest,1009+ trace: list.take(model.trace, keep),1010+ drag: None,1011+ // A single struck frame of inversion. Not drawn under reduced motion:1012+ // a full-screen flash is the one thing on this list that can actually1013+ // hurt someone, and the undo is already announced by the board and the1014+ // tone.1015+ flash: case model.system.still {1016+ True -> 0.01017+ False -> 55.07821018 },783- stat(int.to_string(puzzle.optimal), "OPTIMAL"),784- ]),785- html.div([attribute.class("actions")], [786- html.button(787- [788- event.on_click(UserTappedUndo),789- attribute.disabled(list.is_empty(model.history)),790- ],791- [html.text("UNDO")],792- ),793- html.button(794- [attribute.class("ghost"), event.on_click(UserTappedReset)],795- [html.text("RESET")],796- ),797- ]),798- html.p([attribute.class("interaction-hint")], [799- html.text("DRAG A BLOCK · ARROW KEYS ALSO WORK"),1019+ )1020+ #(1021+ model,1022+ effect.batch([1023+ browser.cue("page", model.save.sound),1024+ browser.buzz([6], model.save.haptics),1025+ paint(model),8001026 ]),801- ]),802- ]),803- ])1027+ )1028+ }1029+ }8041030 }8051031 806-fn won_view(model: Model) -> Element(Message) {807- let family = current_family(model)808- let puzzle = current_puzzle(model)809- html.div([attribute.class("screen won-screen")], [810- top_bar(model, "", False),811- html.main([attribute.class("game-layout")], [812- html.section([attribute.class("window solved-window")], [813- html.div([attribute.class("trophy")], [814- html.span([attribute.class("trophy-cup")], []),815- html.span([attribute.class("trophy-stem")], []),816- html.span([attribute.class("trophy-base")], []),817- ]),818- html.p([], [html.text("SOLVED")]),819- html.h1([], [html.text(family.label)]),820- html.div(821- [822- attribute.class("run-chart"),823- attribute.attribute(824- "style",825- "--peak:" <> int.to_string(int.max(1, puzzle.optimal)),826- ),827- ],828- indices(int.max(1, model.steps))829- |> list.map(fn(index) {830- html.span(831- [832- attribute.attribute(833- "style",834- "--bar:"835- <> int.to_string(int.max(836- 1,837- puzzle.optimal - int.min(index, puzzle.optimal - 1),838- )),839- ),840- ],841- [],842- )843- }),844- ),845- html.small([], [html.text("DISTANCE TO GOAL")]),846- ]),847- html.aside([attribute.class("window control-window")], [848- html.div([attribute.class("hud record-hud")], [849- stat(int.to_string(model.steps), "YOUR STEPS"),850- stat(int.to_string(puzzle.optimal), "OPTIMAL"),851- stat(int.to_string(model.run_detours), "DETOURS"),852- stat(int.to_string(model.run_detours), "DEEPEST"),853- ]),854- html.div([attribute.class("actions")], [855- html.button([event.on_click(UserTappedNext)], [856- html.text(857- case model.puzzle_index + 1 < catalog.puzzle_count(family) {858- True -> "NEXT"859- False -> "PUZZLES"860- },861- ),862- ]),863- html.button(864- [attribute.class("ghost"), event.on_click(UserTappedBack)],865- [html.text("PUZZLES")],866- ),867- ]),868- ]),869- ]),870- ])1032+fn measured(1033+ model: Model,1034+ id: String,1035+ seq: Int,1036+ distances: List(Int),1037+) -> #(Model, Effect(Message)) {1038+ let awaiting = int.max(0, model.awaiting - 1)1039+ // An answer that no longer describes the run — an undo landed while it was in1040+ // flight — is dropped rather than spliced in at the wrong place.1041+ case id == model.family.id && list.length(model.trace) == seq {1042+ False -> #(Model(..model, awaiting:), effect.none())1043+ True -> {1044+ let model =1045+ Model(1046+ ..model,1047+ awaiting:,1048+ trace: list.append(model.trace, list.filter(distances, fn(d) { d >= 0 })),1049+ )1050+ #(model, effect.batch([keep_walking(model), paint(model)]))1051+ }1052+ }8711053 }8721054 873-fn statistics_view(model: Model) -> Element(Message) {874- let efficiency = case model.save.steps == 0 {875- True -> "0%"876- False -> int.to_string(model.save.optimal * 100 / model.save.steps) <> "%"1055+// ------------------------------------------------------------------ harness1056+//1057+// Walk the board down the distance field to the goal.1058+//1059+// A debugging aid rather than a feature — but a useful one, since it reaches the1060+// record screen without anyone having to solve a hundred-step board by hand, and1061+// it is how a screenshot of a real run gets taken.1062+//1063+// It plays through the game's own input path: the trace, the history, the sounds1064+// and the record all happen exactly as they would under a thumb, which is the1065+// whole point. A walk that had its own way of moving a block would be a second1066+// implementation nobody looks at.1067+1068+fn harness_step(model: Model) -> #(Model, Effect(Message)) {1069+ // Only ever one question in flight, and only once the last step's own distance1070+ // has landed: the next choice is made against the board that actually exists,1071+ // not against the one the trace has not caught up with yet.1072+ case model.screen, model.probing, model.awaiting {1073+ Playing, [], 0 -> {1074+ let board = families.board(model.family)1075+ let moves =1076+ model.pieces1077+ |> list.flat_map(fn(piece) {1078+ domain.move_options(board, model.pieces, piece.id)1079+ |> list.map(fn(direction) { #(piece.id, direction) })1080+ })1081+ let states =1082+ list.map(moves, fn(move) {1083+ let #(piece, direction) = move1084+ let #(after, _) =1085+ domain.move_many(board, model.pieces, piece, direction, 1)1086+ domain.encode_state(board, after)1087+ })1088+ #(1089+ Model(..model, probing: moves, walking: True),1090+ browser.probe(model.family.id, states),1091+ )1092+ }1093+ Playing, _, _ -> #(Model(..model, walking: True), effect.none())1094+ _, _, _ -> #(model, effect.none())8771095 }878- html.div([attribute.class("screen stats-screen")], [879- top_bar(model, "RECORD", False),880- html.main([attribute.class("stats-grid")], [881- html.section([attribute.class("window level-panel")], [882- html.p([attribute.class("level-label")], [html.text("LEVEL")]),883- html.p([attribute.class("xp-label")], [884- html.text(int.to_string(model.save.xp) <> " XP"),885- ]),886- html.strong([], [html.text("1")]),887- html.div(888- [889- attribute.class("level-track"),890- attribute.attribute(891- "style",892- "--level-progress:" <> int.to_string(int.min(45, model.save.xp)),893- ),894- ],895- [],896- ),897- html.small([], [898- html.text(int.to_string(model.save.xp) <> " / 45 TO 2"),899- ]),900- ]),901- html.section([attribute.class("window lifetime-panel")], [902- html.div([attribute.class("record-cells")], [903- stat(int.to_string(model.save.solved), "SOLVED"),904- stat(int.to_string(model.save.clean), "CLEAN"),905- stat(int.to_string(model.save.detours), "DETOURS"),906- stat(efficiency, "EFFICIENCY"),907- stat("0S", "PLAYED"),908- stat(909- case model.save.solved > 0 {910- True -> "1"911- False -> "0"912- },913- "DAY RUN",1096+}1097+1098+fn harness_took(1099+ model: Model,1100+ id: String,1101+ distances: List(Int),1102+) -> #(Model, Effect(Message)) {1103+ let here = current_distance(model)1104+ let downhill =1105+ list.zip(model.probing, distances)1106+ |> list.find(fn(pair) { pair.1 == here - 1 })1107+ let model = Model(..model, probing: [])1108+ case id == model.family.id, downhill {1109+ True, Ok(#(#(piece, direction), _)) ->1110+ perform_move(model, piece, direction, 1)1111+ // Nothing downhill from here means the board is solved, or the walk was1112+ // asked for on a board the survey no longer describes.1113+ _, _ -> #(Model(..model, walking: False), effect.none())1114+ }1115+}1116+1117+/// The step that keeps a walk going, taken when the last one has been measured.1118+fn keep_walking(model: Model) -> Effect(Message) {1119+ case model.walking && model.screen == Playing && model.awaiting == 0 {1120+ True -> effect.from(fn(dispatch) { dispatch(HarnessWalked) })1121+ False -> effect.none()1122+ }1123+}1124+1125+// -------------------------------------------------------------------- input1126+1127+fn piece_at(model: Model, x: Float, y: Float) -> Option(Piece) {1128+ let b = model.board1129+ let cell = b.cell1130+ let cx = float.truncate({ x -. int.to_float(b.board.x) } /. int.to_float(cell))1131+ let cy =1132+ float.truncate({ y -. int.to_float(b.board.y - model.rise) } /. int.to_float(cell))1133+ case1134+ cx < 01135+ || cy < 01136+ || cx >= model.family.width1137+ || cy >= model.family.height1138+ {1139+ True -> None1140+ False ->1141+ model.pieces1142+ |> list.find(fn(p) {1143+ cx >= p.x && cx < p.x + p.width && cy >= p.y && cy < p.y + p.height1144+ })1145+ |> option.from_result1146+ }1147+}1148+1149+fn pointer_down(model: Model, x: Float, y: Float) -> #(Model, Effect(Message)) {1150+ case model.screen {1151+ Boot -> {1152+ let #(m, e) = go(model, Atlas)1153+ #(m, effect.batch([browser.unlock_audio(), e]))1154+ }1155+ Card -> begin_play(model)1156+ Atlas -> #(1157+ Model(..model, scroll: Some(#(y, model.atlas_scroll, 0.0))),1158+ effect.none(),1159+ )1160+ Playing ->1161+ case model.anim {1162+ Some(_) -> #(model, effect.none())1163+ None ->1164+ case piece_at(model, x, y) {1165+ None -> #(model, effect.none())1166+ Some(piece) -> #(1167+ Model(1168+ ..model,1169+ drag: Some(Drag(piece.id, x, y, None, 0, 0, 0)),1170+ ),1171+ paint(model),1172+ )1173+ }1174+ }1175+ _ -> #(model, effect.none())1176+ }1177+}1178+1179+fn pointer_moved(model: Model, x: Float, y: Float) -> #(Model, Effect(Message)) {1180+ case model.scroll, model.drag {1181+ Some(#(origin, from, moved)), _ -> {1182+ let dy = y -. origin1183+ let model =1184+ Model(1185+ ..model,1186+ scroll: Some(#(origin, from, float.max(moved, float.absolute_value(dy)))),1187+ atlas_scroll: clamp_scroll(model, from + float.round(dy)),1188+ )1189+ #(model, paint(model))1190+ }1191+ None, Some(drag) -> {1192+ let dx = x -. drag.origin_x1193+ let dy = y -. drag.origin_y1194+ case drag.axis {1195+ None ->1196+ case float.absolute_value(dx) <. 3.0 && float.absolute_value(dy) <. 3.0 {1197+ True -> #(model, effect.none())1198+ False -> begin_axis(model, drag, dx, dy)1199+ }1200+ Some(axis) -> {1201+ let raw = case axis {1202+ Horizontal -> dx1203+ Vertical -> dy1204+ }1205+ let clamped =1206+ float.max(1207+ int.to_float(drag.reach_negative),1208+ float.min(int.to_float(drag.reach_positive), raw),1209+ )1210+ let offset =1211+ float.round(clamped /. int.to_float(shading.period))1212+ * shading.period1213+ #(1214+ Model(..model, drag: Some(Drag(..drag, offset:))),1215+ paint(model),1216+ )1217+ }1218+ }1219+ }1220+ None, None -> #(model, effect.none())1221+ }1222+}1223+1224+fn begin_axis(1225+ model: Model,1226+ drag: Drag,1227+ dx: Float,1228+ dy: Float,1229+) -> #(Model, Effect(Message)) {1230+ let axis = case float.absolute_value(dx) >. float.absolute_value(dy) {1231+ True -> Horizontal1232+ False -> Vertical1233+ }1234+ let board = families.board(model.family)1235+ let #(forward, backward) = case axis {1236+ Horizontal -> #(Right, Left)1237+ Vertical -> #(Down, Up)1238+ }1239+ let cell = model.board.cell1240+ let positive =1241+ domain.free_cells(board, model.pieces, drag.piece, forward) * cell1242+ let negative =1243+ -domain.free_cells(board, model.pieces, drag.piece, backward) * cell1244+ case positive == 0 && negative == 0 {1245+ True -> refuse(model, drag.piece)1246+ False -> #(1247+ Model(1248+ ..model,1249+ drag: Some(1250+ Drag(1251+ ..drag,1252+ axis: Some(axis),1253+ reach_positive: positive,1254+ reach_negative: negative,9141255 ),915- ]),916- ]),917- html.section([attribute.class("window family-table")], [918- html.div([attribute.class("family-table-head")], [919- html.span([], [html.text("SET")]),920- html.span([], [html.text("STAGE")]),921- html.span([], [html.text("DONE")]),922- html.span([], [html.text("DEEPEST")]),923- ]),924- html.div(925- [attribute.class("family-table-body")],926- list.map(model.families, fn(family) {927- let progress = persistence.progress_for(model.save, family.id)928- html.div([], [929- html.strong([], [html.text(family.label)]),930- html.span([], [html.text(int.to_string(progress.cleared + 1))]),931- html.span([], [html.text(int.to_string(progress.cleared))]),932- html.span([], [html.text(deepest_for(family, progress.cleared))]),933- ])934- }),9351256 ),936- ]),937- html.section([attribute.class("window recent-panel")], [938- html.p([], [html.text("RECENT DEPTH")]),939- case recent_depths(model) {940- [] -> html.span([], [html.text("NOTHING SOLVED YET")])941- depths ->942- html.div(943- [attribute.class("recent-chart")],944- list.map(depths, fn(depth) {945- html.i(946- [947- attribute.attribute(948- "style",949- "--depth:" <> int.to_string(depth),950- ),951- ],952- [],953- )954- }),955- )956- },957- ]),958- html.footer([attribute.class("window record-footer")], [959- html.text(case model.survey_count > 1 {960- True -> int.to_string(model.survey_count) <> " CONFIGURATIONS MAPPED"961- False -> "8 PIECE SETS, NONE MAPPED YET"962- }),963- ]),964- ]),965- ])1257+ ),1258+ paint(model),1259+ )1260+ }9661261 }9671262 968-fn board_element(969- family: Family,970- pieces: List(Piece),971- interactive: Bool,972- drag: Option(Drag),973-) -> Element(Message) {974- let board = family.board975- let board_attributes = [976- attribute.class(977- case interactive {978- True -> "board interactive"979- False -> "board"1263+fn clamp_scroll(model: Model, y: Int) -> Int {1264+ let g = paint.atlas_geometry(model.layout, families.count())1265+ let over =1266+ paint.atlas_content_height(g)1267+ - { model.layout.vh - model.layout.bar_h - 6 }1268+ case over <= 0 {1269+ True -> 01270+ False -> int.max(-over, int.min(0, y))1271+ }1272+}1273+1274+fn pointer_up(model: Model, x: Float, y: Float) -> #(Model, Effect(Message)) {1275+ case model.scroll {1276+ // A short drag is a tap. Anything longer was someone scrolling the list.1277+ Some(#(_, _, moved)) -> {1278+ let model = Model(..model, scroll: None)1279+ case moved <. 5.0 {1280+ False -> #(model, effect.none())1281+ True -> atlas_tap(model, x, y)1282+ }1283+ }1284+ None ->1285+ case model.drag {1286+ None -> #(model, effect.none())1287+ Some(drag) -> end_drag(Model(..model, drag: None), drag)9801288 }981- <> " family-"982- <> family.id,983- ),984- attribute.attribute(985- "style",986- "--cols:"987- <> int.to_string(board.width)988- <> ";--rows:"989- <> int.to_string(board.height)990- <> ";--goal-x:"991- <> int.to_string(board.goal_x),992- ),993- ]994- let board_attributes = case interactive {995- True -> [996- pointer_event("pointermove", UserMovedBoard),997- pointer_event("pointerup", UserReleasedBoard),998- ..board_attributes999- ]1000- False -> board_attributes10011289 }1002- html.div(board_attributes, [1003- html.div([attribute.class("exit")], []),1004- ..list.map(pieces, fn(piece) { piece_element(piece, interactive, drag) })1005- ])10061290 }10071291 1008-fn piece_element(1009- piece: Piece,1010- interactive: Bool,1011- drag: Option(Drag),1012-) -> Element(Message) {1013- let drag_style = case drag {1014- Some(Drag(id, start_x, start_y, current_x, current_y, _))1015- if id == piece.id1016- ->1017- ";--drag-x:"1018- <> float.to_string(current_x -. start_x)1019- <> "px;--drag-y:"1020- <> float.to_string(current_y -. start_y)1021- <> "px;--dragging:1"1022- _ -> ""1292+fn end_drag(model: Model, drag: Drag) -> #(Model, Effect(Message)) {1293+ let board = families.board(model.family)1294+ let cell = model.board.cell1295+ case drag.axis {1296+ // A tap, not a drag. If there is exactly one way out, take it — the1297+ // affordance that makes the board feel like it wants to be solved.1298+ None ->1299+ case domain.move_options(board, model.pieces, drag.piece) {1300+ [only] -> {1301+ let #(dx, dy) = domain.direction_delta(only)1302+ animate(1303+ model,1304+ drag.piece,1305+ case dx {1306+ 0 -> Vertical1307+ _ -> Horizontal1308+ },1309+ 0.0,1310+ int.to_float({ dx + dy } * cell),1311+ Some(Pending(drag.piece, only, 1)),1312+ )1313+ }1314+ [] -> refuse(model, drag.piece)1315+ _ -> #(model, paint(model))1316+ }1317+ Some(axis) -> {1318+ let cells =1319+ float.round(int.to_float(drag.offset) /. int.to_float(cell))1320+ case cells {1321+ 0 ->1322+ animate(model, drag.piece, axis, int.to_float(drag.offset), 0.0, None)1323+ _ -> {1324+ let direction = case axis, cells > 0 {1325+ Horizontal, True -> Right1326+ Horizontal, False -> Left1327+ Vertical, True -> Down1328+ Vertical, False -> Up1329+ }1330+ animate(1331+ model,1332+ drag.piece,1333+ axis,1334+ int.to_float(drag.offset),1335+ int.to_float(cells * cell),1336+ Some(Pending(drag.piece, direction, int.absolute_value(cells))),1337+ )1338+ }1339+ }1340+ }10231341 }1024- let attributes = [1025- attribute.class("piece piece-" <> domain.shape_name(piece.shape)),1026- attribute.attribute(1027- "style",1028- "--x:"1029- <> int.to_string(piece.x)1030- <> ";--y:"1031- <> int.to_string(piece.y)1032- <> ";--w:"1033- <> int.to_string(piece.width)1034- <> ";--h:"1035- <> int.to_string(piece.height)1036- <> drag_style,1037- ),1038- attribute.attribute(1039- "aria-label",1040- domain.shape_name(piece.shape) <> " block",1041- ),1042- ]1043- let attributes = case interactive {1044- True -> [1045- piece_pointer_event(piece.id),1046- pointer_event("pointerup", UserReleasedBoard),1047- event.on_keydown(UserPressedPieceKey(piece.id, _)),1048- ..attributes1049- ]1050- False -> [attribute.tabindex(-1), ..attributes]1342+}1343+1344+fn animate(1345+ model: Model,1346+ piece: Int,1347+ axis: Axis,1348+ from: Float,1349+ to: Float,1350+ then: Option(Pending),1351+) -> #(Model, Effect(Message)) {1352+ case model.system.still {1353+ True ->1354+ case then {1355+ None -> #(model, paint(model))1356+ Some(p) -> perform_move(model, p.piece, p.direction, p.cells)1357+ }1358+ False -> #(1359+ Model(1360+ ..model,1361+ anim: Some(Anim(1362+ piece:,1363+ axis:,1364+ from:,1365+ to:,1366+ current: float.round(from),1367+ elapsed: 0.0,1368+ duration: float.max(80.0, float.absolute_value(to -. from) *. 3.0),1369+ then:,1370+ )),1371+ ),1372+ paint(model),1373+ )10511374 }1052- let children = case piece.shape == domain.Goal {1053- True -> [html.span([attribute.class("goal-mark")], [])]1054- False -> []1375+}1376+1377+fn atlas_tap(model: Model, x: Float, y: Float) -> #(Model, Effect(Message)) {1378+ let g = paint.atlas_geometry(model.layout, families.count())1379+ let hit =1380+ families.all()1381+ |> list.index_map(fn(family, index) { #(family, index) })1382+ |> list.find(fn(entry) {1383+ let r = paint.family_row_rect(g, entry.1, model.atlas_scroll)1384+ x >=. int.to_float(r.x)1385+ && x <=. int.to_float(r.x + r.w)1386+ && y >=. int.to_float(r.y)1387+ && y <=. int.to_float(r.y + r.h)1388+ })1389+ case hit {1390+ Error(_) -> #(model, effect.none())1391+ Ok(#(family, _)) -> open_family(model, family)10551392 }1056- case interactive {1057- True -> html.button(attributes, children)1058- False -> html.div(attributes, children)1393+}1394+1395+fn key_pressed(model: Model, key: String) -> #(Model, Effect(Message)) {1396+ case key, model.screen {1397+ "Escape", _ ->1398+ case model.shading_open, model.settings_open {1399+ True, _ -> #(Model(..model, shading_open: False), effect.none())1400+ False, True -> #(Model(..model, settings_open: False), effect.none())1401+ False, False -> #(model, effect.none())1402+ }1403+ _, Playing ->1404+ case model.anim, key_direction(key) {1405+ None, Some(direction) -> arrow_move(model, direction)1406+ _, _ -> #(model, effect.none())1407+ }1408+ _, _ -> #(model, effect.none())10591409 }10601410 }10611411 1062-fn pointer_event(1063- name: String,1064- message: fn(Float, Float) -> Message,1065-) -> attribute.Attribute(Message) {1066- let decoder = {1067- use x <- decode.field("clientX", decode.float)1068- use y <- decode.field("clientY", decode.float)1069- decode.success(message(x, y))1412+fn key_direction(key: String) -> Option(Direction) {1413+ case key {1414+ "ArrowUp" -> Some(Up)1415+ "ArrowDown" -> Some(Down)1416+ "ArrowLeft" -> Some(Left)1417+ "ArrowRight" -> Some(Right)1418+ _ -> None10701419 }1071- event.on(name, decoder) |> event.prevent_default10721420 }10731421 1074-fn piece_pointer_event(piece_id: Int) -> attribute.Attribute(Message) {1075- let decoder = {1076- use x <- decode.field("clientX", decode.float)1077- use y <- decode.field("clientY", decode.float)1078- use width <- decode.subfield(["currentTarget", "offsetWidth"], decode.float)1079- decode.success(UserPressedPiece(piece_id, x, y, width))1422+/// Arrow keys move the goal block, which is the piece a keyboard has any1423+/// business naming: it is the one with an identity, and every other piece is1424+/// interchangeable with something the same shape.1425+fn arrow_move(model: Model, direction: Direction) -> #(Model, Effect(Message)) {1426+ let board = families.board(model.family)1427+ let goal =1428+ model.pieces |> list.find(fn(p) { p.shape == domain.Goal })1429+ case goal {1430+ Error(_) -> #(model, effect.none())1431+ Ok(piece) ->1432+ case domain.can_move(board, model.pieces, piece.id, direction) {1433+ False -> refuse(model, piece.id)1434+ True -> {1435+ let #(dx, dy) = domain.direction_delta(direction)1436+ let cell = model.board.cell1437+ animate(1438+ model,1439+ piece.id,1440+ case dx {1441+ 0 -> Vertical1442+ _ -> Horizontal1443+ },1444+ 0.0,1445+ int.to_float({ dx + dy } * cell),1446+ Some(Pending(piece.id, direction, 1)),1447+ )1448+ }1449+ }10801450 }1081- event.on("pointerdown", decoder) |> event.prevent_default10821451 }10831452 1084-fn stat(value: String, label: String) -> Element(Message) {1085- html.div([attribute.class("stat")], [1086- html.strong([], [html.text(value)]),1087- html.span([], [html.text(label)]),1088- ])1453+// ------------------------------------------------------------------- effects1454+1455+fn persist(model: Model) -> Effect(Message) {1456+ browser.persist(store.encode(model.save))10891457 }10901458 1091-fn gap_gauge(gap: Int, start_gap: Int) -> Element(Message) {1092- html.div([attribute.class("gauge")], [1093- html.span([], [html.text("START")]),1094- html.div([], [1095- html.i(1096- [1097- attribute.attribute(1098- "style",1099- "--gap:"1100- <> int.to_string(gap)1101- <> ";--start:"1102- <> int.to_string(start_gap),1103- ),1104- ],1105- [],1106- ),1107- ]),1108- html.span([], [html.text("SOLVED")]),1459+fn page(model: Model) -> Effect(Message) {1460+ browser.cue("page", model.save.sound)1461+}1462+1463+fn relayout(model: Model) -> Model {1464+ let #(x, y, w, h) = render.safe_box()1465+ let l =1466+ layout.choose(1467+ layout.Box(x, y, w, h),1468+ render.device_pixel_ratio(),1469+ store.density_units(model.save.density),1470+ )1471+ Model(..model, layout: l, board: layout.board_layout(l, model.family))1472+}1473+1474+fn repaint(model: Model) -> Effect(Message) {1475+ paint(model)1476+}1477+1478+// ------------------------------------------------------------------ the look1479+1480+fn active_theme(model: Model) -> Theme {1481+ theme.by_id(theme.resolve(model.save.palette, model.system.dark))1482+}1483+1484+/// The look of the board on screen: drawn from the theme's pools and seeded on1485+/// the family and rung, but only where there is a board. The atlas, the record1486+/// and the title are chrome and keep the house style; a rung is also the only1487+/// thing there is to seed from, and those screens do not have one.1488+///1489+/// The record screen counts as the board: it is the same plate, the same family1490+/// and the same rung, arrived at through a wipe. Handing it back to the house1491+/// lattice snapped the backdrop the instant the puzzle was solved, which read as1492+/// the reward taking the world away.1493+fn combo_for(model: Model, t: Theme) -> Combo {1494+ let on_board = case model.screen {1495+ Card | Playing | Won -> True1496+ _ -> False1497+ }1498+ let base = case on_board {1499+ True -> theme.combo_for(t, model.family.id, model.rung)1500+ False -> theme.house_combo(t)1501+ }1502+ theme.apply_pin(base, model.save.shading)1503+}1504+1505+fn frame_screen(model: Model, combo: Combo) -> Shade {1506+ let density = store.density_units(model.save.density)1507+ let cell = int.max(1, model.board.cell)1508+ // Stepped down to what the smallest piece on this board can carry *and* to1509+ // what stays in register with the grid it is printed on: the screen runs1510+ // across the whole picture, so a cell that is not a whole number of periods1511+ // wide would put every piece on a different part of the pattern from its1512+ // neighbour.1513+ let ruling =1514+ shading.fit_ruling(combo.ruling, { cell - 6 } * density, cell * density)1515+ paint.screen_for(combo.shading, ruling)1516+}1517+1518+fn paint(model: Model) -> Effect(Message) {1519+ let t = active_theme(model)1520+ let combo = combo_for(model, t)1521+ let screen = frame_screen(model, combo)1522+ let ops = ops_for(model, t, screen, combo)1523+ effect.batch([1524+ browser.set_theme_colour(1525+ rgb_css(t.ink),1526+ rgb_css(t.paper),1527+ rgb_css(combo.ground.field),1528+ ),1529+ render.paint(scene.Frame(1530+ scale: model.layout.scale,1531+ vw: model.layout.vw,1532+ vh: model.layout.vh,1533+ off_x: model.layout.off_x,1534+ off_y: model.layout.off_y,1535+ density: store.density_units(model.save.density),1536+ ink: t.ink,1537+ paper: t.paper,1538+ gold: t.gold,1539+ screen:,1540+ tile: ground.tile(combo.backdrop),1541+ dot: combo.ground.dot,1542+ field: combo.ground.field,1543+ flash: model.flash >. 0.0,1544+ wipe: model.wipe,1545+ ops:,1546+ )),11091547 ])11101548 }11111549 1112-fn settings_sheet(model: Model) -> Element(Message) {1113- let preferences = model.save.preferences1114- html.div([attribute.class("sheet-scrim")], [1115- html.section(1116- [1117- attribute.class("window settings-sheet"),1118- attribute.attribute("role", "dialog"),1119- attribute.attribute("aria-modal", "true"),1120- attribute.attribute("aria-label", "Settings"),1121- ],1122- [1123- setting_group("THEME", [1124- setting_button(1125- "AUTO",1126- preferences.palette == Auto,1127- UserSelectedPalette(Auto),1128- ),1129- setting_button(1130- "MACINTOSH",1131- preferences.palette == Macintosh,1132- UserSelectedPalette(Macintosh),1133- ),1134- setting_button(1135- "PAPER",1136- preferences.palette == Paper,1137- UserSelectedPalette(Paper),1138- ),1139- setting_button(1140- "NIGHT",1141- preferences.palette == Night,1142- UserSelectedPalette(Night),1143- ),1144- setting_button(1145- "AMOLED",1146- preferences.palette == Amoled,1147- UserSelectedPalette(Amoled),1148- ),1149- ]),1150- html.div([attribute.class("setting-group")], [1151- html.p([], [html.text("BLOCKS")]),1152- html.div([attribute.class("shade-menu")], [1153- html.button([event.on_click(UserToggledShadingMenu)], [1154- html.text(shading_label(preferences.shading) <> " ▾"),1155- ]),1156- case model.shading_open {1157- False -> element.none()1158- True ->1159- html.div(1160- [attribute.class("shade-list")],1161- list.map(shadings(), fn(shading) {1162- setting_button(1163- shading_label(shading),1164- preferences.shading == shading,1165- UserSelectedShading(shading),1166- )1167- }),1168- )1169- },1170- ]),1550+fn rgb_css(colour: theme.Rgb) -> String {1551+ "rgb("1552+ <> int.to_string(colour.r)1553+ <> ","1554+ <> int.to_string(colour.g)1555+ <> ","1556+ <> int.to_string(colour.b)1557+ <> ")"1558+}1559+1560+fn ops_for(1561+ model: Model,1562+ t: Theme,1563+ screen: Shade,1564+ _combo: Combo,1565+) -> List(scene.Op) {1566+ case model.screen {1567+ Boot -> paint.boot(model.layout, families.at(0), screen, t)1568+ Sweeping -> {1569+ let sweep =1570+ model.sweep |> option.unwrap(Sweep(model.family.id, 0, 1, 1))1571+ paint.survey(1572+ model.layout,1573+ model.family.label,1574+ sweep.count,1575+ sweep.phase,1576+ int.to_float(sweep.count) /. int.to_float(int.max(1, sweep.total)),1577+ )1578+ }1579+ Atlas ->1580+ paint.atlas(1581+ model.layout,1582+ atlas_rows(model, t),1583+ model.atlas_scroll,1584+ t,1585+ theme.logo_ink(t, browser.day_of_year()),1586+ store.density_units(model.save.density),1587+ )1588+ Card ->1589+ paint.card(1590+ model.layout,1591+ model.family.label,1592+ model.rung,1593+ optimal(model),1594+ model.card_step,1595+ model.card_t >. 240.0,1596+ )1597+ Playing ->1598+ paint.play(1599+ model.layout,1600+ model.board,1601+ model.family,1602+ placed(model),1603+ screen,1604+ t,1605+ model.rise,1606+ model.save.hints,1607+ current_distance(model),1608+ gauge_reference(model),1609+ )1610+ Won -> paint.won(model.layout, model.board, model.family, model.trace)1611+ Record -> {1612+ let records =1613+ families.all()1614+ |> list.map(fn(family) {1615+ #(family, store.record_for(model.save, family.id))1616+ })1617+ paint.stats(1618+ model.layout,1619+ model.save.totals,1620+ progress.level_progress(model.save.totals.xp),1621+ model.save.days.streak,1622+ records,1623+ model.save.log,1624+ list.length(model.surveys),1625+ list.fold(model.surveys, 0, fn(n, s) { n + s.count }),1626+ )1627+ }1628+ }1629+}1630+1631+fn optimal(model: Model) -> Int {1632+ case model.puzzle {1633+ Some(p) -> p.optimal1634+ None -> 01635+ }1636+}1637+1638+fn current_distance(model: Model) -> Int {1639+ model.trace |> list.last |> result.unwrap(optimal(model))1640+}1641+1642+/// FULL scales the gauge to the distance you started at. BEST scales it to the1643+/// furthest out you have ever been on this board instead: the number is gone, so1644+/// the gauge is all there is, and anchoring it to your own worst point makes it1645+/// a reading of your progress rather than a disguised step count.1646+fn gauge_reference(model: Model) -> Int {1647+ let start = model.trace |> list.first |> result.unwrap(optimal(model))1648+ case model.save.hints {1649+ Best -> list.fold(model.trace, start, int.max)1650+ _ -> start1651+ }1652+}1653+1654+fn placed(model: Model) -> List(paint.Placed) {1655+ let b = model.board1656+ let cell = b.cell1657+ let bx = b.board.x1658+ let by = b.board.y - model.rise1659+ list.map(model.pieces, fn(piece) {1660+ let #(dx, dy) = piece_offset(model, piece.id)1661+ let jolt = case model.shake_piece == Some(piece.id) {1662+ True -> model.shake_off1663+ False -> 01664+ }1665+ paint.Placed(1666+ // The offset moves the block; it does not move the screen the block is cut1667+ // from. A piece under a thumb carries its own ink across the board.1668+ x: bx + piece.x * cell + dx + jolt,1669+ y: by + piece.y * cell + dy,1670+ w: piece.width * cell,1671+ h: piece.height * cell,1672+ class: domain.shape_class(piece.shape),1673+ rest_x: bx + piece.x * cell,1674+ rest_y: by + piece.y * cell,1675+ thick: case model.drag {1676+ Some(d) -> d.piece == piece.id1677+ None -> False1678+ },1679+ )1680+ })1681+}1682+1683+fn piece_offset(model: Model, id: Int) -> #(Int, Int) {1684+ case model.drag {1685+ Some(Drag(piece, _, _, Some(axis), offset, _, _)) if piece == id ->1686+ case axis {1687+ Horizontal -> #(offset, 0)1688+ Vertical -> #(0, offset)1689+ }1690+ _ ->1691+ case model.anim {1692+ Some(a) if a.piece == id ->1693+ case a.axis {1694+ Horizontal -> #(a.current, 0)1695+ Vertical -> #(0, a.current)1696+ }1697+ _ -> #(0, 0)1698+ }1699+ }1700+}1701+1702+fn atlas_rows(model: Model, t: Theme) -> List(paint.Row) {1703+ list.map(families.all(), fn(family) {1704+ let rung = rung_of(model, family)1705+ let survey = survey_of(model, family.id)1706+ let preview =1707+ model.previews1708+ |> list.find(fn(p) { p.family == family.id && p.rung == rung })1709+ |> option.from_result1710+ let ecc = case survey {1711+ Some(s) -> s.ecc1712+ None -> 01713+ }1714+ let combo = theme.apply_pin(theme.combo_for(t, family.id, rung), model.save.shading)1715+ paint.Row(1716+ family:,1717+ rung:,1718+ grid: case preview {1719+ Some(p) -> p.grid1720+ None -> family.grid1721+ },1722+ surveyed: option.is_some(survey) && option.is_some(preview),1723+ optimal: case preview {1724+ Some(p) -> p.optimal1725+ None -> 01726+ },1727+ grade: case preview {1728+ Some(p) -> difficulty.grade(p.score)1729+ None -> 11730+ },1731+ ecc:,1732+ at_ceiling: progress.at_ceiling(rung, ecc),1733+ depth: progress.depth_for_rung(rung, ecc),1734+ look: #(combo.shading, combo.ruling),1735+ )1736+ })1737+}1738+1739+// --------------------------------------------------------------------- view1740+//1741+// Crisp mono chrome, laid over the shaded canvas. Every serif on screen is drawn1742+// into the canvas instead, so it gets thresholded; anything here is a control,1743+// and a control has to be legible before it is atmospheric.1744+//1745+// A zone is a band of DOM laid over the picture, in the picture's own units. It1746+// used to span the picture, because the picture was the composition; now the1747+// composition is a column somewhere inside it, so a zone is told which box it1748+// belongs to and only the menu bar still spans the screen.1749+1750+fn view(model: Model) -> Element(Message) {1751+ let l = model.layout1752+ html.div(1753+ [1754+ attribute.id("stage"),1755+ attribute.attribute(1756+ "data-mode",1757+ case l.mode == Split {1758+ True -> "split"1759+ False -> "stack"1760+ },1761+ ),1762+ attribute.styles([1763+ #("left", px(int.to_float(l.off_x))),1764+ #("top", px(int.to_float(l.off_y))),1765+ #("width", px(int.to_float(l.vw) *. l.scale)),1766+ #("height", px(int.to_float(l.vh) *. l.scale)),1767+ #("--s", float.to_string(l.scale)),1768+ ]),1769+ ],1770+ [1771+ // The picture is shaded at low resolution; type rides above it at full1772+ // device resolution so it never meets the threshold.1773+ html.canvas([1774+ attribute.id("board"),1775+ attribute.styles([1776+ #("width", px(int.to_float(l.vw) *. l.scale)),1777+ #("height", px(int.to_float(l.vh) *. l.scale)),11711778 ]),1172- setting_group("INTENSITY", [1173- setting_button(1174- "LOW",1175- preferences.density == Low,1176- UserSelectedDensity(Low),1177- ),1178- setting_button(1179- "DEFAULT",1180- preferences.density == Default,1181- UserSelectedDensity(Default),1182- ),1779+ ]),1780+ html.canvas([1781+ attribute.id("type"),1782+ attribute.styles([1783+ #("width", px(int.to_float(l.vw) *. l.scale)),1784+ #("height", px(int.to_float(l.vh) *. l.scale)),11831785 ]),1184- setting_group("HINTS", [1185- setting_button(1186- "FULL",1187- preferences.hints == Full,1188- UserSelectedHints(Full),1189- ),1190- setting_button(1191- "BEST",1192- preferences.hints == Best,1193- UserSelectedHints(Best),1194- ),1195- setting_button(1196- "OFF",1197- preferences.hints == Off,1198- UserSelectedHints(Off),1786+ ]),1787+ html.div([attribute.id("ui")], chrome(model)),1788+ ],1789+ )1790+}1791+1792+fn px(value: Float) -> String {1793+ float.to_string(value) <> "px"1794+}1795+1796+fn chrome(model: Model) -> List(Element(Message)) {1797+ let l = model.layout1798+ let b = model.board1799+ let playing = model.screen == Playing1800+ let won = model.screen == Won1801+ let has_bar = case model.screen {1802+ Atlas | Playing | Won | Record -> True1803+ _ -> False1804+ }1805+1806+ let panel = layout.Column(b.panel.x, b.panel.w, 0.0)1807+ let column = layout.Column(l.col.x, l.col.w, 0.0)1808+1809+ [1810+ case has_bar {1811+ True -> menu_zone(model)1812+ False -> element.none()1813+ },1814+ // The zone is the drawn scale, so the two end labels land on its two ends1815+ // without a padding that has to be kept in step with it by hand.1816+ case playing && model.save.hints != OffHints {1817+ True ->1818+ zone(1819+ model,1820+ "gauge",1821+ b.gauge.y + 6 - gauge_rise(model),1822+ 14,1823+ layout.Column(b.gauge.x, b.gauge.w, 0.0),1824+ [1825+ html.span([attribute.class("lbl")], [1826+ // The gauge's left end stops being the start once BEST anchors it1827+ // to the furthest out you have been, and a scale labelled with the1828+ // wrong end is worse than no label.1829+ html.text(case model.save.hints {1830+ Best -> "DEEPEST"1831+ _ -> "START"1832+ }),1833+ ]),1834+ html.span([attribute.class("spacer")], []),1835+ html.span([attribute.class("lbl")], [html.text("SOLVED")]),1836+ ],1837+ )1838+ False -> element.none()1839+ },1840+ case playing {1841+ True ->1842+ zone(model, "hud", b.hud_y, 30, panel, [1843+ stat(int.to_string(model.steps), "STEPS"),1844+ case model.save.hints {1845+ Full -> stat(int.to_string(current_distance(model)), "TO GOAL")1846+ _ -> element.none()1847+ },1848+ stat(int.to_string(optimal(model)), "OPTIMAL"),1849+ ])1850+ False -> element.none()1851+ },1852+ case won {1853+ True -> record_zone(model, b, panel)1854+ False -> element.none()1855+ },1856+ case playing || won {1857+ True ->1858+ zone(model, "acts", b.panel.actions_y, 34, panel, case playing {1859+ True -> [1860+ html.button(1861+ [1862+ event.on_click(UndoPressed),1863+ attribute.disabled(list.is_empty(model.history)),1864+ ],1865+ [html.text("UNDO")],1866+ ),1867+ html.button(1868+ [attribute.class("ghost"), event.on_click(ResetPressed)],1869+ [html.text("RESET")],1870+ ),1871+ ]1872+ False -> [1873+ html.button([event.on_click(NextPressed)], [html.text("NEXT")]),1874+ html.button(1875+ [attribute.class("ghost"), event.on_click(BackPressed)],1876+ [html.text("PUZZLES")],1877+ ),1878+ ]1879+ })1880+ False -> element.none()1881+ },1882+ case model.screen == Boot {1883+ True -> {1884+ // Held clear of the window's own bottom rule: the fraction alone puts1885+ // the box through it once the window is short enough.1886+ let w = l.boot_win1887+ zone(1888+ model,1889+ "gate",1890+ int.min(1891+ w.y + float.round(int.to_float(w.h) *. 0.85),1892+ w.y + w.h - 24,11991893 ),1200- ]),1201- setting_group("SOUND", [1202- setting_button("TONES", preferences.sound, UserToggledSound),1203- setting_button("HAPTICS", preferences.haptics, UserToggledHaptics),1204- ]),1205- html.p([attribute.class("credit")], [1206- html.text("ERROR DIFFUSION AND ORDERED SCREENS"),1207- html.br([]),1208- html.text("SILKSCREEN AND SPACE MONO, OFL"),1209- ]),1210- html.button([event.on_click(UserClosedSettings)], [html.text("CLOSE")]),1211- ],1212- ),1213- ])1894+ 20,1895+ column,1896+ [1897+ html.span([attribute.class("val beat")], [html.text("TAP TO BEGIN")]),1898+ ],1899+ )1900+ }1901+ False -> element.none()1902+ },1903+ case model.settings_open {1904+ True -> sheet(model)1905+ False -> element.none()1906+ },1907+ ]12141908 }12151909 1216-fn setting_group(1217- label: String,1218- controls: List(Element(Message)),1910+fn gauge_rise(model: Model) -> Int {1911+ case model.layout.mode == Split {1912+ True -> 01913+ False -> model.rise1914+ }1915+}1916+1917+fn zone(1918+ model: Model,1919+ id: String,1920+ top: Int,1921+ height: Int,1922+ box: layout.Column,1923+ children: List(Element(Message)),12191924 ) -> Element(Message) {1220- html.div([attribute.class("setting-group")], [1221- html.p([], [html.text(label)]),1222- html.div([attribute.class("setting-row")], controls),1223- ])1925+ let s = model.layout.scale1926+ html.div(1927+ [1928+ attribute.id(id),1929+ attribute.class("zone"),1930+ attribute.styles([1931+ #("top", px(int.to_float(top) *. s)),1932+ #("height", px(int.to_float(height) *. s)),1933+ #("left", px(int.to_float(box.x) *. s)),1934+ #("width", px(int.to_float(box.w) *. s)),1935+ ]),1936+ ],1937+ children,1938+ )12241939 }12251940 1226-fn setting_button(1941+fn menu_zone(model: Model) -> Element(Message) {1942+ let l = model.layout1943+ let s = l.scale1944+ let on_atlas = model.screen == Atlas1945+ html.div(1946+ [1947+ attribute.id("top"),1948+ attribute.class("zone"),1949+ attribute.styles([1950+ #("top", px(1.0 *. s)),1951+ #("height", px(int.to_float(l.bar_h - 2) *. s)),1952+ #("left", "0"),1953+ #("width", "100%"),1954+ ]),1955+ ],1956+ [1957+ case on_atlas {1958+ True -> html.span([attribute.class("spacer")], [])1959+ False ->1960+ html.button(1961+ [attribute.class("ghost"), event.on_click(BackPressed)],1962+ [html.text("BACK")],1963+ )1964+ },1965+ html.span([attribute.class("spacer")], []),1966+ case on_atlas && model.install_available {1967+ True ->1968+ html.button([event.on_click(InstallPressed)], [html.text("INSTALL")])1969+ False -> element.none()1970+ },1971+ // Boxed icons, the way a system of this vintage drew a toolbar: a hard1972+ // rectangle with a glyph struck inside it, rather than a word. Drawn as
diff truncated
M src/klotski/browser.gleam +279 -61
@@ -1,97 +1,315 @@
1+/// Everything the platform owns.2+///3+/// Storage, tones, haptics, the install prompt, the survey worker, the clock,4+/// and the two questions the system has already been asked. Each one is a thin5+/// effect over a capability that has no Gleam expression; none of them decides6+/// anything.7+import gleam/dynamic.{type Dynamic}8+import klotski/prefs.{type Preference}19 import lustre/effect.{type Effect}210 11+// ------------------------------------------------------------------ storage12+313 @external(javascript, "./browser_ffi.mjs", "load")414 pub fn load() -> String515 616 @external(javascript, "./browser_ffi.mjs", "save")7-fn save_value(value: String) -> Nil8-9-@external(javascript, "./browser_ffi.mjs", "feedback")10-fn play_feedback(kind: String, sound: Bool, haptics: Bool) -> Nil17+fn save_ffi(value: String) -> Nil1118 1219 pub fn persist(value: String) -> Effect(message) {13- effect.from(fn(_dispatch) { save_value(value) })20+ effect.from(fn(_dispatch) { save_ffi(value) })21+}22+23+@external(javascript, "./browser_ffi.mjs", "requestPersistence")24+fn request_persistence_ffi() -> Nil25+26+/// Ask to keep the record through storage pressure. Best effort everywhere, and27+/// on iOS it is genuinely only a request.28+pub fn request_persistence() -> Effect(message) {29+ effect.from(fn(_dispatch) { request_persistence_ffi() })30+}31+32+// -------------------------------------------------------------------- sound33+34+@external(javascript, "./browser_ffi.mjs", "unlockAudio")35+fn unlock_audio_ffi() -> Nil36+37+/// Audio starts suspended until a real gesture; the title screen's tap exists38+/// partly to pay this debt.39+pub fn unlock_audio() -> Effect(message) {40+ effect.from(fn(_dispatch) { unlock_audio_ffi() })41+}42+43+@external(javascript, "./browser_ffi.mjs", "steps")44+fn steps_ffi(count: Int, enabled: Bool) -> Nil45+46+/// A block sliding: every cell it travels sounds one degree of a minor47+/// pentatonic, so a long slide plays a rising figure and solving a board plays a48+/// line rather than a rattle.49+pub fn steps(count: Int, enabled: Bool) -> Effect(message) {50+ effect.from(fn(_dispatch) { steps_ffi(count, enabled) })51+}52+53+@external(javascript, "./browser_ffi.mjs", "cue")54+fn cue_ffi(name: String, enabled: Bool) -> Nil55+56+/// One of the fixed figures: `page`, `thud`, `solve`.57+pub fn cue(name: String, enabled: Bool) -> Effect(message) {58+ effect.from(fn(_dispatch) { cue_ffi(name, enabled) })59+}60+61+@external(javascript, "./browser_ffi.mjs", "buzz")62+fn buzz_ffi(pattern: List(Int), enabled: Bool) -> Nil63+64+/// Android only; iOS has no vibration API, so this is simply nothing there.65+pub fn buzz(pattern: List(Int), enabled: Bool) -> Effect(message) {66+ effect.from(fn(_dispatch) { buzz_ffi(pattern, enabled) })67+}68+69+// ------------------------------------------------------------------- system70+71+@external(javascript, "./browser_ffi.mjs", "prefersDark")72+pub fn prefers_dark() -> Bool73+74+@external(javascript, "./browser_ffi.mjs", "prefersStill")75+pub fn prefers_still() -> Bool76+77+@external(javascript, "./browser_ffi.mjs", "watchSystem")78+fn watch_system_ffi(dispatch: fn(message) -> Nil, message: message) -> Nil79+80+/// The system can change its mind while the app is open — a phone that goes81+/// dark at sunset, or someone turning motion off because of something this just82+/// did. Re-resolving costs nothing and applying it late looks like a bug.83+pub fn watch_system(message: message) -> Effect(message) {84+ effect.from(fn(dispatch) { watch_system_ffi(dispatch, message) })85+}86+87+/// Both preferences, read live.88+pub fn preference() -> Preference {89+ prefs.Preference(dark: prefers_dark(), still: prefers_still())90+}91+92+@external(javascript, "./browser_ffi.mjs", "watchViewport")93+fn watch_viewport_ffi(dispatch: fn(message) -> Nil, message: message) -> Nil94+95+pub fn watch_viewport(message: message) -> Effect(message) {96+ effect.from(fn(dispatch) { watch_viewport_ffi(dispatch, message) })97+}98+99+@external(javascript, "./browser_ffi.mjs", "watchKeys")100+fn watch_keys_ffi(101+ dispatch: fn(message) -> Nil,102+ to_message: fn(String) -> message,103+) -> Nil104+105+pub fn watch_keys(to_message: fn(String) -> message) -> Effect(message) {106+ effect.from(fn(dispatch) { watch_keys_ffi(dispatch, to_message) })107+}108+109+@external(javascript, "./browser_ffi.mjs", "watchFrame")110+fn watch_frame_ffi(111+ dispatch: fn(message) -> Nil,112+ to_message: fn(Float) -> message,113+) -> Nil114+115+/// The clock. Every screen in this app is stepped rather than smooth, so what116+/// this drives is a comparison and not a redraw: the loop asks for a picture117+/// when one of the stepped values moves, which for a 300ms rise is seven118+/// pictures however many times a second the display asks for one.119+pub fn watch_frame(to_message: fn(Float) -> message) -> Effect(message) {120+ effect.from(fn(dispatch) { watch_frame_ffi(dispatch, to_message) })121+}122+123+@external(javascript, "./browser_ffi.mjs", "now")124+pub fn now() -> Int125+126+@external(javascript, "./browser_ffi.mjs", "today")127+pub fn today() -> String128+129+@external(javascript, "./browser_ffi.mjs", "dayOfYear")130+pub fn day_of_year() -> Int131+132+// ------------------------------------------------------------- installation133+134+@external(javascript, "./browser_ffi.mjs", "watchInstall")135+fn watch_install_ffi(dispatch: fn(message) -> Nil, message: message) -> Nil136+137+pub fn watch_install(message: message) -> Effect(message) {138+ effect.from(fn(dispatch) { watch_install_ffi(dispatch, message) })139+}140+141+@external(javascript, "./browser_ffi.mjs", "promptInstall")142+fn prompt_install_ffi(dispatch: fn(message) -> Nil, message: message) -> Nil143+144+pub fn prompt_install(message: message) -> Effect(message) {145+ effect.from(fn(dispatch) { prompt_install_ffi(dispatch, message) })146+}147+148+@external(javascript, "./browser_ffi.mjs", "registerServiceWorker")149+fn register_service_worker_ffi() -> Nil150+151+pub fn register_service_worker() -> Effect(message) {152+ effect.from(fn(_dispatch) { register_service_worker_ffi() })153+}154+155+@external(javascript, "./browser_ffi.mjs", "loadFonts")156+fn load_fonts_ffi(dispatch: fn(message) -> Nil, message: message) -> Nil157+158+/// Wait for the faces before the first frame: type is drawn on its own canvas at159+/// full resolution, but a fallback face would still be measured and laid out at160+/// the wrong widths for the first frames.161+pub fn load_fonts(message: message) -> Effect(message) {162+ effect.from(fn(dispatch) { load_fonts_ffi(dispatch, message) })163+}164+165+@external(javascript, "./browser_ffi.mjs", "setThemeColour")166+fn set_theme_colour_ffi(ink: String, paper: String, ground: String) -> Nil167+168+/// The picture buffer is upscaled by a whole number, so on anything wider than a169+/// phone there is a margin of page around it. That margin was paper, which was170+/// invisible while the only ground *was* paper and became a seam the moment the171+/// ground could be any colour. It follows the ground now instead.172+pub fn set_theme_colour(173+ ink: String,174+ paper: String,175+ ground: String,176+) -> Effect(message) {177+ effect.from(fn(_dispatch) { set_theme_colour_ffi(ink, paper, ground) })178+}179+180+// ------------------------------------------------------------- the surveyor181+182+@external(javascript, "./browser_ffi.mjs", "watchSolver")183+fn watch_solver_ffi(184+ dispatch: fn(message) -> Nil,185+ sweeping: fn(String, Int, Int, Int) -> message,186+ surveyed: fn(String, Int, Int) -> message,187+ puzzled: fn(String, Int, List(Dynamic)) -> message,188+ measured: fn(String, Int, List(Int)) -> message,189+ probed: fn(String, List(Int)) -> message,190+) -> Nil191+192+/// Subscribe to the survey worker. Five answers: how a sweep is going, that one193+/// has finished, the candidates for a rung, the exact distance of states just194+/// walked through, and — for the harness — the distance of states nobody has195+/// walked yet.196+pub fn watch_solver(197+ sweeping: fn(String, Int, Int, Int) -> message,198+ surveyed: fn(String, Int, Int) -> message,199+ puzzled: fn(String, Int, List(Dynamic)) -> message,200+ measured: fn(String, Int, List(Int)) -> message,201+ probed: fn(String, List(Int)) -> message,202+) -> Effect(message) {203+ effect.from(fn(dispatch) {204+ watch_solver_ffi(dispatch, sweeping, surveyed, puzzled, measured, probed)205+ })14206 }15207 16-pub fn feedback(kind: String, sound: Bool, haptics: Bool) -> Effect(message) {17- effect.from(fn(_dispatch) { play_feedback(kind, sound, haptics) })208+@external(javascript, "./browser_ffi.mjs", "probe")209+fn probe_ffi(family_id: String, states: List(Int)) -> Nil210+211+/// How far each of these is, with no claim that they are a run.212+pub fn probe(family_id: String, states: List(Int)) -> Effect(message) {213+ effect.from(fn(_dispatch) { probe_ffi(family_id, states) })18214 }19215 20-@external(javascript, "./browser_ffi.mjs", "beginSurvey")21-fn begin_survey_ffi(216+@external(javascript, "./browser_ffi.mjs", "exposeHarness")217+fn expose_harness_ffi(dispatch: fn(message) -> Nil, solve: message) -> Nil218+219+/// The handle a screenshot harness drives the game through, and the one anybody220+/// inspecting a phone over a remote debugger reaches for. It dispatches the same221+/// message a thumb would, so a walked board and a played one are the same run.222+pub fn expose_harness(solve: message) -> Effect(message) {223+ effect.from(fn(dispatch) { expose_harness_ffi(dispatch, solve) })224+}225+226+@external(javascript, "./browser_ffi.mjs", "survey")227+fn survey_ffi(22228 family_id: String,23229 width: Int,24230 height: Int,25231 goal_x: Int,26232 goal_y: Int,27- solved_grid: String,28- start_state: Int,29- dispatch: fn(message) -> Nil,30- progress_message: fn(Int, Int) -> message,31- ready_message: fn(Int, Int, Int) -> message,32-) -> Nil33-34-@external(javascript, "./browser_ffi.mjs", "queryDistance")35-fn query_distance_ffi(36- family_id: String,37- state: Int,38- dispatch: fn(message) -> Nil,39- distance_message: fn(Int, Int) -> message,233+ grid: List(Int),40234 ) -> Nil41235 42-pub fn begin_survey(236+pub fn survey(43237 family_id: String,44238 width: Int,45239 height: Int,46240 goal_x: Int,47241 goal_y: Int,48- solved_grid: String,49- start_state: Int,50- progress_message: fn(Int, Int) -> message,51- ready_message: fn(Int, Int, Int) -> message,242+ grid: List(Int),52243 ) -> Effect(message) {53- effect.from(fn(dispatch) {54- begin_survey_ffi(55- family_id,56- width,57- height,58- goal_x,59- goal_y,60- solved_grid,61- start_state,62- dispatch,63- progress_message,64- ready_message,65- )244+ effect.from(fn(_dispatch) {245+ survey_ffi(family_id, width, height, goal_x, goal_y, grid)66246 })67247 }68248 69-pub fn query_distance(249+@external(javascript, "./browser_ffi.mjs", "requestPuzzle")250+fn request_puzzle_ffi(70251 family_id: String,71- state: Int,72- distance_message: fn(Int, Int) -> message,252+ rung: Int,253+ want: Int,254+ ceiling: Bool,255+ window: Int,256+ offset: Int,257+ seed: Int,258+ stray: Float,259+ rollouts: Int,260+ goal_x: Int,261+ goal_y: Int,262+) -> Nil263+264+/// Ask for the candidates a rung may be drawn from.265+///266+/// Every number here was decided in Gleam: the depth the schedule asks for,267+/// whether the ladder has run out of it, how wide the window is, where in the268+/// band it starts, and the seed the walker is run under. The worker reaches into269+/// the field and measures; it never judges.270+pub fn request_puzzle(271+ family_id family_id: String,272+ rung rung: Int,273+ want want: Int,274+ ceiling ceiling: Bool,275+ window window: Int,276+ offset offset: Int,277+ seed seed: Int,278+ stray stray: Float,279+ rollouts rollouts: Int,280+ goal_x goal_x: Int,281+ goal_y goal_y: Int,73282 ) -> Effect(message) {74- effect.from(fn(dispatch) {75- query_distance_ffi(family_id, state, dispatch, distance_message)283+ effect.from(fn(_dispatch) {284+ request_puzzle_ffi(285+ family_id,286+ rung,287+ want,288+ ceiling,289+ window,290+ offset,291+ seed,292+ stray,293+ rollouts,294+ goal_x,295+ goal_y,296+ )76297 })77298 }78299 79-@external(javascript, "./browser_ffi.mjs", "watchInstall")80-fn watch_install_ffi(81- dispatch: fn(message) -> Nil,82- available_message: message,83-) -> Nil84-85-@external(javascript, "./browser_ffi.mjs", "promptInstall")86-fn prompt_install_ffi(87- dispatch: fn(message) -> Nil,88- finished_message: message,300+@external(javascript, "./browser_ffi.mjs", "requestDistances")301+fn request_distances_ffi(302+ family_id: String,303+ states: List(Int),304+ seq: Int,89305 ) -> Nil90306 91-pub fn watch_install(available_message: message) -> Effect(message) {92- effect.from(fn(dispatch) { watch_install_ffi(dispatch, available_message) })93-}94-95-pub fn prompt_install(finished_message: message) -> Effect(message) {96- effect.from(fn(dispatch) { prompt_install_ffi(dispatch, finished_message) })307+/// The exact distance of every state a move passed through, in order, so the308+/// trace the record screen draws is measured rather than inferred.309+pub fn request_distances(310+ family_id: String,311+ states: List(Int),312+ seq: Int,313+) -> Effect(message) {314+ effect.from(fn(_dispatch) { request_distances_ffi(family_id, states, seq) })97315 }
M src/klotski/browser_ffi.mjs +330 -101
@@ -1,129 +1,358 @@
1-const KEY = "klotski.gleam.save";2-let audioContext = null;3-let solverWorker = null;4-let requestId = 0;5-const surveys = new Map();6-const queries = new Map();7-let installPrompt = null;8-let installWatcher = null;9-10-function solver() {11- if (solverWorker) return solverWorker;12- solverWorker = new Worker("/solver-worker.js", { type: "module" });13- solverWorker.addEventListener("message", ({ data }) => {14- if (data.type === "progress") {15- const active = surveys.get(data.familyId);16- if (active) active.dispatch(active.progress(data.count, data.phase));17- }18- if (data.type === "ready") {19- const active = surveys.get(data.familyId);20- if (active) {21- active.dispatch(active.ready(data.distance, data.eccentricity, data.count));22- surveys.delete(data.familyId);23- }24- }25- if (data.type === "distance") {26- const active = queries.get(data.requestId);27- if (active) {28- active.dispatch(active.message(data.state, data.distance));29- queries.delete(data.requestId);30- }31- }1+// The platform, and nothing else.2+//3+// Every function here is a capability with no Gleam expression: a key-value4+// store, an oscillator, a vibration motor, a media query, a frame callback, a5+// worker. None of them decides anything — the tones are a scale that was chosen6+// upstream, the seeds the worker runs under were drawn upstream, and the theme7+// this writes to the document was resolved upstream.8+9+import { toList } from "../gleam.mjs";10+11+const KEY = "klotski.save";12+13+// ------------------------------------------------------------------ storage14+15+export function load() {16+ try {17+ return localStorage.getItem(KEY) || "";18+ } catch (_) {19+ return "";20+ }21+}22+23+export function save(value) {24+ try {25+ localStorage.setItem(KEY, value);26+ } catch (_) {27+ // Private mode, quota, evicted — play on regardless.28+ }29+}30+31+export function requestPersistence() {32+ if (navigator.storage && navigator.storage.persist) {33+ navigator.storage.persist().catch(() => {});34+ }35+}36+37+// -------------------------------------------------------------------- sound38+//39+// Notes, not foley.40+//41+// Monument Valley's rule: interaction sounds should be musical, because music42+// is the most abstract sound there is and abstraction is what a geometric puzzle43+// wants. Every cell a block travels sounds one degree of a minor pentatonic, so44+// a long slide plays a rising figure and solving a board plays a line rather45+// than a rattle. Synthesised rather than sampled — it keeps the install tiny,46+// and a synthetic timbre is the audio equivalent of one bit.47+48+// A minor pentatonic degrees, and G3: low enough to stay soft.49+const PENTATONIC = [0, 3, 5, 7, 10, 12, 15, 17, 19, 22];50+const ROOT = 196;51+52+let audio = null;53+54+export function unlockAudio() {55+ if (audio) {56+ if (audio.state === "suspended") audio.resume();57+ return;58+ }59+ const Context = window.AudioContext || window.webkitAudioContext;60+ if (!Context) return;61+ audio = new Context();62+ if (audio.state === "suspended") audio.resume();63+ const buffer = audio.createBuffer(1, 1, 22050);64+ const source = audio.createBufferSource();65+ source.buffer = buffer;66+ source.connect(audio.destination);67+ source.start(0);68+}69+70+function tone(frequency, duration, volume, type = "triangle", delay = 0) {71+ if (!audio) return;72+ const t0 = audio.currentTime + delay;73+ const gain = audio.createGain();74+ const osc = audio.createOscillator();75+ // A hair of detune: wood, not glass.76+ const detuned = audio.createOscillator();77+ const detunedGain = audio.createGain();78+ osc.type = detuned.type = type;79+ osc.frequency.value = frequency;80+ detuned.frequency.value = frequency * 1.006;81+ detunedGain.gain.value = 0.38;82+ gain.gain.setValueAtTime(0.0001, t0);83+ // An 8ms attack transient.84+ gain.gain.exponentialRampToValueAtTime(volume, t0 + 0.008);85+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration);86+ osc.connect(gain);87+ detuned.connect(detunedGain);88+ detunedGain.connect(gain);89+ gain.connect(audio.destination);90+ osc.start(t0);91+ detuned.start(t0);92+ osc.stop(t0 + duration + 0.02);93+ detuned.stop(t0 + duration + 0.02);94+}95+96+export function steps(count, enabled) {97+ if (!enabled || !audio) return;98+ for (let i = 0; i < count; i++) {99+ const semitone = PENTATONIC[Math.min(PENTATONIC.length - 1, i)];100+ tone(ROOT * Math.pow(2, semitone / 12), 0.26, 0.15, "triangle", i * 0.055);101+ }102+}103+104+export function cue(name, enabled) {105+ if (!enabled || !audio) return;106+ if (name === "page") {107+ tone(330, 0.09, 0.045, "sine");108+ return;109+ }110+ if (name === "thud") {111+ // A blocked move: damped, pitchless, close to silence.112+ const t0 = audio.currentTime;113+ const osc = audio.createOscillator();114+ const gain = audio.createGain();115+ osc.type = "sine";116+ osc.frequency.setValueAtTime(150, t0);117+ osc.frequency.exponentialRampToValueAtTime(58, t0 + 0.09);118+ gain.gain.setValueAtTime(0.15, t0);119+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.12);120+ osc.connect(gain);121+ gain.connect(audio.destination);122+ osc.start(t0);123+ osc.stop(t0 + 0.14);124+ return;125+ }126+ if (name === "solve") {127+ // The figure resolves to the tonic, with a bell over it.128+ [0, 3, 5, 7, 12].forEach((semitone, i) =>129+ tone(ROOT * Math.pow(2, semitone / 12), 0.5, 0.12, "triangle", i * 0.085),130+ );131+ tone(ROOT * 4, 1.6, 0.06, "sine", 0.42);132+ }133+}134+135+export function buzz(pattern, enabled) {136+ if (!enabled || !navigator.vibrate) return;137+ try {138+ navigator.vibrate(Array.from(pattern));139+ } catch (_) {140+ // Some browsers refuse without a gesture; there is nothing to recover.141+ }142+}143+144+// ------------------------------------------------------------------- system145+146+const dark = query("(prefers-color-scheme: dark)");147+const still = query("(prefers-reduced-motion: reduce)");148+149+function query(media) {150+ return typeof window !== "undefined" && window.matchMedia151+ ? window.matchMedia(media)152+ : { matches: false, addEventListener() {} };153+}154+155+export function prefersDark() {156+ return dark.matches;157+}158+159+export function prefersStill() {160+ return still.matches;161+}162+163+export function watchSystem(dispatch, message) {164+ const fire = () => dispatch(message);165+ dark.addEventListener("change", fire);166+ still.addEventListener("change", fire);167+}168+169+export function watchViewport(dispatch, message) {170+ const fire = () => dispatch(message);171+ window.addEventListener("resize", fire);172+ // A rotation reports its new size a beat after it says it happened.173+ window.addEventListener("orientationchange", () => setTimeout(fire, 150));174+ // The board owns every gesture, and a browser that pinches or double-taps175+ // mid-drag has taken one.176+ document.addEventListener("gesturestart", (e) => e.preventDefault());177+ document.addEventListener("dblclick", (e) => e.preventDefault());178+ document.addEventListener("contextmenu", (e) => {179+ if (e.target instanceof HTMLCanvasElement) e.preventDefault();32180 });33- return solverWorker;34181 }35182 36-export function beginSurvey(familyId, width, height, goalX, goalY, solvedGrid, startState, dispatch, progress, ready) {37- surveys.set(familyId, { dispatch, progress, ready });38- solver().postMessage({39- type: "survey",40- familyId,41- width,42- height,43- goalX,44- goalY,45- solvedGrid: solvedGrid.split(",").map(Number),46- startState,183+export function watchKeys(dispatch, toMessage) {184+ window.addEventListener("keydown", (event) => {185+ dispatch(toMessage(event.key));47186 });48187 }49188 50-export function queryDistance(familyId, state, dispatch, message) {51- const id = ++requestId;52- queries.set(id, { dispatch, message });53- solver().postMessage({ type: "distance", familyId, state, requestId: id });189+export function watchFrame(dispatch, toMessage) {190+ const loop = (now) => {191+ requestAnimationFrame(loop);192+ dispatch(toMessage(now));193+ };194+ requestAnimationFrame(loop);195+}196+197+export function now() {198+ return Date.now();199+}200+201+export function today() {202+ return new Date().toISOString().slice(0, 10);54203 }55204 56-export function watchInstall(dispatch, availableMessage) {57- installWatcher = () => dispatch(availableMessage);205+export function dayOfYear() {206+ const date = new Date();207+ const start = Date.UTC(date.getFullYear(), 0, 1);208+ const today = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());209+ return Math.floor((today - start) / 86400000) + 1;210+}211+212+export function setThemeColour(ink, paper, ground) {213+ const root = document.documentElement.style;214+ root.setProperty("--ink", ink);215+ root.setProperty("--paper", paper);216+ root.setProperty("--ground", ground);217+ const meta = document.querySelector('meta[name="theme-color"]');218+ if (meta) meta.setAttribute("content", paper);219+}220+221+export function loadFonts(dispatch, message) {222+ const faces = [223+ document.fonts.load("32px Silk"),224+ document.fonts.load("32px SilkR"),225+ document.fonts.load("16px Mono"),226+ ];227+ Promise.all(faces)228+ .catch(() => {})229+ .then(() => dispatch(message));230+}231+232+// ------------------------------------------------------------- installation233+234+let installPrompt = null;235+236+export function watchInstall(dispatch, message) {58237 window.addEventListener("beforeinstallprompt", (event) => {59238 event.preventDefault();60239 installPrompt = event;61- installWatcher?.();62- }, { once: true });240+ dispatch(message);241+ });63242 }64243 65-export async function promptInstall(dispatch, finishedMessage) {244+export async function promptInstall(dispatch, message) {66245 const prompt = installPrompt;67246 installPrompt = null;68247 if (prompt) {69248 try {70- await prompt.prompt();249+ prompt.prompt();71250 await prompt.userChoice;72- } catch (_) {}251+ } catch (_) {252+ // Dismissed, or unsupported. Either way there is nothing to report.253+ }73254 }74- dispatch(finishedMessage);255+ dispatch(message);75256 }76257 77-export function load() {78- try {79- return localStorage.getItem(KEY) || "";80- } catch (_) {81- return "";82- }258+export function registerServiceWorker() {259+ if (!("serviceWorker" in navigator)) return;260+ window.addEventListener("load", () => {261+ navigator.serviceWorker.register("/sw.js").catch(() => {});262+ });83263 }84264 85-export function save(value) {86- try {87- localStorage.setItem(KEY, value);88- } catch (_) {89- // Storage may be unavailable in private browsing. The in-memory model is90- // still authoritative for this session.91- }265+// ------------------------------------------------------------- the surveyor266+267+let solver = null;268+269+function worker() {270+ if (solver) return solver;271+ solver = new Worker("/solver-worker.js");272+ return solver;92273 }93274 94-function tone(frequency, duration = 0.12, volume = 0.045, delay = 0) {95- const AudioContext = window.AudioContext || window.webkitAudioContext;96- if (!AudioContext) return;97- audioContext ||= new AudioContext();98- if (audioContext.state === "suspended") audioContext.resume();99-100- const start = audioContext.currentTime + delay;101- const oscillator = audioContext.createOscillator();102- const gain = audioContext.createGain();103- oscillator.type = "triangle";104- oscillator.frequency.value = frequency;105- gain.gain.setValueAtTime(0.0001, start);106- gain.gain.exponentialRampToValueAtTime(volume, start + 0.008);107- gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);108- oscillator.connect(gain).connect(audioContext.destination);109- oscillator.start(start);110- oscillator.stop(start + duration + 0.02);111-}112-113-export function feedback(kind, sound, haptics) {114- if (sound) {115- if (kind === "move") tone(196);116- if (kind === "page") tone(330, 0.09, 0.035);117- if (kind === "blocked") tone(82, 0.1, 0.035);118- if (kind === "solve") {119- [196, 233, 294, 392].forEach((frequency, index) =>120- tone(frequency, 0.42, 0.05, index * 0.075),121- );275+export function watchSolver(dispatch, sweeping, surveyed, puzzled, measured, probed) {276+ worker().addEventListener("message", ({ data }) => {277+ if (data.type === "probed") {278+ dispatch(probed(data.familyId, toList(data.distances)));279+ return;122280 }123- }281+ if (data.type === "sweeping") {282+ dispatch(sweeping(data.familyId, data.count, data.total, data.phase));283+ } else if (data.type === "surveyed") {284+ dispatch(surveyed(data.familyId, data.ecc, data.count));285+ } else if (data.type === "puzzled") {286+ dispatch(puzzled(data.familyId, data.rung, toList(data.candidates)));287+ } else if (data.type === "measured") {288+ dispatch(measured(data.familyId, data.seq, toList(data.distances)));289+ }290+ });291+}124292 125- if (haptics && navigator.vibrate) {126- const pattern = kind === "solve" ? [18, 35, 70] : kind === "blocked" ? [10, 24, 10] : 7;127- try { navigator.vibrate(pattern); } catch (_) {}128- }293+export function survey(familyId, width, height, goalX, goalY, grid) {294+ worker().postMessage({295+ type: "survey",296+ familyId,297+ width,298+ height,299+ goalX,300+ goalY,301+ grid: Array.from(grid),302+ });303+}304+305+export function requestPuzzle(306+ familyId,307+ rung,308+ want,309+ ceiling,310+ window,311+ offset,312+ seed,313+ stray,314+ rollouts,315+ goalX,316+ goalY,317+) {318+ worker().postMessage({319+ type: "puzzle",320+ familyId,321+ rung,322+ want,323+ ceiling,324+ window,325+ offset,326+ seed,327+ stray,328+ rollouts,329+ goalX,330+ goalY,331+ });332+}333+334+export function probe(familyId, states) {335+ worker().postMessage({ type: "probe", familyId, states: Array.from(states) });336+}337+338+// The handle a harness drives the game through — and the one anybody inspecting339+// a phone over chrome://inspect reaches for. It dispatches into the app's own340+// input path rather than reaching past it, so what a screenshot shows is what a341+// run does.342+export function exposeHarness(dispatch, solve) {343+ window.klotski = Object.assign(window.klotski || {}, {344+ solve: () => {345+ dispatch(solve);346+ return "walking";347+ },348+ });349+}350+351+export function requestDistances(familyId, states, seq) {352+ worker().postMessage({353+ type: "distances",354+ familyId,355+ states: Array.from(states),356+ seq,357+ });129358 }
D src/klotski/catalog.gleam +0 -109
@@ -1,109 +0,0 @@
1-import gleam/list2-import gleam/result3-import klotski/domain.{type Board, Board}4-5-pub type Puzzle {6- Puzzle(stage: Int, optimal: Int, grid: List(Int))7-}8-9-pub type Family {10- Family(11- id: String,12- label: String,13- board: Board,14- ceiling: Int,15- puzzles: List(Puzzle),16- )17-}18-19-pub fn all() -> List(Family) {20- [21- Family("classic", "CLASSIC", Board(4, 5, 1, 3), 126, [22- p(1, 14, [2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 4, 4, 0, 0, 4, 4, 3, 3, 1, 1]),23- p(2, 20, [0, 2, 2, 2, 2, 2, 2, 2, 2, 4, 4, 1, 1, 4, 4, 1, 1, 0, 3, 3]),24- p(3, 26, [1, 2, 2, 2, 2, 2, 2, 2, 2, 4, 4, 1, 0, 4, 4, 0, 1, 3, 3, 1]),25- p(4, 32, [2, 2, 2, 1, 2, 2, 2, 1, 1, 2, 4, 4, 0, 2, 4, 4, 1, 3, 3, 0]),26- ]),27- Family("narrow", "NARROW", Board(3, 5, 0, 3), 24, [28- p(1, 5, [1, 3, 3, 1, 0, 1, 1, 1, 1, 0, 4, 4, 1, 4, 4]),29- p(2, 7, [1, 1, 1, 1, 0, 1, 3, 3, 0, 1, 4, 4, 1, 4, 4]),30- p(3, 9, [3, 3, 1, 1, 1, 0, 4, 4, 0, 4, 4, 1, 1, 1, 1]),31- p(4, 11, [3, 3, 1, 0, 1, 1, 1, 4, 4, 1, 4, 4, 1, 0, 1]),32- ]),33- Family("compact", "COMPACT", Board(4, 4, 1, 2), 27, [34- p(1, 6, [3, 3, 3, 3, 4, 4, 1, 0, 4, 4, 1, 1, 0, 1, 0, 0]),35- p(2, 8, [3, 3, 4, 4, 3, 3, 4, 4, 1, 0, 0, 0, 1, 0, 1, 1]),36- p(3, 10, [3, 3, 4, 4, 3, 3, 4, 4, 1, 0, 0, 1, 0, 1, 1, 0]),37- p(4, 12, [0, 0, 4, 4, 3, 3, 4, 4, 3, 3, 1, 0, 1, 0, 1, 1]),38- ]),39- Family("span", "SPAN", Board(5, 4, 1, 2), 33, [40- p(1, 7, [2, 2, 1, 0, 0, 2, 2, 1, 1, 0, 2, 2, 4, 4, 1, 2, 2, 4, 4, 0]),41- p(2, 9, [2, 4, 4, 1, 0, 2, 4, 4, 0, 1, 2, 2, 2, 0, 1, 2, 2, 2, 1, 0]),42- p(3, 11, [4, 4, 2, 0, 1, 4, 4, 2, 2, 1, 2, 2, 0, 2, 0, 2, 2, 1, 0, 1]),43- p(4, 13, [2, 2, 4, 4, 0, 2, 2, 4, 4, 0, 2, 2, 0, 1, 1, 2, 2, 1, 0, 1]),44- ]),45- Family("wide", "WIDE", Board(4, 5, 1, 3), 80, [46- p(1, 14, [1, 3, 3, 1, 0, 0, 1, 1, 3, 3, 4, 4, 3, 3, 4, 4, 3, 3, 1, 1]),47- p(2, 18, [3, 3, 3, 3, 1, 1, 3, 3, 1, 4, 4, 0, 1, 4, 4, 1, 3, 3, 1, 0]),48- p(3, 22, [3, 3, 3, 3, 3, 3, 4, 4, 0, 1, 4, 4, 1, 3, 3, 0, 1, 1, 1, 1]),49- p(4, 26, [3, 3, 3, 3, 3, 3, 4, 4, 1, 1, 4, 4, 3, 3, 1, 1, 0, 0, 1, 1]),50- ]),51- Family("yard", "YARD", Board(5, 4, 2, 2), 101, [52- p(1, 14, [2, 3, 3, 0, 1, 2, 3, 3, 3, 3, 3, 3, 1, 4, 4, 1, 0, 1, 4, 4]),53- p(2, 19, [2, 3, 3, 3, 3, 2, 1, 4, 4, 0, 3, 3, 4, 4, 0, 1, 3, 3, 1, 1]),54- p(3, 24, [2, 3, 3, 0, 0, 2, 3, 3, 4, 4, 3, 3, 1, 4, 4, 1, 1, 3, 3, 1]),55- p(4, 29, [3, 3, 4, 4, 0, 3, 3, 4, 4, 0, 1, 2, 3, 3, 1, 1, 2, 3, 3, 1]),56- ]),57- Family("corner", "CORNER", Board(4, 5, 0, 3), 129, [58- p(1, 14, [3, 3, 3, 3, 1, 3, 3, 2, 0, 1, 1, 2, 2, 0, 4, 4, 2, 1, 4, 4]),59- p(2, 20, [3, 3, 3, 3, 3, 3, 1, 1, 2, 0, 0, 2, 2, 4, 4, 2, 1, 4, 4, 1]),60- p(3, 26, [3, 3, 1, 2, 3, 3, 0, 2, 2, 1, 4, 4, 2, 1, 4, 4, 3, 3, 1, 0]),61- p(4, 32, [0, 3, 3, 1, 2, 3, 3, 2, 2, 4, 4, 2, 1, 4, 4, 0, 3, 3, 1, 1]),62- ]),63- Family("mixed", "MIXED", Board(4, 5, 1, 3), 178, [64- p(1, 14, [3, 3, 3, 3, 1, 1, 3, 3, 2, 2, 4, 4, 2, 2, 4, 4, 0, 1, 0, 1]),65- p(2, 23, [3, 3, 1, 2, 2, 3, 3, 2, 2, 4, 4, 1, 0, 4, 4, 0, 1, 1, 3, 3]),66- p(3, 32, [2, 0, 3, 3, 2, 0, 3, 3, 1, 4, 4, 1, 1, 4, 4, 2, 3, 3, 1, 2]),67- p(4, 41, [3, 3, 3, 3, 2, 0, 0, 1, 2, 4, 4, 2, 1, 4, 4, 2, 1, 1, 3, 3]),68- ]),69- ]70-}71-72-fn p(stage: Int, optimal: Int, grid: List(Int)) -> Puzzle {73- Puzzle(stage, optimal, grid)74-}75-76-pub fn family_at(index: Int) -> Family {77- all()78- |> list.drop(index)79- |> list.first80- |> result.unwrap(Family("classic", "CLASSIC", Board(4, 5, 1, 3), 126, []))81-}82-83-pub fn puzzle_at(family: Family, index: Int) -> Puzzle {84- family.puzzles85- |> list.drop(index)86- |> list.first87- |> result.unwrap(Puzzle(1, 0, []))88-}89-90-pub fn puzzle_count(family: Family) -> Int {91- list.length(family.puzzles)92-}93-94-/// The solved packing that identifies a family in the atlas. These are not95-/// puzzle starts: showing the family packing before it has been opened is an96-/// intentional part of the original navigation language.97-pub fn preview_grid(family: Family) -> List(Int) {98- case family.id {99- "classic" -> [2, 4, 4, 2, 2, 4, 4, 2, 2, 3, 3, 2, 2, 1, 1, 2, 1, 0, 0, 1]100- "narrow" -> [3, 3, 1, 1, 1, 1, 1, 1, 1, 4, 4, 0, 4, 4, 0]101- "compact" -> [3, 3, 3, 3, 1, 1, 1, 1, 0, 4, 4, 0, 0, 4, 4, 0]102- "span" -> [2, 2, 2, 2, 1, 2, 2, 2, 2, 1, 0, 4, 4, 0, 1, 0, 4, 4, 0, 1]103- "wide" -> [3, 3, 3, 3, 3, 3, 3, 3, 1, 1, 1, 1, 1, 4, 4, 1, 0, 4, 4, 0]104- "yard" -> [2, 3, 3, 3, 3, 2, 3, 3, 3, 3, 1, 1, 4, 4, 0, 1, 1, 4, 4, 0]105- "corner" -> [3, 3, 3, 3, 2, 3, 3, 2, 2, 1, 1, 2, 4, 4, 1, 1, 4, 4, 0, 0]106- "mixed" -> [3, 3, 3, 3, 2, 3, 3, 2, 2, 1, 1, 2, 1, 4, 4, 1, 0, 4, 4, 0]107- _ -> []108- }109-}
A src/klotski/difficulty.gleam +84 -0
@@ -0,0 +1,84 @@
1+/// Difficulty, measured from the shape of the state space.2+///3+/// The tempting metrics are the wrong ones. Optimal solution length correlates4+/// only weakly with how long a person actually takes (Jarusek and Pelanek put5+/// it near rho = 0.47 on Sokoban), and solver effort — nodes expanded — is6+/// worse still, near rho = 0.2. Machine difficulty is not human difficulty.7+///8+/// What does predict it is a walker: someone who mostly steps toward the goal9+/// but strays with probability eps, because the board rarely tells you which10+/// way is downhill. Its expected hitting time absorbs branching factor,11+/// corridor width and the quality of the local gradient in one number. A long12+/// solution down a wide, well-signposted corridor stays easy; a short one that13+/// makes you retreat does not.14+///15+/// We combine that with the count of counter-intuitive steps — moves on the16+/// optimal path that carry the goal block *away* from its exit, which is the17+/// move nobody finds.18+///19+/// The walk itself happens where the distance field is, which is the survey20+/// worker: eighty thousand states is not something to hand across a boundary21+/// forty times a rung. What lives here is the part that is a judgement rather22+/// than a traversal — how the four measurements are weighed, and where the five23+/// marks fall — so that changing the definition of difficulty is a change to24+/// this file and not to a worker.25+import gleam/float26+import gleam/int27+28+pub type Metrics {29+ Metrics(30+ /// Optimal solution length, in unit steps.31+ optimal: Int,32+ /// Expected steps for an eps-greedy walker to arrive.33+ wander: Float,34+ /// Moves on the optimal path that carry the goal block away from its exit.35+ retreats: Int,36+ /// Mean legal moves available along that path.37+ branch: Float,38+ score: Float,39+ )40+}41+42+/// How strayed a walker is, and how many rollouts are averaged. Passed to the43+/// worker rather than written there, because they are part of what difficulty44+/// is defined to mean.45+pub const stray = 0.1546+47+pub const rollouts = 4048+49+/// The weighing. Logarithms on the three unbounded measurements, because each50+/// of them is worth much less at the top of its range than at the bottom: the51+/// difference between a 5-step board and a 20-step one is felt, and the52+/// difference between 150 and 165 is not.53+pub fn score(54+ optimal optimal: Int,55+ wander wander: Float,56+ retreats retreats: Int,57+ branch branch: Float,58+) -> Float {59+ let retreat_rate =60+ int.to_float(retreats) /. int.to_float(int.max(1, optimal))61+ 0.1562+ *. log1p(int.to_float(optimal))63+ +. 0.4064+ *. log1p(wander)65+ +. 0.6066+ *. retreat_rate67+ +. 0.1068+ *. log1p(branch)69+}70+71+fn log1p(value: Float) -> Float {72+ case float.logarithm(1.0 +. float.max(0.0, value)) {73+ Ok(result) -> result74+ Error(_) -> 0.075+ }76+}77+78+/// Five marks, so a plate can wear its difficulty without a number.79+///80+/// Calibrated against the observed range across all the families, roughly 1.781+/// for an opening plate to 3.2 for the deepest one in the atlas.82+pub fn grade(score: Float) -> Int {83+ int.max(1, int.min(5, 1 + float.round({ score -. 1.65 } *. 2.6)))84+}
M src/klotski/domain.gleam +6 -0
A src/klotski/families.gleam +143 -0
@@ -0,0 +1,143 @@
1+/// A family is a board, a piece multiset and a goal — the unit of generation.2+///3+/// Difficulty inside a family comes from the distance field, not from changing4+/// the pieces; changing the pieces is what makes a *different* family, with its5+/// own state-space character. Hole count is the strongest single dial: three6+/// cells of air move loosely, two seize up.7+///8+/// Every packing here but one is a *solved* one, with the big block already9+/// seated. Sweeping outward from a solved state makes reachability free, which10+/// matters more than it sounds: most hand-written packings strand the big block11+/// in a component containing no solved state at all, and are not puzzles.12+///13+/// CLASSIC is the exception, and is allowed to be: its packing is the14+/// traditional arrangement, which is a landmark in its own right at 116 unit15+/// steps from home. It is safe because the sweep does not require a solved16+/// packing, only a packing in a component that contains one — pass one finds the17+/// component, pass two measures it from every seated state inside it.18+///19+/// None of these is a puzzle *start*. A start is whatever configuration the20+/// survey hands back at the depth the ladder asked for, which is why there is no21+/// list of them anywhere: a family carries a state space, not a set of levels.22+import gleam/list23+import gleam/result24+import klotski/domain.{type Board, Board}25+26+pub type Family {27+ Family(28+ id: String,29+ label: String,30+ width: Int,31+ height: Int,32+ goal_x: Int,33+ goal_y: Int,34+ /// The solved packing: 0 empty, 1 single, 2 upright, 3 recumbent, 4 goal.35+ grid: List(Int),36+ )37+}38+39+pub fn board(family: Family) -> Board {40+ Board(family.width, family.height, family.goal_x, family.goal_y)41+}42+43+/// Classic leads because it is what the word on the title screen promises. The44+/// rest are ordered by ceiling, so the list past it is a ramp: 24 steps of45+/// depth at the top, 178 at the bottom.46+pub fn all() -> List(Family) {47+ [48+ // The traditional packing, and the only family whose own arrangement is a49+ // landmark in its own right: 116 unit steps from home.50+ Family("classic", "CLASSIC", 4, 5, 1, 3, [51+ 2, 4, 4, 2,52+ 2, 4, 4, 2,53+ 2, 3, 3, 2,54+ 2, 1, 1, 2,55+ 1, 0, 0, 1,56+ ]),57+ Family("narrow", "NARROW", 3, 5, 0, 3, [58+ 3, 3, 1,59+ 1, 1, 1,60+ 1, 1, 1,61+ 4, 4, 0,62+ 4, 4, 0,63+ ]),64+ Family("compact", "COMPACT", 4, 4, 1, 2, [65+ 3, 3, 3, 3,66+ 1, 1, 1, 1,67+ 0, 4, 4, 0,68+ 0, 4, 4, 0,69+ ]),70+ // Four cells of air: it gives, and keeps giving. A wide state space with a71+ // low ceiling, which is a different kind of easy from a small one.72+ Family("span", "SPAN", 5, 4, 1, 2, [73+ 2, 2, 2, 2, 1,74+ 2, 2, 2, 2, 1,75+ 0, 4, 4, 0, 1,76+ 0, 4, 4, 0, 1,77+ ]),78+ Family("wide", "WIDE", 4, 5, 1, 3, [79+ 3, 3, 3, 3,80+ 3, 3, 3, 3,81+ 1, 1, 1, 1,82+ 1, 4, 4, 1,83+ 0, 4, 4, 0,84+ ]),85+ Family("yard", "YARD", 5, 4, 2, 2, [86+ 2, 3, 3, 3, 3,87+ 2, 3, 3, 3, 3,88+ 1, 1, 4, 4, 0,89+ 1, 1, 4, 4, 0,90+ ]),91+ // The same pieces as MIXED, leaving by the corner instead of the middle.92+ // One goal cell moved, and the distance field is a different shape — the93+ // cheapest way there is to get a genuinely new family.94+ Family("corner", "CORNER", 4, 5, 0, 3, [95+ 3, 3, 3, 3,96+ 2, 3, 3, 2,97+ 2, 1, 1, 2,98+ 4, 4, 1, 1,99+ 4, 4, 0, 0,100+ ]),101+ Family("mixed", "MIXED", 4, 5, 1, 3, [102+ 3, 3, 3, 3,103+ 2, 3, 3, 2,104+ 2, 1, 1, 2,105+ 1, 4, 4, 1,106+ 0, 4, 4, 0,107+ ]),108+ ]109+}110+111+pub fn count() -> Int {112+ list.length(all())113+}114+115+pub fn at(index: Int) -> Family {116+ all()117+ |> list.drop(index)118+ |> list.first119+ |> result.unwrap(fallback())120+}121+122+pub fn by_id(id: String) -> Family {123+ all()124+ |> list.find(fn(family) { family.id == id })125+ |> result.unwrap(fallback())126+}127+128+pub fn index_of(id: String) -> Int {129+ all()130+ |> list.index_map(fn(family, index) { #(family.id, index) })131+ |> list.key_find(id)132+ |> result.unwrap(0)133+}134+135+fn fallback() -> Family {136+ Family("classic", "CLASSIC", 4, 5, 1, 3, [137+ 2, 4, 4, 2,138+ 2, 4, 4, 2,139+ 2, 3, 3, 2,140+ 2, 1, 1, 2,141+ 1, 0, 0, 1,142+ ])143+}
A src/klotski/ground.gleam +156 -0
@@ -0,0 +1,156 @@
1+/// The ground: what is behind everything, when nothing is in front of it.2+///3+/// There used to be exactly one of these — a 4px lattice of ink dots on bare4+/// paper — and it had to be drawn *into* the greyscale scene before the5+/// threshold. That put a hard constraint on it: both its values had to sit at6+/// the extremes, because error diffusion has no error to spread at 0 and 2557+/// and so passes such a pattern through untouched. Anything with a midtone in8+/// it, or in any colour other than ink and paper, was simply not available.9+///10+/// The ground is the *absence* of scene instead. The scene is cleared to11+/// transparent, the screens paint their opaque windows onto it, and every pixel12+/// still transparent when the frame is thresholded is ground. That is13+/// pixel-exact occlusion for the cost of one alpha test, and it moves the14+/// ground to after the threshold, where it is free to be any two colours.15+///16+/// So a pattern here is not a tone. It is a stencil: one bit per pixel saying17+/// dot or field. Every tile is 8x8 because the period is 8 — slides are18+/// quantised to it so a pattern never lands out of phase, and a tile with any19+/// other period would beat against that.20+///21+/// They are deliberately sparse. A background is not a picture; the densest22+/// here is one pixel in eight, and the lattice that shipped first is one in23+/// sixteen. A ground that competes with the board is a ground that has to be24+/// looked past.25+import gleam/list26+import gleam/result27+28+pub type Backdrop {29+ Lattice30+ Sparse31+ Twill32+ Rule33+ Graph34+ Benday35+ Plain36+}37+38+pub fn all() -> List(Backdrop) {39+ [Lattice, Sparse, Twill, Rule, Graph, Benday, Plain]40+}41+42+pub fn id(backdrop: Backdrop) -> String {43+ case backdrop {44+ Lattice -> "lattice"45+ Sparse -> "sparse"46+ Twill -> "twill"47+ Rule -> "rule"48+ Graph -> "graph"49+ Benday -> "benday"50+ Plain -> "plain"51+ }52+}53+54+pub fn from_id(value: String) -> Backdrop {55+ all()56+ |> list.find(fn(backdrop) { id(backdrop) == value })57+ |> result.unwrap(Lattice)58+}59+60+/// The stencil, row-major, 64 entries of 0 or 1.61+pub fn tile(backdrop: Backdrop) -> List(Int) {62+ case backdrop {63+ // The original, preserved exactly: rows every 4px, alternate rows offset64+ // by 2. One pixel in sixteen.65+ Lattice -> [66+ 1, 0, 0, 0, 1, 0, 0, 0,67+ 0, 0, 0, 0, 0, 0, 0, 0,68+ 0, 0, 0, 0, 0, 0, 0, 0,69+ 0, 0, 0, 0, 0, 0, 0, 0,70+ 0, 0, 1, 0, 0, 0, 1, 0,71+ 0, 0, 0, 0, 0, 0, 0, 0,72+ 0, 0, 0, 0, 0, 0, 0, 0,73+ 0, 0, 0, 0, 0, 0, 0, 0,74+ ]75+76+ // Two dots in sixty-four. Almost nothing — which is the point on a dark77+ // theme, where the ground reads as a field of stars rather than a texture.78+ Sparse -> [79+ 1, 0, 0, 0, 0, 0, 0, 0,80+ 0, 0, 0, 0, 0, 0, 0, 0,81+ 0, 0, 0, 0, 0, 0, 0, 0,82+ 0, 0, 0, 0, 0, 0, 0, 0,83+ 0, 0, 0, 0, 1, 0, 0, 0,84+ 0, 0, 0, 0, 0, 0, 0, 0,85+ 0, 0, 0, 0, 0, 0, 0, 0,86+ 0, 0, 0, 0, 0, 0, 0, 0,87+ ]88+89+ // A single diagonal thread. The only pattern with a direction in it, which90+ // is exactly why there is one and not two: crossing it with its mirror91+ // doubles the density and reads as a mesh rather than a weave.92+ Twill -> [93+ 1, 0, 0, 0, 0, 0, 0, 0,94+ 0, 0, 0, 0, 0, 0, 0, 1,95+ 0, 0, 0, 0, 0, 0, 1, 0,96+ 0, 0, 0, 0, 0, 1, 0, 0,97+ 0, 0, 0, 0, 1, 0, 0, 0,98+ 0, 0, 0, 1, 0, 0, 0, 0,99+ 0, 0, 1, 0, 0, 0, 0, 0,100+ 0, 1, 0, 0, 0, 0, 0, 0,101+ ]102+103+ // Dotted rules every 4px. A solid scanline was tried and it stripes the104+ // screen; broken in half it reads as ruled paper.105+ Rule -> [106+ 1, 0, 1, 0, 1, 0, 1, 0,107+ 0, 0, 0, 0, 0, 0, 0, 0,108+ 0, 0, 0, 0, 0, 0, 0, 0,109+ 0, 0, 0, 0, 0, 0, 0, 0,110+ 1, 0, 1, 0, 1, 0, 1, 0,111+ 0, 0, 0, 0, 0, 0, 0, 0,112+ 0, 0, 0, 0, 0, 0, 0, 0,113+ 0, 0, 0, 0, 0, 0, 0, 0,114+ ]115+116+ // Graph paper: one dotted rule each way per tile.117+ Graph -> [118+ 1, 0, 1, 0, 1, 0, 1, 0,119+ 0, 0, 0, 0, 0, 0, 0, 0,120+ 1, 0, 0, 0, 0, 0, 0, 0,121+ 0, 0, 0, 0, 0, 0, 0, 0,122+ 1, 0, 0, 0, 0, 0, 0, 0,123+ 0, 0, 0, 0, 0, 0, 0, 0,124+ 1, 0, 0, 0, 0, 0, 0, 0,125+ 0, 0, 0, 0, 0, 0, 0, 0,126+ ]127+128+ // Clustered dots — the newsprint ground, and the backdrop that belongs129+ // with the Ben-Day block screen. Clustered rather than dispersed is the130+ // whole difference between a halftone and a dither.131+ Benday -> [132+ 0, 0, 0, 0, 0, 0, 0, 0,133+ 0, 1, 1, 0, 0, 0, 0, 0,134+ 0, 1, 1, 0, 0, 0, 0, 0,135+ 0, 0, 0, 0, 0, 0, 0, 0,136+ 0, 0, 0, 0, 0, 0, 0, 0,137+ 0, 0, 0, 0, 0, 1, 1, 0,138+ 0, 0, 0, 0, 0, 1, 1, 0,139+ 0, 0, 0, 0, 0, 0, 0, 0,140+ ]141+142+ // No pattern at all. Not an omission — a flat ground is a real member of143+ // the family, and it is the only one that lets a pure-black theme actually144+ // reach pure black across the whole screen.145+ Plain -> [146+ 0, 0, 0, 0, 0, 0, 0, 0,147+ 0, 0, 0, 0, 0, 0, 0, 0,148+ 0, 0, 0, 0, 0, 0, 0, 0,149+ 0, 0, 0, 0, 0, 0, 0, 0,150+ 0, 0, 0, 0, 0, 0, 0, 0,151+ 0, 0, 0, 0, 0, 0, 0, 0,152+ 0, 0, 0, 0, 0, 0, 0, 0,153+ 0, 0, 0, 0, 0, 0, 0, 0,154+ ]155+ }156+}
A src/klotski/layout.gleam +585 -0
@@ -0,0 +1,585 @@
1+/// Where everything goes.2+///3+/// A phone is no longer one shape. It is 22:9 and it is 4:3, it folds open into4+/// a square, it turns on its side, and a keypad phone is a 4:3 screen the size5+/// of a card. What all of those have in common is that the *composition* does6+/// not want to change with them: this is a game drawn at one scale in a ~2007+/// unit column, and stretching that column to fit a screen is how a designed8+/// thing turns into a responsive web page.9+///10+/// So the column is not the picture. The picture is the screen — the canvas11+/// covers the whole safe area — and the column sits in the middle of it at the12+/// width it has always been. What that leaves over is not a margin to be13+/// apologised for: every pixel nobody paints becomes ground, so on a wide14+/// screen the leftover is the desktop, patterned, dithered, and exactly what a15+/// Macintosh with a big monitor showed.16+///17+/// That handles every proportion in one direction. It does not handle the other18+/// one: on a landscape screen height is the scarce dimension, and a column19+/// stacked board-over-controls has to shrink the board to nothing to fit its20+/// own chrome underneath. So there is a second arrangement, in which the same21+/// two windows stop stacking and sit side by side. Two windows on a desktop is22+/// not a concession to landscape — it is what this system did with a wide23+/// screen.24+///25+/// Which arrangement, and at what upscale, is *chosen* rather than calculated.26+/// Both were tried as formulas first and both kept being wrong at the edges: a27+/// threshold on the proportion sends a 768x800 fold to the side-by-side28+/// arrangement, where it comes out worse than stacked, and any expression that29+/// gets the upscale right for a 900-tall desktop gets it wrong for a 393-tall30+/// phone on its side. There are only about a dozen candidates, they can each be31+/// costed exactly, and the cost is not a matter of opinion — so the search32+/// below builds all of them and takes the best. The arrangement falls out of33+/// that rather than being a rule anyone has to defend.34+import gleam/float35+import gleam/int36+import gleam/list37+import klotski/families.{type Family}38+import klotski/shading39+40+/// The design width, in layout units. Every type size and offset in the app is41+/// a fraction of this and always has been; it is the one number the drawing42+/// code is entitled to assume.43+pub const col = 20044+45+/// How tall a column is allowed to get before the extra goes to the desktop46+/// instead. Past about this, a column stops reading as a page and starts47+/// reading as a corridor with the board adrift in the middle of it.48+const col_ratio = 1.949+50+/// Between the two windows when they sit side by side, and how wide the one51+/// holding the controls is allowed to be. Its floor is three readouts abreast;52+/// past its ceiling it is just a wide empty panel.53+const pair_gap = 1054+55+const panel_min = 10056+57+const panel_max = 16858+59+/// The board the picture is sized for. Every family is re-fitted afterwards — a60+/// 3x5 set gets a bigger cell than a 5x4 one — but the *picture* has to be61+/// decided before a family is open, and it cannot change when one is picked.62+const ref_w = 463+64+const ref_h = 565+66+pub type Mode {67+ Stack68+ Split69+}70+71+pub type Rect {72+ Rect(x: Int, y: Int, w: Int, h: Int)73+}74+75+pub type Box {76+ Box(x: Int, y: Int, w: Int, h: Int)77+}78+79+pub type Column {80+ Column(x: Int, w: Int, cx: Float)81+}82+83+pub type Panel {84+ Panel(x: Int, y: Int, w: Int, h: Int, actions_y: Int)85+}86+87+pub type Gauge {88+ Gauge(x: Int, y: Int, w: Int)89+}90+91+/// The picture, and everything that follows from the arrangement it was drawn92+/// in. Independent of which family is open, so a resize does not need to know.93+pub type Layout {94+ Layout(95+ mode: Mode,96+ scale: Float,97+ vw: Int,98+ vh: Int,99+ off_x: Int,100+ off_y: Int,101+ bar_h: Int,102+ hud_gap: Int,103+ head_h: Int,104+ foot_h: Int,105+ room_w: Int,106+ room_h: Int,107+ col: Column,108+ boot_win: Rect,109+ )110+}111+112+/// The board's own measurements, which depend on the family in play — a 3x5 set113+/// and a 5x4 set want different cells and a different plate.114+///115+/// Everything downstream reads the same names in either arrangement: the116+/// painters and the DOM chrome are told where the plate, the panel and the117+/// gauge are, and never which arrangement put them there.118+pub type BoardLayout {119+ BoardLayout(120+ cell: Int,121+ pad_x: Int,122+ pad_top: Int,123+ pad_bottom: Int,124+ below_plate: Int,125+ tray_pad: Int,126+ plate: Rect,127+ board: Rect,128+ panel: Panel,129+ gauge: Gauge,130+ hud_y: Int,131+ record_y: Int,132+ )133+}134+135+/// The plate's three measurements as functions of the cell, for a given board.136+///137+/// Shared by the fitting and the drawing so the two cannot drift apart: the138+/// tray is inset from the cells because a block's drop shadow reaches past its139+/// own cell, and without that margin the bottom and right rules sit directly on140+/// the last row of blocks and clip their shadows.141+pub fn plate_pad(cell: Int) -> Int {142+ int.max(3, round(int.to_float(cell) *. 0.16))143+}144+145+pub fn plate_w(cell: Int, board_w: Int) -> Int {146+ cell * board_w + plate_pad(cell) * 2 + round(int.to_float(cell) *. 0.5) * 2147+}148+149+pub fn plate_h(cell: Int, board_h: Int) -> Int {150+ round(151+ int.to_float(cell * board_h)152+ +. int.to_float(plate_pad(cell) * 2)153+ +. int.to_float(cell) *. 0.5154+ +. int.to_float(cell) *. 1.05,155+ )156+}157+158+/// The largest cell that fits the room, in whole dither periods so an ordered159+/// screen does not flash when a block slides. `Error` when even the smallest one160+/// does not fit, which is how a candidate picture gets ruled out.161+///162+/// Stacked, the air under the plate is part of what has to fit: it is measured163+/// off the cell, so leaving it out of the fitting is how a board that fits by164+/// two units posts the buttons under it off the bottom of the screen.165+pub fn fit_cell(166+ mode: Mode,167+ room_w: Int,168+ room_h: Int,169+ board_w: Int,170+ board_h: Int,171+) -> Result(Int, Nil) {172+ let need = fn(cell) {173+ plate_h(cell, board_h)174+ + case mode {175+ Split -> 0176+ Stack -> round(int.to_float(cell) *. 0.3)177+ }178+ }179+ let fits = fn(cell) {180+ need(cell) <= room_h && plate_w(cell, board_w) <= room_w181+ }182+ let cell = shrink(40, fits)183+ case fits(cell) {184+ True -> Ok(cell)185+ False -> Error(Nil)186+ }187+}188+189+fn shrink(cell: Int, fits: fn(Int) -> Bool) -> Int {190+ case cell > 16 && !fits(cell) {191+ True -> shrink(cell - shading.period, fits)192+ False -> cell193+ }194+}195+196+/// Everything that follows from an arrangement and an upscale, as a pure197+/// function of the two, so a candidate can be built and costed without198+/// committing to it.199+fn metrics_for(mode: Mode, scale: Float, box: Box) -> Layout {200+ let vw = float.truncate(int.to_float(box.w) /. scale)201+ let vh = float.truncate(int.to_float(box.h) /. scale)202+ let split = mode == Split203+204+ // The column: where everything that is not the desktop is composed. It is205+ // `col` units wide wherever there is room for them, which is the point — the206+ // app is drawn at one scale and the screen does not get a say in it. It gives207+ // way in exactly two directions. A picture narrower than the design width has208+ // to be taken as it comes, and a taller one is allowed to widen a little209+ // rather than leave the board stranded in a corridor, up to a limit: past210+ // about a third over, the fixed things in it — the margins, the table rows,211+ // the buttons — start looking small for the page they are on.212+ let col_w = case split {213+ True ->214+ int.min(215+ vw,216+ int.max(217+ col + 24,218+ int.min(219+ round(int.to_float(vh) *. 1.2),220+ round(int.to_float(col) *. 1.6),221+ ),222+ ),223+ )224+ False ->225+ int.min(226+ vw,227+ int.max(228+ col,229+ int.min(230+ round(int.to_float(vh) /. col_ratio),231+ round(int.to_float(col) *. 1.35),232+ ),233+ ),234+ )235+ }236+237+ // The menu bar spans the screen, not the column — it did on a Macintosh and a238+ // bar that stopped short of the edges would read as a strip of window. It is239+ // proportioned against the height it has: a bar taking a twentieth of a tall240+ // column would take a tenth of a short one.241+ let bar_h =242+ int.max(243+ 14,244+ round(int.to_float(vh) *. case split {245+ True -> 0.07246+ False -> 0.048247+ }),248+ )249+250+ // The head and the foot are fractions with floors under them. The fractions251+ // are what a tall phone always used; the floors are what stops a short column252+ // from computing a foot too small to hold the controls that go in it and253+ // posting the actions row off the bottom of the screen. The foot is the254+ // gauge, the readout, the buttons and the air between them, so it is stated255+ // as exactly that rather than as a fraction that happens to work out.256+ let hud_gap =257+ int.min(46, int.max(26, round(int.to_float(vh) *. 0.095)))258+ let head_h = int.max(bar_h + 10, round(int.to_float(vh) *. 0.14))259+ let foot_h =260+ int.max(hud_gap + 30 + 10 + 34 + 4, round(int.to_float(vh) *. 0.30))261+262+ Layout(263+ mode:,264+ scale:,265+ vw:,266+ vh:,267+ off_x: 0,268+ off_y: 0,269+ bar_h:,270+ hud_gap:,271+ head_h:,272+ foot_h:,273+ // What the plate has to fit inside — the only thing the two arrangements274+ // actually argue about. Split measures against the narrowest the control275+ // panel is allowed to be, so the board gets first call on the width and the276+ // panel takes what is left.277+ room_w: case split {278+ True -> vw - 24 - panel_min - pair_gap279+ False -> col_w - 8280+ },281+ // Stacked, the board is fitted against the *least* head it could have — the282+ // bar and a little clearance. The head above is a composition, not a283+ // reservation: on a tall phone there is room for all of it and the plate284+ // sits where it always did, and on a short one it gives way rather than285+ // taking a step off the board to keep itself whole.286+ room_h: case split {287+ True -> vh - bar_h - 12288+ False -> vh - foot_h - { bar_h + 10 }289+ },290+ col: Column(291+ x: round({ int.to_float(vw) -. int.to_float(col_w) } /. 2.0),292+ w: col_w,293+ cx: round({ int.to_float(vw) -. int.to_float(col_w) } /. 2.0)294+ |> int.to_float295+ |> float.add(int.to_float(col_w) /. 2.0),296+ ),297+ boot_win: Rect(0, 0, 0, 0),298+ )299+}300+301+/// The upscales worth trying, in the order of how much they are worth.302+///303+/// A buffer pixel has to land on a whole number of *device* pixels or the304+/// compositor resamples the one thing in this app that must never be resampled.305+/// The picture is upscaled by S CSS pixels per layout unit and composed at306+/// `density` buffer pixels per unit, so a buffer pixel covers S*dpr/density307+/// device ones — and on the phone this was found on, S=2 with dpr 2.748 makes308+/// that 1.832. Nearest-neighbour then keeps five buffer rows out of every six309+/// and doubles the sixth, in a rhythm that walks down the screen: a ground310+/// ruled every four buffer pixels came out at seven device pixels two times in311+/// three and eight the rest, which is not a texture any more, it is a beat.312+///313+/// So the candidates are the scales where it lands whole: S = density*k/dpr for314+/// integer k, which is every scale at which one buffer pixel is exactly k315+/// device pixels. S is not an integer and does not need to be — what has to be316+/// whole is the device mapping, and this makes a *layout unit* exactly317+/// density*k device pixels, so the one-unit rules the whole design is drawn318+/// with are whole pixels too, which they were not before.319+///320+/// The integer upscales are still offered, and rank below any crisp one at the321+/// same board size. They are what a display with dpr 2 and density 3 gets:322+/// there is no k there that both lands whole and leaves the column wide enough,323+/// and a board at half the size is a worse answer than a board that resamples.324+fn upscales(dpr: Float, density: Int) -> List(#(Float, Bool)) {325+ let crisp =326+ [1, 2, 3, 4, 5, 6]327+ |> list.map(fn(k) { int.to_float(density * k) /. dpr })328+ |> list.filter(fn(scale) { scale >=. 1.0 && scale <=. 8.0 })329+ |> list.map(fn(scale) { #(scale, True) })330+ let plain =331+ [1, 2, 3, 4, 5, 6, 7, 8]332+ |> list.map(fn(scale) { #(int.to_float(scale), False) })333+ list.append(crisp, plain)334+}335+336+/// Build every arrangement at every upscale and take the best of them.337+///338+/// The cost is the size of the board in real pixels, which is the thing a339+/// player is actually given, and it is not the same as the size of the picture:340+/// a cell is capped at 40 units, so past a point more units stop turning into341+/// more board and the upscale is what has to grow instead.342+///343+/// Two riders on it. A layout unit under two CSS pixels sets a six-unit label344+/// at six pixels, so anything that can only be had at S=1 is a last resort345+/// rather than a candidate — it is what a keypad phone gets and nothing else.346+/// And where two candidates give the same board, the smaller upscale wins,347+/// because it is the one with more units left over for everything around it.348+///349+/// Crispness is worth about an eighth of the board's size and not more. It is350+/// not free: a whole mapping needs the layout unit to be a multiple of `density`351+/// device pixels, and which multiples exist depends on the display. At dpr 2.748352+/// the crisp scale is *larger* than the integer one and there is nothing to353+/// trade. At dpr 2 with density 3 the only crisp scales are 1.5 and 3, and 3354+/// leaves the column too narrow to hold, so crispness there would cost a quarter355+/// of the board — which is the wrong trade, and a resampled board at full size356+/// is what that display gets.357+pub fn choose(box: Box, dpr: Float, density: Int) -> Layout {358+ let candidates =359+ list.flat_map([Stack, Split], fn(mode) {360+ list.filter_map(upscales(dpr, density), fn(candidate) {361+ let #(scale, crisp) = candidate362+ let m = metrics_for(mode, scale, box)363+ case m.vw < 150 || m.vw > 640 || m.vh < 120 {364+ True -> Error(Nil)365+ False ->366+ case fit_cell(mode, m.room_w, m.room_h, ref_w, ref_h) {367+ Error(_) -> Error(Nil)368+ Ok(cell) -> {369+ let step = case scale >. 1.0 {370+ True -> 1_000_000.0371+ False -> 0.0372+ }373+ let sharpness = case crisp {374+ True -> 1.125375+ False -> 1.0376+ }377+ let cost =378+ step379+ +. int.to_float(cell)380+ *. scale381+ *. 100.0382+ *. sharpness383+ -. scale384+ Ok(#(cost, m))385+ }386+ }387+ }388+ })389+ })390+391+ // Nothing fit at all: a screen this small has no good answer, only an honest392+ // one.393+ let best =394+ list.fold(candidates, #(-1.0, metrics_for(Stack, 1.0, box)), fn(best, it) {395+ case it.0 >. best.0 {396+ True -> it397+ False -> best398+ }399+ })400+ finish(best.1, box)401+}402+403+fn finish(m: Layout, box: Box) -> Layout {404+ Layout(405+ ..m,406+ off_x: box.x407+ + round({ int.to_float(box.w) -. int.to_float(m.vw) *. m.scale } /. 2.0),408+ off_y: box.y409+ + round({ int.to_float(box.h) -. int.to_float(m.vh) *. m.scale } /. 2.0),410+ boot_win: Rect(411+ x: m.col.x + 12,412+ y: round(int.to_float(m.vh) *. 0.15),413+ w: m.col.w - 24,414+ h: round(int.to_float(m.vh) *. 0.66),415+ ),416+ )417+}418+419+/// Fit a family to the picture that was already chosen.420+///421+/// The room was worked out when the picture was chosen; only the board it has422+/// to hold changes here. A family that will not fit at the smallest cell gets423+/// the smallest cell anyway — there is nothing else to give it.424+pub fn board_layout(layout: Layout, family: Family) -> BoardLayout {425+ let bw = family.width426+ let bh = family.height427+ let cell =428+ fit_cell(layout.mode, layout.room_w, layout.room_h, bw, bh)429+ |> unwrap_or(16)430+431+ let cellf = int.to_float(cell)432+ let tray_pad = plate_pad(cell)433+ let pad_x = round(cellf *. 0.5)434+ let pad_top = round(cellf *. 0.5)435+ // The caption band.436+ let pad_bottom = round(cellf *. 1.05)437+ // Was a drawn plinth under the plate, a leftover from an earlier direction. A438+ // dithered slab under a window is not something QuickDraw would have done and439+ // it read as a stray textured bar; the space it held is kept as plain440+ // breathing room.441+ let below_plate = round(cellf *. 0.3)442+ let pw = plate_w(cell, bw)443+ let ph = plate_h(cell, bh)444+445+ let split = layout.mode == Split446+ // The panel takes what the plate leaves, between the narrowest that holds447+ // three readouts abreast and the widest that is not just empty panel. The448+ // board has first call because it is the thing being played; a five-wide set449+ // on a square screen is the case where there is nothing spare, and there the450+ // panel goes to its floor rather than the board going to its.451+ let panel_w = case split {452+ True ->453+ int.min(panel_max, int.max(panel_min, layout.vw - 24 - pair_gap - pw))454+ False -> 0455+ }456+ // Split centres the pair, so the desktop shows either side of both windows457+ // rather than all of it landing on one edge.458+ let comp_w = case split {459+ True -> pw + pair_gap + panel_w460+ False -> pw461+ }462+ let plate_x = round({ int.to_float(layout.vw) -. int.to_float(comp_w) } /. 2.0)463+464+ // Stacked, the plate takes the head it is owed and then centres itself in465+ // whatever is left. On a tall phone that is the same 14% of the height it has466+ // always been and the plate lands exactly where it did; on a short one the467+ // head shrinks to the bar plus clearance instead of pushing the foot off the468+ // bottom of the screen.469+ let head_room = layout.vh - layout.foot_h - ph - below_plate470+ let head = int.max(layout.bar_h + 10, int.min(layout.head_h, head_room))471+ let plate_y = case split {472+ True ->473+ int.max(474+ layout.bar_h + 6,475+ round(476+ int.to_float(layout.bar_h)477+ +. { int.to_float(layout.vh - layout.bar_h - ph) } /. 2.0,478+ ),479+ )480+ False ->481+ round(482+ int.to_float(head)483+ +. int.to_float(int.max(0, head_room - head)) /. 2.0,484+ )485+ }486+487+ let foot = plate_y + ph + below_plate488+489+ // Split, the panel is as tall as the plate and its contents are worth less490+ // than that, so they are centred in it as one group rather than pinned to its491+ // two ends — pinned, the gauge and the buttons drift apart until the panel492+ // reads as two things with a hole between them.493+ let inner = case split {494+ True -> int.min(26, int.max(8, round(int.to_float(ph) *. 0.12)))495+ False -> 0496+ }497+ let group_h = case split {498+ True -> 14 + inner + 30 + inner + 34499+ False -> 0500+ }501+ let group_y = case split {502+ True ->503+ plate_y504+ + int.max(4, round(int.to_float(ph - group_h) /. 2.0))505+ False -> 0506+ }507+508+ let actions_y = case split {509+ True -> group_y + 14 + inner + 30 + inner510+ False -> foot + layout.hud_gap + 42511+ }512+ let panel = case split {513+ True ->514+ Panel(x: plate_x + pw + pair_gap, y: plate_y, w: panel_w, h: ph, actions_y:)515+ False -> {516+ let y = foot + round(int.to_float(layout.vh) *. 0.02)517+ Panel(518+ x: layout.col.x + 7,519+ y:,520+ w: layout.col.w - 14,521+ h: int.min(layout.vh - 6, actions_y + 34 + 8) - y,522+ actions_y:,523+ )524+ }525+ }526+527+ // The gauge is measured off whatever is holding it, so it spans the column528+ // when it is under the board and the panel when it is beside it.529+ let gauge_w = case split {530+ True -> panel.w - 24531+ False -> round(int.to_float(layout.col.w) *. 0.6)532+ }533+ let gauge_y = case split {534+ True -> group_y535+ False ->536+ foot537+ + int.min(22, int.max(12, round(int.to_float(layout.vh) *. 0.045)))538+ }539+540+ BoardLayout(541+ cell:,542+ pad_x:,543+ pad_top:,544+ pad_bottom:,545+ below_plate:,546+ tray_pad:,547+ plate: Rect(x: plate_x, y: plate_y, w: pw, h: ph),548+ board: Rect(549+ x: plate_x + pad_x + tray_pad,550+ y: plate_y + pad_top + tray_pad,551+ w: cell * bw,552+ h: cell * bh,553+ ),554+ panel:,555+ gauge: Gauge(556+ x: case split {557+ True -> panel.x + 12558+ False -> round(layout.col.cx -. int.to_float(gauge_w) /. 2.0)559+ },560+ y: gauge_y,561+ w: gauge_w,562+ ),563+ hud_y: case split {564+ True -> group_y + 14 + inner565+ False -> foot + layout.hud_gap566+ },567+ // The record stands where the readout does — it is the same row of numbers,568+ // counted up rather than counted down.569+ record_y: case split {570+ True -> group_y + 12 + inner571+ False -> foot + round(int.to_float(layout.vh) *. 0.04)572+ },573+ )574+}575+576+fn round(value: Float) -> Int {577+ float.round(value)578+}579+580+fn unwrap_or(result: Result(a, b), fallback: a) -> a {581+ case result {582+ Ok(value) -> value583+ Error(_) -> fallback584+ }585+}
A src/klotski/paint.gleam +1545 -0

Not shown: this page already shows 6000 lines of diff.

D src/klotski/persistence.gleam +0 -349

Not shown: this page already shows 6000 lines of diff.

A src/klotski/prefs.gleam +26 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/prng.gleam +126 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/prng_ffi.mjs +34 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/progress.gleam +130 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/render.gleam +76 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/render_ffi.mjs +808 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/scene.gleam +287 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/shading.gleam +386 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/shading_ffi.mjs +7 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/store.gleam +568 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/theme.gleam +403 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/typeface.gleam +64 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/typeface_ffi.mjs +24 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski/walk.gleam +174 -0

Not shown: this page already shows 6000 lines of diff.

A src/klotski_ffi.mjs +7 -0

Not shown: this page already shows 6000 lines of diff.

M test/klotski/domain_test.gleam +54 -24

Not shown: this page already shows 6000 lines of diff.

A test/klotski/layout_test.gleam +119 -0

Not shown: this page already shows 6000 lines of diff.

D test/klotski/persistence_test.gleam +0 -20

Not shown: this page already shows 6000 lines of diff.

A test/klotski/prng_test.gleam +29 -0

Not shown: this page already shows 6000 lines of diff.

A test/klotski/progress_test.gleam +98 -0

Not shown: this page already shows 6000 lines of diff.

A test/klotski/shading_test.gleam +153 -0

Not shown: this page already shows 6000 lines of diff.

A test/klotski/store_test.gleam +59 -0

Not shown: this page already shows 6000 lines of diff.

A test/klotski/theme_test.gleam +147 -0

Not shown: this page already shows 6000 lines of diff.

A test/klotski/walk_test.gleam +75 -0

Not shown: this page already shows 6000 lines of diff.