phase: 07_canvas_client_and_hud
**Final report — phase 07 final verification pass** - **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found. - **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase). - **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔. - **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry. - **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`. - **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate. - **Next pending phase:** `08_client_prediction_and_touch`.
This commit is contained in:
+516
@@ -0,0 +1,516 @@
|
||||
# CLIENT.md — the browser half
|
||||
|
||||
## Purpose
|
||||
|
||||
The browser half, in prose: what `app/static/` is, how one state machine
|
||||
owns the screens, how the wire contract (`docs/PROTOCOL.md`) maps onto
|
||||
them, and the rules the client code lives under. The code of record is
|
||||
`app/static/js/` (served from `/static/`, same-origin only, PLAN A12);
|
||||
`tests/e2e/` is the proof — every claim in this file is asserted by a
|
||||
browser test, not by eye.
|
||||
|
||||
**No bundler, no framework, no build step (PLAN A10).** Every file in
|
||||
`app/static/js/` is a plain ES module served as-is: `index.html` loads
|
||||
one entry module (`/static/js/main.js` — the composition root, task 03;
|
||||
it imports `app.js` and the net/render modules), the rest of the graph
|
||||
is plain `import` statements the browser resolves. There is no
|
||||
`package.json`, no `node`, no transpile, no vendor directory, and no
|
||||
CDN (PLAN A12 —
|
||||
`tests/unit/test_no_external_assets.py` fails the build on a remote
|
||||
reference anywhere in `app/static/**`, and the e2e suites re-assert the
|
||||
same in a real browser: a full round makes requests to *nothing* but the
|
||||
page's own origin). Consequences the code obeys:
|
||||
|
||||
- no `import` from a bare specifier (`./` and `/static/` only);
|
||||
- no dynamic import except the atlas's own same-origin `fetch`;
|
||||
- no TypeScript, no JSX, no `eval`, no dynamic string assembly of code;
|
||||
- every server-supplied string lands in `textContent` or an attribute —
|
||||
`innerHTML` with server data is never (the screens build through
|
||||
`screens/dom.js`'s `el()`/`frag()`, which make that the only path);
|
||||
- inline `style` attributes are never set either (the CSP is
|
||||
`style-src 'self'` — a blocked inline style is a console error);
|
||||
colours ride on CSS classes.
|
||||
|
||||
## The module map
|
||||
|
||||
```
|
||||
app/static/
|
||||
├── index.html one page, one entry module, #screen-root
|
||||
├── css/
|
||||
│ ├── base.css the shared skin (palette, type scale, 44 px hit targets)
|
||||
│ └── screens.css the screens' layout (360 px → 4K, focus rings, reduced motion)
|
||||
├── assets/ the generated SVG atlas + palette.css + index.json (task 01)
|
||||
└── js/
|
||||
├── main.js the composition root (task 03): URL params, the buffer,
|
||||
│ the event queue, the single loop, the wire routing
|
||||
├── main-flag.js sets `__uc_main` before `app.js` evaluates (the auto-boot guard)
|
||||
├── app.js createApp(): the state machine, the wire reducer, the verbs
|
||||
├── identity.js the per-tab token + the last room code (sessionStorage, A16)
|
||||
├── shared/
|
||||
│ └── enums.js the wire's enum sets (the sim's StrEnums + `ErrorCode`; parity-guarded)
|
||||
├── net/
|
||||
│ ├── connection.js the WebSocket: hello, send, onMessage/onStatus, close-code table
|
||||
│ ├── decode.js `applyDelta` — the client's mirror of `server/encoding.py`
|
||||
│ ├── snapshots.js the snapshot buffer, `lerpState`, the resync rules (pure, injected clock)
|
||||
│ └── events.js the event queue: per-kind TTL, the dedup window, the tick-reset
|
||||
├── input/
|
||||
│ └── keyboard.js the desktop input (task 05): the key map, the send pump, `?controls=`
|
||||
├── render/
|
||||
│ ├── loop.js the single rAF loop (pauses on `document.hidden`, self-measured)
|
||||
│ ├── atlas.js the SVG atlas loader (ready/drawSync/meta; throws on an unknown id)
|
||||
│ ├── palette.js the palette's hex values (agrees with assets/palette.css, tested)
|
||||
│ ├── camera.js fit-to-viewport (DPR-aware, capped at 2), the world⇄screen math
|
||||
│ ├── canvas.js the layer renderer: `drawFrame`, the sprite mapping, `renderStats`,
|
||||
│ │ the target highlight (task 04)
|
||||
│ └── fx.js event→effect: the no-alloc particle pool, the reduced-motion fallbacks
|
||||
├── ui/
|
||||
│ ├── help.js the help overlay (task 05): the key-map rows, `?`/`Esc`, no backdrop blur
|
||||
│ └── hud.js the DOM HUD: order rail, score/target, timer, streak, connection
|
||||
└── screens/ one module per screen: { mount, unmount } (+ update/setBanner…)
|
||||
├── dom.js el()/frag() — the no-innerHTML DOM builder
|
||||
├── landing.js create/join, code validation, the last-code prefill
|
||||
├── connecting.js the between (create in flight, cancel)
|
||||
├── lobby.js code + copy, seats, ready, level picker, start, session panel
|
||||
├── countdown.js 3…2…1 (reduced-motion-safe)
|
||||
├── play.js #kcanvas host, #hud root, leave, emotes, reconnecting overlay
|
||||
├── round_end.js result, stars, score/best, per-player, counters, the exits
|
||||
└── error.js per-code copy, the right retry affordance, the code for support
|
||||
```
|
||||
|
||||
The `window.__undercooked` global is the app instance the e2e suites
|
||||
drive (`createApp()`'s return: the live state, the verbs, the
|
||||
`connection`). The composition root (task 03) attaches to it: `params`
|
||||
(the URL params), `seed` (when the URL carries one), `snapshotBuffer`
|
||||
and `eventQueue`. The loop's census and the pure modules live on
|
||||
`window.__uc` (`frameMs` p50/p95, `buffer`, `events`, `loop`,
|
||||
`params`, `lastView` — the last interpolated state, and — task 04 —
|
||||
`renderStats` (the renderer's `drawCalls` / `frameMs` census),
|
||||
`runtime` (the play-time runtime handle: `teardown`), and `fx` (the
|
||||
FX module's `stats`: the processed-event census, `poolInUse`,
|
||||
`shakeApplied`, the reduced-motion flag)). Both are test seams, not
|
||||
APIs — production code goes through the modules.
|
||||
|
||||
The URL params `main.js` parses at boot: `room` (join this code on
|
||||
boot — a test-harness seam), `seed` (exposed for the deterministic
|
||||
suites; the server stays authoritative), `controls` (`keyboard`
|
||||
(default) / `touch` / `off` — task 05's input module reads it; `off`
|
||||
binds nothing), `freeze=1` (the loop holds the *sample time* at the
|
||||
first playing frame — a pixel-diff test sees a still image), `motion`
|
||||
(`1` (default) / `0` — the reduced-motion mode; task 04's FX and
|
||||
phase 14's a11y read it).
|
||||
|
||||
## The screen state machine
|
||||
|
||||
One machine, in `app.js`'s `createApp()`: an explicit screen enum
|
||||
|
||||
```
|
||||
LANDING → CONNECTING → LOBBY ⇄ (lobby re-broadcasts)
|
||||
→ COUNTDOWN (3, 2, 1)
|
||||
→ PLAYING (snap stream; the overlay on a drop)
|
||||
→ ROUND_END → (next_level | retry | to_lobby)
|
||||
any → ERROR (a fatal err code, a refused handshake, a dead socket)
|
||||
any → LANDING (leave / back-to-landing — a clean close, no error screen)
|
||||
```
|
||||
|
||||
`show(screen, data)`:
|
||||
|
||||
- an unknown screen name (a bug) logs **once** and becomes `ERROR`;
|
||||
- the same screen re-shown in place calls the module's `update()` (a
|
||||
re-mount would drop focus off the live control);
|
||||
- a different screen unmounts the current module, **clears
|
||||
`#screen-root`**, and mounts the new one — a screen module appends,
|
||||
the machine clears, so the DOM holds exactly one screen.
|
||||
|
||||
Every screen is a module exporting `{ mount(target, data, app), unmount() }`
|
||||
plus the hooks it needs (`update`, `setBanner`, `showReconnecting`,
|
||||
`renderBest`) — no global DOM lives outside the modules.
|
||||
|
||||
## The message → screen mapping
|
||||
|
||||
The wire contract is `docs/PROTOCOL.md`; this is the screen half of it.
|
||||
The reducer is `connection.onMessage` in `app.js` — one `switch` on
|
||||
`frame.type`, every branch named:
|
||||
|
||||
| server frame | app state it writes | screen it shows |
|
||||
|---|---|---|
|
||||
| `welcome` | `welcome`, `roomCode`, `you`, `spectator` | by `welcome.state`: `lobby`→LOBBY, `countdown`→COUNTDOWN, `playing`→PLAYING, `round_end`→ROUND_END, `closed`→ERROR |
|
||||
| `lobby` | `lobby`, `roomCode` | LOBBY (the room's state is `lobby`), else the state's screen |
|
||||
| `countdown` | — | COUNTDOWN (the numeral re-shows per frame) |
|
||||
| `snap` | `lastSnapshot`, the merged view (full resets, delta patches, `gone` tombstones drop) — **and the snapshot buffer** (the render path; the frame's `e` feeds the event queue) | PLAYING |
|
||||
| `round` | `round` | ROUND_END |
|
||||
| `err` (fatal: `room_gone`, `room_full`, `protocol_mismatch`, `evicted`, `internal`) | `error` | ERROR (the code's copy, the right retry) |
|
||||
| `err` (non-fatal) | `banner` | the mounted screen's banner region (e.g. a `start` refusal) |
|
||||
| `pong` | — | nothing (the keep-alive's answer) |
|
||||
| `ev` | the event log — **and the event queue** (the `ev` shape, no tick) | (task 04's renderer consumes them) |
|
||||
| socket death (`onStatus`) | by the close code | `mismatch`→ERROR (no retry, ever); `1000` mid-round→the PLAYING overlay (phase 08 reconnects); `1006`→ERROR "unreachable" (retry); `4401/4402/4403/4404`→ERROR by code; an intentional close (`leave`) shows nothing |
|
||||
| a refused `POST /api/rooms` (`503`) | `banner` | LANDING ("the server is at capacity") |
|
||||
|
||||
The verbs the screens call are the app's: `createLobby` (`POST
|
||||
/api/rooms` then `hello`), `join(code)`, `leave`, `backToLanding`,
|
||||
`retryJoin`, `ready`, `start`, `setLevel`, `emote`, `toLobby`,
|
||||
`nextLevel`, `sendInput` (the `seq` is owned by `connection.js`).
|
||||
|
||||
## The screens, as the stories read them
|
||||
|
||||
- **landing** — *Create lobby* (keyboard-first: Tab + Enter), the join
|
||||
form (`inputmode="text"`, `autocapitalize="characters"`,
|
||||
`spellcheck="false"`, `maxlength="5"`), the client-side code check
|
||||
(five Crockford characters — a short code never reaches the wire),
|
||||
the last-code prefill from `sessionStorage`, the "server at
|
||||
capacity" / "cannot reach the server" messages.
|
||||
- **lobby** — the code with a copy button (the clipboard API, the
|
||||
select-and-copy fallback), the seat list (index, name, colour chip —
|
||||
the server's 4-colour `SEAT_PALETTE` as CSS classes, ready, the
|
||||
crown, disconnected), the *Ready* toggle (a toggle on the wire), the
|
||||
host-only level picker (fed by `/api/levels`: difficulty pips,
|
||||
target, time, blurb in the option's tooltip), *Start* (enabled from
|
||||
`lobby.can_start` — the client's mirror of `Room.can_start` —
|
||||
disabled with the named reason otherwise: "Only the host can
|
||||
start" / "Pick a level first" / "Waiting for ready cooks"), the
|
||||
session panel (`/api/session`: unlocked, bests, counters), the
|
||||
spectator notice.
|
||||
- **countdown** — 3/2/1 in big numerals; the pulse is decorative and
|
||||
`prefers-reduced-motion`-gated (under reduced motion the numeral just
|
||||
changes).
|
||||
- **play** — `#kcanvas` (hidden until task 04's renderer sizes and
|
||||
unhides it), the `#hud` root (task 04), *Leave*, the reconnecting
|
||||
overlay (phase 08 populates it), the emote row (the
|
||||
`docs/PROTOCOL.md` whitelist: `wave`, `cheer`, `thumbs_up`,
|
||||
`thumbs_down`, `angry`, `star`), the spectator notice, and — task 05
|
||||
— the keyboard control (WASD/arrows, the action button, the emotes,
|
||||
the help overlay; below) and its `?controls=` gate.
|
||||
- **round_end** — the result word, the stars (1–3, the pop is
|
||||
`prefers-reduced-motion`-gated), score against target, the session's
|
||||
best for the level played, the per-player contribution list (display
|
||||
only), the counters (`served` / `missed` / `wasted`, the longest
|
||||
streak), *Next level* (only when `round.unlocked` names a level) /
|
||||
*Retry* / *Back to lobby*, and the outcome's `aria-live="polite"`
|
||||
announcement.
|
||||
- **error** — per-code copy: `room_gone` (no same-code retry),
|
||||
`room_full` (points at another room), `protocol_mismatch` (a client
|
||||
that must not retry — it is out of date), `evicted` (the other tab
|
||||
owns the seat), `internal`, `closed` / `unreachable` (a retry is
|
||||
offered), and the room code shown for support.
|
||||
|
||||
## Keyboard input and the help overlay (task 05)
|
||||
|
||||
`input/keyboard.js` is the desktop control for the play screen, wired
|
||||
straight to the protocol's `input` frame (the `seq` stays the
|
||||
connection's; the module never touches it). The key map is one
|
||||
function — `getKeyMap()` (the canonical source; `ui/help.js` and the
|
||||
README's `### Controls` block render the same rows, and a test keeps
|
||||
them identical):
|
||||
|
||||
| Keys | Action |
|
||||
|---|---|
|
||||
| `W` `A` `S` `D` (or the arrow keys) | move — 8-way intent; opposites cancel; a diagonal is normalised (`1/√2` — the server clamps the magnitude to 1) |
|
||||
| `Space` / `Enter` | the one action button — a press is a rising edge; the hold rides the frame's `act` flag (the sim's gate turns it into one event + the hold progress) |
|
||||
| `1`–`4` | the emote whitelist's first four (`wave`, `cheer`, `thumbs_up`, `thumbs_down`); legal in `LOBBY` and `PLAYING` (the server's per-state whitelist decides) |
|
||||
| `?` | the help overlay's toggle |
|
||||
| `Esc` | close the help, or — on the play screen with the help closed — leave the room (the *Leave* behaviour) |
|
||||
|
||||
Key events are ignored while focus is in an input field, while
|
||||
`document.hidden`, or while a modifier is held (browser shortcuts
|
||||
survive — no `preventDefault` on Ctrl/Cmd/Meta combos). A `blur` /
|
||||
`visibilitychange` (hidden) / `pagehide` clears every held key and
|
||||
sends one zero-intent frame with the action released — no cook running
|
||||
away on an alt-tab.
|
||||
|
||||
The send pump: while a key is down it re-sends the intent ~every 48 ms
|
||||
(≈ 20.8 Hz — above the 20 Hz tick; a tick with no frame brakes the
|
||||
body, so a held key must ride every tick window); idle it re-sends a
|
||||
zero-intent frame at 10 Hz (the protocol's ≥ 5 Hz keep-alive — it is
|
||||
also what asserts "no input" after a release). `?controls=off` (and
|
||||
`touch`, until phase 08 ships it) binds nothing: the module still
|
||||
exposes `getKeyMap()` and a `stats()` census so the suites can tell
|
||||
the modes apart — `off` is the scripted-test mode, where the e2e suites
|
||||
drive the wire through the `sendInput` seam and the pump's keep-alive
|
||||
would race the script's frames on the same seat.
|
||||
|
||||
The help overlay (`ui/help.js`): a `role="dialog"` panel (a
|
||||
translucent backdrop, no blur) with the key-map rows, opened by `?`
|
||||
(toggled by it), closed by `Esc`, its *Close* button, or a backdrop
|
||||
click; no external docs mid-game.
|
||||
|
||||
## The snapshot buffer and the render loop (task 03)
|
||||
|
||||
`main.js` (the composition root) owns the play-time pipeline: the
|
||||
`snap` stream feeds the **snapshot buffer** (`net/snapshots.js`)
|
||||
*and* the app's merged view (the DOM path the lobby/HUD/
|
||||
choreography read); the buffer's frames drive the **single render
|
||||
loop** (`render/loop.js`) — one `requestAnimationFrame` instance per
|
||||
page, the only owner of the clock (`sample → lerpState → render →
|
||||
hud.update` per frame; task 04 installs the canvas layers and the HUD
|
||||
drawing in the render). The buffer, the decoder and the event queue
|
||||
are pure modules: no DOM, no network, an **injected clock** —
|
||||
`tests/e2e/test_us06_interp_math.py` drives them in the browser with
|
||||
synthetic streams, and `tests/e2e/test_us06_smoothness.py` proves the
|
||||
60 ms-latency round is smooth end to end.
|
||||
|
||||
**The buffer** (`createSnapshotBuffer({interpDelayMs=100, maxAgeMs=1000})`):
|
||||
|
||||
- frames are keyed by `t` (the server tick) and arrive in order; an
|
||||
older or duplicate `t` is **dropped** (counted), a `t` jump is a
|
||||
**gap** (recorded with its size);
|
||||
- a gap *longer than* `MAX_GAP_TICKS` (6 — three 200 ms windows)
|
||||
sets the resync latch and fires `onResync`, **debounced to one
|
||||
request per `RESYNC_DEBOUNCE_MS` (500 ms)**. While the latch is
|
||||
set, delta frames are *skipped* (their changes were partly missed;
|
||||
applying them against a stale `prev` would freeze half-changed
|
||||
state in the view). The stream's full cadence
|
||||
(`FULL_SNAPSHOT_EVERY_TICKS`, phase 06) guarantees a full frame
|
||||
arrives, and it clears the latch — the whole resync contract is
|
||||
*never ask, just hold*: the next full frame is the resync;
|
||||
- `sample(now)` renders at `now - interpDelayMs` (the fixed 100 ms
|
||||
delay — it covers the injected 60 ms latency) and returns the two
|
||||
bracketing decoded frames `{a, b}` plus `alpha` (by the frames'
|
||||
arrival times); no frame for `maxAgeMs` and the sample **freezes**
|
||||
(`a === b`, `alpha 0`) — the buffer never extrapolates, a frozen
|
||||
cook stays exactly where the last frame put it;
|
||||
- `stats()` is the census (`pushed`, `dropped`, `gaps`,
|
||||
`maxGapTicks`, `skippedUntilFull`, `resyncs`, `lastT`).
|
||||
|
||||
**`lerpState(a, b, alpha)`** turns the bracket into a render-ready
|
||||
state: positions lerp along the chord (a cook never passes through a
|
||||
wall, so the chord is the path), progress / time-left / hazard life
|
||||
lerp, and the discrete fields (the facing, the carried item, the
|
||||
states) **snap at the `alpha >= 0.5` boundary**. Entities added at
|
||||
`b` appear for the whole interval (at `b`'s position); entities
|
||||
removed vanish exactly at the boundary (held at `a`'s values until
|
||||
`alpha >= 0.5`, then gone). Stations and orders diff by their wire
|
||||
identity (`k` + coordinates / `id`), as do players (`i`) and hazards
|
||||
(`t` + coordinates).
|
||||
|
||||
**`applyDelta(frame, prev)`** (`net/decode.js`) is the client's mirror
|
||||
of `server/encoding.py`'s delta mode: a `full: false` frame's
|
||||
entities live under `d` (its `gone` tombstones are *not* part of the
|
||||
previous state), upserted by the encoder's identity keys onto the
|
||||
previous frame (full or delta — the encoder's own rule); the scalars
|
||||
(`t`, `s`, `tgt`, `tl`) and the tick's events ride every frame and
|
||||
are taken from the frame itself. `tests/e2e/test_us06_interp_math.py`
|
||||
proves the decoded frame is deep-equal to the full frame the server
|
||||
would have sent, over states encoded by the *real* `encode_snapshot`
|
||||
(a move, a turn, a tombstone, an append — and a delta-over-delta
|
||||
chain). The enum mapping goes through `shared/enums.js`, whose sets
|
||||
`tests/unit/test_client_enum_parity.py` keeps set-equal to the
|
||||
Python enums in both directions (a drift is a build failure, naming
|
||||
the missing value).
|
||||
|
||||
**The event queue** (`net/events.js`) turns the wire's transient
|
||||
events (a frame's `e[]`, the standalone `ev`) into bounded FX work:
|
||||
per-kind `ttlMs` (a table, a default for the rest), a **dedup window**
|
||||
(the same event — kind + position + payload — inside 100 ms is
|
||||
counted, not re-queued: the 20 Hz cadence can echo one tick's
|
||||
events, and a chop plays once), `drift(now)` pruning the expired
|
||||
(called each frame, so an unattended queue shrinks to nothing), and
|
||||
**tick-reset detection**: a `push(t)` whose tick is below the last
|
||||
seen tick is a resync (a new round, a rejoin) — the queue clears
|
||||
first, so a `served` from the previous round cannot replay.
|
||||
|
||||
**The loop's lifecycle**: *paused* when `document.hidden` (the rAF
|
||||
chain stops; the `visibilitychange` back to visible restarts it, and
|
||||
the delta spanning the pause is dropped, not measured); *idle* when
|
||||
the screen is not `PLAYING` (a non-active frame renders nothing but
|
||||
keeps the chain alive, so the `PLAYING` flip is picked up on the very
|
||||
next frame); `?freeze=1` holds the *sample time* at the first playing
|
||||
frame (the composition's business — a frozen render is a still image
|
||||
without the loop pausing). Every rendered frame's time lands in a
|
||||
sliding 120-sample window; `window.__uc.frameMs` is the self-
|
||||
measured census (`p50` / `p95` / `max`).
|
||||
|
||||
## The atlas (task 01)
|
||||
|
||||
`render/atlas.js` loads `assets/index.json` and decodes each SVG symbol
|
||||
through a `data:` URL (the page CSP blocks the art's `style` hooks, and
|
||||
browsers do not render `#symbol` fragments from a cross-file reference
|
||||
in that path — the `data:` round-trip is what the engine renders).
|
||||
`atlas.ready()` resolves when every symbol has `naturalWidth`;
|
||||
`atlas.draw(ctx, id, x, y, w, h, {tint, alpha, flipX})` throws on an
|
||||
unknown id (never a silent nothing-drawn); `atlas.meta(id)` is the
|
||||
read-only probe. The hex values the canvas and the CSS both use live in
|
||||
`assets/palette.css` and `render/palette.js`, and
|
||||
`tests/unit/test_assets.py` asserts the two files agree.
|
||||
|
||||
## Adding a sprite (the asset convention)
|
||||
|
||||
1. Draw the symbol in `assets_src/<file>.svg` (one `<symbol id>` per
|
||||
drawable, the ids namespaced — `tile-floor`, `item-tomato-chopped`,
|
||||
`cook-2-front-1`; the file stays ≤ 40 KB, `tests/unit/test_assets.py`
|
||||
guards it; no `xlink:href`/`http` reference, the same-origin guard
|
||||
covers it).
|
||||
2. Run `uv run python -m undercooked.tools.gen_art_index` — it rewrites
|
||||
`app/static/assets/index.json` and **fails naming a duplicated or a
|
||||
missing expected id** (the expected set derives from the sim's
|
||||
enums: every `ItemKind` × reachable form, every station state, every
|
||||
cook facing/frame must exist, so a sim change that orphans a symbol
|
||||
is a build failure, not a blank tile).
|
||||
3. Copy the SVG into `app/static/assets/` (the generator's check keeps
|
||||
the two trees in sync) and commit the generated `index.json`.
|
||||
4. `tests/e2e/test_atlas_loads.py` proves the engine decodes it (a
|
||||
non-blank pixel assertion on a probe canvas) — run it before the
|
||||
phase completes.
|
||||
5. Give the new id a **mapping entry** so the renderer knows how to
|
||||
draw it: for an *item* form, the pair is already covered if the
|
||||
kind is in `render/canvas.js`'s `REACHABLE_ITEM_FORMS` (the sim's
|
||||
`raw`-guaranteed kinds are there by construction; the set is
|
||||
parity-guarded against the sim's `ItemKind` / `ItemForm`, which
|
||||
`shared/enums.js` mirrors); for a *stove state variant*, add it to
|
||||
`render/canvas.js`'s `STATION_STATE_VARIANT` (a non-stove station
|
||||
is state-blind, and a cook's id is derived by `cookSpriteId` —
|
||||
`cook-<body>-<facing>-<frame>` — so a new facing/frame needs only
|
||||
the symbol, `gen_art_index`'s expected set covers it).
|
||||
`tests/unit/test_client_sprite_map.py` walks every reachable
|
||||
(kind, form) pair and every (station kind, state) pair against the
|
||||
shipped `index.json` — a new pair without its sprite, or a mapping
|
||||
that names a missing id, is a build failure naming the pair.
|
||||
|
||||
## The renderer and the HUD (task 04)
|
||||
|
||||
`main.js`'s play-time pipeline installs the canvas and the HUD in the
|
||||
loop's `render` step: `sample → lerpState → renderer.drawFrame(state,
|
||||
now) → fx.update/draw → hud.update(state, view)`. A **play runtime**
|
||||
(`makePlayRuntime` in `main.js`) is created the first time `#kcanvas`
|
||||
exists in `PLAYING`, torn down when it vanishes, and its canvas stays
|
||||
`hidden` until the atlas is ready (the first `drawFrame` un-hides it).
|
||||
The runtime is exposed on `window.__uc.runtime` (the e2e suites call
|
||||
`teardown` to release the rAF-bound resources between runs).
|
||||
|
||||
**The camera** (`render/camera.js`): fits the kitchen to the viewport
|
||||
with a min/max zoom in tiles-per-pixel, letterboxes the overflow, and
|
||||
is DPR-aware (`devicePixelRatio`, capped at 2) with integer-ish scaling
|
||||
to avoid shimmer. `resize()` re-derives the transform on resize /
|
||||
visibility; `worldToScreen(x, y)` / `screenToWorld(x, y)` are the
|
||||
world⇄CSS-pixel maps the renderer and the target highlight use. The
|
||||
renderer **reconciles the canvas size to the camera every frame**
|
||||
(`drawFrame`): if `#kcanvas` is connected and its `clientWidth/
|
||||
Height` differ from the camera's `cssW/cssH`, it calls `camera.resize()`
|
||||
— so a camera attached while the canvas was `hidden` (a zero
|
||||
`clientWidth`) is re-fit the moment the canvas is un-hidden, and a
|
||||
window resize during play is picked up on the next frame.
|
||||
|
||||
**The layer order** (`render/canvas.js`, `drawFrame` draws in this
|
||||
exact order — the order is the contract; a layer drawn earlier is
|
||||
occluded by the ones after it):
|
||||
|
||||
| # | layer | source |
|
||||
|---|---|---|
|
||||
| 1 | floor | `tile-floor` per walkable cell of the level bounds |
|
||||
| 2 | floor decals (spills) | the `hz`-derived stain decals (faint, under everything) |
|
||||
| 3 | station bases | `station-<kind>` per station (y-sorted within the layer) |
|
||||
| 4 | carried-under items | the carried item drawn *under* its cook (so the cook overlaps it) |
|
||||
| 5 | players | `cook-<body>-<facing>-<frame>` (body 1–4 by seat), y-sorted (a cook never draws through a wall) |
|
||||
| 6 | carried overlays | the carried item drawn *over* its cook (the held highlight) |
|
||||
| 7 | station progress rings | a `0..1` arc over a working/ready station (`progress` lerped) |
|
||||
| 8 | hazards | `hazard-<type>` (the `hz` life lerps the fade) |
|
||||
| 9 | particles | the FX pool (above the world, under the HUD) |
|
||||
| 10 | selection / target highlight | the reach-zone tile the server would target (faint) |
|
||||
|
||||
**Sprite selection** maps the interpolated state to atlas ids via the
|
||||
three `render/canvas.js` mappers: `stationSpriteId(kind, state)`
|
||||
(a non-stove station is state-blind; the stove's `s` — one of the
|
||||
`StationState` set — selects a variant through
|
||||
`STATION_STATE_VARIANT`, and a `fire` hazard on the stove's cell
|
||||
switches the base to the burning sprite in the hazard layer),
|
||||
`itemSpriteId(kind, form)` (the `h` wire field, mapped through
|
||||
`shared/enums.js`; an unreachable pair falls back to the kind's
|
||||
`raw` sprite so a protocol bug draws *something*, never a blank tile),
|
||||
and `cookSpriteId(seat, facing, frame)` (`cook-<body>-<facing>-
|
||||
<frame>`). An **unknown id throws** — the throw is surfaced to the
|
||||
console *once* (naming the id) and then suppressed, so a bad mapping
|
||||
is a one-line console error plus a fallback rect, never a silent blank
|
||||
tile and never a console flood. The walk animation is **fixed-step**:
|
||||
the frame is `floor(now / WALK_STEP_MS) % 2` while the cook's velocity
|
||||
is non-zero, so a 120 Hz display does not animate faster than a 60 Hz
|
||||
one; the clock is `now` (the loop's sample time), which the loop
|
||||
already drops when `document.hidden`, so the animation pauses with the
|
||||
render.
|
||||
|
||||
**`renderStats()`** is the renderer's census — `drawCalls` (cumulative
|
||||
`atlas.drawSync` calls), `frames`, `frameMs` (a moving average),
|
||||
`frameMsP95`, `frameMsMax`, and `warnedSprites` (the set of ids that
|
||||
already logged their one-line console error). It is exposed on
|
||||
`window.__uc.renderStats` for the perf assertions (`tests/e2e/
|
||||
test_us04_render_smoke.py` asserts `drawCalls` is within a sane band,
|
||||
< 4 000 per the budget, and the frame time ≤ 16 ms p95).
|
||||
|
||||
**The target highlight** is a client-side approximation of
|
||||
`interact.candidate_cells`: `candidateCells(x, y, facing)` (the
|
||||
`CANDIDATE_OFFSETS` mirror in `render/canvas.js`) gives the four
|
||||
reach-zone cells — the front tile, the two diagonal-front tiles, and
|
||||
the tile under the cook, deduplicated in priority order — and the
|
||||
renderer draws them faintly, so reaching for a station is
|
||||
predictable. The grid / legend / station-ids never leave the server
|
||||
(PLAN §8), so the highlight is an approximation from the
|
||||
interpolated position and facing — it is a *hint*, not the server's
|
||||
truth.
|
||||
|
||||
**The FX** (`render/fx.js`) maps the wire's transient events to
|
||||
effects: `CHOPPED` a spark, `COOKED`/`READY` steam, `SERVED` a pop +
|
||||
score float, `BURNT` smoke, `DENIED` a cross + nudge, `STUNNED` a
|
||||
spin, `ORDER_MISSED` a flash, `SCORE` a float. Particles come from a
|
||||
**fixed pool** (no allocation per event: a `spawn` reuses a slot, and
|
||||
when the pool is full it steals the oldest, so a burst cannot
|
||||
allocate unboundedly). The camera shake is a decaying offset the
|
||||
renderer adds to the camera; under `[data-motion="reduced"]` /
|
||||
`prefers-reduced-motion` the shake is suppressed (the `shakeApplied`
|
||||
census stays 0) and the effects are **static markers** (no particle
|
||||
animation), so a denied nudge and a hazard are still visible without
|
||||
motion. `fx.stats()` is the census the e2e suites read: `processed`
|
||||
(per-kind counts), `poolInUse`, `shakeApplied`, `reducedMotion`.
|
||||
|
||||
**The HUD** (`ui/hud.js`) is **DOM, not canvas** — it stays crisp and
|
||||
is readable by assistive tech. The structure (all under `#hud`):
|
||||
the **order rail** (a ticket per order: a recipe-icon chain, a time
|
||||
bar, the points; the oldest ticket highlighted with an `oldest`
|
||||
class), the **score + target bar** (the current score over the
|
||||
target), the **countdown timer** (turns amber < 20 s, red < 10 s,
|
||||
with a non-colour cue — the timer's text label: `hurry` < 20 s,
|
||||
`now` < 10 s), the **streak indicator**, the **dirty-dish counter**,
|
||||
the **connection indicator**, and the **spectator banner**. Updates are
|
||||
**coalesced to one write per animation frame** (the HUD accumulates the
|
||||
frame's changes and writes the DOM once, so a 20 Hz event stream
|
||||
causes at most one DOM write per rAF). The HUD changes are visible
|
||||
within 200 ms of the server event (`tests/e2e/test_us04_render_smoke.py`
|
||||
asserts the score text changes within 200 ms of the `served` event).
|
||||
|
||||
- The story suites (`tests/e2e/test_us01_*` … `test_us06_*`): a real
|
||||
browser against a real uvicorn server (the `e2e_server` factory in
|
||||
`tests/e2e/conftest.py` — an ephemeral `127.0.0.1` port, never a
|
||||
routable interface), two contexts = two players; they assert visible
|
||||
text and the `window.__undercooked` seam, not pixels, for the DOM
|
||||
screens. `test_us04_render_smoke.py` is the one exception that
|
||||
asserts *pixels and the perf budget*: the canvas is non-blank
|
||||
(variance over sampled pixels) and changes after a move, `renderStats
|
||||
().drawCalls` is within a sane band (< 4 000), the frame time is ≤
|
||||
16 ms p95 over a 10 s scripted round, the HUD elements exist and
|
||||
update (the score text changes within 200 ms of the `served` event,
|
||||
the timer decreases), `document.hidden` pauses the loop, and the
|
||||
reduced-motion mode shakes nothing while the hazard markers stay
|
||||
visible.
|
||||
- The pure-module suites (`test_atlas_loads.py`,
|
||||
`test_us06_interp_math.py`): the browser imports the module directly
|
||||
(`import("/static/js/…")`) and drives it — no bundler, no jsdom.
|
||||
`test_us06_interp_math.py` covers the buffer's ordering / gap /
|
||||
resync / freeze contract, `lerpState`'s lerp-and-snap, the
|
||||
`applyDelta` decoder against the real encoder, the event queue's
|
||||
TTL / dedup / tick-reset, and the loop's pause / idle / census —
|
||||
all with injected clocks and captured rAFs. `test_us06_smoothness.py`
|
||||
is the end-to-end smoothness proof: the 60 ms-latency round, the
|
||||
per-tick displacement bounded by
|
||||
`MAX_SPEED * (1/snapshot_hz) * 1.35`, and both clients on the
|
||||
result screen within 1 s of each other.
|
||||
- The enum parity suite (`tests/unit/test_client_enum_parity.py`):
|
||||
the JS wire enums stay set-equal to the Python ones, in both
|
||||
directions (a removed value is a build failure, naming it).
|
||||
- The sprite-map suite (`tests/unit/test_client_sprite_map.py`):
|
||||
walks `render/canvas.js`'s `REACHABLE_ITEM_FORMS` (its kind set is
|
||||
parity-guarded against the sim's `ItemKind` / `ItemForm`, which
|
||||
`shared/enums.js` mirrors) and the station-state / cook-frame
|
||||
tables, against the sim's enums and the shipped `assets/index.json`
|
||||
— every reachable (item kind, form) pair, every (station kind,
|
||||
state) pair, and every cook facing/frame must map to an id that
|
||||
exists, so a new sim pair without its sprite, or a mapping table
|
||||
naming a missing id, is a build failure naming the pair.
|
||||
- The same-origin rule is re-asserted in the browser (a full round's
|
||||
request census must equal the module graph + the API calls, all
|
||||
same-origin).
|
||||
Reference in New Issue
Block a user