An installable, offline PWA with generated boards. No build step and no
dependencies: plain ES modules served statically, a canvas composed in
greyscale and shaded to two colours, and type on a second canvas at full
device resolution so it never meets the threshold.
src/theme.js every colour in the app, and the four pools a board
draws its look from
src/shading.js Atkinson diffusion plus seven threshold screens, at
five rulings, with coverage true by construction
src/ground.js the backdrop stencils, painted after the threshold
src/survey.js breadth-first sweep of a family's state space
src/progress.js the rung-to-depth ladder and board selection
The tree was not under version control before now, so this records the
current state as one commit rather than a reconstructed history.- commit
- 846d74086e74399ff970a2254a72e71cc0d27200
- author
- Dami <dami.etoile@gmail.com>
| A | .gitignore | 6 | + |
| A | README.md | 408 | ++++++++++ |
| A | dev-logo-seed.svg | 2 | + |
| A | mockup/ | 93 | ++ |
| A | mockup/ | 93 | ++ |
| A | mockup/ | bin | |
| A | mockup/ | bin | |
| A | mockup/ | bin | |
| A | mockup/ | bin | |
| A | mockup/ | bin | |
| A | mockup/ | bin | |
| A | mockup/ | 129 | +++ |
| A | mockup/ | 19 | + |
| A | mockup/ | 112 | +++ |
| A | mockup/ | 110 | +++ |
| A | mockup/ | 547 | +++++++++++++ |
| A | mockup/ | 140 | +++ |
| A | mockup/ | 138 | +++ |
| A | mockup/ | 63 | ++ |
| A | mockup/ | 144 | ++++ |
| A | mockup/ | 1231 | ++++++++++++++++++++++++++++++ |
| A | mockup/ | 380 | +++++++++ |
| A | mockup/ | 82 | ++ |
| A | mockup/ | 40 | + |
| A | mockup/ | 167 | ++++ |
| A | mockup/ | 97 | ++ |
| A | mockup/ | 294 | +++++++ |
| A | mockup/ | 151 | ++++ |
| A | mockup/ | 87 | ++ |
| A | mockup/ | 197 | +++++ |
| A | mockup/ | 155 | ++++ |
| A | mockup/ | 196 | +++++ |
@@ -0,0 +1,408 @@
1+# Klotski2+3+An installable, offline sliding-block puzzle game with generated boards, rendered in one bit.4+5+`mockup/` is a **working visual and UX mockup**: a real, playable, installable PWA used to settle6+the look and the feel before the production app is built. It is not scaffolding to be extended —7+it is a thing to be judged, argued with, and then rebuilt properly (see *Next* below).8+9+---10+11+## The direction12+13+**Atkinson dithering.** Bill Atkinson wrote the dithering in QuickDraw for the Lisa and the14+Macintosh, and it is the reason images from 1984-88 look the way they do. It is error diffusion:15+each pixel is forced to black or white and the difference is pushed onto pixels not yet drawn.16+What makes it *Atkinson* rather than Floyd-Steinberg is that it pays out only six eighths of the17+error and discards the rest. Throwing error away is, strictly, a bug; it is also the entire18+flavour, because highlights run clean to white and shadows to solid black instead of being19+speckled, so all the texture lives in the midtones and the picture reads with far more snap. Crisp20+rather than grainy.21+22+Two consequences shape the whole renderer. Block tones are set to QuickDraw's stock greys — an23+eighth, a quarter, a half, three quarters — because Atkinson resolves those to clean even textures24+and arbitrary values between them to streaks. And because diffusion depends on the entire image,25+shifting anything by a pixel re-flows the pattern downstream of it, so the one thing that moves is26+dithered once into a sprite and carried while the rest of the scene is composed without it.27+28+**Shading, of which dithering is one kind.** This module was called `dither.js` and was the poorer29+for it: dithering is one answer to "how do you print a grey with one ink", and naming the file30+after it quietly ruled out the others. Hatching is not a dither. A comic's two-plate overprint is31+not a dither. Both are shading, and so is Atkinson.32+33+Everything that is not Atkinson is a *screen*: a small table of thresholds, tiled across the34+picture, saying in what order pixels give way to ink. That one idea covers far more than it looks35+like it should, because the difference between a dither, a halftone, a hatch and a flat fill is36+nothing but which numbers are in the table and how they are arranged — dispersed scatters the ink,37+clustered grows it into round blobs, a table varying along one axis turns tone into the width of a38+band, and a single threshold makes tone stop existing so only the reprinted colour survives. Eight39+shapes: Atkinson, dispersed, Ben-Day, ruled, diagonal, crosshatch, comic, solid.40+41+A screen has a second property independent of its shape: its *ruling*, how many pixels one cell42+covers. The same table read at one pixel a cell or at six resolves identical tone and changes only43+the grain, which is most of the difference between newsprint and a magazine. It is **not a44+setting** — it is the press the board happened to be printed on, chosen with the shape it belongs45+to. Five steps, and it can be that many precisely because nobody has to read the list.46+47+That subsumed a family. CHECKER used to be its own style and was a dispersed screen at a coarser48+ruling all along — not a different shape, a different frequency, and having it as a family meant49+two names for one picture. A dispersed screen resolves to a chequer at a half tone by50+construction, so every chequer is still reachable; it is a coordinate now rather than a species,51+and the same move buys coarse clustered, coarse ruled and coarse hatched screens that did not52+exist before.53+54+COMIC is the one that is not a threshold at all but a second pass. A colour comic was never one55+ink: each was screened separately, printed at its own angle, and very slightly out of register.56+A tint may now carry a second plate, so the block's ink is split between the piece colour and the57+same hue at full strength on a rotated screen. Not a true CMYK separation — that needs the58+pipeline to stop being one bit per region — but the same mechanism and the same look.59+60+Diffusion and the flat threshold take no ruling: error diffusion's grain *is* the pixel grid, and61+a flat threshold has no grain. They report one look each rather than five identical ones.62+63+**Black on white, with a menu bar.** The Macintosh was ink on paper, so this is too. Text lives64+inside a window, never on the desktop, which is how a patterned ground and legible labels stop65+being a trade. Blocks are flat with a hard offset drop shadow; an extruded 2.5D side was tried and66+merged into one muddy band down the right of the board.67+68+The desktop is not dithered either — it is not drawn at all. It used to be, as a lattice of single69+dots composed into the scene before the threshold, and that forced it to both extremes: a flat mid70+grey run through error diffusion across a whole screen is restless and faintly directional, fine71+on a small object and exhausting as a background, while 0 and 255 give diffusion no error to72+spread and so pass through untouched. That worked, and it allowed exactly one backdrop in exactly73+two colours.74+75+The scene is now cleared to transparent instead. The screens paint their opaque windows onto it,76+and every pixel still transparent when the frame is thresholded is ground. That is pixel-exact77+occlusion for the cost of one alpha test, and it moves the backdrop to *after* the threshold,78+where it is free to be any pattern in any two colours.79+80+**Colour, one per piece size.** The dither is untouched — a block is still a threshold between its81+own value and the paper, keeping exactly the texture it had; only the ink it is printed in82+changes, which is what colour QuickDraw did. The big block is red because it always has been (Cao83+Cao in Huarong Dao, the red donkey in L'Âne Rouge); the rest take the Apple rainbow's blue, green84+and orange. In the sets list this does the job the piece-count captions used to: you can see at a85+glance that *wide* is green bars and *mixed* is all three.86+87+Coverage is restricted to three quarters, a half and a quarter. Atkinson resolves those to even88+textures, while the eighths between them come out visibly streaked, and an eighth is too sparse89+for a hue to register at all — so the levels are not free to be nudged by eye.90+91+**Every block is dithered alone.** Diffusion is global by nature: error runs off one shape into92+whatever is drawn to its right and below, so a block's texture depended on its neighbours and a93+50% bar could develop a streaked patch where the error happened to pile up. Each block is now94+dithered on its own against bare paper and cached, making its texture a pure function of its size,95+tone and colour — identical in every position, and free after the first one. Sprites are alpha-cut96+to the block and its shadow so neighbours compose without one sprite's paper margin erasing the97+next one's shadow. It also means nothing boils while a block slides, which is what the mechanism98+was originally built for.99+100+Nothing small is ever set on the desktop pattern — including the title screen, which is a single101+centred window holding the title, the specimen board and the prompt. An About box, essentially.102+103+**The dev-logo** sits at the far left of the menu bar on the puzzle list, which is where a104+Macintosh put the Apple and the reason that position reads as a signature rather than as an icon105+someone left lying there. It is constant in shape and position; the one thing that varies is which106+of the theme's four piece colours fills it, drawn from the day of the year so it holds for a day107+and moves on. Like the trophy it is painted after the threshold and is never shaded — two flat108+colours, a ring in ink and a triangle in the day's colour. At fourteen pixels a screen of any109+ruling turns a ring into four dots, and a mark whose whole job is to be constant should not be put110+through a pattern that is not.111+112+It costs the layout nothing. The title shifts right by about a sixth of the bar's width and still113+clears the buttons on the right; the list below is measured from the bar's height, which has not114+moved.115+116+**A gold trophy** marks a solved board. It is queued like type and painted after the threshold, so117+it sits on top of the dither. The inked seal it replaced was ink on ink and sank into the board.118+119+**Hints are a dial, and it only subtracts.** The board can tell you exactly how far from solved120+you are, which is a strong hint and not everyone's game.121+122+| | steps | to goal | optimal | gauge |123+|---|---|---|---|---|124+| FULL | ✓ | ✓ | ✓ | scaled to where you started |125+| BEST | ✓ | — | ✓ | scaled to the furthest out you have been |126+| OFF | ✓ | — | ✓ | — |127+128+BEST is the interesting one. Dropping the number and keeping the gauge would still leak the answer129+if the gauge were scaled to the start, so it is anchored to your own deepest point instead: you130+cannot work backwards from it to the solver's count, and what it reads is how far you have come131+from your worst rather than how close you are to the end. It is the same choice the record132+screen's chart already makes, and for the same reason — going backwards is the interesting part,133+and a scale that cannot show it hides it. The gauge's left label changes from START to DEEPEST to134+match, because a scale labelled with the wrong end is worse than no label.135+136+Steps and optimal survive every setting: one is a record of what you did, the other is printed on137+the card before you start, so neither is a hint.138+139+**Type is not art and is not dithered.** It is drawn on a second canvas at full device resolution140+in flat ink. Thresholding a typeface turns it to gravel, which is survivable for a title and141+unreadable for anything else. Silkscreen for display, Space Mono for labels.142+143+**One theme object, and four things a board rolls for.** Every colour in the app is written down144+in `src/theme.js` and nowhere else. A theme owns its ink and paper, its four piece colours, its145+gold — and four *pools*: which backdrop stencils belong to it, which colour pairs those are146+printed in, which screen shapes the blocks may take, and how coarse those screens may be. A board147+draws one of each, seeded on its family and rung by the same pair that decides which board it is,148+so a rung always looks the way it looked and a ladder walks through combinations nobody authored.149+Seven backdrops, six grounds and thirty-two block looks (six shapes at five rulings, plus the two150+that take none) is **1344 per theme** out of about twenty written-down numbers, which is the whole151+reason for doing it this way rather than drawing 1344 backdrops.152+153+The rolling is confined to the board — the card, the puzzle and the record. The atlas, the title154+and the record-of-everything are chrome, and chrome that shifts under you every time you open it is155+not chrome.156+157+The one exception is the board *inside* an atlas card, which is drawn in the screen that rung will158+actually be played in. The card already showed the exact position waiting behind it; showing the159+look as well means the list reads as eight different places rather than eight different sizes of160+the same one.161+162+**Coverage is true by construction**, and it took a bug to find out why that matters. A block is163+shaded alone in its own sprite, so its fill samples whatever window of the tiled screen happens to164+lie under it — fine when the window is many periods across, and quietly wrong when it is not. At165+the extreme a 1x1 piece under a coarse screen came out entirely bare paper and vanished off the166+board. Measured across every block size and screen, four of the five failures were the ruled167+screen, which is what you would predict: it varies along one axis, so a block's height alone168+decides how much of the screen it ever sees.169+170+The fix is not to scale the screen to the block. A press does not print the small object at a171+finer ruling than the big one, and doing so made two pieces of one board look like two different172+printing jobs. Instead, pixels of a given tone are grouped and the group is cut at whichever173+threshold lands closest to the intended proportion of ink, with the cut always falling *between*174+levels rather than through one — so a band stays a band, a dot stays round, and a tone that asked175+for ink always gets some. The screen's shape is untouched; only how much of it is inked is decided176+by proportion rather than by accident.177+178+One floor survives that: a block landing inside a *single* cell has one threshold and therefore179+two possible outcomes, neither of which is a quarter. So a board steps its ruling down until its180+smallest piece spans at least two cells — one ruling for the whole board, taken from its cell, so181+every piece is still printed on the same press.182+183+Piece colours are deliberately *not* pooled. The 2x2 is Cao Cao; a goal block that changes colour184+between rungs stops being a thing you recognise and starts being a thing you have to re-read.185+Holding them still is also what keeps the ground pools designable — each ground has to be checked186+against one known set of four accents plus ink, rather than against every set.187+188+A "night" palette is only the two values swapped; nothing in the renderer needs to know which way189+round they are.190+191+| | ink | paper | |192+|---|---|---|---|193+| Macintosh (default) | `#2B2B22` | `#E8F4F2` | tinted papers, ink pulled toward each |194+| Paper | `#2E3037` | `#EBE5CE` | creams and linens |195+| Night | `#DCE4E2` | `#1C2028` | grounds sit *below* the paper, so panels are the lit thing |196+| AMOLED | `#DCF2FF` | `#000000` | pure black grounds, neon stencil over them |197+198+AMOLED is the one theme that breaks the rule above — that the paper is always tinted and the ink199+always has a cast, which is most of why a 1-bit image reads as a printed thing rather than a200+screenshot. It breaks it on purpose: on an OLED panel a black pixel is a pixel that is off, and201+`#1C2028` is not off. What replaces the tint is the ground, which is where its colour lives —202+pure black with a neon stencil at one pixel in sixteen or fewer, so the colour arrives as a field203+of light on true black rather than as a wash across it. Its piece colours are its own for the same204+reason the other three themes share theirs: the Apple rainbow was chosen against paper and goes205+muddy against black.206+207+---208+209+## What is actually real210+211+Three things are not faked, because the feel depends on them.212+213+**The solver.** A full breadth-first sweep of each piece set's configuration graph. Configurations214+are stored as the grid of *shape classes*, not piece identities, since same-shaped pieces are215+interchangeable; twenty cells of five classes packs to 5²⁰, under 2⁵³, so a plain number is both an216+exact key and its own hash. The classic board comes out at **25,955 reachable configurations and217+116 unit steps** — the published figures.218+219+**The generation.** Sliding-block moves are reversible, so the graph is undirected, so a BFS220+*outward from the goal* labels every configuration with its exact distance to a solved board — and221+every state at depth *k* is a puzzle whose optimal solution is exactly *k* steps. One sweep per222+piece set yields that set's entire difficulty-stratified catalogue. Every board in the game is223+drawn from that field. Nothing is hand-placed.224+225+A practical note that cost some time: a hand-written packing usually is *not* solvable. Most dense226+arrangements strand the big block in a component containing no solved state at all. Sets whose227+reference packing is already solved are swept outward from it, which makes reachability free.228+229+**The difficulty.** Not solution length, which correlates only weakly with how long a person230+actually takes, and emphatically not solver effort, which is worse. The load-bearing term is an231+ε-greedy **hitting time**: a walker that mostly steps downhill but strays, whose expected step232+count absorbs branching factor, corridor width and the quality of the local gradient at once. It233+is combined with the count of *counter-intuitive* steps — moves on the optimal path that carry the234+big block away from its exit. Depth picks the tier; complexity picks which board of that depth you235+actually get.236+237+The state space is surfaced rather than hidden, since that is half the appeal of the puzzle: a238+live distance-to-goal gauge, and a post-solve chart of distance against steps, against the239+straight line a perfect solver draws.240+241+A per-move assist was tried and cut. It marked each legal move by whether it closed the distance242+to the goal, which sounds useful and is not: a Klotski move is one block in one direction, so the243+marks answered a question nobody was asking while saying nothing about the sequence that actually244+matters.245+246+### The sets247+248+A family is a board, a piece multiset and a goal — the unit of generation. Every packing shipped is249+a *solved* one, because sweeping outward from a solved state makes reachability free, and most250+hand-written packings are not puzzles at all: they strand the big block in a component containing no251+solved state. `tools/find-families.mjs` sweeps each candidate and reports what its state space252+actually looks like; four of the thirteen tried were rejected outright.253+254+| Set | Board | Pieces | States | Ceiling |255+|---|---|---|---|---|256+| Classic | 4×5 | the traditional set | 25,955 | 126 |257+| Narrow | 3×5 | 1 wide, 7 small | 864 | 24 |258+| Compact | 4×4 | 2 wide, 4 small | 12,390 | 27 |259+| Span | 5×4 | 4 tall, 4 small, four cells of air | 92,400 | 33 |260+| Wide | 4×5 | 4 wide, 6 small | 23,704 | 80 |261+| Yard | 5×4 | 1 tall, 4 wide, 4 small | 25,955 | 101 |262+| Corner | 4×5 | as Mixed, leaving by the corner | 81,462 | 129 |263+| Mixed | 4×5 | 2 tall, 3 wide, 4 small | 81,462 | 178 |264+265+Classic leads the list because it is what the word on the title screen promises; the rest are266+ordered by ceiling, so the list past it is a ramp. Classic's own packing is the landmark: 116 unit267+steps from home. Corner is worth noting as the268+cheapest new family there is — the same pieces as Mixed with one goal cell moved, which is a269+different distance field and so a different game.270+271+Board dimensions are per-family, bounded by the key: cells pack in base five and a key must stay272+under 2⁵³, so **22 cells is the ceiling**. 4×6 and 5×5 would need a wider key and are the next axis273+if the roster ever wants them.274+275+### The ladder276+277+Called a **stage** in the interface, not a rung — the word in the code is the mechanism, the278+word on screen should be the one a player already owns.279+280+Each family is an open ladder rather than a fixed set of tiers. A rung is just "a board at depth281+D", and the schedule turns a rung number into that depth:282+283+```284+base = clamp(round(ecc × 0.22), 5, 14)285+step = max(2, round((ecc − base) / 18))286+D(r) = min(ecc, base + step × (r − 1))287+```288+289+Linear, and deliberately so. An exponential ramp read fine on paper and was wrong in the hand: on290+the classic board it served 13 steps and then 33, so the second puzzle you ever saw was two and a291+half times the first. A fixed step gives every family the same shape of arc — an opening board of292+five to fourteen steps, about eighteen rungs of growth, then the ceiling — whether that ceiling is293+24 or 178.294+295+On what "endless" honestly means: the **ladder** has no end, but depth does. Nothing can sit further296+from a solved board than the family's eccentricity. So once the schedule arrives there it keeps297+drawing fresh boards from the deep band, ranked by complexity rather than by length. Rung 400 is not298+deeper than rung 40; it is a different board that is just as deep, and there are thousands of those.299+Below the ceiling a rung's depth is exact; at it, a few steps of slack buys real variety, because300+states at the exact maximum distance are rare enough that insisting on them would serve the same two301+boards forever.302+303+Sweeps happen when a family is first opened, not at boot — eight families is over a second of304+breadth-first search, and making someone wait through all of it to play one board is the wrong305+trade when they may only ever open two. Boards are generated lazily for the same reason: weighing a306+candidate costs forty walker rollouts.307+308+### The record309+310+Every solve is worth XP for the shape of the board and how cleanly it was taken — depth and311+complexity set the base, efficiency scales it, and replaying a cleared rung is worth a quarter.312+Level is that total on a gentle power curve (`45 × (n−1)^1.7`), so later levels cost more and none313+of them wall off. It is a single global number; per-family mastery is the rung, which is the thing314+that actually decides what you are served next. The progression is a loop, not a readout.315+316+Solve time is recorded and never shown while playing. Lifetime totals are counted as they happen317+rather than derived, because the history log is a capped ring buffer — it is enough to draw a chart,318+not enough to answer "how many have I ever solved".319+320+The perfect-solver reference on the descent chart is gold and painted after the threshold, like the321+trophy: drawn into the scene it was ink on ink, and the bars of your own run covered the very line322+they are meant to be read against.323+324+The record screen shows level and XP, lifetime solved / efficiency / time / day streak, a per-family325+table of rung, solves and deepest board, a chart of recent depths, and the total number of326+configurations mapped this session — which uses `count`, `ecc` and `widths` off the sweep, and is327+the one number that says why this puzzle is interesting in the first place.328+329+---330+331+## Running it332+333+```sh334+cd mockup && python3 -m http.server 8731 # ES modules and the service worker need a server335+```336+337+Then open `http://localhost:8731/`. It installs, and plays with the radio off.338+339+Regenerate the launcher icons:340+341+```sh342+python3 mockup/tools/gen_icons.py343+```344+345+The icon answers a specific brief: what would Apple in 2026 ship if the art direction came from346+Apple in 1986? The discipline is modern — one subject, generous margins, a composition that347+survives being masked into a circle and read at 48 pixels — and the materials are period: a 64×64348+bitmap grid scaled by whole pixels, Atkinson-dithered fills in the game's own piece colours, hard349+black rules, and the six-stripe rainbow as a maker's mark rather than decoration. The board shown350+is a complete 3×3 packing, so there is a subject and no leftover holes to read as clutter, and351+every piece size — and so every colour — appears exactly once.352+353+`window.klotski` is exposed for inspection over `chrome://inspect`; `window.klotski.walkOptimal()`354+walks a board down the distance field to the goal.355+356+The service worker is cache-first, so a stale build will be served to anything that has visited357+before — including automated screenshots, which is worth an hour of confusion exactly once. Bump358+`VERSION` in `sw.js` when shipping, and open test browser contexts with `serviceWorkers: 'block'`.359+360+### On a phone361+362+A service worker needs a secure context, and your machine's LAN address is not `localhost` from363+the phone's point of view, so either forward the port over USB —364+365+```sh366+adb reverse tcp:8731 tcp:8731 # then open http://localhost:8731 on the device367+```368+369+— or use the deployed build. Chrome's install criteria are the gate to check against; Lighthouse's370+PWA score was removed and no longer means anything.371+372+---373+374+## Deliberate limits375+376+The sweep runs on the main thread in per-frame slices (about a second across all three sets)377+rather than in a worker. That is a mockup decision: the loading screen is *designed* as a beat you378+watch, and it kept the transfer of a large distance map out of scope.379+380+Progression is `localStorage` under a versioned envelope with a monotonic counter — the shape a381+later sync wants (last-write-wins, union of solved boards, minimum of best steps) without any of382+the machinery. Treat client storage as a cache, not a record: iOS evicts it.383+384+Nothing here is built or bundled. There is no build step at all, on purpose.385+386+---387+388+## Next389+390+The production app, informed by the same research: Vite and TypeScript; the solver moved into a391+worker with packed bitboards and a typed-array visited set; per-set manifests with per-tier392+reservoir sampling so the sweep does not have to run at boot; difficulty tiers recalibrated against393+real solve times, which is what took comparable models from ρ≈0.5 to ρ≈0.95 in the literature;394+`vite-plugin-pwa` for the shell; `idb-keyval` for the record; and the state-space map, drawn as395+concentric rings by distance, which is O(V) and legible where a force-directed layout is neither.396+397+---398+399+## Credits400+401+Silkscreen (Jason Kottke) and Space Mono, both under the SIL Open Font License — see402+`mockup/fonts/`. Subset to Latin-1 and served locally; nothing is fetched at runtime.403+404+An earlier pass used IM Fell DW Pica, the face *Return of the Obra Dinn* sets its text in, with a405+screen-space noise background. Both were abandoned: on a phone the thresholded serif was gravel at406+anything below a title, and interleaved-gradient noise — a 3D-renderer technique — laid visible407+diagonal banding across flat tone that read as a projected dot pattern rather than a dithered408+one.@@ -0,0 +1,2 @@
1+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->2+<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" id="delta-circle" data-name="Line Color" xmlns="http://www.w3.org/2000/svg" class="icon line-color"><path id="secondary" d="M16,15,12,7,8,15Zm-1,0L11.5,8" style="fill: none; stroke: rgb(44, 169, 188); stroke-linecap: round; stroke-linejoin: round; stroke-width: 2;"></path><circle id="primary" cx="12" cy="12" r="9" style="fill: none; stroke: rgb(0, 0, 0); stroke-linecap: round; stroke-linejoin: round; stroke-width: 2;"></circle></svg>\ No newline at end of file@@ -0,0 +1,93 @@
1+Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen)2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+https://scripts.sil.org/OFL6+7+8+-----------------------------------------------------------9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200710+-----------------------------------------------------------11+12+PREAMBLE13+The goals of the Open Font License (OFL) are to stimulate worldwide14+development of collaborative font projects, to support the font creation15+efforts of academic and linguistic communities, and to provide a free and16+open framework in which fonts may be shared and improved in partnership17+with others.18+19+The OFL allows the licensed fonts to be used, studied, modified and20+redistributed freely as long as they are not sold by themselves. The21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved23+names are not used by derivative works. The fonts and derivatives,24+however, cannot be released under any other type of license. The25+requirement for fonts to remain under this license does not apply26+to any document created using the fonts or their derivatives.27+28+DEFINITIONS29+"Font Software" refers to the set of files released by the Copyright30+Holder(s) under this license and clearly marked as such. This may31+include source files, build scripts and documentation.32+33+"Reserved Font Name" refers to any names specified as such after the34+copyright statement(s).35+36+"Original Version" refers to the collection of Font Software components as37+distributed by the Copyright Holder(s).38+39+"Modified Version" refers to any derivative made by adding to, deleting,40+or substituting -- in part or in whole -- any of the components of the41+Original Version, by changing formats or by porting the Font Software to a42+new environment.43+44+"Author" refers to any designer, engineer, programmer, technical45+writer or other person who contributed to the Font Software.46+47+PERMISSION & CONDITIONS48+Permission is hereby granted, free of charge, to any person obtaining49+a copy of the Font Software, to use, study, copy, merge, embed, modify,50+redistribute, and sell modified and unmodified copies of the Font51+Software, subject to the following conditions:52+53+1) Neither the Font Software nor any of its individual components,54+in Original or Modified Versions, may be sold by itself.55+56+2) Original or Modified Versions of the Font Software may be bundled,57+redistributed and/or sold with any software, provided that each copy58+contains the above copyright notice and this license. These can be59+included either as stand-alone text files, human-readable headers or60+in the appropriate machine-readable metadata fields within text or61+binary files as long as those fields can be easily viewed by the user.62+63+3) No Modified Version of the Font Software may use the Reserved Font64+Name(s) unless explicit written permission is granted by the corresponding65+Copyright Holder. This restriction only applies to the primary font name as66+presented to the users.67+68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font69+Software shall not be used to promote, endorse or advertise any70+Modified Version, except to acknowledge the contribution(s) of the71+Copyright Holder(s) and the Author(s) or with their explicit written72+permission.73+74+5) The Font Software, modified or unmodified, in part or in whole,75+must be distributed entirely under this license, and must not be76+distributed under any other license. The requirement for fonts to77+remain under this license does not apply to any document created78+using the Font Software.79+80+TERMINATION81+This license becomes null and void if any of the above conditions are82+not met.83+84+DISCLAIMER85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM93+OTHER DEALINGS IN THE FONT SOFTWARE.@@ -0,0 +1,93 @@
1+Copyright 2016 The Space Mono Project Authors (https://github.com/googlefonts/spacemono)2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+http://scripts.sil.org/OFL6+7+8+-----------------------------------------------------------9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200710+-----------------------------------------------------------11+12+PREAMBLE13+The goals of the Open Font License (OFL) are to stimulate worldwide14+development of collaborative font projects, to support the font creation15+efforts of academic and linguistic communities, and to provide a free and16+open framework in which fonts may be shared and improved in partnership17+with others.18+19+The OFL allows the licensed fonts to be used, studied, modified and20+redistributed freely as long as they are not sold by themselves. The21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved23+names are not used by derivative works. The fonts and derivatives,24+however, cannot be released under any other type of license. The25+requirement for fonts to remain under this license does not apply26+to any document created using the fonts or their derivatives.27+28+DEFINITIONS29+"Font Software" refers to the set of files released by the Copyright30+Holder(s) under this license and clearly marked as such. This may31+include source files, build scripts and documentation.32+33+"Reserved Font Name" refers to any names specified as such after the34+copyright statement(s).35+36+"Original Version" refers to the collection of Font Software components as37+distributed by the Copyright Holder(s).38+39+"Modified Version" refers to any derivative made by adding to, deleting,40+or substituting -- in part or in whole -- any of the components of the41+Original Version, by changing formats or by porting the Font Software to a42+new environment.43+44+"Author" refers to any designer, engineer, programmer, technical45+writer or other person who contributed to the Font Software.46+47+PERMISSION & CONDITIONS48+Permission is hereby granted, free of charge, to any person obtaining49+a copy of the Font Software, to use, study, copy, merge, embed, modify,50+redistribute, and sell modified and unmodified copies of the Font51+Software, subject to the following conditions:52+53+1) Neither the Font Software nor any of its individual components,54+in Original or Modified Versions, may be sold by itself.55+56+2) Original or Modified Versions of the Font Software may be bundled,57+redistributed and/or sold with any software, provided that each copy58+contains the above copyright notice and this license. These can be59+included either as stand-alone text files, human-readable headers or60+in the appropriate machine-readable metadata fields within text or61+binary files as long as those fields can be easily viewed by the user.62+63+3) No Modified Version of the Font Software may use the Reserved Font64+Name(s) unless explicit written permission is granted by the corresponding65+Copyright Holder. This restriction only applies to the primary font name as66+presented to the users.67+68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font69+Software shall not be used to promote, endorse or advertise any70+Modified Version, except to acknowledge the contribution(s) of the71+Copyright Holder(s) and the Author(s) or with their explicit written72+permission.73+74+5) The Font Software, modified or unmodified, in part or in whole,75+must be distributed entirely under this license, and must not be76+distributed under any other license. The requirement for fonts to77+remain under this license does not apply to any document created78+using the Font Software.79+80+TERMINATION81+This license becomes null and void if any of the above conditions are82+not met.83+84+DISCLAIMER85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM93+OTHER DEALINGS IN THE FONT SOFTWARE.Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
@@ -0,0 +1,129 @@
1+2+<html lang="en">3+<head>4+<meta charset="utf-8">5+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">6+<title>Klotski</title>7+<meta name="description" content="An offline sliding-block puzzle game with generated boards, rendered in one bit.">8+9+<meta name="theme-color" content="#E8F4F2">10+<meta name="mobile-web-app-capable" content="yes">11+<meta name="apple-mobile-web-app-capable" content="yes">12+<meta name="apple-mobile-web-app-status-bar-style" content="default">13+<meta name="apple-mobile-web-app-title" content="Klotski">14+<link rel="manifest" href="./manifest.webmanifest">15+<link rel="icon" href="./icons/icon-192.png">16+<link rel="apple-touch-icon" href="./icons/icon-192.png">17+18+<link rel="preload" href="./fonts/silk-bold.woff2" as="font" type="font/woff2" crossorigin>19+<link rel="preload" href="./fonts/silk.woff2" as="font" type="font/woff2" crossorigin>20+<link rel="stylesheet" href="./styles.css">21+22+<!-- The stylesheet has to declare *some* default, and a module cannot run23+ before first paint, so a saved dark theme used to show a full frame of24+ white before setTheme reached it. Survivable on a tinted paper; on the25+ black theme it is a flashbulb, which is the one thing a black theme exists26+ to avoid. This is the smallest thing that can run early enough: the two27+ values per theme, and nothing else. Kept in step with theme.js by hand --28+ it is four pairs, and the alternative is blocking paint on a module. -->29+<script>30+(function () {31+ var INK = { macintosh: '#2B2B22', paper: '#2E3037', night: '#DCE4E2', amoled: '#DCF2FF' };32+ var PAPER = { macintosh: '#E8F4F2', paper: '#EBE5CE', night: '#1C2028', amoled: '#000000' };33+ try {34+ var raw = JSON.parse(localStorage.getItem('klotski.save'));35+ var id = raw && raw.payload && raw.payload.palette;36+ if (!PAPER[id]) return;37+ var s = document.documentElement.style;38+ s.setProperty('--ink', INK[id]);39+ s.setProperty('--paper', PAPER[id]);40+ s.setProperty('--ground', PAPER[id]);41+ document.querySelector('meta[name="theme-color"]').setAttribute('content', PAPER[id]);42+ } catch (e) { /* private mode, or nothing saved yet: the default stands */ }43+})();44+</script>45+</head>46+<body>47+48+<div id="stage">49+ <!-- The picture is dithered at low resolution; type rides above it at full50+ device resolution so it never meets the threshold. -->51+ <canvas id="board"></canvas>52+ <canvas id="type"></canvas>53+54+ <!-- Crisp mono chrome, laid over the dithered canvas. Every serif on55+ screen is drawn into the canvas instead, so it gets thresholded. -->56+ <div id="ui">57+ <div id="top" class="zone">58+ <button id="btnBack" class="ghost">BACK</button>59+ <span class="spacer"></span>60+ <button id="btnInstall">INSTALL</button>61+ <button id="btnStats" class="ghost">RECORD</button>62+ <button id="btnSet" class="ghost">SETTINGS</button>63+ </div>64+65+ <div id="gauge" class="zone">66+ <span class="lbl" id="gaugeFrom">START</span><span class="spacer"></span><span class="lbl">SOLVED</span>67+ </div>68+69+ <div id="hud" class="zone">70+ <div class="stat"><span class="val" id="hSteps">0</span><span class="lbl">STEPS</span></div>71+ <div class="stat" id="statDist"><span class="val" id="hDist">0</span><span class="lbl">TO GOAL</span></div>72+ <div class="stat"><span class="val" id="hOpt">0</span><span class="lbl">OPTIMAL</span></div>73+ </div>74+75+ <div id="record" class="zone">76+ <div class="stat"><span class="val" id="rSteps">0</span><span class="lbl">YOUR STEPS</span></div>77+ <div class="stat"><span class="val" id="rOpt">0</span><span class="lbl">OPTIMAL</span></div>78+ <div class="stat"><span class="val" id="rXp">+0</span><span class="lbl">EARNED</span></div>79+ </div>80+81+ <div id="acts" class="zone">82+ <button id="btnUndo">UNDO</button>83+ <button id="btnReset" class="ghost">RESET</button>84+ <button id="btnNext">NEXT</button>85+ <button id="btnAtlas" class="ghost">PUZZLES</button>86+ </div>87+88+ <div id="gate" class="zone"><span class="val beat">TAP TO BEGIN</span></div>89+90+ <div id="sheet">91+ <!-- Filled by game.js from THEMES, so adding a theme is a one-object92+ edit and never a markup one. -->93+ <div class="group">94+ <div class="grouplbl">THEME</div>95+ <div class="row" id="themeRow"></div>96+ </div>97+ <div class="group">98+ <div class="grouplbl">HINTS</div>99+ <div class="row" id="hintRow"></div>100+ </div>101+ <div class="group">102+ <div class="grouplbl">SOUND</div>103+ <div class="row">104+ <button data-toggle="sound">TONES</button>105+ <button data-toggle="haptics">HAPTICS</button>106+ </div>107+ </div>108+ <!-- A pull-down rather than a row of buttons: eight shapes plus AUTO109+ wrapped into three ragged lines and read as a wall. Built by game.js110+ out of real elements, not a <select> -- a native menu is the one111+ control the platform insists on drawing in its own house style, and112+ it would be the only rounded, shaded, system-blue thing on screen.113+ The ruling is not here at all; it is not a preference. -->114+ <div class="group">115+ <div class="grouplbl">BLOCKS</div>116+ <div class="menu" id="shadeMenu">117+ <button class="menutitle" id="shadeTitle" aria-haspopup="listbox" aria-expanded="false"></button>118+ <div class="menulist" id="shadeList" role="listbox"></div>119+ </div>120+ </div>121+ <div class="credit" id="credit"></div>122+ <button id="btnClose">CLOSE</button>123+ </div>124+ </div>125+</div>126+127+<script type="module" src="./src/main.js"></script>128+</body>129+</html>@@ -0,0 +1,19 @@
1+{2+ "name": "Klotski",3+ "short_name": "Klotski",4+ "description": "An offline sliding-block puzzle game with generated boards, rendered in one bit.",5+ "id": "/klotski",6+ "start_url": "./",7+ "scope": "./",8+ "display": "standalone",9+ "display_override": ["standalone", "fullscreen"],10+ "orientation": "portrait",11+ "background_color": "#E8F4F2",12+ "theme_color": "#E8F4F2",13+ "categories": ["games", "puzzle"],14+ "icons": [15+ { "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },16+ { "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },17+ { "src": "./icons/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }18+ ]19+}@@ -0,0 +1,112 @@
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 & Pelanek put it5+ * near rho = 0.47 on Sokoban), and solver effort -- nodes expanded -- is worse6+ * 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+20+import { neighbours, goalXY } from './klotski.js';21+import { mulberry } from './families.js';22+23+const ROLL_CAP = 2500;24+25+/** Expected steps for an eps-greedy walker to reach the goal. */26+export function hittingTime(b, state, dist, eps, rolls, rng) {27+ const nb = [];28+ let total = 0;29+ for (let r = 0; r < rolls; r++) {30+ let s = state, steps = 0;31+ while (dist.get(s) > 0 && steps < ROLL_CAP) {32+ neighbours(b, s, nb);33+ if (!nb.length) break;34+ let pick;35+ if (rng() < eps) {36+ pick = nb[(rng() * nb.length) | 0];37+ } else {38+ const d = dist.get(s);39+ let downhill = 0;40+ for (let i = 0; i < nb.length; i++) if (dist.get(nb[i]) < d) downhill++;41+ if (downhill) {42+ let j = (rng() * downhill) | 0;43+ for (let i = 0; i < nb.length; i++) {44+ if (dist.get(nb[i]) < d && j-- === 0) { pick = nb[i]; break; }45+ }46+ } else {47+ pick = nb[(rng() * nb.length) | 0];48+ }49+ }50+ s = pick; steps++;51+ }52+ total += steps;53+ }54+ return total / rolls;55+}56+57+/** One shortest path, walked greedily down the distance field. */58+export function optimalPath(b, state, dist) {59+ const nb = [], path = [state];60+ let s = state, guard = 0;61+ while (dist.get(s) > 0 && guard++ < 4000) {62+ neighbours(b, s, nb);63+ const d = dist.get(s);64+ let next = -1;65+ for (let i = 0; i < nb.length; i++) if (dist.get(nb[i]) === d - 1) { next = nb[i]; break; }66+ if (next === -1) break;67+ s = next; path.push(s);68+ }69+ return path;70+}71+72+export function analyse(b, state, sv, seed) {73+ const rng = mulberry(seed);74+ const dist = sv.dist;75+ const [gx, gy] = sv.fam.goal;76+77+ const L = dist.get(state);78+ const wander = hittingTime(b, state, dist, 0.15, 40, rng);79+80+ const path = optimalPath(b, state, dist);81+ const nb = [];82+ let retreats = 0, branch = 0;83+ for (let i = 0; i < path.length; i++) {84+ neighbours(b, path[i], nb);85+ branch += nb.length;86+ if (i) {87+ const was = goalXY(b, path[i - 1]), now = goalXY(b, path[i]);88+ if (was && now) {89+ const da = Math.abs(was.x - gx) + Math.abs(was.y - gy);90+ const db = Math.abs(now.x - gx) + Math.abs(now.y - gy);91+ if (db > da) retreats++;92+ }93+ }94+ }95+ branch /= Math.max(1, path.length);96+ const retreatRate = retreats / Math.max(1, L);97+98+ const score = 0.15 * Math.log(1 + L)99+ + 0.40 * Math.log(1 + wander)100+ + 0.60 * retreatRate101+ + 0.10 * Math.log(1 + branch);102+103+ return { L, wander, retreats, retreatRate, branch, score };104+}105+106+/** Five marks, so a plate can wear its difficulty without a number.107+ * Calibrated against the observed range across all three families, roughly108+ * 1.7 for an opening plate to 3.2 for the deepest one in the atlas. */109+export function grade(score) {110+ if (!isFinite(score)) return 1;111+ return Math.max(1, Math.min(5, 1 + Math.round((score - 1.65) * 2.6)));112+}@@ -0,0 +1,110 @@
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 is a *solved* one, with the big block already seated.9+ * Sweeping outward from a solved state makes reachability free, which matters10+ * more than it sounds: most hand-written packings strand the big block in a11+ * component containing no solved state at all, and are not puzzles. Each of12+ * these was swept and measured by tools/find-families.mjs before it shipped.13+ */14+15+import { makeBoard } from './klotski.js';16+17+// 0 empty · 1 single 1x1 · 2 upright 1x2 · 3 recumbent 2x1 · 4 goal 2x218+const F = (id, label, w, h, goal, grid) => ({ id, label, w, h, goal, grid, board: makeBoard(w, h) });19+20+export const FAMILIES = [21+ // Classic leads because it is what the word on the title screen promises.22+ // The rest are ordered by ceiling, so the list past it is a ramp: 24 steps23+ // of depth at the top, 178 at the bottom.24+ // The traditional packing, and the only family whose own arrangement is a25+ // landmark in its own right: 116 unit steps from home.26+ F('classic', 'CLASSIC', 4, 5, [1, 3], [27+ 2, 4, 4, 2,28+ 2, 4, 4, 2,29+ 2, 3, 3, 2,30+ 2, 1, 1, 2,31+ 1, 0, 0, 1]),32+33+ F('narrow', 'NARROW', 3, 5, [0, 3], [34+ 3, 3, 1,35+ 1, 1, 1,36+ 1, 1, 1,37+ 4, 4, 0,38+ 4, 4, 0]),39+40+ F('compact', 'COMPACT', 4, 4, [1, 2], [41+ 3, 3, 3, 3,42+ 1, 1, 1, 1,43+ 0, 4, 4, 0,44+ 0, 4, 4, 0]),45+46+ // Four cells of air: it gives, and keeps giving. A wide state space with a47+ // low ceiling, which is a different kind of easy from a small one.48+ F('span', 'SPAN', 5, 4, [1, 2], [49+ 2, 2, 2, 2, 1,50+ 2, 2, 2, 2, 1,51+ 0, 4, 4, 0, 1,52+ 0, 4, 4, 0, 1]),53+54+ F('wide', 'WIDE', 4, 5, [1, 3], [55+ 3, 3, 3, 3,56+ 3, 3, 3, 3,57+ 1, 1, 1, 1,58+ 1, 4, 4, 1,59+ 0, 4, 4, 0]),60+61+ F('yard', 'YARD', 5, 4, [2, 2], [62+ 2, 3, 3, 3, 3,63+ 2, 3, 3, 3, 3,64+ 1, 1, 4, 4, 0,65+ 1, 1, 4, 4, 0]),66+67+ // The same pieces as MIXED, leaving by the corner instead of the middle.68+ // One goal cell moved, and the distance field is a different shape --69+ // the cheapest way there is to get a genuinely new family.70+ F('corner', 'CORNER', 4, 5, [0, 3], [71+ 3, 3, 3, 3,72+ 2, 3, 3, 2,73+ 2, 1, 1, 2,74+ 4, 4, 1, 1,75+ 4, 4, 0, 0]),76+77+ F('mixed', 'MIXED', 4, 5, [1, 3], [78+ 3, 3, 3, 3,79+ 2, 3, 3, 2,80+ 2, 1, 1, 2,81+ 1, 4, 4, 1,82+ 0, 4, 4, 0]),83+];84+85+/** Small deterministic PRNG, so a given rung always yields the same board. */86+export function mulberry(a) {87+ return function () {88+ a |= 0; a = a + 0x6D2B79F5 | 0;89+ let t = Math.imul(a ^ a >>> 15, 1 | a);90+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;91+ return ((t ^ t >>> 14) >>> 0) / 4294967296;92+ };93+}94+95+/* FNV-1a, and mulberry's other half: every seeded thing here is96+ * mulberry(hashString(something)). It lives beside the generator rather than97+ * with the ladder because the ladder is no longer the only caller -- the look98+ * of a board is drawn from the same pair.99+ *100+ * The old seed used id.length, so same-length ids shared a stream. */101+export function hashString(s) {102+ let h = 0x811c9dc5;103+ for (let i = 0; i < s.length; i++) {104+ h ^= s.charCodeAt(i);105+ h = Math.imul(h, 0x01000193);106+ }107+ return h >>> 0;108+}109+110+export const familyById = id => FAMILIES.find(f => f.id === id);@@ -0,0 +1,547 @@
1+/* Flow, input and chrome.2+ *3+ * The screens are paced rather than instant: a title that has to be touched,4+ * a sweep you watch happen, a card naming the set and its optimal length, the5+ * board rising into place, and a record when it is done. None of it is long,6+ * 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 -- especially11+ * when they may only ever open two of them.12+ */13+14+import { App } from './state.js';15+import { save, persist, record, famStats } from './store.js';16+import { Sound, buzz } from './sound.js';17+import { FAMILIES } from './families.js';18+import { makeSurvey } from './survey.js';19+import { grade } from './difficulty.js';20+import { puzzleFor, depthForRung, levelFromXp, levelProgress } from './progress.js';21+import {22+ DIRS, decode, encode, piecesOf, canMove, applyMove, freeCells,23+} from './klotski.js';24+import {25+ view, LY, fx, layout, layoutBoard, setTheme, requestRender, consumeDirty,26+ renderFrame, toVirtual, atlasGeometry, familyRowRect, atlasContentHeight,27+ PERIOD, clearSprites,28+} from './render.js';29+import { THEMES } from './theme.js';30+import { PATTERNS } from './shading.js';31+32+const $ = id => document.getElementById(id);33+const cvs = $('board');34+35+/* ----------------------------------------------------------------- the loop */36+let lastT = 0;37+38+export function start() {39+ layout();40+ requestAnimationFrame(loop);41+}42+43+function loop(now) {44+ requestAnimationFrame(loop);45+ tick(now);46+ if (consumeDirty()) renderFrame();47+}48+49+function tick(now) {50+ const dt = Math.min(48, now - lastT || 16);51+ lastT = now;52+ let live = false;53+54+ if (App.anim) {55+ const a = App.anim;56+ a.t += dt;57+ const k = Math.min(1, a.t / a.dur);58+ const e = 1 - Math.pow(1 - k, 3);59+ // Quantised to the dither period, so the pattern never lands out of phase.60+ a.cur = Math.round((a.from + (a.to - a.from) * e) / PERIOD) * PERIOD;61+ live = true;62+ if (k >= 1) { const done = a.done; App.anim = null; if (done) done(); }63+ }64+ if (App.shake > 0) { App.shake = Math.max(0, App.shake - dt * 0.05); live = true; }65+ if (App.riseT < 1) { App.riseT = Math.min(1, App.riseT + dt / 300); live = true; }66+ if (fx.flash > 0) { fx.flash -= dt; live = true; }67+ if (fx.wipe >= 0) {68+ fx.wipe += dt * 0.9; live = true;69+ if (fx.wipe > Math.max(view.VW, view.VH)) fx.wipe = -1;70+ }71+ if (App.screen === 'card') {72+ App.cardT += dt; live = true;73+ if (App.cardT > 1300) beginPlay();74+ }75+ if (App.screen === 'survey') { runSurvey(); live = true; }76+77+ if (live) requestRender();78+}79+80+/* --------------------------------------------------------------- the sweep */81+function runSurvey() {82+ if (!App.pending) return;83+ const t0 = performance.now();84+ while (performance.now() - t0 < 9) { // a slice per frame; the screen keeps breathing85+ if (App.pending.step(2200)) {86+ const sv = App.pending;87+ App.surveys[sv.fam.id] = sv;88+ App.pending = null;89+ openRung(sv.fam, famStats(sv.fam.id).rung);90+ return;91+ }92+ }93+}94+95+/* ------------------------------------------------------------------- flow */96+function go(screen) {97+ App.screen = screen;98+ $('sheet').classList.remove('open');99+ placeDom();100+ requestRender();101+}102+103+/** Open a family at whichever rung it has reached, sweeping it first if new. */104+function openFamily(fam) {105+ App.fam = fam;106+ App.board = fam.board;107+ Sound.page();108+ if (!App.surveys[fam.id]) {109+ App.pending = makeSurvey(fam);110+ go('survey');111+ return;112+ }113+ openRung(fam, famStats(fam.id).rung);114+}115+116+function openRung(fam, rung) {117+ App.fam = fam;118+ App.board = fam.board;119+ App.sv = App.surveys[fam.id];120+ App.rung = Math.max(1, rung);121+ App.pz = puzzleFor(fam, App.sv, App.rung);122+ App.cardT = 0;123+ go('card');124+}125+126+function beginPlay() {127+ const pz = App.pz;128+ const b = App.board;129+ layoutBoard(App.fam);130+ App.startState = App.state = pz.state;131+ App.grid = decode(b, pz.state, new Uint8Array(b.n));132+ App.pieces = piecesOf(b, App.grid, []);133+ App.steps = 0;134+ App.history = [];135+ App.trace = [App.sv.dist.get(pz.state)];136+ App.drag = null;137+ App.anim = null;138+ App.riseT = 0;139+ App.startedAt = Date.now();140+ go('play');141+ syncHud();142+}143+144+function win() {145+ const m = App.pz.metrics;146+ const before = levelFromXp(save.totals.xp);147+ const gain = record({148+ famId: App.fam.id, rung: App.rung,149+ optimal: m.L, steps: App.steps,150+ ms: Math.max(0, Date.now() - App.startedAt),151+ score: m.score,152+ });153+ const after = levelFromXp(save.totals.xp);154+ App.lastGain = { ...gain, level: after, levelled: after > before };155+156+ Sound.solve();157+ buzz(App.lastGain.levelled ? [24, 40, 90, 40, 120] : [24, 40, 90]);158+ fx.wipe = 0; fx.wipeAxis = 'x'; // the invert wipe, spent once per solve159+ setTimeout(() => go('won'), 330);160+}161+162+/* ------------------------------------------------------------------ moves */163+function commitStep(p, dx, dy) {164+ applyMove(App.board, App.grid, p, dx, dy);165+ p.x += dx; p.y += dy;166+ App.state = encode(App.board, App.grid);167+ App.steps++;168+ App.trace.push(App.sv.dist.get(App.state));169+}170+171+function performMove(p, dx, dy, cells) {172+ App.history.push({ piece: App.pieces.indexOf(p), dx, dy, cells });173+ for (let i = 0; i < cells; i++) {174+ commitStep(p, dx, dy);175+ Sound.step(i, i * 0.055); // one degree of the scale per cell travelled176+ }177+ buzz(8);178+ syncHud();179+ // A move changes the board, so the board is repainted. In normal play the180+ // animation that delivered the move had already marked the frame live and181+ // this was free; any path that moves without animating -- walkOptimal on the182+ // console -- left the picture a frame behind the numbers beside it.183+ requestRender();184+ if (App.sv.dist.get(App.state) === 0) win();185+}186+187+function undo() {188+ const h = App.history.pop();189+ if (!h) return;190+ const p = App.pieces[h.piece];191+ for (let i = 0; i < h.cells; i++) {192+ applyMove(App.board, App.grid, p, -h.dx, -h.dy);193+ p.x -= h.dx; p.y -= h.dy;194+ }195+ App.state = encode(App.board, App.grid);196+ App.steps -= h.cells;197+ App.trace.length = Math.max(1, App.trace.length - h.cells);198+ Sound.page(); buzz(6);199+ fx.flash = 55; // a single struck frame of inversion200+ syncHud();201+ requestRender();202+}203+204+function animate(piece, axis, from, to, done) {205+ App.anim = {206+ piece, axis, from, to, cur: from, t: 0,207+ dur: Math.max(80, Math.abs(to - from) * 3), done,208+ };209+ requestRender();210+}211+212+function refuse(p) {213+ App.shake = 12; App.shakePiece = p;214+ Sound.thud(); buzz([10, 26, 10]);215+ requestRender();216+}217+218+/* ------------------------------------------------------------------ input */219+function pieceAt(vx, vy) {220+ const B = LY.board, cell = LY.cell, b = App.board;221+ const cx = Math.floor((vx - B.x) / cell), cy = Math.floor((vy - B.y) / cell);222+ if (cx < 0 || cy < 0 || cx >= b.w || cy >= b.h) return null;223+ for (const p of App.pieces) {224+ if (cx >= p.x && cx < p.x + p.w && cy >= p.y && cy < p.y + p.h) return p;225+ }226+ return null;227+}228+229+let scroll = null; // atlas drag-to-scroll230+231+cvs.addEventListener('pointerdown', e => {232+ cvs.setPointerCapture(e.pointerId);233+ const v = toVirtual(e.clientX, e.clientY);234+235+ if (App.screen === 'boot') { Sound.unlock(); go('atlas'); return; }236+ if (App.screen === 'card') { beginPlay(); return; }237+ if (App.screen === 'atlas') {238+ scroll = { y: v.y, from: App.atlasScroll, moved: 0 };239+ return;240+ }241+ if (App.screen !== 'play' || App.anim) return;242+243+ const p = pieceAt(v.x, v.y);244+ if (!p) return;245+ App.drag = { piece: p, ox: v.x, oy: v.y, axis: null, off: 0, pos: 0, neg: 0 };246+ requestRender();247+});248+249+cvs.addEventListener('pointermove', e => {250+ const v = toVirtual(e.clientX, e.clientY);251+252+ if (scroll) {253+ const dy = v.y - scroll.y;254+ scroll.moved = Math.max(scroll.moved, Math.abs(dy));255+ App.atlasScroll = clampScroll(scroll.from + dy);256+ requestRender();257+ return;258+ }259+260+ const d = App.drag;261+ if (!d) return;262+ const dx = v.x - d.ox, dy = v.y - d.oy;263+264+ if (!d.axis) {265+ if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return;266+ d.axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';267+ const ax = d.axis === 'x';268+ d.pos = freeCells(App.board, App.grid, d.piece, ax ? 1 : 0, ax ? 0 : 1) * LY.cell;269+ d.neg = -freeCells(App.board, App.grid, d.piece, ax ? -1 : 0, ax ? 0 : -1) * LY.cell;270+ if (!d.pos && !d.neg) { const p = d.piece; App.drag = null; refuse(p); return; }271+ }272+ const raw = d.axis === 'x' ? dx : dy;273+ d.off = Math.round(Math.max(d.neg, Math.min(d.pos, raw)) / PERIOD) * PERIOD;274+ requestRender();275+});276+277+function clampScroll(y) {278+ const over = atlasContentHeight() - (view.VH - LY.barH - 6);279+ return over <= 0 ? 0 : Math.max(-over, Math.min(0, y));280+}281+282+function endDrag(e) {283+ if (scroll) {284+ // A short drag is a tap. Anything longer was someone scrolling the list.285+ if (scroll.moved < 5 && e) atlasTap(toVirtual(e.clientX, e.clientY));286+ scroll = null;287+ return;288+ }289+290+ const d = App.drag;291+ if (!d) return;292+ App.drag = null;293+294+ if (!d.axis) {295+ // A tap, not a drag. If there is exactly one way out, take it -- the296+ // affordance that makes the board feel like it wants to be solved.297+ const p = d.piece;298+ const ways = DIRS.filter(([dx, dy]) => canMove(App.board, App.grid, p, dx, dy));299+ if (ways.length === 1) {300+ animate(p, ways[0][0] ? 'x' : 'y', 0, (ways[0][0] || ways[0][1]) * LY.cell,301+ () => performMove(p, ways[0][0], ways[0][1], 1));302+ } else if (!ways.length) refuse(p);303+ requestRender();304+ return;305+ }306+307+ const cells = Math.round(d.off / LY.cell);308+ const ax = d.axis === 'x';309+ if (!cells) { animate(d.piece, d.axis, d.off, 0, null); return; }310+ animate(d.piece, d.axis, d.off, cells * LY.cell, () =>311+ performMove(d.piece, ax ? Math.sign(cells) : 0, ax ? 0 : Math.sign(cells), Math.abs(cells)));312+}313+cvs.addEventListener('pointerup', endDrag);314+cvs.addEventListener('pointercancel', () => { scroll = null; endDrag(); });315+316+function atlasTap(v) {317+ const G = atlasGeometry();318+ for (let fi = 0; fi < FAMILIES.length; fi++) {319+ const r = familyRowRect(G, fi);320+ if (v.y >= r.y && v.y <= r.y + r.h) { openFamily(FAMILIES[fi]); return; }321+ }322+}323+324+/* ------------------------------------------------------------------ chrome */325+function zone(id, top, height, show) {326+ const el = $(id);327+ el.style.display = show ? 'flex' : 'none';328+ el.style.top = `${top * view.S}px`;329+ el.style.height = `${height * view.S}px`;330+}331+332+function placeDom() {333+ const sc = App.screen;334+ const P = LY.plate;335+ const playing = sc === 'play', won = sc === 'won';336+ const chrome = sc === 'atlas' || playing || won || sc === 'stats';337+338+ zone('top', 1, LY.barH - 2, chrome);339+ $('btnBack').style.visibility = sc === 'atlas' || sc === 'boot' ? 'hidden' : 'visible';340+ $('btnStats').style.display = sc === 'atlas' ? '' : 'none';341+ $('btnSet').style.display = sc === 'atlas' ? '' : 'none';342+ $('btnInstall').style.display = sc === 'atlas' && App.installPrompt ? '' : 'none';343+344+ /* The hint level, which is only ever subtractive: FULL is everything, BEST345+ * takes away the exact distance and leaves the gauge, OFF takes the gauge346+ * too. STEPS and OPTIMAL stay throughout -- one is a record of what you did347+ * and the other is on the card before you start, so neither is a hint. */348+ const gaugePad = Math.round(view.VW * 0.19) * view.S;349+ $('gauge').style.padding = `0 ${gaugePad}px`;350+ zone('gauge', LY.gaugeY + 6, 14, playing && save.hints !== 'off');351+ zone('hud', LY.hudY, 30, playing);352+ $('statDist').style.display = save.hints === 'full' ? '' : 'none';353+ // The gauge's left end stops being the start once BEST anchors it to the354+ // furthest out you have been, and a scale labelled with the wrong end is355+ // worse than no label.356+ $('gaugeFrom').textContent = save.hints === 'best' ? 'DEEPEST' : 'START';357+ zone('record', P.y + P.h + LY.belowPlate + Math.round(view.VH * 0.04), 34, won);358+ zone('acts', LY.panel.actionsY, 34, playing || won);359+ zone('gate', LY.bootWin.y + Math.round(LY.bootWin.h * 0.85), 20, sc === 'boot');360+361+ ['btnUndo', 'btnReset'].forEach(id => { $(id).style.display = playing ? '' : 'none'; });362+ ['btnNext', 'btnAtlas'].forEach(id => { $(id).style.display = won ? '' : 'none'; });363+364+ if (won) fillRecord();365+}366+367+function syncHud() {368+ const d = App.sv ? App.sv.dist.get(App.state) : null;369+ $('hSteps').textContent = App.steps;370+ $('hDist').textContent = d == null ? '-' : d;371+ $('hOpt').textContent = App.pz.metrics.L;372+ $('btnUndo').disabled = !App.history.length;373+}374+375+function fillRecord() {376+ const m = App.pz.metrics;377+ $('rSteps').textContent = App.steps;378+ $('rOpt').textContent = m.L;379+ $('rXp').textContent = `+${App.lastGain ? App.lastGain.xp : 0}`;380+}381+382+/* ----------------------------------------------------------------- buttons */383+$('btnUndo').onclick = undo;384+$('btnReset').onclick = beginPlay;385+$('btnBack').onclick = () => go('atlas');386+$('btnAtlas').onclick = () => go('atlas');387+$('btnStats').onclick = () => go('stats');388+$('btnNext').onclick = () => openRung(App.fam, famStats(App.fam.id).rung);389+$('btnSet').onclick = () => { $('sheet').classList.add('open'); syncSheet(); };390+$('btnClose').onclick = () => $('sheet').classList.remove('open');391+392+/* Both rows are generated from their registries rather than written into the393+ * markup, so adding a theme or a block style is a one-object edit and the394+ * settings sheet picks it up. */395+(function buildThemeRow() {396+ const row = $('themeRow');397+ for (const id of Object.keys(THEMES)) {398+ const b = document.createElement('button');399+ b.textContent = THEMES[id].name;400+ b.dataset.theme = id;401+ b.onclick = () => { setTheme(id); persist(); syncSheet(); };402+ row.appendChild(b);403+ }404+})();405+406+/* AUTO first, and the default: the style is drawn from the theme's pool per407+ * rung. The rest pin it, which is the escape hatch for anyone who wants one408+ * texture -- and the only way to hold two of them still long enough to compare,409+ * which is what the original ordered/diffusion toggle was for. */410+/* How much of the solver you are shown. Three buttons rather than a pull-down:411+ * there are only three, and unlike the screens they are ordered -- FULL, BEST,412+ * OFF is a scale, and a row shows a scale where a menu hides it. */413+(function buildHintRow() {414+ const row = $('hintRow');415+ for (const [id, text] of [['full', 'FULL'], ['best', 'BEST'], ['off', 'OFF']]) {416+ const b = document.createElement('button');417+ b.textContent = text;418+ b.dataset.hint = id;419+ b.onclick = () => { save.hints = id; persist(); syncSheet(); placeDom(); requestRender(); };420+ row.appendChild(b);421+ }422+})();423+424+/* The shading pull-down.425+ *426+ * Only the shape is offered. The ruling is not a preference -- it is the press427+ * the board happened to be printed on, chosen with the shape it belongs to, and428+ * a second control for it produced pairings nobody asked for.429+ *430+ * AUTO is first and is the default: the screen is drawn from the theme's pool431+ * per rung. The rest pin one, which is the escape hatch for anyone who wants a432+ * single texture and means it -- and the only way to hold two of them still433+ * long enough to compare, which is what the original ordered-against-diffusion434+ * toggle was for. */435+(function buildShadingMenu() {436+ const menu = $('shadeMenu'), list = $('shadeList');437+ for (const [id, text] of [['auto', 'AUTO'], ...Object.keys(PATTERNS).map(k => [k, PATTERNS[k].label])]) {438+ const b = document.createElement('button');439+ b.textContent = text;440+ b.dataset.shading = id;441+ b.setAttribute('role', 'option');442+ b.onclick = e => {443+ e.stopPropagation();444+ save.shading = id;445+ clearSprites(); // the screen is baked into the sprites446+ closeMenu();447+ persist(); syncSheet(); requestRender();448+ };449+ list.appendChild(b);450+ }451+ $('shadeTitle').onclick = e => {452+ e.stopPropagation();453+ menu.classList.toggle('open');454+ $('shadeTitle').setAttribute('aria-expanded', menu.classList.contains('open'));455+ };456+ // Anywhere else closes it, including the sheet behind -- an open menu that457+ // survives a tap outside itself is a menu you have to dismiss twice. It has458+ // to spare the menu's own pointers: this runs on the way down, and hiding the459+ // list before the finger lifts means the click never lands on the option, so460+ // a tap on a choice would dismiss the menu without ever choosing anything.461+ $('sheet').addEventListener('pointerdown', e => {462+ if (!e.target.closest('#shadeMenu')) closeMenu();463+ });464+ window.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });465+})();466+467+function closeMenu() {468+ $('shadeMenu').classList.remove('open');469+ $('shadeTitle').setAttribute('aria-expanded', 'false');470+}471+472+document.querySelectorAll('[data-toggle]').forEach(b => {473+ b.onclick = () => {474+ save[b.dataset.toggle] = !save[b.dataset.toggle];475+ persist(); syncSheet(); requestRender();476+ };477+});478+479+function syncSheet() {480+ document.querySelectorAll('[data-theme]').forEach(b =>481+ b.classList.toggle('armed', b.dataset.theme === save.palette));482+ document.querySelectorAll('[data-shading]').forEach(b =>483+ b.classList.toggle('armed', b.dataset.shading === save.shading));484+ document.querySelectorAll('[data-hint]').forEach(b =>485+ b.classList.toggle('armed', b.dataset.hint === save.hints));486+ $('shadeTitle').textContent =487+ save.shading === 'auto' ? 'AUTO' : (PATTERNS[save.shading] || PATTERNS.atkinson).label;488+ document.querySelectorAll('[data-toggle]').forEach(b =>489+ b.classList.toggle('armed', !!save[b.dataset.toggle]));490+ // Credits only. The swept-configuration count lived here too and is on the491+ // record screen, where a number about the run belongs; a settings sheet is492+ // not a second place to read your own statistics.493+ $('credit').innerHTML =494+ 'ERROR DIFFUSION AND ORDERED SCREENS<br>SILKSCREEN AND SPACE MONO, OFL';495+}496+497+/* ------------------------------------------------------------ installation */498+window.addEventListener('beforeinstallprompt', e => {499+ e.preventDefault();500+ App.installPrompt = e;501+ placeDom();502+});503+$('btnInstall').onclick = async () => {504+ const p = App.installPrompt;505+ if (!p) return;506+ App.installPrompt = null;507+ p.prompt();508+ await p.userChoice.catch(() => {});509+ placeDom();510+};511+512+/* ----------------------------------------------------------------- gestures */513+window.addEventListener('resize', () => { layout(); if (App.fam) layoutBoard(App.fam); placeDom(); });514+window.addEventListener('orientationchange', () => setTimeout(() => {515+ layout(); if (App.fam) layoutBoard(App.fam); placeDom();516+}, 150));517+document.addEventListener('gesturestart', e => e.preventDefault());518+document.addEventListener('dblclick', e => e.preventDefault());519+document.addEventListener('contextmenu', e => { if (e.target === cvs) e.preventDefault(); });520+521+/* Walk the board down the distance field to the goal. A debugging aid rather522+ * than a feature -- but a useful one to have on the console when inspecting a523+ * phone over chrome://inspect, since it reaches the record screen without524+ * anyone having to solve a hundred-step board by hand. */525+export function walkOptimal(limit = 9999) {526+ if (App.screen !== 'play') return 'not playing';527+ const b = App.board;528+ let guard = 0;529+ while (App.sv.dist.get(App.state) > 0 && guard++ < limit) {530+ const d = App.sv.dist.get(App.state);531+ let moved = false;532+ for (const p of App.pieces) {533+ for (const [dx, dy] of DIRS) {534+ if (!canMove(b, App.grid, p, dx, dy)) continue;535+ applyMove(b, App.grid, p, dx, dy);536+ const k = encode(b, App.grid);537+ applyMove(b, App.grid, { x: p.x + dx, y: p.y + dy, w: p.w, h: p.h, c: p.c }, -dx, -dy);538+ if (App.sv.dist.get(k) === d - 1) { performMove(p, dx, dy, 1); moved = true; break; }539+ }540+ if (moved) break;541+ }542+ if (!moved) break;543+ }544+ return App.steps;545+}546+547+export { placeDom, grade, openFamily, depthForRung, levelProgress };@@ -0,0 +1,140 @@
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 255 and7+ * so passes such a pattern through untouched. Anything with a midtone in it, or8+ * in any colour other than ink and paper, was simply not available.9+ *10+ * The ground is now the *absence* of scene instead. The scene canvas is cleared11+ * to transparent, the screens paint their opaque windows onto it, and every12+ * pixel still transparent when the frame is thresholded is ground. That is13+ * pixel-exact occlusion for the cost of one alpha test, and it moves the ground14+ * to after the threshold, where it is free to be any two colours it likes.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 PERIOD is 8 -- slides are quantised18+ * to it so a pattern never lands out of phase, and a tile with any other period19+ * would beat against that.20+ *21+ * They are deliberately sparse. A background is not a picture; the densest here22+ * is one pixel in eight, and the lattice that shipped first is one in sixteen.23+ * A ground that competes with the board is a ground that has to be looked past.24+ */25+26+const tile = rows => Uint8Array.from(rows.join('').replace(/[^01]/g, ''), c => +c);27+28+export const GROUND_TILES = {29+ /* The original, preserved exactly: rows every 4px, alternate rows offset by30+ * 2. One pixel in sixteen. */31+ lattice: tile([32+ '1 0 0 0 1 0 0 0',33+ '0 0 0 0 0 0 0 0',34+ '0 0 0 0 0 0 0 0',35+ '0 0 0 0 0 0 0 0',36+ '0 0 1 0 0 0 1 0',37+ '0 0 0 0 0 0 0 0',38+ '0 0 0 0 0 0 0 0',39+ '0 0 0 0 0 0 0 0',40+ ]),41+42+ /* Two dots in sixty-four. Almost nothing -- which is the point on a dark43+ * theme, where the ground reads as a field of stars rather than a texture. */44+ sparse: tile([45+ '1 0 0 0 0 0 0 0',46+ '0 0 0 0 0 0 0 0',47+ '0 0 0 0 0 0 0 0',48+ '0 0 0 0 0 0 0 0',49+ '0 0 0 0 1 0 0 0',50+ '0 0 0 0 0 0 0 0',51+ '0 0 0 0 0 0 0 0',52+ '0 0 0 0 0 0 0 0',53+ ]),54+55+ /* A single diagonal thread, (x + y) % 8. The only pattern with a direction56+ * in it, which is exactly why there is one and not two: crossing it with its57+ * mirror doubles the density and reads as a mesh rather than a weave. */58+ twill: tile([59+ '1 0 0 0 0 0 0 0',60+ '0 0 0 0 0 0 0 1',61+ '0 0 0 0 0 0 1 0',62+ '0 0 0 0 0 1 0 0',63+ '0 0 0 0 1 0 0 0',64+ '0 0 0 1 0 0 0 0',65+ '0 0 1 0 0 0 0 0',66+ '0 1 0 0 0 0 0 0',67+ ]),68+69+ /* Dotted rules every 4px. A solid scanline was tried and it stripes the70+ * screen; broken in half it reads as ruled paper. */71+ rule: tile([72+ '1 0 1 0 1 0 1 0',73+ '0 0 0 0 0 0 0 0',74+ '0 0 0 0 0 0 0 0',75+ '0 0 0 0 0 0 0 0',76+ '1 0 1 0 1 0 1 0',77+ '0 0 0 0 0 0 0 0',78+ '0 0 0 0 0 0 0 0',79+ '0 0 0 0 0 0 0 0',80+ ]),81+82+ /* Graph paper: one dotted rule each way per tile. */83+ graph: tile([84+ '1 0 1 0 1 0 1 0',85+ '0 0 0 0 0 0 0 0',86+ '1 0 0 0 0 0 0 0',87+ '0 0 0 0 0 0 0 0',88+ '1 0 0 0 0 0 0 0',89+ '0 0 0 0 0 0 0 0',90+ '1 0 0 0 0 0 0 0',91+ '0 0 0 0 0 0 0 0',92+ ]),93+94+ /* Clustered dots -- the newsprint ground, and the backdrop that belongs with95+ * the benday block style. Clustered rather than dispersed is the whole96+ * difference between a halftone and a dither. */97+ benday: tile([98+ '0 0 0 0 0 0 0 0',99+ '0 1 1 0 0 0 0 0',100+ '0 1 1 0 0 0 0 0',101+ '0 0 0 0 0 0 0 0',102+ '0 0 0 0 0 0 0 0',103+ '0 0 0 0 0 1 1 0',104+ '0 0 0 0 0 1 1 0',105+ '0 0 0 0 0 0 0 0',106+ ]),107+108+ /* No pattern at all. Not an omission -- a flat ground is a real member of109+ * the family, and it is the only one that lets a pure-black theme actually110+ * reach pure black across the whole screen. */111+ plain: tile([112+ '0 0 0 0 0 0 0 0',113+ '0 0 0 0 0 0 0 0',114+ '0 0 0 0 0 0 0 0',115+ '0 0 0 0 0 0 0 0',116+ '0 0 0 0 0 0 0 0',117+ '0 0 0 0 0 0 0 0',118+ '0 0 0 0 0 0 0 0',119+ '0 0 0 0 0 0 0 0',120+ ]),121+};122+123+export const GROUND_NAMES = Object.keys(GROUND_TILES);124+125+/**126+ * Paint the ground into an already-thresholded frame.127+ *128+ * Every pixel the dither left as zero is one it was told to skip, which is to129+ * say one no screen drew over. Packed colours always carry full alpha, so zero130+ * is unambiguous and no separate mask has to be carried.131+ */132+export function paintGround(dst, w, h, tileArr, dot32, field32) {133+ for (let y = 0; y < h; y++) {134+ const row = y * w, tr = (y & 7) << 3;135+ for (let x = 0; x < w; x++) {136+ const i = row + x;137+ if (dst[i] === 0) dst[i] = tileArr[tr + (x & 7)] ? dot32 : field32;138+ }139+ }140+}@@ -0,0 +1,138 @@
1+/* Sliding-block core.2+ *3+ * A configuration is stored as the grid of *shape classes*, not of piece4+ * identities, because two pieces of the same shape are interchangeable -- the5+ * classic 4x5 set has 65,880 distinct configurations under that reading, and6+ * 25,955 of them are reachable from the traditional packing. Packing those7+ * cells in base five gives a plain number that is both an exact key and its8+ * own hash.9+ *10+ * That exactness is what bounds the board sizes on offer. A key has to stay11+ * under 2^53, and 5^22 fits where 5^23 does not, so **22 cells is the12+ * ceiling**: 4x5 and 5x4 (20), 3x6 and 6x3 (18), 4x4 (16), 3x5 and 5x3 (15).13+ * 4x6 (24) and 5x5 (25) would need a wider key -- a BigInt or a pair of14+ * 32-bit halves -- and are deliberately out of scope.15+ *16+ * Dimensions are passed in rather than fixed as module constants, because the17+ * atlas draws boards from several families in a single frame and a mutable18+ * "current board" would be a correctness trap the first time it did.19+ */20+21+export const MAX_CELLS = 22;22+23+// 0 empty · 1 single 1x1 · 2 upright 1x2 · 3 recumbent 2x1 · 4 goal 2x224+export const EMPTY = 0, SINGLE = 1, UPRIGHT = 2, RECUMBENT = 3, GOAL = 4;25+export const SHAPE_W = [0, 1, 1, 2, 2];26+export const SHAPE_H = [0, 1, 2, 1, 2];27+export const DIRS = [[0, -1], [0, 1], [-1, 0], [1, 0]];28+29+/** Build the descriptor every other function in here takes. */30+export function makeBoard(w, h) {31+ const n = w * h;32+ if (n > MAX_CELLS) throw new Error(`board ${w}x${h} exceeds the ${MAX_CELLS}-cell key limit`);33+ return { w, h, n, scratch: new Uint8Array(n), seen: new Uint8Array(n) };34+}35+36+export function encode(b, g) {37+ let k = 0;38+ for (let i = b.n - 1; i >= 0; i--) k = k * 5 + g[i];39+ return k;40+}41+export function decode(b, k, out) {42+ for (let i = 0; i < b.n; i++) { const r = k % 5; out[i] = r; k = (k - r) / 5; }43+ return out;44+}45+export function gridOf(b, k) {46+ return decode(b, k, new Uint8Array(b.n));47+}48+49+const _mv = { x: 0, y: 0, w: 0, h: 0, c: 0 };50+const _ps = [];51+52+/* Row-major scanning reaches a piece's top-left corner first, so the first53+ * unclaimed cell of each piece names it. */54+export function piecesOf(b, g, out) {55+ const seen = b.seen;56+ out.length = 0; seen.fill(0);57+ for (let y = 0; y < b.h; y++) for (let x = 0; x < b.w; x++) {58+ const i = y * b.w + x, c = g[i];59+ if (!c || seen[i]) continue;60+ const w = SHAPE_W[c], h = SHAPE_H[c];61+ for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) seen[(y + dy) * b.w + x + dx] = 1;62+ out.push({ x, y, w, h, c });63+ }64+ return out;65+}66+67+export function canMove(b, g, p, dx, dy) {68+ const nx = p.x + dx, ny = p.y + dy;69+ if (nx < 0 || ny < 0 || nx + p.w > b.w || ny + p.h > b.h) return false;70+ for (let y = 0; y < p.h; y++) for (let x = 0; x < p.w; x++) {71+ const cx = nx + x, cy = ny + y;72+ if (cx >= p.x && cx < p.x + p.w && cy >= p.y && cy < p.y + p.h) continue; // still self-covered73+ if (g[cy * b.w + cx]) return false;74+ }75+ return true;76+}77+78+export function applyMove(b, g, p, dx, dy) {79+ for (let y = 0; y < p.h; y++) for (let x = 0; x < p.w; x++) g[(p.y + y) * b.w + p.x + x] = 0;80+ for (let y = 0; y < p.h; y++) for (let x = 0; x < p.w; x++) {81+ g[(p.y + dy + y) * b.w + p.x + dx + x] = p.c;82+ }83+}84+85+/* How far a piece can travel in one direction before something stops it.86+ * The piece is lifted out of the grid first, or it would collide with the87+ * cells it is itself vacating. */88+export function freeCells(b, g, p, dx, dy, limit = 6) {89+ for (let y = 0; y < p.h; y++) for (let x = 0; x < p.w; x++) g[(p.y + y) * b.w + p.x + x] = EMPTY;90+ let n = 0, cx = p.x, cy = p.y;91+ while (n < limit) {92+ const nx = cx + dx, ny = cy + dy;93+ if (nx < 0 || ny < 0 || nx + p.w > b.w || ny + p.h > b.h) break;94+ let ok = true;95+ for (let y = 0; y < p.h && ok; y++) for (let x = 0; x < p.w; x++) {96+ if (g[(ny + y) * b.w + nx + x]) { ok = false; break; }97+ }98+ if (!ok) break;99+ cx = nx; cy = ny; n++;100+ }101+ for (let y = 0; y < p.h; y++) for (let x = 0; x < p.w; x++) g[(p.y + y) * b.w + p.x + x] = p.c;102+ return n;103+}104+105+/* Unit-step neighbours. One cell, one direction -- the convention the whole106+ * distance field is measured in. */107+export function neighbours(b, k, out) {108+ out.length = 0;109+ const g = decode(b, k, b.scratch), ps = piecesOf(b, g, _ps);110+ for (let i = 0; i < ps.length; i++) {111+ const p = ps[i];112+ for (let d = 0; d < 4; d++) {113+ const dx = DIRS[d][0], dy = DIRS[d][1];114+ if (!canMove(b, g, p, dx, dy)) continue;115+ applyMove(b, g, p, dx, dy);116+ out.push(encode(b, g));117+ _mv.x = p.x + dx; _mv.y = p.y + dy; _mv.w = p.w; _mv.h = p.h; _mv.c = p.c;118+ applyMove(b, g, _mv, -dx, -dy);119+ }120+ }121+ return out;122+}123+124+/** Top-left cell of the 2x2 block, or null. */125+export function goalXY(b, k) {126+ const g = decode(b, k, b.scratch);127+ for (let y = 0; y < b.h - 1; y++) for (let x = 0; x < b.w - 1; x++) {128+ if (g[y * b.w + x] === GOAL && g[y * b.w + x + 1] === GOAL &&129+ g[(y + 1) * b.w + x] === GOAL && g[(y + 1) * b.w + x + 1] === GOAL) return { x, y };130+ }131+ return null;132+}133+134+export function isSeated(b, k, gx, gy) {135+ const g = decode(b, k, b.scratch);136+ return g[gy * b.w + gx] === GOAL && g[gy * b.w + gx + 1] === GOAL &&137+ g[(gy + 1) * b.w + gx] === GOAL && g[(gy + 1) * b.w + gx + 1] === GOAL;138+}@@ -0,0 +1,63 @@
1+/* Boot. */2+3+import { App } from './state.js';4+import { save, requestPersistence } from './store.js';5+import { FAMILIES } from './families.js';6+import {7+ setTheme, setComboOverride, resolveCombo, lookFor, requestRender,8+ view, LY, atlasGeometry, familyRowRect,9+} from './render.js';10+import { depthForRung, levelProgress, puzzleFor } from './progress.js';11+import { THEMES, themeById, comboFor } from './theme.js';12+import { GROUND_NAMES } from './ground.js';13+import { PATTERN_NAMES, RULING_NAMES, effectiveRuling } from './shading.js';14+import { start, placeDom, walkOptimal, openFamily } from './game.js';15+16+/* Pin a look, or pass nothing to read the current one back, or null to hand it17+ * to the seed again.18+ *19+ * There are a couple of hundred combinations per theme and they arrive one per20+ * rung, so without this the only way to see them all is to play the ladder.21+ * With it, sweeping every backdrop against every block style is a for-loop on22+ * the console -- which is how the pools actually get judged. */23+function combo(backdrop, groundName, shading, ruling = 'fine') {24+ if (arguments.length === 0) return resolveCombo();25+ if (backdrop === null) { setComboOverride(null); return 'seeded'; }26+ const t = themeById(save.palette);27+ const ground = t.grounds.find(g => g.name === String(groundName).toUpperCase()) || t.grounds[0];28+ const r = effectiveRuling(shading, ruling);29+ setComboOverride({ backdrop, ground, shading, ruling: r });30+ return { backdrop, ground: ground.name, shading, ruling: r };31+}32+33+/* Reachable from the console when inspecting a device over chrome://inspect,34+ * and the handle the screenshot harness drives the game through. */35+window.klotski = {36+ App, view, LY, save, FAMILIES,37+ walkOptimal, openFamily,38+ atlasGeometry, familyRowRect,39+ depthForRung, levelProgress, puzzleFor,40+ THEMES, comboFor, combo, setTheme, lookFor, placeDom, requestRender,41+ backdrops: GROUND_NAMES, shading: PATTERN_NAMES, rulings: RULING_NAMES,42+};43+44+setTheme(save.palette);45+requestPersistence();46+47+if ('serviceWorker' in navigator) {48+ window.addEventListener('load', () => {49+ navigator.serviceWorker.register('./sw.js').catch(() => {});50+ });51+}52+53+/* Wait for the faces before the first frame: type is drawn on its own canvas54+ * at full resolution, but a fallback face would still be measured and laid out55+ * at the wrong widths for the first frames. */56+Promise.all([57+ document.fonts.load('32px Silk'),58+ document.fonts.load('32px SilkR'),59+ document.fonts.load('16px Mono'),60+]).catch(() => {}).then(() => {61+ start();62+ placeDom();63+});@@ -0,0 +1,144 @@
1+/* The ladder, and the level.2+ *3+ * A family's sweep hands back an exact distance for every reachable4+ * configuration, so a rung is nothing more than "a board at depth D". The5+ * schedule below turns a rung number into that depth.6+ *7+ * On what "endless" honestly means here: the *ladder* has no end, but depth8+ * does. A family's eccentricity is the furthest any configuration sits from a9+ * solved one, and nothing can be deeper than that. So the schedule climbs10+ * quickly at first, asymptotes to the ceiling, and once it arrives keeps11+ * drawing fresh boards from the deep band -- ranked by complexity rather than12+ * by length, since length has run out of room. Rung 400 is not deeper than13+ * rung 40; it is a different board that is just as deep, and there are14+ * thousands of those.15+ *16+ * Boards are generated lazily. Evaluating a candidate costs forty walker17+ * rollouts, so an unbounded ladder cannot pre-compute itself the way a fixed18+ * set of tiers could.19+ */20+21+import { analyse } from './difficulty.js';22+import { mulberry, hashString } from './families.js';23+24+// Moved to families.js, beside the generator it is always paired with. Kept25+// exported from here because this is where every existing caller reaches for it.26+export { hashString };27+28+const POOL = 12; // candidates weighed per rung29+const ARC = 18; // rungs of growth before a family reaches its ceiling30+31+/* Depth served at a given rung, for a family whose ceiling is `ecc`.32+ *33+ * Linear, and deliberately so. An exponential ramp looked reasonable written34+ * down and was wrong in the hand: on the classic board it served 13 steps and35+ * then 33, so the second puzzle you ever saw was two and a half times the36+ * first. Growing by a fixed step keeps every family to the same shape of arc37+ * -- an opening board of five to fourteen steps, about eighteen rungs of38+ * growth, then the ceiling -- whether its ceiling is 24 or 178.39+ */40+export function depthForRung(rung, ecc) {41+ if (!(ecc > 0)) return 0;42+ const base = Math.max(5, Math.min(14, Math.round(ecc * 0.22)));43+ const step = Math.max(2, Math.round((ecc - base) / ARC));44+ return Math.min(ecc, base + step * (Math.max(1, rung) - 1));45+}46+47+/** True once the schedule has run out of depth and is varying boards instead. */48+export function atCeiling(rung, ecc) {49+ return depthForRung(rung, ecc) >= ecc - 1;50+}51+52+const cache = new Map(); // "famId:rung" -> { state, metrics, depth }53+54+export function clearPuzzleCache() { cache.clear(); }55+56+/**57+ * The board for a rung: pick the depth band, weigh a handful of candidates58+ * from it, keep the one whose state space punishes a strayed walker hardest.59+ * Deterministic in (family, rung), so a rung is always the same board.60+ */61+export function puzzleFor(fam, sv, rung) {62+ const key = `${fam.id}:${rung}`;63+ const hit = cache.get(key);64+ if (hit) return hit;65+66+ const want = depthForRung(rung, sv.ecc);67+ const rng = mulberry(hashString(fam.id) + rung * 101);68+69+ // Take the wanted depth alone if it has anything in it, so a rung's board is70+ // exactly as long as the ladder says. Widening is a fallback for a thin band71+ // near the ceiling, not a way to make the pool bigger -- mixing depths and72+ // then picking on score quietly serves a shallower board than advertised.73+ const pool = [];74+ const take = d => {75+ if (d < 2 || d > sv.ecc) return;76+ for (const st of sv.buckets[d] || []) pool.push(st);77+ };78+ if (want >= sv.ecc - 1) {79+ // At the ceiling, draw from the deep band rather than the single deepest80+ // depth. States at the exact maximum distance are rare -- often a handful81+ // in a graph of eighty thousand -- so insisting on them would serve the82+ // same two boards forever. A few steps of slack buys real variety and83+ // costs nothing anyone can feel.84+ for (let d = sv.ecc; d >= sv.ecc - 3; d--) take(d);85+ } else take(want);86+ for (let spread = 1; spread <= 6 && !pool.length; spread++) {87+ take(want - spread);88+ take(want + spread);89+ }90+ if (!pool.length) for (let d = sv.ecc; d >= 1 && !pool.length; d--) take(d);91+ if (!pool.length) {92+ const fallback = { state: sv.start, metrics: analyse(sv.board, sv.start, sv, 7), depth: 0 };93+ cache.set(key, fallback);94+ return fallback;95+ }96+97+ // Past the ceiling the band is the same every time, so rotate through it --98+ // otherwise every rung from here on would serve the identical board. The99+ // window narrows there too: weighing twelve overlapping candidates and100+ // keeping the best simply re-elects the same winner rung after rung.101+ const ceiling = want >= sv.ecc - 1;102+ const window = Math.min(ceiling ? 4 : POOL, pool.length);103+ const offset = (hashString(fam.id) + rung * (ceiling ? 5 : 7)) % pool.length;104+ let best = null;105+ for (let i = 0; i < window; i++) {106+ const s = pool[(offset + i) % pool.length];107+ const m = analyse(sv.board, s, sv, ((rng() * 1e6) | 0) + i);108+ if (!best || m.score > best.metrics.score) best = { state: s, metrics: m, depth: m.L };109+ }110+ cache.set(key, best);111+ return best;112+}113+114+/* ------------------------------------------------------------------- level115+ * XP is earned for the shape of the board and how cleanly you took it, not116+ * for showing up: depth and complexity set the base, efficiency scales it, and117+ * replaying a rung you have already cleared is worth a quarter.118+ */119+export function xpFor({ L, score, steps, firstClear }) {120+ const base = 4 + L * (0.6 + (isFinite(score) ? score : 1.6) / 6);121+ const eff = Math.sqrt(Math.max(0.35, Math.min(1, L / Math.max(1, steps))));122+ return Math.max(1, Math.round(base * eff * (firstClear ? 1 : 0.25)));123+}124+125+/* A gentle power curve: later levels cost more, none of them wall off. */126+const XP_UNIT = 45, XP_POW = 1.7;127+128+export function levelFromXp(xp) {129+ // The closed form is only an estimate because xpForLevel rounds; walk the130+ // last step or two so the two functions are exact inverses of each other.131+ let l = 1 + Math.floor(Math.pow(Math.max(0, xp) / XP_UNIT, 1 / XP_POW));132+ while (xpForLevel(l + 1) <= xp) l++;133+ while (l > 1 && xpForLevel(l) > xp) l--;134+ return l;135+}136+export function xpForLevel(level) {137+ return Math.round(XP_UNIT * Math.pow(Math.max(0, level - 1), XP_POW));138+}139+/** Progress within the current level, which is what a bar should show. */140+export function levelProgress(xp) {141+ const level = levelFromXp(xp);142+ const from = xpForLevel(level), to = xpForLevel(level + 1);143+ return { level, into: xp - from, span: Math.max(1, to - from), from, to };144+}@@ -0,0 +1,1231 @@
1+/* Drawing.2+ *3+ * Everything that is *art* is composed once, in greyscale, into a4+ * low-resolution buffer and shaded once (see shading.js -- Atkinson error5+ * diffusion by default). Nothing is drawn "in colour" and then converted: the6+ * greyscale value *is* the intended coverage of paper, authored linearly, so a7+ * tone of 128 really does put half the dots down.8+ *9+ * Because error diffusion depends on the whole image, shifting anything by a10+ * pixel re-flows the pattern downstream of it. So the one thing that moves --11+ * the block under your thumb -- is dithered once into a sprite and carried,12+ * while the rest of the scene is composed without it. The static picture is13+ * therefore identical frame to frame, and the sliding block keeps its own14+ * texture instead of boiling.15+ *16+ * Type is not art and does not go through any of this. It is drawn on a second17+ * canvas at full device resolution in flat ink. Dithering type turns it to18+ * gravel, which is fine for a title and unreadable for anything else.19+ *20+ * No colour is written down in this file. Every value comes from the active21+ * theme (theme.js), including the four pools a board draws its look from.22+ */23+24+import { App } from './state.js';25+import { save } from './store.js';26+import { decode, gridOf, piecesOf } from './klotski.js';27+import { FAMILIES } from './families.js';28+import { grade } from './difficulty.js';29+import { famStats } from './store.js';30+import { depthForRung, levelProgress, atCeiling, puzzleFor } from './progress.js';31+import { shade, effectiveRuling, fitRuling, PATTERNS, screenOf } from './shading.js';32+import { THEMES, themeById, comboFor, houseCombo, logoInk, dayOfYear } from './theme.js';33+import { GROUND_TILES, paintGround } from './ground.js';34+35+/* Tones are coverage of paper, 0..255.36+ *37+ * Retuned for Atkinson, which throws away a quarter of its error and so runs38+ * highlights clean to white and shadows to solid black. Anything past about39+ * 225 renders as bare paper and anything under about 35 as solid ink, so all40+ * the useful texture lives in the middle and the steps have to sit there. */41+const T = {42+ plate: 255, rule: 0, tray: 255,43+ // Coverage per piece size. Only three quarters, a half and a quarter are44+ // used: Atkinson resolves those to even textures, while the eighths between45+ // them (five eighths, three eighths) come out visibly streaked, and an46+ // eighth is too sparse for a hue to register at all. Tall and wide share a47+ // half and are told apart by colour and by their own shape.48+ goal: 64, upright: 128, recumbent: 128, single: 192,49+};50+51+const TONE_OF = [0, T.single, T.upright, T.recumbent, T.goal];52+let PIECE32 = [], PLATE32 = [];53+export const PERIOD = 8; // slides are quantised to this54+55+/* ------------------------------------------------------------------ canvas */56+const cvs = document.getElementById('board'); // the dithered picture57+const octx = cvs.getContext('2d');58+const typeCvs = document.getElementById('type'); // sharp type, full resolution59+const tctx = typeCvs.getContext('2d');60+const scene = document.createElement('canvas');61+const sctx = scene.getContext('2d', { willReadFrequently: true });62+63+export const view = { S: 2, VW: 208, VH: 440, offX: 0, offY: 0 };64+export const LY = {};65+export const fx = { wipe: -1, wipeAxis: 'x', flash: 0 };66+67+const css = c => `rgb(${c[0]},${c[1]},${c[2]})`;68+69+let outImg = null;70+let INK32 = 0, PAPER32 = 0, INK_CSS = '#000', PAPER_CSS = '#fff';71+let GOLD = '#C89526';72+let typeScale = 2;73+let dirty = true;74+75+/* The look currently in force, resolved once at the top of each frame.76+ *77+ * It has to be a module value rather than a parameter because the block sprites78+ * are built deep inside the screen painters, long before the frame-level79+ * threshold runs, and both need the same answer. */80+let curShading = 'atkinson';81+let curRuling = 'fine';82+let curTile = GROUND_TILES.lattice;83+let curDot32 = 0, curField32 = 0;84+let comboOverride = null;85+86+export function requestRender() { dirty = true; }87+export function consumeDirty() { const d = dirty; dirty = false; return d; }88+export function blockShading() { return curShading; }89+export function blockRuling() { return curRuling; }90+export function clearSprites() { sprites.clear(); }91+92+/**93+ * The look of the board on screen: drawn from the theme's pools and seeded on94+ * the family and rung, but only where there is a board. The atlas, the record95+ * and the title are chrome and keep the house style; a rung is also the only96+ * thing there is to seed from, and those screens do not have one.97+ *98+ * A pinned block style beats the roll. That setting is the escape hatch for99+ * anyone who wants one texture and means it, and it also keeps the comparison100+ * the two original modes existed for -- ordered against diffusion, on a real101+ * screen, on demand.102+ */103+const BOARD_SCREENS = new Set(['card', 'play', 'won']);104+105+export function resolveCombo() {106+ if (comboOverride) return comboOverride;107+ // The record screen counts as the board: it is the same plate, the same108+ // family and the same rung, arrived at through a wipe. Handing it back to the109+ // house lattice snapped the backdrop the instant the puzzle was solved, which110+ // read as the reward taking the world away.111+ const onBoard = BOARD_SCREENS.has(App.screen) && App.fam;112+ const c = onBoard ? comboFor(save.palette, App.fam.id, App.rung) : houseCombo(save.palette);113+ return applyPins(c);114+}115+116+/* Only the shape pins. The ruling is the press this board was printed on, not a117+ * preference: it is chosen with the shape it belongs to, and letting someone118+ * hold one still while the other rolled produced pairings neither the seed nor119+ * the person actually asked for. */120+function applyPins(c) {121+ const shading = save.shading && save.shading !== 'auto' ? save.shading : c.shading;122+ return { ...c, shading, ruling: effectiveRuling(shading, c.ruling) };123+}124+125+/** The look a given rung will be played at -- what the atlas previews. */126+export function lookFor(famId, rung) {127+ return applyPins(comboFor(save.palette, famId, rung));128+}129+130+/* The picture buffer is upscaled by a whole number, so on anything wider than a131+ * phone there is a margin of page around it. That margin was paper, which was132+ * invisible while the only ground *was* paper and became a seam the moment the133+ * ground could be any colour. It follows the ground now instead. Written only134+ * when it changes: this runs inside the frame, and setting a custom property135+ * every frame invalidates style on every frame. */136+let groundCss = null;137+function syncGroundCss(field) {138+ const next = css(field);139+ if (next === groundCss) return;140+ groundCss = next;141+ document.documentElement.style.setProperty('--ground', next);142+}143+144+/** Force a combo, or pass null to hand it back to the seed. A console handle145+ * for looking at all of them without playing two hundred and fifty rungs. */146+export function setComboOverride(o) {147+ comboOverride = o;148+ sprites.clear();149+ requestRender();150+}151+152+/* Unsigned, and it matters. `255 << 24` is negative in JS, and a value read153+ * back out of a Uint32Array never is -- so a signed constant compares equal to154+ * nothing, and every "is this pixel ink?" test silently fails. */155+function pack(rgb) { return (((255 << 24) | (rgb[2] << 16) | (rgb[1] << 8) | rgb[0]) >>> 0); }156+157+/* Regions to reprint in colour once the picture has been thresholded. Filled158+ * while drawing, applied straight after the shading.159+ *160+ * A tint may carry a second plate. A colour comic was never one ink: each was161+ * screened separately and printed at its own angle, and the colour you saw was162+ * two or three of them interleaved and very slightly out of register. A tint163+ * with a `screen` splits its ink between two plates on a rotated screen, which164+ * is that, done with the one pass the pipeline already had. */165+const tints = [];166+function applyTints(dst, w, h) {167+ for (const t of tints) {168+ const x0 = Math.max(0, Math.round(t.x)), x1 = Math.min(w, Math.round(t.x + t.w));169+ const y0 = Math.max(0, Math.round(t.y)), y1 = Math.min(h, Math.round(t.y + t.h));170+ const s = t.screen;171+ for (let y = y0; y < y1; y++) {172+ const row = y * w;173+ const sr = s ? (y % s.period) * s.period : 0;174+ for (let x = x0; x < x1; x++) {175+ if (dst[row + x] !== INK32) continue;176+ dst[row + x] = s ? (s.m[sr + (x % s.period)] >= 128 ? t.c2 : t.c) : t.c;177+ }178+ }179+ }180+}181+182+/* The second plate.183+ *184+ * Driven to the primary the colour is already mostly made of, with the other185+ * two channels pulled back: the same hue, printed at full strength. Laid over186+ * the flat plate on a rotated screen it reads as a colour separation rather187+ * than as a different piece, which is the whole requirement -- the 2x2 has to188+ * stay the red one. */189+const plateTwo = rgb => {190+ const mx = Math.max(rgb[0], rgb[1], rgb[2]);191+ return rgb.map(v => (v === mx ? 255 : Math.round(v * 0.55)));192+};193+194+export function setTheme(id) {195+ const t = themeById(id); // falls back rather than throwing196+ save.palette = THEMES[id] ? id : 'macintosh';197+ INK32 = pack(t.ink); PAPER32 = pack(t.paper);198+ PIECE32 = t.pieces.map(c => (c ? pack(c) : 0));199+ PLATE32 = t.pieces.map(c => (c ? pack(plateTwo(c)) : 0));200+ INK_CSS = css(t.ink); PAPER_CSS = css(t.paper);201+ GOLD = css(t.gold);202+ const root = document.documentElement.style;203+ root.setProperty('--ink', INK_CSS);204+ root.setProperty('--paper', PAPER_CSS);205+ const meta = document.querySelector('meta[name="theme-color"]');206+ if (meta) meta.setAttribute('content', PAPER_CSS);207+ sprites.clear(); // the sprite cache bakes in the palette208+ requestRender();209+}210+211+/* ------------------------------------------------------------------ layout212+ * Composed to a portrait area even on a wide screen, so what shows on a213+ * desktop is an honest reading of what shows on a phone. The upscale from the214+ * picture buffer to CSS pixels is always an exact integer -- a fractional one215+ * smears the dither, which is the fastest way to make this look cheap. The216+ * type canvas has no such constraint and runs at full device resolution. */217+export function layout() {218+ const cw = window.innerWidth, ch = window.innerHeight;219+ const gh = ch, gw = Math.min(cw, Math.round(ch * 0.5));220+ const S = Math.max(1, Math.round(gw / 200));221+ const VW = Math.floor(gw / S), VH = Math.floor(gh / S);222+ Object.assign(view, {223+ S, VW, VH,224+ offX: Math.round((cw - VW * S) / 2),225+ offY: Math.round((ch - VH * S) / 2),226+ });227+228+ cvs.width = VW; cvs.height = VH;229+ cvs.style.width = `${VW * S}px`; cvs.style.height = `${VH * S}px`;230+ scene.width = VW; scene.height = VH;231+232+ typeScale = S * Math.min(3, window.devicePixelRatio || 1);233+ typeCvs.width = Math.round(VW * typeScale);234+ typeCvs.height = Math.round(VH * typeScale);235+ typeCvs.style.width = `${VW * S}px`; typeCvs.style.height = `${VH * S}px`;236+237+ const stage = document.getElementById('stage').style;238+ stage.left = `${view.offX}px`; stage.top = `${view.offY}px`;239+ stage.width = `${VW * S}px`; stage.height = `${VH * S}px`;240+ document.documentElement.style.setProperty('--s', S);241+242+ outImg = octx.createImageData(VW, VH);243+ sprites.clear();244+245+ const barH = Math.max(14, Math.round(VH * 0.048));246+ Object.assign(LY, {247+ headH: Math.round(VH * 0.14), footH: Math.round(VH * 0.30), barH,248+ // Computed here, not while drawing, so the DOM prompt can be positioned249+ // before the first frame is painted.250+ bootWin: { x: 12, y: Math.round(VH * 0.15), w: VW - 24, h: Math.round(VH * 0.66) },251+ });252+ layoutBoard(App.fam || FAMILIES[0]);253+ requestRender();254+}255+256+/* The board's own measurements, which depend on the family in play -- a 3x5257+ * set and a 5x4 set want different cells and a different plate. Kept apart258+ * from layout() so a resize does not need to know which family is open. */259+export function layoutBoard(fam) {260+ const { VW, VH } = view;261+ const bw = fam.w, bh = fam.h;262+ const headH = LY.headH, footH = LY.footH;263+264+ // The cell stays a whole number of dither periods so the ordered mode does265+ // not flash when a block slides.266+ // The tray is inset from the cells: a block's drop shadow reaches past its267+ // own cell, and without this margin the bottom and right rules sit directly268+ // on the last row of blocks and clip their shadows.269+ const trayPadFor = c => Math.max(3, Math.round(c * 0.16));270+ const plateFor = c =>271+ Math.round(c * bh + trayPadFor(c) * 2 + c * 0.5 + c * 1.05);272+ let cell = Math.min(40, Math.floor((VW - 40) / bw / PERIOD) * PERIOD);273+ while (cell > 16 && plateFor(cell) > VH - headH - footH) cell -= PERIOD;274+ cell = Math.max(16, cell);275+276+ const trayPad = trayPadFor(cell);277+ const padX = Math.round(cell * 0.5);278+ const padTop = Math.round(cell * 0.5);279+ const padBottom = Math.round(cell * 1.05); // the caption band280+ // Was a drawn plinth under the plate, a leftover from an earlier direction.281+ // A dithered slab under a window is not something QuickDraw would have done282+ // and it read as a stray textured bar; the space it held is kept as plain283+ // breathing room.284+ const belowPlate = Math.round(cell * 0.3);285+ const plateW = cell * bw + trayPad * 2 + padX * 2;286+ const plateH = cell * bh + trayPad * 2 + padTop + padBottom;287+ const plateX = Math.round((VW - plateW) / 2);288+ const plateY = Math.round(headH + (VH - headH - footH - plateH - belowPlate) / 2);289+290+ Object.assign(LY, {291+ cell, padX, padTop, padBottom, belowPlate, trayPad,292+ plate: { x: plateX, y: plateY, w: plateW, h: plateH },293+ board: {294+ x: plateX + padX + trayPad, y: plateY + padTop + trayPad,295+ w: cell * bw, h: cell * bh,296+ },297+ gaugeY: plateY + plateH + belowPlate + Math.round(VH * 0.045),298+ hudY: plateY + plateH + belowPlate + Math.round(VH * 0.095),299+ // The panel hugs its contents rather than reaching for the bottom of the300+ // screen, so the buttons sit with the numbers they belong to.301+ panel: (() => {302+ const y = plateY + plateH + belowPlate + Math.round(VH * 0.02);303+ const actionsY = plateY + plateH + belowPlate + Math.round(VH * 0.095) + 42;304+ return { x: 7, y, w: VW - 14, h: Math.min(VH - 6, actionsY + 34 + 8) - y, actionsY };305+ })(),306+ });307+}308+309+export function toVirtual(clientX, clientY) {310+ const r = cvs.getBoundingClientRect();311+ return { x: (clientX - r.left) / view.S, y: (clientY - r.top) / view.S };312+}313+314+/* --------------------------------------------------------------------- type315+ * Queued while the picture is composed, then painted afterwards on its own316+ * canvas so it never meets the threshold. Silkscreen is a bitmap face, so317+ * sizes are snapped to whole pixels to keep its stems even. */318+const typeQueue = [];319+const snap = (n, m = 2) => Math.max(m, Math.round(n / m) * m);320+321+/* Sprites and type are composited *after* the threshold, so they do not322+ * respect anything drawn over them in the scene -- a scrolled list ran323+ * straight through the menu bar. Anything queued while a clip is active324+ * carries it, and honours it at paint time. */325+let activeClip = null;326+export function setClip(r) { activeClip = r; }327+function withClip(c, clip, paint) {328+ if (!clip) { paint(); return; }329+ c.save();330+ c.beginPath();331+ c.rect(clip.x, clip.y, clip.w, clip.h);332+ c.clip();333+ paint();334+ c.restore();335+}336+337+function label(str, x, y, opt) {338+ typeQueue.push({339+ str, x, y, clip: activeClip,340+ size: snap(opt.size),341+ face: opt.face || 'Silk',342+ ink: opt.ink !== false,343+ track: opt.track || 0,344+ align: opt.align || 'left',345+ alpha: opt.alpha == null ? 1 : opt.alpha,346+ });347+}348+function setFont(size, face) { tctx.font = `${size}px ${face}`; }349+function measure(str, size, face, track) {350+ setFont(size, face);351+ let w = -track;352+ for (const ch of str) w += tctx.measureText(ch).width + track;353+ return w;354+}355+/** Largest snapped size at or below `size` whose tracked width fits `maxW`. */356+function fitSize(str, size, face, track, maxW) {357+ let s = snap(size);358+ while (s > 4 && measure(str, s, face, track) > maxW) s -= 2;359+ return s;360+}361+function paintTracked(str, x, y, track, align) {362+ let w = -track;363+ for (const ch of str) w += tctx.measureText(ch).width + track;364+ let cx = align === 'center' ? Math.round(x - w / 2) : align === 'right' ? Math.round(x - w) : x;365+ for (const ch of str) { tctx.fillText(ch, cx, y); cx += tctx.measureText(ch).width + track; }366+}367+function drawType() {368+ tctx.setTransform(1, 0, 0, 1, 0, 0);369+ tctx.clearRect(0, 0, typeCvs.width, typeCvs.height);370+ tctx.setTransform(typeScale, 0, 0, typeScale, 0, 0);371+ tctx.textBaseline = 'alphabetic';372+ for (const t of typeQueue) {373+ setFont(t.size, t.face);374+ tctx.globalAlpha = t.alpha;375+ // A struck inversion has to take the type with it or the screen tears.376+ tctx.fillStyle = (fx.flash > 0 ? !t.ink : t.ink) ? INK_CSS : PAPER_CSS;377+ withClip(tctx, t.clip, () => paintTracked(t.str, t.x, t.y, t.track, t.align));378+ }379+ tctx.globalAlpha = 1;380+ for (const d of decals) {381+ withClip(tctx, d.clip, () => {382+ if (d.kind === 'line') paintGoldLine(tctx, d);383+ else if (d.kind === 'logo') paintDevLogo(tctx, d);384+ else paintTrophy(tctx, d.x, d.y, d.size);385+ });386+ }387+}388+389+/* ------------------------------------------------------------ draw helpers */390+const grey = v => `rgb(${v | 0},${v | 0},${v | 0})`;391+function fill(c, x, y, w, h, v) {392+ c.fillStyle = grey(v);393+ c.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h));394+}395+396+/** A window: white, with a hard rule around it. Straight out of QuickDraw. */397+function drawPlate(c, r) {398+ fill(c, r.x, r.y, r.w, r.h, T.plate);399+ c.fillStyle = grey(T.rule);400+ c.fillRect(r.x, r.y, r.w, 1); c.fillRect(r.x, r.y + r.h - 1, r.w, 1);401+ c.fillRect(r.x, r.y, 1, r.h); c.fillRect(r.x + r.w - 1, r.y, 1, r.h);402+ const i = 3;403+ c.fillRect(r.x + i, r.y + i, r.w - 2 * i, 1);404+ c.fillRect(r.x + i, r.y + r.h - i - 1, r.w - 2 * i, 1);405+ c.fillRect(r.x + i, r.y + i, 1, r.h - 2 * i);406+ c.fillRect(r.x + r.w - i - 1, r.y + i, 1, r.h - 2 * i);407+}408+409+/* The desktop is no longer drawn. It is whatever is left.410+ *411+ * It used to be composed into the scene as a lattice of ink dots on bare paper,412+ * and it had to be drawn at the extremes: running a flat mid grey through error413+ * diffusion across a whole screen gives a restless, faintly directional field,414+ * fine on a small object and exhausting as a background, while 0 and 255 give415+ * diffusion no error to spread and so pass through untouched.416+ *417+ * That worked, and it allowed exactly one backdrop in exactly two colours. The418+ * scene is now cleared to transparent instead, the screens paint their opaque419+ * windows over it, and every pixel still transparent at threshold time is420+ * ground -- which is pixel-exact occlusion for one alpha test, and puts the421+ * backdrop after the threshold where it can be any pattern in any two colours.422+ * See ground.js. */423+424+/* A panel, and the menu bar. The desktop carries a pattern, so nothing small425+ * is ever set on it -- on a Macintosh, text lives inside a window. This is426+ * what keeps a textured ground and legible labels from being a trade. */427+function drawPanel(c, r) {428+ fill(c, r.x, r.y, r.w, r.h, T.plate);429+ c.fillStyle = grey(T.rule);430+ c.fillRect(r.x, r.y, r.w, 1); c.fillRect(r.x, r.y + r.h - 1, r.w, 1);431+ c.fillRect(r.x, r.y, 1, r.h); c.fillRect(r.x + r.w - 1, r.y, 1, r.h);432+}433+function drawMenuBar(c) {434+ const { VW } = view;435+ fill(c, 0, 0, VW, LY.barH, T.plate);436+ fill(c, 0, LY.barH, VW, 1, T.rule);437+}438+439+/* Flat, with a hard offset shadow. An extruded 2.5D side was tried and it440+ * smears: the darkened faces of adjacent blocks merge into one muddy band441+ * down the right of the board. A solid black drop, which is what QuickDraw442+ * did, separates them at a glance and costs two pixels. */443+const blockGeom = (w, opt) => ({444+ gap: opt.gap ?? 1,445+ drop: opt.drop ?? Math.max(2, Math.round(w / 22)),446+});447+448+function drawBlock(c, x, y, w, h, cls, opt = {}) {449+ const { gap, drop } = blockGeom(w, opt);450+ const tone = opt.tone ?? TONE_OF[cls];451+ const bx = x + gap, by = y + gap, bw = w - gap * 2, bh = h - gap * 2;452+453+ if (opt.shadow !== false) fill(c, bx + drop, by + drop, bw, bh, T.rule);454+ fill(c, bx, by, bw, bh, tone);455+456+ // A plain ink outline: the fills are told apart by hue now, so the outline457+ // no longer has to invert to keep the silhouette.458+ c.fillStyle = grey(T.rule);459+ c.fillRect(bx, by, bw, 1); c.fillRect(bx, by + bh - 1, bw, 1);460+ c.fillRect(bx, by, 1, bh); c.fillRect(bx + bw - 1, by, 1, bh);461+ if (opt.thick) {462+ c.fillRect(bx + 1, by + 1, bw - 2, 1); c.fillRect(bx + 1, by + bh - 2, bw - 2, 1);463+ c.fillRect(bx + 1, by + 1, 1, bh - 2); c.fillRect(bx + bw - 2, by + 1, 1, bh - 2);464+ }465+ // A struck ring on the goal block: the one piece whose identity matters.466+ //467+ // Plotted a pixel at a time rather than stroked. A stroked arc is468+ // anti-aliased, and a half-covered pixel comes out somewhere in the middle of469+ // the range -- which error diffusion carries happily and an ordered screen470+ // does not: under a flat threshold every partial pixel fell on the ink side471+ // and the ring dropped to four stray dashes. A plotted circle is either on or472+ // off, so it survives whichever style the rung drew.473+ if (opt.device) {474+ const cx = Math.round(bx + bw / 2), cy = Math.round(by + bh / 2);475+ let px = Math.max(3, Math.round(bw * 0.19)), py = 0, err = 1 - px;476+ c.fillStyle = grey(255);477+ while (px >= py) {478+ for (const [ox, oy] of [[px, py], [py, px], [-py, px], [-px, py],479+ [-px, -py], [-py, -px], [py, -px], [px, -py]]) {480+ c.fillRect(cx + ox, cy + oy, 1, 1);481+ }482+ py++;483+ if (err < 0) err += 2 * py + 1;484+ else { px--; err += 2 * (py - px) + 1; }485+ }486+ c.fillRect(cx - 1, cy - 1, 2, 2);487+ }488+ if (cls && PIECE32[cls]) {489+ const p = PATTERNS[opt.shading];490+ const screen = p && p.overprint ? screenOf(opt.shading, opt.ruling, 'overprint') : null;491+ tints.push({492+ x: bx + 1, y: by + 1, w: bw - 2, h: bh - 2,493+ c: PIECE32[cls], c2: screen ? PLATE32[cls] : 0, screen,494+ });495+ }496+}497+498+/* Every block is dithered on its own, here, and then blitted.499+ *500+ * Diffusion is global by nature: error runs off one shape and into whatever is501+ * drawn to its right and below, so a block's texture depended on its502+ * neighbours and a 50% bar could develop a streaked patch where the error503+ * happened to pile up. Dithering each block alone against bare paper makes its504+ * texture a pure function of its size, tone and colour -- identical every505+ * time, in every position, and free after the first one because they cache.506+ *507+ * It also means nothing boils while a block slides, which is what this508+ * mechanism was originally built for.509+ *510+ * Sprites are alpha-cut to the block and its shadow so neighbours compose511+ * without one sprite's paper margin erasing the next one's shadow. */512+const sprites = new Map();513+const blits = [];514+function blockSprite(w, h, cls, opt) {515+ const { gap, drop } = blockGeom(w, opt);516+ // Normally the frame's own screen, but a caller may ask for another: the517+ // atlas previews each row in the screen that row's rung will be played in.518+ const shading = opt.shading || curShading;519+ const ruling = opt.ruling || curRuling;520+ const key = `${w}x${h}|${cls}|${gap}|${drop}|${opt.thick ? 1 : 0}|${opt.device ? 1 : 0}`521+ + `|${shading}@${ruling}`;522+ const hit = sprites.get(key);523+ if (hit) return hit;524+525+ const sw = w + drop + 2, sh = h + drop + 2;526+ const cnv = document.createElement('canvas');527+ cnv.width = sw; cnv.height = sh;528+ const cx = cnv.getContext('2d', { willReadFrequently: true });529+530+ const outer = tints.splice(0); // the sprite has its own tint space531+ fill(cx, 0, 0, sw, sh, T.tray);532+ // Resolved here rather than read off the module inside drawBlock, so a sprite533+ // asked for in another screen -- the atlas previews each row in its own --534+ // gets that screen's overprint too and not the frame's.535+ drawBlock(cx, 0, 0, w, h, cls, { ...opt, shading, ruling });536+ const img = cx.getImageData(0, 0, sw, sh);537+ const buf = new Uint32Array(img.data.buffer);538+ shade(buf, buf, sw, sh, shading, INK32, PAPER32, ruling);539+ applyTints(buf, sw, sh);540+ tints.length = 0; tints.push(...outer);541+542+ // Keep only the block and its shadow; the rest was scaffolding for the543+ // threshold and would otherwise blank a neighbour.544+ const bw = w - gap * 2, bh = h - gap * 2;545+ for (let y = 0; y < sh; y++) {546+ for (let x = 0; x < sw; x++) {547+ const inBlock = x >= gap && x < gap + bw && y >= gap && y < gap + bh;548+ const inShadow = x >= gap + drop && x < gap + drop + bw549+ && y >= gap + drop && y < gap + drop + bh;550+ if (!inBlock && !inShadow) buf[y * sw + x] &= 0x00FFFFFF; // clear alpha551+ }552+ }553+ cx.putImageData(img, 0, 0);554+555+ const sprite = { canvas: cnv, w: sw, h: sh };556+ sprites.set(key, sprite);557+ return sprite;558+}559+560+/* The one thing on screen that is not one bit.561+ *562+ * A solved board used to be marked with an inked seal, which sank straight563+ * into the board's own texture -- ink on ink. The trophy is queued like type564+ * and painted after the threshold, at full resolution and in gold, so it sits565+ * clearly on top of the dither instead of competing with it. The gold is the566+ * theme's own: a printed ochre disappears against a black paper. */567+const decals = [];568+const trophy = (x, y, size) => decals.push({ kind: 'cup', x, y, size, clip: activeClip });569+/* The perfect-solver line. Gold and painted after the threshold, for the same570+ * reason the trophy is: drawn into the scene it was ink on ink, and the bars571+ * of your own descent covered the very line they are meant to be read572+ * against. */573+const goldLine = (x1, y1, x2, y2) =>574+ decals.push({ kind: 'line', x1, y1, x2, y2, clip: activeClip });575+576+/* The dev-logo: a ring around a triangle.577+ *578+ * Queued as a decal for the same reason the trophy is, and then for one more.579+ * The trophy is painted after the threshold because it is gold and the board is580+ * one bit; this is painted after the threshold because it is a *mark* -- two581+ * flat colours, a ring in ink and a triangle in one of the theme's piece582+ * colours, and no texture at all. Shading it would put the house pattern583+ * through a shape whose whole job is to be constant, and at fourteen pixels a584+ * screen of any ruling turns a ring into four dots.585+ *586+ * Drawn from geometry rather than carried as an asset: it is a circle and a587+ * triangle, the renderer has no path anywhere else, and an SVG would have to be588+ * rasterised and recoloured per theme to end up here anyway. */589+const devLogo = (cx, cy, size, ink) =>590+ decals.push({ kind: 'logo', x: cx, y: cy, size, ink, clip: activeClip });591+592+function paintDevLogo(c, d) {593+ const r = d.size / 2;594+ c.save();595+ c.lineJoin = 'round';596+ c.lineCap = 'round';597+598+ // The triangle first, solid, so the ring reads as enclosing it rather than599+ // as being crossed by it.600+ c.fillStyle = d.ink;601+ c.beginPath();602+ c.moveTo(d.x, d.y - r * 0.46);603+ c.lineTo(d.x + r * 0.44, d.y + r * 0.42);604+ c.lineTo(d.x - r * 0.44, d.y + r * 0.42);605+ c.closePath();606+ c.fill();607+608+ c.strokeStyle = INK_CSS;609+ c.lineWidth = Math.max(1, d.size * 0.10);610+ c.beginPath();611+ c.arc(d.x, d.y, r - c.lineWidth / 2, 0, Math.PI * 2);612+ c.stroke();613+ c.restore();614+}615+616+function paintGoldLine(c, d) {617+ c.save();618+ c.strokeStyle = GOLD;619+ c.lineWidth = 1.2;620+ c.setLineDash([3, 2]);621+ c.beginPath();622+ c.moveTo(d.x1, d.y1);623+ c.lineTo(d.x2, d.y2);624+ c.stroke();625+ c.restore();626+}627+628+function paintTrophy(c, cx, cy, s) {629+ const w = s, h = s * 1.1;630+ c.lineJoin = 'round';631+ c.strokeStyle = INK_CSS;632+ c.fillStyle = GOLD;633+ c.lineWidth = Math.max(0.7, s * 0.09);634+635+ for (const side of [-1, 1]) { // handles636+ c.beginPath();637+ c.moveTo(cx + side * w * 0.36, cy - h * 0.34);638+ c.quadraticCurveTo(cx + side * w * 0.72, cy - h * 0.22, cx + side * w * 0.34, cy - h * 0.02);639+ c.stroke();640+ }641+ c.beginPath(); // bowl642+ c.moveTo(cx - w * 0.4, cy - h * 0.44);643+ c.lineTo(cx + w * 0.4, cy - h * 0.44);644+ c.lineTo(cx + w * 0.28, cy + h * 0.04);645+ c.quadraticCurveTo(cx, cy + h * 0.3, cx - w * 0.28, cy + h * 0.04);646+ c.closePath(); c.fill(); c.stroke();647+648+ c.beginPath(); // stem and foot649+ c.rect(cx - w * 0.09, cy + h * 0.16, w * 0.18, h * 0.2);650+ c.fill(); c.stroke();651+ c.beginPath();652+ c.rect(cx - w * 0.34, cy + h * 0.34, w * 0.68, h * 0.14);653+ c.fill(); c.stroke();654+}655+656+function placeBlock(x, y, w, h, cls, opt) {657+ blits.push({658+ sprite: blockSprite(w, h, cls, opt),659+ x: Math.round(x), y: Math.round(y), clip: activeClip,660+ });661+}662+function drawGridInto(c, board, g, bx, by, cell, opt) {663+ for (const p of piecesOf(board, g, [])) {664+ placeBlock(bx + p.x * cell, by + p.y * cell, p.w * cell, p.h * cell, p.c,665+ { ...opt, device: !!opt.device && p.c === 4 });666+ }667+}668+669+/* -------------------------------------------------------------- the frame */670+export function renderFrame() {671+ const { VW, VH } = view;672+ typeQueue.length = 0;673+ decals.length = 0;674+ tints.length = 0;675+ blits.length = 0;676+ activeClip = null;677+678+ // Resolved before anything is drawn: the block sprites are built inside the679+ // screen painters and need the same style the frame-level threshold will use.680+ const combo = resolveCombo();681+ curShading = combo.shading;682+ // Stepped down to what the smallest piece on this board can carry. One683+ // ruling for the whole board, taken from its cell.684+ curRuling = fitRuling(combo.ruling, LY.cell || 16);685+ curTile = GROUND_TILES[combo.backdrop] || GROUND_TILES.lattice;686+ curDot32 = pack(combo.ground.dot);687+ curField32 = pack(combo.ground.field);688+ syncGroundCss(combo.ground.field);689+690+ sctx.setTransform(1, 0, 0, 1, 0, 0);691+ sctx.clearRect(0, 0, VW, VH); // what nobody paints over becomes ground692+693+ switch (App.screen) {694+ case 'boot': drawBoot(sctx); break;695+ case 'survey': drawSurvey(sctx); break;696+ case 'atlas': drawAtlas(sctx); break;697+ case 'card': drawCard(sctx); break;698+ case 'play': drawPlay(sctx); break;699+ case 'won': drawWon(sctx); break;700+ case 'stats': drawStats(sctx); break;701+ }702+703+ const src = new Uint32Array(sctx.getImageData(0, 0, VW, VH).data.buffer);704+ const dst = new Uint32Array(outImg.data.buffer);705+ shade(src, dst, VW, VH, curShading, INK32, PAPER32, curRuling);706+ applyTints(dst, VW, VH);707+ paintGround(dst, VW, VH, curTile, curDot32, curField32);708+709+ // Struck inversions are applied after the threshold. They never coincide710+ // with a drag, so the carried sprite does not have to follow them. The711+ // ground inverts on its own pair rather than on ink and paper -- left out of712+ // it, the backdrop sat perfectly still while the wipe crossed it, which read713+ // as the board flipping inside a window rather than the screen turning over.714+ if (fx.flash > 0 || fx.wipe >= 0) {715+ for (let y = 0; y < VH; y++) {716+ const row = y * VW;717+ for (let x = 0; x < VW; x++) {718+ const past = fx.wipe >= 0 && (fx.wipeAxis === 'x' ? x < fx.wipe : y < fx.wipe);719+ if (fx.flash > 0 || past) {720+ const i = row + x; // piece colour is left alone721+ const v = dst[i];722+ if (v === PAPER32) dst[i] = INK32;723+ else if (v === INK32) dst[i] = PAPER32;724+ else if (v === curField32) dst[i] = curDot32;725+ else if (v === curDot32) dst[i] = curField32;726+ }727+ }728+ }729+ }730+ octx.putImageData(outImg, 0, 0);731+ for (const b of blits) withClip(octx, b.clip, () => octx.drawImage(b.sprite.canvas, b.x, b.y));732+ drawType();733+}734+735+/* --- title ---------------------------------------------------------------- */736+/* One centred window holding the title, the specimen board and the prompt --737+ * an About box, essentially. Nothing is set on the desktop pattern: a subhead738+ * at that weight sitting straight on a dithered ground is exactly the fight739+ * this rule exists to avoid. */740+function drawBoot(c) {741+ const { VW, VH } = view;742+ const cx = VW / 2;743+ const win = LY.bootWin;744+ drawPanel(c, win);745+746+ label('KLOTSKI', cx, win.y + Math.round(win.h * 0.15),747+ { size: fitSize('KLOTSKI', VW * 0.14, 'Silk', VW * 0.02, win.w - 16),748+ track: VW * 0.02, align: 'center' });749+ label('SLIDING BLOCK PUZZLES', cx, win.y + Math.round(win.h * 0.225),750+ { size: fitSize('SLIDING BLOCK PUZZLES', VW * 0.05, 'SilkR', VW * 0.006, win.w - 16),751+ face: 'SilkR', track: VW * 0.006, align: 'center' });752+ c.fillStyle = grey(T.rule);753+ c.fillRect(win.x + 10, win.y + Math.round(win.h * 0.28), win.w - 20, 1);754+755+ const top = win.y + Math.round(win.h * 0.34);756+ const room = Math.round(win.h * 0.52);757+ const hero = FAMILIES.find(f => f.id === 'classic') || FAMILIES[0];758+ const cell = Math.max(8, Math.min(Math.floor((win.w - 52) / hero.w),759+ Math.floor(room / hero.h) - 2));760+ const tp = Math.max(3, Math.round(cell * 0.16));761+ const bx = Math.round(cx - cell * hero.w / 2), by = top + tp;762+ const tray = { x: bx - tp, y: by - tp, w: cell * hero.w + tp * 2, h: cell * hero.h + tp * 2 };763+ c.fillStyle = grey(T.rule); // the tray, ruled764+ c.fillRect(tray.x - 1, tray.y - 1, tray.w + 2, 1);765+ c.fillRect(tray.x - 1, tray.y + tray.h, tray.w + 2, 1);766+ c.fillRect(tray.x - 1, tray.y - 1, 1, tray.h + 2);767+ c.fillRect(tray.x + tray.w, tray.y - 1, 1, tray.h + 2);768+ // Drawn exactly as the board in play is -- same gap, same shadow, same mark769+ // on the goal block. It is the same object; it should not be a lookalike.770+ drawGridInto(c, hero.board, Uint8Array.from(hero.grid), bx, by, cell,771+ { gap: 2, drop: Math.max(2, Math.round(cell * 0.09)), device: true });772+773+}774+775+/* --- the sweep, made visible ---------------------------------------------- */776+function drawSurvey(c) {777+ const { VW, VH } = view;778+ const cx = VW / 2, sv = App.pending;779+780+ label('MAPPING THE STATE SPACE', cx, VH * 0.42,781+ { size: fitSize('MAPPING THE STATE SPACE', VW * 0.05, 'SilkR', VW * 0.006, VW * 0.84),782+ face: 'SilkR', track: VW * 0.006, align: 'center' });783+ label((sv ? sv.count : 0).toLocaleString('en-US'), cx, VH * 0.53,784+ { size: VW * 0.1, track: VW * 0.008, align: 'center' });785+ label(sv && sv.phase === 2 ? 'MEASURING DISTANCE TO GOAL' : 'CONFIGURATIONS REACHED',786+ cx, VH * 0.575,787+ { size: fitSize('MEASURING DISTANCE TO GOAL', VW * 0.038, 'SilkR', 0, VW * 0.84),788+ face: 'SilkR', align: 'center', alpha: 0.75 });789+ label(sv ? sv.fam.label : '', cx, VH * 0.645,790+ { size: VW * 0.05, track: VW * 0.01, align: 'center' });791+792+ const w = Math.round(VW * 0.5), x = Math.round(cx - w / 2), y = Math.round(VH * 0.70);793+ fill(c, x, y, w, 4, 255);794+ c.fillStyle = grey(T.rule);795+ c.fillRect(x - 1, y - 1, w + 2, 1); c.fillRect(x - 1, y + 4, w + 2, 1);796+ c.fillRect(x - 1, y - 1, 1, 6); c.fillRect(x + w, y - 1, 1, 6);797+ const done = sv ? (sv.phase === 2 ? 0.55 + 0.45 * Math.min(1, sv.dist.size / Math.max(1, sv.count))798+ : 0.35) : 0.1;799+ fill(c, x, y, Math.round(w * done / PERIOD) * PERIOD, 4, 0);800+}801+802+/* --- choosing a board -----------------------------------------------------803+ * A vertical list, one card per family, because a grid of three tiers no804+ * longer describes anything: a family is an open ladder, not three plates.805+ * The card shows where you are on that ladder and what the next board costs.806+ */807+export function atlasGeometry() {808+ const { VW, VH } = view;809+ const margin = 7, width = VW - 14;810+811+ // The board column is a fixed width and the text column starts at a fixed812+ // x, whatever shape the board is. Sizing the column to each board made every813+ // row start its name in a different place and at a different size, which is814+ // the one thing a list of like items must not do.815+ const boardCol = Math.round(width * 0.33);816+ const tx = margin + 8 + boardCol + 10;817+818+ // One size for every name, and the name gets the whole text column. Sharing819+ // that line with the stage number left so little room that the names came820+ // out smaller than the numbers beside them, which inverts the hierarchy of821+ // the card: the set is the subject, the stage is a detail about it.822+ const stageSize = snap(VW * 0.038);823+ const avail = margin + width - 8 - tx;824+ let nameSize = snap(VW * 0.052);825+ for (const f of FAMILIES) {826+ nameSize = Math.min(nameSize, fitSize(f.label, nameSize, 'Silk', VW * 0.006, avail));827+ }828+829+ return {830+ top: LY.barH + 8,831+ rowH: Math.max(52, Math.round(VH * 0.155)),832+ gap: 6, margin, width, boardCol, tx, nameSize, stageSize,833+ };834+}835+export function familyRowRect(G, fi) {836+ return {837+ x: G.margin,838+ y: Math.round(G.top + fi * (G.rowH + G.gap) + App.atlasScroll),839+ w: G.width, h: G.rowH,840+ };841+}842+export function atlasContentHeight() {843+ const G = atlasGeometry();844+ return FAMILIES.length * (G.rowH + G.gap) + 8;845+}846+847+function drawAtlas(c) {848+ const { VW, VH } = view;849+ const G = atlasGeometry();850+ setClip({ x: 0, y: LY.barH + 1, w: VW, h: VH - LY.barH - 1 });851+852+ FAMILIES.forEach((fam, fi) => {853+ const r = familyRowRect(G, fi);854+ if (r.y + r.h < LY.barH || r.y > VH) return; // off-screen, skip855+ drawPanel(c, r);856+857+ const rec = famStats(fam.id);858+ const sv = App.surveys[fam.id];859+ const pz = sv ? puzzleFor(fam, sv, rec.rung) : null;860+861+ // The board on the left: the rung you are about to play, or the family's862+ // own packing if it has not been swept yet.863+ const inner = r.h - 12;864+ const mini = Math.max(4, Math.floor(Math.min(G.boardCol / fam.w, inner / fam.h)));865+ // Centred in its fixed column, so boards of different shapes still line up.866+ const bx = r.x + 8 + Math.round((G.boardCol - mini * fam.w) / 2);867+ const by = r.y + Math.round((r.h - mini * fam.h) / 2);868+ const grid = pz ? gridOf(fam.board, pz.state) : Uint8Array.from(fam.grid);869+ // Drawn in the screen that rung will actually be played in, so the card870+ // previews the board *and* its look rather than the board alone -- stepped871+ // down to what a third-size cell can hold two of, which is the same rule872+ // the board itself is printed under, just against a smaller cell.873+ const look = lookFor(fam.id, rec.rung);874+ drawGridInto(c, fam.board, grid, bx, by, mini, {875+ gap: 1, drop: 1, shadow: false,876+ shading: look.shading, ruling: fitRuling(look.ruling, mini),877+ });878+879+ const tx = G.tx;880+ label(fam.label, tx, r.y + 16, { size: G.nameSize, track: VW * 0.006 });881+ label(`STAGE ${rec.rung}`, r.x + r.w - 8, r.y + 31,882+ { size: G.stageSize, face: 'SilkR', track: VW * 0.004, align: 'right', alpha: 0.85 });883+884+ if (pz) {885+ label(`${pz.metrics.L} STEPS`, tx, r.y + 31,886+ { size: snap(VW * 0.036), face: 'SilkR', alpha: 0.85 });887+ const gr = grade(pz.metrics.score);888+ c.fillStyle = grey(T.rule);889+ for (let m = 0; m < 5; m++) {890+ const mx = tx + m * 6, my = r.y + 36;891+ if (m < gr) c.fillRect(mx, my, 4, 4);892+ else { c.fillRect(mx, my, 4, 1); c.fillRect(mx, my + 3, 4, 1);893+ c.fillRect(mx, my, 1, 4); c.fillRect(mx + 3, my, 1, 4); }894+ }895+ // How much of this family's depth you have walked. It fills as the rungs896+ // climb and stops when the ladder reaches the family's ceiling -- which897+ // is the honest end of "deeper", not the end of the family.898+ const bw = Math.min(r.w - (tx - r.x) - 34, 72);899+ const by2 = r.y + r.h - 13;900+ c.fillStyle = grey(T.rule);901+ c.fillRect(tx, by2, bw, 1);902+ c.fillRect(tx, by2 - 3, 1, 4); c.fillRect(tx + bw - 1, by2 - 3, 1, 4);903+ const k = Math.max(0, Math.min(1, depthForRung(rec.rung, sv.ecc) / Math.max(1, sv.ecc)));904+ c.fillRect(tx + Math.round(k * (bw - 3)), by2 - 4, 3, 6);905+ label(atCeiling(rec.rung, sv.ecc) ? 'AT THE CEILING' : `TO ${sv.ecc}`,906+ tx + bw + 6, by2 + 3, { size: snap(VW * 0.03), face: 'SilkR', alpha: 0.6 });907+ } else {908+ // Set in the middle of the space the played rows fill, rather than909+ // tucked under the name leaving the rest of the card empty.910+ label('TAP TO BEGIN', tx, r.y + Math.round((r.h + 26) / 2) + 6,911+ { size: snap(VW * 0.04), face: 'SilkR', track: VW * 0.004, alpha: 0.65 });912+ }913+ });914+915+ setClip(null);916+ drawMenuBar(c);917+ // The dev-logo sits at the far left of the menu bar and the title follows it --918+ // which is where a Macintosh put the Apple, and the reason that position919+ // reads as a signature rather than as an icon someone left lying there.920+ //921+ // It costs the title about a sixth of the bar's width and nothing else: the922+ // list below is measured from barH, which has not moved, and PUZZLES at half923+ // the bar height still clears the buttons on the right with room to spare.924+ const logo = Math.round(LY.barH * 0.62);925+ devLogo(8 + logo / 2, LY.barH / 2, logo, css(logoInk(save.palette, dayOfYear())));926+ label('PUZZLES', 8 + logo + 6, LY.barH - Math.round(LY.barH * 0.28),927+ { size: LY.barH * 0.5, track: VW * 0.008 });928+}929+930+/* puzzleFor memoises per (family, rung), so calling it every frame costs a931+ * map lookup rather than forty walker rollouts. */932+933+/* --- the card between boards ---------------------------------------------- */934+function drawCard(c) {935+ const { VW, VH } = view;936+ const cx = VW / 2;937+ const pz = App.pz;938+ const win = { x: 14, y: Math.round(VH * 0.37), w: VW - 28, h: Math.round(VH * 0.25) };939+ drawPanel(c, win);940+941+ // Arrives in discrete steps, never a smooth fade: a continuous ramp would942+ // put in-between frames in the middle of a one-bit game.943+ const step = Math.min(4, Math.floor(Math.min(1, App.cardT / 280) * 5));944+ label(App.fam.label, cx, win.y + Math.round(win.h * 0.42), {945+ size: fitSize(App.fam.label, VW * 0.11, 'Silk', VW * 0.016, win.w - 18),946+ track: VW * 0.016, align: 'center', alpha: step / 4,947+ });948+949+ if (App.cardT > 240) {950+ label(`STAGE ${App.rung}`, cx, win.y + Math.round(win.h * 0.66),951+ { size: VW * 0.05, face: 'SilkR', track: VW * 0.008, align: 'center' });952+ label(`${pz.metrics.L} STEPS MINIMUM`, cx, win.y + Math.round(win.h * 0.87),953+ { size: VW * 0.042, face: 'SilkR', track: VW * 0.006, align: 'center', alpha: 0.75 });954+ }955+}956+957+/* --- the board ------------------------------------------------------------ */958+function pieceOffset(p) {959+ const d = App.drag;960+ if (d && d.piece === p && d.axis) return d.axis === 'x' ? { dx: d.off, dy: 0 } : { dx: 0, dy: d.off };961+ const a = App.anim;962+ if (a && a.piece === p) return a.axis === 'x' ? { dx: a.cur, dy: 0 } : { dx: 0, dy: a.cur };963+ return { dx: 0, dy: 0 };964+}965+function drawPlay(c) {966+ const { VW } = view;967+ const cell = LY.cell, B = LY.board, P = LY.plate;968+ const rise = App.riseT < 1 ? Math.round((1 - App.riseT) * 12 / 2) * 2 : 0;969+ const plate = { x: P.x, y: P.y - rise, w: P.w, h: P.h };970+971+ drawMenuBar(c);972+ drawPanel(c, LY.panel);973+ drawPlate(c, plate);974+975+ const bx = B.x, by = B.y - rise;976+ const tp = LY.trayPad;977+ const tray = { x: bx - tp, y: by - tp, w: B.w + tp * 2, h: B.h + tp * 2 };978+ fill(c, tray.x, tray.y, tray.w, tray.h, T.tray);979+ c.fillStyle = grey(T.rule); // the tray, ruled980+ c.fillRect(tray.x - 1, tray.y - 1, tray.w + 2, 1);981+ c.fillRect(tray.x - 1, tray.y + tray.h, tray.w + 2, 1);982+ c.fillRect(tray.x - 1, tray.y - 1, 1, tray.h + 2);983+ c.fillRect(tray.x + tray.w, tray.y - 1, 1, tray.h + 2);984+985+ // The aperture: the tray's rule is cut where the block is meant to leave.986+ const [gx] = App.fam.goal;987+ fill(c, bx + gx * cell + 2, tray.y + tray.h, cell * 2 - 4, 1, T.plate);988+ c.fillStyle = grey(T.rule);989+ for (let i = 0; i <= 2; i++) {990+ c.fillRect(Math.round(bx + (gx + i) * cell), tray.y + tray.h + 2, 1, 3);991+ }992+993+ const shake = App.shake ? Math.round(Math.sin(App.shake * 0.9) * 2) : 0;994+ const blockOpt = p => ({995+ gap: 2,996+ thick: !!(App.drag && App.drag.piece === p),997+ drop: Math.max(2, Math.round(cell * 0.09)),998+ device: p.c === 4,999+ });1000+1001+ for (const p of App.pieces) {1002+ const o = pieceOffset(p);1003+ const jx = App.shakePiece === p ? shake : 0;1004+ placeBlock(bx + p.x * cell + o.dx + jx, by + p.y * cell + o.dy,1005+ p.w * cell, p.h * cell, p.c, blockOpt(p));1006+ }1007+1008+ label(App.fam.label, VW / 2,1009+ plate.y + plate.h - Math.round(LY.padBottom * 0.34),1010+ { size: fitSize(App.fam.label, cell * 0.44, 'Silk', 1.5, P.w - 18),1011+ track: 1.5, align: 'center' });1012+1013+ drawGauge(c, LY.gaugeY - rise);1014+}1015+1016+/* How far you actually are, against how far you began -- and the reason a1017+ * board that looks tidy can read as further out than the mess you started1018+ * from. The marker travels left to right as the distance falls. */1019+function drawGauge(c, y) {1020+ if (save.hints === 'off') return;1021+ const d = App.sv.dist.get(App.state), d0 = App.sv.dist.get(App.startState);1022+ if (d == null || d0 == null) return;1023+1024+ /* What the marker is measured against.1025+ *1026+ * FULL scales to the distance you started at, which together with the TO GOAL1027+ * readout beside it tells you exactly where you stand.1028+ *1029+ * BEST scales to the furthest out you have ever been on this board instead.1030+ * The number is gone, so the gauge is all there is, and anchoring it to your1031+ * own worst point makes it a reading of your progress rather than a1032+ * disguised step count -- you cannot work backwards from it to the solver's1033+ * answer. It is the same choice the record screen's chart already makes, and1034+ * for the same reason: going backwards is the interesting part, and a scale1035+ * that cannot show it hides it.1036+ */1037+ const ref = save.hints === 'best' ? Math.max(d0, ...App.trace) : d0;1038+ const { VW } = view;1039+ const w = Math.round(VW * 0.6), x = Math.round((VW - w) / 2);1040+ c.fillStyle = grey(T.rule);1041+ c.fillRect(x, y, w, 1);1042+ for (let i = 0; i <= 4; i++) c.fillRect(x + Math.round(i * w / 4), y - 3, 1, 4);1043+ const k = Math.max(0, Math.min(1, 1 - d / Math.max(1, ref)));1044+ c.fillRect(x + Math.round(k * w) - 2, y - 7, 5, 12);1045+}1046+1047+/* --- the record ----------------------------------------------------------- */1048+function drawWon(c) {1049+ const { VW } = view;1050+ const P = LY.plate;1051+ const plate = { x: P.x, y: P.y, w: P.w, h: P.h };1052+ drawMenuBar(c);1053+ drawPanel(c, LY.panel);1054+ drawPlate(c, plate);1055+ const cx = VW / 2;1056+1057+ // Stacked down the centre, and spaced from the trophy's actual height1058+ // rather than by fractions of the plate -- on a short board the fractions1059+ // closed up until the cup was resting on the S of SOLVED.1060+ const cupSize = LY.cell * 0.62;1061+ const cupY = plate.y + Math.round(P.h * 0.085);1062+ trophy(cx, cupY, cupSize);1063+ const solvedY = Math.round(cupY + cupSize * 0.55 + snap(VW * 0.045) + 6);1064+ label('SOLVED', cx, solvedY,1065+ { size: VW * 0.045, face: 'SilkR', track: VW * 0.018, align: 'center' });1066+ label(App.fam.label, cx, solvedY + Math.round(VW * 0.085),1067+ { size: VW * 0.075, track: VW * 0.01, align: 'center' });1068+1069+ // Distance to goal, falling as the steps accumulate. The dotted rule is the1070+ // line a perfect solver draws -- straight down. The ridge is what actually1071+ // happened, and every place it climbs is a stretch where the board took you1072+ // further from the answer than you already were.1073+ const gx = plate.x + Math.round(P.w * 0.13), gw = Math.round(P.w * 0.74);1074+ const gy = solvedY + Math.round(VW * 0.13), gh = Math.round(P.h * 0.34);1075+ const tr = App.trace, n = Math.max(1, tr.length);1076+ const d0 = Math.max(1, tr[0] || 1);1077+ // Scaled to the furthest you ever got, not to where you started. Going1078+ // *backwards* is the whole point of the chart -- a board can leave you1079+ // further out than it found you -- and scaling to the start meant those1080+ // bars ran off the top of the panel and through the title.1081+ const peak = Math.max(d0, ...tr, 1);1082+ const bw = gw / n;1083+ const topOf = v => gy + gh - Math.round(gh * (v / peak));1084+1085+ const ref = topOf(d0);1086+ goldLine(gx, ref, gx + gw, gy + gh);1087+1088+ c.fillStyle = grey(T.rule);1089+ for (let i = 0; i < n; i++) {1090+ const h = Math.round(gh * (tr[i] / peak));1091+ const x0 = gx + Math.round(i * bw), x1 = gx + Math.round((i + 1) * bw);1092+ c.fillRect(x0, gy + gh - h, Math.max(1, x1 - x0 - (bw > 3 ? 1 : 0)), Math.max(1, h));1093+ }1094+ c.fillRect(gx, gy + gh + 1, gw, 1);1095+1096+ // Sized off the plate, not off the cell: a three-wide board gets big cells,1097+ // and a caption scaled to them runs straight out through the frame.1098+ label('DISTANCE TO GOAL', cx, plate.y + plate.h - Math.round(LY.padBottom * 0.34),1099+ { size: fitSize('DISTANCE TO GOAL', LY.cell * 0.36, 'SilkR', 1, P.w - 18),1100+ face: 'SilkR', track: 1, align: 'center' });1101+1102+}1103+1104+/* --- the record of everything --------------------------------------------1105+ * Built from the save, not from the session: totals are counted as they1106+ * happen and the log is a capped ring buffer, so this reads the same after a1107+ * restart as it did before one.1108+ */1109+function fmtTime(ms) {1110+ if (ms < 60000) return `${Math.round(ms / 1000)}S`;1111+ const m = Math.round(ms / 60000);1112+ if (m < 60) return `${m}M`;1113+ return `${Math.floor(m / 60)}H ${String(m % 60).padStart(2, '0')}M`;1114+}1115+1116+function drawStats(c) {1117+ const { VW, VH } = view;1118+ const t = save.totals, cx = VW / 2;1119+ const prog = levelProgress(t.xp || 0);1120+ const M = 7, W = VW - 14;1121+ let y = LY.barH + 8;1122+1123+ // Level.1124+ const lvlH = Math.round(VH * 0.13);1125+ drawPanel(c, { x: M, y, w: W, h: lvlH });1126+ label('LEVEL', M + 10, y + 16, { size: snap(VW * 0.036), face: 'SilkR', track: VW * 0.01, alpha: 0.7 });1127+ label(String(prog.level), M + 10, y + Math.round(lvlH * 0.72), { size: snap(VW * 0.11), track: VW * 0.006 });1128+ label(`${t.xp || 0} XP`, M + W - 10, y + 16,1129+ { size: snap(VW * 0.036), face: 'SilkR', align: 'right', alpha: 0.7 });1130+1131+ const bx = M + Math.round(W * 0.38), bw = Math.round(W * 0.52);1132+ const by = y + Math.round(lvlH * 0.58);1133+ c.fillStyle = grey(T.rule);1134+ c.fillRect(bx - 1, by - 1, bw + 2, 1); c.fillRect(bx - 1, by + 6, bw + 2, 1);1135+ c.fillRect(bx - 1, by - 1, 1, 8); c.fillRect(bx + bw, by - 1, 1, 8);1136+ const k = Math.max(0, Math.min(1, prog.into / prog.span));1137+ fill(c, bx, by, Math.max(0, Math.round(bw * k)), 6, 0);1138+ label(`${prog.into} / ${prog.span} TO ${prog.level + 1}`, bx + bw, by + 16,1139+ { size: snap(VW * 0.03), face: 'SilkR', align: 'right', alpha: 0.6 });1140+ y += lvlH + 6;1141+1142+ // Lifetime.1143+ const lifeH = Math.round(VH * 0.105);1144+ drawPanel(c, { x: M, y, w: W, h: lifeH });1145+ const eff = t.steps ? Math.round((t.optimal / t.steps) * 100) : 0;1146+ const cells = [1147+ [String(t.solved || 0), 'SOLVED'],1148+ [`${eff}%`, 'EFFICIENCY'],1149+ [fmtTime(t.ms || 0), 'PLAYED'],1150+ [String(save.days.streak || 0), 'DAY RUN'],1151+ ];1152+ cells.forEach(([v, k2], i) => {1153+ const x = M + Math.round(W * (i + 0.5) / cells.length);1154+ label(v, x, y + Math.round(lifeH * 0.52), { size: snap(VW * 0.048), align: 'center' });1155+ label(k2, x, y + Math.round(lifeH * 0.82),1156+ { size: snap(VW * 0.028), face: 'SilkR', align: 'center', alpha: 0.65 });1157+ });1158+ y += lifeH + 6;1159+1160+ // Per family.1161+ const rowH = 11;1162+ const famH = 16 + FAMILIES.length * rowH + 6;1163+ drawPanel(c, { x: M, y, w: W, h: famH });1164+ const col = [M + 8, M + Math.round(W * 0.50), M + Math.round(W * 0.68), M + W - 8];1165+ const head = snap(VW * 0.028);1166+ label('SET', col[0], y + 12, { size: head, face: 'SilkR', alpha: 0.55 });1167+ label('STAGE', col[1], y + 12, { size: head, face: 'SilkR', align: 'right', alpha: 0.55 });1168+ label('DONE', col[2], y + 12, { size: head, face: 'SilkR', align: 'right', alpha: 0.55 });1169+ label('DEEPEST', col[3], y + 12, { size: head, face: 'SilkR', align: 'right', alpha: 0.55 });1170+ FAMILIES.forEach((fam, i) => {1171+ const rec = famStats(fam.id);1172+ const ry = y + 16 + (i + 1) * rowH - 2;1173+ const dim = rec.solved ? 1 : 0.45;1174+ label(fam.label, col[0], ry, { size: snap(VW * 0.032), alpha: dim });1175+ label(String(rec.rung), col[1], ry, { size: snap(VW * 0.032), face: 'SilkR', align: 'right', alpha: dim });1176+ label(String(rec.solved), col[2], ry, { size: snap(VW * 0.032), face: 'SilkR', align: 'right', alpha: dim });1177+ label(String(rec.deepest || 0), col[3], ry, { size: snap(VW * 0.032), face: 'SilkR', align: 'right', alpha: dim });1178+ });1179+ y += famH + 6;1180+1181+ // Recent solves, deepest first-glance shape of how the ladder is going.1182+ const log = save.log || [];1183+ // The chart takes whatever the panels above did not: it is the most1184+ // interesting thing on the screen and was previously a strip with a third1185+ // of the page empty beneath it.1186+ const chartH = Math.max(38, VH - y - 30);1187+ drawPanel(c, { x: M, y, w: W, h: chartH });1188+ label('RECENT DEPTH', M + 8, y + 12, { size: snap(VW * 0.03), face: 'SilkR', alpha: 0.6 });1189+ if (log.length) {1190+ const gx = M + 8, gw = W - 16;1191+ const gy = y + 16, gh = chartH - 24;1192+ const shown = log.slice(-40);1193+ const peak = Math.max(...shown.map(e => e.L), 1);1194+ // Bars keep their own width instead of stretching, so three solves read1195+ // as three solves rather than as one filled rectangle.1196+ const bwid = Math.min(gw / shown.length, 7);1197+ c.fillStyle = grey(T.rule);1198+ shown.forEach((e, i) => {1199+ const h = Math.max(1, Math.round(gh * (e.L / peak)));1200+ const x0 = gx + Math.round(i * bwid);1201+ const x1 = gx + Math.round((i + 1) * bwid);1202+ c.fillRect(x0, gy + gh - h, Math.max(1, x1 - x0 - (bwid > 3 ? 1 : 0)), h);1203+ });1204+ c.fillRect(gx, gy + gh + 1, gw, 1);1205+ label(String(peak), M + W - 8, y + 12,1206+ { size: snap(VW * 0.03), face: 'SilkR', align: 'right', alpha: 0.6 });1207+ } else {1208+ label('NOTHING SOLVED YET', cx, y + Math.round(chartH * 0.62),1209+ { size: snap(VW * 0.034), face: 'SilkR', align: 'center', alpha: 0.5 });1210+ }1211+ y += chartH + 6;1212+1213+ // The one line that says why this puzzle is worth the trouble -- in a panel1214+ // of its own, because nothing small is ever set on the desktop pattern.1215+ const swept = Object.values(App.surveys);1216+ const states = swept.reduce((n, sv) => n + sv.count, 0);1217+ const footH = 18;1218+ if (y + footH < VH - 4) {1219+ drawPanel(c, { x: M, y, w: W, h: footH });1220+ label(swept.length1221+ ? `${states.toLocaleString('en-US')} CONFIGURATIONS MAPPED`1222+ : `${FAMILIES.length} PIECE SETS, NONE MAPPED YET`,1223+ cx, y + 12, { size: snap(VW * 0.03), face: 'SilkR', align: 'center', alpha: 0.6 });1224+ }1225+1226+ drawMenuBar(c);1227+ // Centred, because unlike the atlas this screen has a BACK button sitting in1228+ // the left of the bar.1229+ label('RECORD', cx, LY.barH - Math.round(LY.barH * 0.28),1230+ { size: LY.barH * 0.5, track: VW * 0.008, align: 'center' });1231+}@@ -0,0 +1,380 @@
1+/* Shading: how a tone becomes marks.2+ *3+ * This started life as dither.js and was the poorer for it. Dithering is one4+ * answer to the question -- how do you print a grey with one ink -- and naming5+ * the module after it quietly ruled out every other answer. Hatching is not a6+ * dither. A comic's two-plate overprint is not a dither. Both are shading, and7+ * so is Atkinson, so shading is what this is.8+ *9+ * The house style is still Bill Atkinson's, the one QuickDraw used on the Lisa10+ * and the Macintosh and the reason images from 1984-88 look the way they do. It11+ * is error diffusion: each pixel is forced to black or white, and the12+ * difference between what it should have been and what it became is pushed onto13+ * pixels not yet drawn, so the error is paid off by its neighbours rather than14+ * swallowed. What makes it *Atkinson* rather than Floyd-Steinberg is that it15+ * spreads only six eighths of the error and throws the other quarter away.16+ * Discarding error is, strictly, a bug. It is also the entire flavour:17+ * highlights run clean to white and shadows to solid black instead of being18+ * speckled, so the midtones carry all the texture and the picture reads with19+ * far more snap. Crisp rather than grainy.20+ *21+ * kernel, each cell receiving 1/8 of the error:22+ *23+ * (x) * *24+ * * * *25+ * *26+ *27+ * Everything else is a *screen*: a small table of thresholds, tiled across the28+ * picture, that says in what order pixels give way to ink. That one idea covers29+ * far more than it looks like it should, because the difference between a30+ * dither, a halftone, a hatch and a flat fill turns out to be nothing but which31+ * numbers are in the table and how they are arranged:32+ *33+ * dispersed thresholds as far from their neighbours as possible; ink34+ * scatters, which is a dither35+ * clustered thresholds ascending outward from centres; ink grows into round36+ * blobs, which is a halftone37+ * ruled thresholds varying along one axis only; ink becomes a band38+ * whose width is the tone, which is a hatch39+ * flat one threshold; tone stops existing and only the reprinted40+ * colour survives41+ *42+ * A screen has a second property independent of its shape: its *ruling*, how43+ * many pixels one cell of the table covers. The same table read at one pixel a44+ * cell or at six resolves identical tone and changes only the grain, which is45+ * most of the difference between newsprint and a magazine.46+ *47+ * ---------------------------------------------------------------------------48+ * On area, which is the part that took a bug to understand.49+ *50+ * A block is shaded alone in its own sprite, so its fill samples whatever51+ * window of the tiled screen happens to lie under it. That is fine when the52+ * window is many periods across and quietly wrong when it is not: the realised53+ * coverage becomes a property of the slice rather than of the tone, and at the54+ * extreme a 1x1 piece under a coarse screen came out entirely bare paper and55+ * disappeared off the board. Measured, four of the five failures were the ruled56+ * screen, which is exactly what you would predict -- it varies along one axis,57+ * so a block's height alone decides how much of the screen it ever sees, while58+ * a two-axis screen gets a representative sample from the same area.59+ *60+ * The fix is not to scale the screen to the block. A press does not print the61+ * small object at a finer ruling than the big one, and doing so here made two62+ * pieces of one board look like two different printing jobs.63+ *64+ * Instead the coverage is made true by construction. Pixels of a given tone are65+ * grouped, the group is cut at whichever threshold lands closest to the66+ * intended proportion of ink, and the cut always falls *between* threshold67+ * levels rather than through one -- so a band stays a band and a dot stays68+ * round, and a tone that asked for some ink always gets some. The screen's69+ * shape is untouched; only how much of it is inked is decided by proportion70+ * rather than by accident.71+ */72+73+/* Thresholds over 0..255, compared against the intended coverage of paper.74+ * Raw tables are 0..n-1 and land inside the range without reaching either end;75+ * every screen therefore agrees at 0 and 255, which matters because the scene76+ * outside the block sprites is composed entirely at those two values. */77+const spread = (raw, n) => Uint8Array.from(raw, v => Math.round((v + 0.5) / n * 255));78+79+/* Dispersed. The classic ordered screen. */80+export const BAYER = spread([81+ 0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26,82+ 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22,83+ 3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25,84+ 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21,85+], 64);86+87+/* Clustered. Thresholds ascend outward from two centres, so as tone darkens the88+ * on-pixels grow into round blobs instead of scattering -- which is what a89+ * halftone screen is, and why this reads as printed rather than as a computer. */90+const CLUSTER = spread([91+ 24, 10, 12, 26, 35, 47, 49, 37,92+ 8, 0, 2, 14, 45, 59, 61, 51,93+ 22, 6, 4, 16, 43, 57, 63, 53,94+ 30, 20, 18, 28, 33, 41, 55, 39,95+ 34, 46, 48, 38, 25, 11, 13, 27,96+ 44, 58, 60, 50, 9, 1, 3, 15,97+ 42, 56, 62, 52, 23, 7, 5, 17,98+ 32, 40, 54, 36, 31, 21, 19, 29,99+], 64);100+101+/* The same clustered screen turned about 30 degrees, which is what the second102+ * plate of a colour job is. Only used as an overprint (see `comic`), never on103+ * its own: rotating a screen changes nothing you can see until there is another104+ * screen for it to be out of step with. */105+const CLUSTER_ROT = (() => {106+ const m = new Uint8Array(64);107+ for (let y = 0; y < 8; y++) {108+ for (let x = 0; x < 8; x++) {109+ // A shear either way approximates a rotation on a grid this small, and110+ // unlike a real rotation it still tiles.111+ m[(y << 3) | x] = CLUSTER[(((y + ((x * 3) >> 3)) & 7) << 3) | ((x + ((y * 5) >> 3)) & 7)];112+ }113+ }114+ return m;115+})();116+117+/* One axis only, so tone becomes the width of a rule.118+ *119+ * The eight levels ascend outward from the middle rather than running top to120+ * bottom, so darkening widens one band symmetrically instead of adding a stray121+ * row at the edge -- and, just as importantly, keeps the band away from the122+ * tile seam, where a small block could miss it entirely. */123+const RULED_ORDER = [6, 4, 2, 0, 1, 3, 5, 7];124+const band = f => {125+ const m = new Uint8Array(64);126+ for (let y = 0; y < 8; y++) {127+ for (let x = 0; x < 8; x++) m[(y << 3) | x] = Math.round((f(x, y) + 0.5) / 8 * 255);128+ }129+ return m;130+};131+const LINES = band((x, y) => RULED_ORDER[y]);132+133+/* The same rule laid at 45 degrees. Not a dither at all -- it is hatching, the134+ * thing a pen does, and it belongs here for exactly the reason this file is no135+ * longer called dither.js. */136+const DIAGONAL = band((x, y) => RULED_ORDER[(x + y) & 7]);137+138+/* Hatched both ways at once. The two rules are unioned rather than averaged --139+ * a pixel is inked if either stroke would ink it -- so tone thickens the mesh140+ * instead of fading it, which is how a crosshatch actually darkens. */141+const CROSSHATCH = band((x, y) =>142+ Math.max(RULED_ORDER[(x + y) & 7], RULED_ORDER[(x - y) & 7]));143+144+/* One threshold, set above every tone a block uses and below bare paper. Every145+ * block comes out solid and is carried entirely by its reprinted colour --146+ * which the piece colours were always able to do on their own, since upright147+ * and recumbent already share a tone and are told apart by hue. */148+const FLAT = new Uint8Array(64).fill(224);149+150+/* ------------------------------------------------------------------ ruling151+ *152+ * How many pixels one cell of the screen covers. Not a setting: nothing here is153+ * a preference, it is the press this board happened to be printed on, and a154+ * list of five is only legible because nobody has to read it.155+ *156+ * It can be this wide because coverage is now true by construction. Under the157+ * old arrangement a coarse screen on a small block silently lost the block, so158+ * the ruling had to be clamped by the size of the thing it was printed on; with159+ * the area guaranteed, a coarse screen on a small block is simply a coarse160+ * screen on a small block, which is what it looks like in print too. */161+export const RULINGS = {162+ fine: { label: 'FINE', cell: 1 },163+ medium: { label: 'MEDIUM', cell: 2 },164+ coarse: { label: 'COARSE', cell: 3 },165+ poster: { label: 'POSTER', cell: 4 },166+ billboard: { label: 'BILLBOARD', cell: 6 },167+};168+export const RULING_NAMES = Object.keys(RULINGS);169+170+/* The registry.171+ *172+ * `ruled` says whether a ruling means anything: diffusion has no cell to make173+ * bigger, its grain *is* the pixel grid, and a flat threshold has no grain at174+ * all. Saying so is more honest than inventing a frequency for them and175+ * shipping five identical looks.176+ *177+ * `overprint` is a second screen the colour stage uses to split this pattern's178+ * ink between two plates. */179+export const PATTERNS = {180+ atkinson: { label: 'ATKINSON', diffuse: true, ruled: false },181+ ordered: { label: 'ORDERED', m: BAYER, ruled: true },182+ benday: { label: 'BEN-DAY', m: CLUSTER, ruled: true },183+ lines: { label: 'LINES', m: LINES, ruled: true },184+ diagonal: { label: 'DIAGONAL', m: DIAGONAL, ruled: true },185+ crosshatch: { label: 'CROSSHATCH', m: CROSSHATCH, ruled: true },186+ comic: { label: 'COMIC', m: CLUSTER, ruled: true, overprint: CLUSTER_ROT },187+ solid: { label: 'SOLID', m: FLAT, ruled: false },188+};189+export const PATTERN_NAMES = Object.keys(PATTERNS);190+191+/** The ruling a pattern will actually use: 'fine' for those that take none. */192+export function effectiveRuling(pattern, ruling) {193+ const p = PATTERNS[pattern];194+ return p && p.ruled && RULINGS[ruling] ? ruling : 'fine';195+}196+197+/* The one thing true coverage cannot rescue.198+ *199+ * Making the inked area proportional fixes an unrepresentative slice of the200+ * screen, because there is still a slice to choose within. It cannot do201+ * anything at all when the block lands inside a *single* cell: one cell has one202+ * threshold, one threshold has two outcomes, and neither of them is a quarter.203+ * Measured, that is exactly where the remaining failures were -- a coarse screen204+ * on a 6px preview piece came out solid, which is the same disappearance as205+ * before wearing the other colour.206+ *207+ * So a board steps its ruling down until the smallest piece on it spans at208+ * least two cells. Two is the whole requirement: with two thresholds present209+ * there is always a cut that is neither all nor nothing, and proportional210+ * coverage takes care of the rest.211+ *212+ * Taken from the board's cell rather than from each sprite, so every piece is213+ * printed at one ruling. Choosing per piece would have let a 1x1 and the 2x2214+ * beside it come out at different frequencies, which is the one thing a press215+ * genuinely never does.216+ */217+export function fitRuling(ruling, cellPx) {218+ const fill = cellPx - 6; // a 1x1's fill, less gap and outline219+ let out = ruling;220+ while (RULINGS[out] && RULINGS[out].cell * 2 > fill) {221+ const i = RULING_NAMES.indexOf(out);222+ if (i <= 0) return 'fine';223+ out = RULING_NAMES[i - 1];224+ }225+ return out;226+}227+228+/* Screens are tiled at their ruling once and kept. A cell of 3 or 6 does not229+ * divide 8, so the scaled tile is 8*cell square and indexed by modulo rather230+ * than by a mask -- which is why it is worth building once instead of computing231+ * two divisions per pixel. */232+const tiles = new Map();233+export function screenOf(pattern, ruling, which = 'm') {234+ const p = PATTERNS[pattern];235+ const base = p && p[which];236+ if (!base) return null;237+ const cell = (p.ruled && RULINGS[ruling] ? RULINGS[ruling] : RULINGS.fine).cell;238+ const key = `${pattern}|${which}|${cell}`;239+ const hit = tiles.get(key);240+ if (hit) return hit;241+ const period = 8 * cell;242+ const t = { period, m: new Uint8Array(period * period) };243+ for (let y = 0; y < period; y++) {244+ for (let x = 0; x < period; x++) {245+ t.m[y * period + x] = base[((((y / cell) | 0) & 7) << 3) | (((x / cell) | 0) & 7)];246+ }247+ }248+ tiles.set(key, t);249+ return t;250+}251+252+/* ------------------------------------------------------------------- area253+ *254+ * Choose where to cut a group of same-toned pixels so the inked proportion is255+ * as close to the tone as the screen's own levels allow.256+ *257+ * Ink is the *high* thresholds: a pixel is inked when `tone <= threshold`. So258+ * the levels present are walked from the top down, accumulating, and the cut is259+ * placed at whichever boundary lands nearest the target. Cuts fall between260+ * levels and never through one, which is what keeps a band a band and a dot261+ * round; and a tone that asked for any ink at all is guaranteed at least the262+ * topmost level, which is what makes a piece impossible to lose.263+ *264+ * Returns the lowest threshold that should be inked.265+ */266+function cutFor(levels, counts, total, targetInk) {267+ let best = 256, bestErr = Infinity, run = 0;268+ for (let i = levels.length - 1; i >= 0; i--) {269+ run += counts[i];270+ const err = Math.abs(run / total - targetInk);271+ if (err < bestErr) { bestErr = err; best = levels[i]; }272+ }273+ // Nothing inked would lose the shape entirely; everything inked would lose274+ // the tone. Both are worse than being one level out.275+ if (targetInk > 0 && best === 256) best = levels[levels.length - 1];276+ if (targetInk < 1 && best === levels[0] && levels.length > 1) best = levels[1];277+ return best;278+}279+280+let work = new Float32Array(0);281+function workBuffer(n) {282+ if (work.length < n) work = new Float32Array(n);283+ return work;284+}285+286+/* Reused across calls so a frame allocates nothing. 256 tones in, one list of287+ * pixel indices out. */288+const groups = new Map();289+290+/**291+ * Shade a greyscale source to two colours.292+ *293+ * @param src Uint32Array view of RGBA source; the low byte is the grey294+ * value, which *is* the intended coverage of paper (authored295+ * linearly, so 128 really means half the dots).296+ * @param dst Uint32Array view of the destination RGBA.297+ * @param pattern a key of PATTERNS.298+ * @param ruling a key of RULINGS; ignored by patterns that take none.299+ *300+ * A source pixel with zero alpha is one nothing was drawn over. It is left as301+ * zero in the destination and takes no part in diffusion -- it neither receives302+ * error nor pays any out, because it is not part of the picture. Ground is303+ * painted into those holes afterwards (see ground.js). Packed colours always304+ * carry full alpha, so zero can never collide with a real one.305+ */306+export function shade(src, dst, w, h, pattern, ink32, paper32, ruling) {307+ const p = PATTERNS[pattern] || PATTERNS.atkinson;308+ if (p.diffuse) return diffuse(src, dst, w, h, ink32, paper32);309+310+ const screen = screenOf(pattern, ruling);311+ const period = screen.period, m = screen.m;312+ const n = w * h;313+314+ // Bucket by tone. The two extremes are the overwhelming majority of any frame315+ // -- the whole scene outside the block sprites is composed at 0 and 255 -- and316+ // they resolve without ever touching the screen, so they never reach the area317+ // pass at all.318+ for (const g of groups.values()) g.length = 0;319+ for (let i = 0; i < n; i++) {320+ if ((src[i] >>> 24) === 0) { dst[i] = 0; continue; }321+ const v = src[i] & 0xFF;322+ if (v === 0) { dst[i] = ink32; continue; }323+ if (v === 255) { dst[i] = paper32; continue; }324+ let g = groups.get(v);325+ if (!g) groups.set(v, g = []);326+ g.push(i);327+ }328+329+ for (const [tone, idx] of groups) {330+ if (!idx.length) continue;331+ const total = idx.length;332+333+ // Count the pixels sitting at each threshold this window actually covers.334+ const seen = new Map();335+ for (let k = 0; k < total; k++) {336+ const i = idx[k];337+ const t = m[(((i / w) | 0) % period) * period + ((i % w) % period)];338+ seen.set(t, (seen.get(t) || 0) + 1);339+ }340+ const levels = [...seen.keys()].sort((a, b) => a - b);341+ const counts = levels.map(l => seen.get(l));342+ const cut = cutFor(levels, counts, total, 1 - tone / 255);343+344+ for (let k = 0; k < total; k++) {345+ const i = idx[k];346+ const t = m[(((i / w) | 0) % period) * period + ((i % w) % period)];347+ dst[i] = t >= cut ? ink32 : paper32;348+ }349+ }350+}351+352+function diffuse(src, dst, w, h, ink32, paper32) {353+ const n = w * h;354+ const buf = workBuffer(n);355+ for (let i = 0; i < n; i++) buf[i] = src[i] & 0xFF;356+357+ for (let y = 0; y < h; y++) {358+ const row = y * w;359+ const hasNext = y + 1 < h, hasNext2 = y + 2 < h;360+ for (let x = 0; x < w; x++) {361+ const i = row + x;362+ if ((src[i] >>> 24) === 0) { dst[i] = 0; continue; }363+ const old = buf[i];364+ const on = old >= 128;365+ dst[i] = on ? paper32 : ink32;366+ // Only six eighths of the error is ever paid out. The missing quarter367+ // is the Atkinson signature.368+ const e = (old - (on ? 255 : 0)) / 8;369+ if (e === 0) continue;370+ if (x + 1 < w) buf[i + 1] += e;371+ if (x + 2 < w) buf[i + 2] += e;372+ if (hasNext) {373+ if (x > 0) buf[i + w - 1] += e;374+ buf[i + w] += e;375+ if (x + 1 < w) buf[i + w + 1] += e;376+ }377+ if (hasNext2) buf[i + 2 * w] += e;378+ }379+ }380+}@@ -0,0 +1,82 @@
1+/* Notes, not foley.2+ *3+ * Monument Valley's rule: interaction sounds should be musical, because music4+ * is the most abstract sound there is and abstraction is what a geometric5+ * puzzle wants. Every cell a block travels sounds one degree of a minor6+ * pentatonic, so a long slide plays a rising figure and solving a board plays7+ * a line rather than a rattle. Synthesised rather than sampled -- it keeps the8+ * install tiny, and a synthetic timbre is the audio equivalent of 1-bit.9+ */10+11+import { save } from './store.js';12+13+const PENT = [0, 3, 5, 7, 10, 12, 15, 17, 19, 22]; // A minor pentatonic degrees14+const ROOT = 196; // G3, low enough to stay soft15+16+let ctx = null;17+18+export const Sound = {19+ /* Audio starts suspended until a real gesture; the title screen's tap20+ * exists partly to pay this debt. */21+ unlock() {22+ if (ctx) { if (ctx.state === 'suspended') ctx.resume(); return; }23+ const AC = window.AudioContext || window.webkitAudioContext;24+ if (!AC) return;25+ ctx = new AC();26+ if (ctx.state === 'suspended') ctx.resume();27+ const b = ctx.createBuffer(1, 1, 22050), s = ctx.createBufferSource();28+ s.buffer = b; s.connect(ctx.destination); s.start(0);29+ },30+31+ tone(freq, dur, vol, type = 'triangle', delay = 0) {32+ if (!ctx || !save.sound) return;33+ const t0 = ctx.currentTime + delay;34+ const g = ctx.createGain();35+ const o = ctx.createOscillator();36+ const d = ctx.createOscillator(); // a hair of detune: wood, not glass37+ const dg = ctx.createGain();38+ o.type = d.type = type;39+ o.frequency.value = freq;40+ d.frequency.value = freq * 1.006;41+ dg.gain.value = 0.38;42+ g.gain.setValueAtTime(0.0001, t0);43+ g.gain.exponentialRampToValueAtTime(vol, t0 + 0.008); // 8ms attack transient44+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);45+ o.connect(g); d.connect(dg); dg.connect(g); g.connect(ctx.destination);46+ o.start(t0); d.start(t0); o.stop(t0 + dur + 0.02); d.stop(t0 + dur + 0.02);47+ },48+49+ step(i, delay = 0) {50+ const semi = PENT[Math.min(PENT.length - 1, i)];51+ this.tone(ROOT * Math.pow(2, semi / 12), 0.26, 0.15, 'triangle', delay);52+ },53+54+ /** A blocked move: damped, pitchless, close to silence. */55+ thud() {56+ if (!ctx || !save.sound) return;57+ const t0 = ctx.currentTime;58+ const o = ctx.createOscillator(), g = ctx.createGain();59+ o.type = 'sine';60+ o.frequency.setValueAtTime(150, t0);61+ o.frequency.exponentialRampToValueAtTime(58, t0 + 0.09);62+ g.gain.setValueAtTime(0.15, t0);63+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.12);64+ o.connect(g); g.connect(ctx.destination);65+ o.start(t0); o.stop(t0 + 0.14);66+ },67+68+ /** The figure resolves to the tonic, with a bell over it. */69+ solve() {70+ [0, 3, 5, 7, 12].forEach((s, i) =>71+ this.tone(ROOT * Math.pow(2, s / 12), 0.5, 0.12, 'triangle', i * 0.085));72+ this.tone(ROOT * 4, 1.6, 0.06, 'sine', 0.42);73+ },74+75+ page() { this.tone(330, 0.09, 0.045, 'sine'); },76+};77+78+/** Android only; iOS has no vibration API, so this is simply nothing there. */79+export function buzz(pattern) {80+ if (!save.haptics || !navigator.vibrate) return;81+ try { navigator.vibrate(pattern); } catch (e) { /* ignore */ }82+}@@ -0,0 +1,40 @@
1+/* Shared mutable state.2+ *3+ * Kept in one plain object with no imports of its own, so the renderer and the4+ * game loop can both reach it without a cycle between them.5+ */6+7+export const App = {8+ screen: 'boot', // boot · survey · atlas · card · play · won · stats9+10+ surveys: {}, // familyId -> completed sweep11+ pending: null, // sweep currently stepping, if any12+13+ // the family and rung in play14+ fam: null,15+ board: null,16+ rung: 1,17+ sv: null,18+ pz: null, // { state, metrics, depth } from progress.puzzleFor19+20+ // the board itself21+ grid: null,22+ pieces: [],23+ state: 0,24+ startState: 0,25+ steps: 0,26+ history: [],27+ trace: [], // distance to goal after each step -- the descent28+ startedAt: 0, // timed silently; never shown while playing29+30+ // transient presentation31+ drag: null,32+ anim: null,33+ shake: 0,34+ shakePiece: null,35+ cardT: 0,36+ riseT: 1,37+ atlasScroll: 0,38+ lastGain: null, // { xp, firstClear, level, levelled } for the record screen39+ installPrompt: null,40+};@@ -0,0 +1,167 @@
1+/* Progression, kept in a versioned envelope.2+ *3+ * localStorage is enough for a mockup, but the shape is the one that survives:4+ * a schema version to migrate against, and a monotonic counter rather than a5+ * wall clock, so a later sync can merge by "which write came after" without6+ * trusting two devices to agree on the time.7+ *8+ * Lifetime totals are kept incrementally rather than recomputed from the log,9+ * because the log is capped. A ring buffer of recent solves is enough to draw10+ * a chart; it is not enough to answer "how many have I ever solved", so that11+ * number is counted as it happens and never derived.12+ */13+14+import { xpFor } from './progress.js';15+16+const KEY = 'klotski.save';17+const SCHEMA = 3;18+const LOG_CAP = 140;19+20+const DEFAULTS = () => ({21+ palette: 'macintosh',22+ sound: true,23+ haptics: true,24+ shading: 'auto', // seeded per rung unless pinned to one screen shape25+ hints: 'full', // full | best | off -- how much of the solver shows26+ fams: {}, // id -> { rung, solved, deepest, best: {rung: steps}, ms }27+ totals: { solved: 0, steps: 0, optimal: 0, ms: 0, xp: 0 },28+ days: { first: null, last: null, streak: 0, best: 0 },29+ log: [], // { f, r, L, s, ms, t } newest last, capped30+});31+32+export const famRecord = id => ({ rung: 1, solved: 0, deepest: 0, best: {}, ms: 0 });33+34+/* v2 stored only `solved: {"family:tier": {steps, optimal}}`. Convert each35+ * family's highest cleared tier into a starting rung so nobody is sent back to36+ * the beginning, and keep their settings. */37+function migrate(raw) {38+ const out = DEFAULTS();39+ const p = raw && raw.payload;40+ if (!p) return out;41+ // `ordered` is carried across a migration too, so that the conversion below42+ // sees it whichever path the save arrived by.43+ for (const k of ['palette', 'sound', 'haptics', 'shading', 'hints', 'blockStyle', 'ordered']) {44+ if (p[k] !== undefined) out[k] = p[k];45+ }46+ if (raw.schema === 2 && p.solved) {47+ for (const key of Object.keys(p.solved)) {48+ const [id, tier] = key.split(':');49+ const rec = out.fams[id] || (out.fams[id] = famRecord(id));50+ const cleared = (parseInt(tier, 10) || 0) + 1;51+ rec.rung = Math.max(rec.rung, cleared + 1);52+ rec.solved += 1;53+ const hit = p.solved[key];54+ if (hit && hit.optimal) rec.deepest = Math.max(rec.deepest, hit.optimal);55+ out.totals.solved += 1;56+ if (hit) { out.totals.steps += hit.steps || 0; out.totals.optimal += hit.optimal || 0; }57+ }58+ }59+ return out;60+}61+62+function load() {63+ try {64+ const raw = JSON.parse(localStorage.getItem(KEY));65+ if (!raw) return { ...DEFAULTS(), counter: 0 };66+ if (raw.schema !== SCHEMA) return { ...migrate(raw), counter: raw.counter || 0 };67+ return { ...DEFAULTS(), ...raw.payload, counter: raw.counter || 0 };68+ } catch (e) {69+ return { ...DEFAULTS(), counter: 0 };70+ }71+}72+73+export const save = load();74+75+/* `ordered` was a boolean: Atkinson, or Bayer. It is now one of half a dozen76+ * block styles, or 'auto' to let the rung decide.77+ *78+ * Converted in place rather than by bumping the schema. migrate() rebuilds from79+ * DEFAULTS and carries only the preference keys, so routing a schema-3 save80+ * through it to gain one string would throw away every solve in it.81+ *82+ * The trigger is the presence of the old key, not the absence of the new one:83+ * the new one is in DEFAULTS and so is never absent, and testing for that84+ * quietly converted nothing at all. Anyone who left it off was on the default85+ * and lands on 'auto', which is the new default. */86+if (save.ordered !== undefined) {87+ if (save.ordered) save.shading = 'ordered';88+ delete save.ordered;89+}90+91+/* `blockStyle` became `shading` when the axis stopped being about dithering.92+ * CHECKER stopped being a shape in the same move -- it was a dispersed screen at93+ * a coarser ruling all along -- and the ruling stopped being a saved preference,94+ * so anyone who had either pinned lands on the shape that draws the same95+ * picture and lets the board choose the press. */96+if (save.blockStyle !== undefined) {97+ if (save.shading === 'auto') save.shading = save.blockStyle === 'checker' ? 'ordered' : save.blockStyle;98+ delete save.blockStyle;99+}100+delete save.blockDensity;101+102+export function persist() {103+ save.counter = (save.counter || 0) + 1;104+ try {105+ localStorage.setItem(KEY, JSON.stringify({106+ schema: SCHEMA, counter: save.counter,107+ payload: { ...save, counter: undefined },108+ }));109+ } catch (e) { /* private mode, quota, evicted -- play on regardless */ }110+}111+112+export function famStats(id) {113+ return save.fams[id] || (save.fams[id] = famRecord(id));114+}115+116+const dayKey = t => new Date(t).toISOString().slice(0, 10);117+const daysBetween = (a, b) =>118+ Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86400000);119+120+/* A streak survives a gap of one day, because "yesterday" should still count121+ * while today is still going. Two days is a break. */122+function stampDay(t) {123+ const d = save.days, today = dayKey(t);124+ if (!d.first) d.first = today;125+ if (d.last === today) return;126+ const gap = d.last ? daysBetween(d.last, today) : Infinity;127+ d.streak = gap === 1 ? (d.streak || 0) + 1 : 1;128+ d.last = today;129+ d.best = Math.max(d.best || 0, d.streak);130+}131+132+/**133+ * Record a solved board and return what it earned.134+ * `solve` = { famId, rung, optimal, steps, ms, score }135+ */136+export function record(solve) {137+ const { famId, rung, optimal, steps, ms, score } = solve;138+ const fam = famStats(famId);139+ const firstClear = !(rung in fam.best);140+ const xp = xpFor({ L: optimal, score, steps, firstClear });141+ const now = Date.now();142+143+ if (firstClear || steps < fam.best[rung]) fam.best[rung] = steps;144+ if (firstClear) fam.solved += 1;145+ fam.deepest = Math.max(fam.deepest, optimal);146+ fam.ms += ms;147+ // The ladder only advances on a first clear, so replaying never skips ahead.148+ if (firstClear && rung >= fam.rung) fam.rung = rung + 1;149+150+ const t = save.totals;151+ t.solved += 1; t.steps += steps; t.optimal += optimal; t.ms += ms; t.xp += xp;152+153+ stampDay(now);154+ save.log.push({ f: famId, r: rung, L: optimal, s: steps, ms, t: Math.round(now / 1000) });155+ if (save.log.length > LOG_CAP) save.log.splice(0, save.log.length - LOG_CAP);156+157+ persist();158+ return { xp, firstClear };159+}160+161+/* Ask to keep the record through storage pressure. Best effort everywhere,162+ * and on iOS it is genuinely only a request. */163+export function requestPersistence() {164+ if (navigator.storage && navigator.storage.persist) {165+ navigator.storage.persist().catch(() => {});166+ }167+}@@ -0,0 +1,97 @@
1+/* The sweep.2+ *3+ * Sliding-block moves are reversible, so the configuration graph is4+ * undirected. That single fact is what makes generation cheap: a breadth-first5+ * search *outward from the goal* labels every reachable configuration with its6+ * exact distance to a solved board, and every state sitting at depth k is,7+ * by construction, a puzzle whose optimal solution is exactly k unit steps.8+ * One sweep per family yields the family's whole difficulty-stratified9+ * catalogue at once -- which is what makes an open-ended ladder possible from10+ * a single pass.11+ *12+ * Pass 1 discovers the component the family's packing lives in. Pass 2 is a13+ * multi-source BFS from every seated-goal state in that component.14+ *15+ * Runs in slices so the survey screen stays alive; the classic board is about16+ * 26k states and finishes in well under a second.17+ */18+19+import { encode, neighbours, isSeated } from './klotski.js';20+21+const CAP = 400000; // guard rail against a runaway piece set22+const BUCKET_MAX = 96; // candidate states kept per depth23+24+export function makeSurvey(fam) {25+ const b = fam.board;26+ const start = encode(b, Uint8Array.from(fam.grid));27+ const [gx, gy] = fam.goal;28+29+ const seen = new Set([start]);30+ const dist = new Map();31+ const buckets = []; // depth -> sample of states at that depth32+ const widths = []; // depth -> how many states sit at that depth33+ const nb = [];34+35+ let q = [start], head = 0;36+ let q2 = null, head2 = 0;37+ let phase = 1, ecc = 0, done = false;38+39+ return {40+ fam, board: b, dist, buckets, widths, start,41+ get ecc() { return ecc; },42+ get count() { return seen.size; },43+ get phase() { return phase; },44+ get done() { return done; },45+ get solvable() { return dist.has(start); },46+47+ /** Advance the sweep. Returns true once finished. */48+ step(budget) {49+ let n = 0;50+51+ if (phase === 1) {52+ while (head < q.length && n < budget) {53+ const k = q[head++]; n++;54+ neighbours(b, k, nb);55+ for (let i = 0; i < nb.length; i++) {56+ const m = nb[i];57+ if (!seen.has(m)) { seen.add(m); q.push(m); }58+ }59+ if (seen.size > CAP) break;60+ }61+ if (head >= q.length || seen.size > CAP) {62+ q2 = [];63+ for (const k of seen) if (isSeated(b, k, gx, gy)) { dist.set(k, 0); q2.push(k); }64+ buckets[0] = q2.slice(0, BUCKET_MAX);65+ widths[0] = q2.length;66+ phase = 2; q = null;67+ }68+ return false;69+ }70+71+ while (head2 < q2.length && n < budget) {72+ const k = q2[head2++]; n++;73+ const d = dist.get(k) + 1;74+ neighbours(b, k, nb);75+ for (let i = 0; i < nb.length; i++) {76+ const m = nb[i];77+ if (dist.has(m) || !seen.has(m)) continue;78+ dist.set(m, d); q2.push(m);79+ if (d > ecc) ecc = d;80+ widths[d] = (widths[d] || 0) + 1;81+ const bk = buckets[d] || (buckets[d] = []);82+ if (bk.length < BUCKET_MAX) bk.push(m);83+ }84+ }85+ if (head2 >= q2.length) { done = true; return true; }86+ return false;87+ },88+ };89+}90+91+/** Run a sweep to completion. For tooling and tests, not for the game loop. */92+export function sweep(fam) {93+ const sv = makeSurvey(fam);94+ let guard = 0;95+ while (!sv.step(1e9) && guard++ < 5000) { /* keep going */ }96+ return sv;97+}@@ -0,0 +1,294 @@
1+/* Themes, and the look of a board.2+ *3+ * This is the only place any colour is written down. It used to be three4+ * places -- a palette table, a piece-colour array and a lone gold constant, all5+ * living in the renderer between the drawing code -- which was survivable while6+ * a theme was two values and stopped being so the moment a theme had pools in7+ * it.8+ *9+ * A theme owns:10+ *11+ * ink, paper the two values everything is thresholded to12+ * pieces one colour per piece size, reprinted after the threshold13+ * gold the one thing on screen that is not one bit14+ * backdrops which stencils the desktop may be drawn with15+ * grounds which colour pairs that stencil may be printed in16+ * shading which screen shapes the blocks may be drawn with17+ * rulings how coarse those screens may be18+ *19+ * The last four are pools, not values: a board draws one of each, seeded on its20+ * family and rung, so a ladder walks through combinations nobody authored.21+ *22+ * Shape and ruling are separate pools rather than one list because they are23+ * separate choices -- a printer picks a dot and then picks how big it is, and24+ * holding one while changing the other is most of the difference between25+ * newsprint and a magazine. Folding them together would mean writing out every26+ * product by hand, which is exactly the authoring this file exists to avoid.27+ *28+ * Seven backdrops, six grounds and thirty-two block looks (six shapes at five29+ * rulings, plus the two that take none) is 1344 per theme out of about twenty30+ * written-down numbers, which is the entire reason for doing it this way rather31+ * than drawing 1344 backdrops.32+ *33+ * Piece colours are deliberately *not* in a pool. The 2x2 is Cao Cao, and a34+ * goal block that changes colour between rungs stops being a thing you35+ * recognise and starts being a thing you have to re-read. Holding them still36+ * also keeps the ground pools designable: each ground has to be checked against37+ * one known set of four accents plus ink, rather than against every set.38+ *39+ * Nothing here touches the DOM. tools/check.mjs imports it directly.40+ */41+42+import { mulberry, hashString } from './families.js';43+import { GROUND_NAMES } from './ground.js';44+import { PATTERN_NAMES, RULING_NAMES, PATTERNS, effectiveRuling } from './shading.js';45+46+/* The piece colours.47+ *48+ * The dither is untouched by these: a block is still a threshold between its49+ * own tone and the paper, so it keeps exactly the texture it had. Only the ink50+ * it is printed in changes -- which is what colour QuickDraw did, and the51+ * reason this reads as an early colour Macintosh rather than as a departure52+ * from the look.53+ *54+ * The big block is red because it always has been: Klotski's 2x2 is Cao Cao in55+ * Huarong Dao and the red donkey in L'Ane Rouge. The others take the Apple56+ * rainbow's blue, green and orange.57+ *58+ * Indexed by piece class: 1 single, 2 upright, 3 recumbent, 4 goal. */59+const QUICKDRAW_PIECES = [60+ null,61+ [0xDE, 0x8A, 0x2E], // 1x1 small orange62+ [0x3B, 0x82, 0xC4], // 1x2 tall blue63+ [0x4E, 0x9E, 0x5C], // 2x1 wide green64+ [0xD1, 0x4A, 0x42], // 2x2 the big block, red65+];66+67+const ALL_BACKDROPS = ['lattice', 'sparse', 'twill', 'rule', 'graph', 'benday', 'plain'];68+const ALL_SHADING = ['atkinson', 'ordered', 'benday', 'lines',69+ 'diagonal', 'crosshatch', 'comic', 'solid'];70+const ALL_RULINGS = ['fine', 'medium', 'coarse', 'poster', 'billboard'];71+72+const g = (name, field, dot) => ({ name, field, dot });73+74+export const THEMES = {75+ /* The Macintosh was black on white, so this is too. None of these are pure76+ * black on pure white: the paper is always tinted and the ink always has a77+ * cast, which is most of why a 1-bit image reads as a printed thing rather78+ * than a screenshot. Swapping the two entries is all "night" is. */79+ macintosh: {80+ name: 'MACINTOSH',81+ ink: [0x2B, 0x2B, 0x22], paper: [0xE8, 0xF4, 0xF2],82+ pieces: QUICKDRAW_PIECES,83+ gold: [0xC8, 0x95, 0x26],84+ backdrops: ALL_BACKDROPS,85+ shading: ALL_SHADING,86+ rulings: ALL_RULINGS,87+ // Papers around the theme's own, each with an ink that has been pulled88+ // toward it. A ground whose dot is the same neutral as every other ground's89+ // reads as one backdrop with the lights changed; matching the cast is what90+ // makes them separate places.91+ grounds: [92+ g('MINT', [0xE8, 0xF4, 0xF2], [0x2B, 0x2B, 0x22]), // the original93+ g('SLATE', [0xD8, 0xE2, 0xE6], [0x27, 0x31, 0x3A]),94+ g('BONE', [0xEC, 0xE7, 0xDA], [0x3A, 0x33, 0x2A]),95+ g('SKY', [0xDC, 0xE8, 0xF4], [0x24, 0x38, 0x4C]),96+ g('MOSS', [0xDC, 0xE8, 0xD8], [0x2A, 0x3A, 0x2C]),97+ g('ROSE', [0xF2, 0xE2, 0xE2], [0x42, 0x2C, 0x2E]),98+ ],99+ },100+101+ paper: {102+ name: 'PAPER',103+ ink: [0x2E, 0x30, 0x37], paper: [0xEB, 0xE5, 0xCE],104+ pieces: QUICKDRAW_PIECES,105+ gold: [0xC8, 0x95, 0x26],106+ backdrops: ALL_BACKDROPS,107+ shading: ALL_SHADING,108+ rulings: ALL_RULINGS,109+ grounds: [110+ g('CREAM', [0xEB, 0xE5, 0xCE], [0x2E, 0x30, 0x37]), // the original111+ g('LINEN', [0xE4, 0xDC, 0xC4], [0x3A, 0x34, 0x2A]),112+ g('SAND', [0xEA, 0xDC, 0xBA], [0x46, 0x39, 0x2A]),113+ g('OLIVE', [0xDF, 0xE2, 0xC2], [0x33, 0x38, 0x2A]),114+ g('CLAY', [0xEC, 0xD8, 0xC4], [0x4A, 0x34, 0x2A]),115+ g('ASH', [0xDE, 0xDD, 0xD4], [0x32, 0x34, 0x3A]),116+ ],117+ },118+119+ night: {120+ name: 'NIGHT',121+ ink: [0xDC, 0xE4, 0xE2], paper: [0x1C, 0x20, 0x28],122+ pieces: QUICKDRAW_PIECES,123+ gold: [0xC8, 0x95, 0x26],124+ backdrops: ALL_BACKDROPS,125+ shading: ALL_SHADING,126+ rulings: ALL_RULINGS,127+ // Every ground but the first sits *below* the theme's paper rather than128+ // beside it. On a dark theme the panels are the lit thing; a ground at the129+ // same value as the paper differing only in hue reads as a printing fault130+ // rather than as a desk the windows are lying on.131+ grounds: [132+ g('NIGHT', [0x1C, 0x20, 0x28], [0xDC, 0xE4, 0xE2]), // the original, flush133+ g('INDIGO', [0x0F, 0x14, 0x24], [0xBC, 0xC8, 0xE8]),134+ g('FOREST', [0x0C, 0x1A, 0x14], [0xC4, 0xE4, 0xD2]),135+ g('PLUM', [0x19, 0x0E, 0x22], [0xDE, 0xC8, 0xE8]),136+ g('COAL', [0x10, 0x10, 0x13], [0xD8, 0xD8, 0xD2]),137+ g('STEEL', [0x0E, 0x1A, 0x26], [0xBC, 0xD4, 0xE6]),138+ ],139+ },140+141+ /* The exception, argued rather than assumed.142+ *143+ * Every other theme obeys the rule above -- tinted paper, ink with a cast --144+ * because that is what stops a 1-bit picture reading as a screenshot. This one145+ * breaks it on purpose: on an OLED panel a black pixel is a pixel that is off,146+ * and #1C2028 is not off. The tint that buys warmth on a backlit screen costs147+ * the one thing the display can do that no other can.148+ *149+ * What replaces the tint is the ground. Every backdrop here is pure black150+ * with a neon stencil over it, at one pixel in sixteen or fewer, so the colour151+ * arrives as a field of light on true black instead of as a wash across it.152+ *153+ * The piece colours are the theme's own for the same reason the others are154+ * not: the Apple rainbow was chosen against paper and goes muddy against155+ * black. These sit at the coverages the blocks actually use -- the small156+ * block is only a quarter ink, so it has to be the brightest of the four to157+ * register at all, and the goal block is three quarters, so it can afford to158+ * be the most saturated. */159+ amoled: {160+ name: 'AMOLED',161+ ink: [0xDC, 0xF2, 0xFF], paper: [0x00, 0x00, 0x00],162+ pieces: [163+ null,164+ [0xFF, 0xB0, 0x1A], // 1x1 small amber, at a quarter coverage165+ [0x22, 0xD3, 0xEE], // 1x2 tall cyan166+ [0x34, 0xE8, 0x9E], // 2x1 wide spring167+ [0xFF, 0x2E, 0x63], // 2x2 the big block, still the hottest thing on it168+ ],169+ gold: [0xFF, 0xD2, 0x4A], // the printed gold disappears against black170+ backdrops: ALL_BACKDROPS,171+ shading: ALL_SHADING,172+ rulings: ALL_RULINGS,173+ grounds: [174+ g('CYAN', [0, 0, 0], [0x22, 0xD3, 0xEE]),175+ g('MAGENTA', [0, 0, 0], [0xFF, 0x2E, 0x63]),176+ g('SPRING', [0, 0, 0], [0x34, 0xE8, 0x9E]),177+ g('AMBER', [0, 0, 0], [0xFF, 0xB0, 0x1A]),178+ g('VIOLET', [0, 0, 0], [0xA8, 0x55, 0xF7]),179+ g('ICE', [0, 0, 0], [0x6E, 0xA8, 0xFF]),180+ ],181+ },182+};183+184+export const THEME_NAMES = Object.keys(THEMES);185+export const themeById = id => THEMES[id] || THEMES.macintosh;186+187+/* Rec. 709 luma, 0..255, and plain RGB distance. Both are used to check a188+ * ground pair carries enough separation to be read against, and they answer189+ * different questions: brightness is what makes a dot visible on its field,190+ * and distance is what makes a field distinguishable from the paper of the191+ * panels lying on it -- which on a dark theme is mostly a matter of hue at192+ * near-identical brightness, and so is invisible to luma alone.193+ *194+ * Exported because the check belongs in tools/check.mjs, not in the render195+ * path: it is a fact about the tables, and the tables do not change at run196+ * time. */197+export const luma = ([r, g2, b]) => 0.2126 * r + 0.7152 * g2 + 0.0722 * b;198+export const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);199+200+/* ------------------------------------------------------------------ combos */201+202+/* What the screens that are not a board use. The ladder varies; the atlas, the203+ * record and the title do not -- they are chrome, and chrome that shifts under204+ * you every time you open it is not chrome. It is also the only sensible answer205+ * for a screen with no rung to seed from. */206+export function houseCombo(themeId) {207+ const t = themeById(themeId);208+ return { backdrop: 'lattice', ground: t.grounds[0], shading: 'atkinson', ruling: 'fine' };209+}210+211+const cache = new Map();212+const pick = (arr, rng) => arr[Math.min(arr.length - 1, (rng() * arr.length) | 0)];213+214+/**215+ * The look of one board: a backdrop stencil, the colour pair it is printed in,216+ * a shading screen for the blocks and the ruling that screen is cut at.217+ *218+ * Seeded on family and rung, the same pair that decides which board it is, so a219+ * rung always looks the way it looked -- and mixing the theme into the hash220+ * means the same rung is a different combination under a different theme rather221+ * than the same arrangement recoloured.222+ */223+export function comboFor(themeId, famId, rung) {224+ const key = `${themeId}:${famId}:${rung}`;225+ const hit = cache.get(key);226+ if (hit) return hit;227+228+ const t = themeById(themeId);229+ const rng = mulberry(hashString(`${famId}|${themeId}`) + rung * 8191);230+ const shading = pick(t.shading, rng);231+ const combo = {232+ backdrop: pick(t.backdrops, rng),233+ ground: pick(t.grounds, rng),234+ shading,235+ // Drawn unconditionally so the stream stays in step whichever shape came236+ // up, then normalised: the shapes that take no ruling always report 'fine'237+ // rather than a frequency they quietly ignore, so the sprite cache key238+ // agrees with what is actually on screen.239+ ruling: effectiveRuling(shading, pick(t.rulings, rng)),240+ };241+242+ // The ladder is open-ended, so this map is too. It costs nothing to keep a243+ // few hundred and nothing to lose them; the values are pure functions of the244+ // key and re-derive in microseconds.245+ if (cache.size > 512) cache.clear();246+ cache.set(key, combo);247+ return combo;248+}249+250+/* ---------------------------------------------------------------- dev-logo251+ *252+ * A maker's mark, carried in the menu bar where a Macintosh carried the Apple.253+ * Constant in shape and position -- a mark that moved or redrew itself would be254+ * decoration rather than a signature -- and the one thing about it that varies255+ * is which of the theme's piece colours fills it.256+ *257+ * Seeded on the day rather than on the board, because it does not belong to a258+ * board: it is the same mark on every screen it appears on, all day, and it259+ * moves on tomorrow. The theme is mixed into the hash so switching theme is not260+ * merely the same choice recoloured.261+ */262+export function dayOfYear(date = new Date()) {263+ const y = date.getFullYear();264+ const start = Date.UTC(y, 0, 1);265+ const today = Date.UTC(y, date.getMonth(), date.getDate());266+ return Math.floor((today - start) / 86400000) + 1;267+}268+269+export function logoInk(themeId, day) {270+ const t = themeById(themeId);271+ const rng = mulberry(hashString(`dev-logo|${themeId}`) + day * 2657);272+ // pieces[0] is the empty class, so the pool is the four real ones.273+ return t.pieces[1 + Math.min(3, (rng() * 4) | 0)];274+}275+276+/* Every pool name must resolve to something that exists. Catching this here277+ * rather than at the point of use means a typo in a theme is a failed check278+ * rather than a screen that silently falls back to the house lattice. */279+export function validateThemes() {280+ const bad = [];281+ for (const [id, t] of Object.entries(THEMES)) {282+ if (!t.backdrops.length || !t.grounds.length || !t.shading.length || !t.rulings.length) {283+ bad.push(`${id}: empty pool`);284+ }285+ for (const b of t.backdrops) if (!GROUND_NAMES.includes(b)) bad.push(`${id}: no backdrop ${b}`);286+ for (const s of t.shading) if (!PATTERN_NAMES.includes(s)) bad.push(`${id}: no shading ${s}`);287+ for (const r of t.rulings) if (!RULING_NAMES.includes(r)) bad.push(`${id}: no ruling ${r}`);288+ // A theme naming only unruled shapes would carry a ruling pool that can289+ // never be reached, which is a table lying about what it does.290+ if (!t.shading.some(s => PATTERNS[s] && PATTERNS[s].ruled)) bad.push(`${id}: no ruled shading`);291+ if (t.pieces.length !== 5) bad.push(`${id}: ${t.pieces.length} piece colours, want 5`);292+ }293+ return bad;294+}@@ -0,0 +1,151 @@
1+/* Chrome only. Everything that looks like art is composed into the canvas and2+ dithered there; this file keeps small type crisp and stops the browser3+ behaving 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 to7+ know 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; }12+13+:root {14+ --ink: #2B2B22;15+ --paper: #E8F4F2;16+ --ground: #E8F4F2; /* the backdrop's field; varies per board */17+ --s: 2; /* integer upscale from the picture buffer */18+}19+20+* { margin: 0; padding: 0; box-sizing: border-box; }21+22+html, body {23+ height: 100%;24+ /* The upscale is a whole number, so on anything wider than a phone there is25+ a margin of page either side of the picture. It has to carry the backdrop26+ rather than the paper, or the edge of the canvas becomes a visible seam27+ the moment the two stop being the same colour. */28+ background: var(--ground);29+ color: var(--ink);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;35+ -webkit-touch-callout: none;36+ -webkit-tap-highlight-color: transparent;37+}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+#stage { position: absolute; left: 0; top: 0; }46+47+#board {48+ display: block;49+ image-rendering: pixelated; /* the upscale is always an exact integer */50+ image-rendering: crisp-edges;51+}52+53+/* Type layer: same box, full device resolution, never pixelated. */54+#type { position: absolute; left: 0; top: 0; pointer-events: none; }55+56+#ui { position: absolute; inset: 0; pointer-events: none; }57+#ui button { pointer-events: auto; }58+59+.zone {60+ position: absolute; left: 0; width: 100%;61+ display: none; align-items: center; justify-content: center;62+ gap: calc(13px * var(--s));63+}64+/* The menu bar is already white; a button background would punch a hole in65+ its rule. */66+#top { justify-content: flex-start; gap: calc(5px * var(--s)); padding: 0 calc(8px * var(--s)); }67+#top button { background: none; padding: calc(2px * var(--s)) calc(4px * var(--s)); }68+#top .spacer { flex: 1; }69+#acts { gap: calc(6px * var(--s)); }70+71+.stat { display: flex; flex-direction: column; align-items: center; gap: calc(2px * var(--s)); }72+.val { font-family: Silk, monospace; font-size: calc(10px * var(--s)); }73+.lbl { font-size: calc(5px * var(--s)); letter-spacing: calc(1px * var(--s)); opacity: .72; }74+#gauge { justify-content: space-between; gap: 0; }75+#gauge .spacer { flex: 1; }76+77+/* Buttons are QuickDraw buttons: a hard rectangle, inverted while pressed. */78+button {79+ font-family: Mono, monospace;80+ font-size: calc(6px * var(--s));81+ letter-spacing: calc(1.2px * var(--s));82+ color: var(--ink);83+ background: var(--paper);84+ border: calc(1px * var(--s)) solid var(--ink);85+ border-radius: 0;86+ padding: calc(3.5px * var(--s)) calc(6.5px * var(--s));87+ cursor: pointer;88+}89+button:active, button.armed { background: var(--ink); color: var(--paper); }90+button:disabled { opacity: .35; border-color: transparent; }91+button.ghost { border-color: transparent; }92+93+/* Discrete steps, never a smooth fade: an opacity ramp would put in-between94+ frames in the middle of a one-bit game. */95+@keyframes beat { 0%, 55% { opacity: 1 } 56%, 100% { opacity: 0 } }96+.beat { animation: beat 1.4s steps(1, end) infinite; }97+98+99+#sheet {100+ position: absolute; inset: 0;101+ display: none; flex-direction: column;102+ align-items: center; justify-content: center;103+ gap: calc(11px * var(--s));104+ padding: calc(16px * var(--s));105+ background: var(--paper);106+ pointer-events: auto;107+}108+#sheet.open { display: flex; }109+.group { display: flex; flex-direction: column; align-items: center; }110+.grouplbl {111+ font-size: calc(5px * var(--s)); letter-spacing: calc(1.6px * var(--s));112+ opacity: .6; margin-bottom: calc(5px * var(--s));113+}114+.row { display: flex; gap: calc(4px * var(--s)); flex-wrap: wrap; justify-content: center; }115+116+/* A pull-down, drawn the way the rest of the chrome is drawn.117+ A native <select> is the one control the platform will not let you style118+ past a point, and one rounded system-blue menu in a screen of hard119+ rectangles undoes the whole conceit. This is the QuickDraw menu instead:120+ a hard box, a hairline frame, and inversion to show the highlight. */121+.menu { position: relative; }122+.menutitle { min-width: calc(84px * var(--s)); text-align: left; }123+.menutitle::after { content: ' \25BE'; float: right; } /* the little triangle */124+.menulist {125+ display: none;126+ position: absolute; left: 0; top: calc(100% - 1px * var(--s));127+ z-index: 5; min-width: 100%;128+ background: var(--paper);129+ border: calc(1px * var(--s)) solid var(--ink);130+ /* The drop shadow QuickDraw menus cast: solid ink, offset, never blurred. */131+ box-shadow: calc(2px * var(--s)) calc(2px * var(--s)) 0 var(--ink);132+}133+.menu.open .menulist { display: block; }134+.menulist button {135+ display: block; width: 100%; text-align: left;136+ border: 0; background: none;137+ padding: calc(3px * var(--s)) calc(7px * var(--s));138+ white-space: nowrap;139+}140+.menulist button:hover, .menulist button.armed { background: var(--ink); color: var(--paper); }141+/* A rule under AUTO, because it is a different kind of answer from the rest:142+ everything below it is a screen, AUTO is "let the board decide". */143+.menulist button[data-shading="auto"] {144+ border-bottom: calc(1px * var(--s)) solid var(--ink);145+}146+.credit {147+ font-size: calc(4.4px * var(--s)); line-height: 1.9;148+ letter-spacing: calc(.5px * var(--s));149+ opacity: .5; text-align: center;150+ max-width: calc(180px * var(--s));151+}@@ -0,0 +1,87 @@
1+/* Precache everything, serve from cache, go to the network only for what is2+ * missing. The whole app is a few hundred kilobytes and there is no dynamic3+ * content, so there is nothing to be clever about: after the first visit it4+ * runs with the radio off.5+ *6+ * While this is a mockup under active iteration, a new worker takes over as7+ * soon as it installs, so the next load is always the newest build -- without8+ * it, cache-first quietly serves yesterday's app forever and every round of9+ * feedback is spent on a version that no longer exists. It still never10+ * reloads the page underneath the player; the current session finishes on the11+ * build it started with.12+ *13+ * For the real app this should become the polite flow instead: install, wait,14+ * tell the page, and let it choose a harmless moment. Refreshing someone15+ * mid-puzzle to ship a CSS tweak is a way to lose them.16+ */17+18+const VERSION = 'klotski-v17';19+20+const SHELL = [21+ './',22+ './index.html',23+ './styles.css',24+ './manifest.webmanifest',25+ './src/main.js',26+ './src/game.js',27+ './src/render.js',28+ './src/state.js',29+ './src/store.js',30+ './src/sound.js',31+ './src/klotski.js',32+ './src/families.js',33+ './src/survey.js',34+ './src/difficulty.js',35+ './src/shading.js',36+ './src/ground.js',37+ './src/theme.js',38+ './src/progress.js',39+ './fonts/silk.woff2',40+ './fonts/silk-bold.woff2',41+ './fonts/mono.woff2',42+ './icons/icon-192.png',43+ './icons/icon-512.png',44+ './icons/icon-maskable.png',45+];46+47+self.addEventListener('install', event => {48+ self.skipWaiting();49+ event.waitUntil(50+ caches.open(VERSION)51+ .then(cache => cache.addAll(SHELL))52+ .catch(() => { /* a missing optional asset must not block installation */ })53+ );54+});55+56+self.addEventListener('activate', event => {57+ event.waitUntil((async () => {58+ const names = await caches.keys();59+ await Promise.all(names.filter(n => n !== VERSION).map(n => caches.delete(n)));60+ await self.clients.claim();61+ })());62+});63+64+self.addEventListener('fetch', event => {65+ const req = event.request;66+ if (req.method !== 'GET' || new URL(req.url).origin !== self.location.origin) return;67+68+ event.respondWith((async () => {69+ const hit = await caches.match(req, { ignoreSearch: true });70+ if (hit) return hit;71+ try {72+ const res = await fetch(req);73+ if (res && res.ok && res.type === 'basic') {74+ const cache = await caches.open(VERSION);75+ cache.put(req, res.clone());76+ }77+ return res;78+ } catch (e) {79+ // Offline and unseen: fall back to the shell so navigation still lands.80+ return (await caches.match('./index.html')) || Response.error();81+ }82+ })());83+});84+85+self.addEventListener('message', event => {86+ if (event.data === 'SKIP_WAITING') self.skipWaiting();87+});@@ -0,0 +1,197 @@
1+import { FAMILIES } from '../src/families.js';2+import { sweep } from '../src/survey.js';3+import { puzzleFor, depthForRung, xpFor, levelFromXp, xpForLevel, levelProgress } from '../src/progress.js';4+import {5+ THEMES, comboFor, houseCombo, validateThemes, luma, distance, logoInk, dayOfYear,6+} from '../src/theme.js';7+import { GROUND_TILES } from '../src/ground.js';8+import { PATTERNS, RULINGS, effectiveRuling, screenOf } from '../src/shading.js';9+10+let fail = 0;11+const ok = (cond, msg) => { if (!cond) { console.log(' FAIL', msg); fail++; } };12+13+// 1. the canary: parameterising the board must not have moved the classic numbers14+const classic = FAMILIES.find(f => f.id === 'classic');15+const sv = sweep(classic);16+console.log(`classic: ${sv.count} states, start at ${sv.dist.get(sv.start)} steps, ecc ${sv.ecc}`);17+ok(sv.count === 25955, `classic states ${sv.count} !== 25955`);18+ok(sv.dist.get(sv.start) === 116, `classic optimal ${sv.dist.get(sv.start)} !== 116`);19+20+// 2. the schedule is monotone, capped, and saturates21+for (const ecc of [24, 27, 33, 80, 101, 126, 129, 178]) {22+ let prev = 0;23+ for (let r = 1; r <= 200; r++) {24+ const d = depthForRung(r, ecc);25+ ok(d >= prev, `ecc ${ecc} rung ${r} went backwards`);26+ ok(d <= ecc, `ecc ${ecc} rung ${r} exceeded the ceiling`);27+ prev = d;28+ }29+ ok(depthForRung(200, ecc) === ecc, `ecc ${ecc} never reached its ceiling`);30+ ok(depthForRung(1, ecc) <= 14, `ecc ${ecc} opens at ${depthForRung(1, ecc)}, too deep`);31+}32+33+// 3. puzzleFor returns the exact requested depth, and keeps giving new boards34+const seenStates = new Set();35+for (const r of [1, 2, 3, 5, 8, 12, 20, 40, 80]) {36+ const pz = puzzleFor(classic, sv, r);37+ const want = depthForRung(r, sv.ecc);38+ // At the ceiling the exact band can be thin, so a board within a couple of39+ // steps is honest; below it the depth must be exactly what was advertised.40+ const slack = want >= sv.ecc - 1 ? 3 : 0;41+ ok(Math.abs(pz.metrics.L - want) <= slack,42+ `rung ${r}: got depth ${pz.metrics.L}, wanted ${want}`);43+ seenStates.add(pz.state);44+}45+ok(seenStates.size >= 8, `only ${seenStates.size} distinct boards across 9 rungs`);46+ok(new Set([20,21,22,23,24,25,26,27].map(r => puzzleFor(classic, sv, r).state)).size >= 6,47+ 'ceiling rungs should rarely repeat over eight in a row');48+console.log(`rungs 1..80 gave ${seenStates.size} distinct boards, depths ` +49+ [1,2,3,5,8,12,20,40,80].map(r => puzzleFor(classic, sv, r).metrics.L).join(' '));50+51+// past the ceiling it must still vary52+const deep = new Set([60,61,62,63,64,65].map(r => puzzleFor(classic, sv, r).state));53+ok(deep.size >= 4, `ceiling rungs repeat: only ${deep.size} distinct of 6`);54+console.log(`six rungs at the ceiling gave ${deep.size} distinct boards`);55+56+// 4. xp and levels57+ok(xpFor({ L: 20, score: 2, steps: 20, firstClear: true }) >58+ xpFor({ L: 20, score: 2, steps: 60, firstClear: true }), 'efficiency must pay');59+ok(xpFor({ L: 60, score: 2.5, steps: 60, firstClear: true }) >60+ xpFor({ L: 20, score: 2.5, steps: 20, firstClear: true }), 'depth must pay');61+ok(xpFor({ L: 20, score: 2, steps: 20, firstClear: false }) <62+ xpFor({ L: 20, score: 2, steps: 20, firstClear: true }), 'replays must pay less');63+for (let l = 1; l < 40; l++) ok(levelFromXp(xpForLevel(l)) === l, `level ${l} round trip`);64+const p10 = levelProgress(xpForLevel(10) + 5);65+ok(p10.level === 10 && p10.into === 5, 'level progress within band');66+console.log('first solve xp:', xpFor({ L: 5, score: 1.6, steps: 7, firstClear: true }),67+ '| a deep clean one:', xpFor({ L: 120, score: 3, steps: 130, firstClear: true }));68+console.log('levels 1..8 need:', [1,2,3,4,5,6,7,8].map(xpForLevel).join(' '));69+70+// 5. themes: the pools have to name things that exist, and the tables have to71+// tile at the period the animation is quantised to.72+for (const bad of validateThemes()) ok(false, bad);73+74+for (const [id, s] of Object.entries(PATTERNS)) {75+ if (s.diffuse) continue;76+ ok(s.m.length === 64, `style ${id}: ${s.m.length}-cell matrix, want 64`);77+ // Every style must agree at the extremes. The scene outside the block sprites78+ // is composed entirely at 0 and 255, so if one style disagreed there the79+ // whole frame -- panels, rules, plates -- would change with the block texture.80+ let lo = 256, hi = 0;81+ for (const v of s.m) { lo = Math.min(lo, v); hi = Math.max(hi, v); }82+ ok(lo > 0, `style ${id}: a zero threshold makes solid ink into paper`);83+ ok(hi < 255, `style ${id}: a 255 threshold makes bare paper into ink`);84+}85+for (const [id, t] of Object.entries(GROUND_TILES)) {86+ ok(t.length === 64, `pattern ${id}: ${t.length}-cell tile, want 8x8`);87+ const on = t.reduce((n, v) => n + (v ? 1 : 0), 0);88+ // A background is not a picture. The lattice that shipped first is 4/64.89+ ok(on <= 8, `pattern ${id}: ${on}/64 dots, too dense for a ground`);90+}91+92+// A rung must always look the way it looked, and two themes must not merely93+// recolour the same arrangement.94+for (const fam of FAMILIES.slice(0, 3)) {95+ for (const r of [1, 2, 17, 400]) {96+ const a = comboFor('macintosh', fam.id, r), b = comboFor('macintosh', fam.id, r);97+ ok(a.backdrop === b.backdrop && a.ground === b.ground && a.shading === b.shading,98+ `combo for ${fam.id}:${r} is not stable`);99+ }100+}101+// A ruling is only ever reported when the shape actually has a grain to102+// resize, so the combo never claims a coarse Atkinson.103+for (const s of Object.keys(PATTERNS)) {104+ for (const d of Object.keys(RULINGS)) {105+ const got = effectiveRuling(s, d);106+ ok(PATTERNS[s].ruled ? got === d : got === 'fine', `${s}@${d} normalised to ${got}`);107+ }108+}109+for (const id of Object.keys(THEMES)) {110+ for (const fam of FAMILIES) {111+ for (let r = 1; r <= 60; r++) {112+ const c = comboFor(id, fam.id, r);113+ ok(c.ruling === effectiveRuling(c.shading, c.ruling),114+ `${id}/${fam.id}:${r} reports ${c.shading}@${c.ruling}`);115+ }116+ }117+}118+119+const key = c => `${c.backdrop}/${c.ground.name}/${c.shading}@${c.ruling}`;120+const acrossThemes = new Set(Object.keys(THEMES).map(t => key(comboFor(t, 'classic', 9))));121+ok(acrossThemes.size >= 3, `rung 9 gives only ${acrossThemes.size} arrangements across four themes`);122+123+// And the pools must actually get used: a seed that keeps electing the same124+// member is worse than no seed, because it looks like variety that failed.125+for (const id of Object.keys(THEMES)) {126+ const seen = { backdrop: new Set(), ground: new Set(), shading: new Set(), ruling: new Set() };127+ for (const fam of FAMILIES) {128+ for (let r = 1; r <= 40; r++) {129+ const c = comboFor(id, fam.id, r);130+ seen.backdrop.add(c.backdrop); seen.ground.add(c.ground.name);131+ seen.shading.add(c.shading); seen.ruling.add(c.ruling);132+ }133+ }134+ const t = THEMES[id];135+ ok(seen.backdrop.size === t.backdrops.length, `${id}: ${seen.backdrop.size}/${t.backdrops.length} backdrops drawn`);136+ ok(seen.ground.size === t.grounds.length, `${id}: ${seen.ground.size}/${t.grounds.length} grounds drawn`);137+ ok(seen.shading.size === t.shading.length, `${id}: ${seen.shading.size}/${t.shading.length} shadings drawn`);138+ ok(seen.ruling.size === t.rulings.length, `${id}: ${seen.ruling.size}/${t.rulings.length} rulings drawn`);139+}140+141+// 6. the dev mark: one colour per day, all four reached, none of them hogging.142+ok(dayOfYear(new Date(2026, 0, 1)) === 1, 'jan 1 is day 1');143+ok(dayOfYear(new Date(2026, 11, 31)) === 365, 'dec 31 is day 365 in a common year');144+ok(dayOfYear(new Date(2024, 11, 31)) === 366, 'dec 31 is day 366 in a leap year');145+for (const id of Object.keys(THEMES)) {146+ const t = THEMES[id];147+ const tally = new Map();148+ for (let d = 1; d <= 366; d++) {149+ const c = logoInk(id, d);150+ ok(t.pieces.indexOf(c) > 0, `${id} day ${d}: mark colour is not one of the piece colours`);151+ const k = c.join(',');152+ tally.set(k, (tally.get(k) || 0) + 1);153+ ok(logoInk(id, d) === c, `${id} day ${d}: mark colour is not stable`);154+ }155+ ok(tally.size === 4, `${id}: ${tally.size} of 4 piece colours ever used for the mark`);156+ // A year is small enough that a hash will not land perfectly even; it is not157+ // small enough to excuse one colour taking a third of it.158+ for (const [k, n] of tally) ok(n >= 55 && n <= 130, `${id}: mark colour ${k} used ${n}/366 days`);159+}160+161+// Ground contrast. The dot has to read against its own field, and the field has162+// to stay out of the way of the panels -- which are drawn in the theme's paper163+// and ruled in its ink, so a ground field that lands on either erases the edge164+// of every window on screen.165+for (const [id, t] of Object.entries(THEMES)) {166+ const paperL = luma(t.paper), inkL = luma(t.ink);167+ for (const g of t.grounds) {168+ const fieldL = luma(g.field), dotL = luma(g.dot);169+ ok(Math.abs(dotL - fieldL) > 60, `${id}/${g.name}: dot and field only ${Math.round(Math.abs(dotL - fieldL))} apart`);170+ ok(Math.abs(fieldL - inkL) > 60, `${id}/${g.name}: field sits on the ink, panel rules vanish`);171+ // Distance, not brightness. The field has to be told apart from the paper172+ // of the panels lying on it, and on a dark theme that separation is mostly173+ // hue at near-identical brightness -- which luma cannot see at all.174+ //175+ // Flush is allowed and is the first ground of every theme: a pattern176+ // printed straight onto the page, which is what shipped originally. What is177+ // not allowed is nearly flush, which reads as a printing fault.178+ const dp = distance(g.field, t.paper);179+ ok(dp === 0 || dp > 12, `${id}/${g.name}: field is ${dp.toFixed(1)} from the paper, neither flush nor distinct`);180+ }181+ ok(houseCombo(id).ground === t.grounds[0], `${id}: house ground is not the first of the pool`);182+}183+184+// Counted through the ruling rather than across it: an unruled shape is one185+// look however many densities the theme lists, and multiplying regardless186+// would claim variety that is not on screen.187+const looksPerTheme = t => {188+ const blocks = t.shading.reduce((n, s) => n + (PATTERNS[s].ruled ? t.rulings.length : 1), 0);189+ return t.backdrops.length * t.grounds.length * blocks;190+};191+const combos = Object.values(THEMES).map(looksPerTheme).reduce((a, b) => a + b, 0);192+console.log(`themes: ${Object.keys(THEMES).length}, ` +193+ `${looksPerTheme(THEMES.macintosh)} looks each and ${combos} in total, ` +194+ `sample ${key(comboFor('amoled', 'classic', 1))} then ${key(comboFor('amoled', 'classic', 2))}`);195+196+console.log(fail ? `\n${fail} FAILURES` : '\nall engine checks passed');197+process.exit(fail ? 1 : 0);@@ -0,0 +1,155 @@
1+/* Validate candidate families.2+ *3+ * A hand-written packing is usually NOT a puzzle. Most dense arrangements4+ * strand the big block in a component that contains no solved state at all, so5+ * every candidate is swept to completion here and judged on what its state6+ * space actually looks like before it is allowed into the game.7+ *8+ * Candidates are written as solved packings -- the big block already seated --9+ * because sweeping outward from a solved state makes reachability free.10+ *11+ * Usage: node tools/find-families.mjs [--scores]12+ */13+14+import { makeBoard, encode } from '../src/klotski.js';15+import { sweep } from '../src/survey.js';16+import { analyse } from '../src/difficulty.js';17+import { depthForRung } from '../src/progress.js';18+19+// 0 empty · 1 single 1x1 · 2 upright 1x2 · 3 recumbent 2x1 · 4 goal 2x220+const CANDIDATES = [21+ // ---- 3x5, fifteen cells: quick boards, shallow ceilings -----------------22+ { id: 'stack', w: 3, h: 5, goal: [0, 3], grid: [23+ 2, 2, 2,24+ 2, 2, 2,25+ 1, 1, 1,26+ 4, 4, 0,27+ 4, 4, 0] },28+ { id: 'narrow', w: 3, h: 5, goal: [0, 3], grid: [29+ 3, 3, 1,30+ 1, 1, 1,31+ 1, 1, 1,32+ 4, 4, 0,33+ 4, 4, 0] },34+35+ // ---- 4x4, sixteen cells: compact ---------------------------------------36+ { id: 'compact', w: 4, h: 4, goal: [1, 2], grid: [37+ 3, 3, 3, 3,38+ 1, 1, 1, 1,39+ 0, 4, 4, 0,40+ 0, 4, 4, 0] },41+ { id: 'squat', w: 4, h: 4, goal: [1, 2], grid: [42+ 2, 3, 3, 2,43+ 2, 1, 1, 2,44+ 1, 4, 4, 1,45+ 0, 4, 4, 0] },46+47+ // ---- 4x5, the classic frame --------------------------------------------48+ { id: 'classic', w: 4, h: 5, goal: [1, 3], grid: [49+ 2, 4, 4, 2,50+ 2, 4, 4, 2,51+ 2, 3, 3, 2,52+ 2, 1, 1, 2,53+ 1, 0, 0, 1] },54+ { id: 'wide', w: 4, h: 5, goal: [1, 3], grid: [55+ 3, 3, 3, 3,56+ 3, 3, 3, 3,57+ 1, 1, 1, 1,58+ 1, 4, 4, 1,59+ 0, 4, 4, 0] },60+ { id: 'mixed', w: 4, h: 5, goal: [1, 3], grid: [61+ 3, 3, 3, 3,62+ 2, 3, 3, 2,63+ 2, 1, 1, 2,64+ 1, 4, 4, 1,65+ 0, 4, 4, 0] },66+ { id: 'tower', w: 4, h: 5, goal: [1, 3], grid: [67+ 2, 2, 2, 2,68+ 2, 2, 2, 2,69+ 2, 1, 1, 2,70+ 2, 4, 4, 2,71+ 0, 4, 4, 0] },72+ { id: 'scatter', w: 4, h: 5, goal: [1, 3], grid: [73+ 3, 3, 3, 3,74+ 1, 1, 1, 1,75+ 1, 1, 1, 1,76+ 1, 4, 4, 1,77+ 0, 4, 4, 0] },78+ { id: 'corner', w: 4, h: 5, goal: [0, 3], grid: [79+ 3, 3, 3, 3,80+ 2, 3, 3, 2,81+ 2, 1, 1, 2,82+ 4, 4, 1, 1,83+ 4, 4, 0, 0] },84+85+ // ---- 5x4, landscape: the deep end --------------------------------------86+ { id: 'span', w: 5, h: 4, goal: [1, 2], grid: [87+ 2, 2, 2, 2, 1,88+ 2, 2, 2, 2, 1,89+ 0, 4, 4, 0, 1,90+ 0, 4, 4, 0, 1] },91+ { id: 'shelf', w: 5, h: 4, goal: [1, 2], grid: [92+ 3, 3, 3, 3, 1,93+ 3, 3, 3, 3, 1,94+ 1, 4, 4, 1, 0,95+ 1, 4, 4, 1, 0] },96+ { id: 'yard', w: 5, h: 4, goal: [2, 2], grid: [97+ 2, 3, 3, 3, 3,98+ 2, 3, 3, 3, 3,99+ 1, 1, 4, 4, 0,100+ 1, 1, 4, 4, 0] },101+];102+103+const NAMES = { 1: 'small', 2: 'tall', 3: 'wide', 4: 'big' };104+105+function census(grid) {106+ const seen = new Set();107+ const counts = { 1: 0, 2: 0, 3: 0, 4: 0, 0: 0 };108+ for (const v of grid) counts[v]++;109+ return `${counts[4] / 4} big ${counts[2] / 2} tall ${counts[3] / 2} wide `110+ + `${counts[1]} small ${counts[0]} air`;111+}112+113+const wantScores = process.argv.includes('--scores');114+const good = [];115+116+for (const c of CANDIDATES) {117+ const board = makeBoard(c.w, c.h);118+ const fam = { ...c, board };119+ const t0 = Date.now();120+ let sv;121+ try {122+ sv = sweep(fam);123+ } catch (e) {124+ console.log(`${c.id.padEnd(9)} FAILED ${e.message}`);125+ continue;126+ }127+ const ms = Date.now() - t0;128+ const reachable = sv.dist.has(sv.start) || sv.ecc > 0;129+ const verdict = !reachable ? 'no goal reachable'130+ : sv.ecc < 20 ? 'ceiling too low for a ladder'131+ : ms > 4000 ? 'too slow to sweep'132+ : 'OK';133+ if (verdict === 'OK') good.push(c.id);134+135+ console.log(136+ `${c.id.padEnd(9)} ${String(c.w)}x${c.h} ` +137+ `states ${String(sv.count).padStart(7)} ecc ${String(sv.ecc).padStart(4)} ` +138+ `${String(ms).padStart(5)}ms ${verdict === 'OK' ? ' ' : '>> '}${verdict}`);139+ console.log(` ${census(c.grid)}`);140+141+ if (wantScores && verdict === 'OK') {142+ // What the ladder actually serves, and the score range grade() must span.143+ const line = [];144+ for (const r of [1, 3, 6, 10, 16, 24]) {145+ const d = depthForRung(r, sv.ecc);146+ const pool = sv.buckets[d] || [];147+ if (!pool.length) continue;148+ const m = analyse(board, pool[0], sv, r * 977);149+ line.push(`r${r}:d${d}/s${m.score.toFixed(2)}`);150+ }151+ console.log(` ${line.join(' ')}`);152+ }153+}154+155+console.log(`\naccepted: ${good.join(' ')}`);@@ -0,0 +1,196 @@
1+#!/usr/bin/env python32+"""Generate the app icons.3+4+The brief was: what would Apple in 2026 ship if the art direction came from5+Apple in 1986? So the *discipline* is modern -- one subject, generous margins,6+a composition that survives being masked into a circle and read at 48 pixels --7+and the *materials* are period: a bitmap grid, Atkinson-dithered fills, hard8+black rules, and the six-stripe rainbow as a maker's mark rather than a9+decoration.10+11+Everything is composed on a 64x64 logical grid and scaled up by whole pixels,12+so the icon is made of the same chunky dots as the game rather than of smooth13+vectors that happen to be square.14+"""15+import pathlib16+import struct17+import zlib18+19+OUT = pathlib.Path(__file__).resolve().parent.parent / "icons"20+21+GRID = 64 # logical pixels; everything is drawn at this size22+23+PAPER = (0xE8, 0xF4, 0xF2)24+INK = (0x2B, 0x2B, 0x22)25+26+# The game's own piece colours.27+RED = (0xD1, 0x4A, 0x42) # the big block28+BLUE = (0x3B, 0x82, 0xC4) # tall29+GREEN = (0x4E, 0x9E, 0x5C) # wide30+ORANGE = (0xDE, 0x8A, 0x2E) # small31+32+# Apple's six stripes, 1977-1998, top to bottom.33+RAINBOW = [(0x61, 0xBB, 0x46), (0xFD, 0xB8, 0x27), (0xF5, 0x82, 0x1F),34+ (0xE0, 0x3A, 0x3E), (0x96, 0x3D, 0x97), (0x00, 0x9D, 0xDC)]35+36+# Coverage in exact eighths, as in the game: three quarters, a half, a quarter.37+TONE = {RED: 64, BLUE: 128, GREEN: 128, ORANGE: 192}38+39+40+class Layer:41+ """A logical-resolution canvas: greyscale tone, plus per-pixel overrides."""42+43+ def __init__(self, n):44+ self.n = n45+ self.tone = [[255] * n for _ in range(n)] # what gets dithered46+ self.tint = [[None] * n for _ in range(n)] # colour to print the dots in47+ self.solid = [[None] * n for _ in range(n)] # bypasses the dither entirely48+49+ def rect(self, x, y, w, h, tone=None, tint=None, solid=None):50+ for yy in range(max(0, int(round(y))), min(self.n, int(round(y + h)))):51+ for xx in range(max(0, int(round(x))), min(self.n, int(round(x + w)))):52+ if tone is not None:53+ self.tone[yy][xx] = tone54+ self.tint[yy][xx] = tint55+ if solid is not None:56+ self.solid[yy][xx] = solid57+58+59+def atkinson_flat(tone, w, h):60+ """Dither one flat tone over a w x h area. Six eighths of the error out, a61+ quarter discarded -- Atkinson, as in the game.62+63+ Run per piece rather than over the whole icon: diffusion carries error into64+ whatever lies to the right and below, so dithering the icon in one pass let65+ one block's edge streak the next one. Alone against bare paper, each block's66+ texture is a pure function of its size and tone."""67+ buf = [[float(tone)] * w for _ in range(h)]68+ dots = [[False] * w for _ in range(h)]69+ for y in range(h):70+ for x in range(w):71+ old = buf[y][x]72+ on = old >= 12873+ dots[y][x] = not on # a "dot" is ink74+ e = (old - (255 if on else 0)) / 8.075+ if e:76+ for dx, dy in ((1, 0), (2, 0), (-1, 1), (0, 1), (1, 1), (0, 2)):77+ nx, ny = x + dx, y + dy78+ if 0 <= nx < w and 0 <= ny < h:79+ buf[ny][nx] += e80+ return dots81+82+83+def compose(safe=1.0):84+ """Draw the icon on the logical grid and return it as rows of RGB tuples."""85+ n = GRID86+ L = Layer(n)87+ c = n / 2.088+89+ # Desktop: bare paper with the game's dot lattice, thinned out. At icon90+ # sizes the game's own spacing reads as noise behind the subject.91+ L.rect(0, 0, n, n, tone=255)92+ for y in range(0, n, 6):93+ for x in range(3 if (y // 6) % 2 else 0, n, 6):94+ L.solid[y][x] = INK95+96+ cell = max(5, int(round(13 * safe)))97+ tw, th = cell * 3, cell * 398+ rule_ = max(1, int(round(1.1 * safe)))99+ bar_h_ = max(2, int(round(3 * safe)))100+ bar_gap = max(3, int(round(4 * safe)))101+ # Centre the tray *and* its rainbow as one group, or the mask crops the102+ # composition off-axis.103+ group_h = th + rule_ * 2 + bar_gap + bar_h_104+ x0 = int(round(c - tw / 2))105+ y0 = int(round(c - group_h / 2)) + rule_106+107+ # The tray. A hard rule, and paper inside it, so the lattice stops at the108+ # frame exactly as the desktop stops at a window.109+ rule = rule_110+ L.rect(x0 - rule, y0 - rule, tw + rule * 2, th + rule * 2, tone=0, solid=INK)111+ L.rect(x0, y0, tw, th, tone=255, solid=False)112+ for yy in range(y0, y0 + th):113+ for xx in range(x0, x0 + tw):114+ L.solid[yy][xx] = None115+116+ # A complete packing, so there is a subject and no leftover holes to read117+ # as clutter -- and every piece size, and so every colour, appears once.118+ # R R B119+ # R R B120+ # G G O121+ pieces = [122+ (0, 0, 2, 2, RED),123+ (2, 0, 1, 2, BLUE),124+ (0, 2, 2, 1, GREEN),125+ (2, 2, 1, 1, ORANGE),126+ ]127+ gap = max(1, int(round(0.9 * safe)))128+ drop = max(1, int(round(1.4 * safe)))129+ for cx_, cy_, cw, ch, colour in pieces:130+ bx = x0 + cx_ * cell + gap131+ by = y0 + cy_ * cell + gap132+ bw = cw * cell - gap * 2133+ bh = ch * cell - gap * 2134+ L.rect(bx + drop, by + drop, bw, bh, tone=0, solid=INK) # hard shadow135+ dots = atkinson_flat(TONE[colour], bw, bh) # its own texture136+ for yy in range(bh):137+ for xx in range(bw):138+ if 0 <= by + yy < n and 0 <= bx + xx < n:139+ L.solid[by + yy][bx + xx] = colour if dots[yy][xx] else PAPER140+ L.rect(bx, by, bw, 1, tone=0, solid=INK) # outline141+ L.rect(bx, by + bh - 1, bw, 1, tone=0, solid=INK)142+ L.rect(bx, by, 1, bh, tone=0, solid=INK)143+ L.rect(bx + bw - 1, by, 1, bh, tone=0, solid=INK)144+145+ # The maker's mark: six stripes under the tray.146+ bar_w = int(round(tw * 0.82))147+ bar_h = bar_h_148+ bx = int(round(c - bar_w / 2))149+ by = y0 + th + rule + bar_gap150+ stripe = bar_w / 6.0151+ for i, colour in enumerate(RAINBOW):152+ L.rect(bx + i * stripe, by, stripe + 0.6, bar_h, tone=0, solid=colour)153+154+ return [[L.solid[y][x] or PAPER for x in range(n)] for y in range(n)]155+156+157+def png(w, h, rows):158+ raw = b"".join(b"\x00" + r for r in rows)159+160+ def chunk(tag, data):161+ return (struct.pack(">I", len(data)) + tag + data162+ + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))163+164+ return (b"\x89PNG\r\n\x1a\n"165+ + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))166+ + chunk(b"IDAT", zlib.compress(raw, 9))167+ + chunk(b"IEND", b""))168+169+170+def render(size, safe=1.0):171+ grid = compose(safe)172+ scale = size // GRID173+ rows = []174+ for y in range(GRID):175+ line = bytearray()176+ for x in range(GRID):177+ line += bytes(grid[y][x]) * scale178+ for _ in range(scale):179+ rows.append(bytes(line))180+ return png(size, size, rows)181+182+183+def main():184+ OUT.mkdir(exist_ok=True)185+ # The maskable one shrinks toward the centre so Android's circle cannot186+ # clip the tray; the plain ones use the full square.187+ for name, size, safe in (("icon-192.png", 192, 1.0),188+ ("icon-512.png", 512, 1.0),189+ ("icon-maskable.png", 512, 0.78)):190+ p = OUT / name191+ p.write_bytes(render(size, safe))192+ print("%-20s %6.1f KB" % (name, p.stat().st_size / 1024))193+194+195+if __name__ == "__main__":196+ main()