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:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user