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:
2026-10-10 18:03:52 -04:00
parent f26849c1f3
commit 95ca3fd1fa
78 changed files with 13650 additions and 259 deletions
+317
View File
@@ -0,0 +1,317 @@
// Undercooked — the keyboard input module (phase 07, task 05).
//
// Desktop control for the play screen, wired to the protocol's `input`
// frame (docs/PROTOCOL.md "Input rules"): one frame of intent, sent on
// change plus a keep-alive at ≥ 5 Hz. The `seq` is the connection's
// (`net/connection.js` — a monotonic counter per connection, reset only
// on a new socket); this module never touches it.
//
// * `WASD` + the arrow keys → an 8-way intent vector; opposite keys
// cancel (W+S is a stop, not a nudge); a diagonal is normalised
// (the server clamps the magnitude to 1 — a diagonal must not be
// faster than a cardinal move).
// * `Space` / `Enter` = the one action button. A press is a rising
// edge (`action_held_ticks` semantics from the protocol: the hold
// rides the frame's `act` flag, `sim/interact.py`'s gate turns the
// edge into one event and the hold counters into the hold progress).
// * `1`–`4` = the emote whitelist's first four (`wave`, `cheer`,
// `thumbs_up`, `thumbs_down` — `server/seats.py`); legal in `LOBBY`
// and `PLAYING` (the server's per-state whitelist decides; this
// module only knows the screen, and the lobby screen has no
// competing control).
// * `?` = the help overlay's toggle; `Escape` = close the help, or —
// on the play screen with the help closed — leave the room (the
// Leave button's behaviour).
// * Key events are ignored when focus is in an input field, when
// `document.hidden`, or when a modifier is held (browser shortcuts
// survive: no `preventDefault` on Ctrl/Cmd/Meta combos).
// * `blur` / `visibilitychange` (hidden) / `pagehide` clear every held
// key and send one zero-intent frame with the action released (no
// cook running away when you alt-tab).
//
// `?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 mode apart. The send pump holds the intent while
// a key is down (the sim brakes a tick with no frame — a held key must
// ride every tick window, ~48 ms ≈ 20.8 Hz) and, idle, a zero-intent
// keep-alive at 10 Hz (the protocol's ≥ 5 Hz; it is also what asserts
// "no input" after a release).
import { getKeyMap } from "../ui/help.js";
export { getKeyMap };
/** The movement key map (key code → axis). WASD and the arrows; the
* `e.code` (layout-independent physical key) is what a game binds —
* a AZERTY user's `Z` is the same physical key as a QWERTY `W`. */
const MOVE_CODES = {
KeyW: "up",
ArrowUp: "up",
KeyS: "down",
ArrowDown: "down",
KeyA: "left",
ArrowLeft: "left",
KeyD: "right",
ArrowRight: "right",
};
/** The action key codes (one button, two ways to reach it). */
const ACTION_CODES = new Set(["Space", "Enter"]);
/** `1`–`4` → the emote whitelist's first four (the server's
* `EMOTES` order, `server/seats.py`). */
const EMOTES_BY_KEY = {
Digit1: "wave",
Numpad1: "wave",
Digit2: "cheer",
Numpad2: "cheer",
Digit3: "thumbs_up",
Numpad3: "thumbs_up",
Digit4: "thumbs_down",
Numpad4: "thumbs_down",
};
/** A diagonal's length (both axes held at 1): normalising by
* `1 / √2` keeps the wire's magnitude ≤ 1 — the server's clamp must
* never see a diagonal (it would be faster than a cardinal move). */
const DIAGONAL = Math.SQRT1_2;
/** The pump's cadence: 25 ms (40 Hz — it checks every frame the
* `requestAnimationFrame`-free setInterval gives it); a held key
* re-sends at ~48 ms (above the 20 Hz tick, the hold stays
* continuous), an idle one at 100 ms (10 Hz, the ≥ 5 Hz keep-alive).
*/
const PUMP_MS = 25;
const HELD_RESEND_MS = 48;
const IDLE_KEEPALIVE_MS = 100;
/** Whether a key event is not game input (the ignore rules, one place). */
function ignored(e) {
if (document.hidden) return true;
if (e.ctrlKey || e.metaKey || e.altKey) return true;
const t = e.target;
if (t === null || t === undefined) return true;
return (
t instanceof HTMLInputElement ||
t instanceof HTMLTextAreaElement ||
t instanceof HTMLSelectElement ||
(t instanceof Element && t.isContentEditable)
);
}
/**
* The keyboard input module.
*
* `app` — the app instance (`app.js`): its `sendInput` is the wire,
* `screen` / `spectator` the gate, `emote` / `leave` the verbs.
* `controls` — the `?controls=` param (`keyboard` binds, anything else
* exposes the seams and does nothing). `help` — the help overlay's
* `{ toggle(), visible() }` (optional; `?` and `Esc` skip it when
* absent).
*
* Returns `{ active, getKeyMap, stats, dispose }`; `stats()` is the
* census (a `sent` count, the last frame sent, what is held).
*/
export function createKeyboardInput({ app, controls = "keyboard", help = null } = {}) {
const active = controls === "keyboard";
const held = { up: false, down: false, left: false, right: false };
let actionHeld = false;
let sent = 0;
let lastSent = { x: 0, y: 0, act: false };
let lastSentAt = 0;
let disposed = false;
let timer = null;
/** The 8-way intent vector (opposites cancel; a diagonal is normalised). */
function intentVector() {
const x = (held.right ? 1 : 0) - (held.left ? 1 : 0);
const y = (held.down ? 1 : 0) - (held.up ? 1 : 0);
if (x !== 0 && y !== 0) {
return { x: x * DIAGONAL, y: y * DIAGONAL };
}
return { x, y };
}
/** One frame out (the census + the wire). */
function sendFrame(v, act) {
lastSent = { x: v.x, y: v.y, act };
lastSentAt = performance.now();
sent += 1;
app.sendInput(v, act);
}
/** A change in intent: send the new frame (the keep-alive pump
* resends while a key is held; this is the "on change" half). */
function sendOnChange() {
if (app.screen !== "playing" || app.spectator) return;
const v = intentVector();
if (v.x === lastSent.x && v.y === lastSent.y && actionHeld === lastSent.act) {
return;
}
sendFrame(v, actionHeld);
}
/** The forced zero (focus loss / tab hidden): clear everything and
* assert one zero-intent frame with the action released. */
function releaseAll() {
held.up = held.down = held.left = held.right = false;
actionHeld = false;
if (app.screen === "playing" && !app.spectator) {
sendFrame({ x: 0, y: 0 }, false);
} else {
lastSentAt = performance.now();
}
}
function anyHeld() {
return held.up || held.down || held.left || held.right || actionHeld;
}
/** The pump: while a key is held, resend ~every 48 ms (the sim
* brakes a tick with no frame — the hold must ride every tick
* window); idle, a zero-intent keep-alive at 100 ms (10 Hz ≥ 5 Hz —
* it also re-asserts "no input" after a release). */
function pump() {
const now = performance.now();
const due = anyHeld()
? now - lastSentAt >= HELD_RESEND_MS
: now - lastSentAt >= IDLE_KEEPALIVE_MS;
if (!due) return;
if (app.screen === "playing" && !app.spectator) {
sendFrame(intentVector(), actionHeld);
} else {
lastSentAt = now; // off-screen: reset the clock, no burst on return
}
}
function onKeydown(e) {
if (ignored(e)) return;
const move = MOVE_CODES[e.code];
if (move !== undefined) {
e.preventDefault(); // the arrows (and some pages' Space) would scroll
if (!e.repeat) {
held[move] = true;
sendOnChange();
}
return;
}
if (ACTION_CODES.has(e.code)) {
// A focused button/anchor keeps its native activation (the play
// screen's Leave / emote row); a bare page only ever scrolls.
// `HTMLDetailsElement` is a real interface (the `open` attribute)
// but `<summary>` has none — there is no `HTMLSummaryElement`, so
// the summary is matched by `tagName` (checking the identifier
// would throw a `ReferenceError` and swallow the press).
const t = e.target;
const interactive =
t instanceof HTMLButtonElement ||
t instanceof HTMLAnchorElement ||
t instanceof HTMLDetailsElement ||
(t instanceof Element && t.tagName === "SUMMARY");
if (app.screen === "playing" && !interactive) e.preventDefault();
if (!e.repeat) {
actionHeld = true;
sendOnChange();
}
return;
}
if (e.repeat) return;
const emote = EMOTES_BY_KEY[e.code];
if (emote !== undefined) {
if (app.screen === "playing" || app.screen === "lobby") {
e.preventDefault();
app.emote(emote);
}
return;
}
if (e.key === "?") {
e.preventDefault();
if (help !== null) help.toggle();
return;
}
if (e.key === "Escape") {
if (help !== null && help.visible()) {
help.hide();
} else if (app.screen === "playing") {
app.leave();
}
}
}
function onKeyup(e) {
// A keyup always clears (even when the keydown was ignored — a
// held key that was not game input must not leak into the vector
// once the target changes).
const move = MOVE_CODES[e.code];
if (move !== undefined) {
if (held[move]) {
held[move] = false;
sendOnChange();
}
return;
}
if (ACTION_CODES.has(e.code)) {
if (actionHeld) {
actionHeld = false;
sendOnChange();
}
}
}
function onBlur() {
releaseAll();
}
function onVisibility() {
if (document.hidden) releaseAll();
}
function onPagehide() {
releaseAll();
}
const listeners = [];
if (active) {
const add = (target, type, fn, options) => {
target.addEventListener(type, fn, options);
listeners.push([target, type, fn, options]);
};
add(window, "keydown", onKeydown);
add(window, "keyup", onKeyup);
add(window, "blur", onBlur);
add(document, "visibilitychange", onVisibility);
add(window, "pagehide", onPagehide);
timer = setInterval(pump, PUMP_MS);
}
return {
active,
getKeyMap,
/** The census (the e2e suites' seam): what is held, what the last
* frame sent was, how many frames went out. */
stats() {
return {
active,
sent,
last: { ...lastSent },
lastSentAt,
held: { ...held },
actionHeld,
};
},
/** Tear down (a page reload's `beforeunload` is the caller; the
* suites call it to reset between scenarios). */
dispose() {
if (disposed) return;
disposed = true;
if (timer !== null) {
clearInterval(timer);
timer = null;
}
for (const [target, type, fn, options] of listeners) {
target.removeEventListener(type, fn, options);
}
listeners.length = 0;
},
};
}