clone

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

The proof of concept has somewhere else to live

Dami committed 9b4ae6a
`mockup/` and the README go to `archive/mockup`, which is pushed and is the
only thing that needs to hold them. The mockup was locked and unimported — the
production app has been at the repository root since the port — and the README
had become mostly its design record: of 843 lines, some seven hundred described
a directory nobody builds, a release script nobody runs and a server on a port
nobody serves. Documentation that outlives what it documents is worse than
none, because it is indistinguishable from documentation that is merely long.

The `.nzip-*/` ignore goes with it. It was there because nzip published from
`mockup/`, and nothing publishes from anywhere now except Pages, from `dist/`,
on a push.

A README comes back in the next commit, written against what the repository
actually contains.
commit
9b4ae6aba89882ee9d56453b75a874297e725f74
parent
75950bfe34fab8e1f9cdf0f3732441dd7b07aac1
author
Dami <dami.etoile@gmail.com>
34 files changed, +0 -7088
M.gitignore4-
DREADME.md843--------------
Dmockup/fonts/OFL-Silkscreen.txt93--
Dmockup/fonts/OFL-SpaceMono.txt93--
Dmockup/fonts/mono.woff2bin
Dmockup/fonts/silk-bold.woff2bin
Dmockup/fonts/silk.woff2bin
Dmockup/icons/icon-192.pngbin
Dmockup/icons/icon-512.pngbin
Dmockup/icons/icon-maskable.pngbin
Dmockup/index.html164---
Dmockup/manifest.webmanifest19-
Dmockup/src/difficulty.js112--
Dmockup/src/families.js110--
Dmockup/src/game.js708-----------
Dmockup/src/ground.js140--
Dmockup/src/klotski.js138--
Dmockup/src/main.js78-
Dmockup/src/prefs.js46-
Dmockup/src/progress.js144--
Dmockup/src/render.js1858------------------------------
Dmockup/src/shading.js440-------
Dmockup/src/sound.js82-
Dmockup/src/state.js50-
Dmockup/src/store.js188---
Dmockup/src/survey.js97--
Dmockup/src/theme.js309-----
Dmockup/src/walk.js182---
Dmockup/styles.css207---
Dmockup/sw.js93--
Dmockup/tools/check.mjs337-----
Dmockup/tools/find-families.mjs155---
Dmockup/tools/gen_icons.py196---
Dmockup/tools/release.mjs202---
M .gitignore +0 -4
D README.md +0 -843
@@ -1,843 +0,0 @@
1-# Klotski2-3-An installable, offline sliding-block puzzle game built with Gleam and Lustre.4-5-The production application lives at the repository root. `mockup/` is locked as a working proof of6-concept and design record: it remains playable, but production code does not import it or extend7-it. The port carries the proof of concept's interaction model, visual language and design systems8-across intact — the same tables, the same seeds, the same arithmetic — and changes the9-*architecture* where a Lustre application wants a different boundary.10-11-## Production app12-13-A client-side Lustre application targeting JavaScript. No backend, and no network after the first14-load.15-16-```sh17-# Development server at http://localhost:123418-gleam run -m lustre/dev start19-20-# Type-check and test21-gleam check --target javascript22-gleam test --target javascript23-24-# Offline-capable production bundle in dist/25-./build.sh26-```27-28-### What is Gleam, and what is not29-30-The rule is that every *decision* is Gleam and every *capability* is not. A capability is something31-the platform owns and no expression can stand in for: a key-value store, an oscillator, a vibration32-motor, a media query, a frame callback, a worker, and the per-pixel loop of a whole-image threshold.33-A decision is which board a rung serves, what it is printed in, how hard it is, where it goes, and34-what a run was made of. Nothing in the second list is in a `.mjs` file, and nothing in the first is35-in a `.gleam` one.36-37-Two of those capabilities are load-bearing enough to name, because they are the two places the38-proof of concept's design systems have to reach across the boundary rather than living entirely39-inside it:40-41-**The generators.** `klotski/prng.gleam` is mulberry32 and FNV-1a, written out in Gleam and checked42-against the proof of concept's output to the bit. What is in FFI is the 32-bit wrap-around multiply43-and the logical shift, because a Gleam `Int` on this target is a double and cannot express either.44-The streams themselves are threaded rather than closed over, which makes their determinism45-structural instead of conventional: a rung is the same board and the same look because the same46-pair of numbers went in, and there is no hidden state that could have made it otherwise.47-48-**The pixel pipeline.** `klotski/render_ffi.mjs` composes the greyscale scene, error-diffuses or49-thresholds it, reverses the goal block's mark, reprints the tinted regions, paints the ground into50-the holes nobody drew over, and blits the sprites and the type. A one-bit picture is a whole-image51-operation — diffusion pays its error forward across the entire buffer — and there is no declarative52-way to say that. So the canvas is a managed side effect: Lustre owns the elements and the state, and53-the pipeline owns the pixels inside them. What crosses the boundary is `klotski/scene.gleam`, a54-list of drawing operations in which every choice has already been made. The renderer is handed a55-threshold table and a cell size; it has never heard of a theme, a family or a rung.56-57-### The modules58-59-- `klotski/families.gleam`, `ground.gleam`, `shading.gleam`, `theme.gleam` — the tables. Eight piece60- sets, seven backdrop stencils, eight screen shapes at five rulings, four themes with pools for61- each, and `combo_for`, which draws one of each seeded on the family and the rung. About twenty62- written-down numbers per theme, and a couple of hundred looks out of them.63-- `klotski/progress.gleam`, `difficulty.gleam` — the ladder and what difficulty means. The depth64- schedule, the XP curve and its exact inverse, how the walker's four measurements are weighed, and65- where the five marks fall.66-- `klotski/walk.gleam` — what a run was made of. Setbacks, idle moves, detours and the depth of the67- worst one, which are what the record screen is actually about.68-- `klotski/layout.gleam` — the arrangement, chosen rather than calculated. Both arrangements at69- every upscale are built and costed, and the winner is whichever gives the largest board on whole70- device pixels.71-- `klotski/paint.gleam` — the seven screens, as pure functions from layout and state to scene72- operations. Nothing here reaches for a canvas, a document or a clock.73-- `klotski/store.gleam` — progression in a versioned envelope with a monotonic counter, lifetime74- totals counted as they happen, day streaks, and a capped log.75-- `klotski.gleam` — the Lustre application. Screens, settings, pointer and keyboard input, the76- quantised clocks, and the effects.77-- `assets/solver-worker.js` — traversal, and only traversal. It sweeps a family's state space,78- keeps a sample of states at every depth, and measures a candidate with an eps-greedy walker. Which79- depth a rung asks for, how wide the window is, where in the band it starts and every seed it runs80- under are decided in Gleam and arrive in the message. It never judges how hard a board is; it81- measures one and says what it found.82-83-### The ladder is open-ended84-85-There is no list of levels anywhere, because a family carries a state space rather than a set of86-plates. One breadth-first sweep outward from the seated goal labels every reachable configuration87-with its exact distance, so a rung is nothing more than "a board at depth D" — and `TO GOAL` is an88-exact distance after every legal move, not an estimate. Rung 400 is not deeper than rung 40; it is a89-different board that is just as deep, and there are thousands of those.90-91-Both halves of the loop hold: motion is quantised to the shading period so the pattern never lands92-out of phase, the block under your thumb is shaded once into a sprite and carried so nothing boils93-while it slides, and the frame is composed only when a stepped value moves rather than when time94-passes.95-96-The UI supports direct dragging, the single-exit tap affordance, arrow-key movement, undo and reset,97-three hint levels, eight piece families, the survey/atlas/card/play/record flow, install prompting,98-versioned local records, four palettes, eight screens at five rulings, reduced-motion behaviour,99-tones and haptics. `window.klotski.solve()` walks the board down the distance field through the100-game's own input path, which is how a screenshot of a real run gets taken.101-102-## Offline PWA103-104-`./build.sh` compiles to JavaScript, then `tools/bundle.mjs` turns that into a directory that can105-be served. It uses a Gleam that is already installed and fetches a pinned one when there is not, so106-it is the deploy's build command as well as the local one and what ships is what was tested.107-108-Not `lustre/dev build`, which is the obvious choice and does not work off this machine:109-lustre_dev_tools is itself an Erlang program, so building with it needs `escript` — a runtime this110-app never uses and the deploy image does not have. The dev server still uses it, and still wants111-Erlang; nothing else does.112-113-**Every asset carries a digest of its own contents in its name**, under `/assets/`. That is what114-makes the cache correct rather than merely careful: two builds never contend for one URL, so a115-mixed cache — the new bundle beside the old stylesheet — is not unlikely, it is unrepresentable.116-Those files are then cached for a year and never revalidated. Four things keep their names on117-purpose: `index.html` is the door and needs a fixed address, `sw.js` is how an update is noticed at118-all and a hashed one would need updating in order to be updated, and the manifest and its icons are119-how an installed app is recognised.120-121-The order is the dependency order and is load bearing. A font's name is settled before the122-stylesheet that points at it is hashed, and the solver worker's before the bundle that names it,123-or a digest covers text that then changes underneath it. The worker's URL is substituted into the124-bundle before the bundle is hashed, so a bundle can only ever reach the worker it was built125-against — which matters because the two speak a protocol of their own, and an old bundle meeting a126-new worker is not an error but a conversation at cross purposes.127-128-The service worker's version is a digest of everything it precaches, so it changes exactly when the129-contents do. It was a number kept by hand and it had drifted: thirteen commits touched the app and130-three of them remembered to bump it.131-132-**A new version waits.** The worker does not call `skipWaiting`, so it installs and sits there while133-the running one carries on. The app takes it in `begin_play` — the one place a board starts,134-whichever way it was reached — because that is the only moment a reload costs a player nothing.135-Taking over on arrival would swap the code under a board somebody is halfway through, and would let136-the new worker's activation delete assets the running one still means to fetch: the solver is not137-loaded until the first solve.138-139-Serve `dist/` from an HTTPS origin (or localhost) to install it. The service worker uses a versioned cache, removes superseded140-caches on activation, refreshes same-origin resources while online, and falls back to cached141-resources or the app shell offline.142-143----144-145-## Proof-of-concept design record146-147-Everything below documents decisions made in `mockup/`. It is preserved because it explains the148-desired look and feel and the experiments behind it; it does not prescribe the production Gleam149-architecture.150-151-**Atkinson dithering.** Bill Atkinson wrote the dithering in QuickDraw for the Lisa and the152-Macintosh, and it is the reason images from 1984-88 look the way they do. It is error diffusion:153-each pixel is forced to black or white and the difference is pushed onto pixels not yet drawn.154-What makes it *Atkinson* rather than Floyd-Steinberg is that it pays out only six eighths of the155-error and discards the rest. Throwing error away is, strictly, a bug; it is also the entire156-flavour, because highlights run clean to white and shadows to solid black instead of being157-speckled, so all the texture lives in the midtones and the picture reads with far more snap. Crisp158-rather than grainy.159-160-Two consequences shape the whole renderer. Block tones are set to QuickDraw's stock greys — an161-eighth, a quarter, a half, three quarters — because Atkinson resolves those to clean even textures162-and arbitrary values between them to streaks. And because diffusion depends on the entire image,163-shifting anything by a pixel re-flows the pattern downstream of it, so the one thing that moves is164-dithered once into a sprite and carried while the rest of the scene is composed without it.165-166-**Shading, of which dithering is one kind.** This module was called `dither.js` and was the poorer167-for it: dithering is one answer to "how do you print a grey with one ink", and naming the file168-after it quietly ruled out the others. Hatching is not a dither. A comic's two-plate overprint is169-not a dither. Both are shading, and so is Atkinson.170-171-Everything that is not Atkinson is a *screen*: a small table of thresholds, tiled across the172-picture, saying in what order pixels give way to ink. That one idea covers far more than it looks173-like it should, because the difference between a dither, a halftone, a hatch and a flat fill is174-nothing but which numbers are in the table and how they are arranged — dispersed scatters the ink,175-clustered grows it into round blobs, a table varying along one axis turns tone into the width of a176-band, and a single threshold makes tone stop existing so only the reprinted colour survives. Eight177-shapes: Atkinson, dispersed, Ben-Day, ruled, diagonal, crosshatch, comic, solid.178-179-A screen has a second property independent of its shape: its *ruling*, how many pixels one cell180-covers. The same table read at one pixel a cell or at six resolves identical tone and changes only181-the grain, which is most of the difference between newsprint and a magazine. It is **not a182-setting** — it is the press the board happened to be printed on, chosen with the shape it belongs183-to. Five steps, and it can be that many precisely because nobody has to read the list.184-185-That subsumed a family. CHECKER used to be its own style and was a dispersed screen at a coarser186-ruling all along — not a different shape, a different frequency, and having it as a family meant187-two names for one picture. A dispersed screen resolves to a chequer at a half tone by188-construction, so every chequer is still reachable; it is a coordinate now rather than a species,189-and the same move buys coarse clustered, coarse ruled and coarse hatched screens that did not190-exist before.191-192-COMIC is the one that is not a threshold at all but a second pass. A colour comic was never one193-ink: each was screened separately, printed at its own angle, and very slightly out of register.194-A tint may now carry a second plate, so the block's ink is split between the piece colour and the195-same hue at full strength on a rotated screen. Not a true CMYK separation — that needs the196-pipeline to stop being one bit per region — but the same mechanism and the same look.197-198-Diffusion and the flat threshold take no ruling: error diffusion's grain *is* the pixel grid, and199-a flat threshold has no grain. They report one look each rather than five identical ones.200-201-A shape *may* have a shorter ladder than the rest — `effectiveRuling` caps every ruling against the202-shape's own top, wherever a ruling can enter — but none does. ORDERED was capped at MEDIUM for one203-release, on the theory that a dispersed screen enlarges its scatter until the eye joins it into204-rules. The faint lines were real; they were the compositor resampling a buffer that did not land on205-whole device pixels, they showed up under every screen once looked for, and capping the ladder did206-not move them. The cap is gone and the machinery stays, because the question is a fair one and207-because it is what proves, every run, that nothing is capped.208-209-**Black on white, with a menu bar.** The Macintosh was ink on paper, so this is too. Text lives210-inside a window, never on the desktop, which is how a patterned ground and legible labels stop211-being a trade. Blocks are flat with a hard offset drop shadow; an extruded 2.5D side was tried and212-merged into one muddy band down the right of the board.213-214-The desktop is not dithered either — it is not drawn at all. It used to be, as a lattice of single215-dots composed into the scene before the threshold, and that forced it to both extremes: a flat mid216-grey run through error diffusion across a whole screen is restless and faintly directional, fine217-on a small object and exhausting as a background, while 0 and 255 give diffusion no error to218-spread and so pass through untouched. That worked, and it allowed exactly one backdrop in exactly219-two colours.220-221-The scene is now cleared to transparent instead. The screens paint their opaque windows onto it,222-and every pixel still transparent when the frame is thresholded is ground. That is pixel-exact223-occlusion for the cost of one alpha test, and it moves the backdrop to *after* the threshold,224-where it is free to be any pattern in any two colours.225-226-**Colour, one per piece size.** The dither is untouched — a block is still a threshold between its227-own value and the paper, keeping exactly the texture it had; only the ink it is printed in228-changes, which is what colour QuickDraw did. The big block is red because it always has been (Cao229-Cao in Huarong Dao, the red donkey in L'Âne Rouge); the rest take the Apple rainbow's blue, green230-and orange. In the sets list this does the job the piece-count captions used to: you can see at a231-glance that *wide* is green bars and *mixed* is all three.232-233-Coverage is restricted to three quarters, a half and a quarter. Atkinson resolves those to even234-textures, while the eighths between them come out visibly streaked, and an eighth is too sparse235-for a hue to register at all — so the levels are not free to be nudged by eye.236-237-**Every block is dithered alone.** Diffusion is global by nature: error runs off one shape into238-whatever is drawn to its right and below, so a block's texture depended on its neighbours and a239-50% bar could develop a streaked patch where the error happened to pile up. Each block is now240-dithered on its own against bare paper and cached, making its texture a pure function of its size,241-tone and colour — identical in every position, and free after the first one. Sprites are alpha-cut242-to the block and its shadow so neighbours compose without one sprite's paper margin erasing the243-next one's shadow. It also means nothing boils while a block slides, which is what the mechanism244-was originally built for.245-246-**One screen, ruled across the whole picture.** Dithering each block alone is right for the247-*texture* and was wrong about the *phase*. Every sprite indexed its screen from its own corner, so248-whether two identical blocks a cell apart agreed depended on the screen's period dividing the cell249-in buffer pixels — which is arithmetic luck. At a 32-unit cell it is 96 and every period divides it;250-at 24 units it is 72 and POSTER lands 8 pixels out, BILLBOARD 24 — half a period, on a 360-wide251-phone.252-253-A ruled screen is a pure function of position, so the fix is not a bigger buffer. `shade` takes the254-window's own offset on the sheet, which is *exactly* equivalent to ruling one screen across the255-whole picture and cutting each block out of it — at no cost, because the field never has to exist.256-Diffusion takes no offset and needs none: its texture is aperiodic, so there is no phase to be out257-of. The sprite cache gains the phase as a key and, because the cell is a whole number of periods,258-that is one phase per class rather than one per square.259-260-That last part is now guaranteed rather than hoped for. `fitRuling` already stepped a ruling down261-when a block was too small to carry it; it also steps down until the period divides the cell. So a262-32-unit cell carries the whole ladder and a 24-unit cell stops at COARSE — the coarse end is263-unavailable there rather than available and out of register, which is the better failure. A screen264-that does not hold register is not a coarser screen, it is a misprint.265-266-The block is cut at the phase of the square it **rests** on, never the one it is being dragged267-through. Ink belongs to the block; a piece under a thumb carries its own across the board, and268-because a move is one cell — a whole number of periods — it lands on the pattern it left, with269-nothing to snap. That is also what makes stillness free: see below.270-271-Nothing small is ever set on the desktop pattern — including the title screen, which is a single272-centred window holding the title, the specimen board and the prompt. An About box, essentially.273-274-**The picture is composed at three buffer pixels per layout unit.** The whole app is measured in a275-~200-wide coordinate space and always has been. On a 1984 Macintosh that *was* the screen; on a276-2026 phone it is a decision, and it is the difference between a dot of the dither landing as a277-visible square and landing as grain. `DENSITY` in `render.js` composes the same picture on a finer278-grid — three times the dots per inch, with the layout, the type and the touch targets exactly where279-they were. Set it to `1` for the 1984 look.280-281-Two of its rungs are a setting, because the cost is real: the buffer grows with the square, and a282-phone that cannot keep up with nine times the pixels is better off drawing four than dropping283-frames. INTENSITY in the settings sheet offers LOW and DEFAULT — 2 and 3 — and says it in those284-terms, because nobody outside `render.js` has any reason to choose a number of buffer pixels per285-layout unit; what they can tell is whether the screen looks right and whether the phone keeps up.286-1 is not offered: at 1 the coarse half of the ruling ladder stops being reachable, so it is a287-different picture rather than a cheaper one. Changing it re-cuts every buffer and clears the sprite288-cache, which is `layout()` — the same path a rotation takes.289-290-It is a transform, not a rewrite. Nothing that measures anything changes units: the scene canvas is291-scaled and every drawing call arrives through it, so the several dozen hard-coded offsets in the292-layout code keep meaning what they meant. Only the handful of places that touch a canvas's real293-pixels know the number — the backing stores, `getImageData`, the sprite blits, the tint rectangles294-and the wipe. It has to be an integer: every rule here is one unit wide, and a fractional scale295-would put those on half pixels and hand a 1-bit picture back to the antialiaser.296-297-The rulings are deliberately *not* scaled with it — FINE at 3 is a third the physical dot it was,298-which is the point. That also turned out to be what makes the coarse end of the ladder mean299-anything. A screen needs a period and a half of the block to read as a pattern rather than as one300-or two arbitrary bands of one; at density 1 a playing board could not carry more than MEDIUM, and301-POSTER and BILLBOARD were always quietly stepped back down. Three times the buffer pixels is three302-times the room, so the whole ladder is reachable for the first time.303-304-It costs about 3ms a frame on a nine-times buffer on a laptop, because the scene outside the block305-sprites is composed entirely at 0 and 255 and so gives error diffusion nothing to propagate. On a306-phone it is several times that, which is what makes the next paragraph matter.307-308-**A buffer pixel has to land on a whole number of device pixels**, and for a while it did not. The309-picture is upscaled by S CSS pixels per layout unit and composed at DENSITY buffer pixels per unit,310-so one buffer pixel covers `S × dpr / DENSITY` device pixels — and at S=2 on a dpr-2.748 phone that311-is **1.832**. `image-rendering: pixelated` then keeps five buffer rows out of every six and doubles312-the sixth, in a rhythm that walks down the screen. Measured on a ground ruled every four buffer313-pixels: the rules came out **7 device pixels apart 203 times and 8 apart 100 times**. That is not a314-texture, it is a beat — it reads as the pattern collapsing into lines, and as a seam where the beat315-turns over. It was blamed on the dispersed screen first, and capping that screen's ladder changed316-nothing, because the screen was never the problem.317-318-So `chooseLayout` offers the scales where it lands whole, `S = DENSITY × k / dpr` for integer k, and319-prefers them. S stops being an integer and does not need to be: what has to be whole is the device320-mapping, and this makes a *layout unit* exactly `DENSITY × k` device pixels — so the one-unit rules321-the whole design is drawn with are whole pixels too, which they were not before. Same measurement322-after: **2.000** device pixels per buffer pixel, and every rule 8 apart.323-324-It is preferred rather than forced, at about an eighth of the board's size. Which multiples exist325-depends on the display: at dpr 2.748 the crisp scale is *larger* than the integer one and there is326-nothing to trade, while at dpr 2 with density 3 the only crisp scales are 1.5 and 3, and 3 leaves327-the column too narrow to hold. In the sweep across eight devices the crisp choice cost nothing at328-all — a smaller S with a larger cell came out to the same board in millimetres — but the trade is329-real and the setting interacts with the hardware: density 2 is crisp at full size on a dpr-2 screen330-where density 3 is not.331-332-**The loop draws when the picture changes, not when time passes.** Composing a frame is not cheap333-and cannot be — the scene is drawn, read back, diffused, tinted and written out, every pixel of it,334-because diffusion depends on the whole image. But nothing in this app moves smoothly: a slide is335-quantised to the dither period so the pattern never lands out of phase, the board falls into place336-in even units, the card's title arrives in five steps. A 300ms rise is *seven pictures*, however337-many times a second the display asks for one.338-339-It used to ask for all of them: any running clock marked the frame dirty, so the rise composed the340-screen 36 times to show seven things, and the other 29 were pixel-identical to the frame before.341-That is what a phone feels as a stutter — the work does not fit in a frame, so frames queue and the342-animation crawls behind its own clock. So the clocks are stepped in the loop rather than in the343-painters (`frameKey` in `game.js`), and a tick that lands on the same values as the last one draws344-nothing. 36 composites became 7, a slide's became 6, and an idle screen's became 0. Input paths mark345-the frame dirty themselves, as they always did; this governs only what the clocks may ask for.346-347-**The column is not the screen.** A phone is no longer one shape: 22:9 and 4:3, folding open into a348-square, turned on its side, and a keypad phone is a 4:3 screen the size of a card. None of that349-should reach the composition, which is a game drawn at one scale in that ~200-unit column —350-stretching the column to fit a screen is how a designed thing turns into a responsive web page. So351-the canvas covers the whole safe area, the column sits in the middle of it at the width it has352-always been, and what is left over is not a margin to apologise for: every pixel nobody paints is353-ground, so on a wide screen the leftover is the desktop, patterned and dithered, which is what a354-Macintosh with a big monitor showed. It used to be a flat band of body background.355-356-That handles every proportion in one direction and not the other. On a landscape screen height is357-the scarce dimension, and a column stacked board-over-controls has to shrink the board to nothing to358-fit its own chrome underneath — so there is a second arrangement, in which the same two windows sit359-side by side. Two windows on a desktop is not a concession to landscape; it is what this system did360-with a wide screen. The sets list and the record take a second column with it, because eight sets in361-one column of a landscape picture is three of them and a scroll.362-363-Which arrangement, and at what upscale, is *chosen* rather than calculated. Both were tried as364-formulas first and both kept being wrong at the edges: a threshold on the proportion sends a 768×800365-fold to the side-by-side arrangement, where it comes out worse than stacked, and any expression that366-gets the upscale right for a 900-tall desktop gets it wrong for a 393-tall phone on its side. There367-are about a dozen candidates and each can be costed exactly — the size of the board in real pixels,368-with a floor under how small a layout unit may get, since a unit under two CSS pixels sets a369-six-unit label at six pixels — so `chooseLayout` builds them all and takes the best. The370-arrangement falls out of that rather than being a rule anyone has to defend.371-372-The fractions that place the head and the foot of the stacked column now have floors under them,373-stated as the thing they hold — the gauge, the readout, the buttons and the air between them —374-rather than as a share of the height that happened to work at 852 tall. On a 360×640 phone the old375-share came up seven units short and drew the UNDO/RESET row fourteen pixels below the bottom of the376-screen. The head gives way first, since it is air above the board rather than anything in it.377-378-**The goal block's mark is the shading reversed, not stamped on it.** It used to be a struck ring379-in bare paper, and a struck ring is an opinion about colour: stamping paper through the middle of a380-shaded block fought whatever screen the rung drew, and a hard outline across a Ben-Day field or a381-hatch reads as damage rather than as a mark. Under almost every screen, it did.382-383-So the disc *inverts* the picture instead of contributing to it. Every pixel inside it is swapped,384-ink for paper, after the threshold and before the colour — so the mark is the exact negative of385-whatever screen the rung drew, in register with it, and comes out as the block's own red where the386-field is paper and paper where the field is inked. Under a line screen it is the stripes reversed387-inside a circle; under diffusion, a light disc carrying red dots in a red field carrying light388-ones. The wipe and the solve flash already invert the picture this way. The goal block does it in a389-circle.390-391-It was a *tone* before that — a disc laid down before the threshold and put through the same screen392-in the same ink, so what showed was a change in the density of the dots already there. The same393-idea, one step short: an inlay cannot clash with a pattern it is made of, but it does need room394-inside itself for a density to differ. At a coarse ruling the disc is barely wider than one cell,395-so it thinned out to nothing exactly where the screen was boldest, and under SOLID it could not396-appear at all — that screen discards tone by definition, and a mark made of tone then has nothing397-to be made of. Every fix for that was a special case about to be written.398-399-An inversion needs no room. It does not want two densities to tell apart, it wants one and the400-opposite of it, which is available at every ruling and under every screen — including SOLID, where401-the block is a flat plate and the mark is a clean disc of paper, and BILLBOARD, where the block is402-a handful of enormous dots and the mark is those dots photographically reversed. Same disc, same403-place, same size; the structure did not move, and the clarity stopped being something to arrange.404-405-**Toolbar icons carry their boxes.** RECORD and SETTINGS are a bar chart and a pair of sliders in406-hard-ruled squares rather than words — the way a system of this vintage drew a toolbar, and the407-reason it drew the rectangle every time: an unboxed glyph on a menu bar reads as a picture of408-something rather than as a thing to press. They are inline SVG on integer coordinates with409-`crispEdges`, in `currentColor` so they invert with the box when pressed. The canvas still has no410-SVG anywhere and should not; this is DOM chrome, which is where a vector glyph belongs.411-412-**The dev-logo** sits at the far left of the menu bar on the puzzle list, which is where a413-Macintosh put the Apple and the reason that position reads as a signature rather than as an icon414-someone left lying there. It is constant in shape and position; the one thing that varies is which415-of the theme's four piece colours fills it, drawn from the day of the year so it holds for a day416-and moves on. Like the trophy it is painted after the threshold and is never shaded — two flat417-colours, a ring in ink and a triangle in the day's colour. At fourteen pixels a screen of any418-ruling turns a ring into four dots, and a mark whose whole job is to be constant should not be put419-through a pattern that is not.420-421-It costs the layout nothing. The title shifts right by about a sixth of the bar's width and still422-clears the buttons on the right; the list below is measured from the bar's height, which has not423-moved.424-425-**A gold trophy** marks a solved board. It is queued like type and painted after the threshold, so426-it sits on top of the dither. The inked seal it replaced was ink on ink and sank into the board.427-428-**Hints are a dial, and it only subtracts.** The board can tell you exactly how far from solved429-you are, which is a strong hint and not everyone's game.430-431-| | steps | to goal | optimal | gauge |432-|---|---|---|---|---|433-| FULL | ✓ | ✓ | ✓ | scaled to where you started |434-| BEST | ✓ | — | ✓ | scaled to the furthest out you have been |435-| OFF | ✓ | — | ✓ | — |436-437-BEST is the interesting one. Dropping the number and keeping the gauge would still leak the answer438-if the gauge were scaled to the start, so it is anchored to your own deepest point instead: you439-cannot work backwards from it to the solver's count, and what it reads is how far you have come440-from your worst rather than how close you are to the end. It is the same choice the record441-screen's chart already makes, and for the same reason — going backwards is the interesting part,442-and a scale that cannot show it hides it. The gauge's left label changes from START to DEEPEST to443-match, because a scale labelled with the wrong end is worse than no label.444-445-Steps and optimal survive every setting: one is a record of what you did, the other is printed on446-the card before you start, so neither is a hint.447-448-**Type is not art and is not dithered.** It is drawn on a second canvas at full device resolution449-in flat ink. Thresholding a typeface turns it to gravel, which is survivable for a title and450-unreadable for anything else. Tiny5 for display, DM Mono for labels. Tiny5 ships one weight, so a451-title is not a heavier caption but a different kind of letter — which is the stronger signal452-anyway: a bitmap face at fourteen pixels cannot hold a bold convincingly, and the two it does hold453-end up looking like the same word printed twice.454-455-**What the system already knows, it is not asked again.** Two questions have been answered456-somewhere else and are read live rather than at boot, so a phone that goes dark at sunset does not457-wait for a reload.458-459-`prefers-color-scheme` is the theme row's AUTO, which is the default for anyone who has not chosen.460-It resolves to one pair — MACINTOSH and NIGHT — rather than to a rule over the whole table: PAPER is461-a warmer version of the same light idea and AMOLED is a deliberate extreme, and neither is what462-someone means when they have simply told their phone they prefer dark. They mean the house look, in463-the dark, which is the pair the tables were written as. AUTO is *stored* as AUTO, never resolved and464-written down, or the app would remember a decision the person never made. Pinning a theme wins over465-the system, permanently, because that is what pinning is.466-467-`prefers-reduced-motion` stops everything that moves to be nice: the board is in place instead of468-falling into it, a block arrives where it was sent instead of sliding there, the refused-move jolt469-is not drawn, the card's title has already arrived, and the solve's inverting wipe and its struck470-frame — a full-screen flash, the one thing here that can actually hurt someone — do not happen at471-all. What does not stop is anything carrying information: the sweep still counts up, because that is472-a progress bar and a still screen through a second and a half of work is a worse answer. Sounds and473-haptics are untouched; they were never motion. There is no setting for it, because "reduce motion"474-is not a preference about this app, it is a fact about the person, and offering to put it back is475-offering to ignore them.476-477-The two paths draw the *same picture*, and that is a property rather than a hope: block ink is cut478-at the square a piece rests on, so a piece that jumps and a piece that slides show identical dots on479-arrival. Verified by screenshot — the same three moves, animated and still, are byte-identical PNGs.480-Had the phase been carried through the tween instead, stillness would have been a second rendering481-that nobody reviews, which is the footgun this arrangement exists to not build.482-483-**One theme object, and four things a board rolls for.** Every colour in the app is written down484-in `src/theme.js` and nowhere else. A theme owns its ink and paper, its four piece colours, its485-gold — and four *pools*: which backdrop stencils belong to it, which colour pairs those are486-printed in, which screen shapes the blocks may take, and how coarse those screens may be. A board487-draws one of each, seeded on its family and rung by the same pair that decides which board it is,488-so a rung always looks the way it looked and a ladder walks through combinations nobody authored.489-Seven backdrops, six grounds and thirty-two block looks (six shapes at five rulings, plus the two490-that take none) is **1344 per theme** out of about twenty written-down numbers, which is the whole491-reason for doing it this way rather than drawing 1344 backdrops.492-493-The rolling is confined to the board — the card, the puzzle and the record. The atlas, the title494-and the record-of-everything are chrome, and chrome that shifts under you every time you open it is495-not chrome.496-497-The one exception is the board *inside* an atlas card, which is drawn in the screen that rung will498-actually be played in. The card already showed the exact position waiting behind it; showing the499-look as well means the list reads as eight different places rather than eight different sizes of500-the same one.501-502-**Coverage is true by construction**, and it took a bug to find out why that matters. A block is503-shaded alone in its own sprite, so its fill samples whatever window of the tiled screen happens to504-lie under it — fine when the window is many periods across, and quietly wrong when it is not. At505-the extreme a 1x1 piece under a coarse screen came out entirely bare paper and vanished off the506-board. Measured across every block size and screen, four of the five failures were the ruled507-screen, which is what you would predict: it varies along one axis, so a block's height alone508-decides how much of the screen it ever sees.509-510-The fix is not to scale the screen to the block. A press does not print the small object at a511-finer ruling than the big one, and doing so made two pieces of one board look like two different512-printing jobs. Instead, pixels of a given tone are grouped and the group is cut at whichever513-threshold lands closest to the intended proportion of ink, with the cut always falling *between*514-levels rather than through one — so a band stays a band, a dot stays round, and a tone that asked515-for ink always gets some. The screen's shape is untouched; only how much of it is inked is decided516-by proportion rather than by accident.517-518-One floor survives that: a block landing inside a *single* cell has one threshold and therefore519-two possible outcomes, neither of which is a quarter. So a board steps its ruling down until its520-smallest piece spans at least two cells — one ruling for the whole board, taken from its cell, so521-every piece is still printed on the same press.522-523-Piece colours are deliberately *not* pooled. The 2x2 is Cao Cao; a goal block that changes colour524-between rungs stops being a thing you recognise and starts being a thing you have to re-read.525-Holding them still is also what keeps the ground pools designable — each ground has to be checked526-against one known set of four accents plus ink, rather than against every set.527-528-A "night" palette is only the two values swapped; nothing in the renderer needs to know which way529-round they are.530-531-| | ink | paper | |532-|---|---|---|---|533-| Macintosh (default) | `#2B2B22` | `#E8F4F2` | tinted papers, ink pulled toward each |534-| Paper | `#2E3037` | `#EBE5CE` | creams and linens |535-| Night | `#DCE4E2` | `#1C2028` | grounds sit *below* the paper, so panels are the lit thing |536-| AMOLED | `#DCF2FF` | `#000000` | pure black grounds, neon stencil over them |537-538-AMOLED is the one theme that breaks the rule above — that the paper is always tinted and the ink539-always has a cast, which is most of why a 1-bit image reads as a printed thing rather than a540-screenshot. It breaks it on purpose: on an OLED panel a black pixel is a pixel that is off, and541-`#1C2028` is not off. What replaces the tint is the ground, which is where its colour lives —542-pure black with a neon stencil at one pixel in sixteen or fewer, so the colour arrives as a field543-of light on true black rather than as a wash across it. Its piece colours are its own for the same544-reason the other three themes share theirs: the Apple rainbow was chosen against paper and goes545-muddy against black.546-547----548-549-## What is actually real550-551-Three things are not faked, because the feel depends on them.552-553-**The solver.** A full breadth-first sweep of each piece set's configuration graph. Configurations554-are stored as the grid of *shape classes*, not piece identities, since same-shaped pieces are555-interchangeable; twenty cells of five classes packs to 5²⁰, under 2⁵³, so a plain number is both an556-exact key and its own hash. The classic board comes out at **25,955 reachable configurations and557-116 unit steps** — the published figures.558-559-**The generation.** Sliding-block moves are reversible, so the graph is undirected, so a BFS560-*outward from the goal* labels every configuration with its exact distance to a solved board — and561-every state at depth *k* is a puzzle whose optimal solution is exactly *k* steps. One sweep per562-piece set yields that set's entire difficulty-stratified catalogue. Every board in the game is563-drawn from that field. Nothing is hand-placed.564-565-A practical note that cost some time: a hand-written packing usually is *not* solvable. Most dense566-arrangements strand the big block in a component containing no solved state at all. Sets whose567-reference packing is already solved are swept outward from it, which makes reachability free.568-569-**The difficulty.** Not solution length, which correlates only weakly with how long a person570-actually takes, and emphatically not solver effort, which is worse. The load-bearing term is an571-ε-greedy **hitting time**: a walker that mostly steps downhill but strays, whose expected step572-count absorbs branching factor, corridor width and the quality of the local gradient at once. It573-is combined with the count of *counter-intuitive* steps — moves on the optimal path that carry the574-big block away from its exit. Depth picks the tier; complexity picks which board of that depth you575-actually get.576-577-The state space is surfaced rather than hidden, since that is half the appeal of the puzzle: a578-live distance-to-goal gauge, and a post-solve chart of distance against steps, against the579-straight line a perfect solver draws.580-581-A per-move assist was tried and cut. It marked each legal move by whether it closed the distance582-to the goal, which sounds useful and is not: a Klotski move is one block in one direction, so the583-marks answered a question nobody was asking while saying nothing about the sequence that actually584-matters.585-586-### The sets587-588-A family is a board, a piece multiset and a goal — the unit of generation. Every packing shipped is589-a *solved* one, because sweeping outward from a solved state makes reachability free, and most590-hand-written packings are not puzzles at all: they strand the big block in a component containing no591-solved state. `tools/find-families.mjs` sweeps each candidate and reports what its state space592-actually looks like; four of the thirteen tried were rejected outright.593-594-| Set | Board | Pieces | States | Ceiling |595-|---|---|---|---|---|596-| Classic | 4×5 | the traditional set | 25,955 | 126 |597-| Narrow | 3×5 | 1 wide, 7 small | 864 | 24 |598-| Compact | 4×4 | 2 wide, 4 small | 12,390 | 27 |599-| Span | 5×4 | 4 tall, 4 small, four cells of air | 92,400 | 33 |600-| Wide | 4×5 | 4 wide, 6 small | 23,704 | 80 |601-| Yard | 5×4 | 1 tall, 4 wide, 4 small | 25,955 | 101 |602-| Corner | 4×5 | as Mixed, leaving by the corner | 81,462 | 129 |603-| Mixed | 4×5 | 2 tall, 3 wide, 4 small | 81,462 | 178 |604-605-Classic leads the list because it is what the word on the title screen promises; the rest are606-ordered by ceiling, so the list past it is a ramp. Classic's own packing is the landmark: 116 unit607-steps from home. Corner is worth noting as the608-cheapest new family there is — the same pieces as Mixed with one goal cell moved, which is a609-different distance field and so a different game.610-611-Board dimensions are per-family, bounded by the key: cells pack in base five and a key must stay612-under 2⁵³, so **22 cells is the ceiling**. 4×6 and 5×5 would need a wider key and are the next axis613-if the roster ever wants them.614-615-### The ladder616-617-Called a **stage** in the interface, not a rung — the word in the code is the mechanism, the618-word on screen should be the one a player already owns.619-620-Each family is an open ladder rather than a fixed set of tiers. A rung is just "a board at depth621-D", and the schedule turns a rung number into that depth:622-623-```624-base = clamp(round(ecc × 0.22), 5, 14)625-step = max(2, round((ecc − base) / 18))626-D(r) = min(ecc, base + step × (r − 1))627-```628-629-Linear, and deliberately so. An exponential ramp read fine on paper and was wrong in the hand: on630-the classic board it served 13 steps and then 33, so the second puzzle you ever saw was two and a631-half times the first. A fixed step gives every family the same shape of arc — an opening board of632-five to fourteen steps, about eighteen rungs of growth, then the ceiling — whether that ceiling is633-24 or 178.634-635-On what "endless" honestly means: the **ladder** has no end, but depth does. Nothing can sit further636-from a solved board than the family's eccentricity. So once the schedule arrives there it keeps637-drawing fresh boards from the deep band, ranked by complexity rather than by length. Rung 400 is not638-deeper than rung 40; it is a different board that is just as deep, and there are thousands of those.639-Below the ceiling a rung's depth is exact; at it, a few steps of slack buys real variety, because640-states at the exact maximum distance are rare enough that insisting on them would serve the same two641-boards forever.642-643-Sweeps happen when a family is first opened, not at boot — eight families is over a second of644-breadth-first search, and making someone wait through all of it to play one board is the wrong645-trade when they may only ever open two. Boards are generated lazily for the same reason: weighing a646-candidate costs forty walker rollouts.647-648-### The record649-650-Every solve is worth XP for the shape of the board and how cleanly it was taken — depth and651-complexity set the base, efficiency scales it, and replaying a cleared rung is worth a quarter.652-Level is that total on a gentle power curve (`45 × (n−1)^1.7`), so later levels cost more and none653-of them wall off. It is a single global number; per-family mastery is the rung, which is the thing654-that actually decides what you are served next. The progression is a loop, not a readout.655-656-Solve time is recorded and never shown while playing. Lifetime totals are counted as they happen657-rather than derived, because the history log is a capped ring buffer — it is enough to draw a chart,658-not enough to answer "how many have I ever solved".659-660-The perfect-solver reference on the descent chart is gold and painted after the threshold, like the661-trophy: drawn into the scene it was ink on ink, and the mass of your own run covered the very line662-it is meant to be read against.663-664-The descent itself is one silhouette rather than a row of bars, at every length. The columns used to665-be given a pixel of daylight once they were wide enough to spare it, which made the chart two666-different pictures: a hundred-step run packed to a pixel a column and read as a solid falling mass,667-and a fourteen-step one opened out into fourteen tally marks. The mass is the one worth having — a668-run of play is a continuous thing, not fourteen unrelated measurements — so the columns meet, and669-what separates them is the step in the top edge where the distance actually changed. At one pixel a670-column that is a ragged ridge and at ten it is a staircase; it is the same picture either way.671-672-Its height is the room, not a fraction of it. It was a share of the plate, and the plate is the673-*board's* — a five-wide set gets a wide short one and a three-wide set a narrow tall one, so the same674-run was drawn at a different size on every family, and on the square plates the ridge sat stranded in675-the middle of the window with a hand's width of nothing under it. There is nothing else in that676-window: the chart takes everything between the set's name and the caption, on every board.677-678-**A solved board is described by what cannot be worked out from the two numbers already on it.**679-Steps against optimal says how much a run cost; it does not say what on. And it very nearly says the680-rest by itself: a step back has to be re-walked, so it costs two, and where a family offers no idle681-moves — classic offers one every eighty states — "wrong moves" is just the gap between steps and682-optimal, halved. Printing that would be printing the same fact twice.683-684-So the panel under the chart reads YOUR STEPS, OPTIMAL, DETOURS, DEEPEST. A detour is one excursion685-above the closest you had already been — one bump on the ridge — and DEEPEST is how far back the686-worst of them put you. Four setbacks can be four separate stumbles or two holes two deep, and only687-those two numbers say which. The XP that used to sit in that slot is a function of the ones beside688-it rather than a fact about the run, and it is on the record screen with the other lifetime figures.689-690-The record screen shows level and XP, then lifetime solved / clean / detours over efficiency /691-played / day streak — what you have solved on one row, how you play on the next — a per-family table692-of rung, solves and deepest board, a chart of recent depths, and the total number of configurations693-mapped this session, which uses `count`, `ecc` and `widths` off the sweep and is the one number that694-says why this puzzle is interesting in the first place. CLEAN is solves that never went backwards at695-all, and the recent-depth chart carries a pip under every bar that was one: the bar says how deep696-the board was, the pip says the descent was a straight line, and the second is worth about a pixel.697-Both are counted as they happen rather than derived from the log, which is capped — deriving them698-would quietly mean "of the last hundred and forty".699-700----701-702-## Running it703-704-```sh705-cd mockup && python3 -m http.server 8731 # ES modules and the service worker need a server706-```707-708-Then open `http://localhost:8731/`. It installs, and plays with the radio off.709-710-Regenerate the launcher icons:711-712-```sh713-python3 mockup/tools/gen_icons.py714-```715-716-The icon answers a specific brief: what would Apple in 2026 ship if the art direction came from717-Apple in 1986? The discipline is modern — one subject, generous margins, a composition that718-survives being masked into a circle and read at 48 pixels — and the materials are period: a 64×64719-bitmap grid scaled by whole pixels, Atkinson-dithered fills in the game's own piece colours, hard720-black rules, and the six-stripe rainbow as a maker's mark rather than decoration. The board shown721-is a complete 3×3 packing, so there is a subject and no leftover holes to read as clutter, and722-every piece size — and so every colour — appears exactly once.723-724-`window.klotski` is exposed for inspection over `chrome://inspect`. `walkOptimal()` walks a board725-down the distance field to the goal; `walkWandering({ wrong, idle, seed })` walks it badly on726-purpose — `wrong` moves that take you further away, `idle` moves that change nothing, everything727-else descending, seeded so a run can be asked for twice and arrive twice.728-729-The second one exists because the descent chart is mostly about the bumps and there was no way to730-produce one. Every screenshot of a solved board showed a perfect straight line, which is the one731-shape a real run never has, so the chart was only ever reviewed in the case it is not drawn for.732-The moves come from `walk.js`, which is also what the headless checks walk, so what a screenshot733-shows is what a check asserts — the harness feeds them through the game's own input path, and the734-trace, the history, the sounds and the record all happen as they would under a thumb.735-736-The checks assert the two identities a finished run satisfies, since a unit move changes the737-distance to the goal by exactly −1, 0 or +1:738-739-```740-descents = optimal + setbacks741-steps = optimal + 2 × setbacks + idle742-```743-744-Break either and the distance field is not a unit-step breadth-first one, or the walker is playing745-illegal moves. Two things fell out of measuring it. An idle move is not always available and how746-often it is depends entirely on the family — along classic's optimal corridor one state in eighty747-offers one, while on the 5×4 sets more than half of all moves are idle. And a *detour* is not a748-*setback*: two setbacks in a row are one excursion two deep, which is one bump on the chart and not749-two.750-751-The service worker is cache-first, so a stale build will be served to anything that has visited752-before — including automated screenshots, which is worth an hour of confusion exactly once. Open753-test browser contexts with `serviceWorkers: 'block'`.754-755-### Shipping756-757-```sh758-cd mockup && node tools/release.mjs "what changed" --title "Klotski: short headline"759-```760-761-Bump, deploy, notify — in that order, and never by hand. `--dry` stops after the bump, `--no-notify`762-after the deploy.763-764-The version is not a number anyone increments. It is derived:765-766-```767-klotski-2026-08-06-29bd5bb5768- │ └── sha256 of every file sw.js precaches, and of the list itself769- └── the day it shipped770-```771-772-This exists because of a bug that already happened. The worker keys its cache on `VERSION`, so a773-build whose files changed but whose `VERSION` did not is served from the old cache forever: the774-deploy succeeds, the URL is right, and every installed copy keeps showing yesterday's app. Nothing775-warns you, and it is invisible on exactly the devices that matter most, because those are the ones776-that installed it. Deriving the version from content makes that impossible rather than unlikely —777-forgetting to bump is not a thing that can be done.778-779-The date leads because a cache name is something you read in devtools at two in the morning, and780-`2026-08-06` tells you what `v17` never did. It also sorts. Hand-counted versions were the781-alternative and they have no ceiling: a mockup under daily revision reaches v300 in a year, and not782-one of those numbers means anything beyond "later than the one before".783-784-`sw.js` is deliberately not hashed — it is not in `SHELL` and cannot be, since hashing the file that785-carries the hash never settles. That is also the correct behaviour rather than a compromise: a786-change to the worker's own logic ships as a byte-different worker script, which the browser installs787-on its own terms, and if no precached file moved then the old cache is still the right one to keep.788-789-Checks run before the bump, not after, so a failure cannot leave the tree carrying a version that790-was never deployed. A dirty working tree is a warning rather than a refusal — previewing uncommitted791-work is legitimate, but the live site silently matching no commit is not.792-793-### On a phone794-795-A service worker needs a secure context, and your machine's LAN address is not `localhost` from796-the phone's point of view, so either forward the port over USB —797-798-```sh799-adb reverse tcp:8731 tcp:8731 # then open http://localhost:8731 on the device800-```801-802-— or use the deployed build. Chrome's install criteria are the gate to check against; Lighthouse's803-PWA score was removed and no longer means anything.804-805----806-807-## Deliberate limits808-809-The sweep runs on the main thread in per-frame slices (about a second across all three sets)810-rather than in a worker. That is a mockup decision: the loading screen is *designed* as a beat you811-watch, and it kept the transfer of a large distance map out of scope.812-813-Progression is `localStorage` under a versioned envelope with a monotonic counter — the shape a814-later sync wants (last-write-wins, union of solved boards, minimum of best steps) without any of815-the machinery. Treat client storage as a cache, not a record: iOS evicts it.816-817-Nothing here is built or bundled. There is no build step at all, on purpose.818-819----820-821-## Next822-823-The production app, informed by the same research: Vite and TypeScript; the solver moved into a824-worker with packed bitboards and a typed-array visited set; per-set manifests with per-tier825-reservoir sampling so the sweep does not have to run at boot; difficulty tiers recalibrated against826-real solve times, which is what took comparable models from ρ≈0.5 to ρ≈0.95 in the literature;827-`vite-plugin-pwa` for the shell; `idb-keyval` for the record; and the state-space map, drawn as828-concentric rings by distance, which is O(V) and legible where a force-directed layout is neither.829-830----831-832-## Credits833-834-Tiny5 (Stefan Schmidt) and DM Mono (Colophon Foundry), both under the SIL Open Font License — see835-`assets/fonts/`. Subset to Latin-1 and served locally; nothing is fetched at runtime. The mockup is836-locked and keeps the pair it was drawn with, Silkscreen (Jason Kottke) and Space Mono, under the837-same licence in `mockup/fonts/`.838-839-An earlier pass used IM Fell DW Pica, the face *Return of the Obra Dinn* sets its text in, with a840-screen-space noise background. Both were abandoned: on a phone the thresholded serif was gravel at841-anything below a title, and interleaved-gradient noise — a 3D-renderer technique — laid visible842-diagonal banding across flat tone that read as a projected dot pattern rather than a dithered843-one.
D mockup/fonts/OFL-Silkscreen.txt +0 -93
@@ -1,93 +0,0 @@
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.
D mockup/fonts/OFL-SpaceMono.txt +0 -93
@@ -1,93 +0,0 @@
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.
D mockup/fonts/mono.woff2 +0 -0

Binary file.

D mockup/fonts/silk-bold.woff2 +0 -0

Binary file.

D mockup/fonts/silk.woff2 +0 -0

Binary file.

D mockup/icons/icon-192.png +0 -0

Binary file.

D mockup/icons/icon-512.png +0 -0

Binary file.

D mockup/icons/icon-maskable.png +0 -0

Binary file.

D mockup/index.html +0 -164
@@ -1,164 +0,0 @@
1-<!doctype html>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- <!-- Boxed icons, the way a system of this vintage drew a toolbar: a hard62- rectangle with a glyph struck inside it, rather than a word. Drawn as63- inline SVG on integer coordinates with crispEdges, so the strokes64- land on whole pixels like everything else, and in currentColor so65- they invert with the box when it is pressed.66-67- The canvas has no SVG anywhere and should not; this is DOM chrome,68- which is exactly where a vector glyph belongs. -->69- <button id="btnStats" class="iconbtn" aria-label="Record" title="RECORD">70- <svg viewBox="0 0 9 9" aria-hidden="true" focusable="false">71- <rect x="0" y="6" width="2" height="3"/>72- <rect x="3.5" y="3" width="2" height="6"/>73- <rect x="7" y="0" width="2" height="9"/>74- </svg>75- </button>76- <button id="btnSet" class="iconbtn" aria-label="Settings" title="SETTINGS">77- <svg viewBox="0 0 9 9" aria-hidden="true" focusable="false">78- <rect x="0" y="2" width="9" height="1"/>79- <rect x="2" y="0" width="2" height="5"/>80- <rect x="0" y="6" width="9" height="1"/>81- <rect x="5" y="4" width="2" height="5"/>82- </svg>83- </button>84- </div>85-86- <div id="gauge" class="zone">87- <span class="lbl" id="gaugeFrom">START</span><span class="spacer"></span><span class="lbl">SOLVED</span>88- </div>89-90- <div id="hud" class="zone">91- <div class="stat"><span class="val" id="hSteps">0</span><span class="lbl">STEPS</span></div>92- <div class="stat" id="statDist"><span class="val" id="hDist">0</span><span class="lbl">TO GOAL</span></div>93- <div class="stat"><span class="val" id="hOpt">0</span><span class="lbl">OPTIMAL</span></div>94- </div>95-96- <!-- What the run was made of, which is what the chart above it is drawing.97- The XP that used to sit here is a function of these numbers rather than98- a fact about the run, and it is on the record screen where the rest of99- the lifetime figures are. -->100- <div id="record" class="zone">101- <div class="stat"><span class="val" id="rSteps">0</span><span class="lbl">YOUR STEPS</span></div>102- <div class="stat"><span class="val" id="rOpt">0</span><span class="lbl">OPTIMAL</span></div>103- <div class="stat"><span class="val" id="rDetours">0</span><span class="lbl">DETOURS</span></div>104- <div class="stat"><span class="val" id="rDeepest">0</span><span class="lbl">DEEPEST</span></div>105- </div>106-107- <div id="acts" class="zone">108- <button id="btnUndo">UNDO</button>109- <button id="btnReset" class="ghost">RESET</button>110- <button id="btnNext">NEXT</button>111- <button id="btnAtlas" class="ghost">PUZZLES</button>112- </div>113-114- <div id="gate" class="zone"><span class="val beat">TAP TO BEGIN</span></div>115-116- <div id="sheet">117- <!-- The three that change how the picture looks come first and together:118- what it is printed in, what the blocks are printed with, and how119- finely. Reading them apart, with the hints and the sound in between,120- made three settings about one thing look like three unrelated ones.121-122- Filled by game.js from THEMES, so adding a theme is a one-object123- edit and never a markup one. -->124- <div class="group">125- <div class="grouplbl">THEME</div>126- <div class="row" id="themeRow"></div>127- </div>128- <!-- A pull-down rather than a row of buttons: eight shapes plus AUTO129- wrapped into three ragged lines and read as a wall. Built by game.js130- out of real elements, not a <select> -- a native menu is the one131- control the platform insists on drawing in its own house style, and132- it would be the only rounded, shaded, system-blue thing on screen.133- The ruling is not here at all; it is not a preference. -->134- <div class="group">135- <div class="grouplbl">BLOCKS</div>136- <div class="menu" id="shadeMenu">137- <button class="menutitle" id="shadeTitle" aria-haspopup="listbox" aria-expanded="false"></button>138- <div class="menulist" id="shadeList" role="listbox"></div>139- </div>140- </div>141- <div class="group">142- <div class="grouplbl">INTENSITY</div>143- <div class="row" id="densityRow"></div>144- </div>145- <div class="group">146- <div class="grouplbl">HINTS</div>147- <div class="row" id="hintRow"></div>148- </div>149- <div class="group">150- <div class="grouplbl">SOUND</div>151- <div class="row">152- <button data-toggle="sound">TONES</button>153- <button data-toggle="haptics">HAPTICS</button>154- </div>155- </div>156- <div class="credit" id="credit"></div>157- <button id="btnClose">CLOSE</button>158- </div>159- </div>160-</div>161-162-<script type="module" src="./src/main.js"></script>163-</body>164-</html>
D mockup/manifest.webmanifest +0 -19
D mockup/src/difficulty.js +0 -112
@@ -1,112 +0,0 @@
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-}
D mockup/src/families.js +0 -110
@@ -1,110 +0,0 @@
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);
D mockup/src/game.js +0 -708
@@ -1,708 +0,0 @@
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, setDensity, DENSITIES,28-} from './render.js';29-import { THEMES } from './theme.js';30-import { PATTERNS } from './shading.js';31-import { makeWalker, traceStats } from './walk.js';32-import { prefersStill } from './prefs.js';33-34-const $ = id => document.getElementById(id);35-const cvs = $('board');36-37-/* ----------------------------------------------------------------- the loop */38-let lastT = 0;39-40-export function start() {41- layout();42- requestAnimationFrame(loop);43-}44-45-function loop(now) {46- requestAnimationFrame(loop);47- tick(now);48- if (consumeDirty()) renderFrame();49-}50-51-/* Everything the next picture will be made of, as one value to compare against52- * the last one.53- *54- * The clocks advance every frame; the picture does not. Nothing in this app55- * moves smoothly -- a slide is quantised to the dither period so the pattern56- * never lands out of phase, the board falls into place in even units, the57- * card's title arrives in five steps, the jolt is a whole number of units --58- * so a 300ms rise is seven pictures and a slide is a handful, however many59- * times a second the display asks for one.60- *61- * Composing the screen is not cheap and cannot be: the scene is drawn, read62- * back, error-diffused, tinted and written out, every pixel of it, because63- * diffusion depends on the whole image. Doing that five times for the same64- * picture is what a phone feels as a stutter -- the work does not fit in a65- * frame, so the frames queue and the animation crawls behind its own clock.66- *67- * So the loop renders when the picture changes rather than when time passes.68- * Input paths still mark the frame dirty themselves, as they always did; this69- * governs only what the clocks are entitled to ask for. */70-let lastKey = '';71-function frameKey() {72- const a = App.anim, sv = App.pending;73- return [74- App.screen, App.rise, App.shakeOff,75- a ? `${a.axis}${a.cur}` : '',76- App.cardStep, App.cardT > 240 ? 1 : 0,77- fx.flash > 0 ? 1 : 0,78- fx.wipe >= 0 ? Math.round(fx.wipe) : -1,79- sv ? `${sv.phase}:${sv.count}` : '',80- ].join('|');81-}82-83-function tick(now) {84- const dt = Math.min(48, now - lastT || 16);85- lastT = now;86-87- if (App.anim) {88- const a = App.anim;89- a.t += dt;90- const k = Math.min(1, a.t / a.dur);91- const e = 1 - Math.pow(1 - k, 3);92- // Quantised to the dither period, so the pattern never lands out of phase.93- a.cur = Math.round((a.from + (a.to - a.from) * e) / PERIOD) * PERIOD;94- if (k >= 1) { const done = a.done; App.anim = null; if (done) done(); }95- }96- if (App.shake > 0) App.shake = Math.max(0, App.shake - dt * 0.05);97- if (App.riseT < 1) App.riseT = Math.min(1, App.riseT + dt / 300);98- if (fx.flash > 0) fx.flash -= dt;99- if (fx.wipe >= 0) {100- fx.wipe += dt * 0.9;101- if (fx.wipe > Math.max(view.VW, view.VH)) fx.wipe = -1;102- }103- if (App.screen === 'card') {104- App.cardT += dt;105- if (App.cardT > 1300) beginPlay();106- }107- if (App.screen === 'survey') runSurvey();108-109- /* The stepped form of each clock, worked out here rather than in the painter110- * that used to do it: the loop has to know what the picture will be before it111- * decides whether to draw one, and two places rounding the same clock their112- * own way is how they drift apart. Last, so that anything above -- beginPlay113- * off the card timer, an animation's done callback -- is already accounted114- * for in the frame it happened in. */115- App.rise = App.riseT < 1 ? Math.round((1 - App.riseT) * 12 / 2) * 2 : 0;116- App.shakeOff = App.shake > 0 ? Math.round(Math.sin(App.shake * 0.9) * 2) : 0;117- // The card's title arrives in five steps, or has already arrived. The card118- // itself keeps its pause either way: it is a screen naming the board, not an119- // effect, and cutting it would be cutting information rather than motion.120- App.cardStep = prefersStill() ? 4 : Math.min(4, Math.floor(Math.min(1, App.cardT / 280) * 5));121-122- const key = frameKey();123- if (key !== lastKey) { lastKey = key; requestRender(); }124-}125-126-/* --------------------------------------------------------------- the sweep */127-function runSurvey() {128- if (!App.pending) return;129- const t0 = performance.now();130- while (performance.now() - t0 < 9) { // a slice per frame; the screen keeps breathing131- if (App.pending.step(2200)) {132- const sv = App.pending;133- App.surveys[sv.fam.id] = sv;134- App.pending = null;135- openRung(sv.fam, famStats(sv.fam.id).rung);136- return;137- }138- }139-}140-141-/* ------------------------------------------------------------------- flow */142-function go(screen) {143- App.screen = screen;144- $('sheet').classList.remove('open');145- placeDom();146- requestRender();147-}148-149-/** Open a family at whichever rung it has reached, sweeping it first if new. */150-function openFamily(fam) {151- App.fam = fam;152- App.board = fam.board;153- Sound.page();154- if (!App.surveys[fam.id]) {155- App.pending = makeSurvey(fam);156- go('survey');157- return;158- }159- openRung(fam, famStats(fam.id).rung);160-}161-162-function openRung(fam, rung) {163- App.fam = fam;164- App.board = fam.board;165- App.sv = App.surveys[fam.id];166- App.rung = Math.max(1, rung);167- App.pz = puzzleFor(fam, App.sv, App.rung);168- App.cardT = 0;169- go('card');170-}171-172-function beginPlay() {173- const pz = App.pz;174- const b = App.board;175- layoutBoard(App.fam);176- App.startState = App.state = pz.state;177- App.grid = decode(b, pz.state, new Uint8Array(b.n));178- App.pieces = piecesOf(b, App.grid, []);179- App.steps = 0;180- App.history = [];181- App.trace = [App.sv.dist.get(pz.state)];182- App.drag = null;183- App.anim = null;184- // The board falls into place, unless the system asked for stillness -- then185- // it is simply in place, which is where the fall was always going.186- App.riseT = prefersStill() ? 1 : 0;187- App.startedAt = Date.now();188- go('play');189- syncHud();190-}191-192-function win() {193- const m = App.pz.metrics;194- const before = levelFromXp(save.totals.xp);195- const st = traceStats(App.trace);196- const gain = record({197- famId: App.fam.id, rung: App.rung,198- optimal: m.L, steps: App.steps,199- ms: Math.max(0, Date.now() - App.startedAt),200- score: m.score,201- // Kept with the solve rather than recomputed later: the trace is a session202- // thing and the log is a capped ring buffer, so the shape of a run is only203- // knowable at the moment it finishes.204- detours: st.detours, worstDetour: st.worstDetour,205- });206- const after = levelFromXp(save.totals.xp);207- App.lastGain = { ...gain, level: after, levelled: after > before };208-209- Sound.solve();210- buzz(App.lastGain.levelled ? [24, 40, 90, 40, 120] : [24, 40, 90]);211- // The invert wipe, spent once per solve -- and skipped entirely under reduced212- // motion, along with the pause it exists to fill. It is the whole screen213- // turning over; there is no gentler version of it that is still it.214- if (prefersStill()) { go('won'); return; }215- fx.wipe = 0; fx.wipeAxis = 'x';216- setTimeout(() => go('won'), 330);217-}218-219-/* ------------------------------------------------------------------ moves */220-function commitStep(p, dx, dy) {221- applyMove(App.board, App.grid, p, dx, dy);222- p.x += dx; p.y += dy;223- App.state = encode(App.board, App.grid);224- App.steps++;225- App.trace.push(App.sv.dist.get(App.state));226-}227-228-function performMove(p, dx, dy, cells) {229- App.history.push({ piece: App.pieces.indexOf(p), dx, dy, cells });230- for (let i = 0; i < cells; i++) {231- commitStep(p, dx, dy);232- Sound.step(i, i * 0.055); // one degree of the scale per cell travelled233- }234- buzz(8);235- syncHud();236- // A move changes the board, so the board is repainted. In normal play the237- // animation that delivered the move had already marked the frame live and238- // this was free; any path that moves without animating -- walkOptimal on the239- // console -- left the picture a frame behind the numbers beside it.240- requestRender();241- if (App.sv.dist.get(App.state) === 0) win();242-}243-244-function undo() {245- const h = App.history.pop();246- if (!h) return;247- const p = App.pieces[h.piece];248- for (let i = 0; i < h.cells; i++) {249- applyMove(App.board, App.grid, p, -h.dx, -h.dy);250- p.x -= h.dx; p.y -= h.dy;251- }252- App.state = encode(App.board, App.grid);253- App.steps -= h.cells;254- App.trace.length = Math.max(1, App.trace.length - h.cells);255- Sound.page(); buzz(6);256- // A single struck frame of inversion. Not drawn under reduced motion: a257- // full-screen flash is the one thing on this list that can actually hurt258- // someone, and the undo is already announced by the board and the tone.259- if (!prefersStill()) fx.flash = 55;260- syncHud();261- requestRender();262-}263-264-/* A block sliding to where it was sent.265- *266- * Under reduced motion it does not slide: it is already there. The move itself267- * is not the animation -- the board changes either way, the sound plays either268- * way -- so this returns the same position by the same path, without the tween269- * in between. Which is only true because a block's ink is cut at its resting270- * square rather than at wherever it happens to be passing: the two paths draw271- * the same picture, rather than the still one being a second rendering that272- * nobody looks at. */273-function animate(piece, axis, from, to, done) {274- if (prefersStill()) { if (done) done(); requestRender(); return; }275- App.anim = {276- piece, axis, from, to, cur: from, t: 0,277- dur: Math.max(80, Math.abs(to - from) * 3), done,278- };279- requestRender();280-}281-282-function refuse(p) {283- // The jolt that says a piece has nowhere to go. Under reduced motion the284- // thud and the buzz say it on their own; they were never movement.285- if (!prefersStill()) { App.shake = 12; App.shakePiece = p; }286- Sound.thud(); buzz([10, 26, 10]);287- requestRender();288-}289-290-/* ------------------------------------------------------------------ input */291-function pieceAt(vx, vy) {292- const B = LY.board, cell = LY.cell, b = App.board;293- const cx = Math.floor((vx - B.x) / cell), cy = Math.floor((vy - B.y) / cell);294- if (cx < 0 || cy < 0 || cx >= b.w || cy >= b.h) return null;295- for (const p of App.pieces) {296- if (cx >= p.x && cx < p.x + p.w && cy >= p.y && cy < p.y + p.h) return p;297- }298- return null;299-}300-301-let scroll = null; // atlas drag-to-scroll302-303-cvs.addEventListener('pointerdown', e => {304- cvs.setPointerCapture(e.pointerId);305- const v = toVirtual(e.clientX, e.clientY);306-307- if (App.screen === 'boot') { Sound.unlock(); go('atlas'); return; }308- if (App.screen === 'card') { beginPlay(); return; }309- if (App.screen === 'atlas') {310- scroll = { y: v.y, from: App.atlasScroll, moved: 0 };311- return;312- }313- if (App.screen !== 'play' || App.anim) return;314-315- const p = pieceAt(v.x, v.y);316- if (!p) return;317- App.drag = { piece: p, ox: v.x, oy: v.y, axis: null, off: 0, pos: 0, neg: 0 };318- requestRender();319-});320-321-cvs.addEventListener('pointermove', e => {322- const v = toVirtual(e.clientX, e.clientY);323-324- if (scroll) {325- const dy = v.y - scroll.y;326- scroll.moved = Math.max(scroll.moved, Math.abs(dy));327- App.atlasScroll = clampScroll(scroll.from + dy);328- requestRender();329- return;330- }331-332- const d = App.drag;333- if (!d) return;334- const dx = v.x - d.ox, dy = v.y - d.oy;335-336- if (!d.axis) {337- if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return;338- d.axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';339- const ax = d.axis === 'x';340- d.pos = freeCells(App.board, App.grid, d.piece, ax ? 1 : 0, ax ? 0 : 1) * LY.cell;341- d.neg = -freeCells(App.board, App.grid, d.piece, ax ? -1 : 0, ax ? 0 : -1) * LY.cell;342- if (!d.pos && !d.neg) { const p = d.piece; App.drag = null; refuse(p); return; }343- }344- const raw = d.axis === 'x' ? dx : dy;345- d.off = Math.round(Math.max(d.neg, Math.min(d.pos, raw)) / PERIOD) * PERIOD;346- requestRender();347-});348-349-function clampScroll(y) {350- const over = atlasContentHeight() - (view.VH - LY.barH - 6);351- return over <= 0 ? 0 : Math.max(-over, Math.min(0, y));352-}353-354-function endDrag(e) {355- if (scroll) {356- // A short drag is a tap. Anything longer was someone scrolling the list.357- if (scroll.moved < 5 && e) atlasTap(toVirtual(e.clientX, e.clientY));358- scroll = null;359- return;360- }361-362- const d = App.drag;363- if (!d) return;364- App.drag = null;365-366- if (!d.axis) {367- // A tap, not a drag. If there is exactly one way out, take it -- the368- // affordance that makes the board feel like it wants to be solved.369- const p = d.piece;370- const ways = DIRS.filter(([dx, dy]) => canMove(App.board, App.grid, p, dx, dy));371- if (ways.length === 1) {372- animate(p, ways[0][0] ? 'x' : 'y', 0, (ways[0][0] || ways[0][1]) * LY.cell,373- () => performMove(p, ways[0][0], ways[0][1], 1));374- } else if (!ways.length) refuse(p);375- requestRender();376- return;377- }378-379- const cells = Math.round(d.off / LY.cell);380- const ax = d.axis === 'x';381- if (!cells) { animate(d.piece, d.axis, d.off, 0, null); return; }382- animate(d.piece, d.axis, d.off, cells * LY.cell, () =>383- performMove(d.piece, ax ? Math.sign(cells) : 0, ax ? 0 : Math.sign(cells), Math.abs(cells)));384-}385-cvs.addEventListener('pointerup', endDrag);386-cvs.addEventListener('pointercancel', () => { scroll = null; endDrag(); });387-388-function atlasTap(v) {389- const G = atlasGeometry();390- for (let fi = 0; fi < FAMILIES.length; fi++) {391- const r = familyRowRect(G, fi);392- if (v.x >= r.x && v.x <= r.x + r.w &&393- v.y >= r.y && v.y <= r.y + r.h) { openFamily(FAMILIES[fi]); return; }394- }395-}396-397-/* ------------------------------------------------------------------ chrome398- * A zone is a band of DOM laid over the picture, in the picture's own units.399- * It used to span the picture, because the picture was the composition; now400- * the composition is a column somewhere inside it, so a zone is told which box401- * it belongs to and only the menu bar still spans the screen. */402-function zone(id, top, height, show, box) {403- const el = $(id);404- el.style.display = show ? 'flex' : 'none';405- el.style.top = `${top * view.S}px`;406- el.style.height = `${height * view.S}px`;407- el.style.left = box ? `${box.x * view.S}px` : '0';408- el.style.width = box ? `${box.w * view.S}px` : '100%';409-}410-411-function placeDom() {412- const sc = App.screen;413- const panel = LY.panel, g = LY.gauge;414- const playing = sc === 'play', won = sc === 'won';415- const chrome = sc === 'atlas' || playing || won || sc === 'stats';416-417- document.body.dataset.mode = view.mode;418- zone('top', 1, LY.barH - 2, chrome);419- $('btnBack').style.visibility = sc === 'atlas' || sc === 'boot' ? 'hidden' : 'visible';420- $('btnStats').style.display = sc === 'atlas' ? '' : 'none';421- $('btnSet').style.display = sc === 'atlas' ? '' : 'none';422- $('btnInstall').style.display = sc === 'atlas' && App.installPrompt ? '' : 'none';423-424- /* The hint level, which is only ever subtractive: FULL is everything, BEST425- * takes away the exact distance and leaves the gauge, OFF takes the gauge426- * too. STEPS and OPTIMAL stay throughout -- one is a record of what you did427- * and the other is on the card before you start, so neither is a hint. */428- // The zone is the drawn scale, so the two end labels land on its two ends429- // without a padding that has to be kept in step with it by hand.430- zone('gauge', g.y + 6, 14, playing && save.hints !== 'off', g);431- zone('hud', LY.hudY, 30, playing, panel);432- $('statDist').style.display = save.hints === 'full' ? '' : 'none';433- // The gauge's left end stops being the start once BEST anchors it to the434- // furthest out you have been, and a scale labelled with the wrong end is435- // worse than no label.436- $('gaugeFrom').textContent = save.hints === 'best' ? 'DEEPEST' : 'START';437- zone('record', LY.recordY, 34, won, panel);438- zone('acts', panel.actionsY, 34, playing || won, panel);439- // Held clear of the window's own bottom rule: the fraction alone puts the440- // box through it once the window is short enough.441- const W = LY.bootWin;442- zone('gate', Math.min(W.y + Math.round(W.h * 0.85), W.y + W.h - 24),443- 20, sc === 'boot', LY.col);444-445- ['btnUndo', 'btnReset'].forEach(id => { $(id).style.display = playing ? '' : 'none'; });446- ['btnNext', 'btnAtlas'].forEach(id => { $(id).style.display = won ? '' : 'none'; });447-448- if (won) fillRecord();449-}450-451-function syncHud() {452- const d = App.sv ? App.sv.dist.get(App.state) : null;453- $('hSteps').textContent = App.steps;454- $('hDist').textContent = d == null ? '-' : d;455- $('hOpt').textContent = App.pz.metrics.L;456- $('btnUndo').disabled = !App.history.length;457-}458-459-/* The run, in the terms the chart above it is drawn in.460- *461- * Steps against optimal says how much was spent; it does not say what on. A462- * detour is one excursion above the closest you had already been -- one bump on463- * the ridge -- and DEEPEST is how far back the worst of them put you. Neither464- * can be worked out from the two numbers beside them, which is the test every465- * one of these has to pass: a step back costs two steps, so "wrong moves" is466- * just the gap between steps and optimal, halved, and printing it would be467- * printing the same fact twice. Four bumps can be four separate stumbles or two468- * holes two deep, and only these say which. */469-function fillRecord() {470- const m = App.pz.metrics;471- const st = traceStats(App.trace);472- $('rSteps').textContent = App.steps;473- $('rOpt').textContent = m.L;474- $('rDetours').textContent = st.detours;475- $('rDeepest').textContent = st.worstDetour;476-}477-478-/* ----------------------------------------------------------------- buttons */479-$('btnUndo').onclick = undo;480-$('btnReset').onclick = beginPlay;481-$('btnBack').onclick = () => go('atlas');482-$('btnAtlas').onclick = () => go('atlas');483-$('btnStats').onclick = () => go('stats');484-$('btnNext').onclick = () => openRung(App.fam, famStats(App.fam.id).rung);485-$('btnSet').onclick = () => { $('sheet').classList.add('open'); syncSheet(); };486-$('btnClose').onclick = () => $('sheet').classList.remove('open');487-488-/* Both rows are generated from their registries rather than written into the489- * markup, so adding a theme or a block style is a one-object edit and the490- * settings sheet picks it up. */491-/* AUTO first, and the default for anyone who has not chosen: the phone was492- * already told whether it wants a dark screen, and asking again is asking493- * someone to state a preference they have stated. The rest pin one, which is494- * what a person who wants PAPER at night is for. */495-(function buildThemeRow() {496- const row = $('themeRow');497- for (const id of ['auto', ...Object.keys(THEMES)]) {498- const b = document.createElement('button');499- b.textContent = id === 'auto' ? 'AUTO' : THEMES[id].name;500- b.dataset.theme = id;501- b.onclick = () => { setTheme(id); persist(); syncSheet(); };502- row.appendChild(b);503- }504-})();505-506-/* AUTO first, and the default: the style is drawn from the theme's pool per507- * rung. The rest pin it, which is the escape hatch for anyone who wants one508- * texture -- and the only way to hold two of them still long enough to compare,509- * which is what the original ordered/diffusion toggle was for. */510-/* How much of the solver you are shown. Three buttons rather than a pull-down:511- * there are only three, and unlike the screens they are ordered -- FULL, BEST,512- * OFF is a scale, and a row shows a scale where a menu hides it. */513-(function buildHintRow() {514- const row = $('hintRow');515- for (const [id, text] of [['full', 'FULL'], ['best', 'BEST'], ['off', 'OFF']]) {516- const b = document.createElement('button');517- b.textContent = text;518- b.dataset.hint = id;519- b.onclick = () => { save.hints = id; persist(); syncSheet(); placeDom(); requestRender(); };520- row.appendChild(b);521- }522-})();523-524-/* The shading pull-down.525- *526- * Only the shape is offered. The ruling is not a preference -- it is the press527- * the board happened to be printed on, chosen with the shape it belongs to, and528- * a second control for it produced pairings nobody asked for.529- *530- * AUTO is first and is the default: the screen is drawn from the theme's pool531- * per rung. The rest pin one, which is the escape hatch for anyone who wants a532- * single texture and means it -- and the only way to hold two of them still533- * long enough to compare, which is what the original ordered-against-diffusion534- * toggle was for. */535-(function buildShadingMenu() {536- const menu = $('shadeMenu'), list = $('shadeList');537- for (const [id, text] of [['auto', 'AUTO'], ...Object.keys(PATTERNS).map(k => [k, PATTERNS[k].label])]) {538- const b = document.createElement('button');539- b.textContent = text;540- b.dataset.shading = id;541- b.setAttribute('role', 'option');542- b.onclick = e => {543- e.stopPropagation();544- save.shading = id;545- clearSprites(); // the screen is baked into the sprites546- closeMenu();547- persist(); syncSheet(); requestRender();548- };549- list.appendChild(b);550- }551- $('shadeTitle').onclick = e => {552- e.stopPropagation();553- menu.classList.toggle('open');554- $('shadeTitle').setAttribute('aria-expanded', menu.classList.contains('open'));555- };556- // Anywhere else closes it, including the sheet behind -- an open menu that557- // survives a tap outside itself is a menu you have to dismiss twice. It has558- // to spare the menu's own pointers: this runs on the way down, and hiding the559- // list before the finger lifts means the click never lands on the option, so560- // a tap on a choice would dismiss the menu without ever choosing anything.561- $('sheet').addEventListener('pointerdown', e => {562- if (!e.target.closest('#shadeMenu')) closeMenu();563- });564- window.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });565-})();566-567-function closeMenu() {568- $('shadeMenu').classList.remove('open');569- $('shadeTitle').setAttribute('aria-expanded', 'false');570-}571-572-/* How finely the picture is composed -- DENSITY, said in the only terms anyone573- * outside this file has any reason to care about.574- *575- * Nobody wants to choose a number of buffer pixels per layout unit. What they576- * can tell is whether the screen looks the way it should and whether the phone577- * in their hand keeps up, so the control names the look and not the mechanism,578- * and offers the two rungs where the answer to that can differ. It sits with579- * the theme and the blocks because it is the third thing about how the picture580- * is printed, and reading it apart from them made one setting look like three.581- *582- * A row, like the hints: two choices, and they are ordered. */583-(function buildDensityRow() {584- const row = $('densityRow');585- for (const { n, label } of DENSITIES) {586- const b = document.createElement('button');587- b.textContent = label;588- b.dataset.density = n;589- b.onclick = () => {590- save.density = n;591- setDensity(n); // re-cuts the buffers and the sprite cache592- persist(); syncSheet(); placeDom(); requestRender();593- };594- row.appendChild(b);595- }596-})();597-598-document.querySelectorAll('[data-toggle]').forEach(b => {599- b.onclick = () => {600- save[b.dataset.toggle] = !save[b.dataset.toggle];601- persist(); syncSheet(); requestRender();602- };603-});604-605-function syncSheet() {606- document.querySelectorAll('[data-theme]').forEach(b =>607- b.classList.toggle('armed', b.dataset.theme === save.palette));608- document.querySelectorAll('[data-shading]').forEach(b =>609- b.classList.toggle('armed', b.dataset.shading === save.shading));610- document.querySelectorAll('[data-hint]').forEach(b =>611- b.classList.toggle('armed', b.dataset.hint === save.hints));612- // The dataset is a string and the save is a number, so it is compared as one.613- document.querySelectorAll('[data-density]').forEach(b =>614- b.classList.toggle('armed', +b.dataset.density === save.density));615- $('shadeTitle').textContent =616- save.shading === 'auto' ? 'AUTO' : (PATTERNS[save.shading] || PATTERNS.atkinson).label;617- document.querySelectorAll('[data-toggle]').forEach(b =>618- b.classList.toggle('armed', !!save[b.dataset.toggle]));619- // Credits only. The swept-configuration count lived here too and is on the620- // record screen, where a number about the run belongs; a settings sheet is621- // not a second place to read your own statistics.622- $('credit').innerHTML =623- 'ERROR DIFFUSION AND ORDERED SCREENS<br>SILKSCREEN AND SPACE MONO, OFL';624-}625-626-/* ------------------------------------------------------------ installation */627-window.addEventListener('beforeinstallprompt', e => {628- e.preventDefault();629- App.installPrompt = e;630- placeDom();631-});632-$('btnInstall').onclick = async () => {633- const p = App.installPrompt;634- if (!p) return;635- App.installPrompt = null;636- p.prompt();637- await p.userChoice.catch(() => {});638- placeDom();639-};640-641-/* ----------------------------------------------------------------- gestures */642-window.addEventListener('resize', () => { layout(); if (App.fam) layoutBoard(App.fam); placeDom(); });643-window.addEventListener('orientationchange', () => setTimeout(() => {644- layout(); if (App.fam) layoutBoard(App.fam); placeDom();645-}, 150));646-document.addEventListener('gesturestart', e => e.preventDefault());647-document.addEventListener('dblclick', e => e.preventDefault());648-document.addEventListener('contextmenu', e => { if (e.target === cvs) e.preventDefault(); });649-650-/* Walk the board down the distance field to the goal. A debugging aid rather651- * than a feature -- but a useful one to have on the console when inspecting a652- * phone over chrome://inspect, since it reaches the record screen without653- * anyone having to solve a hundred-step board by hand. */654-export function walkOptimal(limit = 9999) {655- if (App.screen !== 'play') return 'not playing';656- const b = App.board;657- let guard = 0;658- while (App.sv.dist.get(App.state) > 0 && guard++ < limit) {659- const d = App.sv.dist.get(App.state);660- let moved = false;661- for (const p of App.pieces) {662- for (const [dx, dy] of DIRS) {663- if (!canMove(b, App.grid, p, dx, dy)) continue;664- applyMove(b, App.grid, p, dx, dy);665- const k = encode(b, App.grid);666- applyMove(b, App.grid, { x: p.x + dx, y: p.y + dy, w: p.w, h: p.h, c: p.c }, -dx, -dy);667- if (App.sv.dist.get(k) === d - 1) { performMove(p, dx, dy, 1); moved = true; break; }668- }669- if (moved) break;670- }671- if (!moved) break;672- }673- return App.steps;674-}675-676-/* Walk it badly, on purpose.677- *678- * The same debugging aid, with a budget of mistakes: `wrong` moves that take679- * the board further from the goal and `idle` ones that change nothing. Seeded,680- * so a run can be asked for twice and arrive twice.681- *682- * This exists because the descent chart is mostly about the bumps and there was683- * no way to produce one. Every screenshot of a solved board showed a perfect684- * straight line, which is the one shape a real run never has -- so the chart685- * was only ever reviewed in the case it was not drawn for.686- *687- * The moves come from walk.js, which is also what the headless checks walk, so688- * what a screenshot shows and what a check asserts are the same walk. They are689- * played through performMove, which is the game's own input path: the trace,690- * the history, the sounds and the record all happen exactly as they would under691- * a thumb. */692-export function walkWandering({ wrong = 3, idle = 2, seed = 1, limit = 4000 } = {}) {693- if (App.screen !== 'play') return 'not playing';694- const w = makeWalker(App.sv.dist, { wrong, idle, seed });695- let guard = 0;696- for (;;) {697- const m = w.next(App.board, App.grid);698- if (!m || guard++ > limit) break;699- // The walker speaks in coordinates; performMove wants the live piece that700- // is standing there.701- const p = App.pieces.find(q => q.x === m.x && q.y === m.y && q.c === m.c);702- if (!p) break;703- performMove(p, m.dx, m.dy, 1);704- }705- return traceStats(App.trace);706-}707-708-export { placeDom, grade, openFamily, depthForRung, levelProgress };
D mockup/src/ground.js +0 -140
@@ -1,140 +0,0 @@
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-}
D mockup/src/klotski.js +0 -138
@@ -1,138 +0,0 @@
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-}
D mockup/src/main.js +0 -78
@@ -1,78 +0,0 @@
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, renderFrame, DENSITY,8- setDensity, activePalette, view, LY, atlasGeometry, familyRowRect, clearSprites,9-} from './render.js';10-import { onSystemChange, prefersDark, prefersStill } from './prefs.js';11-import { depthForRung, levelProgress, puzzleFor } from './progress.js';12-import { THEMES, themeById, comboFor } from './theme.js';13-import { GROUND_NAMES } from './ground.js';14-import { PATTERN_NAMES, RULING_NAMES, effectiveRuling } from './shading.js';15-import { start, placeDom, walkOptimal, walkWandering, openFamily } from './game.js';16-17-/* Pin a look, or pass nothing to read the current one back, or null to hand it18- * to the seed again.19- *20- * There are a couple of hundred combinations per theme and they arrive one per21- * rung, so without this the only way to see them all is to play the ladder.22- * With it, sweeping every backdrop against every block style is a for-loop on23- * the console -- which is how the pools actually get judged. */24-function combo(backdrop, groundName, shading, ruling = 'fine') {25- if (arguments.length === 0) return resolveCombo();26- if (backdrop === null) { setComboOverride(null); return 'seeded'; }27- const t = themeById(activePalette());28- const ground = t.grounds.find(g => g.name === String(groundName).toUpperCase()) || t.grounds[0];29- const r = effectiveRuling(shading, ruling);30- setComboOverride({ backdrop, ground, shading, ruling: r });31- return { backdrop, ground: ground.name, shading, ruling: r };32-}33-34-/* Reachable from the console when inspecting a device over chrome://inspect,35- * and the handle the screenshot harness drives the game through. */36-window.klotski = {37- App, view, LY, save, FAMILIES,38- walkOptimal, walkWandering, openFamily,39- atlasGeometry, familyRowRect,40- depthForRung, levelProgress, puzzleFor,41- THEMES, comboFor, combo, setTheme, lookFor, placeDom, requestRender, renderFrame, setDensity,42- backdrops: GROUND_NAMES, shading: PATTERN_NAMES, rulings: RULING_NAMES,43- // What the system asked for, so a harness can check that the app did it.44- system: () => ({ dark: prefersDark(), still: prefersStill(), palette: activePalette() }),45- // A getter, not a copy: it is a setting now, and a number read once at boot46- // would tell the screenshot harness whatever it happened to be then.47- get DENSITY() { return DENSITY; },48-};49-50-setTheme(save.palette);51-requestPersistence();52-53-/* The system can change its mind while the app is open -- a phone that goes54- * dark at sunset, or someone turning motion off because of something this just55- * did. Re-resolving costs nothing and applying it late looks like a bug. */56-onSystemChange(() => {57- if (save.palette === 'auto') setTheme('auto'); // clears the sprite cache too58- else clearSprites();59- requestRender();60-});61-62-if ('serviceWorker' in navigator) {63- window.addEventListener('load', () => {64- navigator.serviceWorker.register('./sw.js').catch(() => {});65- });66-}67-68-/* Wait for the faces before the first frame: type is drawn on its own canvas69- * at full resolution, but a fallback face would still be measured and laid out70- * at the wrong widths for the first frames. */71-Promise.all([72- document.fonts.load('32px Silk'),73- document.fonts.load('32px SilkR'),74- document.fonts.load('16px Mono'),75-]).catch(() => {}).then(() => {76- start();77- placeDom();78-});
D mockup/src/prefs.js +0 -46
@@ -1,46 +0,0 @@
1-/* What the system has already been told.2- *3- * Two questions the person answered once, somewhere else, and should not have4- * to answer again here: whether they want a dark screen, and whether they want5- * things to move. Both are read live rather than at boot -- a phone that6- * switches to dark at sunset, or a person who turns motion off *because* of7- * something this app just did, gets the answer applied on the spot.8- *9- * Deliberately not settings. The app's own theme row can override the first,10- * and does; there is no override for the second, because "reduce motion" is not11- * a preference about this app, it is a fact about the person, and offering to12- * put it back is offering to ignore them.13- *14- * matchMedia is guarded because this file is imported by tooling that has no15- * window at all -- the headless checks import the theme tables through it.16- */17-18-const query = q => (typeof window !== 'undefined' && window.matchMedia19- ? window.matchMedia(q)20- : { matches: false, addEventListener() {} });21-22-const DARK = query('(prefers-color-scheme: dark)');23-const STILL = query('(prefers-reduced-motion: reduce)');24-25-/** True when the system asks for a dark screen. */26-export const prefersDark = () => DARK.matches;27-28-/* True when the system asks for less movement.29- *30- * What that means here is that everything which moves *to be nice* stops: the31- * board stops falling into place, a block arrives where it was sent instead of32- * sliding there, the refused-move jolt is not drawn, and the solve's inverting33- * wipe and its struck frame -- a full-screen flash, which is the one thing on34- * this list that can actually hurt someone -- do not happen at all.35- *36- * What does not stop is anything carrying information: the sweep still counts37- * up, because that is a progress bar and hiding it would leave a still screen38- * during a second and a half of work. The sounds and the haptics are untouched;39- * they were never motion. */40-export const prefersStill = () => STILL.matches;41-42-/** Both, as one subscription -- callers here want to redraw either way. */43-export function onSystemChange(fn) {44- DARK.addEventListener('change', fn);45- STILL.addEventListener('change', fn);46-}
D mockup/src/progress.js +0 -144
@@ -1,144 +0,0 @@
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-}
D mockup/src/render.js +0 -1858
@@ -1,1858 +0,0 @@
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, RULINGS, screenOf } from './shading.js';32-import {33- THEMES, themeById, comboFor, houseCombo, logoInk, dayOfYear, resolveTheme,34-} from './theme.js';35-import { prefersDark, prefersStill } from './prefs.js';36-import { GROUND_TILES, paintGround } from './ground.js';37-38-/* Tones are coverage of paper, 0..255.39- *40- * Retuned for Atkinson, which throws away a quarter of its error and so runs41- * highlights clean to white and shadows to solid black. Anything past about42- * 225 renders as bare paper and anything under about 35 as solid ink, so all43- * the useful texture lives in the middle and the steps have to sit there. */44-const T = {45- plate: 255, rule: 0, tray: 255,46- // Coverage per piece size. Only three quarters, a half and a quarter are47- // used: Atkinson resolves those to even textures, while the eighths between48- // them (five eighths, three eighths) come out visibly streaked, and an49- // eighth is too sparse for a hue to register at all. Tall and wide share a50- // half and are told apart by colour and by their own shape.51- goal: 64, upright: 128, recumbent: 128, single: 192,52-};53-54-const TONE_OF = [0, T.single, T.upright, T.recumbent, T.goal];55-let PIECE32 = [], PLATE32 = [];56-export const PERIOD = 8; // slides are quantised to this57-58-/* ------------------------------------------------------------------ density59- *60- * How many buffer pixels there are per unit of the layout. The whole app is61- * measured in a ~200-wide coordinate space and always has been, which on a 198462- * Macintosh was the screen and on a 2026 phone is a decision: it is why a dot of63- * the dither lands as a visible square rather than as grain. Raising this64- * composes the same picture on a finer grid, so every screen resolves at this65- * many times the dots per inch while the layout, the type and the touch targets66- * stay exactly where they were.67- *68- * It is a transform, not a rewrite. Nothing that measures anything changes69- * units; the scene canvas is scaled by this factor and every drawing call70- * arrives through it, so the ~40 hard-coded offsets scattered through the71- * layout code keep meaning what they meant. Only the handful of places that72- * touch a canvas's real pixels -- the backing stores, getImageData, the sprite73- * blits and the tint rectangles -- have to know the number at all.74- *75- * An integer, and it has to be: every rule in this app is one unit wide, and a76- * fractional scale would put those on half pixels and hand the whole 1-bit77- * picture back to the antialiaser.78- *79- * The rulings are deliberately *not* scaled with it. FINE at 3 is a third the80- * physical dot it was, which is the whole point; scaling them alongside would81- * reproduce the old look at nine times the memory and no gain.82- *83- * It also turned out to be what makes the coarse end of the ruling ladder mean84- * anything. A screen needs a period and a half of the block to read as a85- * pattern (see fitRuling), and at 1 a playing board could not carry more than86- * MEDIUM -- POSTER and BILLBOARD were always being stepped back down. Three87- * times the buffer pixels is three times the room, so the whole ladder is88- * reachable for the first time.89- *90- * 1 is the 1984 look, should it ever be wanted back.91- *92- * Two of the rungs are a setting, because the cost is real: the buffer grows93- * with the square, and a phone that cannot keep up with nine times the pixels94- * is better off drawing four than dropping frames. 3 is the standard and 2 is95- * the way out; 1 is not offered, because at 1 the coarse half of the ruling96- * ladder stops being reachable and the picture is a different one, not a97- * cheaper one. */98-/* The rungs offered, in order, and what each is called where somebody actually99- * has to choose one -- nobody outside this file has any reason to pick a number100- * of buffer pixels per layout unit. The settings row is built from this, so a101- * third rung would be a one-object edit and never a markup one. */102-export const DENSITIES = [103- { n: 2, label: 'LOW' },104- { n: 3, label: 'DEFAULT' },105-];106-/** Anything not offered lands on the standard. */107-export const cleanDensity = n => (DENSITIES.some(d => d.n === n) ? n : 3);108-109-export let DENSITY = cleanDensity(save.density);110-const dpx = n => Math.round(n * DENSITY);111-112-/* Changing it re-cuts every pixel the app owns: the backing stores are sized in113- * buffer pixels and the sprite cache is baked at the old ones. layout() does114- * both, and is the same path a rotation takes, so there is nothing here that a115- * resize does not already do every day. */116-export function setDensity(n) {117- const d = cleanDensity(n);118- if (d === DENSITY) return;119- DENSITY = d;120- layout();121-}122-123-/* ------------------------------------------------------------------ canvas */124-const cvs = document.getElementById('board'); // the dithered picture125-const octx = cvs.getContext('2d');126-const typeCvs = document.getElementById('type'); // sharp type, full resolution127-const tctx = typeCvs.getContext('2d');128-const scene = document.createElement('canvas');129-const sctx = scene.getContext('2d', { willReadFrequently: true });130-131-export const view = { S: 2, VW: 208, VH: 440, offX: 0, offY: 0, mode: 'stack' };132-export const LY = {};133-export const fx = { wipe: -1, wipeAxis: 'x', flash: 0 };134-135-const css = c => `rgb(${c[0]},${c[1]},${c[2]})`;136-137-let outImg = null;138-let INK32 = 0, PAPER32 = 0, INK_CSS = '#000', PAPER_CSS = '#fff';139-let GOLD = '#C89526';140-let typeScale = 2;141-let dirty = true;142-143-/* The look currently in force, resolved once at the top of each frame.144- *145- * It has to be a module value rather than a parameter because the block sprites146- * are built deep inside the screen painters, long before the frame-level147- * threshold runs, and both need the same answer. */148-let curShading = 'atkinson';149-let curRuling = 'fine';150-let curTile = GROUND_TILES.lattice;151-let curDot32 = 0, curField32 = 0;152-let comboOverride = null;153-154-export function requestRender() { dirty = true; }155-export function consumeDirty() { const d = dirty; dirty = false; return d; }156-export function blockShading() { return curShading; }157-export function blockRuling() { return curRuling; }158-export function clearSprites() { sprites.clear(); }159-160-/**161- * The look of the board on screen: drawn from the theme's pools and seeded on162- * the family and rung, but only where there is a board. The atlas, the record163- * and the title are chrome and keep the house style; a rung is also the only164- * thing there is to seed from, and those screens do not have one.165- *166- * A pinned block style beats the roll. That setting is the escape hatch for167- * anyone who wants one texture and means it, and it also keeps the comparison168- * the two original modes existed for -- ordered against diffusion, on a real169- * screen, on demand.170- */171-const BOARD_SCREENS = new Set(['card', 'play', 'won']);172-173-export function resolveCombo() {174- if (comboOverride) return comboOverride;175- // The record screen counts as the board: it is the same plate, the same176- // family and the same rung, arrived at through a wipe. Handing it back to the177- // house lattice snapped the backdrop the instant the puzzle was solved, which178- // read as the reward taking the world away.179- const onBoard = BOARD_SCREENS.has(App.screen) && App.fam;180- const pal = activePalette();181- const c = onBoard ? comboFor(pal, App.fam.id, App.rung) : houseCombo(pal);182- return applyPins(c);183-}184-185-/* Only the shape pins. The ruling is the press this board was printed on, not a186- * preference: it is chosen with the shape it belongs to, and letting someone187- * hold one still while the other rolled produced pairings neither the seed nor188- * the person actually asked for. */189-function applyPins(c) {190- const shading = save.shading && save.shading !== 'auto' ? save.shading : c.shading;191- return { ...c, shading, ruling: effectiveRuling(shading, c.ruling) };192-}193-194-/** The look a given rung will be played at -- what the atlas previews. */195-export function lookFor(famId, rung) {196- return applyPins(comboFor(activePalette(), famId, rung));197-}198-199-/* The picture buffer is upscaled by a whole number, so on anything wider than a200- * phone there is a margin of page around it. That margin was paper, which was201- * invisible while the only ground *was* paper and became a seam the moment the202- * ground could be any colour. It follows the ground now instead. Written only203- * when it changes: this runs inside the frame, and setting a custom property204- * every frame invalidates style on every frame. */205-let groundCss = null;206-function syncGroundCss(field) {207- const next = css(field);208- if (next === groundCss) return;209- groundCss = next;210- document.documentElement.style.setProperty('--ground', next);211-}212-213-/** Force a combo, or pass null to hand it back to the seed. A console handle214- * for looking at all of them without playing two hundred and fifty rungs. */215-export function setComboOverride(o) {216- comboOverride = o;217- sprites.clear();218- requestRender();219-}220-221-/* Unsigned, and it matters. `255 << 24` is negative in JS, and a value read222- * back out of a Uint32Array never is -- so a signed constant compares equal to223- * nothing, and every "is this pixel ink?" test silently fails. */224-function pack(rgb) { return (((255 << 24) | (rgb[2] << 16) | (rgb[1] << 8) | rgb[0]) >>> 0); }225-226-/* Regions to reprint in colour once the picture has been thresholded. Filled227- * while drawing, applied straight after the shading.228- *229- * A tint may carry a second plate. A colour comic was never one ink: each was230- * screened separately and printed at its own angle, and the colour you saw was231- * two or three of them interleaved and very slightly out of register. A tint232- * with a `screen` splits its ink between two plates on a rotated screen, which233- * is that, done with the one pass the pipeline already had. */234-const tints = [];235-/* Rectangles arrive in layout units, because that is what the drawing code236- * speaks; this indexes the buffer, which is in real pixels. Everything below237- * that crosses the two has to be converted, and the screens are read in real238- * pixels too so an overprint stays in register with the shading under it. */239-const scaleRect = r => ({ x: dpx(r.x), y: dpx(r.y), w: dpx(r.w), h: dpx(r.h) });240-/* `phaseX/phaseY` are this buffer's position on the sheet, exactly as they are241- * for the threshold -- the overprint is a second screen at a rotated angle and242- * has to stay in register with the first one, so it is offset by the same243- * amount or a comic block prints its two plates against each other. */244-function applyTints(dst, w, h, phaseX = 0, phaseY = 0) {245- for (const t of tints) {246- const x0 = Math.max(0, dpx(t.x)), x1 = Math.min(w, dpx(t.x + t.w));247- const y0 = Math.max(0, dpx(t.y)), y1 = Math.min(h, dpx(t.y + t.h));248- const s = t.screen;249- const ox = s ? ((phaseX % s.period) + s.period) % s.period : 0;250- const oy = s ? ((phaseY % s.period) + s.period) % s.period : 0;251- for (let y = y0; y < y1; y++) {252- const row = y * w;253- const sr = s ? ((y + oy) % s.period) * s.period : 0;254- for (let x = x0; x < x1; x++) {255- if (dst[row + x] !== INK32) continue;256- dst[row + x] = s ? (s.m[sr + ((x + ox) % s.period)] >= 128 ? t.c2 : t.c) : t.c;257- }258- }259- }260-}261-262-/* Discs to reverse once the picture has been thresholded and before it is263- * reprinted in colour, which is the mark on the goal block and nothing else.264- *265- * Between those two passes on purpose. Run after the colour it would have to266- * know what a piece's ink and its second plate were and swap three things267- * rather than two; run here it is the same ink-for-paper swap the wipe and the268- * flash already do, and the tint pass that follows reprints the reversed dots269- * for free -- so the mark comes out in the block's own colour, in register with270- * its screen, and no stage below this one has to know it exists.271- *272- * Sprite-local by construction: drawBlock is the only thing that fills it and273- * blockSprite is the only thing that calls drawBlock, so it is emptied around274- * the one place it is used rather than living across a frame. */275-const inverts = [];276-function applyInverts(dst, w, h) {277- for (const v of inverts) {278- // In real pixels, like the screens themselves -- so the disc is cut on the279- // same grid as the dots it reverses, and gains its edge back at density 3280- // rather than being quantised to layout units and scaled up blocky.281- const cx = dpx(v.cx), cy = dpx(v.cy), r = dpx(v.r);282- for (let dy = -r; dy <= r; dy++) {283- const y = cy + dy;284- if (y < 0 || y >= h) continue;285- const half = Math.floor(Math.sqrt(r * r - dy * dy));286- const x0 = Math.max(0, cx - half), x1 = Math.min(w - 1, cx + half);287- const row = y * w;288- for (let x = x0; x <= x1; x++) {289- const p = dst[row + x];290- if (p === INK32) dst[row + x] = PAPER32;291- else if (p === PAPER32) dst[row + x] = INK32;292- }293- }294- }295-}296-297-/* The second plate.298- *299- * Driven to the primary the colour is already mostly made of, with the other300- * two channels pulled back: the same hue, printed at full strength. Laid over301- * the flat plate on a rotated screen it reads as a colour separation rather302- * than as a different piece, which is the whole requirement -- the 2x2 has to303- * stay the red one. */304-const plateTwo = rgb => {305- const mx = Math.max(rgb[0], rgb[1], rgb[2]);306- return rgb.map(v => (v === mx ? 255 : Math.round(v * 0.55)));307-};308-309-/* The theme actually in force.310- *311- * AUTO is stored as AUTO and resolved here rather than being written down as312- * whichever theme the system wanted at the moment it was chosen -- otherwise a313- * phone that goes dark at sunset finds the app remembered a decision the person314- * never made. Everything downstream asks for this rather than for save.palette,315- * including the seeded look: the pools belong to the theme, and AUTO is not316- * one. */317-export const activePalette = () => resolveTheme(save.palette, prefersDark());318-319-export function setTheme(id) {320- save.palette = id === 'auto' || THEMES[id] ? id : 'macintosh';321- const t = themeById(activePalette()); // falls back rather than throwing322- INK32 = pack(t.ink); PAPER32 = pack(t.paper);323- PIECE32 = t.pieces.map(c => (c ? pack(c) : 0));324- PLATE32 = t.pieces.map(c => (c ? pack(plateTwo(c)) : 0));325- INK_CSS = css(t.ink); PAPER_CSS = css(t.paper);326- GOLD = css(t.gold);327- const root = document.documentElement.style;328- root.setProperty('--ink', INK_CSS);329- root.setProperty('--paper', PAPER_CSS);330- const meta = document.querySelector('meta[name="theme-color"]');331- if (meta) meta.setAttribute('content', PAPER_CSS);332- sprites.clear(); // the sprite cache bakes in the palette333- requestRender();334-}335-336-/* ------------------------------------------------------------------ layout337- *338- * A phone is no longer one shape. It is 22:9 and it is 4:3, it folds open into339- * a square, it turns on its side, and a keypad phone is a 4:3 screen the size340- * of a card. What all of those have in common is that the *composition* does341- * not want to change with them: this is a game drawn at one scale in a ~200342- * unit column, and stretching that column to fit a screen is how a designed343- * thing turns into a responsive web page.344- *345- * So the column is no longer the picture. The picture is the screen -- the346- * canvas covers the whole safe area -- and the column sits in the middle of347- * it at the width it has always been. What that leaves over is not a margin348- * to be apologised for: every pixel nobody paints becomes ground, so on a349- * wide screen the leftover is the desktop, patterned, dithered, and exactly350- * what a Macintosh with a big monitor showed. It used to be a flat band of351- * body background, which is the one thing it should never have been.352- *353- * That handles every proportion in one direction. It does not handle the354- * other one: on a landscape screen height is the scarce dimension, and a355- * column stacked board-over-controls has to shrink the board to nothing to356- * fit its own chrome underneath. So there is a second arrangement, in which357- * the same two windows stop stacking and sit side by side. Two windows on a358- * desktop is not a concession to landscape -- it is what this system did with359- * a wide screen.360- *361- * Which arrangement, and at what upscale, is *chosen* rather than calculated.362- * Both were tried as formulas first and both kept being wrong at the edges: a363- * threshold on the proportion sends a 768x800 fold to the side-by-side364- * arrangement, where it comes out worse than stacked, and any expression that365- * gets the upscale right for a 900-tall desktop gets it wrong for a 393-tall366- * phone on its side. There are only about a dozen candidates, they can each be367- * costed exactly, and the cost is not a matter of opinion -- so chooseLayout368- * builds all of them and takes the best. The arrangement falls out of that369- * rather than being a rule anyone has to defend.370- *371- * Everything else is unchanged: the upscale from the picture buffer to CSS372- * pixels is an exact integer, because a fractional one smears the dither and373- * that is the fastest way to make this look cheap. The type canvas has no374- * such constraint and runs at full device resolution.375- */376-377-/* The design width, in layout units. Every type size and offset in the app is378- * a fraction of this and always has been; it is the one number the drawing379- * code is entitled to assume. */380-const COL = 200;381-/* How tall a column is allowed to get before the extra goes to the desktop382- * instead. Past about this, a column stops reading as a page and starts383- * reading as a corridor with the board adrift in the middle of it. */384-const COL_R = 1.9;385-/* Between the two windows when they sit side by side, and how wide the one386- * holding the controls is allowed to be. Its floor is three readouts abreast;387- * past its ceiling it is just a wide empty panel. */388-const PAIR_GAP = 10, PANEL_MIN = 100, PANEL_MAX = 168;389-/* The board the picture is sized for. Every family is re-fitted afterwards --390- * a 3x5 set gets a bigger cell than a 5x4 one -- but the *picture* has to be391- * decided before a family is open, and it cannot change when one is picked. */392-const REF_W = 4, REF_H = 5;393-394-/* The screen, less anything the hardware has taken out of it. The body carries395- * the safe-area insets as padding, so the content box is the honest figure --396- * innerWidth is not, and centring against it is what used to push the picture397- * under a notch on a landscape phone. */398-function safeBox() {399- const b = document.body, cs = getComputedStyle(b);400- const n = v => parseFloat(v) || 0;401- const x = n(cs.paddingLeft), y = n(cs.paddingTop);402- return {403- x, y,404- w: Math.max(160, b.clientWidth - x - n(cs.paddingRight)),405- h: Math.max(160, b.clientHeight - y - n(cs.paddingBottom)),406- };407-}408-409-/* The plate's three measurements as functions of the cell, for a given board.410- * Shared by the fitting and the drawing so the two cannot drift apart: the411- * tray is inset from the cells because a block's drop shadow reaches past its412- * own cell, and without that margin the bottom and right rules sit directly on413- * the last row of blocks and clip their shadows. */414-function plateOf(bw, bh) {415- const pad = c => Math.max(3, Math.round(c * 0.16));416- return {417- pad,418- w: c => c * bw + pad(c) * 2 + Math.round(c * 0.5) * 2,419- h: c => Math.round(c * bh + pad(c) * 2 + c * 0.5 + c * 1.05),420- };421-}422-423-/* The largest cell that fits the room, in whole dither periods so the ordered424- * mode does not flash when a block slides. Null when even the smallest one425- * does not fit, which is how a candidate picture gets ruled out.426- *427- * Stacked, the air under the plate is part of what has to fit: it is measured428- * off the cell, so leaving it out of the fitting is how a board that fits by429- * two units posts the buttons under it off the bottom of the screen. */430-function fitCell(m, bw, bh) {431- const P = plateOf(bw, bh);432- const need = c => P.h(c) + (m.mode === 'split' ? 0 : Math.round(c * 0.3));433- let cell = 40;434- while (cell > 16 && (need(cell) > m.roomH || P.w(cell) > m.roomW)) cell -= PERIOD;435- return need(cell) <= m.roomH && P.w(cell) <= m.roomW ? cell : null;436-}437-438-/* Everything that follows from an arrangement and an upscale, as a pure439- * function of the two, so a candidate can be built and costed without440- * committing to it. */441-function metricsFor(mode, S, box) {442- const VW = Math.floor(box.w / S), VH = Math.floor(box.h / S);443- const split = mode === 'split';444-445- /* The column: where everything that is not the desktop is composed. It is446- * COL units wide wherever there is room for COL units, which is the point --447- * the app is drawn at one scale and the screen does not get a say in it. It448- * gives way in exactly two directions. A picture narrower than the design449- * width has to be taken as it comes, and a taller one is allowed to widen a450- * little rather than leave the board stranded in a corridor, up to a limit:451- * past about a third over, the fixed things in it -- the margins, the table452- * rows, the buttons -- start looking small for the page they are on. */453- const colW = Math.min(VW, split454- ? Math.max(COL + 24, Math.min(Math.round(VH * 1.2), Math.round(COL * 1.6)))455- : Math.max(COL, Math.min(Math.round(VH / COL_R), Math.round(COL * 1.35))));456-457- /* The menu bar spans the screen, not the column -- it did on a Macintosh and458- * a bar that stopped short of the edges would read as a strip of window. It459- * is proportioned against the height it has: a bar taking a twentieth of a460- * tall column would take a tenth of a short one. */461- const barH = Math.max(14, Math.round(VH * (split ? 0.07 : 0.048)));462-463- /* The head and the foot are fractions with floors under them. The fractions464- * are what a tall phone always used; the floors are what stops a short465- * column from computing a foot too small to hold the controls that go in it466- * and posting the actions row off the bottom of the screen -- which is what467- * a 360x640 phone did. The foot is the gauge, the readout, the buttons and468- * the air between them, so it is stated as exactly that rather than as a469- * fraction that happens to work out. */470- const hudGap = Math.min(46, Math.max(26, Math.round(VH * 0.095)));471- const headH = Math.max(barH + 10, Math.round(VH * 0.14));472- const footH = Math.max(hudGap + 30 + 10 + 34 + 4, Math.round(VH * 0.30));473-474- return {475- mode, S, VW, VH, barH, hudGap, headH, footH, colW,476- colX: Math.round((VW - colW) / 2),477- // What the plate has to fit inside -- the only thing the two arrangements478- // actually argue about. Split measures against the narrowest the control479- // panel is allowed to be, so the board gets first call on the width and480- // the panel takes what is left.481- roomW: split ? VW - 24 - PANEL_MIN - PAIR_GAP : colW - 8,482- // Stacked, the board is fitted against the *least* head it could have --483- // the bar and a little clearance. The head above is a composition, not a484- // reservation: on a tall phone there is room for all of it and the plate485- // sits where it always did, and on a short one it gives way rather than486- // taking a step off the board to keep itself whole.487- roomH: split ? VH - barH - 12 : VH - footH - (barH + 10),488- };489-}490-491-/* Build every arrangement at every upscale and take the best of them.492- *493- * The cost is the size of the board in real pixels, which is the thing a494- * player is actually given, and it is not the same as the size of the picture:495- * a cell is capped at 40 units, so past a point more units stop turning into496- * more board and the upscale is what has to grow instead.497- *498- * Two riders on it. A layout unit under two CSS pixels sets a six-unit label499- * at six pixels, so anything that can only be had at S=1 is a last resort500- * rather than a candidate -- it is what a keypad phone gets and nothing else.501- * And where two candidates give the same board, the smaller upscale wins,502- * because it is the one with more units left over for everything around it. */503-/* The upscales worth trying, in the order of how much they are worth.504- *505- * A buffer pixel has to land on a whole number of *device* pixels or the506- * compositor resamples the one thing in this app that must never be resampled.507- * The picture is upscaled by S CSS pixels per layout unit and composed at508- * DENSITY buffer pixels per unit, so a buffer pixel covers S*dpr/DENSITY device509- * ones -- and on the phone this was found on, S=2 with dpr 2.748 makes that510- * 1.832. Nearest-neighbour then keeps five buffer rows out of every six and511- * doubles the sixth, in a rhythm that walks down the screen: a ground ruled512- * every four buffer pixels came out at seven device pixels two times in three513- * and eight the rest, which is not a texture any more, it is a beat. It reads514- * as the pattern collapsing into lines, and as a seam where the beat turns515- * over.516- *517- * So the candidates are the scales where it lands whole: S = DENSITY*k/dpr for518- * integer k, which is every scale at which one buffer pixel is exactly k device519- * pixels. S is no longer an integer and does not need to be -- what has to be520- * whole is the device mapping, and this makes a *layout unit* exactly DENSITY*k521- * device pixels, so the one-unit rules the whole design is drawn with are whole522- * pixels too, which they were not before.523- *524- * The integer upscales are still offered, and rank below any crisp one at the525- * same board size. They are what a display with dpr 2 and density 3 gets: there526- * is no k there that both lands whole and leaves the column wide enough, and a527- * board at half the size is a worse answer than a board that resamples. Which528- * is a real limit and worth naming -- a unit is a multiple of DENSITY device529- * pixels or it is not crisp, and DENSITY is the setting, so the two interact. */530-function upscales(dpr) {531- const out = [];532- for (let k = 1; k <= 6; k++) {533- const S = (DENSITY * k) / dpr;534- if (S >= 1 && S <= 8) out.push({ S, crisp: true });535- }536- for (let S = 1; S <= 8; S++) out.push({ S, crisp: false });537- return out;538-}539-540-function chooseLayout(box) {541- const dpr = window.devicePixelRatio || 1;542- let best = null, bestCost = -1;543- for (const mode of ['stack', 'split']) {544- for (const { S, crisp } of upscales(dpr)) {545- const m = metricsFor(mode, S, box);546- if (m.VW < 150 || m.VW > 640 || m.VH < 120) continue;547- const cell = fitCell(m, REF_W, REF_H);548- if (!cell) continue;549- /* Crispness is worth about an eighth of the board's size and not more.550- *551- * It is not free: a whole mapping needs the layout unit to be a multiple552- * of DENSITY device pixels, and which multiples exist depends on the553- * display. At dpr 2.748 the crisp scale is *larger* than the integer one554- * and there is nothing to trade. At dpr 2 with density 3 the only crisp555- * scales are 1.5 and 3, and 3 leaves the column too narrow to hold, so556- * crispness there would cost a quarter of the board -- which is the wrong557- * trade, and a resampled board at full size is what that display gets.558- * (Density 2 is crisp at full size on those screens; the setting and the559- * display interact, and pretending otherwise would mean shrinking the560- * board on hardware that never asked for it.)561- *562- * Both still outrank the tie-break that prefers the smaller upscale. */563- const cost = (S > 1 ? 1e6 : 0) + cell * S * 100 * (crisp ? 1.125 : 1) - S;564- if (cost > bestCost) { bestCost = cost; best = m; }565- }566- }567- // Nothing fit at all: a screen this small has no good answer, only an568- // honest one.569- return best || metricsFor('stack', 1, box);570-}571-572-export function layout() {573- const box = safeBox();574- const m = chooseLayout(box);575- const { mode, S, VW, VH } = m;576- Object.assign(view, {577- mode, S, VW, VH,578- offX: box.x + Math.round((box.w - VW * S) / 2),579- offY: box.y + Math.round((box.h - VH * S) / 2),580- });581-582- // The backing stores carry the density; the CSS size does not, so the583- // picture occupies exactly the space it always did and simply has more584- // pixels in it. What the CSS size does have to do is land the buffer on585- // whole device pixels, which is what chooseLayout picked S for.586- cvs.width = dpx(VW); cvs.height = dpx(VH);587- cvs.style.width = `${VW * S}px`; cvs.style.height = `${VH * S}px`;588- scene.width = dpx(VW); scene.height = dpx(VH);589-590- // Type is not dithered and is not upscaled from the buffer, so it wants the591- // device grid itself rather than a capped multiple of the upscale.592- typeScale = S * (window.devicePixelRatio || 1);593- typeCvs.width = Math.round(VW * typeScale);594- typeCvs.height = Math.round(VH * typeScale);595- typeCvs.style.width = `${VW * S}px`; typeCvs.style.height = `${VH * S}px`;596-597- const stage = document.getElementById('stage').style;598- stage.left = `${view.offX}px`; stage.top = `${view.offY}px`;599- stage.width = `${VW * S}px`; stage.height = `${VH * S}px`;600- document.documentElement.style.setProperty('--s', S);601-602- outImg = octx.createImageData(dpx(VW), dpx(VH));603- sprites.clear();604-605- Object.assign(LY, {606- barH: m.barH, hudGap: m.hudGap, headH: m.headH, footH: m.footH,607- roomW: m.roomW, roomH: m.roomH,608- col: { x: m.colX, w: m.colW, cx: m.colX + m.colW / 2 },609- // Computed here, not while drawing, so the DOM prompt can be positioned610- // before the first frame is painted.611- bootWin: {612- x: m.colX + 12, y: Math.round(VH * 0.15),613- w: m.colW - 24, h: Math.round(VH * 0.66),614- },615- });616- layoutBoard(App.fam || FAMILIES[0]);617- requestRender();618-}619-620-/* The board's own measurements, which depend on the family in play -- a 3x5621- * set and a 5x4 set want different cells and a different plate. Kept apart622- * from layout() so a resize does not need to know which family is open.623- *624- * Two arrangements of the same two windows. Stacked, the plate sits in the625- * column with the controls beneath it. Split, they sit side by side, because626- * on a landscape screen the height is all spoken for and a foot of chrome627- * under the board is paid for out of the board. Everything downstream reads628- * the same names either way: the painters and the DOM chrome are told where629- * the plate, the panel and the gauge are, and never which arrangement put630- * them there. */631-export function layoutBoard(fam) {632- const { VW, VH, mode } = view;633- const col = LY.col;634- const bw = fam.w, bh = fam.h;635- const barH = LY.barH;636- const P = plateOf(bw, bh);637-638- // The room was worked out when the picture was chosen; only the board it has639- // to hold changes here. A family that will not fit at the smallest cell gets640- // the smallest cell anyway -- there is nothing else to give it.641- const cell = fitCell({ mode, roomW: LY.roomW, roomH: LY.roomH }, bw, bh) || 16;642-643- const trayPad = P.pad(cell);644- const padX = Math.round(cell * 0.5);645- const padTop = Math.round(cell * 0.5);646- const padBottom = Math.round(cell * 1.05); // the caption band647- // Was a drawn plinth under the plate, a leftover from an earlier direction.648- // A dithered slab under a window is not something QuickDraw would have done649- // and it read as a stray textured bar; the space it held is kept as plain650- // breathing room.651- const belowPlate = Math.round(cell * 0.3);652- const plateW = P.w(cell);653- const plateH = P.h(cell);654-655- const split = mode === 'split';656- /* The panel takes what the plate leaves, between the narrowest that holds657- * three readouts abreast and the widest that is not just empty panel. The658- * board has first call because it is the thing being played; a five-wide set659- * on a square screen is the case where there is nothing spare, and there the660- * panel goes to its floor rather than the board going to its. */661- const panelW = split662- ? Math.min(PANEL_MAX, Math.max(PANEL_MIN, VW - 24 - PAIR_GAP - plateW))663- : 0;664- // Split centres the pair, so the desktop shows either side of both windows665- // rather than all of it landing on one edge.666- const compW = split ? plateW + PAIR_GAP + panelW : plateW;667- const plateX = Math.round((VW - compW) / 2);668- /* Stacked, the plate takes the head it is owed and then centres itself in669- * whatever is left. On a tall phone that is the same 14% of the height it670- * has always been and the plate lands exactly where it did; on a short one671- * the head shrinks to the bar plus clearance instead of pushing the foot672- * off the bottom of the screen. */673- const headRoom = VH - LY.footH - plateH - belowPlate;674- const head = Math.max(barH + 10, Math.min(LY.headH, headRoom));675- const plateY = split676- ? Math.max(barH + 6, Math.round(barH + (VH - barH - plateH) / 2))677- : Math.round(head + Math.max(0, headRoom - head) / 2);678-679- /* The panel: below the plate, or beside it. Stacked, it hugs its contents680- * rather than reaching for the bottom of the screen, so the buttons sit with681- * the numbers they belong to. Split, it matches the plate's height, because682- * two windows of the same height side by side is a pair and two of different683- * heights is an accident. */684- const foot = plateY + plateH + belowPlate;685-686- /* Split, the panel is as tall as the plate and its contents are worth less687- * than that, so they are centred in it as one group rather than pinned to688- * its two ends -- pinned, the gauge and the buttons drift apart until the689- * panel reads as two things with a hole between them. */690- const inner = split ? Math.min(26, Math.max(8, Math.round(plateH * 0.12))) : 0;691- const groupH = split ? 14 + inner + 30 + inner + 34 : 0;692- const groupY = split ? plateY + Math.max(4, Math.round((plateH - groupH) / 2)) : 0;693-694- const actionsY = split695- ? groupY + 14 + inner + 30 + inner696- : foot + LY.hudGap + 42;697- const panel = split698- ? { x: plateX + plateW + PAIR_GAP, y: plateY, w: panelW, h: plateH, actionsY }699- : (() => {700- const y = foot + Math.round(VH * 0.02);701- return { x: col.x + 7, y, w: col.w - 14, h: Math.min(VH - 6, actionsY + 34 + 8) - y, actionsY };702- })();703-704- // The gauge is measured off whatever is holding it, so it spans the column705- // when it is under the board and the panel when it is beside it.706- const gaugeW = split ? panel.w - 24 : Math.round(col.w * 0.6);707- const gaugeY = split708- ? groupY709- : foot + Math.min(22, Math.max(12, Math.round(VH * 0.045)));710-711- Object.assign(LY, {712- cell, padX, padTop, padBottom, belowPlate, trayPad, panel,713- plate: { x: plateX, y: plateY, w: plateW, h: plateH },714- board: {715- x: plateX + padX + trayPad, y: plateY + padTop + trayPad,716- w: cell * bw, h: cell * bh,717- },718- gauge: { x: split ? panel.x + 12 : Math.round(col.cx - gaugeW / 2), y: gaugeY, w: gaugeW },719- gaugeY,720- hudY: split ? groupY + 14 + inner : foot + LY.hudGap,721- // The record stands where the readout does -- it is the same row of722- // numbers, counted up rather than counted down.723- recordY: split ? groupY + 12 + inner : foot + Math.round(VH * 0.04),724- });725-}726-727-export function toVirtual(clientX, clientY) {728- const r = cvs.getBoundingClientRect();729- return { x: (clientX - r.left) / view.S, y: (clientY - r.top) / view.S };730-}731-732-/* --------------------------------------------------------------------- type733- * Queued while the picture is composed, then painted afterwards on its own734- * canvas so it never meets the threshold. Silkscreen is a bitmap face, so735- * sizes are snapped to whole pixels to keep its stems even. */736-const typeQueue = [];737-const snap = (n, m = 2) => Math.max(m, Math.round(n / m) * m);738-739-/* Sprites and type are composited *after* the threshold, so they do not740- * respect anything drawn over them in the scene -- a scrolled list ran741- * straight through the menu bar. Anything queued while a clip is active742- * carries it, and honours it at paint time. */743-let activeClip = null;744-export function setClip(r) { activeClip = r; }745-function withClip(c, clip, paint) {746- if (!clip) { paint(); return; }747- c.save();748- c.beginPath();749- c.rect(clip.x, clip.y, clip.w, clip.h);750- c.clip();751- paint();752- c.restore();753-}754-755-function label(str, x, y, opt) {756- typeQueue.push({757- str, x, y, clip: activeClip,758- size: snap(opt.size),759- face: opt.face || 'Silk',760- ink: opt.ink !== false,761- track: opt.track || 0,762- align: opt.align || 'left',763- alpha: opt.alpha == null ? 1 : opt.alpha,764- });765-}766-function setFont(size, face) { tctx.font = `${size}px ${face}`; }767-function measure(str, size, face, track) {768- setFont(size, face);769- let w = -track;770- for (const ch of str) w += tctx.measureText(ch).width + track;771- return w;772-}773-/** Largest snapped size at or below `size` whose tracked width fits `maxW`. */774-function fitSize(str, size, face, track, maxW) {775- let s = snap(size);776- while (s > 4 && measure(str, s, face, track) > maxW) s -= 2;777- return s;778-}779-function paintTracked(str, x, y, track, align) {780- let w = -track;781- for (const ch of str) w += tctx.measureText(ch).width + track;782- let cx = align === 'center' ? Math.round(x - w / 2) : align === 'right' ? Math.round(x - w) : x;783- for (const ch of str) { tctx.fillText(ch, cx, y); cx += tctx.measureText(ch).width + track; }784-}785-function drawType() {786- tctx.setTransform(1, 0, 0, 1, 0, 0);787- tctx.clearRect(0, 0, typeCvs.width, typeCvs.height);788- tctx.setTransform(typeScale, 0, 0, typeScale, 0, 0);789- tctx.textBaseline = 'alphabetic';790- for (const t of typeQueue) {791- setFont(t.size, t.face);792- tctx.globalAlpha = t.alpha;793- // A struck inversion has to take the type with it or the screen tears.794- tctx.fillStyle = (fx.flash > 0 ? !t.ink : t.ink) ? INK_CSS : PAPER_CSS;795- withClip(tctx, t.clip, () => paintTracked(t.str, t.x, t.y, t.track, t.align));796- }797- tctx.globalAlpha = 1;798- for (const d of decals) {799- withClip(tctx, d.clip, () => {800- if (d.kind === 'line') paintGoldLine(tctx, d);801- else if (d.kind === 'logo') paintDevLogo(tctx, d);802- else paintTrophy(tctx, d.x, d.y, d.size);803- });804- }805-}806-807-/* ------------------------------------------------------------ draw helpers */808-const grey = v => `rgb(${v | 0},${v | 0},${v | 0})`;809-function fill(c, x, y, w, h, v) {810- c.fillStyle = grey(v);811- c.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h));812-}813-814-/** A window: white, with a hard rule around it. Straight out of QuickDraw. */815-function drawPlate(c, r) {816- fill(c, r.x, r.y, r.w, r.h, T.plate);817- c.fillStyle = grey(T.rule);818- c.fillRect(r.x, r.y, r.w, 1); c.fillRect(r.x, r.y + r.h - 1, r.w, 1);819- c.fillRect(r.x, r.y, 1, r.h); c.fillRect(r.x + r.w - 1, r.y, 1, r.h);820- const i = 3;821- c.fillRect(r.x + i, r.y + i, r.w - 2 * i, 1);822- c.fillRect(r.x + i, r.y + r.h - i - 1, r.w - 2 * i, 1);823- c.fillRect(r.x + i, r.y + i, 1, r.h - 2 * i);824- c.fillRect(r.x + r.w - i - 1, r.y + i, 1, r.h - 2 * i);825-}826-827-/* The desktop is no longer drawn. It is whatever is left.828- *829- * It used to be composed into the scene as a lattice of ink dots on bare paper,830- * and it had to be drawn at the extremes: running a flat mid grey through error831- * diffusion across a whole screen gives a restless, faintly directional field,832- * fine on a small object and exhausting as a background, while 0 and 255 give833- * diffusion no error to spread and so pass through untouched.834- *835- * That worked, and it allowed exactly one backdrop in exactly two colours. The836- * scene is now cleared to transparent instead, the screens paint their opaque837- * windows over it, and every pixel still transparent at threshold time is838- * ground -- which is pixel-exact occlusion for one alpha test, and puts the839- * backdrop after the threshold where it can be any pattern in any two colours.840- * See ground.js. */841-842-/* A panel, and the menu bar. The desktop carries a pattern, so nothing small843- * is ever set on it -- on a Macintosh, text lives inside a window. This is844- * what keeps a textured ground and legible labels from being a trade. */845-function drawPanel(c, r) {846- fill(c, r.x, r.y, r.w, r.h, T.plate);847- c.fillStyle = grey(T.rule);848- c.fillRect(r.x, r.y, r.w, 1); c.fillRect(r.x, r.y + r.h - 1, r.w, 1);849- c.fillRect(r.x, r.y, 1, r.h); c.fillRect(r.x + r.w - 1, r.y, 1, r.h);850-}851-function drawMenuBar(c) {852- const { VW } = view;853- fill(c, 0, 0, VW, LY.barH, T.plate);854- fill(c, 0, LY.barH, VW, 1, T.rule);855-}856-857-/* Flat, with a hard offset shadow. An extruded 2.5D side was tried and it858- * smears: the darkened faces of adjacent blocks merge into one muddy band859- * down the right of the board. A solid black drop, which is what QuickDraw860- * did, separates them at a glance and costs two pixels. */861-const blockGeom = (w, opt) => ({862- gap: opt.gap ?? 1,863- drop: opt.drop ?? Math.max(2, Math.round(w / 22)),864-});865-866-function drawBlock(c, x, y, w, h, cls, opt = {}) {867- const { gap, drop } = blockGeom(w, opt);868- const tone = opt.tone ?? TONE_OF[cls];869- const bx = x + gap, by = y + gap, bw = w - gap * 2, bh = h - gap * 2;870-871- if (opt.shadow !== false) fill(c, bx + drop, by + drop, bw, bh, T.rule);872- fill(c, bx, by, bw, bh, tone);873-874- // A plain ink outline: the fills are told apart by hue now, so the outline875- // no longer has to invert to keep the silhouette.876- c.fillStyle = grey(T.rule);877- c.fillRect(bx, by, bw, 1); c.fillRect(bx, by + bh - 1, bw, 1);878- c.fillRect(bx, by, 1, bh); c.fillRect(bx + bw - 1, by, 1, bh);879- if (opt.thick) {880- c.fillRect(bx + 1, by + 1, bw - 2, 1); c.fillRect(bx + 1, by + bh - 2, bw - 2, 1);881- c.fillRect(bx + 1, by + 1, 1, bh - 2); c.fillRect(bx + bw - 2, by + 1, 1, bh - 2);882- }883- // The mark on the goal block: the one piece whose identity matters.884- //885- // It is the same disc it has always been, in the same place, at the same886- // size. What changed is what the disc *does*. It was a tone -- a denser patch887- // laid down before the threshold and put through the screen with everything888- // else, so what showed was a change in the density of the dots already there.889- // That is a lovely idea and it is at the mercy of the screen it is made of:890- // it needs room inside itself for a density to differ, so it thinned out at891- // the coarse rulings where a disc is barely wider than one cell, and under892- // SOLID it could not appear at all, because that screen discards tone by893- // definition and a mark made of tone then has nothing to be made of.894- //895- // So the disc inverts the picture instead of contributing to it. Every pixel896- // inside it is swapped, ink for paper, after the threshold and before the897- // colour -- so the mark is exactly the negative of whatever screen the rung898- // drew, in register with it, and comes out as the block's own colour where899- // the field is paper and paper where the field is inked.900- //901- // Which is why this is not a hack and the tone was starting to be one. An902- // inversion cannot clash with the pattern, for the same reason the old inlay903- // could not: it is made of it. But it also cannot run out of room. There is904- // no ruling coarse enough to wash it out, because it does not need two905- // densities to be distinguishable -- it needs one, and the opposite of it.906- // Under SOLID, where the block is a flat plate of colour, it is a clean disc907- // of paper. Under BILLBOARD, where the block is four enormous dots, it is908- // those dots photographically reversed. The wipe and the solve flash already909- // invert the picture this way; the goal block simply does it in a circle.910- //911- // Recorded rather than drawn, because a canvas cannot express "swap these912- // pixels for their opposites" and the threshold has not run yet in any case.913- // Plotted span by span when it is applied, for the reason it always was: a914- // filled arc is anti-aliased, and half-covered pixels are not a thing a915- // one-bit picture has anywhere to put.916- if (opt.device) {917- // Unchanged, and generous for a reason that has changed: it used to have to918- // clear the screen's own period before it could read as a shape at all. Now919- // it only has to be large enough to be a mark rather than a speck.920- inverts.push({921- cx: bx + bw / 2, cy: by + bh / 2,922- r: Math.max(3, Math.round(Math.min(bw, bh) * 0.26)),923- });924- }925- if (cls && PIECE32[cls]) {926- const p = PATTERNS[opt.shading];927- const screen = p && p.overprint ? screenOf(opt.shading, opt.ruling, 'overprint') : null;928- tints.push({929- x: bx + 1, y: by + 1, w: bw - 2, h: bh - 2,930- c: PIECE32[cls], c2: screen ? PLATE32[cls] : 0, screen,931- });932- }933-}934-935-/* Every block is dithered on its own, here, and then blitted.936- *937- * Diffusion is global by nature: error runs off one shape and into whatever is938- * drawn to its right and below, so a block's texture depended on its939- * neighbours and a 50% bar could develop a streaked patch where the error940- * happened to pile up. Dithering each block alone against bare paper makes its941- * texture a pure function of its size, tone and colour -- identical every942- * time, in every position, and free after the first one because they cache.943- *944- * It also means nothing boils while a block slides, which is what this945- * mechanism was originally built for.946- *947- * Sprites are alpha-cut to the block and its shadow so neighbours compose948- * without one sprite's paper margin erasing the next one's shadow. */949-const sprites = new Map();950-const blits = [];951-/* `opt.rx` and `opt.ry` are where this block *rests* on the picture, in layout952- * units -- not where it is being dragged through, which is the whole point.953- *954- * The screen is ruled across the picture rather than restarted at each block955- * (see shade), so a sprite has to be cut at the phase of the place it belongs956- * to. Taking that from the animated position instead would make the ink a957- * function of how the piece got there: the pattern would crawl under a sliding958- * block, and a board drawn without animation would not match the same board959- * drawn with it. Resting position keeps it a function of *where*, which is what960- * makes the reduced-motion path the same picture rather than a second one.961- *962- * A move of one cell changes that phase by the cell's own width, which fitRuling963- * guarantees is a whole number of periods -- so the pattern a block sits on is964- * the same before and after, and the landing has nothing to snap. */965-function blockSprite(w, h, cls, opt) {966- const { gap, drop } = blockGeom(w, opt);967- // Normally the frame's own screen, but a caller may ask for another: the968- // atlas previews each row in the screen that row's rung will be played in.969- const shading = opt.shading || curShading;970- const ruling = opt.ruling || curRuling;971- // Reduced modulo the period here, where the ruling is known, so the cache does972- // not gain a key per pixel of the board -- there are only ever as many phases973- // as the period has room for, and usually exactly one.974- const period = (RULINGS[ruling] || RULINGS.fine).cell * 8;975- const phaseX = ((dpx(opt.rx || 0) % period) + period) % period;976- const phaseY = ((dpx(opt.ry || 0) % period) + period) % period;977- const key = `${w}x${h}|${cls}|${gap}|${drop}|${opt.thick ? 1 : 0}|${opt.device ? 1 : 0}`978- + `|${shading}@${ruling}|${phaseX},${phaseY}`;979- const hit = sprites.get(key);980- if (hit) return hit;981-982- const sw = w + drop + 2, sh = h + drop + 2;983- const pw = dpx(sw), ph = dpx(sh);984- const cnv = document.createElement('canvas');985- cnv.width = pw; cnv.height = ph;986- const cx = cnv.getContext('2d', { willReadFrequently: true });987- cx.setTransform(DENSITY, 0, 0, DENSITY, 0, 0);988-989- const outer = tints.splice(0); // the sprite has its own tint space990- inverts.length = 0; // and is the only thing that marks one991- fill(cx, 0, 0, sw, sh, T.tray);992- // Resolved here rather than read off the module inside drawBlock, so a sprite993- // asked for in another screen -- the atlas previews each row in its own --994- // gets that screen's overprint too and not the frame's.995- drawBlock(cx, 0, 0, w, h, cls, { ...opt, shading, ruling });996- const img = cx.getImageData(0, 0, pw, ph);997- const buf = new Uint32Array(img.data.buffer);998- shade(buf, buf, pw, ph, shading, INK32, PAPER32, ruling, phaseX, phaseY);999- applyInverts(buf, pw, ph); // the mark, before there is a colour1000- applyTints(buf, pw, ph, phaseX, phaseY);1001- tints.length = 0; tints.push(...outer);1002- inverts.length = 0;1003-1004- // Keep only the block and its shadow; the rest was scaffolding for the1005- // threshold and would otherwise blank a neighbour.1006- // In real pixels, since it is masking the buffer rather than drawing.1007- const bw = dpx(w - gap * 2), bh = dpx(h - gap * 2);1008- const g = dpx(gap), d = dpx(drop);1009- for (let y = 0; y < ph; y++) {1010- for (let x = 0; x < pw; x++) {1011- const inBlock = x >= g && x < g + bw && y >= g && y < g + bh;1012- const inShadow = x >= g + d && x < g + d + bw1013- && y >= g + d && y < g + d + bh;1014- if (!inBlock && !inShadow) buf[y * pw + x] &= 0x00FFFFFF; // clear alpha1015- }1016- }1017- cx.setTransform(1, 0, 0, 1, 0, 0);1018- cx.putImageData(img, 0, 0);1019-1020- const sprite = { canvas: cnv, w: sw, h: sh };1021- sprites.set(key, sprite);1022- return sprite;1023-}1024-1025-/* The one thing on screen that is not one bit.1026- *1027- * A solved board used to be marked with an inked seal, which sank straight1028- * into the board's own texture -- ink on ink. The trophy is queued like type1029- * and painted after the threshold, at full resolution and in gold, so it sits1030- * clearly on top of the dither instead of competing with it. The gold is the1031- * theme's own: a printed ochre disappears against a black paper. */1032-const decals = [];1033-const trophy = (x, y, size) => decals.push({ kind: 'cup', x, y, size, clip: activeClip });1034-/* The perfect-solver line. Gold and painted after the threshold, for the same1035- * reason the trophy is: drawn into the scene it was ink on ink, and the bars1036- * of your own descent covered the very line they are meant to be read1037- * against. */1038-const goldLine = (x1, y1, x2, y2) =>1039- decals.push({ kind: 'line', x1, y1, x2, y2, clip: activeClip });1040-1041-/* The dev-logo: a delta in a ring.1042- *1043- * Queued as a decal for the same reason the trophy is, and then for one more.1044- * The trophy is painted after the threshold because it is gold and the board is1045- * one bit; this is painted after the threshold because it is a *mark* -- two1046- * flat colours, a ring in ink and a delta in one of the theme's piece colours,1047- * and no texture at all. Shading it would put the house pattern through a shape1048- * whose whole job is to be constant, and at fourteen pixels a screen of any1049- * ruling turns a ring into four dots.1050- *1051- * Drawn from geometry rather than carried as an asset: it is a circle and three1052- * lines, the renderer has no path anywhere else, and an SVG would have to be1053- * rasterised and recoloured per theme to end up here anyway. Which is also why1054- * the numbers below are the seed drawing's, transcribed rather than eyeballed:1055- * the seed is a 24-unit box with a 9-radius circle stroked at 2, so every1056- * coordinate here is that drawing divided by its own outer diameter of 20 and1057- * multiplied back up by the ring's -- and the one stroke weight, a tenth of the1058- * outer diameter, is the circle's and the delta's alike, as it is there.1059- *1060- * The delta is stroked and not filled, which is the whole character of the1061- * mark. At this weight on a shape this size the outline very nearly fills1062- * itself, so what is left of the middle is a slot rather than a hole -- and the1063- * short inner stroke, the one that makes it a delta rather than a triangle,1064- * cuts that slot off-centre. A solid triangle was the earlier reading of it and1065- * it lost exactly that: the same silhouette with nothing inside. */1066-const devLogo = (cx, cy, size, ink) =>1067- decals.push({ kind: 'logo', x: cx, y: cy, size, ink, clip: activeClip });1068-1069-function paintDevLogo(c, d) {1070- const r = d.size / 2;1071- c.save();1072- // Load-bearing now that the delta is a stroke: three heavy segments meeting1073- // at sharp angles are a spike at each corner under a mitre, and the seed's1074- // corners are round.1075- c.lineJoin = 'round';1076- c.lineCap = 'round';1077- c.lineWidth = Math.max(1, d.size * 0.10);1078-1079- // The ring, and then the delta inside it: the ring is the enclosure, so it is1080- // laid down first and the mark sits within it rather than across it.1081- c.strokeStyle = INK_CSS;1082- c.beginPath();1083- c.arc(d.x, d.y, r - c.lineWidth / 2, 0, Math.PI * 2);1084- c.stroke();1085-1086- c.strokeStyle = d.ink;1087- c.beginPath();1088- c.moveTo(d.x, d.y - r * 0.5); // apex (12,7)1089- c.lineTo(d.x + r * 0.4, d.y + r * 0.3); // foot right (16,15)1090- c.lineTo(d.x - r * 0.4, d.y + r * 0.3); // foot left (8,15)1091- c.closePath();1092- c.stroke();1093-1094- // The stroke that makes it a delta: up from just inside the right foot,1095- // stopping short of the apex.1096- c.beginPath();1097- c.moveTo(d.x + r * 0.3, d.y + r * 0.3); // (15,15)1098- c.lineTo(d.x - r * 0.05, d.y - r * 0.4); // (11.5,8)1099- c.stroke();1100- c.restore();1101-}1102-1103-function paintGoldLine(c, d) {1104- c.save();1105- c.strokeStyle = GOLD;1106- c.lineWidth = 1.2;1107- c.setLineDash([3, 2]);1108- c.beginPath();1109- c.moveTo(d.x1, d.y1);1110- c.lineTo(d.x2, d.y2);1111- c.stroke();1112- c.restore();1113-}1114-1115-function paintTrophy(c, cx, cy, s) {1116- const w = s, h = s * 1.1;1117- c.lineJoin = 'round';1118- c.strokeStyle = INK_CSS;1119- c.fillStyle = GOLD;1120- c.lineWidth = Math.max(0.7, s * 0.09);1121-1122- for (const side of [-1, 1]) { // handles1123- c.beginPath();1124- c.moveTo(cx + side * w * 0.36, cy - h * 0.34);1125- c.quadraticCurveTo(cx + side * w * 0.72, cy - h * 0.22, cx + side * w * 0.34, cy - h * 0.02);1126- c.stroke();1127- }1128- c.beginPath(); // bowl1129- c.moveTo(cx - w * 0.4, cy - h * 0.44);1130- c.lineTo(cx + w * 0.4, cy - h * 0.44);1131- c.lineTo(cx + w * 0.28, cy + h * 0.04);1132- c.quadraticCurveTo(cx, cy + h * 0.3, cx - w * 0.28, cy + h * 0.04);1133- c.closePath(); c.fill(); c.stroke();1134-1135- c.beginPath(); // stem and foot1136- c.rect(cx - w * 0.09, cy + h * 0.16, w * 0.18, h * 0.2);1137- c.fill(); c.stroke();1138- c.beginPath();1139- c.rect(cx - w * 0.34, cy + h * 0.34, w * 0.68, h * 0.14);1140- c.fill(); c.stroke();1141-}1142-1143-/* Drawn at (x, y); *cut* at the phase of (rx, ry), which is where the block1144- * rests on the grid. They are the same everywhere except under a thumb or1145- * mid-slide, and keeping them apart is what stops the ink crawling. */1146-function placeBlock(x, y, w, h, cls, opt, rx = x, ry = y) {1147- blits.push({1148- sprite: blockSprite(w, h, cls, { ...opt, rx, ry }),1149- x: Math.round(x), y: Math.round(y), clip: activeClip,1150- });1151-}1152-function drawGridInto(c, board, g, bx, by, cell, opt) {1153- for (const p of piecesOf(board, g, [])) {1154- placeBlock(bx + p.x * cell, by + p.y * cell, p.w * cell, p.h * cell, p.c,1155- { ...opt, device: !!opt.device && p.c === 4 });1156- }1157-}1158-1159-/* -------------------------------------------------------------- the frame */1160-export function renderFrame() {1161- const { VW, VH } = view;1162- typeQueue.length = 0;1163- decals.length = 0;1164- tints.length = 0;1165- blits.length = 0;1166- activeClip = null;1167-1168- // Resolved before anything is drawn: the block sprites are built inside the1169- // screen painters and need the same style the frame-level threshold will use.1170- const combo = resolveCombo();1171- curShading = combo.shading;1172- // Stepped down to what the smallest piece on this board can carry: one1173- // ruling for the whole board, measured in the buffer pixels the screen is1174- // actually cut in, against a 1x1's fill less its gap and outline.1175- // Stepped down to what the smallest piece can carry *and* to what stays in1176- // register with the grid it is printed on: the screen runs across the whole1177- // picture now, so a cell that is not a whole number of periods wide would put1178- // every piece on a different part of the pattern from its neighbour.1179- curRuling = fitRuling(combo.ruling, dpx((LY.cell || 16) - 6), dpx(LY.cell || 16));1180- curTile = GROUND_TILES[combo.backdrop] || GROUND_TILES.lattice;1181- curDot32 = pack(combo.ground.dot);1182- curField32 = pack(combo.ground.field);1183- syncGroundCss(combo.ground.field);1184-1185- // Every draw call below is in layout units and arrives through this, which1186- // is the whole of what density costs the drawing code.1187- sctx.setTransform(DENSITY, 0, 0, DENSITY, 0, 0);1188- sctx.clearRect(0, 0, VW, VH); // what nobody paints over becomes ground1189-1190- switch (App.screen) {1191- case 'boot': drawBoot(sctx); break;1192- case 'survey': drawSurvey(sctx); break;1193- case 'atlas': drawAtlas(sctx); break;1194- case 'card': drawCard(sctx); break;1195- case 'play': drawPlay(sctx); break;1196- case 'won': drawWon(sctx); break;1197- case 'stats': drawStats(sctx); break;1198- }1199-1200- const PW = dpx(VW), PH = dpx(VH); // the picture's real pixels1201- const src = new Uint32Array(sctx.getImageData(0, 0, PW, PH).data.buffer);1202- const dst = new Uint32Array(outImg.data.buffer);1203- shade(src, dst, PW, PH, curShading, INK32, PAPER32, curRuling);1204- applyTints(dst, PW, PH);1205- paintGround(dst, PW, PH, curTile, curDot32, curField32);1206-1207- // Struck inversions are applied after the threshold. They never coincide1208- // with a drag, so the carried sprite does not have to follow them. The1209- // ground inverts on its own pair rather than on ink and paper -- left out of1210- // it, the backdrop sat perfectly still while the wipe crossed it, which read1211- // as the board flipping inside a window rather than the screen turning over.1212- if (fx.flash > 0 || fx.wipe >= 0) {1213- for (let y = 0; y < PH; y++) {1214- const row = y * PW;1215- for (let x = 0; x < PW; x++) {1216- const past = fx.wipe >= 0 && (fx.wipeAxis === 'x' ? x < dpx(fx.wipe) : y < dpx(fx.wipe));1217- if (fx.flash > 0 || past) {1218- const i = row + x; // piece colour is left alone1219- const v = dst[i];1220- if (v === PAPER32) dst[i] = INK32;1221- else if (v === INK32) dst[i] = PAPER32;1222- else if (v === curField32) dst[i] = curDot32;1223- else if (v === curDot32) dst[i] = curField32;1224- }1225- }1226- }1227- }1228- octx.putImageData(outImg, 0, 0);1229- // The sprites are already density-sized, so they blit at density coordinates.1230- for (const b of blits) {1231- withClip(octx, b.clip && scaleRect(b.clip), () =>1232- octx.drawImage(b.sprite.canvas, dpx(b.x), dpx(b.y)));1233- }1234- drawType();1235-}1236-1237-/* --- title ---------------------------------------------------------------- */1238-/* One centred window holding the title, the specimen board and the prompt --1239- * an About box, essentially. Nothing is set on the desktop pattern: a subhead1240- * at that weight sitting straight on a dithered ground is exactly the fight1241- * this rule exists to avoid. */1242-function drawBoot(c) {1243- // Sized off the column rather than the picture: on a wide screen the picture1244- // is mostly desktop, and type scaled to it would be set for a window twice1245- // the size of the one it is actually in.1246- const U = LY.col.w, cx = LY.col.cx;1247- const win = LY.bootWin;1248- drawPanel(c, win);1249-1250- /* The title is sized off the column and set by a fraction of the window --1251- * two measurements that agree on a tall phone and pull apart on a short1252- * wide one, where the window is wide enough to ask for big type and not tall1253- * enough to have anywhere to put it. So the size is capped by the height as1254- * well, and the baseline is never nearer the top of the window than the type1255- * is tall. Left to the fraction alone, KLOTSKI is printed through the frame.1256- */1257- const titleSize = fitSize('KLOTSKI', Math.min(U * 0.14, win.h * 0.19),1258- 'Silk', U * 0.02, win.w - 16);1259- const titleY = win.y + Math.max(Math.round(win.h * 0.15), Math.round(titleSize) + 4);1260- label('KLOTSKI', cx, titleY, { size: titleSize, track: U * 0.02, align: 'center' });1261- label('SLIDING BLOCK PUZZLES', cx, titleY + Math.round(titleSize * 0.55),1262- { size: fitSize('SLIDING BLOCK PUZZLES', U * 0.05, 'SilkR', U * 0.006, win.w - 16),1263- face: 'SilkR', track: U * 0.006, align: 'center' });1264- const ruleY = titleY + Math.round(titleSize * 0.95);1265- c.fillStyle = grey(T.rule);1266- c.fillRect(win.x + 10, ruleY, win.w - 20, 1);1267-1268- // The specimen takes what is left under the rule rather than its own1269- // fraction of the window, which is the same trap the title fell into.1270- const top = ruleY + Math.round(win.h * 0.06);1271- const room = win.y + win.h - top - Math.round(win.h * 0.22);1272- const hero = FAMILIES.find(f => f.id === 'classic') || FAMILIES[0];1273- const cell = Math.max(8, Math.min(Math.floor((win.w - 52) / hero.w),1274- Math.floor(room / hero.h) - 2));1275- const tp = Math.max(3, Math.round(cell * 0.16));1276- const bx = Math.round(cx - cell * hero.w / 2), by = top + tp;1277- const tray = { x: bx - tp, y: by - tp, w: cell * hero.w + tp * 2, h: cell * hero.h + tp * 2 };1278- c.fillStyle = grey(T.rule); // the tray, ruled1279- c.fillRect(tray.x - 1, tray.y - 1, tray.w + 2, 1);1280- c.fillRect(tray.x - 1, tray.y + tray.h, tray.w + 2, 1);1281- c.fillRect(tray.x - 1, tray.y - 1, 1, tray.h + 2);1282- c.fillRect(tray.x + tray.w, tray.y - 1, 1, tray.h + 2);1283- // Drawn exactly as the board in play is -- same gap, same shadow, same mark1284- // on the goal block. It is the same object; it should not be a lookalike.1285- drawGridInto(c, hero.board, Uint8Array.from(hero.grid), bx, by, cell,1286- { gap: 2, drop: Math.max(2, Math.round(cell * 0.09)), device: true });1287-1288-}1289-1290-/* --- the sweep, made visible ---------------------------------------------- */1291-function drawSurvey(c) {1292- const { VH } = view;1293- const U = LY.col.w, cx = LY.col.cx, sv = App.pending;1294-1295- label('MAPPING THE STATE SPACE', cx, VH * 0.42,1296- { size: fitSize('MAPPING THE STATE SPACE', U * 0.05, 'SilkR', U * 0.006, U * 0.84),1297- face: 'SilkR', track: U * 0.006, align: 'center' });1298- label((sv ? sv.count : 0).toLocaleString('en-US'), cx, VH * 0.53,1299- { size: U * 0.1, track: U * 0.008, align: 'center' });1300- label(sv && sv.phase === 2 ? 'MEASURING DISTANCE TO GOAL' : 'CONFIGURATIONS REACHED',1301- cx, VH * 0.575,1302- { size: fitSize('MEASURING DISTANCE TO GOAL', U * 0.038, 'SilkR', 0, U * 0.84),1303- face: 'SilkR', align: 'center', alpha: 0.75 });1304- label(sv ? sv.fam.label : '', cx, VH * 0.645,1305- { size: U * 0.05, track: U * 0.01, align: 'center' });1306-1307- const w = Math.round(U * 0.5), x = Math.round(cx - w / 2), y = Math.round(VH * 0.70);1308- fill(c, x, y, w, 4, 255);1309- c.fillStyle = grey(T.rule);1310- c.fillRect(x - 1, y - 1, w + 2, 1); c.fillRect(x - 1, y + 4, w + 2, 1);1311- c.fillRect(x - 1, y - 1, 1, 6); c.fillRect(x + w, y - 1, 1, 6);1312- const done = sv ? (sv.phase === 2 ? 0.55 + 0.45 * Math.min(1, sv.dist.size / Math.max(1, sv.count))1313- : 0.35) : 0.1;1314- fill(c, x, y, Math.round(w * done / PERIOD) * PERIOD, 4, 0);1315-}1316-1317-/* --- choosing a board -----------------------------------------------------1318- * A vertical list, one card per family, because a grid of three tiers no1319- * longer describes anything: a family is an open ladder, not three plates.1320- * The card shows where you are on that ladder and what the next board costs.1321- */1322-export function atlasGeometry() {1323- const { VH, mode } = view;1324- const col = LY.col;1325-1326- /* Two columns of cards on a wide screen. Eight sets in a single column of a1327- * landscape picture is three of them visible and five below the fold, which1328- * makes a list of eight things read as a list of three -- and the scroll1329- * that reveals the rest is the one gesture this app otherwise never asks1330- * for. Filled down the first column and then the second, so it still reads1331- * as one ordered ladder rather than as two lists. */1332- const cols = mode === 'split' && FAMILIES.length > 3 ? 2 : 1;1333- const gapX = 8;1334- const margin = col.x + 7;1335- const width = Math.round((col.w - 14 - (cols - 1) * gapX) / cols);1336- const rows = Math.ceil(FAMILIES.length / cols);1337-1338- // The board column is a fixed width and the text column starts at a fixed1339- // x, whatever shape the board is. Sizing the column to each board made every1340- // row start its name in a different place and at a different size, which is1341- // the one thing a list of like items must not do.1342- const boardCol = Math.round(width * 0.33);1343- const tx = 8 + boardCol + 10; // from the card's own left edge1344-1345- // Type is sized off the card rather than off the picture, so a pair of1346- // half-width cards sets its names for the space a half-width card has.1347- const U = width * 1.08;1348-1349- // One size for every name, and the name gets the whole text column. Sharing1350- // that line with the stage number left so little room that the names came1351- // out smaller than the numbers beside them, which inverts the hierarchy of1352- // the card: the set is the subject, the stage is a detail about it.1353- const stageSize = snap(U * 0.038);1354- const avail = width - 8 - tx;1355- let nameSize = snap(U * 0.052);1356- for (const f of FAMILIES) {1357- nameSize = Math.min(nameSize, fitSize(f.label, nameSize, 'Silk', U * 0.006, avail));1358- }1359-1360- /* One column is a list and always scrolls -- eight sets never fit a phone.1361- * Two columns usually can fit, so they are sized to, and the scroll goes1362- * away on anything with the room for it. */1363- const top = LY.barH + 8, gap = 6;1364- const rowH = cols > 11365- ? Math.min(72, Math.max(46, Math.floor((VH - top - 8) / rows) - gap))1366- : Math.max(52, Math.round(VH * 0.155));1367-1368- return { top, rowH, gap, margin, width, boardCol, tx, nameSize, stageSize, cols, gapX, rows, U };1369-}1370-export function familyRowRect(G, fi) {1371- return {1372- x: G.margin + Math.floor(fi / G.rows) * (G.width + G.gapX),1373- y: Math.round(G.top + (fi % G.rows) * (G.rowH + G.gap) + App.atlasScroll),1374- w: G.width, h: G.rowH,1375- };1376-}1377-export function atlasContentHeight() {1378- const G = atlasGeometry();1379- return G.rows * (G.rowH + G.gap) + 8;1380-}1381-1382-function drawAtlas(c) {1383- const { VW, VH } = view;1384- const G = atlasGeometry(), U = G.U;1385- setClip({ x: 0, y: LY.barH + 1, w: VW, h: VH - LY.barH - 1 });1386-1387- FAMILIES.forEach((fam, fi) => {1388- const r = familyRowRect(G, fi);1389- if (r.y + r.h < LY.barH || r.y > VH) return; // off-screen, skip1390- drawPanel(c, r);1391-1392- const rec = famStats(fam.id);1393- const sv = App.surveys[fam.id];1394- const pz = sv ? puzzleFor(fam, sv, rec.rung) : null;1395-1396- // The board on the left: the rung you are about to play, or the family's1397- // own packing if it has not been swept yet.1398- const inner = r.h - 12;1399- const mini = Math.max(4, Math.floor(Math.min(G.boardCol / fam.w, inner / fam.h)));1400- // Centred in its fixed column, so boards of different shapes still line up.1401- const bx = r.x + 8 + Math.round((G.boardCol - mini * fam.w) / 2);1402- const by = r.y + Math.round((r.h - mini * fam.h) / 2);1403- const grid = pz ? gridOf(fam.board, pz.state) : Uint8Array.from(fam.grid);1404- // Drawn in the screen that rung will actually be played in, so the card1405- // previews the board *and* its look rather than the board alone -- stepped1406- // down to what a third-size cell can hold two of, which is the same rule1407- // the board itself is printed under, just against a smaller cell.1408- const look = lookFor(fam.id, rec.rung);1409- drawGridInto(c, fam.board, grid, bx, by, mini, {1410- gap: 1, drop: 1, shadow: false,1411- shading: look.shading, ruling: fitRuling(look.ruling, dpx(mini - 2)),1412- });1413-1414- const tx = r.x + G.tx;1415- label(fam.label, tx, r.y + 16, { size: G.nameSize, track: U * 0.006 });1416- label(`STAGE ${rec.rung}`, r.x + r.w - 8, r.y + 31,1417- { size: G.stageSize, face: 'SilkR', track: U * 0.004, align: 'right', alpha: 0.85 });1418-1419- if (pz) {1420- label(`${pz.metrics.L} STEPS`, tx, r.y + 31,1421- { size: snap(U * 0.036), face: 'SilkR', alpha: 0.85 });1422- const gr = grade(pz.metrics.score);1423- c.fillStyle = grey(T.rule);1424- for (let m = 0; m < 5; m++) {1425- const mx = tx + m * 6, my = r.y + 36;1426- if (m < gr) c.fillRect(mx, my, 4, 4);1427- else { c.fillRect(mx, my, 4, 1); c.fillRect(mx, my + 3, 4, 1);1428- c.fillRect(mx, my, 1, 4); c.fillRect(mx + 3, my, 1, 4); }1429- }1430- // How much of this family's depth you have walked. It fills as the rungs1431- // climb and stops when the ladder reaches the family's ceiling -- which1432- // is the honest end of "deeper", not the end of the family.1433- const bw = Math.min(r.w - (tx - r.x) - 34, 72);1434- const by2 = r.y + r.h - 13;1435- c.fillStyle = grey(T.rule);1436- c.fillRect(tx, by2, bw, 1);1437- c.fillRect(tx, by2 - 3, 1, 4); c.fillRect(tx + bw - 1, by2 - 3, 1, 4);1438- const k = Math.max(0, Math.min(1, depthForRung(rec.rung, sv.ecc) / Math.max(1, sv.ecc)));1439- c.fillRect(tx + Math.round(k * (bw - 3)), by2 - 4, 3, 6);1440- label(atCeiling(rec.rung, sv.ecc) ? 'AT THE CEILING' : `TO ${sv.ecc}`,1441- tx + bw + 6, by2 + 3, { size: snap(U * 0.03), face: 'SilkR', alpha: 0.6 });1442- } else {1443- // Set in the middle of the space the played rows fill, rather than1444- // tucked under the name leaving the rest of the card empty.1445- label('TAP TO BEGIN', tx, r.y + Math.round((r.h + 26) / 2) + 6,1446- { size: snap(U * 0.04), face: 'SilkR', track: U * 0.004, alpha: 0.65 });1447- }1448- });1449-1450- setClip(null);1451- drawMenuBar(c);1452- // The dev-logo sits at the far left of the menu bar and the title follows it --1453- // which is where a Macintosh put the Apple, and the reason that position1454- // reads as a signature rather than as an icon someone left lying there.1455- //1456- // It costs the title about a sixth of the bar's width and nothing else: the1457- // list below is measured from barH, which has not moved, and PUZZLES at half1458- // the bar height still clears the buttons on the right with room to spare.1459- const logo = Math.round(LY.barH * 0.62);1460- devLogo(8 + logo / 2, LY.barH / 2, logo, css(logoInk(activePalette(), dayOfYear())));1461- label('PUZZLES', 8 + logo + 6, LY.barH - Math.round(LY.barH * 0.28),1462- { size: LY.barH * 0.5, track: LY.barH * 0.08 });1463-}1464-1465-/* puzzleFor memoises per (family, rung), so calling it every frame costs a1466- * map lookup rather than forty walker rollouts. */1467-1468-/* --- the card between boards ---------------------------------------------- */1469-function drawCard(c) {1470- const { VH } = view;1471- const U = LY.col.w, cx = LY.col.cx;1472- const pz = App.pz;1473- const win = { x: LY.col.x + 14, y: Math.round(VH * 0.37), w: U - 28, h: Math.round(VH * 0.25) };1474- drawPanel(c, win);1475-1476- // Arrives in discrete steps, never a smooth fade: a continuous ramp would1477- // put in-between frames in the middle of a one-bit game. Stepped by the loop,1478- // for the reason the rise is.1479- const step = App.cardStep;1480- label(App.fam.label, cx, win.y + Math.round(win.h * 0.42), {1481- size: fitSize(App.fam.label, U * 0.11, 'Silk', U * 0.016, win.w - 18),1482- track: U * 0.016, align: 'center', alpha: step / 4,1483- });1484-1485- if (App.cardT > 240) {1486- label(`STAGE ${App.rung}`, cx, win.y + Math.round(win.h * 0.66),1487- { size: U * 0.05, face: 'SilkR', track: U * 0.008, align: 'center' });1488- label(`${pz.metrics.L} STEPS MINIMUM`, cx, win.y + Math.round(win.h * 0.87),1489- { size: U * 0.042, face: 'SilkR', track: U * 0.006, align: 'center', alpha: 0.75 });1490- }1491-}1492-1493-/* --- the board ------------------------------------------------------------ */1494-function pieceOffset(p) {1495- const d = App.drag;1496- if (d && d.piece === p && d.axis) return d.axis === 'x' ? { dx: d.off, dy: 0 } : { dx: 0, dy: d.off };1497- const a = App.anim;1498- if (a && a.piece === p) return a.axis === 'x' ? { dx: a.cur, dy: 0 } : { dx: 0, dy: a.cur };1499- return { dx: 0, dy: 0 };1500-}1501-function drawPlay(c) {1502- const cell = LY.cell, B = LY.board, P = LY.plate;1503- // Stepped by the loop, not here: it has to know what this picture will be1504- // before it decides to ask for it. See frameKey in game.js.1505- const rise = App.rise;1506- const plate = { x: P.x, y: P.y - rise, w: P.w, h: P.h };1507-1508- drawMenuBar(c);1509- drawPanel(c, LY.panel);1510- drawPlate(c, plate);1511-1512- const bx = B.x, by = B.y - rise;1513- const tp = LY.trayPad;1514- const tray = { x: bx - tp, y: by - tp, w: B.w + tp * 2, h: B.h + tp * 2 };1515- fill(c, tray.x, tray.y, tray.w, tray.h, T.tray);1516- c.fillStyle = grey(T.rule); // the tray, ruled1517- c.fillRect(tray.x - 1, tray.y - 1, tray.w + 2, 1);1518- c.fillRect(tray.x - 1, tray.y + tray.h, tray.w + 2, 1);1519- c.fillRect(tray.x - 1, tray.y - 1, 1, tray.h + 2);1520- c.fillRect(tray.x + tray.w, tray.y - 1, 1, tray.h + 2);1521-1522- // The aperture: the tray's rule is cut where the block is meant to leave.1523- const [gx] = App.fam.goal;1524- fill(c, bx + gx * cell + 2, tray.y + tray.h, cell * 2 - 4, 1, T.plate);1525- c.fillStyle = grey(T.rule);1526- for (let i = 0; i <= 2; i++) {1527- c.fillRect(Math.round(bx + (gx + i) * cell), tray.y + tray.h + 2, 1, 3);1528- }1529-1530- const shake = App.shakeOff;1531- const blockOpt = p => ({1532- gap: 2,1533- thick: !!(App.drag && App.drag.piece === p),1534- drop: Math.max(2, Math.round(cell * 0.09)),1535- device: p.c === 4,1536- });1537-1538- for (const p of App.pieces) {1539- const o = pieceOffset(p);1540- const jx = App.shakePiece === p ? shake : 0;1541- // The offset moves the block; it does not move the screen the block is cut1542- // from. A piece under a thumb carries its own ink across the board.1543- placeBlock(bx + p.x * cell + o.dx + jx, by + p.y * cell + o.dy,1544- p.w * cell, p.h * cell, p.c, blockOpt(p),1545- bx + p.x * cell, by + p.y * cell);1546- }1547-1548- // Centred on the plate it is printed on, not on the picture: beside a panel1549- // the plate is no longer in the middle of the screen.1550- label(App.fam.label, plate.x + plate.w / 2,1551- plate.y + plate.h - Math.round(LY.padBottom * 0.34),1552- { size: fitSize(App.fam.label, cell * 0.44, 'Silk', 1.5, P.w - 18),1553- track: 1.5, align: 'center' });1554-1555- drawGauge(c, LY.gaugeY - (view.mode === 'split' ? 0 : rise));1556-}1557-1558-/* How far you actually are, against how far you began -- and the reason a1559- * board that looks tidy can read as further out than the mess you started1560- * from. The marker travels left to right as the distance falls. */1561-function drawGauge(c, y) {1562- if (save.hints === 'off') return;1563- const d = App.sv.dist.get(App.state), d0 = App.sv.dist.get(App.startState);1564- if (d == null || d0 == null) return;1565-1566- /* What the marker is measured against.1567- *1568- * FULL scales to the distance you started at, which together with the TO GOAL1569- * readout beside it tells you exactly where you stand.1570- *1571- * BEST scales to the furthest out you have ever been on this board instead.1572- * The number is gone, so the gauge is all there is, and anchoring it to your1573- * own worst point makes it a reading of your progress rather than a1574- * disguised step count -- you cannot work backwards from it to the solver's1575- * answer. It is the same choice the record screen's chart already makes, and1576- * for the same reason: going backwards is the interesting part, and a scale1577- * that cannot show it hides it.1578- */1579- const ref = save.hints === 'best' ? Math.max(d0, ...App.trace) : d0;1580- const w = LY.gauge.w, x = LY.gauge.x;1581- c.fillStyle = grey(T.rule);1582- c.fillRect(x, y, w, 1);1583- for (let i = 0; i <= 4; i++) c.fillRect(x + Math.round(i * w / 4), y - 3, 1, 4);1584- const k = Math.max(0, Math.min(1, 1 - d / Math.max(1, ref)));1585- c.fillRect(x + Math.round(k * w) - 2, y - 7, 5, 12);1586-}1587-1588-/* --- the record ----------------------------------------------------------- */1589-function drawWon(c) {1590- const P = LY.plate;1591- const plate = { x: P.x, y: P.y, w: P.w, h: P.h };1592- drawMenuBar(c);1593- drawPanel(c, LY.panel);1594- drawPlate(c, plate);1595- const cx = P.x + P.w / 2;1596- /* Everything here is set inside the plate, so it is measured off the plate.1597- * It used to be measured off the picture, which was the same number while1598- * the picture was a phone-wide column and is not one beside a panel -- and1599- * a three-wide board's plate is narrow enough that it was never quite the1600- * same number anyway. The 1.15 is what keeps the tall-phone sizes exactly1601- * where they were. */1602- const U = P.w * 1.15;1603-1604- // Stacked down the centre, and spaced from the trophy's actual height1605- // rather than by fractions of the plate -- on a short board the fractions1606- // closed up until the cup was resting on the S of SOLVED.1607- const cupSize = LY.cell * 0.62;1608- const cupY = plate.y + Math.round(P.h * 0.085);1609- trophy(cx, cupY, cupSize);1610- const solvedY = Math.round(cupY + cupSize * 0.55 + snap(U * 0.045) + 6);1611- label('SOLVED', cx, solvedY,1612- { size: U * 0.045, face: 'SilkR', track: U * 0.018, align: 'center' });1613- label(App.fam.label, cx, solvedY + Math.round(U * 0.085),1614- { size: U * 0.075, track: U * 0.01, align: 'center' });1615-1616- // Distance to goal, falling as the steps accumulate. The dotted rule is the1617- // line a perfect solver draws -- straight down. The ridge is what actually1618- // happened, and every place it climbs is a stretch where the board took you1619- // further from the answer than you already were.1620-1621- // The caption is measured before the chart is placed, because the chart is1622- // now given the room rather than a fraction of it.1623- const capSize = fitSize('DISTANCE TO GOAL', LY.cell * 0.36, 'SilkR', 1, P.w - 18);1624- const capY = plate.y + plate.h - Math.round(LY.padBottom * 0.34);1625-1626- /* The chart takes everything between the set's name and that caption.1627- *1628- * Its height used to be a fraction of the plate, and the plate is the1629- * *board's* -- a five-wide set gets a wide short one and a three-wide set a1630- * narrow tall one, so the same run of play was drawn at a different size on1631- * every family, and on the square plates a third of the height left the ridge1632- * stranded in the middle of the window with a hand's width of nothing under1633- * it. There is nothing else in this window: the chart is what the screen is1634- * for, and it should be as big as the frame allows on every board. */1635- const gx = plate.x + Math.round(P.w * 0.13), gw = Math.round(P.w * 0.74);1636- const gy = solvedY + Math.round(U * 0.13);1637- // Room for the baseline rule and a breath under it before the caption's caps.1638- const gh = Math.max(24, capY - capSize - Math.round(U * 0.075) - gy);1639- const tr = App.trace, n = Math.max(1, tr.length);1640- const d0 = Math.max(1, tr[0] || 1);1641- // Scaled to the furthest you ever got, not to where you started. Going1642- // *backwards* is the whole point of the chart -- a board can leave you1643- // further out than it found you -- and scaling to the start meant those1644- // bars ran off the top of the panel and through the title.1645- const peak = Math.max(d0, ...tr, 1);1646- const bw = gw / n;1647- const topOf = v => gy + gh - Math.round(gh * (v / peak));1648-1649- const ref = topOf(d0);1650- goldLine(gx, ref, gx + gw, gy + gh);1651-1652- /* Drawn as one silhouette, never as separate bars.1653- *1654- * The columns used to be given a pixel of daylight between them once they1655- * were wide enough to spare it, which meant the chart was two different1656- * pictures depending on how long the run was: a hundred-step descent packed1657- * to a pixel a column and read as a solid falling mass, and a fourteen-step1658- * one opened out into fourteen tally marks. The mass is the one worth having1659- * -- it is a shape you take in at a glance, and a run of play is a continuous1660- * thing rather than fourteen unrelated measurements.1661- *1662- * So the columns meet. What separates them is the step in the top edge where1663- * the distance changed, which is the only separation the data actually has,1664- * and it survives at any width: at one pixel a column it is a ragged ridge,1665- * at ten it is a staircase, and it is the same picture in both. */1666- c.fillStyle = grey(T.rule);1667- for (let i = 0; i < n; i++) {1668- const h = Math.max(1, Math.round(gh * (tr[i] / peak)));1669- const x0 = gx + Math.round(i * bw), x1 = gx + Math.round((i + 1) * bw);1670- c.fillRect(x0, gy + gh - h, Math.max(1, x1 - x0), h);1671- }1672- c.fillRect(gx, gy + gh + 1, gw, 1);1673-1674- // Sized off the plate, not off the cell: a three-wide board gets big cells,1675- // and a caption scaled to them runs straight out through the frame.1676- label('DISTANCE TO GOAL', cx, capY,1677- { size: capSize, face: 'SilkR', track: 1, align: 'center' });1678-1679-}1680-1681-/* --- the record of everything --------------------------------------------1682- * Built from the save, not from the session: totals are counted as they1683- * happen and the log is a capped ring buffer, so this reads the same after a1684- * restart as it did before one.1685- */1686-function fmtTime(ms) {1687- if (ms < 60000) return `${Math.round(ms / 1000)}S`;1688- const m = Math.round(ms / 60000);1689- if (m < 60) return `${m}M`;1690- return `${Math.floor(m / 60)}H ${String(m % 60).padStart(2, '0')}M`;1691-}1692-1693-function drawStats(c) {1694- const { VW, VH, mode } = view;1695- const col = LY.col;1696- const t = save.totals;1697- const prog = levelProgress(t.xp || 0);1698-1699- /* Two columns when the screen is wide, because the alternative is a stack of1700- * panels taller than the picture with the chart -- the one thing on here1701- * worth looking at -- squeezed to a strip at the bottom. The per-family1702- * table is the tall item and it is also the one that does not want to be1703- * cut, so it takes the second column whole. */1704- const two = mode === 'split';1705- const gapX = 8;1706- const M = col.x + 7;1707- const W = two ? Math.round((col.w - 14 - gapX) / 2) : col.w - 14;1708- const M2 = M + W + gapX;1709- const U = W * 1.08; // type is sized off the panel it is in1710- const cx = M + W / 2;1711- const top = LY.barH + 8;1712- let y = top, y2 = top;1713-1714- // Level.1715- const lvlH = Math.max(48, Math.round(VH * 0.13));1716- drawPanel(c, { x: M, y, w: W, h: lvlH });1717- label('LEVEL', M + 10, y + 16, { size: snap(U * 0.036), face: 'SilkR', track: U * 0.01, alpha: 0.7 });1718- label(String(prog.level), M + 10, y + Math.round(lvlH * 0.72), { size: snap(U * 0.11), track: U * 0.006 });1719- label(`${t.xp || 0} XP`, M + W - 10, y + 16,1720- { size: snap(U * 0.036), face: 'SilkR', align: 'right', alpha: 0.7 });1721-1722- const bx = M + Math.round(W * 0.38), bw = Math.round(W * 0.52);1723- const by = y + Math.round(lvlH * 0.58);1724- c.fillStyle = grey(T.rule);1725- c.fillRect(bx - 1, by - 1, bw + 2, 1); c.fillRect(bx - 1, by + 6, bw + 2, 1);1726- c.fillRect(bx - 1, by - 1, 1, 8); c.fillRect(bx + bw, by - 1, 1, 8);1727- const k = Math.max(0, Math.min(1, prog.into / prog.span));1728- fill(c, bx, by, Math.max(0, Math.round(bw * k)), 6, 0);1729- label(`${prog.into} / ${prog.span} TO ${prog.level + 1}`, bx + bw, by + 16,1730- { size: snap(U * 0.03), face: 'SilkR', align: 'right', alpha: 0.6 });1731- y += lvlH + 6;1732-1733- /* Lifetime, in two rows of three.1734- *1735- * It was one row, and adding what a run was made of to it put five captions1736- * across a phone: EFFICIENCY and PLAYED met in the middle and the row read as1737- * one long word. Three to a row is the same information at a size that1738- * survives a 360-wide screen, and it splits along the meaning anyway -- what1739- * you have solved on top, how you play underneath.1740- *1741- * CLEAN is solves that never went backwards at all, and DETOURS the lifetime1742- * count of the times one did. Both are counted as they happen; the log they1743- * could be derived from is capped, so deriving them would quietly mean "of1744- * the last hundred and forty". */1745- const eff = t.steps ? Math.round((t.optimal / t.steps) * 100) : 0;1746- const cells = [1747- [String(t.solved || 0), 'SOLVED'],1748- [String(t.clean || 0), 'CLEAN'],1749- [String(t.detours || 0), 'DETOURS'],1750- [`${eff}%`, 'EFFICIENCY'],1751- [fmtTime(t.ms || 0), 'PLAYED'],1752- [String(save.days.streak || 0), 'DAY RUN'],1753- ];1754- const PER_ROW = 3;1755- const rows = Math.ceil(cells.length / PER_ROW);1756- const rowH2 = Math.max(30, Math.round(VH * 0.075));1757- const lifeH = rows * rowH2 + 4;1758- drawPanel(c, { x: M, y, w: W, h: lifeH });1759- const cellW = W / PER_ROW;1760- cells.forEach(([v, k2], i) => {1761- const row = Math.floor(i / PER_ROW), colI = i % PER_ROW;1762- const x = M + Math.round(cellW * (colI + 0.5));1763- const ry = y + 2 + row * rowH2;1764- label(v, x, ry + Math.round(rowH2 * 0.52), { size: snap(U * 0.048), align: 'center' });1765- // Fitted to its own cell rather than trusted to be short enough: the widest1766- // of these is EFFICIENCY and it is the one that ran into its neighbour.1767- label(k2, x, ry + Math.round(rowH2 * 0.82),1768- { size: fitSize(k2, U * 0.028, 'SilkR', 0, cellW - 6),1769- face: 'SilkR', align: 'center', alpha: 0.65 });1770- });1771- y += lifeH + 6;1772-1773- // Per family -- the second column when there is one, the next panel down1774- // when there is not.1775- const fmX = two ? M2 : M, fmY = two ? y2 : y;1776- const rowH = 11;1777- const famH = 16 + FAMILIES.length * rowH + 6;1778- drawPanel(c, { x: fmX, y: fmY, w: W, h: famH });1779- const tc = [fmX + 8, fmX + Math.round(W * 0.50), fmX + Math.round(W * 0.68), fmX + W - 8];1780- const head = snap(U * 0.028);1781- label('SET', tc[0], fmY + 12, { size: head, face: 'SilkR', alpha: 0.55 });1782- label('STAGE', tc[1], fmY + 12, { size: head, face: 'SilkR', align: 'right', alpha: 0.55 });1783- label('DONE', tc[2], fmY + 12, { size: head, face: 'SilkR', align: 'right', alpha: 0.55 });1784- label('DEEPEST', tc[3], fmY + 12, { size: head, face: 'SilkR', align: 'right', alpha: 0.55 });1785- FAMILIES.forEach((fam, i) => {1786- const rec = famStats(fam.id);1787- const ry = fmY + 16 + (i + 1) * rowH - 2;1788- const dim = rec.solved ? 1 : 0.45;1789- label(fam.label, tc[0], ry, { size: snap(U * 0.032), alpha: dim });1790- label(String(rec.rung), tc[1], ry, { size: snap(U * 0.032), face: 'SilkR', align: 'right', alpha: dim });1791- label(String(rec.solved), tc[2], ry, { size: snap(U * 0.032), face: 'SilkR', align: 'right', alpha: dim });1792- label(String(rec.deepest || 0), tc[3], ry, { size: snap(U * 0.032), face: 'SilkR', align: 'right', alpha: dim });1793- });1794- if (two) y2 += famH + 6; else y += famH + 6;1795-1796- // Recent solves, deepest first-glance shape of how the ladder is going.1797- const log = save.log || [];1798- // The chart takes whatever the panels above did not: it is the most1799- // interesting thing on the screen and was previously a strip with a third1800- // of the page empty beneath it.1801- const chartH = Math.max(38, VH - y - 30);1802- drawPanel(c, { x: M, y, w: W, h: chartH });1803- label('RECENT DEPTH', M + 8, y + 12, { size: snap(U * 0.03), face: 'SilkR', alpha: 0.6 });1804- if (log.length) {1805- const gx = M + 8, gw = W - 16;1806- const gy = y + 16, gh = chartH - 24;1807- const shown = log.slice(-40);1808- const peak = Math.max(...shown.map(e => e.L), 1);1809- // Bars keep their own width instead of stretching, so three solves read1810- // as three solves rather than as one filled rectangle.1811- const bwid = Math.min(gw / shown.length, 7);1812- c.fillStyle = grey(T.rule);1813- shown.forEach((e, i) => {1814- const h = Math.max(1, Math.round(gh * (e.L / peak)));1815- const x0 = gx + Math.round(i * bwid);1816- const x1 = gx + Math.round((i + 1) * bwid);1817- c.fillRect(x0, gy + gh - h, Math.max(1, x1 - x0 - (bwid > 3 ? 1 : 0)), h);1818- });1819- c.fillRect(gx, gy + gh + 1, gw, 1);1820- /* A pip under the rule for every board that was solved without ever going1821- * backwards. The bar says how deep the board was; the pip says the descent1822- * was a straight one -- two facts about the same solve, and the second is1823- * worth about a pixel.1824- *1825- * Solves recorded before the shape of a run was kept have no answer rather1826- * than a zero, so they get no pip and are not claimed as clean. */1827- shown.forEach((e, i) => {1828- if (e.d !== 0) return;1829- const x0 = gx + Math.round(i * bwid), x1 = gx + Math.round((i + 1) * bwid);1830- c.fillRect(x0, gy + gh + 3, Math.max(1, x1 - x0 - (bwid > 3 ? 1 : 0)), 2);1831- });1832- label(String(peak), M + W - 8, y + 12,1833- { size: snap(U * 0.03), face: 'SilkR', align: 'right', alpha: 0.6 });1834- } else {1835- label('NOTHING SOLVED YET', cx, y + Math.round(chartH * 0.62),1836- { size: snap(U * 0.034), face: 'SilkR', align: 'center', alpha: 0.5 });1837- }1838- y += chartH + 6;1839-1840- // The one line that says why this puzzle is worth the trouble -- in a panel1841- // of its own, because nothing small is ever set on the desktop pattern.1842- const swept = Object.values(App.surveys);1843- const states = swept.reduce((n, sv) => n + sv.count, 0);1844- const footH = 18;1845- if (y + footH < VH - 4) {1846- drawPanel(c, { x: M, y, w: W, h: footH });1847- label(swept.length1848- ? `${states.toLocaleString('en-US')} CONFIGURATIONS MAPPED`1849- : `${FAMILIES.length} PIECE SETS, NONE MAPPED YET`,1850- cx, y + 12, { size: snap(U * 0.03), face: 'SilkR', align: 'center', alpha: 0.6 });1851- }1852-1853- drawMenuBar(c);1854- // Centred, because unlike the atlas this screen has a BACK button sitting in1855- // the left of the bar -- and centred on the bar, which spans the screen.1856- label('RECORD', VW / 2, LY.barH - Math.round(LY.barH * 0.28),1857- { size: LY.barH * 0.5, track: LY.barH * 0.08, align: 'center' });1858-}
D mockup/src/shading.js +0 -440
@@ -1,440 +0,0 @@
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-/** How many rungs of the ladder a shape can actually be printed at. */193-export const rulingsFor = pattern => {194- const p = PATTERNS[pattern];195- if (!p || !p.ruled) return ['fine'];196- return RULING_NAMES.slice(0, RULING_NAMES.indexOf(p.top || RULING_NAMES[RULING_NAMES.length - 1]) + 1);197-};198-199-/* The ruling a pattern will actually use.200- *201- * 'fine' for the shapes that take none, and capped at the shape's own top for202- * the one that has a top. Capping here rather than in the pools is what keeps203- * every count honest: a theme lists five rulings, ORDERED can print at two of204- * them, and the roll landing on BILLBOARD gives MEDIUM rather than a sixth name205- * for a picture already on the list. Every ladder is a prefix of the full one,206- * so anything that steps *down* from here stays inside it. */207-export function effectiveRuling(pattern, ruling) {208- const p = PATTERNS[pattern];209- if (!p || !p.ruled || !RULINGS[ruling]) return 'fine';210- const allowed = rulingsFor(pattern);211- return allowed.includes(ruling) ? ruling : allowed[allowed.length - 1];212-}213-214-/* A screen coarser than the thing it is printed on stops being a screen.215- *216- * Proportional coverage fixes an unrepresentative *slice* of a screen, because217- * there is still a slice to choose within. It cannot do anything when there is218- * barely a slice at all, and the threshold for "barely" is higher than it first219- * looks. Two cells is enough to guarantee the block is neither all ink nor all220- * paper -- which is what this once asked for, and it was not enough. A screen's221- * shape lives in its whole period: eight cells make one, and a block spanning222- * less than that does not show a pattern, it shows one or two arbitrary bands223- * of it. A line screen makes that plainest -- an upright piece came out as two224- * solid halves rather than as stripes -- but it is true of all of them.225- *226- * So the requirement is a period and a half, not two cells. That is the point227- * at which the pattern repeats visibly enough to read as a pattern rather than228- * as a coincidence.229- *230- * `fillPx` is in *buffer* pixels, because that is the space screens are231- * measured in. Passing layout units instead was the other half of the same bug:232- * the guard was comparing a cell size in real pixels against a length in233- * layout ones, and quietly permitted whatever the density factor happened to be.234- *235- * Taken from the board's cell rather than from each sprite, so every piece is236- * printed at one ruling. Choosing per piece would have let a 1x1 and the 2x2237- * beside it come out at different frequencies, which is the one thing a press238- * genuinely never does.239- *240- * The second requirement is register, and it is the same kind of rule. The241- * screen is ruled across the whole picture rather than restarted at each block,242- * so a block landing one cell over lands on a different part of it -- unless the243- * cell is a whole number of periods wide, in which case it lands on exactly the244- * pattern it left. `gridPx` is that cell, in buffer pixels, and a ruling whose245- * period does not divide it is stepped down until one does.246- *247- * Which is a real constraint and worth stating plainly: at three buffer pixels248- * per unit a 32-unit cell is 96 wide and every ruling divides it, so a phone249- * gets the whole ladder. A 24-unit cell is 72 and only FINE and COARSE do, so250- * the coarse end of the ladder is unavailable there rather than being available251- * and out of register. That is the better failure. A screen that does not hold252- * register is not a coarser screen, it is a misprint.253- *254- * Pass nothing and the check is skipped, which is what the previews want: an255- * atlas row is a picture of a board rather than a board, and nothing on it256- * moves.257- */258-export function fitRuling(ruling, fillPx, gridPx = 0) {259- let out = ruling;260- for (;;) {261- const r = RULINGS[out];262- if (!r) return out;263- const period = r.cell * 8;264- const tooCoarse = r.cell * 12 > fillPx; // 8 cells x 1.5265- const offRegister = gridPx > 0 && gridPx % period !== 0;266- if (!tooCoarse && !offRegister) return out;267- const i = RULING_NAMES.indexOf(out);268- if (i <= 0) return 'fine';269- out = RULING_NAMES[i - 1];270- }271-}272-273-/* Screens are tiled at their ruling once and kept. A cell of 3 or 6 does not274- * divide 8, so the scaled tile is 8*cell square and indexed by modulo rather275- * than by a mask -- which is why it is worth building once instead of computing276- * two divisions per pixel. */277-const tiles = new Map();278-export function screenOf(pattern, ruling, which = 'm') {279- const p = PATTERNS[pattern];280- const base = p && p[which];281- if (!base) return null;282- const cell = (p.ruled && RULINGS[ruling] ? RULINGS[ruling] : RULINGS.fine).cell;283- const key = `${pattern}|${which}|${cell}`;284- const hit = tiles.get(key);285- if (hit) return hit;286- const period = 8 * cell;287- const t = { period, m: new Uint8Array(period * period) };288- for (let y = 0; y < period; y++) {289- for (let x = 0; x < period; x++) {290- t.m[y * period + x] = base[((((y / cell) | 0) & 7) << 3) | (((x / cell) | 0) & 7)];291- }292- }293- tiles.set(key, t);294- return t;295-}296-297-/* ------------------------------------------------------------------- area298- *299- * Choose where to cut a group of same-toned pixels so the inked proportion is300- * as close to the tone as the screen's own levels allow.301- *302- * Ink is the *high* thresholds: a pixel is inked when `tone <= threshold`. So303- * the levels present are walked from the top down, accumulating, and the cut is304- * placed at whichever boundary lands nearest the target. Cuts fall between305- * levels and never through one, which is what keeps a band a band and a dot306- * round; and a tone that asked for any ink at all is guaranteed at least the307- * topmost level, which is what makes a piece impossible to lose.308- *309- * Returns the lowest threshold that should be inked.310- */311-function cutFor(levels, counts, total, targetInk) {312- let best = 256, bestErr = Infinity, run = 0;313- for (let i = levels.length - 1; i >= 0; i--) {314- run += counts[i];315- const err = Math.abs(run / total - targetInk);316- if (err < bestErr) { bestErr = err; best = levels[i]; }317- }318- // Nothing inked would lose the shape entirely; everything inked would lose319- // the tone. Both are worse than being one level out.320- if (targetInk > 0 && best === 256) best = levels[levels.length - 1];321- if (targetInk < 1 && best === levels[0] && levels.length > 1) best = levels[1];322- return best;323-}324-325-let work = new Float32Array(0);326-function workBuffer(n) {327- if (work.length < n) work = new Float32Array(n);328- return work;329-}330-331-/* Reused across calls so a frame allocates nothing. 256 tones in, one list of332- * pixel indices out. */333-const groups = new Map();334-335-/**336- * Shade a greyscale source to two colours.337- *338- * @param src Uint32Array view of RGBA source; the low byte is the grey339- * value, which *is* the intended coverage of paper (authored340- * linearly, so 128 really means half the dots).341- * @param dst Uint32Array view of the destination RGBA.342- * @param pattern a key of PATTERNS.343- * @param ruling a key of RULINGS; ignored by patterns that take none.344- *345- * A source pixel with zero alpha is one nothing was drawn over. It is left as346- * zero in the destination and takes no part in diffusion -- it neither receives347- * error nor pays any out, because it is not part of the picture. Ground is348- * painted into those holes afterwards (see ground.js). Packed colours always349- * carry full alpha, so zero can never collide with a real one.350- */351-/* `phaseX` and `phaseY` are where this buffer sits on the sheet, in real pixels.352- *353- * A ruled screen is a pure function of position, so passing that offset in is354- * exactly equivalent to ruling one screen across the whole picture and cutting355- * this window out of it -- the same thing a press does, and at no cost, because356- * the field never has to exist. Without it every buffer starts the pattern at357- * its own corner, which is why two identical blocks a cell apart could sit half358- * a period out of register with each other.359- *360- * Diffusion takes no offset and needs none: its texture is aperiodic, so there361- * is no phase for it to be out of. */362-export function shade(src, dst, w, h, pattern, ink32, paper32, ruling, phaseX = 0, phaseY = 0) {363- const p = PATTERNS[pattern] || PATTERNS.atkinson;364- if (p.diffuse) return diffuse(src, dst, w, h, ink32, paper32);365-366- const screen = screenOf(pattern, ruling);367- const period = screen.period, m = screen.m;368- const n = w * h;369- // Kept non-negative so the modulo below never sees one.370- const ox = ((phaseX % period) + period) % period;371- const oy = ((phaseY % period) + period) % period;372- const at = i => m[((((i / w) | 0) + oy) % period) * period + (((i % w) + ox) % period)];373-374- // Bucket by tone. The two extremes are the overwhelming majority of any frame375- // -- the whole scene outside the block sprites is composed at 0 and 255 -- and376- // they resolve without ever touching the screen, so they never reach the area377- // pass at all.378- for (const g of groups.values()) g.length = 0;379- for (let i = 0; i < n; i++) {380- if ((src[i] >>> 24) === 0) { dst[i] = 0; continue; }381- const v = src[i] & 0xFF;382- if (v === 0) { dst[i] = ink32; continue; }383- if (v === 255) { dst[i] = paper32; continue; }384- let g = groups.get(v);385- if (!g) groups.set(v, g = []);386- g.push(i);387- }388-389- for (const [tone, idx] of groups) {390- if (!idx.length) continue;391- const total = idx.length;392-393- // Count the pixels sitting at each threshold this window actually covers.394- const seen = new Map();395- for (let k = 0; k < total; k++) {396- const i = idx[k];397- const t = at(i);398- seen.set(t, (seen.get(t) || 0) + 1);399- }400- const levels = [...seen.keys()].sort((a, b) => a - b);401- const counts = levels.map(l => seen.get(l));402- const cut = cutFor(levels, counts, total, 1 - tone / 255);403-404- for (let k = 0; k < total; k++) {405- const i = idx[k];406- const t = at(i);407- dst[i] = t >= cut ? ink32 : paper32;408- }409- }410-}411-412-function diffuse(src, dst, w, h, ink32, paper32) {413- const n = w * h;414- const buf = workBuffer(n);415- for (let i = 0; i < n; i++) buf[i] = src[i] & 0xFF;416-417- for (let y = 0; y < h; y++) {418- const row = y * w;419- const hasNext = y + 1 < h, hasNext2 = y + 2 < h;420- for (let x = 0; x < w; x++) {421- const i = row + x;422- if ((src[i] >>> 24) === 0) { dst[i] = 0; continue; }423- const old = buf[i];424- const on = old >= 128;425- dst[i] = on ? paper32 : ink32;426- // Only six eighths of the error is ever paid out. The missing quarter427- // is the Atkinson signature.428- const e = (old - (on ? 255 : 0)) / 8;429- if (e === 0) continue;430- if (x + 1 < w) buf[i + 1] += e;431- if (x + 2 < w) buf[i + 2] += e;432- if (hasNext) {433- if (x > 0) buf[i + w - 1] += e;434- buf[i + w] += e;435- if (x + 1 < w) buf[i + w + 1] += e;436- }437- if (hasNext2) buf[i + 2 * w] += e;438- }439- }440-}
D mockup/src/sound.js +0 -82
@@ -1,82 +0,0 @@
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-}
D mockup/src/state.js +0 -50
@@ -1,50 +0,0 @@
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- *32- * The three clocks below -- shake, cardT, riseT -- run continuously, and33- * beside each is the stepped value the painters actually draw with. Nothing34- * in this app moves smoothly: motion is quantised so the dither never lands35- * out of phase, which means most ticks of a clock produce the same picture as36- * the last one. The loop compares the stepped values, not the clocks, and37- * only re-composes the screen when one of them moves. */38- drag: null,39- anim: null,40- shake: 0,41- shakeOff: 0, // the jolt, in whole units, -2..242- shakePiece: null,43- cardT: 0,44- cardStep: 0, // the card's title, in five discrete strengths45- riseT: 1,46- rise: 0, // how far the board still has to fall, in even units47- atlasScroll: 0,48- lastGain: null, // { xp, firstClear, level, levelled } for the record screen49- installPrompt: null,50-};
D mockup/src/store.js +0 -188
@@ -1,188 +0,0 @@
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- // AUTO follows the system's light/dark preference. Anyone who has already22- // chosen a theme keeps it: migrate carries the key across, and a save that23- // names one is a decision rather than a default.24- palette: 'auto',25- sound: true,26- haptics: true,27- shading: 'auto', // seeded per rung unless pinned to one screen shape28- hints: 'full', // full | best | off -- how much of the solver shows29- density: 3, // buffer pixels per layout unit -- 2 is the low rung30- fams: {}, // id -> { rung, solved, deepest, best: {rung: steps}, ms }31- // `clean` is solves finished without ever going backwards, and `detours` the32- // lifetime count of the excursions in the rest. Counted as they happen for33- // the same reason everything else here is: the log they could be derived from34- // is capped, so deriving them would quietly mean "of the last 140".35- totals: { solved: 0, steps: 0, optimal: 0, ms: 0, xp: 0, clean: 0, detours: 0 },36- days: { first: null, last: null, streak: 0, best: 0 },37- log: [], // { f, r, L, s, ms, t } newest last, capped38-});39-40-export const famRecord = id => ({ rung: 1, solved: 0, deepest: 0, best: {}, ms: 0 });41-42-/* v2 stored only `solved: {"family:tier": {steps, optimal}}`. Convert each43- * family's highest cleared tier into a starting rung so nobody is sent back to44- * the beginning, and keep their settings. */45-function migrate(raw) {46- const out = DEFAULTS();47- const p = raw && raw.payload;48- if (!p) return out;49- // `ordered` is carried across a migration too, so that the conversion below50- // sees it whichever path the save arrived by.51- for (const k of ['palette', 'sound', 'haptics', 'shading', 'hints', 'density', 'blockStyle', 'ordered']) {52- if (p[k] !== undefined) out[k] = p[k];53- }54- if (raw.schema === 2 && p.solved) {55- for (const key of Object.keys(p.solved)) {56- const [id, tier] = key.split(':');57- const rec = out.fams[id] || (out.fams[id] = famRecord(id));58- const cleared = (parseInt(tier, 10) || 0) + 1;59- rec.rung = Math.max(rec.rung, cleared + 1);60- rec.solved += 1;61- const hit = p.solved[key];62- if (hit && hit.optimal) rec.deepest = Math.max(rec.deepest, hit.optimal);63- out.totals.solved += 1;64- if (hit) { out.totals.steps += hit.steps || 0; out.totals.optimal += hit.optimal || 0; }65- }66- }67- return out;68-}69-70-function load() {71- try {72- const raw = JSON.parse(localStorage.getItem(KEY));73- if (!raw) return { ...DEFAULTS(), counter: 0 };74- if (raw.schema !== SCHEMA) return { ...migrate(raw), counter: raw.counter || 0 };75- return { ...DEFAULTS(), ...raw.payload, counter: raw.counter || 0 };76- } catch (e) {77- return { ...DEFAULTS(), counter: 0 };78- }79-}80-81-export const save = load();82-83-/* `ordered` was a boolean: Atkinson, or Bayer. It is now one of half a dozen84- * block styles, or 'auto' to let the rung decide.85- *86- * Converted in place rather than by bumping the schema. migrate() rebuilds from87- * DEFAULTS and carries only the preference keys, so routing a schema-3 save88- * through it to gain one string would throw away every solve in it.89- *90- * The trigger is the presence of the old key, not the absence of the new one:91- * the new one is in DEFAULTS and so is never absent, and testing for that92- * quietly converted nothing at all. Anyone who left it off was on the default93- * and lands on 'auto', which is the new default. */94-if (save.ordered !== undefined) {95- if (save.ordered) save.shading = 'ordered';96- delete save.ordered;97-}98-99-/* `blockStyle` became `shading` when the axis stopped being about dithering.100- * CHECKER stopped being a shape in the same move -- it was a dispersed screen at101- * a coarser ruling all along -- and the ruling stopped being a saved preference,102- * so anyone who had either pinned lands on the shape that draws the same103- * picture and lets the board choose the press. */104-if (save.blockStyle !== undefined) {105- if (save.shading === 'auto') save.shading = save.blockStyle === 'checker' ? 'ordered' : save.blockStyle;106- delete save.blockStyle;107-}108-delete save.blockDensity;109-110-/* The picture's density is a number the renderer sizes its buffers with, and a111- * save is the one input to it nobody validates. A stale or hand-edited value112- * lands on the standard rather than on a canvas of some arbitrary size. */113-if (save.density !== 2) save.density = 3;114-115-export function persist() {116- save.counter = (save.counter || 0) + 1;117- try {118- localStorage.setItem(KEY, JSON.stringify({119- schema: SCHEMA, counter: save.counter,120- payload: { ...save, counter: undefined },121- }));122- } catch (e) { /* private mode, quota, evicted -- play on regardless */ }123-}124-125-export function famStats(id) {126- return save.fams[id] || (save.fams[id] = famRecord(id));127-}128-129-const dayKey = t => new Date(t).toISOString().slice(0, 10);130-const daysBetween = (a, b) =>131- Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86400000);132-133-/* A streak survives a gap of one day, because "yesterday" should still count134- * while today is still going. Two days is a break. */135-function stampDay(t) {136- const d = save.days, today = dayKey(t);137- if (!d.first) d.first = today;138- if (d.last === today) return;139- const gap = d.last ? daysBetween(d.last, today) : Infinity;140- d.streak = gap === 1 ? (d.streak || 0) + 1 : 1;141- d.last = today;142- d.best = Math.max(d.best || 0, d.streak);143-}144-145-/**146- * Record a solved board and return what it earned.147- * `solve` = { famId, rung, optimal, steps, ms, score, detours, worstDetour }148- */149-export function record(solve) {150- const { famId, rung, optimal, steps, ms, score, detours = 0, worstDetour = 0 } = solve;151- const fam = famStats(famId);152- const firstClear = !(rung in fam.best);153- const xp = xpFor({ L: optimal, score, steps, firstClear });154- const now = Date.now();155-156- if (firstClear || steps < fam.best[rung]) fam.best[rung] = steps;157- if (firstClear) fam.solved += 1;158- fam.deepest = Math.max(fam.deepest, optimal);159- fam.ms += ms;160- // The ladder only advances on a first clear, so replaying never skips ahead.161- if (firstClear && rung >= fam.rung) fam.rung = rung + 1;162-163- const t = save.totals;164- t.solved += 1; t.steps += steps; t.optimal += optimal; t.ms += ms; t.xp += xp;165- t.detours = (t.detours || 0) + detours;166- if (!detours) t.clean = (t.clean || 0) + 1;167-168- stampDay(now);169- // `d` and `w` are the shape of the run: how many times it went backwards and170- // how far back the worst of those went. Absent on anything solved before they171- // were recorded, which the record screen reads as unknown rather than as zero.172- save.log.push({173- f: famId, r: rung, L: optimal, s: steps, ms, t: Math.round(now / 1000),174- d: detours, w: worstDetour,175- });176- if (save.log.length > LOG_CAP) save.log.splice(0, save.log.length - LOG_CAP);177-178- persist();179- return { xp, firstClear };180-}181-182-/* Ask to keep the record through storage pressure. Best effort everywhere,183- * and on iOS it is genuinely only a request. */184-export function requestPersistence() {185- if (navigator.storage && navigator.storage.persist) {186- navigator.storage.persist().catch(() => {});187- }188-}
D mockup/src/survey.js +0 -97
@@ -1,97 +0,0 @@
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-}
D mockup/src/theme.js +0 -309
@@ -1,309 +0,0 @@
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-/* Which of these is a dark screen is not a flag anybody has to remember to set:188- * it is what the paper is. Swapping ink for paper is all "night" ever was. */189-export const isDarkTheme = id => luma(themeById(id).paper) < 128;190-191-/* AUTO, and what it resolves to.192- *193- * One pair rather than a rule over the whole table: PAPER is a warmer version of194- * the same light idea and AMOLED is a deliberate extreme -- true black, for a195- * screen where that is free -- and neither is what someone means when they have196- * simply told their phone they prefer dark. They mean the house look, in the197- * dark, which is exactly the pair the tables were written as. */198-export const AUTO_THEMES = { light: 'macintosh', dark: 'night' };199-export const resolveTheme = (id, dark) =>200- (id === 'auto' ? AUTO_THEMES[dark ? 'dark' : 'light'] : (THEMES[id] ? id : 'macintosh'));201-202-/* Rec. 709 luma, 0..255, and plain RGB distance. Both are used to check a203- * ground pair carries enough separation to be read against, and they answer204- * different questions: brightness is what makes a dot visible on its field,205- * and distance is what makes a field distinguishable from the paper of the206- * panels lying on it -- which on a dark theme is mostly a matter of hue at207- * near-identical brightness, and so is invisible to luma alone.208- *209- * Exported because the check belongs in tools/check.mjs, not in the render210- * path: it is a fact about the tables, and the tables do not change at run211- * time. */212-export const luma = ([r, g2, b]) => 0.2126 * r + 0.7152 * g2 + 0.0722 * b;213-export const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);214-215-/* ------------------------------------------------------------------ combos */216-217-/* What the screens that are not a board use. The ladder varies; the atlas, the218- * record and the title do not -- they are chrome, and chrome that shifts under219- * you every time you open it is not chrome. It is also the only sensible answer220- * for a screen with no rung to seed from. */221-export function houseCombo(themeId) {222- const t = themeById(themeId);223- return { backdrop: 'lattice', ground: t.grounds[0], shading: 'atkinson', ruling: 'fine' };224-}225-226-const cache = new Map();227-const pick = (arr, rng) => arr[Math.min(arr.length - 1, (rng() * arr.length) | 0)];228-229-/**230- * The look of one board: a backdrop stencil, the colour pair it is printed in,231- * a shading screen for the blocks and the ruling that screen is cut at.232- *233- * Seeded on family and rung, the same pair that decides which board it is, so a234- * rung always looks the way it looked -- and mixing the theme into the hash235- * means the same rung is a different combination under a different theme rather236- * than the same arrangement recoloured.237- */238-export function comboFor(themeId, famId, rung) {239- const key = `${themeId}:${famId}:${rung}`;240- const hit = cache.get(key);241- if (hit) return hit;242-243- const t = themeById(themeId);244- const rng = mulberry(hashString(`${famId}|${themeId}`) + rung * 8191);245- const shading = pick(t.shading, rng);246- const combo = {247- backdrop: pick(t.backdrops, rng),248- ground: pick(t.grounds, rng),249- shading,250- // Drawn unconditionally so the stream stays in step whichever shape came251- // up, then normalised: the shapes that take no ruling always report 'fine'252- // rather than a frequency they quietly ignore, so the sprite cache key253- // agrees with what is actually on screen.254- ruling: effectiveRuling(shading, pick(t.rulings, rng)),255- };256-257- // The ladder is open-ended, so this map is too. It costs nothing to keep a258- // few hundred and nothing to lose them; the values are pure functions of the259- // key and re-derive in microseconds.260- if (cache.size > 512) cache.clear();261- cache.set(key, combo);262- return combo;263-}264-265-/* ---------------------------------------------------------------- dev-logo266- *267- * A maker's mark, carried in the menu bar where a Macintosh carried the Apple.268- * Constant in shape and position -- a mark that moved or redrew itself would be269- * decoration rather than a signature -- and the one thing about it that varies270- * is which of the theme's piece colours fills it.271- *272- * Seeded on the day rather than on the board, because it does not belong to a273- * board: it is the same mark on every screen it appears on, all day, and it274- * moves on tomorrow. The theme is mixed into the hash so switching theme is not275- * merely the same choice recoloured.276- */277-export function dayOfYear(date = new Date()) {278- const y = date.getFullYear();279- const start = Date.UTC(y, 0, 1);280- const today = Date.UTC(y, date.getMonth(), date.getDate());281- return Math.floor((today - start) / 86400000) + 1;282-}283-284-export function logoInk(themeId, day) {285- const t = themeById(themeId);286- const rng = mulberry(hashString(`dev-logo|${themeId}`) + day * 2657);287- // pieces[0] is the empty class, so the pool is the four real ones.288- return t.pieces[1 + Math.min(3, (rng() * 4) | 0)];289-}290-291-/* Every pool name must resolve to something that exists. Catching this here292- * rather than at the point of use means a typo in a theme is a failed check293- * rather than a screen that silently falls back to the house lattice. */294-export function validateThemes() {295- const bad = [];296- for (const [id, t] of Object.entries(THEMES)) {297- if (!t.backdrops.length || !t.grounds.length || !t.shading.length || !t.rulings.length) {298- bad.push(`${id}: empty pool`);299- }300- for (const b of t.backdrops) if (!GROUND_NAMES.includes(b)) bad.push(`${id}: no backdrop ${b}`);301- for (const s of t.shading) if (!PATTERN_NAMES.includes(s)) bad.push(`${id}: no shading ${s}`);302- for (const r of t.rulings) if (!RULING_NAMES.includes(r)) bad.push(`${id}: no ruling ${r}`);303- // A theme naming only unruled shapes would carry a ruling pool that can304- // never be reached, which is a table lying about what it does.305- if (!t.shading.some(s => PATTERNS[s] && PATTERNS[s].ruled)) bad.push(`${id}: no ruled shading`);306- if (t.pieces.length !== 5) bad.push(`${id}: ${t.pieces.length} piece colours, want 5`);307- }308- return bad;309-}
D mockup/src/walk.js +0 -182
@@ -1,182 +0,0 @@
1-/* Walking a board -- well, or deliberately badly.2- *3- * The solver walk that existed before this only ever took a step that reduced4- * the distance to the goal, which made it useless for the one thing the record5- * screen is actually about. A descent that only descends is a straight line;6- * every interesting run has bumps in it, and there was no way to produce one on7- * purpose. Screenshots of a real board therefore never showed a climb, and8- * neither did any check.9- *10- * So a walk is described by a budget rather than by a rule: how many moves11- * should take you further away, and how many should achieve nothing at all.12- * Everything else descends. That is enough to reproduce any shape of run --13- * a perfect line, one early stumble, a run that thrashes at the end -- and it14- * is seeded, so the same numbers give the same board twice.15- *16- * Pure, and deliberately so: it takes a board descriptor and a grid and hands17- * back a move, touching no application state. The headless checks walk their18- * own scratch grid with it and the browser harness feeds the same moves through19- * the game's own input path, so what a screenshot shows is what a check20- * asserts.21- */22-23-import { decode, piecesOf, canMove, applyMove, encode } from './klotski.js';24-import { DIRS } from './klotski.js';25-import { mulberry, hashString } from './families.js';26-27-/* Every legal move from here, with what each does to the distance.28- *29- * `delta` is the only classification there is and it has exactly three values:30- * a unit move changes the distance to the goal by -1, 0 or +1, never more,31- * because the distance field is a breadth-first one over these very moves. */32-export function movesFrom(b, g, dist) {33- const ps = piecesOf(b, g, []);34- const here = dist.get(encode(b, g));35- const out = [];36- for (const p of ps) {37- for (const [dx, dy] of DIRS) {38- if (!canMove(b, g, p, dx, dy)) continue;39- applyMove(b, g, p, dx, dy);40- const k = encode(b, g);41- applyMove(b, g, { x: p.x + dx, y: p.y + dy, w: p.w, h: p.h, c: p.c }, -dx, -dy);42- const d = dist.get(k);43- if (d == null) continue; // unreachable, which cannot happen44- out.push({ x: p.x, y: p.y, w: p.w, h: p.h, c: p.c, dx, dy, delta: d - here, to: k });45- }46- }47- return out;48-}49-50-/**51- * A walker with a budget of mistakes.52- *53- * `wrong` moves take you further from the goal and `idle` moves leave the54- * distance exactly where it was. Everything else descends.55- *56- * The budget is spread across the run rather than spent off the top. Front-57- * loading it was the first attempt and it produces a shape nobody plays: three58- * mistakes in the first three moves and then a flawless eighty-step descent.59- * Each step takes a setback with probability weighted by what is left to spend60- * against what is left to walk, so the bumps land through the run the way real61- * ones do -- and when the remaining descent is only just long enough to still62- * fit the budget, the walk stops rolling and spends it, so asking for four63- * setbacks does not quietly give you one.64- *65- * A setback is never taken within two steps of the goal: a run that stumbles on66- * the last move is a shape the chart cannot show properly, and it is not what a67- * real one looks like either.68- *69- * An idle move is not always on offer, and how often it is depends entirely on70- * the family. Along classic's optimal corridor almost every state has only71- * descents and setbacks to choose from -- one state in eighty offers a move72- * that changes nothing -- while on the 5x4 sets more than half of all moves are73- * idle ones. So an idle budget is a ceiling rather than a promise, and a walk74- * that could not spend it says so in the numbers rather than inventing them.75- *76- * Returns { next(b, g) } giving the move to play, or null when there is nothing77- * left to do because the board is solved.78- */79-export function makeWalker(dist, { wrong = 0, idle = 0, seed = 1 } = {}) {80- const rng = mulberry(hashString(`walk|${wrong}|${idle}|${seed}`));81- let budgetUp = wrong, budgetFlat = idle;82- const pick = list => list[Math.min(list.length - 1, (rng() * list.length) | 0)];83-84- return {85- next(b, g) {86- const here = dist.get(encode(b, g));87- if (!here) return null; // solved88- const all = movesFrom(b, g, dist);89- if (!all.length) return null; // stuck, which cannot happen90- const up = [], flat = [], down = [];91- for (const m of all) (m.delta > 0 ? up : m.delta < 0 ? down : flat).push(m);92-93- // What is left to walk if every remaining mistake is made: each setback94- // costs its own step and the one that undoes it, each idle move costs one.95- const left = here + 2 * budgetUp + budgetFlat;96- if (budgetUp > 0 && up.length && here > 2 &&97- (here <= 2 * budgetUp || rng() < (2 * budgetUp) / left)) {98- budgetUp--; return pick(up);99- }100- if (budgetFlat > 0 && flat.length &&101- (here <= budgetFlat || rng() < budgetFlat / left)) {102- budgetFlat--; return pick(flat);103- }104- return down.length ? pick(down) : pick(all);105- },106- };107-}108-109-/**110- * Walk a whole board without touching the game, and hand back the trace.111- *112- * The trace is what the record screen draws: the distance to the goal after113- * every step, starting with the distance before any.114- */115-export function walkTrace(b, startKey, dist, opts) {116- const g = decode(b, startKey, new Uint8Array(b.n));117- const w = makeWalker(dist, opts);118- const trace = [dist.get(startKey)];119- const moves = [];120- let guard = 0;121- for (;;) {122- const m = w.next(b, g);123- if (!m || guard++ > 4000) break;124- applyMove(b, g, m, m.dx, m.dy);125- moves.push(m);126- trace.push(dist.get(encode(b, g)));127- }128- return { trace, moves };129-}130-131-/* What a run was actually made of.132- *133- * A unit move changes the distance by exactly -1, 0 or +1, so a finished run134- * decomposes without any interpretation at all: every step either closed the135- * gap, opened it, or did nothing. Two identities fall straight out and are136- * worth stating because they are what makes the numbers honest rather than137- * decorative --138- *139- * descents = optimal + setbacks every step back is re-walked140- * steps = optimal + 2*setbacks + idle141- *142- * -- so `wasted` is not a residual anybody has to trust. A setback costs two143- * steps and an idle move costs one, and those two numbers add up to the gap144- * between what a run took and what it needed, exactly, every time.145- *146- * A `detour` is one excursion above the closest you had already been, from147- * leaving that mark to regaining it. It is not the same number as `setbacks`148- * and the difference is the whole reason to count both: two setbacks in a row149- * are one detour two deep, and on the chart they are one bump, not two. What a150- * person reads off the ridge is detours; what the arithmetic above accounts for151- * is setbacks. `worstDetour` is the depth of the deepest of them and152- * `longestRun` the longest unbroken descent.153- */154-export function traceStats(trace) {155- let setbacks = 0, idle = 0, descents = 0, detours = 0;156- let run = 0, longestRun = 0, worstDetour = 0, firstSetback = -1;157- let best = trace.length ? trace[0] : 0;158- let inDetour = false;159- for (let i = 1; i < trace.length; i++) {160- const d = trace[i] - trace[i - 1];161- if (d > 0) {162- setbacks++; run = 0;163- if (firstSetback < 0) firstSetback = i;164- } else if (d < 0) {165- descents++; run++;166- if (run > longestRun) longestRun = run;167- } else {168- idle++; run = 0;169- }170- if (trace[i] > best && !inDetour) { inDetour = true; detours++; }171- if (trace[i] <= best) inDetour = false;172- if (trace[i] < best) best = trace[i];173- if (trace[i] - best > worstDetour) worstDetour = trace[i] - best;174- }175- return {176- steps: Math.max(0, trace.length - 1),177- optimal: trace.length ? trace[0] : 0,178- setbacks, idle, descents, detours,179- wasted: setbacks * 2 + idle,180- longestRun, worstDetour, firstSetback,181- };182-}
D mockup/styles.css +0 -207
@@ -1,207 +0,0 @@
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 picture now covers the whole safe area, so what is left of the page is25- the strip the hardware took -- a notch, a rounded corner, a home bar. It26- carries the backdrop rather than the paper, or the edge of the canvas27- becomes a visible seam 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-/* Left and width are written by placeDom in picture units: a zone belongs to60- the panel it sits in, and only the menu bar still spans the screen. */61-.zone {62- position: absolute; left: 0; width: 100%;63- display: none; align-items: center; justify-content: center;64- gap: calc(13px * var(--s));65-}66-/* Beside the board rather than under it, the panel is a third of the width it67- had, and the spacing that set three readouts across a phone has to come in68- with it. The type does not change size -- it is the same panel at the same69- scale, just narrower. */70-[data-mode="split"] .zone { gap: calc(6px * var(--s)); }71-[data-mode="split"] #acts { gap: calc(5px * var(--s)); }72-/* The menu bar is already white; a button background would punch a hole in73- its rule. */74-#top { justify-content: flex-start; gap: calc(5px * var(--s)); padding: 0 calc(8px * var(--s)); }75-#top button { background: none; padding: calc(2px * var(--s)) calc(4px * var(--s)); }76-#top .spacer { flex: 1; }77-78-/* A boxed icon: square, hard-ruled, with the glyph struck inside it. The box is79- the point -- an unboxed glyph on a menu bar reads as a picture of something80- rather than as a thing to press, which is why systems of this vintage drew81- the rectangle every time. */82-.iconbtn {83- display: flex; align-items: center; justify-content: center;84- /* Two units smaller than it wants to be, taken off the padding rather than85- the glyph: the glyph is drawn on a 9-unit grid and only stays on whole86- pixels while its box is a whole multiple of 9, so shrinking it is what87- would make the strokes uneven.88-89- The two units buy clearance from the menu bar's own rule below. Both are90- drawn in ink, and a box that comes within a pixel of a line of the same91- colour stops reading as a box near a line and starts reading as one shape92- that has gone wrong. */93- padding: calc(1.5px * var(--s)) !important;94- border-color: var(--ink) !important;95-}96-.iconbtn svg {97- display: block;98- width: calc(9px * var(--s)); height: calc(9px * var(--s));99- fill: currentColor;100- shape-rendering: crispEdges; /* whole pixels, like the rest of the picture */101-}102-/* Inverts on press. The plain menu-bar buttons deliberately do not take a103- background, but these already carry a box, so filling it is the QuickDraw104- answer rather than a hole in the rule. */105-#top .iconbtn:active { background: var(--ink); color: var(--paper); }106-#acts { gap: calc(6px * var(--s)); }107-108-.stat { display: flex; flex-direction: column; align-items: center; gap: calc(2px * var(--s)); min-width: 0; }109-.val { font-family: Silk, monospace; font-size: calc(10px * var(--s)); }110-.lbl { font-size: calc(5px * var(--s)); letter-spacing: calc(1px * var(--s)); opacity: .72; }111-#gauge { justify-content: space-between; gap: 0; }112-#gauge .spacer { flex: 1; }113-114-/* Buttons are QuickDraw buttons: a hard rectangle, inverted while pressed. */115-button {116- font-family: Mono, monospace;117- font-size: calc(6px * var(--s));118- letter-spacing: calc(1.2px * var(--s));119- color: var(--ink);120- background: var(--paper);121- border: calc(1px * var(--s)) solid var(--ink);122- border-radius: 0;123- padding: calc(3.5px * var(--s)) calc(6.5px * var(--s));124- cursor: pointer;125-}126-button:active, button.armed { background: var(--ink); color: var(--paper); }127-button:disabled { opacity: .35; border-color: transparent; }128-button.ghost { border-color: transparent; }129-130-/* Discrete steps, never a smooth fade: an opacity ramp would put in-between131- frames in the middle of a one-bit game. */132-@keyframes beat { 0%, 55% { opacity: 1 } 56%, 100% { opacity: 0 } }133-.beat { animation: beat 1.4s steps(1, end) infinite; }134-/* The one animation the canvas does not own. Told to stop by the same135- preference that stops all the others -- and left legible rather than left136- mid-blink, since the prompt is the only thing telling anyone what to do. */137-@media (prefers-reduced-motion: reduce) {138- .beat { animation: none; opacity: 1; }139-}140-141-142-#sheet {143- position: absolute; inset: 0;144- display: none; flex-direction: column;145- align-items: center; justify-content: center;146- gap: calc(11px * var(--s));147- padding: calc(16px * var(--s));148- background: var(--paper);149- pointer-events: auto;150-}151-#sheet.open { display: flex; }152-/* On a short screen the stacked groups are taller than the screen is. Two153- columns rather than a scroll: a settings sheet you have to scroll to reach154- CLOSE is a dialog that has stopped being a dialog. The credit and the button155- take a line of their own, which is where they were anyway. */156-[data-mode="split"] #sheet.open {157- display: grid;158- grid-template-columns: auto auto;159- justify-content: center; align-content: center;160- gap: calc(7px * var(--s)) calc(16px * var(--s));161- padding: calc(10px * var(--s));162-}163-[data-mode="split"] #sheet .credit,164-[data-mode="split"] #sheet #btnClose { grid-column: 1 / -1; justify-self: center; }165-.group { display: flex; flex-direction: column; align-items: center; }166-.grouplbl {167- font-size: calc(5px * var(--s)); letter-spacing: calc(1.6px * var(--s));168- opacity: .6; margin-bottom: calc(5px * var(--s));169-}170-.row { display: flex; gap: calc(4px * var(--s)); flex-wrap: wrap; justify-content: center; }171-172-/* A pull-down, drawn the way the rest of the chrome is drawn.173- A native <select> is the one control the platform will not let you style174- past a point, and one rounded system-blue menu in a screen of hard175- rectangles undoes the whole conceit. This is the QuickDraw menu instead:176- a hard box, a hairline frame, and inversion to show the highlight. */177-.menu { position: relative; }178-.menutitle { min-width: calc(84px * var(--s)); text-align: left; }179-.menutitle::after { content: ' \25BE'; float: right; } /* the little triangle */180-.menulist {181- display: none;182- position: absolute; left: 0; top: calc(100% - 1px * var(--s));183- z-index: 5; min-width: 100%;184- background: var(--paper);185- border: calc(1px * var(--s)) solid var(--ink);186- /* The drop shadow QuickDraw menus cast: solid ink, offset, never blurred. */187- box-shadow: calc(2px * var(--s)) calc(2px * var(--s)) 0 var(--ink);188-}189-.menu.open .menulist { display: block; }190-.menulist button {191- display: block; width: 100%; text-align: left;192- border: 0; background: none;193- padding: calc(3px * var(--s)) calc(7px * var(--s));194- white-space: nowrap;195-}196-.menulist button:hover, .menulist button.armed { background: var(--ink); color: var(--paper); }197-/* A rule under AUTO, because it is a different kind of answer from the rest:198- everything below it is a screen, AUTO is "let the board decide". */199-.menulist button[data-shading="auto"] {200- border-bottom: calc(1px * var(--s)) solid var(--ink);201-}202-.credit {203- font-size: calc(4.4px * var(--s)); line-height: 1.9;204- letter-spacing: calc(.5px * var(--s));205- opacity: .5; text-align: center;206- max-width: calc(180px * var(--s));207-}
D mockup/sw.js +0 -93

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

D mockup/tools/check.mjs +0 -337

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

D mockup/tools/find-families.mjs +0 -155

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

D mockup/tools/gen_icons.py +0 -196

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

D mockup/tools/release.mjs +0 -202

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