Files
undercooked/app/static/js/input/keyboard.js
T
ducoterra 95ca3fd1fa 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`.
2026-10-10 18:03:52 -04:00

318 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
},
};
}