// 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 `` 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; }, }; }