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,116 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
|
||||
<!-- Undercooked cooks (phase 07, task 01). Four bodies (cook-1..4),
|
||||
each 4 facings x 2 walk frames; the torso colour is
|
||||
`currentColor`, so a <use> recolors it: in the DOM through the
|
||||
page's cook-colour CSS custom properties, in the canvas through
|
||||
the var() fallback (app/static/assets/palette.css). The carry
|
||||
overlay and the stun frame are shared. -->
|
||||
|
||||
<defs>
|
||||
<ellipse id="shadow" cx="16" cy="29.5" rx="8" ry="1.8" fill="#1a1713" opacity="0.18"/>
|
||||
|
||||
<g id="legs-0">
|
||||
<rect x="12.4" y="23" width="3.2" height="6" rx="1.4" fill="#4a4238"/>
|
||||
<rect x="16.4" y="23" width="3.2" height="6" rx="1.4" fill="#4a4238"/>
|
||||
</g>
|
||||
<g id="legs-1">
|
||||
<path d="M13.5 23 L10.8 28.8" stroke="#4a4238" stroke-width="3.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M18.5 23 L21.2 28.8" stroke="#4a4238" stroke-width="3.2" stroke-linecap="round" fill="none"/>
|
||||
</g>
|
||||
|
||||
<g id="chef-torso">
|
||||
<rect x="10" y="14" width="12" height="11" rx="3" fill="currentColor"/>
|
||||
<path d="M12 18.5 h8 v3.5 a4 3.4 0 0 1 -8 0 z" fill="#f2ede3"/>
|
||||
</g>
|
||||
|
||||
<g id="chef-hat">
|
||||
<path d="M11.4 8.6 a4.6 4.2 0 0 1 9.2 0 z" fill="#fbf7ee"/>
|
||||
<circle cx="16" cy="5.4" r="2.8" fill="#fbf7ee"/>
|
||||
<path d="M11.4 8.6 h9.2" stroke="#e2dbcb" stroke-width="1.2" stroke-linecap="round" fill="none"/>
|
||||
</g>
|
||||
|
||||
<g id="chef-head-down">
|
||||
<circle cx="16" cy="11" r="4.6" fill="#f2c9a0"/>
|
||||
<use href="#chef-hat"/>
|
||||
<circle cx="14.2" cy="10.6" r="0.75" fill="#3a322a"/>
|
||||
<circle cx="17.8" cy="10.6" r="0.75" fill="#3a322a"/>
|
||||
<path d="M15.2 13 q0.8 0.7 1.6 0" stroke="#c98a5e" stroke-width="0.9" stroke-linecap="round" fill="none"/>
|
||||
</g>
|
||||
<g id="chef-head-up">
|
||||
<circle cx="16" cy="11" r="4.6" fill="#f2c9a0"/>
|
||||
<use href="#chef-hat"/>
|
||||
<path d="M12.6 13.4 q3.4 2 6.8 0" stroke="#e0b184" stroke-width="1" stroke-linecap="round" fill="none"/>
|
||||
</g>
|
||||
<g id="chef-head-right">
|
||||
<circle cx="16" cy="11" r="4.6" fill="#f2c9a0"/>
|
||||
<use href="#chef-hat"/>
|
||||
<circle cx="18.9" cy="10.6" r="0.75" fill="#3a322a"/>
|
||||
<path d="M18.4 13.2 q0.8 0.6 1.6 0" stroke="#c98a5e" stroke-width="0.9" stroke-linecap="round" fill="none"/>
|
||||
</g>
|
||||
<g id="chef-head-left">
|
||||
<circle cx="16" cy="11" r="4.6" fill="#f2c9a0"/>
|
||||
<use href="#chef-hat"/>
|
||||
<circle cx="13.1" cy="10.6" r="0.75" fill="#3a322a"/>
|
||||
<path d="M12 13.2 q0.8 0.6 1.6 0" stroke="#c98a5e" stroke-width="0.9" stroke-linecap="round" fill="none"/>
|
||||
</g>
|
||||
|
||||
<g id="shape-up-0"><use href="#shadow"/><use href="#legs-0"/><use href="#chef-torso"/><use href="#chef-head-up"/></g>
|
||||
<g id="shape-up-1"><use href="#shadow"/><use href="#legs-1"/><use href="#chef-torso"/><use href="#chef-head-up"/></g>
|
||||
<g id="shape-right-0"><use href="#shadow"/><use href="#legs-0"/><use href="#chef-torso"/><use href="#chef-head-right"/></g>
|
||||
<g id="shape-right-1"><use href="#shadow"/><use href="#legs-1"/><use href="#chef-torso"/><use href="#chef-head-right"/></g>
|
||||
<g id="shape-down-0"><use href="#shadow"/><use href="#legs-0"/><use href="#chef-torso"/><use href="#chef-head-down"/></g>
|
||||
<g id="shape-down-1"><use href="#shadow"/><use href="#legs-1"/><use href="#chef-torso"/><use href="#chef-head-down"/></g>
|
||||
<g id="shape-left-0"><use href="#shadow"/><use href="#legs-0"/><use href="#chef-torso"/><use href="#chef-head-left"/></g>
|
||||
<g id="shape-left-1"><use href="#shadow"/><use href="#legs-1"/><use href="#chef-torso"/><use href="#chef-head-left"/></g>
|
||||
</defs>
|
||||
|
||||
<symbol id="cook-1-up-0" viewBox="0 0 32 32"><use href="#shape-up-0" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
|
||||
<symbol id="cook-1-up-1" viewBox="0 0 32 32"><use href="#shape-up-1" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
|
||||
<symbol id="cook-1-right-0" viewBox="0 0 32 32"><use href="#shape-right-0" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
|
||||
<symbol id="cook-1-right-1" viewBox="0 0 32 32"><use href="#shape-right-1" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
|
||||
<symbol id="cook-1-down-0" viewBox="0 0 32 32"><use href="#shape-down-0" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
|
||||
<symbol id="cook-1-down-1" viewBox="0 0 32 32"><use href="#shape-down-1" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
|
||||
<symbol id="cook-1-left-0" viewBox="0 0 32 32"><use href="#shape-left-0" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
|
||||
<symbol id="cook-1-left-1" viewBox="0 0 32 32"><use href="#shape-left-1" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
|
||||
|
||||
<symbol id="cook-2-up-0" viewBox="0 0 32 32"><use href="#shape-up-0" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
|
||||
<symbol id="cook-2-up-1" viewBox="0 0 32 32"><use href="#shape-up-1" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
|
||||
<symbol id="cook-2-right-0" viewBox="0 0 32 32"><use href="#shape-right-0" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
|
||||
<symbol id="cook-2-right-1" viewBox="0 0 32 32"><use href="#shape-right-1" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
|
||||
<symbol id="cook-2-down-0" viewBox="0 0 32 32"><use href="#shape-down-0" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
|
||||
<symbol id="cook-2-down-1" viewBox="0 0 32 32"><use href="#shape-down-1" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
|
||||
<symbol id="cook-2-left-0" viewBox="0 0 32 32"><use href="#shape-left-0" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
|
||||
<symbol id="cook-2-left-1" viewBox="0 0 32 32"><use href="#shape-left-1" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
|
||||
|
||||
<symbol id="cook-3-up-0" viewBox="0 0 32 32"><use href="#shape-up-0" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
|
||||
<symbol id="cook-3-up-1" viewBox="0 0 32 32"><use href="#shape-up-1" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
|
||||
<symbol id="cook-3-right-0" viewBox="0 0 32 32"><use href="#shape-right-0" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
|
||||
<symbol id="cook-3-right-1" viewBox="0 0 32 32"><use href="#shape-right-1" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
|
||||
<symbol id="cook-3-down-0" viewBox="0 0 32 32"><use href="#shape-down-0" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
|
||||
<symbol id="cook-3-down-1" viewBox="0 0 32 32"><use href="#shape-down-1" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
|
||||
<symbol id="cook-3-left-0" viewBox="0 0 32 32"><use href="#shape-left-0" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
|
||||
<symbol id="cook-3-left-1" viewBox="0 0 32 32"><use href="#shape-left-1" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
|
||||
|
||||
<symbol id="cook-4-up-0" viewBox="0 0 32 32"><use href="#shape-up-0" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
|
||||
<symbol id="cook-4-up-1" viewBox="0 0 32 32"><use href="#shape-up-1" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
|
||||
<symbol id="cook-4-right-0" viewBox="0 0 32 32"><use href="#shape-right-0" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
|
||||
<symbol id="cook-4-right-1" viewBox="0 0 32 32"><use href="#shape-right-1" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
|
||||
<symbol id="cook-4-down-0" viewBox="0 0 32 32"><use href="#shape-down-0" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
|
||||
<symbol id="cook-4-down-1" viewBox="0 0 32 32"><use href="#shape-down-1" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
|
||||
<symbol id="cook-4-left-0" viewBox="0 0 32 32"><use href="#shape-left-0" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
|
||||
<symbol id="cook-4-left-1" viewBox="0 0 32 32"><use href="#shape-left-1" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
|
||||
|
||||
<!-- The carrying overlay: one arm raised with the item slot ring; the
|
||||
renderer draws the item sprite in the slot. -->
|
||||
<symbol id="cook-carry" viewBox="0 0 32 32">
|
||||
<path d="M19 17 l7 -5.5" stroke="#f2c9a0" stroke-width="3" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="27" cy="11" r="2.4" fill="#f2c9a0"/>
|
||||
<circle cx="27" cy="9.6" r="4.6" fill="none" stroke="#cfc5b5" stroke-width="1" stroke-dasharray="2.4 2.2"/>
|
||||
</symbol>
|
||||
|
||||
<!-- The stun frame: a dizzy swirl and a star. -->
|
||||
<symbol id="cook-stun" viewBox="0 0 32 32">
|
||||
<path d="M19.5 12.5 a3.5 3.5 0 1 1 -3.5 -3.5 a5 5 0 1 0 5 5" fill="none" stroke="#3a322a" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<path d="M16 2.6 l1 2.2 2.4 0.3 -1.8 1.6 0.5 2.3 -2.1 -1.2 -2.1 1.2 0.5 -2.3 -1.8 -1.6 2.4 -0.3 z" fill="#ffd95e"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,39 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
|
||||
<!-- Undercooked transient FX (phase 07, task 01). One symbol per event
|
||||
the sim can emit; the renderer plays them at the event position. -->
|
||||
|
||||
<symbol id="fx-chop" viewBox="0 0 32 32">
|
||||
<path d="M10 16 L4 16 M16 10 L16 4 M22 16 L28 16 M12.5 11.5 L8.5 7.5 M19.5 11.5 L23.5 7.5" stroke="#ffd95e" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="fx-steam" viewBox="0 0 32 32">
|
||||
<path d="M11 22 q1.4 -2.4 0 -4.8 q-1.4 -2.4 0 -4.8" stroke="#e8e2d4" stroke-width="1.8" stroke-linecap="round" fill="none" opacity="0.9"/>
|
||||
<path d="M16 24 q1.4 -2.4 0 -4.8 q-1.4 -2.4 0 -4.8" stroke="#e8e2d4" stroke-width="1.8" stroke-linecap="round" fill="none" opacity="0.75"/>
|
||||
<path d="M21 22 q1.4 -2.4 0 -4.8 q-1.4 -2.4 0 -4.8" stroke="#e8e2d4" stroke-width="1.8" stroke-linecap="round" fill="none" opacity="0.9"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="fx-flame" viewBox="0 0 32 32">
|
||||
<path d="M16 3.5 c3.4 3 5.4 5.6 5.4 8.6 a5.4 5.4 0 0 1 -10.8 0 c0 -3 2 -5.6 5.4 -8.6 z" fill="#ff5a4e"/>
|
||||
<path d="M16 9 c1.8 1.8 3 3.2 3 4.8 a3 3 0 0 1 -6 0 c0 -1.6 1.2 -3 3 -4.8 z" fill="#ffa63d"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="fx-splash" viewBox="0 0 32 32">
|
||||
<path d="M16 8 c2 2.6 3.4 4.6 3.4 6.6 a3.4 3.4 0 0 1 -6.8 0 c0 -2 1.4 -4 3.4 -6.6 z" fill="#6fa8c9"/>
|
||||
<path d="M8 18 q-1.5 2 0 3.4 M24 18 q1.5 2 0 3.4 M12 24.5 q-1 1.6 0 2.6 M20 24.5 q1 1.6 0 2.6" stroke="#6fa8c9" stroke-width="1.6" stroke-linecap="round" fill="none"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="fx-deny" viewBox="0 0 32 32">
|
||||
<path d="M9 9 L23 23 M23 9 L9 23" stroke="#ff5a4e" stroke-width="4" stroke-linecap="round" fill="none"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="fx-score" viewBox="0 0 32 32">
|
||||
<path d="M16 3 l2.6 6.4 6.9 0.8 -5.2 4.6 1.5 6.8 -5.8 -3.6 -5.8 3.6 1.5 -6.8 -5.2 -4.6 6.9 -0.8 z" fill="#ffd95e"/>
|
||||
<path d="M16 11.5 v7 M12.8 15 h6.4" stroke="#8a6b45" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="fx-serve" viewBox="0 0 32 32">
|
||||
<path d="M10 8 l1.2 2.8 2.8 1.2 -2.8 1.2 L10 16 l-1.2 -2.8 L6 12 l2.8 -1.2 z" fill="#fff7d6"/>
|
||||
<path d="M22 14 l1 2.4 2.4 1 -2.4 1 L22 20.8 l-1 -2.4 -2.4 -1 2.4 -1 z" fill="#fff7d6"/>
|
||||
<path d="M17 22 l0.8 1.8 1.8 0.8 -1.8 0.8 L17 27.2 l-0.8 -1.8 -1.8 -0.8 1.8 -0.8 z" fill="#ffd95e"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,527 @@
|
||||
{
|
||||
"cook-1-down-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-1-down-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-1-left-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-1-left-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-1-right-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-1-right-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-1-up-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-1-up-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-2-down-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-2-down-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-2-left-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-2-left-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-2-right-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-2-right-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-2-up-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-2-up-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-3-down-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-3-down-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-3-left-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-3-left-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-3-right-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-3-right-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-3-up-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-3-up-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-4-down-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-4-down-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-4-left-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-4-left-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-4-right-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-4-right-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-4-up-0": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-4-up-1": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-carry": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"cook-stun": {
|
||||
"file": "cooks.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"fx-chop": {
|
||||
"file": "fx.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"fx-deny": {
|
||||
"file": "fx.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"fx-flame": {
|
||||
"file": "fx.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"fx-score": {
|
||||
"file": "fx.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"fx-serve": {
|
||||
"file": "fx.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"fx-splash": {
|
||||
"file": "fx.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"fx-steam": {
|
||||
"file": "fx.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-dough-burnt": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-dough-chopped": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-dough-cooked": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-dough-cooking": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-dough-raw": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-fish-burnt": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-fish-chopped": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-fish-cooked": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-fish-cooking": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-fish-raw": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-lettuce-burnt": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-lettuce-chopped": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-lettuce-cooked": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-lettuce-cooking": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-lettuce-raw": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-onion-burnt": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-onion-chopped": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-onion-cooked": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-onion-cooking": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-onion-raw": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-patty-burnt": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-patty-chopped": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-patty-cooked": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-patty-cooking": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-patty-raw": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-plate-dirty": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-plate-plated": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-plate-raw": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-rice-burnt": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-rice-cooked": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-rice-cooking": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-rice-raw": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-tomato-burnt": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-tomato-chopped": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-tomato-cooked": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-tomato-cooking": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"item-tomato-raw": {
|
||||
"file": "items.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-chop_board": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-pan": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-pot": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-prep": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-serve": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-sink": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-spawn_shelf": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-stove": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-stove-burning": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-stove-off": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-stove-ready": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-stove-working": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"station-trash": {
|
||||
"file": "stations.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"tile-counter": {
|
||||
"file": "tiles.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"tile-floor": {
|
||||
"file": "tiles.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"tile-floor-1": {
|
||||
"file": "tiles.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"tile-floor-2": {
|
||||
"file": "tiles.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"tile-floor-3": {
|
||||
"file": "tiles.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"tile-floor-4": {
|
||||
"file": "tiles.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"tile-void": {
|
||||
"file": "tiles.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"tile-wall": {
|
||||
"file": "tiles.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"ui-crown": {
|
||||
"file": "ui.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"ui-ghost": {
|
||||
"file": "ui.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"ui-ready": {
|
||||
"file": "ui.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"ui-star": {
|
||||
"file": "ui.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"ui-streak": {
|
||||
"file": "ui.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
},
|
||||
"ui-ticket": {
|
||||
"file": "ui.svg",
|
||||
"h": 32.0,
|
||||
"w": 32.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
|
||||
<!-- Undercooked items (phase 07, task 01). Every ItemKind x reachable
|
||||
ItemForm the simulation can produce (see tools/gen_art_index.py),
|
||||
32x32. Shared geometry lives in <defs>; the per-kind colour rides
|
||||
on `currentColor` so a <use> recolors it. -->
|
||||
|
||||
<defs>
|
||||
<g id="grid-dice">
|
||||
<rect x="8" y="11" width="7" height="7" rx="1.6" fill="currentColor"/>
|
||||
<rect x="17" y="11" width="7" height="7" rx="1.6" fill="currentColor"/>
|
||||
<rect x="8" y="20" width="7" height="7" rx="1.6" fill="currentColor"/>
|
||||
<rect x="17" y="20" width="7" height="7" rx="1.6" fill="currentColor"/>
|
||||
</g>
|
||||
<g id="pot-steam">
|
||||
<rect x="7" y="13.5" width="18" height="11.5" rx="3" fill="#8b8b8b"/>
|
||||
<rect x="5.6" y="11.2" width="20.8" height="3.4" rx="1.7" fill="#a3a3a3"/>
|
||||
<path d="M11 7 q1 -1.6 0 -3 M16 6.4 q1 -1.6 0 -3 M21 7 q1 -1.6 0 -3" stroke="#e8e2d4" stroke-width="1.4" stroke-linecap="round" fill="none" opacity="0.9"/>
|
||||
</g>
|
||||
<g id="bowl">
|
||||
<path d="M6.5 13 h19 c0 5.6 -4 8.6 -9.5 8.6 s-9.5 -3 -9.5 -8.6 z" fill="#f6f1e7"/>
|
||||
<ellipse cx="16" cy="13" rx="9.5" ry="3" fill="#e2dbcb"/>
|
||||
</g>
|
||||
<g id="char">
|
||||
<path d="M16 11 c4.2 0 7.2 2.7 7.2 6 c0 3.6 -3.2 6.3 -7.2 6.3 s-7.2 -2.7 -7.2 -6.3 c0 -3.3 3 -6 7.2 -6 z" fill="#3b3b3b"/>
|
||||
<path d="M13.2 8.4 q0.8 -1.4 0 -2.8 M17.8 8 q0.8 -1.4 0 -2.8" stroke="#6b6b6b" stroke-width="1.2" stroke-linecap="round" fill="none"/>
|
||||
</g>
|
||||
</defs>
|
||||
|
||||
<!-- ============================== tomato ============================== -->
|
||||
<symbol id="item-tomato-raw" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="18" r="8" fill="#e0533d"/>
|
||||
<ellipse cx="13" cy="15.4" rx="2.6" ry="1.7" fill="#f08a63" opacity="0.8"/>
|
||||
<path d="M16 11.4 v-2.4" stroke="#5d8a3c" stroke-width="1.6" stroke-linecap="round" fill="none"/>
|
||||
<path d="M16 10.2 c-2.4 -1.4 -4.6 -1 -6 0.6 c2 0.4 4 0.2 6 -0.6 z" fill="#5d8a3c"/>
|
||||
<path d="M16 10.2 c2.4 -1.4 4.6 -1 6 0.6 c-2 0.4 -4 0.2 -6 -0.6 z" fill="#5d8a3c"/>
|
||||
</symbol>
|
||||
<symbol id="item-tomato-chopped" viewBox="0 0 32 32">
|
||||
<use href="#grid-dice" style="color: #e0533d"/>
|
||||
</symbol>
|
||||
<symbol id="item-tomato-cooking" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="10.8" r="2.8" fill="#e0533d"/>
|
||||
<use href="#pot-steam"/>
|
||||
</symbol>
|
||||
<symbol id="item-tomato-cooked" viewBox="0 0 32 32">
|
||||
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#e0533d" stroke-width="2.6" stroke-linecap="round" fill="none"/>
|
||||
<use href="#bowl"/>
|
||||
</symbol>
|
||||
<symbol id="item-tomato-burnt" viewBox="0 0 32 32">
|
||||
<use href="#char"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ============================== onion ============================== -->
|
||||
<symbol id="item-onion-raw" viewBox="0 0 32 32">
|
||||
<ellipse cx="16" cy="18.5" rx="8" ry="7.4" fill="#c9a2cf"/>
|
||||
<path d="M10.4 14.6 c1.8 -3 9.4 -3 11.2 0 M9.4 19 h13.2 M11.4 23.4 c1.8 1.5 7.4 1.5 9.2 0" stroke="#a67fb0" stroke-width="1" fill="none"/>
|
||||
<path d="M16 11.6 c0 -2 0.8 -3.2 2.2 -3.8" stroke="#8a6b45" stroke-width="1.6" stroke-linecap="round" fill="none"/>
|
||||
</symbol>
|
||||
<symbol id="item-onion-chopped" viewBox="0 0 32 32">
|
||||
<use href="#grid-dice" style="color: #c9a2cf"/>
|
||||
</symbol>
|
||||
<symbol id="item-onion-cooking" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="10.8" r="2.8" fill="#c9a2cf"/>
|
||||
<use href="#pot-steam"/>
|
||||
</symbol>
|
||||
<symbol id="item-onion-cooked" viewBox="0 0 32 32">
|
||||
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#c9a2cf" stroke-width="2.6" stroke-linecap="round" fill="none"/>
|
||||
<use href="#bowl"/>
|
||||
</symbol>
|
||||
<symbol id="item-onion-burnt" viewBox="0 0 32 32">
|
||||
<use href="#char"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ============================= lettuce ============================= -->
|
||||
<symbol id="item-lettuce-raw" viewBox="0 0 32 32">
|
||||
<circle cx="12.5" cy="17" r="5.5" fill="#7db85a"/>
|
||||
<circle cx="20" cy="16.5" r="5.5" fill="#8bc76c"/>
|
||||
<circle cx="16.5" cy="20.5" r="5.5" fill="#6ea84e"/>
|
||||
<path d="M10 15 c2 -2.5 8 -2.5 10 0" stroke="#a9dc8f" stroke-width="1.3" fill="none" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<symbol id="item-lettuce-chopped" viewBox="0 0 32 32">
|
||||
<use href="#grid-dice" style="color: #7db85a"/>
|
||||
</symbol>
|
||||
<symbol id="item-lettuce-cooking" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="10.8" r="2.8" fill="#7db85a"/>
|
||||
<use href="#pot-steam"/>
|
||||
</symbol>
|
||||
<symbol id="item-lettuce-cooked" viewBox="0 0 32 32">
|
||||
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#7db85a" stroke-width="2.6" stroke-linecap="round" fill="none"/>
|
||||
<use href="#bowl"/>
|
||||
</symbol>
|
||||
<symbol id="item-lettuce-burnt" viewBox="0 0 32 32">
|
||||
<use href="#char"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ============================== dough ============================== -->
|
||||
<symbol id="item-dough-raw" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="18" r="7.5" fill="#d9b97c"/>
|
||||
<ellipse cx="13.5" cy="15.5" rx="2.4" ry="1.5" fill="#e8d3a5" opacity="0.9"/>
|
||||
<circle cx="19.5" cy="19" r="0.7" fill="#f2ead8"/>
|
||||
<circle cx="14.5" cy="20.5" r="0.6" fill="#f2ead8"/>
|
||||
<circle cx="18.5" cy="14.5" r="0.5" fill="#f2ead8"/>
|
||||
</symbol>
|
||||
<symbol id="item-dough-chopped" viewBox="0 0 32 32">
|
||||
<use href="#grid-dice" style="color: #d9b97c"/>
|
||||
</symbol>
|
||||
<symbol id="item-dough-cooking" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="10.8" r="2.8" fill="#d9b97c"/>
|
||||
<use href="#pot-steam"/>
|
||||
</symbol>
|
||||
<symbol id="item-dough-cooked" viewBox="0 0 32 32">
|
||||
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#d9b97c" stroke-width="2.6" stroke-linecap="round" fill="none"/>
|
||||
<use href="#bowl"/>
|
||||
</symbol>
|
||||
<symbol id="item-dough-burnt" viewBox="0 0 32 32">
|
||||
<use href="#char"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ============================== patty ============================== -->
|
||||
<symbol id="item-patty-raw" viewBox="0 0 32 32">
|
||||
<ellipse cx="16" cy="19" rx="8.5" ry="5.5" fill="#8a5a3a"/>
|
||||
<ellipse cx="16" cy="17.4" rx="7" ry="4" fill="#a06c48"/>
|
||||
<path d="M11 16.5 c1.5 -1.5 8.5 -1.5 10 0" stroke="#6f462c" stroke-width="0.9" fill="none"/>
|
||||
</symbol>
|
||||
<symbol id="item-patty-chopped" viewBox="0 0 32 32">
|
||||
<use href="#grid-dice" style="color: #8a5a3a"/>
|
||||
</symbol>
|
||||
<symbol id="item-patty-cooking" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="10.8" r="2.8" fill="#8a5a3a"/>
|
||||
<use href="#pot-steam"/>
|
||||
</symbol>
|
||||
<symbol id="item-patty-cooked" viewBox="0 0 32 32">
|
||||
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#8a5a3a" stroke-width="2.6" stroke-linecap="round" fill="none"/>
|
||||
<use href="#bowl"/>
|
||||
</symbol>
|
||||
<symbol id="item-patty-burnt" viewBox="0 0 32 32">
|
||||
<use href="#char"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ============================== fish ============================== -->
|
||||
<symbol id="item-fish-raw" viewBox="0 0 32 32">
|
||||
<path d="M8 17 c2.5 -3.5 7 -5 11 -3.5 c2 0.8 3.5 2.4 4 3.5 c-0.5 1.1 -2 2.7 -4 3.5 c-4 1.5 -8.5 0 -11 -3.5 z" fill="#7fb2c9"/>
|
||||
<path d="M23 17 l5 -3.5 v7 z" fill="#7fb2c9"/>
|
||||
<circle cx="11.5" cy="16" r="1" fill="#3a322a"/>
|
||||
<path d="M15 14.5 c1.5 1.5 1.5 3.5 0 5 M18 14.8 c1.2 1.2 1.2 3.4 0 4.6" stroke="#5f95ad" stroke-width="0.9" fill="none"/>
|
||||
</symbol>
|
||||
<symbol id="item-fish-chopped" viewBox="0 0 32 32">
|
||||
<use href="#grid-dice" style="color: #7fb2c9"/>
|
||||
</symbol>
|
||||
<symbol id="item-fish-cooking" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="10.8" r="2.8" fill="#7fb2c9"/>
|
||||
<use href="#pot-steam"/>
|
||||
</symbol>
|
||||
<symbol id="item-fish-cooked" viewBox="0 0 32 32">
|
||||
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#7fb2c9" stroke-width="2.6" stroke-linecap="round" fill="none"/>
|
||||
<use href="#bowl"/>
|
||||
</symbol>
|
||||
<symbol id="item-fish-burnt" viewBox="0 0 32 32">
|
||||
<use href="#char"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ============================== rice ============================== -->
|
||||
<symbol id="item-rice-raw" viewBox="0 0 32 32">
|
||||
<ellipse cx="16" cy="19.5" rx="9" ry="5" fill="#e8dfc8"/>
|
||||
<circle cx="12" cy="18" r="1.8" fill="#f2ead8"/>
|
||||
<circle cx="16" cy="16" r="1.8" fill="#f2ead8"/>
|
||||
<circle cx="20" cy="18" r="1.8" fill="#f2ead8"/>
|
||||
<circle cx="14" cy="20.5" r="1.7" fill="#f2ead8"/>
|
||||
<circle cx="18.5" cy="20.5" r="1.7" fill="#f2ead8"/>
|
||||
</symbol>
|
||||
<symbol id="item-rice-cooking" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="10.8" r="2.8" fill="#f2ead8"/>
|
||||
<use href="#pot-steam"/>
|
||||
</symbol>
|
||||
<symbol id="item-rice-cooked" viewBox="0 0 32 32">
|
||||
<ellipse cx="16" cy="12.2" rx="7" ry="3" fill="#f2ead8"/>
|
||||
<use href="#bowl"/>
|
||||
</symbol>
|
||||
<symbol id="item-rice-burnt" viewBox="0 0 32 32">
|
||||
<use href="#char"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ============================== plate ============================== -->
|
||||
<symbol id="item-plate-raw" viewBox="0 0 32 32">
|
||||
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="#f6f1e7"/>
|
||||
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="none" stroke="#cfc5b5" stroke-width="1.2"/>
|
||||
<ellipse cx="16" cy="17" rx="5.5" ry="3.4" fill="none" stroke="#e2dbcb" stroke-width="1"/>
|
||||
</symbol>
|
||||
<symbol id="item-plate-dirty" viewBox="0 0 32 32">
|
||||
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="#f6f1e7"/>
|
||||
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="none" stroke="#cfc5b5" stroke-width="1.2"/>
|
||||
<ellipse cx="13" cy="15.5" rx="3" ry="1.8" fill="#9a8f7a" opacity="0.85"/>
|
||||
<ellipse cx="20" cy="18.5" rx="2.4" ry="1.5" fill="#8a7d5a" opacity="0.8"/>
|
||||
<ellipse cx="17" cy="13.8" rx="1.8" ry="1.1" fill="#7d8a5a" opacity="0.8"/>
|
||||
</symbol>
|
||||
<symbol id="item-plate-plated" viewBox="0 0 32 32">
|
||||
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="#f6f1e7"/>
|
||||
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="none" stroke="#cfc5b5" stroke-width="1.2"/>
|
||||
<ellipse cx="16" cy="16.4" rx="6.5" ry="3" fill="#8a5a3a"/>
|
||||
<path d="M10.5 15.4 c1 -2 8.5 -2 9.5 0 c0.5 1.2 -8.5 2.2 -9.5 0 z" fill="#7db85a"/>
|
||||
<circle cx="16" cy="13.4" r="2.6" fill="#e0533d"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,34 @@
|
||||
/* Undercooked palette (phase 07, task 01).
|
||||
*
|
||||
* The single source of every colour the DOM and the Canvas 2D renderer
|
||||
* both use. The canvas half re-exports the same hex values from
|
||||
* app/static/js/render/palette.js; tests/unit/test_assets.py asserts the
|
||||
* two files agree, so the CSS and the canvas can never drift apart.
|
||||
*
|
||||
* The four cook colours are what a player sees their own and their
|
||||
* teammates' cooks in; [data-palette="cb"] swaps them for the Okabe-Ito
|
||||
* set, which stays distinguishable under deuteranopia/protanopia. The
|
||||
* semantic colours name the game's state vocabulary (a danger reads red,
|
||||
* a ready reads green, dirt reads mud) and are shared by the CSS, the
|
||||
* JS module and the SVG var() fallbacks in app/static/assets/.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--uc-cook-1: #e0533d;
|
||||
--uc-cook-2: #3d84a8;
|
||||
--uc-cook-3: #7da84b;
|
||||
--uc-cook-4: #b0699a;
|
||||
--uc-danger: #ff5a4e;
|
||||
--uc-hot: #ffa63d;
|
||||
--uc-ready: #67d16c;
|
||||
--uc-dirty: #9a8f7a;
|
||||
}
|
||||
|
||||
/* Okabe-Ito cook colours: colourblind-safe (deutan/protan) without
|
||||
losing the "four different cooks" signal. */
|
||||
[data-palette="cb"] {
|
||||
--uc-cook-1: #d55e00;
|
||||
--uc-cook-2: #0072b2;
|
||||
--uc-cook-3: #009e73;
|
||||
--uc-cook-4: #cc79a7;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
|
||||
<!-- Undercooked station fixtures (phase 07, task 01). 32x32 symbols.
|
||||
The spawn shelf's stock circle is drawn neutral on purpose: the
|
||||
renderer tints it per ingredient with the atlas `tint` option. -->
|
||||
|
||||
<symbol id="station-spawn_shelf" viewBox="0 0 32 32">
|
||||
<rect x="4" y="5" width="24" height="4" rx="1.5" fill="#a5825a"/>
|
||||
<rect x="4" y="22" width="24" height="4" rx="1.5" fill="#a5825a"/>
|
||||
<rect x="6" y="9" width="3" height="13" fill="#8a6b45"/>
|
||||
<rect x="23" y="9" width="3" height="13" fill="#8a6b45"/>
|
||||
<circle cx="16" cy="15.5" r="6" fill="#cfc5b5"/>
|
||||
<circle cx="16" cy="15.5" r="6" fill="none" stroke="#8a6b45" stroke-width="1.2"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="station-chop_board" viewBox="0 0 32 32">
|
||||
<rect x="5" y="9" width="22" height="15" rx="3" fill="#c99e63"/>
|
||||
<rect x="5" y="9" width="22" height="15" rx="3" fill="none" stroke="#a5825a" stroke-width="1.4"/>
|
||||
<path d="M9 13 H23 M9 17 H23 M9 21 H23" stroke="#a5825a" stroke-width="0.9" fill="none"/>
|
||||
<path d="M7 6 L21 4 L21 6 L8 8 Z" fill="#dfe3e6"/>
|
||||
<rect x="21" y="3.6" width="6" height="2.8" rx="1.2" fill="#6b5a48"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="station-stove" viewBox="0 0 32 32">
|
||||
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
|
||||
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
|
||||
<circle cx="16" cy="16.5" r="7" fill="none" stroke="#6b625a" stroke-width="2.4"/>
|
||||
<circle cx="8.5" cy="24" r="1.4" fill="#6b625a"/>
|
||||
<circle cx="12.5" cy="24" r="1.4" fill="#6b625a"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="station-stove-off" viewBox="0 0 32 32">
|
||||
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
|
||||
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
|
||||
<circle cx="16" cy="16.5" r="7" fill="none" stroke="#6b625a" stroke-width="2.4"/>
|
||||
<circle cx="8.5" cy="24" r="1.4" fill="#6b625a"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="station-stove-working" viewBox="0 0 32 32">
|
||||
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
|
||||
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
|
||||
<path d="M12.5 20.5 q1.5 -2.5 3.5 0 q2 -2.5 3.5 0 z" fill="#ffa63d"/>
|
||||
<rect x="10" y="11" width="12" height="8" rx="2" fill="#8b8b8b"/>
|
||||
<rect x="9" y="9.6" width="14" height="2.6" rx="1.3" fill="#a3a3a3"/>
|
||||
<circle cx="16" cy="8.6" r="1.1" fill="#a3a3a3"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="station-stove-ready" viewBox="0 0 32 32">
|
||||
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
|
||||
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
|
||||
<rect x="10" y="11" width="12" height="8" rx="2" fill="#8b8b8b"/>
|
||||
<rect x="9" y="9.6" width="14" height="2.6" rx="1.3" fill="#a3a3a3"/>
|
||||
<circle cx="16" cy="8.6" r="1.1" fill="#a3a3a3"/>
|
||||
<path d="M12 5 q1 -1.6 0 -3 M16 4.6 q1 -1.6 0 -3 M20 5 q1 -1.6 0 -3" stroke="#e8e2d4" stroke-width="1.4" stroke-linecap="round" fill="none" opacity="0.85"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="station-stove-burning" viewBox="0 0 32 32">
|
||||
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
|
||||
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
|
||||
<rect x="11" y="12" width="10" height="7" rx="2" fill="#3b3b3b"/>
|
||||
<path d="M16 4.5 c2.6 2.2 4 4 4 6.2 a4 4 0 0 1 -8 0 c0 -2.2 1.4 -4 4 -6.2 z" fill="#ff5a4e"/>
|
||||
<path d="M16 8 c1.4 1.4 2.2 2.4 2.2 3.6 a2.2 2.2 0 0 1 -4.4 0 c0 -1.2 0.8 -2.2 2.2 -3.6 z" fill="#ffa63d"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="station-pot" viewBox="0 0 32 32">
|
||||
<rect x="8" y="12" width="16" height="11" rx="2.5" fill="#8b8b8b"/>
|
||||
<rect x="6.6" y="10.4" width="18.8" height="3" rx="1.5" fill="#a3a3a3"/>
|
||||
<circle cx="16" cy="9.2" r="1.4" fill="#a3a3a3"/>
|
||||
<rect x="4.6" y="14" width="3.4" height="2.4" rx="1.2" fill="#6f6a63"/>
|
||||
<rect x="24" y="14" width="3.4" height="2.4" rx="1.2" fill="#6f6a63"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="station-pan" viewBox="0 0 32 32">
|
||||
<ellipse cx="14" cy="17" rx="9.5" ry="6" fill="#55504a"/>
|
||||
<ellipse cx="14" cy="15.8" rx="8" ry="4.6" fill="#6b625a"/>
|
||||
<rect x="22" y="14.6" width="8" height="3" rx="1.5" fill="#6b5a48"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="station-prep" viewBox="0 0 32 32">
|
||||
<rect x="4" y="10" width="24" height="15" rx="2.5" fill="#d8c49a"/>
|
||||
<rect x="4" y="10" width="24" height="15" rx="2.5" fill="none" stroke="#a5825a" stroke-width="1.4"/>
|
||||
<ellipse cx="13" cy="17.5" rx="6" ry="3.4" fill="#f6f1e7"/>
|
||||
<ellipse cx="13" cy="17.5" rx="6" ry="3.4" fill="none" stroke="#cfc5b5" stroke-width="1"/>
|
||||
<circle cx="24" cy="17.5" r="3.4" fill="#8b8b8b"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="station-serve" viewBox="0 0 32 32">
|
||||
<rect x="4" y="8" width="24" height="18" rx="2.5" fill="#d8c49a"/>
|
||||
<rect x="7" y="12" width="18" height="10" rx="1.8" fill="#4a4238"/>
|
||||
<path d="M11 17 h10" stroke="#f6f1e7" stroke-width="1.6" stroke-linecap="round" fill="none"/>
|
||||
<path d="M16 4.5 a3 3 0 0 1 3 3 v1 h-6 v-1 a3 3 0 0 1 3 -3 z" fill="#ffd95e"/>
|
||||
<circle cx="16" cy="3.4" r="1" fill="#ffd95e"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="station-sink" viewBox="0 0 32 32">
|
||||
<rect x="5" y="9" width="22" height="16" rx="2.5" fill="#b9b9b9"/>
|
||||
<rect x="8" y="12" width="16" height="10" rx="2" fill="#8f8f8f"/>
|
||||
<circle cx="16" cy="18.5" r="1.5" fill="#6f6f6f"/>
|
||||
<path d="M22 6 v4 a3 3 0 0 1 -3 3 h-2" stroke="#a3a3a3" stroke-width="2.4" stroke-linecap="round" fill="none"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="station-trash" viewBox="0 0 32 32">
|
||||
<path d="M10 9 L22 9 L20.5 27 L11.5 27 Z" fill="#7a7f85"/>
|
||||
<rect x="8.4" y="5.6" width="15.2" height="3.4" rx="1.4" fill="#9aa0a6"/>
|
||||
<path d="M13.4 12 V24 M16 12 V24 M18.6 12 V24" stroke="#62676d" stroke-width="1.3" fill="none"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,61 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
|
||||
<!-- Undercooked tileset (phase 07, task 01). One <symbol> per drawable,
|
||||
32x32 viewBox, hand-authored vector cartoon. Served from
|
||||
app/static/assets/ and indexed by index.json. -->
|
||||
|
||||
<symbol id="tile-floor" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#b98a5c"/>
|
||||
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
|
||||
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="tile-floor-1" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#b98a5c"/>
|
||||
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
|
||||
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
|
||||
<ellipse cx="24" cy="5" rx="2.6" ry="1.7" fill="#a1734c"/>
|
||||
<ellipse cx="24" cy="5" rx="1.1" ry="0.7" fill="#8d6340"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="tile-floor-2" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#b98a5c"/>
|
||||
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
|
||||
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
|
||||
<path d="M3 15 L8 15 M25 25 L30 25 M5 27 L10 27" stroke="#ab7d52" stroke-width="1.4" stroke-linecap="round" fill="none"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="tile-floor-3" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#b98a5c"/>
|
||||
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
|
||||
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
|
||||
<rect x="13" y="13.5" width="6" height="6" rx="1.6" fill="#c4976b"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="tile-floor-4" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#b98a5c"/>
|
||||
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
|
||||
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
|
||||
<circle cx="6" cy="6" r="0.9" fill="#ab7d52"/>
|
||||
<circle cx="27" cy="16" r="0.9" fill="#ab7d52"/>
|
||||
<circle cx="12" cy="27" r="0.9" fill="#ab7d52"/>
|
||||
<circle cx="24" cy="28" r="0.7" fill="#ab7d52"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="tile-counter" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#d8c49a"/>
|
||||
<rect y="24" width="32" height="8" fill="#a5825a"/>
|
||||
<rect y="22" width="32" height="3" fill="#8a6b45"/>
|
||||
<path d="M3 3 H29" stroke="#c4b088" stroke-width="1.4" stroke-linecap="round" fill="none"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="tile-wall" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#6b5a48"/>
|
||||
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#5a4b3b" stroke-width="1.5" fill="none"/>
|
||||
<path d="M16 0 V10.5 M8 10.5 V21.5 M24 10.5 V21.5 M16 21.5 V32" stroke="#5a4b3b" stroke-width="1.5" fill="none"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="tile-void" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#241f1a"/>
|
||||
<path d="M-4 36 L36 -4 M-4 24 L24 -4 M8 36 L36 8 M20 36 L36 20" stroke="#3a322a" stroke-width="1.4" fill="none"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
|
||||
<!-- Undercooked HUD symbols (phase 07, task 01): the order ticket,
|
||||
the result stars, the host crown, the ready check, the
|
||||
disconnected-cook ghost and the streak flame. -->
|
||||
|
||||
<symbol id="ui-ticket" viewBox="0 0 32 32">
|
||||
<path d="M7 5 h18 v22 l-3 -2.4 -3 2.4 -3 -2.4 -3 2.4 -3 -2.4 -3 2.4 z" fill="#f6f1e7"/>
|
||||
<path d="M7 5 h18 v5 H7 z" fill="#ffd95e"/>
|
||||
<path d="M10.5 15 h11 M10.5 19 h11 M10.5 23 h7" stroke="#9a8f7a" stroke-width="1.5" stroke-linecap="round" fill="none"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="ui-star" viewBox="0 0 32 32">
|
||||
<path d="M16 3.5 l3.8 7.7 8.5 1.2 -6.2 6 1.5 8.4 -7.6 -4 -7.6 4 1.5 -8.4 -6.2 -6 8.5 -1.2 z" fill="#ffd95e"/>
|
||||
<path d="M16 3.5 l3.8 7.7 8.5 1.2 -6.2 6 1.5 8.4 -7.6 -4 -7.6 4 1.5 -8.4 -6.2 -6 8.5 -1.2 z" fill="none" stroke="#d9a92e" stroke-width="1.2"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="ui-crown" viewBox="0 0 32 32">
|
||||
<path d="M6 22 L8 10 l5 5 3 -7 3 7 5 -5 2 12 z" fill="#ffd95e"/>
|
||||
<rect x="6" y="22" width="20" height="4" rx="1.6" fill="#d9a92e"/>
|
||||
<circle cx="8" cy="9.4" r="1.5" fill="#d9a92e"/>
|
||||
<circle cx="16" cy="6.4" r="1.5" fill="#d9a92e"/>
|
||||
<circle cx="24" cy="9.4" r="1.5" fill="#d9a92e"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="ui-ready" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="11" fill="#67d16c"/>
|
||||
<path d="M10.5 16.5 L14.5 20.5 L21.5 12.5" stroke="#f6f1e7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="ui-ghost" viewBox="0 0 32 32">
|
||||
<path d="M9 26 V15 a7 7 0 0 1 14 0 v11 l-2.3 -2 -2.4 2 -2.3 -2 -2.3 2 -2.4 -2 z" fill="#f6f1e7" opacity="0.55"/>
|
||||
<circle cx="13.5" cy="14.5" r="1.4" fill="#4a4238"/>
|
||||
<circle cx="18.5" cy="14.5" r="1.4" fill="#4a4238"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="ui-streak" viewBox="0 0 32 32">
|
||||
<path d="M16 5 c3 2.8 4.8 5.2 4.8 8 a4.8 4.8 0 0 1 -9.6 0 c0 -2.8 1.8 -5.2 4.8 -8 z" fill="#ffa63d"/>
|
||||
<path d="M16 10 c1.5 1.6 2.6 2.9 2.6 4.3 a2.6 2.6 0 0 1 -5.2 0 c0 -1.4 1.1 -2.7 2.6 -4.3 z" fill="#fff7d6"/>
|
||||
<path d="M9 22.5 q-1.4 1.8 0 3 M23 22.5 q1.4 1.8 0 3" stroke="#ffa63d" stroke-width="1.6" stroke-linecap="round" fill="none"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,662 @@
|
||||
/* Undercooked — screen styles (phase 07 task 02).
|
||||
*
|
||||
* Rules this file must keep (base.css's, extended):
|
||||
* - every byte is same-origin: no @import, no web fonts, no remote images (A12);
|
||||
* - the palette, spacing and type scale exist only as base.css custom properties,
|
||||
* so a `[data-palette]` swap (e.g. `data-palette="cb"` for the colourblind-safe
|
||||
* alternate in assets/palette.css) recolours the screens automatically;
|
||||
* - 44x44 minimum hit targets (base.css --uc-hit) on every interactive control;
|
||||
* - the layout is fluid from a 360px phone to a 4K desktop without breakpoints;
|
||||
* - motion is decorative: prefers-reduced-motion turns all of it off;
|
||||
* - visible focus rings: base.css's :focus-visible rule covers every element here.
|
||||
*/
|
||||
|
||||
.screen-root {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
/* The screens share the phase-01 panel skin; each owns its content. */
|
||||
.panel.lobby,
|
||||
.panel.result,
|
||||
.panel.error,
|
||||
.panel.connecting,
|
||||
.panel.countdown {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
margin: 0;
|
||||
font-size: var(--uc-step--1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--uc-ink-muted);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- landing */
|
||||
|
||||
.join-field {
|
||||
display: grid;
|
||||
gap: var(--uc-space-1);
|
||||
flex: 1 1 10rem;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- connecting */
|
||||
|
||||
.connecting {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#connecting-text {
|
||||
margin: 0;
|
||||
font-size: var(--uc-step-1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- lobby */
|
||||
|
||||
.lobby-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--uc-space-2);
|
||||
}
|
||||
|
||||
.room-code {
|
||||
margin: 0;
|
||||
font-family: var(--uc-font-ui);
|
||||
font-size: var(--uc-step-2);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--uc-accent);
|
||||
}
|
||||
|
||||
.code-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--uc-space-2);
|
||||
}
|
||||
|
||||
.seat-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--uc-space-1);
|
||||
}
|
||||
|
||||
.seat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--uc-space-1);
|
||||
min-height: calc(var(--uc-hit) - 12px);
|
||||
padding: 0 var(--uc-space-2);
|
||||
background: var(--uc-bg);
|
||||
border: 1px solid var(--uc-line);
|
||||
border-radius: var(--uc-radius);
|
||||
}
|
||||
|
||||
.chip {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
border: 1px solid var(--uc-line);
|
||||
}
|
||||
|
||||
/* The 4-colour seat palette (server/seats.py `SEAT_PALETTE`,
|
||||
* docs/PROTOCOL.md "Lobby"). Colours ride on classes, not inline
|
||||
* `style` attributes — the CSP's `style-src 'self'` blocks the
|
||||
* latter (a console error). */
|
||||
.chip-red {
|
||||
background: #e5484d;
|
||||
}
|
||||
|
||||
.chip-blue {
|
||||
background: #3b9eff;
|
||||
}
|
||||
|
||||
.chip-green {
|
||||
background: #3ddc84;
|
||||
}
|
||||
|
||||
.chip-amber {
|
||||
background: #ffb224;
|
||||
}
|
||||
|
||||
.seat-index {
|
||||
color: var(--uc-ink-muted);
|
||||
font-size: var(--uc-step--1);
|
||||
}
|
||||
|
||||
.seat-name {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.crown {
|
||||
color: var(--uc-accent);
|
||||
font-size: var(--uc-step-1);
|
||||
}
|
||||
|
||||
.ready-badge,
|
||||
.ghost-badge {
|
||||
font-size: var(--uc-step--1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--uc-radius);
|
||||
border: 1px solid var(--uc-line);
|
||||
}
|
||||
|
||||
.ready-badge {
|
||||
color: var(--uc-accent-ink);
|
||||
background: var(--uc-accent);
|
||||
border-color: var(--uc-accent);
|
||||
}
|
||||
|
||||
.ghost-badge {
|
||||
color: var(--uc-ink-muted);
|
||||
}
|
||||
|
||||
.lobby-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--uc-space-2);
|
||||
}
|
||||
|
||||
#ready-btn {
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.level-pick {
|
||||
display: grid;
|
||||
gap: var(--uc-space-1);
|
||||
}
|
||||
|
||||
#level-picker {
|
||||
min-height: var(--uc-hit);
|
||||
padding: 0 var(--uc-space-2);
|
||||
background: var(--uc-bg);
|
||||
color: var(--uc-ink);
|
||||
border: 1px solid var(--uc-line);
|
||||
border-radius: var(--uc-radius);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#level-picker:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#start-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.session {
|
||||
margin-top: var(--uc-space-2);
|
||||
}
|
||||
|
||||
.session h3 {
|
||||
margin: 0 0 var(--uc-space-1);
|
||||
font-size: var(--uc-step-0);
|
||||
}
|
||||
|
||||
.session p,
|
||||
.session li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bests {
|
||||
margin: var(--uc-space-1) 0 0;
|
||||
padding-left: 1.25rem;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
#spectator-note {
|
||||
margin: var(--uc-space-2) 0 0;
|
||||
}
|
||||
|
||||
#lobby-banner {
|
||||
margin: var(--uc-space-2) 0 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- countdown */
|
||||
|
||||
.countdown {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.countdown-num {
|
||||
margin: 0;
|
||||
font-family: var(--uc-font-ui);
|
||||
font-size: clamp(5rem, 20vw, 11rem);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: var(--uc-accent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.countdown-num {
|
||||
animation: countdown-pulse 1s var(--uc-ease);
|
||||
}
|
||||
|
||||
@keyframes countdown-pulse {
|
||||
from {
|
||||
transform: scale(1.25);
|
||||
opacity: 0.4;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- play */
|
||||
|
||||
.play-screen {
|
||||
display: grid;
|
||||
gap: var(--uc-space-2);
|
||||
width: 100%;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.play-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--uc-space-2);
|
||||
}
|
||||
|
||||
#play-banner {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#kcanvas {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 1;
|
||||
background: var(--uc-bg);
|
||||
border: 1px solid var(--uc-line);
|
||||
border-radius: var(--uc-radius);
|
||||
}
|
||||
|
||||
.hud {
|
||||
min-height: calc(var(--uc-hit) - 8px);
|
||||
}
|
||||
|
||||
/* The HUD (task 04): the order rail, the score bar, the timer, the
|
||||
status row. The timer's colour bands carry a non-colour cue too:
|
||||
`low` (amber) and `critical` (red) each add a striped background
|
||||
and a label word (`hurry` / `now`). */
|
||||
|
||||
.hud-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 1fr) minmax(10rem, 1fr) auto;
|
||||
gap: var(--uc-space-2);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.hud-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.hud-orders .section-label {
|
||||
margin: 0 0 var(--uc-space-1);
|
||||
}
|
||||
|
||||
.ticket-list {
|
||||
display: grid;
|
||||
gap: var(--uc-space-1);
|
||||
}
|
||||
|
||||
.ticket {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
gap: 2px var(--uc-space-1);
|
||||
padding: var(--uc-space-1) var(--uc-space-2);
|
||||
background: var(--uc-surface-2);
|
||||
border: 1px solid var(--uc-line);
|
||||
border-radius: var(--uc-radius);
|
||||
}
|
||||
|
||||
.ticket.oldest {
|
||||
border-color: var(--uc-accent);
|
||||
box-shadow: 0 0 0 1px var(--uc-accent);
|
||||
}
|
||||
|
||||
.ticket-icon {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.ticket-pts {
|
||||
font-family: var(--uc-font-ui);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ticket-bar {
|
||||
grid-column: 2 / 4;
|
||||
height: 0.4rem;
|
||||
background: var(--uc-bg);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ticket-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--uc-accent);
|
||||
}
|
||||
|
||||
.hud-score-line {
|
||||
margin: 0;
|
||||
font-family: var(--uc-font-ui);
|
||||
}
|
||||
|
||||
.hud-streak {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: var(--uc-space-1);
|
||||
color: var(--uc-accent);
|
||||
}
|
||||
|
||||
.hud-streak img {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
.score-bar {
|
||||
margin-top: var(--uc-space-1);
|
||||
height: 0.5rem;
|
||||
background: var(--uc-bg);
|
||||
border: 1px solid var(--uc-line);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.score-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--uc-accent);
|
||||
}
|
||||
|
||||
.hud-timer {
|
||||
padding: var(--uc-space-1) var(--uc-space-2);
|
||||
border: 1px solid var(--uc-line);
|
||||
border-radius: var(--uc-radius);
|
||||
font-family: var(--uc-font-ui);
|
||||
font-size: var(--uc-step-1);
|
||||
min-width: 5.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The non-colour halves: the striped background + the label word. */
|
||||
|
||||
.hud-timer-low {
|
||||
color: var(--uc-accent);
|
||||
border-color: var(--uc-accent);
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent 0 0.5rem,
|
||||
color-mix(in srgb, var(--uc-accent) 18%, transparent) 0.5rem 1rem
|
||||
);
|
||||
}
|
||||
|
||||
.hud-timer-critical {
|
||||
color: var(--uc-danger);
|
||||
border-color: var(--uc-danger);
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent 0 0.5rem,
|
||||
color-mix(in srgb, var(--uc-danger) 22%, transparent) 0.5rem 1rem
|
||||
);
|
||||
}
|
||||
|
||||
.hud-timer-label {
|
||||
margin-left: 0.4rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: var(--uc-step--1);
|
||||
}
|
||||
|
||||
.hud-meta {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.hud-dirty,
|
||||
.hud-conn {
|
||||
font-family: var(--uc-font-ui);
|
||||
}
|
||||
|
||||
.hud-conn.is-live {
|
||||
color: var(--uc-ink-muted);
|
||||
}
|
||||
|
||||
.hud-conn.is-down {
|
||||
color: var(--uc-danger);
|
||||
}
|
||||
|
||||
#hud-spectator {
|
||||
margin: 0;
|
||||
color: var(--uc-accent);
|
||||
}
|
||||
|
||||
.emote-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--uc-space-1);
|
||||
}
|
||||
|
||||
.emote {
|
||||
min-width: var(--uc-hit);
|
||||
min-height: var(--uc-hit);
|
||||
padding: 0 var(--uc-space-2);
|
||||
font-size: var(--uc-step-1);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--uc-bg) 82%, transparent);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.overlay p {
|
||||
margin: 0;
|
||||
padding: var(--uc-space-3);
|
||||
background: var(--uc-surface);
|
||||
border: 1px solid var(--uc-line);
|
||||
border-radius: var(--uc-radius);
|
||||
font-size: var(--uc-step-1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- result */
|
||||
|
||||
.result {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#result-word {
|
||||
font-size: var(--uc-step-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stars {
|
||||
margin: var(--uc-space-1) 0;
|
||||
font-size: var(--uc-step-2);
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.star.filled {
|
||||
color: var(--uc-accent);
|
||||
}
|
||||
|
||||
.star {
|
||||
color: var(--uc-line);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.star.filled {
|
||||
animation: star-pop 600ms var(--uc-ease);
|
||||
}
|
||||
|
||||
@keyframes star-pop {
|
||||
from {
|
||||
transform: scale(0.3);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#score-line,
|
||||
#best-line,
|
||||
#counters-line {
|
||||
margin: var(--uc-space-1) 0;
|
||||
}
|
||||
|
||||
#result-players {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--uc-space-2);
|
||||
margin-top: var(--uc-space-2);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- error */
|
||||
|
||||
.error h2 {
|
||||
margin: 0 0 var(--uc-space-1);
|
||||
}
|
||||
|
||||
#error-detail,
|
||||
#error-code {
|
||||
margin: 0 0 var(--uc-space-2);
|
||||
}
|
||||
|
||||
#error-code {
|
||||
color: var(--uc-ink-muted);
|
||||
font-family: var(--uc-font-ui);
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--uc-space-2);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- shared */
|
||||
|
||||
/* Screen-reader-only: the result announcement, focusable nowhere. */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- help (task 05) */
|
||||
|
||||
/* The `?`-key in-game help: a body-level fixed overlay over the panel
|
||||
skin, dismissed by its Close button, the backdrop click, or the
|
||||
keyboard's `?`/`Esc` (the module's `Esc` branch defers to it while
|
||||
it is open). The rows are `getKeyMap()`'s (the README's controls
|
||||
section renders the same thing). */
|
||||
.help-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--uc-bg) 72%, transparent);
|
||||
}
|
||||
|
||||
.help-panel {
|
||||
width: min(30rem, calc(100vw - 2 * var(--uc-space-3)));
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.help-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--uc-space-2);
|
||||
}
|
||||
|
||||
.help-heading {
|
||||
font-size: var(--uc-step-1);
|
||||
}
|
||||
|
||||
.help-rows {
|
||||
display: grid;
|
||||
gap: var(--uc-space-1);
|
||||
}
|
||||
|
||||
.help-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--uc-space-2);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.help-keys {
|
||||
font-family: var(--uc-font-ui);
|
||||
font-size: var(--uc-step--1);
|
||||
color: var(--uc-accent);
|
||||
background: var(--uc-surface-2);
|
||||
border: 1px solid var(--uc-line);
|
||||
border-radius: calc(var(--uc-radius) / 2);
|
||||
padding: 0.1rem 0.45rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.help-action {
|
||||
color: var(--uc-ink);
|
||||
}
|
||||
|
||||
.help-foot {
|
||||
margin: 0;
|
||||
color: var(--uc-ink-muted);
|
||||
font-size: var(--uc-step--1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.help-overlay {
|
||||
animation: uc-help-fade 120ms var(--uc-ease);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes uc-help-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
+8
-42
@@ -13,6 +13,7 @@
|
||||
while keeping every byte same-origin (PLAN.md A12). -->
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="/static/css/base.css" />
|
||||
<link rel="stylesheet" href="/static/css/screens.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="shell">
|
||||
@@ -21,47 +22,12 @@
|
||||
<p class="tagline">Anonymous co-op kitchen chaos. No accounts, no install, 1–4 cooks.</p>
|
||||
</header>
|
||||
|
||||
<section id="screen-lobby" class="panel" aria-labelledby="lobby-heading">
|
||||
<h2 id="lobby-heading">Kitchen lobby</h2>
|
||||
<p id="lobby-help">
|
||||
Create a kitchen and read its 5-character code out loud, or join a kitchen that is
|
||||
already open. Nothing is saved and nothing is asked for.
|
||||
</p>
|
||||
|
||||
<button id="create-lobby" class="btn btn-primary" type="button">Create lobby</button>
|
||||
|
||||
<form id="join-form" class="join" autocomplete="off">
|
||||
<label for="join-code">Room code</label>
|
||||
<div class="join-row">
|
||||
<input
|
||||
id="join-code"
|
||||
name="code"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocapitalize="characters"
|
||||
spellcheck="false"
|
||||
size="5"
|
||||
maxlength="5"
|
||||
placeholder="ABCDE"
|
||||
aria-describedby="join-hint"
|
||||
required
|
||||
/>
|
||||
<button class="btn" type="submit">Join</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="join-hint" class="hint">
|
||||
Five characters, letters and digits, never <code>I</code>, <code>L</code>, <code>O</code>
|
||||
or <code>U</code>.
|
||||
</p>
|
||||
|
||||
<div id="status" class="status" role="status" aria-live="polite">
|
||||
Pick a kitchen to start.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The renderer (phase 07) sizes and unhides this canvas; it stays out of
|
||||
the layout and the a11y tree until then. -->
|
||||
<canvas id="kcanvas" hidden aria-hidden="true"></canvas>
|
||||
<!-- The screen state machine (app/static/js/app.js) mounts exactly one
|
||||
screen here at a time: landing, connecting, lobby, countdown,
|
||||
playing, round_end or error. The playing screen owns #kcanvas
|
||||
(hidden until the phase-07 task-04 renderer sizes and unhides it)
|
||||
and the #hud root. -->
|
||||
<div id="screen-root" class="screen-root"></div>
|
||||
</main>
|
||||
|
||||
<footer class="colophon">
|
||||
@@ -71,6 +37,6 @@
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
<script type="module" src="/static/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+411
-112
@@ -1,134 +1,433 @@
|
||||
// Undercooked — client shell entry point (phase 01).
|
||||
// Undercooked — the screen state machine (phase 07 task 02; the
|
||||
// composition root — URL params, the snapshot buffer, the render
|
||||
// loop, the wire routing — is `main.js`, phase 07 task 03).
|
||||
//
|
||||
// There is no game logic here on purpose. Phase 05 shipped the server-side
|
||||
// room shell (supervisor, room FSM, session store); phase 06 adds the WebSocket
|
||||
// transport and phase 07 replaces the placeholder button handlers with the lobby
|
||||
// flow and adds render/ (Canvas 2D). What this file owns today:
|
||||
// * the anonymous per-tab session token (PLAN.md A16),
|
||||
// * the last room code typed in this tab, so a reload can rejoin fast,
|
||||
// * mounting the lobby screen and announcing state into #status.
|
||||
// It is a plain ES module served from /static/ — no bundler, no framework, no
|
||||
// remote import (PLAN.md A10/A12).
|
||||
// `createApp()` owns the one client-side state machine: an explicit
|
||||
// screen enum (`LANDING, CONNECTING, LOBBY, COUNTDOWN, PLAYING,
|
||||
// ROUND_END, ERROR`), `show(screen, data)`, and the message reducer
|
||||
// that maps the wire contract (docs/PROTOCOL.md) onto screens —
|
||||
// `welcome`/`lobby` by the room's state, `countdown` → COUNTDOWN,
|
||||
// `snap` → PLAYING, `round` → ROUND_END, fatal `err` codes → ERROR.
|
||||
// Every screen is a module exporting `{ mount, unmount }` (plus an
|
||||
// optional same-screen `update`); no global DOM lives outside them.
|
||||
//
|
||||
// `identity.js` keeps the per-tab identity (PLAN A16): the anonymous
|
||||
// session token and the last room code, both in `sessionStorage`.
|
||||
// The app instance is exposed as `window.__undercooked` — the documented test seam the
|
||||
// e2e suites drive (and the task-05 keyboard module will feed).
|
||||
//
|
||||
// It is a plain ES module served from /static/ — no bundler, no
|
||||
// framework, no remote import (PLAN A10/A12).
|
||||
|
||||
const TOKEN_KEY = "uc.token";
|
||||
const LAST_CODE_KEY = "uc.lastCode";
|
||||
const CODE_LENGTH = 5;
|
||||
const NOT_IMPLEMENTED = "Not implemented until phase 07.";
|
||||
const READY_MESSAGE = "Pick a kitchen to start.";
|
||||
import { createConnection, FATAL_ERR_CODES } from "./net/connection.js";
|
||||
import { getToken, lastCode, rememberCode, randomToken } from "./identity.js";
|
||||
import * as landingScreen from "./screens/landing.js";
|
||||
import * as connectingScreen from "./screens/connecting.js";
|
||||
import * as lobbyScreen from "./screens/lobby.js";
|
||||
import * as countdownScreen from "./screens/countdown.js";
|
||||
import * as playScreen from "./screens/play.js";
|
||||
import * as roundEndScreen from "./screens/round_end.js";
|
||||
import * as errorScreen from "./screens/error.js";
|
||||
|
||||
/** Base64url without padding, so the token is safe in a query string or header. */
|
||||
function base64url(bytes) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
export { randomToken, getToken, lastCode, rememberCode };
|
||||
|
||||
/** A random, unguessable id for this browser tab (identity, not an account). */
|
||||
export function randomToken() {
|
||||
const bytes = new Uint8Array(24);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return base64url(bytes);
|
||||
}
|
||||
/** The screen enum (the task's list, frozen). */
|
||||
export const SCREENS = Object.freeze({
|
||||
LANDING: "landing",
|
||||
CONNECTING: "connecting",
|
||||
LOBBY: "lobby",
|
||||
COUNTDOWN: "countdown",
|
||||
PLAYING: "playing",
|
||||
ROUND_END: "round_end",
|
||||
ERROR: "error",
|
||||
});
|
||||
|
||||
function read(storage, key) {
|
||||
try {
|
||||
return storage.getItem(key);
|
||||
} catch {
|
||||
return null; // private browsing / storage disabled: stay functional for this tab only
|
||||
}
|
||||
}
|
||||
const SCREEN_MODULES = {
|
||||
landing: landingScreen,
|
||||
connecting: connectingScreen,
|
||||
lobby: lobbyScreen,
|
||||
countdown: countdownScreen,
|
||||
playing: playScreen,
|
||||
round_end: roundEndScreen,
|
||||
error: errorScreen,
|
||||
};
|
||||
|
||||
function write(storage, key, value) {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
} catch {
|
||||
/* nothing to persist to; the in-memory value still works */
|
||||
}
|
||||
}
|
||||
/** The room-state value → screen (the `welcome`/`lobby` mapping half). */
|
||||
const SCREEN_BY_ROOM_STATE = {
|
||||
lobby: SCREENS.LOBBY,
|
||||
countdown: SCREENS.COUNTDOWN,
|
||||
playing: SCREENS.PLAYING,
|
||||
round_end: SCREENS.ROUND_END,
|
||||
closed: SCREENS.ERROR,
|
||||
};
|
||||
|
||||
/** The tab's session token, created once per tab and kept in sessionStorage. */
|
||||
export function getToken(storage = globalThis.sessionStorage) {
|
||||
const existing = read(storage, TOKEN_KEY);
|
||||
if (typeof existing === "string" && existing.length > 0) return existing;
|
||||
const token = randomToken();
|
||||
write(storage, TOKEN_KEY, token);
|
||||
return token;
|
||||
}
|
||||
const LOG_CAP = 2000;
|
||||
|
||||
/** Uppercase what the user typed and keep only characters a code can contain. */
|
||||
export function normalizeCode(value) {
|
||||
return String(value ?? "")
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9A-Z]/g, "")
|
||||
.slice(0, CODE_LENGTH);
|
||||
}
|
||||
export function createApp(root = document, options = {}) {
|
||||
const { onWireMessage = null } = options;
|
||||
const screenRoot = root.getElementById("screen-root");
|
||||
const app = {
|
||||
token: getToken(),
|
||||
lastCode: lastCode(),
|
||||
screen: null, // set by the first show()
|
||||
data: {},
|
||||
roomCode: "",
|
||||
welcome: null,
|
||||
you: null, // the viewer's seat (YouWire)
|
||||
spectator: false, // joined mid-round (or the token holds no seat)
|
||||
lobby: null, // the latest `lobby` frame
|
||||
round: null, // the latest `round` frame
|
||||
lastSnapshot: null, // the latest `snap` raw body (the render path is task 03's buffer; this view is the DOM path the screens and the choreography read)
|
||||
session: null, // the `/api/session` projection
|
||||
levels: null, // the `/api/levels` cards (null = not fetched yet)
|
||||
view: makeView(), // the merged snapshot view (full frames reset, deltas patch)
|
||||
error: null, // the fatal `err` frame, when one caused ERROR
|
||||
banner: null, // non-fatal `err` text for the mounted screen
|
||||
messageLog: [], // bounded wire-census for the e2e suites
|
||||
leaving: false, // we closed the socket on purpose
|
||||
};
|
||||
const connection = createConnection();
|
||||
const warnedUnknown = new Set();
|
||||
let mounted = null;
|
||||
|
||||
export function lastCode(storage = globalThis.sessionStorage) {
|
||||
return read(storage, LAST_CODE_KEY) || "";
|
||||
}
|
||||
|
||||
export function rememberCode(code, storage = globalThis.sessionStorage) {
|
||||
write(storage, LAST_CODE_KEY, normalizeCode(code));
|
||||
}
|
||||
|
||||
/** Write the live region. `kind` is "info" | "error" and only changes styling. */
|
||||
export function announce(message, root = document, kind = "info") {
|
||||
const region = root.getElementById("status");
|
||||
if (!region) return;
|
||||
region.textContent = message;
|
||||
region.dataset.kind = kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the lobby screen. Returns the tab identity so later phases can reuse it.
|
||||
*/
|
||||
export function mount(root = document) {
|
||||
const token = getToken();
|
||||
const createButton = root.getElementById("create-lobby");
|
||||
const joinForm = root.getElementById("join-form");
|
||||
const joinInput = root.getElementById("join-code");
|
||||
|
||||
const remembered = lastCode();
|
||||
if (joinInput && remembered) joinInput.value = remembered;
|
||||
|
||||
if (createButton) {
|
||||
createButton.addEventListener("click", () => {
|
||||
// Phase 07 (on the phase-06 transport): POST to the room supervisor,
|
||||
// open /ws/{code}.
|
||||
announce(NOT_IMPLEMENTED, root);
|
||||
});
|
||||
/**
|
||||
* The merged snapshot view — the task-02 seed of task 03's buffer: a
|
||||
* full frame resets the entity maps, a delta patches them (an upsert on
|
||||
* the identity key, a `gone` tombstone drops). The stream's full cadence
|
||||
* (every 20 encoded frames) plus the fresh-connection full keeps this
|
||||
* resyncable; a client-side drift is what task 03's decoder handles.
|
||||
*/
|
||||
function makeView() {
|
||||
return { players: {}, stations: {}, orders: {} };
|
||||
}
|
||||
|
||||
if (joinForm) {
|
||||
joinForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const code = normalizeCode(joinInput ? joinInput.value : "");
|
||||
if (joinInput) joinInput.value = code;
|
||||
if (code.length !== CODE_LENGTH) {
|
||||
announce(`A room code is ${CODE_LENGTH} characters.`, root, "error");
|
||||
if (joinInput) joinInput.focus();
|
||||
return;
|
||||
function stationKey(entry) {
|
||||
return `${entry.k}@${entry.x},${entry.y}`;
|
||||
}
|
||||
|
||||
function applySnapshot(raw) {
|
||||
app.lastSnapshot = raw;
|
||||
const view = app.view;
|
||||
if (raw.full) {
|
||||
view.players = {};
|
||||
view.stations = {};
|
||||
view.orders = {};
|
||||
for (const entry of raw.p ?? []) view.players[entry.i] = entry;
|
||||
for (const entry of raw.st ?? []) view.stations[stationKey(entry)] = entry;
|
||||
for (const entry of raw.o ?? []) view.orders[entry.id] = entry;
|
||||
return;
|
||||
}
|
||||
const delta = raw.d ?? {};
|
||||
for (const entry of delta.p ?? []) {
|
||||
if (entry.gone) delete view.players[entry.i];
|
||||
else view.players[entry.i] = entry;
|
||||
}
|
||||
for (const entry of delta.st ?? []) {
|
||||
const key = stationKey(entry);
|
||||
if (entry.gone) delete view.stations[key];
|
||||
else view.stations[key] = entry;
|
||||
}
|
||||
for (const entry of delta.o ?? []) {
|
||||
if (entry.gone) delete view.orders[entry.id];
|
||||
else view.orders[entry.id] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
function pushLog(frame) {
|
||||
app.messageLog.push(frame);
|
||||
if (app.messageLog.length > LOG_CAP) app.messageLog.shift();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show one screen. 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 live controls).
|
||||
*/
|
||||
function show(screen, data = {}) {
|
||||
if (!(screen in SCREEN_MODULES)) {
|
||||
if (!warnedUnknown.has(screen)) {
|
||||
console.warn(
|
||||
`undercooked: unknown screen "${String(screen)}" — going to the error screen`,
|
||||
);
|
||||
warnedUnknown.add(screen);
|
||||
}
|
||||
rememberCode(code);
|
||||
// Phase 07 (on the phase-06 transport): join the room over the
|
||||
// WebSocket.
|
||||
announce(NOT_IMPLEMENTED, root);
|
||||
});
|
||||
screen = SCREENS.ERROR;
|
||||
}
|
||||
app.data = data;
|
||||
if (app.screen === screen && mounted !== null) {
|
||||
if (typeof mounted.update === "function") mounted.update(data, app);
|
||||
return screen;
|
||||
}
|
||||
if (mounted !== null && typeof mounted.unmount === "function") mounted.unmount();
|
||||
mounted = null;
|
||||
app.screen = screen;
|
||||
mounted = SCREEN_MODULES[screen];
|
||||
// The previous screen's DOM is the previous screen's problem no
|
||||
// more: a screen module appends, it does not replace, so the
|
||||
// machine clears the root before mounting (a re-mount would stack
|
||||
// a second copy of the screen on top of the first).
|
||||
screenRoot.replaceChildren();
|
||||
if (typeof mounted.mount === "function") mounted.mount(screenRoot, data, app);
|
||||
return screen;
|
||||
}
|
||||
|
||||
if (!lastCode() && root.getElementById("status")) {
|
||||
announce(READY_MESSAGE, root);
|
||||
function failToLanding(banner) {
|
||||
app.banner = banner;
|
||||
show(SCREENS.LANDING, {});
|
||||
}
|
||||
|
||||
return { token, code: lastCode() };
|
||||
// ------------------------------------------------------------- verbs
|
||||
|
||||
async function createLobby({ name = null } = {}) {
|
||||
app.leaving = false;
|
||||
show(SCREENS.CONNECTING, { what: "creating" });
|
||||
let response;
|
||||
try {
|
||||
response = await fetch("/api/rooms", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: app.token,
|
||||
...(name !== null && name !== "" ? { name } : {}),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
failToLanding("Cannot reach the server — check the address and try again.");
|
||||
return;
|
||||
}
|
||||
if (response.status === 503) {
|
||||
failToLanding("The server is at capacity — try again in a moment.");
|
||||
return;
|
||||
}
|
||||
if (response.status !== 201) {
|
||||
failToLanding("The server refused the room. Try again.");
|
||||
return;
|
||||
}
|
||||
const body = await response.json().catch(() => null);
|
||||
if (body === null || typeof body.room_code !== "string") {
|
||||
failToLanding("The server answered something unrecognisable. Try again.");
|
||||
return;
|
||||
}
|
||||
app.roomCode = body.room_code;
|
||||
rememberCode(body.room_code);
|
||||
connection.connect(body.room_code, { token: app.token });
|
||||
}
|
||||
|
||||
function join(code) {
|
||||
app.leaving = false;
|
||||
app.roomCode = code;
|
||||
rememberCode(code);
|
||||
show(SCREENS.CONNECTING, { what: "joining", code });
|
||||
connection.connect(code, { token: app.token });
|
||||
}
|
||||
|
||||
function leave() {
|
||||
app.leaving = true;
|
||||
connection.close();
|
||||
show(SCREENS.LANDING, {});
|
||||
}
|
||||
|
||||
function backToLanding() {
|
||||
app.leaving = true;
|
||||
connection.close();
|
||||
app.banner = null;
|
||||
show(SCREENS.LANDING, {});
|
||||
}
|
||||
|
||||
function retryJoin() {
|
||||
if (app.roomCode === "") return;
|
||||
app.leaving = false;
|
||||
show(SCREENS.CONNECTING, { what: "joining", code: app.roomCode });
|
||||
connection.connect(app.roomCode, { token: app.token });
|
||||
}
|
||||
|
||||
function ready() {
|
||||
connection.send({ type: "ready" });
|
||||
}
|
||||
|
||||
function start() {
|
||||
connection.send({ type: "start" });
|
||||
}
|
||||
|
||||
function setLevel(level) {
|
||||
connection.send({ type: "set_level", level });
|
||||
}
|
||||
|
||||
function emote(id) {
|
||||
connection.send({ type: "emote", id });
|
||||
}
|
||||
|
||||
function toLobby() {
|
||||
connection.send({ type: "to_lobby" });
|
||||
}
|
||||
|
||||
function nextLevel() {
|
||||
connection.send({ type: "next_level" });
|
||||
}
|
||||
|
||||
function sendInput(move, act) {
|
||||
connection.sendInput(move, act);
|
||||
}
|
||||
|
||||
async function fetchSession() {
|
||||
try {
|
||||
const response = await fetch("/api/session", {
|
||||
headers: { "X-Undercooked-Token": app.token },
|
||||
});
|
||||
if (response.ok) {
|
||||
app.session = await response.json();
|
||||
if (mounted !== null && typeof mounted.renderBest === "function") {
|
||||
mounted.renderBest(app);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* offline: the previous session stays */
|
||||
}
|
||||
return app.session;
|
||||
}
|
||||
|
||||
async function fetchLevels() {
|
||||
if (app.levels !== null) return app.levels;
|
||||
try {
|
||||
const response = await fetch("/api/levels");
|
||||
app.levels = response.ok ? await response.json() : [];
|
||||
} catch {
|
||||
app.levels = [];
|
||||
}
|
||||
return app.levels;
|
||||
}
|
||||
|
||||
/** The merged view's own half (a `snap` landed, delta or full). */
|
||||
function orderOpen() {
|
||||
return Object.keys(app.view.orders).length > 0;
|
||||
}
|
||||
|
||||
function showReconnecting(visible) {
|
||||
if (mounted !== null && typeof mounted.showReconnecting === "function") {
|
||||
mounted.showReconnecting(visible);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- reducer
|
||||
|
||||
connection.onMessage = (frame) => {
|
||||
// The composition root's half (phase 07 task 03): the `snap` frames
|
||||
// also feed the render buffer before the reducer's DOM view.
|
||||
if (onWireMessage !== null) onWireMessage(frame);
|
||||
pushLog(frame);
|
||||
switch (frame.type) {
|
||||
case "welcome": {
|
||||
app.welcome = frame;
|
||||
app.roomCode = frame.room;
|
||||
app.you = frame.you;
|
||||
// A fresh token in a non-lobby room is a spectator (a rejoin
|
||||
// reclaims its seat, so `rejoined` is the discriminator).
|
||||
app.spectator = frame.state !== "lobby" && frame.rejoined !== true;
|
||||
show(SCREEN_BY_ROOM_STATE[frame.state] ?? SCREENS.ERROR, { code: frame.room });
|
||||
break;
|
||||
}
|
||||
case "lobby": {
|
||||
app.lobby = frame;
|
||||
app.roomCode = frame.code;
|
||||
show(
|
||||
frame.state === "lobby" ? SCREENS.LOBBY : SCREEN_BY_ROOM_STATE[frame.state] ?? SCREENS.ERROR,
|
||||
{},
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "countdown":
|
||||
show(SCREENS.COUNTDOWN, { n: frame.n });
|
||||
break;
|
||||
case "snap":
|
||||
if (frame.raw !== undefined) applySnapshot(frame.raw);
|
||||
show(SCREENS.PLAYING, {});
|
||||
break;
|
||||
case "round":
|
||||
app.round = frame;
|
||||
show(SCREENS.ROUND_END, {});
|
||||
break;
|
||||
case "err": {
|
||||
connection.noteError(frame);
|
||||
if (FATAL_ERR_CODES.includes(frame.code)) {
|
||||
app.error = frame;
|
||||
show(SCREENS.ERROR, { code: frame.code, msg: frame.msg });
|
||||
} else if (mounted !== null && typeof mounted.setBanner === "function") {
|
||||
app.banner = frame.msg;
|
||||
mounted.setBanner(frame.msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "pong":
|
||||
break; // the keep-alive's answer: nothing to render
|
||||
case "ev":
|
||||
break; // FX/log (task 04's renderer consumes these)
|
||||
default:
|
||||
break; // unknown server types are ignored (patch-compatible)
|
||||
}
|
||||
};
|
||||
|
||||
connection.onStatus = (status) => {
|
||||
if (status === "mismatch") {
|
||||
show(SCREENS.ERROR, { code: "protocol_mismatch" });
|
||||
return;
|
||||
}
|
||||
if (status !== "closed") return;
|
||||
if (app.leaving) return; // an intentional close: no error screen
|
||||
const code = connection.lastCloseCode;
|
||||
if (code === 1000 && app.screen === SCREENS.PLAYING) {
|
||||
// A drop mid-round: the seat is kept for the grace; phase 08
|
||||
// reconnects. This task's half is the overlay.
|
||||
showReconnecting(true);
|
||||
return;
|
||||
}
|
||||
show(SCREENS.ERROR, { code: code === 1006 ? "unreachable" : "closed" });
|
||||
};
|
||||
|
||||
// The public verbs — assigned onto `app` itself so the screens (which
|
||||
// hold `app`) and the e2e seam (`window.__undercooked`) see one and
|
||||
// the same live object: a `show()` later is visible through the seam.
|
||||
Object.assign(app, {
|
||||
connection,
|
||||
show,
|
||||
createLobby,
|
||||
join,
|
||||
leave,
|
||||
backToLanding,
|
||||
retryJoin,
|
||||
ready,
|
||||
start,
|
||||
setLevel,
|
||||
emote,
|
||||
toLobby,
|
||||
nextLevel,
|
||||
sendInput,
|
||||
fetchSession,
|
||||
fetchLevels,
|
||||
orderOpen,
|
||||
showReconnecting,
|
||||
});
|
||||
|
||||
show(SCREENS.LANDING, {});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// Auto-mount when loaded as the page's module, but stay importable (and
|
||||
// therefore testable) without a lobby in the document.
|
||||
if (typeof document !== "undefined" && document.getElementById("screen-lobby")) {
|
||||
// Auto-mount when loaded directly as the page's module; stay
|
||||
// importable (and therefore testable) without one. The instance is
|
||||
// the e2e test seam. `main.js` (the composition root) sets
|
||||
// `globalThis.__uc_main` before importing this module — when it is
|
||||
// set, main.js boots the app and this auto-boot stays out of the way.
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.getElementById("screen-root") &&
|
||||
!globalThis.__uc_main
|
||||
) {
|
||||
const boot = () => {
|
||||
globalThis.__undercooked = createApp();
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => mount(), { once: true });
|
||||
document.addEventListener("DOMContentLoaded", boot, { once: true });
|
||||
} else {
|
||||
mount();
|
||||
boot();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Undercooked — the tab identity (PLAN A16; carried over from the
|
||||
// phase-01 shell, where these helpers lived in `app.js`).
|
||||
//
|
||||
// Identity is a 24-byte random token per browser tab (the browser's
|
||||
// `crypto.getRandomValues` is the same entropy the server's
|
||||
// `secrets.token_urlsafe(24)` uses, and the only option a browser has),
|
||||
// held in `sessionStorage` so a reload re-joins instead of re-minting.
|
||||
// The last room code is remembered the same way, so a reload can
|
||||
// rejoin fast. Nothing here is personal: no names, no PII (PLAN §11).
|
||||
|
||||
const TOKEN_KEY = "uc.token";
|
||||
const LAST_CODE_KEY = "uc.lastCode";
|
||||
const CODE_LENGTH = 5;
|
||||
|
||||
/** Base64url without padding, so the token is safe in a query string or header. */
|
||||
function base64url(bytes) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/** A random, unguessable id for this browser tab (identity, not an account). */
|
||||
export function randomToken() {
|
||||
const bytes = new Uint8Array(24);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return base64url(bytes);
|
||||
}
|
||||
|
||||
function read(storage, key) {
|
||||
try {
|
||||
return storage.getItem(key);
|
||||
} catch {
|
||||
return null; // private browsing / storage disabled: stay functional for this tab only
|
||||
}
|
||||
}
|
||||
|
||||
function write(storage, key, value) {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
} catch {
|
||||
/* nothing to persist to; the in-memory value still works */
|
||||
}
|
||||
}
|
||||
|
||||
/** The tab's session token, created once per tab and kept in sessionStorage. */
|
||||
export function getToken(storage = globalThis.sessionStorage) {
|
||||
const existing = read(storage, TOKEN_KEY);
|
||||
if (typeof existing === "string" && existing.length > 0) return existing;
|
||||
const token = randomToken();
|
||||
write(storage, TOKEN_KEY, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Uppercase what the user typed and keep only characters a code can contain. */
|
||||
export function normalizeCode(value) {
|
||||
return String(value ?? "")
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9A-Z]/g, "")
|
||||
.slice(0, CODE_LENGTH);
|
||||
}
|
||||
|
||||
export function lastCode(storage = globalThis.sessionStorage) {
|
||||
return read(storage, LAST_CODE_KEY) || "";
|
||||
}
|
||||
|
||||
export function rememberCode(code, storage = globalThis.sessionStorage) {
|
||||
write(storage, LAST_CODE_KEY, normalizeCode(code));
|
||||
}
|
||||
|
||||
export { CODE_LENGTH };
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Undercooked — the `main.js` flag (phase 07 task 03).
|
||||
//
|
||||
// Evaluated before `app.js` (import order in `main.js`), so when
|
||||
// `app.js`'s module top-level runs its auto-boot check, the flag is
|
||||
// already set: the composition root is bootstrapping the app, and the
|
||||
// legacy auto-boot (for a direct `app.js` load, e.g. a developer
|
||||
// opening the file) must not boot a second instance.
|
||||
|
||||
globalThis.__uc_main = true;
|
||||
@@ -0,0 +1,208 @@
|
||||
// Undercooked — the composition root (phase 07 task 03).
|
||||
//
|
||||
// The page's one entry module (`index.html` loads `/static/js/main.js`
|
||||
// and nothing else): it parses the URL params, builds the pieces —
|
||||
// the screen machine (`app.js`), the snapshot buffer, the event queue,
|
||||
// the single render loop — and routes the wire:
|
||||
//
|
||||
// * `snap` → the snapshot buffer (gap / resync / freeze rules; the
|
||||
// buffer is the *render* path) **and** the app's reducer (whose
|
||||
// merged view is the *DOM* path the lobby / HUD / choreography
|
||||
// read); the frame's `e` also feeds the event queue;
|
||||
// * everything else → the screen machine's reducer, untouched.
|
||||
//
|
||||
// The seams: `window.__undercooked` is the app instance (the state
|
||||
// machine the e2e suites drive — unchanged from task 02, plus the
|
||||
// `params` / `seed` / `snapshotBuffer` / `eventQueue` fields);
|
||||
// `window.__uc` is the loop census and the pure modules
|
||||
// (`frameMs` p50/p95 self-measured, `buffer`, `events`, `loop`,
|
||||
// `params`, the last interpolated `lastView`).
|
||||
//
|
||||
// Still a plain ES module: no bundler, no framework, no remote import
|
||||
// (PLAN A10/A12). The boot is synchronous at module evaluation
|
||||
// (static imports only), so by the time the page's module graph has
|
||||
// settled, `window.__undercooked` is set — no async boot race.
|
||||
|
||||
import "./main-flag.js"; // the auto-boot guard (must precede app.js)
|
||||
import { createApp } from "./app.js";
|
||||
import { createSnapshotBuffer, lerpState } from "./net/snapshots.js";
|
||||
import { createEventQueue } from "./net/events.js";
|
||||
import { createRenderLoop } from "./render/loop.js";
|
||||
import { createCamera } from "./render/camera.js";
|
||||
import { createRenderer } from "./render/canvas.js";
|
||||
import { createFx } from "./render/fx.js";
|
||||
import { createHud } from "./ui/hud.js";
|
||||
import { createKeyboardInput } from "./input/keyboard.js";
|
||||
import { createHelpOverlay } from "./ui/help.js";
|
||||
|
||||
/** The URL params (docs/CLIENT.md "The URL params"):
|
||||
* `room` — join this code on boot (a test harness seam);
|
||||
* `seed` — the campaign's seed (exposed for the deterministic
|
||||
* suites; the server is authoritative, it never simulates);
|
||||
* `controls` — `keyboard` (default) / `touch` / `off` (task 05's
|
||||
* input module reads it; `off` means no input module binds);
|
||||
* `freeze` — `1` holds the sample clock 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).
|
||||
*/
|
||||
function parseParams(search = new URLSearchParams(location.search)) {
|
||||
return {
|
||||
room: search.get("room") ?? "",
|
||||
seed: search.get("seed") ?? "",
|
||||
controls: search.get("controls") ?? "keyboard",
|
||||
freeze: search.get("freeze") === "1" || search.get("freeze") === "true",
|
||||
motion: search.get("motion") === "0" ? "0" : "1",
|
||||
};
|
||||
}
|
||||
|
||||
/** The one-time boot (synchronous; `index.html`'s module evaluation runs it). */
|
||||
export function bootstrap() {
|
||||
const params = parseParams();
|
||||
const buffer = createSnapshotBuffer({
|
||||
onResync: () => {
|
||||
// The resync request (the buffer's debounce is the only gate):
|
||||
// the client's half is to *hold* — the stream's full cadence
|
||||
// (phase 06, every 20 encoded frames) is the resync. Nothing to
|
||||
// send; the hook exists so a phase-08 reconnect can hook in.
|
||||
},
|
||||
});
|
||||
const events = createEventQueue();
|
||||
|
||||
const app = createApp(document, {
|
||||
onWireMessage: (frame) => {
|
||||
if (frame.type === "snap" && frame.raw !== undefined) {
|
||||
buffer.push(frame.raw); // the render path (gap / resync / freeze)
|
||||
if (Array.isArray(frame.raw.e)) events.push(frame.raw.t, frame.raw.e);
|
||||
} else if (frame.type === "ev" && Array.isArray(frame.events)) {
|
||||
events.push(null, frame.events); // the standalone `ev` (no tick)
|
||||
}
|
||||
},
|
||||
});
|
||||
app.params = params;
|
||||
if (params.seed !== "") app.seed = params.seed;
|
||||
app.snapshotBuffer = buffer;
|
||||
app.eventQueue = events;
|
||||
|
||||
// The in-game help (task 05): one body-level overlay, created once,
|
||||
// persisting across screens; the keyboard's `?`/`Esc` branches call
|
||||
// into it.
|
||||
const help = createHelpOverlay();
|
||||
// The keyboard input (task 05): `?controls=keyboard` (the default)
|
||||
// binds the WASD/arrow + Space/Enter + emote + `?`/Esc listeners and
|
||||
// the keep-alive pump; `touch`/`off` leave the seams, bind nothing.
|
||||
const input = createKeyboardInput({
|
||||
app,
|
||||
controls: params.controls,
|
||||
help,
|
||||
});
|
||||
app.help = help;
|
||||
app.input = input;
|
||||
|
||||
let freezeAt = null;
|
||||
// The play session's runtime (task 04): the camera, the FX, the
|
||||
// canvas renderer and the HUD — bound to the *mounted* `#kcanvas`
|
||||
// (the play screen's DOM is rebuilt per round: a new canvas means a
|
||||
// fresh runtime, a vanished one means the screen left and the
|
||||
// runtime tears down). The single-owner rule is untouched: the loop
|
||||
// is the only thing that drives them.
|
||||
let runtime = null;
|
||||
function playRuntime(nowMs) {
|
||||
const canvas = document.getElementById("kcanvas");
|
||||
if (canvas === null) {
|
||||
if (runtime !== null) {
|
||||
if (runtime.hud !== null) runtime.hud.unmount();
|
||||
runtime.renderer.dispose();
|
||||
runtime.camera.detach();
|
||||
runtime = null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (runtime === null || runtime.canvas !== canvas) {
|
||||
const camera = createCamera({ canvas });
|
||||
const fx = createFx({ canvas });
|
||||
const renderer = createRenderer({
|
||||
canvas,
|
||||
camera,
|
||||
fx,
|
||||
ownSeat: () => (app.you !== null ? app.you.seat : null),
|
||||
});
|
||||
const hudRoot = document.getElementById("hud");
|
||||
const hud = hudRoot !== null ? createHud({ root: hudRoot, app }) : null;
|
||||
if (params.motion === "0") canvas.dataset.motion = "reduced";
|
||||
camera.attach();
|
||||
renderer.ensureReady(); // the atlas's one-time load (a failed load throws into the promise; the first frames wait for it)
|
||||
runtime = { canvas, camera, fx, renderer, hud };
|
||||
}
|
||||
if (runtime.renderer.isReady() && canvas.hidden) {
|
||||
canvas.hidden = false; // the renderer is ready: the canvas may show
|
||||
}
|
||||
if (runtime.renderer.isReady()) {
|
||||
const sample = buffer.sample(params.freeze ? (freezeAt ??= nowMs) : nowMs);
|
||||
if (sample !== null) {
|
||||
const state = lerpState(sample.a, sample.b, sample.alpha);
|
||||
globalThis.__uc.lastView = state;
|
||||
const due = events.due(nowMs); // one drain per frame (FX + HUD share it)
|
||||
runtime.fx.update(nowMs);
|
||||
runtime.fx.consume(due, state, nowMs);
|
||||
runtime.renderer.drawFrame(state, nowMs);
|
||||
if (runtime.hud !== null) {
|
||||
runtime.hud.update(state, buffer.latest(), app, due, nowMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
const loop = createRenderLoop({
|
||||
active: () => app.screen === "playing",
|
||||
render: (nowMs) => {
|
||||
// Task 04's half: the sample feeds the renderer (the canvas
|
||||
// layers, docs/CLIENT.md's order) and the HUD (the diff-
|
||||
// written readouts); `lastView` stays the smoothness suites'
|
||||
// seam.
|
||||
playRuntime(nowMs);
|
||||
},
|
||||
});
|
||||
loop.start();
|
||||
|
||||
globalThis.__uc = {
|
||||
params,
|
||||
app,
|
||||
buffer,
|
||||
events,
|
||||
loop,
|
||||
lastView: null,
|
||||
get frameMs() {
|
||||
return loop.frameStats();
|
||||
},
|
||||
/** The renderer's frame census (the perf assertions' seam; the
|
||||
* loop's own rAF census is `frameMs`). */
|
||||
renderStats: () =>
|
||||
runtime !== null ? runtime.renderer.renderStats() : { drawCalls: 0, frames: 0 },
|
||||
/** The play session's runtime (the camera / the FX / the
|
||||
* renderer / the HUD; `null` off the play screen). */
|
||||
get runtime() {
|
||||
return runtime;
|
||||
},
|
||||
/** The FX engine (the reduced-motion + the nudge's census). */
|
||||
get fx() {
|
||||
return runtime !== null ? runtime.fx : null;
|
||||
},
|
||||
/** The keyboard input module (task 05; its `stats()` is the
|
||||
* held-key / last-frame / sent-count census). */
|
||||
input,
|
||||
/** The help overlay (task 05; `?` toggles it). */
|
||||
help,
|
||||
};
|
||||
globalThis.__undercooked = app;
|
||||
|
||||
if (params.room !== "") app.join(params.room);
|
||||
return app;
|
||||
}
|
||||
|
||||
// The page loads this module for the side effect: boot once, at
|
||||
// module evaluation (the module graph settles before the first paint,
|
||||
// so there is no visible flash and no async boot race).
|
||||
if (typeof document !== "undefined" && document.getElementById("screen-root")) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Undercooked — the WebSocket connection (phase 07 task 02).
|
||||
//
|
||||
// A thin, protocol-only layer (hardened into the rejoin/backoff machine
|
||||
// in phase 08): it opens `/ws/{code}`, sends the `hello` (PLAN A14 —
|
||||
// the version travels in the hello only), dispatches every server frame
|
||||
// to a single `onMessage` handler, sends client frames, and classifies
|
||||
// how the socket died so the app's state machine can pick the right
|
||||
// screen (the close-code decision table, docs/PROTOCOL.md "Heartbeats,
|
||||
// drops, and seat grace").
|
||||
//
|
||||
// Deliberate gaps in this task: no reconnect/backoff (phase 08), no
|
||||
// snapshot buffering (task 03), no input prediction (task 08). The
|
||||
// `protocol_mismatch` path *is* final in this task: a `p` the server
|
||||
// refuses means this client must not retry — it stops and the app
|
||||
// shows the error screen without a retry affordance.
|
||||
|
||||
const PROTOCOL_VERSION = 1;
|
||||
|
||||
/** The close codes the client can act on (docs/PROTOCOL.md "Close codes"). */
|
||||
export const CLOSE_CODES = Object.freeze({
|
||||
normal: 1000,
|
||||
protocol_mismatch: 4400,
|
||||
room_gone: 4401,
|
||||
room_full: 4402,
|
||||
evicted: 4403,
|
||||
abuse: 4404,
|
||||
});
|
||||
|
||||
/** The `err` codes that end the session on this socket (fatal for the app). */
|
||||
export const FATAL_ERR_CODES = Object.freeze([
|
||||
"room_gone",
|
||||
"room_full",
|
||||
"protocol_mismatch",
|
||||
"evicted",
|
||||
"internal",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Build a connection. The returned object's surface:
|
||||
*
|
||||
* connect(code, { token, name }) — open `/ws/{code}` and `hello`
|
||||
* send(model) — one JSON frame out (false when closed)
|
||||
* sendInput(move, act) — one `input` frame, `seq` owned here
|
||||
* onMessage(frame) — the single reducer the app installs
|
||||
* onStatus(status) — "idle" | "connecting" | "open" | "closed" | "mismatch"
|
||||
* lastCloseCode — the close code of the last death (1006 if the
|
||||
* transport died without a close frame)
|
||||
* noteError(frame) — feed an `err` frame; `protocol_mismatch`
|
||||
* latches the mismatch (no retry, ever)
|
||||
* close() — a clean close (1000)
|
||||
*/
|
||||
export function createConnection() {
|
||||
let socket = null;
|
||||
let status = "idle";
|
||||
let lastCloseCode = 1006;
|
||||
let mismatchLatched = false;
|
||||
let seq = 0;
|
||||
let pingTimer = null;
|
||||
let pingClock = 0;
|
||||
let onMessage = null;
|
||||
let onStatus = null;
|
||||
|
||||
function setStatus(next) {
|
||||
if (status === next) return;
|
||||
status = next;
|
||||
if (onStatus) onStatus(status);
|
||||
}
|
||||
|
||||
function stopPing() {
|
||||
if (pingTimer !== null) clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
|
||||
function send(model) {
|
||||
if (socket === null || socket.readyState !== 1 /* OPEN */) return false;
|
||||
socket.send(JSON.stringify(model));
|
||||
return true;
|
||||
}
|
||||
|
||||
function connect(code, { token = null, name = null } = {}) {
|
||||
if (socket !== null && socket.readyState !== 3 /* CLOSED */) close();
|
||||
lastCloseCode = 1006;
|
||||
seq = 0; // a fresh socket: the server's `last_seq` starts at 0, so the
|
||||
// first `input` frame must be seq >= 1
|
||||
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
||||
const next = new WebSocket(
|
||||
`${scheme}://${location.host}/ws/${encodeURIComponent(code)}`,
|
||||
);
|
||||
socket = next;
|
||||
setStatus("connecting");
|
||||
next.onopen = () => {
|
||||
// `extra="forbid"` on the server: only `p`, `token`, `name` may ride
|
||||
// the hello, and nulls are omitted rather than sent.
|
||||
const hello = { type: "hello", p: PROTOCOL_VERSION };
|
||||
if (token !== null) hello.token = token;
|
||||
if (name !== null) hello.name = name;
|
||||
send(hello);
|
||||
setStatus("open");
|
||||
// The keep-alive (docs/PROTOCOL.md "Heartbeats"): any inbound frame
|
||||
// resets the server's 12 s dead window; a 1 s `ping` keeps a half-open
|
||||
// TCP honest. Phase 08 raises this to the documented 5 Hz RTT probe.
|
||||
stopPing();
|
||||
pingTimer = setInterval(() => {
|
||||
send({ type: "ping", t: pingClock += 1 });
|
||||
}, 1000);
|
||||
};
|
||||
next.onmessage = (event) => {
|
||||
let frame = null;
|
||||
try {
|
||||
frame = JSON.parse(event.data);
|
||||
} catch {
|
||||
return; // the server must not send non-JSON; if it does, ignore
|
||||
}
|
||||
if (frame !== null && onMessage !== null) onMessage(frame);
|
||||
};
|
||||
next.onerror = () => {
|
||||
/* the close event follows; classified there */
|
||||
};
|
||||
next.onclose = (event) => {
|
||||
// A replaced socket's close is stale (a `connect()` swapped it
|
||||
// out): only the current socket's death is the connection's.
|
||||
if (socket !== next) return;
|
||||
stopPing();
|
||||
lastCloseCode = event.wasClean ? event.code : 1006;
|
||||
if (mismatchLatched) setStatus("mismatch");
|
||||
else setStatus("closed");
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
function sendInput(move, act) {
|
||||
seq += 1;
|
||||
const x = move === null ? 0 : move.x;
|
||||
const y = move === null ? 0 : move.y;
|
||||
// `face: null` = "derive from move" (docs/PROTOCOL.md "Input rules");
|
||||
// an explicit facing is task 05's (the keyboard module).
|
||||
return send({
|
||||
type: "input",
|
||||
seq,
|
||||
move: { x, y },
|
||||
act: Boolean(act),
|
||||
face: null,
|
||||
});
|
||||
}
|
||||
|
||||
function noteError(frame) {
|
||||
if (frame !== null && frame.code === "protocol_mismatch") {
|
||||
mismatchLatched = true;
|
||||
if (status !== "mismatch") setStatus("mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
stopPing();
|
||||
const current = socket;
|
||||
socket = null;
|
||||
if (current !== null && current.readyState !== 3) {
|
||||
try {
|
||||
current.close(1000);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
if (status !== "mismatch" && status !== "idle") setStatus("closed");
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
send,
|
||||
sendInput,
|
||||
close,
|
||||
noteError,
|
||||
get status() {
|
||||
return status;
|
||||
},
|
||||
get lastCloseCode() {
|
||||
return lastCloseCode;
|
||||
},
|
||||
get mismatchLatched() {
|
||||
return mismatchLatched;
|
||||
},
|
||||
set onMessage(handler) {
|
||||
onMessage = handler;
|
||||
},
|
||||
set onStatus(handler) {
|
||||
onStatus = handler;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Undercooked — the wire decoder (phase 07 task 03).
|
||||
//
|
||||
// The client-side counterpart of `server/encoding.py`. `applyDelta(frame,
|
||||
// prev)` reconstructs the full frame the server would have sent from a
|
||||
// `full: false` frame and the previous frame (a full one or a delta one
|
||||
// — the mirror of the encoder's `_prev_entries`: a delta frame's entity
|
||||
// lists live under `d`, and its `gone` tombstones are *not* part of the
|
||||
// previous state). The enum mapping goes through `shared/enums.js` (the
|
||||
// wire's string values and the facing index, parity-guarded by
|
||||
// `tests/unit/test_client_enum_parity.py`).
|
||||
//
|
||||
// The delta contract (docs/PROTOCOL.md "The `snap` frame"): `d` carries
|
||||
// *only the changed entities* — new, moved, or state-changed since
|
||||
// `prev` (an entity back at its `prev` values is out) — plus a
|
||||
// `{"gone": true}` tombstone for each entity that vanished. The scalars
|
||||
// (`t`, `s`, `tgt`, `tl`) and the tick's events (`e`) ride every frame,
|
||||
// full or delta, so a decoded frame takes them from the frame itself.
|
||||
//
|
||||
// The identity keys mirror the encoder's: the player's `i`, the station's
|
||||
// `k` + coordinates (its internal id never appears on the wire), the
|
||||
// order's `id`, the hazard's `t` + coordinates.
|
||||
|
||||
import { FACING, EVENT_KIND, STATION_KIND, ITEM_KIND, ITEM_FORM } from "../shared/enums.js";
|
||||
|
||||
const COLLECTIONS = [
|
||||
["p", "player"],
|
||||
["st", "station"],
|
||||
["o", "order"],
|
||||
["hz", "hazard"],
|
||||
];
|
||||
|
||||
/** One entity's identity key (the encoder's `_key_of_*`, as a string). */
|
||||
export function keyOf(entity, kind) {
|
||||
switch (kind) {
|
||||
case "player":
|
||||
return `p:${entity.i}`;
|
||||
case "station":
|
||||
return `st:${entity.k}@${entity.x},${entity.y}`;
|
||||
case "order":
|
||||
return `o:${entity.id}`;
|
||||
case "hazard":
|
||||
return `hz:${entity.t}@${entity.x},${entity.y}`;
|
||||
default:
|
||||
throw new Error(`unknown entity kind ${String(kind)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isEntry(value) {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame's entity entries for one collection (the mirror of
|
||||
* `_prev_entries`): top-level on a full frame, under `d` on a delta
|
||||
* frame; `gone` tombstones filtered out — they are not part of the
|
||||
* state the delta is computed against.
|
||||
*/
|
||||
export function effectiveEntries(frame, key) {
|
||||
if (!isEntry(frame)) return [];
|
||||
const delta = frame.d;
|
||||
const raw =
|
||||
typeof delta === "object" && delta !== null ? delta[key] : frame[key];
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((entry) => isEntry(entry) && entry.gone !== true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert / tombstone one collection: start from `previous` (in its
|
||||
* order), replace a changed entry in place, append a new one, drop a
|
||||
* tombstoned one. Deterministic — the same (prev, delta) pair always
|
||||
* yields the same list.
|
||||
*/
|
||||
function applyCollection(previous, entries, kind) {
|
||||
const out = previous.slice();
|
||||
for (const entry of entries) {
|
||||
if (!isEntry(entry)) continue;
|
||||
const k = keyOf(entry, kind);
|
||||
const idx = out.findIndex((existing) => keyOf(existing, kind) === k);
|
||||
if (entry.gone === true) {
|
||||
if (idx !== -1) out.splice(idx, 1); // the tombstone: gone
|
||||
continue;
|
||||
}
|
||||
if (idx === -1) out.push(entry); // new: appended
|
||||
else out[idx] = entry; // changed: replaced in place
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeFull(frame) {
|
||||
const list = (key) => (Array.isArray(frame[key]) ? frame[key] : []);
|
||||
return {
|
||||
full: true,
|
||||
t: frame.t,
|
||||
s: frame.s,
|
||||
tgt: frame.tgt,
|
||||
tl: frame.tl,
|
||||
p: list("p"),
|
||||
st: list("st"),
|
||||
o: list("o"),
|
||||
hz: list("hz"),
|
||||
e: Array.isArray(frame.e) ? frame.e : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One raw `snap` frame in, one decoded full frame out.
|
||||
*
|
||||
* A `full: true` frame passes through (normalized: the entity arrays and
|
||||
* `e` always present). A delta is applied against `prev` (the previous
|
||||
* decoded full — or a raw full/delta frame, the encoder's own rule;
|
||||
* `null` applies the delta's `d` against the empty state, best effort).
|
||||
* The result is a full frame: `full: true`, the entity arrays top-level,
|
||||
* the scalars and events from the frame itself.
|
||||
*/
|
||||
export function applyDelta(frame, prev = null) {
|
||||
if (!isEntry(frame)) throw new Error("applyDelta: the frame is not an object");
|
||||
if (frame.full === true) return normalizeFull(frame);
|
||||
const d = typeof frame.d === "object" && frame.d !== null ? frame.d : {};
|
||||
const base = isEntry(prev) ? prev : {};
|
||||
const out = {
|
||||
full: true,
|
||||
t: frame.t,
|
||||
s: frame.s,
|
||||
tgt: frame.tgt,
|
||||
tl: frame.tl,
|
||||
e: Array.isArray(frame.e) ? frame.e : [],
|
||||
};
|
||||
for (const [key, kind] of COLLECTIONS) {
|
||||
out[key] = applyCollection(
|
||||
effectiveEntries(base, key),
|
||||
Array.isArray(d[key]) ? d[key] : [],
|
||||
kind,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ enum mapping
|
||||
|
||||
/** The wire's `f` (an index) → the facing's string value, or undefined. */
|
||||
export function facingName(f) {
|
||||
return Number.isInteger(f) && f >= 0 && f < FACING.length ? FACING[f] : undefined;
|
||||
}
|
||||
|
||||
/** Whether a string is a known event kind (the FX queue's admission gate). */
|
||||
export function isEventKind(k) {
|
||||
return typeof k === "string" && EVENT_KIND.includes(k);
|
||||
}
|
||||
|
||||
/** Whether a string is a known station kind (the renderer's sprite gate). */
|
||||
export function isStationKind(k) {
|
||||
return typeof k === "string" && STATION_KIND.includes(k);
|
||||
}
|
||||
|
||||
/**
|
||||
* A short item form (`"tomato_chopped"`, the wire's `h` / `g`) →
|
||||
* `{kind, form}`; null when the shape is not a `_`-joined pair of known
|
||||
* values (a carried `plate_plated` is the plated dish; a null short is a
|
||||
* nothing-in-hand, which the caller handles before calling this).
|
||||
*/
|
||||
export function splitItemShort(short) {
|
||||
if (typeof short !== "string" || short === "") return null;
|
||||
const idx = short.lastIndexOf("_");
|
||||
if (idx <= 0 || idx === short.length - 1) return null;
|
||||
const kind = short.slice(0, idx);
|
||||
const form = short.slice(idx + 1);
|
||||
if (!ITEM_KIND.includes(kind) || !ITEM_FORM.includes(form)) return null;
|
||||
return { kind, form };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Undercooked — the event queue (phase 07 task 03).
|
||||
//
|
||||
// A pure module (no DOM, injected clock) that turns the wire's
|
||||
// transient events — the `e[]` of a `snap` frame and the standalone
|
||||
// `ev` message — into FX work with a bounded lifetime, so a burst of
|
||||
// `chopped` events cannot leak listeners or replay after a resync.
|
||||
//
|
||||
// The rules:
|
||||
//
|
||||
// * **per-kind `ttlMs`** — each queued event lives until
|
||||
// `now + TTL_MS[kind]` (a short table, a default for the rest); the
|
||||
// renderer drains `due(now)` and the event's `until` is its lifetime;
|
||||
// * **the dedup window** — the same event (kind + position + payload,
|
||||
// the dedup key) pushed again within `dedupWindowMs` (100 ms) of the
|
||||
// last accepted push is counted (`deduped`) and not queued again:
|
||||
// the 20 Hz cadence can echo one tick's events, and the FX for a
|
||||
// chop plays once;
|
||||
// * **`drift(now)`** — prunes the expired (events past their `until`)
|
||||
// and the dedup keys past their window; the renderer calls it each
|
||||
// frame, so a queue left unattended shrinks to nothing on its own;
|
||||
// * **the tick-reset detection** — a `push(t, ...)` whose `t` is
|
||||
// *below* the last seen tick is a resync (a new round, a rejoin):
|
||||
// the queue is cleared first, so a `served` from the previous round
|
||||
// cannot replay over the new one. (`t` omitted — the standalone `ev`
|
||||
// message has no tick — never resets.)
|
||||
|
||||
import { isEventKind } from "./decode.js";
|
||||
|
||||
/** The FX lifetime per event kind (ms); anything unlisted gets the default. */
|
||||
const TTL_MS = Object.freeze({
|
||||
served: 1200,
|
||||
score: 1200,
|
||||
round_end: 3000,
|
||||
order_missed: 1000,
|
||||
denied: 600,
|
||||
ghost_on: 2000,
|
||||
ghost_off: 1500,
|
||||
ghost_park: 2000,
|
||||
resync: 400,
|
||||
});
|
||||
|
||||
const DEFAULT_TTL_MS = 800;
|
||||
const DEFAULT_DEDUP_WINDOW_MS = 100;
|
||||
|
||||
/**
|
||||
* One event queue. `push(t, entries, nowMs?)` — `t` the frame's tick
|
||||
* (or null/undefined), `entries` the raw event entries (the frame's
|
||||
* short-keyed `e` shape or the `ev` message's `{kind, payload}` shape),
|
||||
* returns how many were queued. `due(nowMs?)` — the live events (in
|
||||
* arrival order, raw entries). `drift(nowMs?)` — the prune; returns the
|
||||
* number of entries dropped. `stats()` — `pushed`, `deduped`,
|
||||
* `resyncs`, `pruned`, `size`.
|
||||
*/
|
||||
export function createEventQueue({
|
||||
now = null,
|
||||
dedupWindowMs = DEFAULT_DEDUP_WINDOW_MS,
|
||||
} = {}) {
|
||||
const clock = typeof now === "function" ? now : () => performance.now();
|
||||
let entries = []; // { k, x, y, payload, raw, until }
|
||||
let dedup = new Map(); // dedup key -> the last accepted push's time
|
||||
let lastT = null;
|
||||
const stats = { pushed: 0, deduped: 0, resyncs: 0, pruned: 0 };
|
||||
|
||||
function at(nowMs) {
|
||||
return typeof nowMs === "number" ? nowMs : clock();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
entries = [];
|
||||
dedup = new Map();
|
||||
stats.resyncs += 1;
|
||||
}
|
||||
|
||||
/** The one wire shape the queue admits (both message shapes map here). */
|
||||
function normalize(entry) {
|
||||
if (entry === null || typeof entry !== "object") return null;
|
||||
const k =
|
||||
typeof entry.k === "string"
|
||||
? entry.k
|
||||
: typeof entry.kind === "string"
|
||||
? entry.kind
|
||||
: null;
|
||||
if (k === null || !isEventKind(k)) return null; // unknown kinds: no FX
|
||||
return {
|
||||
k,
|
||||
x: typeof entry.x === "number" ? entry.x : 0,
|
||||
y: typeof entry.y === "number" ? entry.y : 0,
|
||||
payload:
|
||||
entry.payload !== undefined && entry.payload !== null
|
||||
? entry.payload
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupKey(entry) {
|
||||
const payload =
|
||||
entry.payload !== undefined
|
||||
? JSON.stringify(entry.payload, Object.keys(entry.payload).sort())
|
||||
: "";
|
||||
return `${entry.k}|${Math.round(entry.x * 100)}|${Math.round(entry.y * 100)}|${payload}`;
|
||||
}
|
||||
|
||||
function push(t, eventEntries, nowMs) {
|
||||
const nowAt = at(nowMs);
|
||||
if (typeof t === "number") {
|
||||
if (lastT !== null && t < lastT) {
|
||||
reset(); // the tick went backwards: a new round (or a resync)
|
||||
}
|
||||
lastT = t; // monotonicity resumes from the new tick
|
||||
}
|
||||
let added = 0;
|
||||
for (const raw of Array.isArray(eventEntries) ? eventEntries : []) {
|
||||
const entry = normalize(raw);
|
||||
if (entry === null) continue;
|
||||
const key = dedupKey(entry);
|
||||
const lastAccepted = dedup.get(key);
|
||||
if (lastAccepted !== undefined && nowAt - lastAccepted < dedupWindowMs) {
|
||||
stats.deduped += 1; // the echo: the FX already plays
|
||||
continue;
|
||||
}
|
||||
const ttl = TTL_MS[entry.k] ?? DEFAULT_TTL_MS;
|
||||
entries.push({ ...entry, until: nowAt + ttl });
|
||||
dedup.set(key, nowAt);
|
||||
stats.pushed += 1;
|
||||
added += 1;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/** The live events (their `until` is at least `now`), in arrival order. */
|
||||
function due(nowMs) {
|
||||
const nowAt = at(nowMs);
|
||||
return entries
|
||||
.filter((entry) => entry.until >= nowAt)
|
||||
.map(({ k, x, y, payload }) => ({ k, x, y, payload }));
|
||||
}
|
||||
|
||||
/** The prune: events past their `until`, dedup keys past their window. */
|
||||
function drift(nowMs) {
|
||||
const nowAt = at(nowMs);
|
||||
const kept = entries.filter((entry) => entry.until >= nowAt);
|
||||
const dropped = entries.length - kept.length;
|
||||
entries = kept;
|
||||
let dedupDropped = 0;
|
||||
for (const [key, seen] of [...dedup]) {
|
||||
if (nowAt - seen >= dedupWindowMs) {
|
||||
dedup.delete(key);
|
||||
dedupDropped += 1;
|
||||
}
|
||||
}
|
||||
stats.pruned += dropped + dedupDropped;
|
||||
return dropped + dedupDropped;
|
||||
}
|
||||
|
||||
return {
|
||||
push,
|
||||
due,
|
||||
drift,
|
||||
stats: () => ({ ...stats, size: entries.length }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// Undercooked — the snapshot buffer and render-time interpolation
|
||||
// (phase 07 task 03).
|
||||
//
|
||||
// A pure module: no DOM, no network, no wall clock — the clock is
|
||||
// injected (`now`), which is what the e2e suite
|
||||
// (`tests/e2e/test_us06_interp_math.py`) drives with synthetic streams.
|
||||
//
|
||||
// The buffer turns the `snap` stream into render-time frames:
|
||||
//
|
||||
// * 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) 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. That is the whole resync contract: never ask, just hold —
|
||||
// the next full frame is the resync.
|
||||
// * if no frame arrives for `maxAgeMs`, `sample()` **freezes** on the
|
||||
// latest frame instead of extrapolating: the buffer never animates a
|
||||
// ghost cook into a wall.
|
||||
//
|
||||
// `sample(now)` renders at `now - interpDelayMs` (the fixed 100 ms
|
||||
// delay, docs/CLIENT.md "why 100 ms"): it returns the two bracketing
|
||||
// decoded frames `{a, b}` plus the lerp factor `alpha` (0..1, by the
|
||||
// frames' arrival times), or the frozen hold (`a === b`, `alpha 0`).
|
||||
// `lerpState(a, b, alpha)` turns the bracket into a render-ready state:
|
||||
// positions lerp along the straight line (the shorter path — a cook
|
||||
// never passes through a wall, so the chord is the path), the facing
|
||||
// snaps at the `alpha >= 0.5` boundary, progress / time-left / hazard
|
||||
// life lerp, and entities added or removed between the frames appear at
|
||||
// `b` (visible from `alpha 0`, at `b`'s position) and vanish at the
|
||||
// `b` boundary (held at `a`'s position until `alpha >= 0.5`, then gone).
|
||||
// Stations and orders diff by their wire identity (`k` + coordinates /
|
||||
// `id`), players and hazards the same.
|
||||
//
|
||||
// `applyDelta` (imported from `net/decode.js`, re-exported here for the
|
||||
// buffer's surface) decodes a raw frame; the buffer only ever stores
|
||||
// decoded full frames.
|
||||
|
||||
import { applyDelta as decodeApplyDelta } from "./decode.js";
|
||||
|
||||
export { decodeApplyDelta as applyDelta };
|
||||
|
||||
/** A `t` jump longer than this is a resync (6 = three 200 ms
|
||||
* snapshot windows: beyond that the missing frames have changed too
|
||||
* much to bridge by interpolation). */
|
||||
export const MAX_GAP_TICKS = 6;
|
||||
|
||||
/** A resync request fires at most once per this window (debounce). */
|
||||
export const RESYNC_DEBOUNCE_MS = 500;
|
||||
|
||||
/**
|
||||
* One jitter-tolerant snapshot buffer.
|
||||
*
|
||||
* `push(raw)` — a raw `snap` frame (`full: true`, or a delta applied
|
||||
* against the last decoded full); returns true when it was applied.
|
||||
* `sample(nowMs?)` — `{a, b, alpha}` (or the frozen hold), null while
|
||||
* empty. `latest()` — the last decoded full frame (the delta's `prev`),
|
||||
* or null. `needsFullResync()` — the latch. `stats()` — the census
|
||||
* (`pushed`, `dropped`, `gaps`, `maxGapTicks`, `skippedUntilFull`,
|
||||
* `resyncs`, `lastT`).
|
||||
*/
|
||||
export function createSnapshotBuffer({
|
||||
interpDelayMs = 100,
|
||||
maxAgeMs = 1000,
|
||||
now = null,
|
||||
onResync = null,
|
||||
} = {}) {
|
||||
const clock = typeof now === "function" ? now : () => performance.now();
|
||||
const frames = new Map(); // t -> { frame (decoded full), at (clock ms) }
|
||||
let lastT = null; // the greatest tick applied
|
||||
let lastPushAt = null; // the clock time of the last applied frame
|
||||
let resyncPending = false;
|
||||
let lastResyncAt = null; // the debounce window's start
|
||||
const stats = {
|
||||
pushed: 0,
|
||||
dropped: 0,
|
||||
gaps: 0,
|
||||
maxGapTicks: 0,
|
||||
skippedUntilFull: 0,
|
||||
resyncs: 0,
|
||||
};
|
||||
|
||||
function requestResync() {
|
||||
const at = clock();
|
||||
if (lastResyncAt !== null && at - lastResyncAt < RESYNC_DEBOUNCE_MS) {
|
||||
return; // debounced: one request per window
|
||||
}
|
||||
lastResyncAt = at;
|
||||
stats.resyncs += 1;
|
||||
if (typeof onResync === "function") onResync();
|
||||
}
|
||||
|
||||
function prune() {
|
||||
// Keep the interpolation window (a 100 ms delay is two 50 ms ticks,
|
||||
// plus margin) and the most recent frames; anything older than
|
||||
// `maxAgeMs + interpDelayMs` and at least 4 ticks back is dropped.
|
||||
for (const [t, entry] of frames) {
|
||||
if (
|
||||
lastT - t >= 4 &&
|
||||
lastPushAt !== null &&
|
||||
lastPushAt - entry.at > maxAgeMs + interpDelayMs
|
||||
) {
|
||||
frames.delete(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function push(raw) {
|
||||
if (raw === null || typeof raw !== "object" || typeof raw.t !== "number") {
|
||||
stats.dropped += 1; // malformed: counted, never fatal
|
||||
return false;
|
||||
}
|
||||
if (lastT !== null && raw.t <= lastT) {
|
||||
stats.dropped += 1; // older (or duplicate): the stream is ordered
|
||||
return false;
|
||||
}
|
||||
if (lastT !== null && raw.t > lastT + 1) {
|
||||
const gap = raw.t - lastT;
|
||||
stats.gaps += 1;
|
||||
if (gap > stats.maxGapTicks) stats.maxGapTicks = gap;
|
||||
if (gap > MAX_GAP_TICKS) {
|
||||
resyncPending = true;
|
||||
requestResync();
|
||||
}
|
||||
}
|
||||
if (resyncPending && raw.full !== true) {
|
||||
// The chain is broken (a delta whose `prev` we do not have):
|
||||
// hold until the next full frame clears the latch.
|
||||
stats.skippedUntilFull += 1;
|
||||
return false;
|
||||
}
|
||||
const at = clock();
|
||||
const decoded =
|
||||
raw.full === true ? raw : decodeApplyDelta(raw, latest());
|
||||
frames.set(raw.t, { frame: decoded, at });
|
||||
lastT = raw.t;
|
||||
lastPushAt = at;
|
||||
stats.pushed += 1;
|
||||
if (raw.full === true) resyncPending = false;
|
||||
prune();
|
||||
return true;
|
||||
}
|
||||
|
||||
function latest() {
|
||||
if (lastT === null) return null;
|
||||
const entry = frames.get(lastT);
|
||||
return entry === undefined ? null : entry.frame;
|
||||
}
|
||||
|
||||
function isFrozen(nowMs) {
|
||||
return lastPushAt !== null && nowMs - lastPushAt > maxAgeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two bracketing decoded frames plus the lerp factor, at
|
||||
* `nowMs - interpDelayMs` (an explicit `nowMs` wins over the clock —
|
||||
* the synthetic-stream seam). Null while empty; the frozen hold
|
||||
* (`a === b`, `alpha 0`, `frozen: true`) when the stream went quiet
|
||||
* or when the render time outran every frame (a latency spike:
|
||||
* hold, never extrapolate).
|
||||
*/
|
||||
function sample(nowMs) {
|
||||
const at = typeof nowMs === "number" ? nowMs : clock();
|
||||
if (frames.size === 0) return null;
|
||||
const ordered = [...frames.values()].sort((x, y) => x.at - y.at);
|
||||
const newest = ordered[ordered.length - 1];
|
||||
if (isFrozen(at) || at - interpDelayMs >= newest.at) {
|
||||
return { a: newest.frame, b: newest.frame, alpha: 0, frozen: true };
|
||||
}
|
||||
const renderAt = at - interpDelayMs;
|
||||
let bIdx = -1;
|
||||
for (let i = 0; i < ordered.length; i += 1) {
|
||||
if (ordered[i].at >= renderAt) {
|
||||
bIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bIdx === -1) {
|
||||
// `renderAt` is before the first frame (a late start): hold.
|
||||
const oldest = ordered[0];
|
||||
return { a: oldest.frame, b: oldest.frame, alpha: 0 };
|
||||
}
|
||||
if (bIdx === 0) {
|
||||
const b = ordered[0];
|
||||
return { a: b.frame, b: b.frame, alpha: 0 };
|
||||
}
|
||||
const aEntry = ordered[bIdx - 1];
|
||||
const bEntry = ordered[bIdx];
|
||||
const span = bEntry.at - aEntry.at;
|
||||
const alpha =
|
||||
span > 0
|
||||
? Math.min(1, Math.max(0, (renderAt - aEntry.at) / span))
|
||||
: 0;
|
||||
return { a: aEntry.frame, b: bEntry.frame, alpha };
|
||||
}
|
||||
|
||||
return {
|
||||
push,
|
||||
sample,
|
||||
latest,
|
||||
needsFullResync: () => resyncPending,
|
||||
stats: () => ({ ...stats, lastT, frozen: isFrozen(clock()) }),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ lerpState
|
||||
|
||||
function lerp(v0, v1, alpha) {
|
||||
return v0 + (v1 - v0) * alpha;
|
||||
}
|
||||
|
||||
function num(value, fallback = 0) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* One collection between two frames: the union of the identity keys, in
|
||||
* `left`'s order then the new ones; an entity in both is lerped (the
|
||||
* per-kind fields below), one only in `right` appears at `b` (its own
|
||||
* values, visible for the whole interval), one only in `left` vanishes
|
||||
* at the `b` boundary (held until `alpha >= 0.5`, then dropped).
|
||||
*/
|
||||
function lerpCollection(left, right, key, kind, alpha, atHalf) {
|
||||
const rightKeys = new Set(right.map((e) => key(e)));
|
||||
const out = [];
|
||||
for (const a of left) {
|
||||
const k = key(a);
|
||||
if (!rightKeys.has(k)) {
|
||||
if (!atHalf) out.push(a); // still visible: held at a's values
|
||||
continue;
|
||||
}
|
||||
const b = right.find((e) => key(e) === k);
|
||||
out.push(lerpEntry(a, b, kind, alpha, atHalf));
|
||||
}
|
||||
for (const b of right) {
|
||||
if (!left.some((a) => key(a) === key(b))) out.push(b); // appears at b
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lerpEntry(a, b, kind, alpha, atHalf) {
|
||||
if (kind === "player") {
|
||||
return {
|
||||
...b,
|
||||
x: lerp(num(a.x), num(b.x), alpha),
|
||||
y: lerp(num(a.y), num(b.y), alpha),
|
||||
f: atHalf ? b.f : a.f, // the facing snaps at the boundary
|
||||
h: atHalf ? b.h : a.h, // the carried item snaps with it
|
||||
s: atHalf ? b.s : a.s, // the state snaps with it
|
||||
};
|
||||
}
|
||||
if (kind === "station") {
|
||||
return {
|
||||
...b,
|
||||
p: lerp(num(a.p), num(b.p), alpha), // the progress lerps
|
||||
q: lerp(num(a.q, 1), num(b.q, 1), alpha), // the content quality
|
||||
g: atHalf ? b.g : a.g,
|
||||
s: atHalf ? b.s : a.s,
|
||||
};
|
||||
}
|
||||
if (kind === "order") {
|
||||
return {
|
||||
...b,
|
||||
l: lerp(num(a.l), num(b.l), alpha), // the time-left share lerps
|
||||
};
|
||||
}
|
||||
// hazard: the life lerps; a `moving_obstacle`'s position does too
|
||||
return {
|
||||
...b,
|
||||
x: lerp(num(a.x), num(b.x), alpha),
|
||||
y: lerp(num(a.y), num(b.y), alpha),
|
||||
l: lerp(num(a.l), num(b.l), alpha),
|
||||
};
|
||||
}
|
||||
|
||||
const PLAYERS = (f) => (Array.isArray(f.p) ? f.p : []);
|
||||
const STATIONS = (f) => (Array.isArray(f.st) ? f.st : []);
|
||||
const ORDERS = (f) => (Array.isArray(f.o) ? f.o : []);
|
||||
const HAZARDS = (f) => (Array.isArray(f.hz) ? f.hz : []);
|
||||
|
||||
const P_KEY = (e) => `p:${e.i}`;
|
||||
const S_KEY = (e) => `st:${e.k}@${e.x},${e.y}`;
|
||||
const O_KEY = (e) => `o:${e.id}`;
|
||||
const H_KEY = (e) => `hz:${e.t}@${e.x},${e.y}`;
|
||||
|
||||
/**
|
||||
* A render-ready state from the bracketing frames: `lerpState(a, b,
|
||||
* alpha)` with `a === b` (the frozen hold) returns `b`'s values
|
||||
* verbatim for the discrete fields. The scalars: `t`/`s`/`tgt` from
|
||||
* `b` (discrete), `tl` lerped; `e` rides with `b` (the renderer
|
||||
* consumes events through the event queue, not the lerp).
|
||||
*/
|
||||
export function lerpState(a, b, alpha) {
|
||||
if (b === null || typeof b !== "object") {
|
||||
throw new Error("lerpState: `b` is not a frame");
|
||||
}
|
||||
const left = a === null || typeof a !== "object" ? b : a;
|
||||
const factor = typeof alpha === "number" ? Math.min(1, Math.max(0, alpha)) : 0;
|
||||
const atHalf = factor >= 0.5;
|
||||
return {
|
||||
full: true,
|
||||
t: b.t,
|
||||
s: b.s,
|
||||
tgt: b.tgt,
|
||||
tl: lerp(num(left.tl), num(b.tl), factor),
|
||||
p: lerpCollection(PLAYERS(left), PLAYERS(b), P_KEY, "player", factor, atHalf),
|
||||
st: lerpCollection(STATIONS(left), STATIONS(b), S_KEY, "station", factor, atHalf),
|
||||
o: lerpCollection(ORDERS(left), ORDERS(b), O_KEY, "order", factor, atHalf),
|
||||
hz: lerpCollection(HAZARDS(left), HAZARDS(b), H_KEY, "hazard", factor, atHalf),
|
||||
e: Array.isArray(b.e) ? b.e : [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// Undercooked — the SVG atlas (phase 07, task 01).
|
||||
//
|
||||
// Loads `app/static/assets/index.json` (written by
|
||||
// `undercooked.tools.gen_art_index`) and turns every symbol into an
|
||||
// `<img>` the renderer can draw. Browsers do not render a
|
||||
// `file.svg#symbol-id` fragment as an image (the symbol paints
|
||||
// nothing), and the page CSP (`style-src 'self'`) blocks the
|
||||
// `style`-attribute colour hooks the art uses — both in the decoded
|
||||
// image and while `DOMParser` parses the file in the page context —
|
||||
// so each symbol is decoded the way the engine actually supports: the
|
||||
// file is fetched as text, the `style` hooks are pulled into neutral
|
||||
// attributes, the wanted symbol is spliced into a standalone document
|
||||
// (its `<use>` references expanded to plain geometry, the hooks
|
||||
// resolved to literal `fill`s), and that document is decoded through a
|
||||
// `data:` URL — the CSP already allows it (`img-src data:`), and the
|
||||
// bytes are the app's own.
|
||||
//
|
||||
// Contract:
|
||||
// * `atlas.ready()` resolves when every symbol has decoded; it rejects
|
||||
// (naming every sprite that failed) when the index or a sprite does
|
||||
// not load, and a failed load clears the cache so a later `ready()`
|
||||
// retries from scratch.
|
||||
// * `atlas.draw(ctx, id, x, y, w, h, opts)` throws an `Error` for an
|
||||
// unknown id or a sprite that decoded without pixels — it never
|
||||
// draws nothing silently.
|
||||
// * `opts.tint` is a CSS colour painted over the non-transparent part
|
||||
// of the sprite (`source-atop`), `opts.alpha` a 0..1 opacity,
|
||||
// `opts.flipX` mirrors the sprite in the target rect.
|
||||
|
||||
const INDEX_URL = "/static/assets/index.json";
|
||||
const SVG_BASE = "/static/assets/";
|
||||
|
||||
const index = new Map(); // id -> { file, w, h }
|
||||
const images = new Map(); // id -> HTMLImageElement
|
||||
const svgTexts = new Map(); // file -> the fetched file text
|
||||
let readyPromise = null;
|
||||
|
||||
/**
|
||||
* Resolve one `color: var(--uc-*, #hex)` (or literal `color: #hex`)
|
||||
* style into a hex colour, or `null` when the style has no colour.
|
||||
* An image document cannot resolve custom properties — the page's
|
||||
* styles are CSP-blocked there and the decode fails with them — so
|
||||
* the art's cook-colour hooks (a `style` on the `<use>`, a
|
||||
* `currentColor` on the shape it pulls in) are resolved here, before
|
||||
* the document is serialised.
|
||||
*/
|
||||
function colourFromStyle(style) {
|
||||
if (!style) return null;
|
||||
const varMatch = style.match(
|
||||
/color\s*:\s*var\(\s*(--[\w-]+)\s*,\s*(#[0-9a-fA-F]{3,8})\s*\)/i,
|
||||
);
|
||||
if (varMatch) return varMatch[2];
|
||||
const literal = style.match(/color\s*:\s*(#[0-9a-fA-F]{3,8})(\s*[;,]|\s*$)/i);
|
||||
return literal ? literal[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand every `<use>` in a (cloned) subtree into a clone of its
|
||||
* target, then paint each `fill="currentColor"` with `color`. The
|
||||
* expansion happens because the colour hooks ride on a `style`
|
||||
* attribute the image context forbids; once expanded, the shapes are
|
||||
* plain geometry and the colour is a plain `fill`.
|
||||
*/
|
||||
function paintColour(container, color, document) {
|
||||
const walk = (node) => {
|
||||
for (const element of Array.from(node.children)) {
|
||||
const isUse = element.tagName === "use";
|
||||
const isLink = element.tagName === "a";
|
||||
if (isUse || isLink) {
|
||||
const href = element.getAttribute("href");
|
||||
const target = href ? document.getElementById(href.slice(1)) : null;
|
||||
if (target) {
|
||||
const expansion = target.cloneNode(true);
|
||||
expansion.removeAttribute("id");
|
||||
element.replaceWith(expansion);
|
||||
walk(expansion);
|
||||
} else {
|
||||
element.remove();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (element.getAttribute("fill") === "currentColor") {
|
||||
element.setAttribute("fill", color);
|
||||
}
|
||||
walk(element);
|
||||
}
|
||||
};
|
||||
walk(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the `style` hooks out of the file text before parsing: a
|
||||
* `DOMParser` of `image/svg+xml` applies inline `style` attributes in
|
||||
* the embedding document's context, where the page CSP
|
||||
* (`style-src 'self'`) blocks the `var()` colours before they can be
|
||||
* read. The hooks are carried on a neutral attribute instead, in
|
||||
* document order, so the parsed tree can be walked and each hook's
|
||||
* colour resolved from the extracted value.
|
||||
*/
|
||||
function extractStyleHooks(fileText) {
|
||||
const hooks = [];
|
||||
const sanitized = fileText.replace(/\sstyle="([^"]*)"/g, (_match, value) => {
|
||||
hooks.push(value);
|
||||
return ` ucstyle="${hooks.length - 1}"`;
|
||||
});
|
||||
return { hooks, sanitized };
|
||||
}
|
||||
|
||||
/**
|
||||
* The standalone document one symbol decodes into: the file's root with
|
||||
* every other symbol removed and the wanted one replaced by a plain
|
||||
* `<g>` (a `<symbol>` renders nothing on its own). `<use>` references
|
||||
* are expanded to plain geometry and the cook-colour `style` hooks are
|
||||
* resolved to literal fills, because the image context forbids the
|
||||
* `style` attribute (and any `var()`) the hooks would otherwise need.
|
||||
* The symbol's `viewBox` (or `width`/`height`) sizes the root's
|
||||
* viewport, so the geometry lands 1:1.
|
||||
*/
|
||||
function buildSvgDocument(fileText, file, id) {
|
||||
const { hooks, sanitized } = extractStyleHooks(fileText);
|
||||
const document = new DOMParser().parseFromString(sanitized, "image/svg+xml");
|
||||
if (document.querySelector("parsererror")) {
|
||||
throw new Error(`atlas file failed to parse: ${file}`);
|
||||
}
|
||||
const root = document.documentElement.cloneNode(true);
|
||||
const symbol = root.querySelector(`symbol#${CSS.escape(id)}`);
|
||||
if (!symbol) throw new Error(`atlas symbol not found in ${file}: ${id}`);
|
||||
for (const other of root.querySelectorAll("symbol")) {
|
||||
if (other !== symbol) other.remove();
|
||||
}
|
||||
const viewBox = symbol.getAttribute("viewBox");
|
||||
const group = document.createElementNS(document.documentElement.namespaceURI, "g");
|
||||
while (symbol.firstChild) group.appendChild(symbol.firstChild);
|
||||
symbol.replaceWith(group);
|
||||
// The colour hooks: a `ucstyle` on the <use> that pulls in the shape.
|
||||
for (const use of Array.from(group.children)) {
|
||||
if (use.tagName !== "use") continue;
|
||||
const hook = use.getAttribute("ucstyle");
|
||||
const color =
|
||||
hook === null ? null : colourFromStyle(hooks[Number(hook)]);
|
||||
use.removeAttribute("ucstyle");
|
||||
if (color === null) continue;
|
||||
const href = use.getAttribute("href");
|
||||
const target = href ? document.getElementById(href.slice(1)) : null;
|
||||
if (!target) {
|
||||
use.remove();
|
||||
continue;
|
||||
}
|
||||
const expansion = target.cloneNode(true);
|
||||
expansion.removeAttribute("id");
|
||||
use.replaceWith(expansion);
|
||||
paintColour(expansion, color, document);
|
||||
}
|
||||
if (viewBox) {
|
||||
root.setAttribute("viewBox", viewBox);
|
||||
} else {
|
||||
const width = symbol.getAttribute("width");
|
||||
const height = symbol.getAttribute("height");
|
||||
if (width) root.setAttribute("width", width);
|
||||
if (height) root.setAttribute("height", height);
|
||||
}
|
||||
return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(
|
||||
new XMLSerializer().serializeToString(root),
|
||||
);
|
||||
}
|
||||
|
||||
function decodeImage(file, id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error(`atlas image failed to decode: ${file}#${id}`));
|
||||
const text = svgTexts.get(file);
|
||||
if (text === undefined) {
|
||||
reject(new Error(`atlas has no text for ${file} (id ${id})`));
|
||||
return;
|
||||
}
|
||||
img.src = buildSvgDocument(text, file, id);
|
||||
dataUrls.set(id, img.src); // the `data:` URL the DOM icons use (dataUrl)
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const response = await fetch(INDEX_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`atlas index fetch failed: HTTP ${response.status}`);
|
||||
}
|
||||
const table = await response.json();
|
||||
const entries = Object.entries(table);
|
||||
if (entries.length === 0) throw new Error("atlas index is empty");
|
||||
const files = [...new Set(entries.map(([, entry]) => entry.file))];
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const fileResponse = await fetch(`${SVG_BASE}${file}`);
|
||||
if (!fileResponse.ok) {
|
||||
throw new Error(`atlas fetch failed for ${file}: HTTP ${fileResponse.status}`);
|
||||
}
|
||||
svgTexts.set(file, await fileResponse.text());
|
||||
}),
|
||||
);
|
||||
const settled = await Promise.allSettled(
|
||||
entries.map(async ([id, entry]) => {
|
||||
images.set(id, await decodeImage(entry.file, id));
|
||||
}),
|
||||
);
|
||||
const failures = settled
|
||||
.filter((outcome) => outcome.status === "rejected")
|
||||
.map((outcome) => outcome.reason.message);
|
||||
if (failures.length > 0) throw new Error(`atlas load failed: ${failures.join("; ")}`);
|
||||
for (const [id, entry] of entries) index.set(id, entry);
|
||||
}
|
||||
|
||||
/** Resolve when every symbol has decoded; reject naming the failures. */
|
||||
export function ready() {
|
||||
if (readyPromise === null) {
|
||||
readyPromise = load().catch((error) => {
|
||||
readyPromise = null; // a failed load may be retried from scratch
|
||||
images.clear();
|
||||
index.clear();
|
||||
svgTexts.clear();
|
||||
dataUrls.clear();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return readyPromise;
|
||||
}
|
||||
|
||||
/** The number of symbols the index names (0 before `ready()` resolves). */
|
||||
export function size() {
|
||||
return index.size;
|
||||
}
|
||||
|
||||
/** The `{ file, w, h }` entry for one id, or `null` when unknown. */
|
||||
export function meta(id) {
|
||||
return index.get(id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The decoded `data:` URL for one id (or `null` when unknown / not
|
||||
* ready) — the DOM half of the atlas: the HUD's recipe-ticket and
|
||||
* streak icons are `<img>` elements on these URLs (the CSP allows
|
||||
* `img-src data:`; the bytes are the app's own, the same decode the
|
||||
* canvas uses). `#symbol` fragments never render from a cross-file
|
||||
* `<img>`, so the `data:` URL is the only same-origin way to put a
|
||||
* symbol in the DOM.
|
||||
*/
|
||||
export function dataUrl(id) {
|
||||
const entry = index.get(id);
|
||||
if (entry === undefined) return null;
|
||||
return dataUrls.get(id) ?? null;
|
||||
}
|
||||
|
||||
const dataUrls = new Map(); // id -> the decoded data: URL (the decode's cache)
|
||||
|
||||
/**
|
||||
* Draw one symbol, scaled to the `(x, y, w, h)` rect, onto `ctx`.
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx the target 2D context
|
||||
* @param {string} id a symbol id from index.json
|
||||
* @param {number} x the rect's left edge
|
||||
* @param {number} y the rect's top edge
|
||||
* @param {number} w the rect's width
|
||||
* @param {number} h the rect's height
|
||||
* @param {{tint?: string, alpha?: number, flipX?: boolean}} [opts]
|
||||
* @throws {Error} when the id is unknown or the sprite has no pixels —
|
||||
* the atlas never draws nothing silently.
|
||||
*/
|
||||
export async function draw(ctx, id, x, y, w, h, opts = {}) {
|
||||
await ready();
|
||||
paint(ctx, id, x, y, w, h, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* The synchronous half of `draw` (the renderer's hot path: after
|
||||
* `ready()` has resolved, every symbol is decoded and this draws
|
||||
* without a promise round-trip). Throws like `draw` on an unknown id
|
||||
* or a pixel-less sprite; throws `atlas not ready` before the load has
|
||||
* settled (the renderer awaits `ready()` first).
|
||||
*/
|
||||
export function drawSync(ctx, id, x, y, w, h, opts = {}) {
|
||||
if (readyPromise === null) {
|
||||
throw new Error("atlas not ready (call ready() first)");
|
||||
}
|
||||
paint(ctx, id, x, y, w, h, opts);
|
||||
}
|
||||
|
||||
function paint(ctx, id, x, y, w, h, opts = {}) {
|
||||
const entry = index.get(id);
|
||||
if (!entry) throw new Error(`unknown atlas id: ${id}`);
|
||||
const img = images.get(id);
|
||||
if (!img || !img.complete || img.naturalWidth === 0 || img.naturalHeight === 0) {
|
||||
throw new Error(`atlas image has no pixels: ${id}`);
|
||||
}
|
||||
ctx.save();
|
||||
if (opts.flipX) {
|
||||
// Mirror in the target rect: flip about its centre, draw as usual.
|
||||
ctx.translate(x + w / 2, y + h / 2);
|
||||
ctx.scale(-1, 1);
|
||||
ctx.translate(-(x + w / 2), -(y + h / 2));
|
||||
}
|
||||
if (typeof opts.alpha === "number") {
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, opts.alpha));
|
||||
}
|
||||
ctx.drawImage(img, x, y, w, h);
|
||||
if (opts.tint) {
|
||||
// `source-atop` paints the tint over the sprite's non-transparent
|
||||
// pixels only, so the background stays untouched.
|
||||
ctx.globalCompositeOperation = "source-atop";
|
||||
ctx.fillStyle = opts.tint;
|
||||
ctx.fillRect(x, y, w, h);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Undercooked — the camera (phase 07, task 04).
|
||||
//
|
||||
// Fits the (approximated) kitchen into the canvas viewport: one scale
|
||||
// (pixels per tile) plus a centred letterbox offset. The scale is the
|
||||
// fit-to-viewport value, snapped *down* to an integer-ish step
|
||||
// (`snapScale`) so tile boundaries land on whole device pixels (a
|
||||
// sub-pixel drift that changes every frame is shimmer), then clamped to
|
||||
// the zoom bounds, which the task states in tiles-per-pixel
|
||||
// (`minTilesPerPx` = the most zoomed-in, `maxTilesPerPx` = the most
|
||||
// zoomed-out; `scale = 1 / tilesPerPx`).
|
||||
//
|
||||
// DPR-aware: the backing store is `cssSize × dpr` with
|
||||
// `dpr = min(devicePixelRatio, 2)` (a 4K 3× display is not worth the
|
||||
// fill-rate); `snapScale`'s step is `1 / (2 × dpr)`, so the snap is
|
||||
// integer at the device-pixel level. `resize()` re-reads the CSS size
|
||||
// (and re-applies the backing store), so a window resize or a DPR
|
||||
// change (a drag between monitors) refits.
|
||||
//
|
||||
// A pure core (`computeFit`, `snapScale`) for the e2e suites plus a
|
||||
// `createCamera` wrapper that owns the DOM plumbing (the canvas
|
||||
// backing store, the `resize` / `visibilitychange` listeners). The
|
||||
// world bounds are the kitchen's: the renderer derives them from the
|
||||
// snapshot (the grid never leaves the server, PLAN §8 — the floor is
|
||||
// the entity bounding box + a one-tile margin, the client-side
|
||||
// approximation documented in `docs/CLIENT.md`), `setWorld(w, h)`
|
||||
// refits only when the (rounded) bounds change, so a cook walking does
|
||||
// not re-snap the scale mid-step.
|
||||
|
||||
/** The `devicePixelRatio` cap (a 3× display renders at 2×). */
|
||||
export const MAX_DPR = 2;
|
||||
|
||||
/**
|
||||
* Snap a scale down to the integer-ish step. `step = 1 / (2 × dpr)`
|
||||
* (dpr 1 → 0.5 px/tile, dpr 2 → 0.25): the largest multiple of the
|
||||
* step at or below `scale`, at least one step (a zero-sized viewport
|
||||
* must not produce a zero scale). `scale ≤ 0` is `0` (the caller's
|
||||
* letterbox handles it — nothing to fit).
|
||||
*/
|
||||
export function snapScale(scale, dpr) {
|
||||
const ratio = Math.max(1, Number.isFinite(dpr) ? dpr : 1);
|
||||
if (!(scale > 0)) return 0;
|
||||
const step = 1 / (2 * ratio);
|
||||
const whole = Math.floor(scale / step - 1e-9);
|
||||
return Math.max(1, whole) * step;
|
||||
}
|
||||
|
||||
/**
|
||||
* One fit: `{scale, offX, offY}` in CSS pixels. The raw fit is
|
||||
* `min(view / world)` on each axis; it is snapped down (`snapScale`)
|
||||
* and clamped to `[1 / maxTilesPerPx, 1 / minTilesPerPx]`; the offset
|
||||
* centres the world on the remainder (the letterbox, negative when the
|
||||
* clamp zoomed in past the fit — the kitchen is bigger than the view,
|
||||
* and the centre of both coincide).
|
||||
*/
|
||||
export function computeFit({
|
||||
viewW,
|
||||
viewH,
|
||||
worldW,
|
||||
worldH,
|
||||
dpr = 1,
|
||||
minTilesPerPx = 0.02,
|
||||
maxTilesPerPx = 0.5,
|
||||
} = {}) {
|
||||
const vw = Math.max(0, Number(viewW) || 0);
|
||||
const vh = Math.max(0, Number(viewH) || 0);
|
||||
const ww = Math.max(1e-6, Number(worldW) || 0);
|
||||
const wh = Math.max(1e-6, Number(worldH) || 0);
|
||||
if (vw === 0 || vh === 0) return { scale: 0, offX: 0, offY: 0 };
|
||||
const minScale = 1 / Math.max(1e-9, Number(maxTilesPerPx) || 0.5);
|
||||
const maxScale = 1 / Math.max(1e-9, Number(minTilesPerPx) || 0.02);
|
||||
const raw = Math.min(vw / ww, vh / wh);
|
||||
const scale = Math.min(maxScale, Math.max(minScale, snapScale(raw, dpr)));
|
||||
return {
|
||||
scale,
|
||||
offX: (vw - ww * scale) / 2,
|
||||
offY: (vh - wh * scale) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One camera.
|
||||
*
|
||||
* `resize()` — re-reads the CSS size (`getSize()`), applies the backing
|
||||
* store at the capped DPR and refits. `setWorld(w, h)` — the kitchen's
|
||||
* bounds; a change (integer-rounded, so sub-pixel drift does not
|
||||
* refit) refits. `worldToScreen(x, y)` / `screenToWorld(x, y)` — the
|
||||
* transforms in CSS pixels (the renderer scales the context by the DPR
|
||||
* itself, so the math stays in CSS units). `attach()` / `detach()` —
|
||||
* the `resize` and `visibilitychange` listeners (the DPR can change
|
||||
* with the window).
|
||||
*/
|
||||
export function createCamera({
|
||||
canvas = null,
|
||||
minTilesPerPx = 0.02,
|
||||
maxTilesPerPx = 0.5,
|
||||
getSize = null,
|
||||
dprOf = null,
|
||||
onResize = null,
|
||||
} = {}) {
|
||||
const sizeOf =
|
||||
typeof getSize === "function"
|
||||
? getSize
|
||||
: () => ({
|
||||
w: canvas !== null ? canvas.clientWidth : 0,
|
||||
h: canvas !== null ? canvas.clientHeight : 0,
|
||||
});
|
||||
const dprGet =
|
||||
typeof dprOf === "function"
|
||||
? dprOf
|
||||
: () =>
|
||||
typeof window !== "undefined" &&
|
||||
Number.isFinite(window.devicePixelRatio)
|
||||
? Math.min(MAX_DPR, window.devicePixelRatio)
|
||||
: 1;
|
||||
|
||||
let worldW = 0;
|
||||
let worldH = 0;
|
||||
let key = null; // the rounded world + size + dpr the fit was computed for
|
||||
let fit = { scale: 0, offX: 0, offY: 0 };
|
||||
let dpr = 1;
|
||||
let cssW = 0;
|
||||
let cssH = 0;
|
||||
const listeners = [];
|
||||
|
||||
function applySize() {
|
||||
const size = sizeOf();
|
||||
cssW = size.w;
|
||||
cssH = size.h;
|
||||
dpr = dprGet();
|
||||
if (canvas !== null) {
|
||||
canvas.width = Math.max(0, Math.round(cssW * dpr));
|
||||
canvas.height = Math.max(0, Math.round(cssH * dpr));
|
||||
}
|
||||
refit();
|
||||
}
|
||||
|
||||
function refit() {
|
||||
const k = `${Math.round(worldW)}x${Math.round(worldH)}@${cssW}x${cssH}×${dpr}`;
|
||||
if (k === key) return;
|
||||
key = k;
|
||||
fit = computeFit({
|
||||
viewW: cssW,
|
||||
viewH: cssH,
|
||||
worldW,
|
||||
worldH,
|
||||
dpr,
|
||||
minTilesPerPx,
|
||||
maxTilesPerPx,
|
||||
});
|
||||
if (typeof onResize === "function") onResize();
|
||||
}
|
||||
|
||||
function attach() {
|
||||
if (typeof window !== "undefined") {
|
||||
for (const name of ["resize", "visibilitychange"]) {
|
||||
window.addEventListener(name, applySize);
|
||||
listeners.push(name);
|
||||
}
|
||||
}
|
||||
applySize();
|
||||
}
|
||||
|
||||
function detach() {
|
||||
if (typeof window !== "undefined") {
|
||||
for (const name of listeners.splice(0)) window.removeEventListener(name, applySize);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attach,
|
||||
detach,
|
||||
resize: applySize,
|
||||
setWorld: (w, h) => {
|
||||
worldW = w;
|
||||
worldH = h;
|
||||
refit();
|
||||
},
|
||||
worldToScreen: (x, y) => ({
|
||||
x: fit.offX + x * fit.scale,
|
||||
y: fit.offY + y * fit.scale,
|
||||
}),
|
||||
screenToWorld: (x, y) => ({
|
||||
x: (x - fit.offX) / (fit.scale || 1),
|
||||
y: (y - fit.offY) / (fit.scale || 1),
|
||||
}),
|
||||
stats: () => ({
|
||||
scale: fit.scale,
|
||||
offX: fit.offX,
|
||||
offY: fit.offY,
|
||||
worldW,
|
||||
worldH,
|
||||
cssW,
|
||||
cssH,
|
||||
dpr,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
// Undercooked — the canvas renderer (phase 07, task 04).
|
||||
//
|
||||
// `drawFrame(view, nowMs)` draws one render-ready state (the loop's
|
||||
// `lerpState` output, docs/CLIENT.md "The snapshot buffer and the
|
||||
// render loop") in a fixed layer order — `LAYERS`, in draw order:
|
||||
//
|
||||
// floor → floor decals (spills) → station bases → carried-under
|
||||
// items (the content a station holds) → players (y-sorted) → carried
|
||||
// overlays (the held item) → station progress rings → hazards →
|
||||
// particles (the FX) → selection / target highlight.
|
||||
//
|
||||
// Sprite selection maps the wire's vocabulary onto atlas ids:
|
||||
// `stationSpriteId(kind, state)` (the stove has a sprite per state —
|
||||
// `STATION_STATE_VARIANT`; the other fixtures are state-blind),
|
||||
// `itemSpriteId(kind, form)` (the `REACHABLE_ITEM_FORMS` table —
|
||||
// `tests/unit/test_client_sprite_map.py` keeps it equal to the sim's
|
||||
// `reachable_item_forms()`, so a pair the simulation cannot produce
|
||||
// falls back to the kind's `raw` sprite instead of a blank),
|
||||
// `cookSpriteId(seat, facing, frame)` (4 bodies × 4 facings × 2 walk
|
||||
// frames) and `tileSpriteId(kind, x, y)` (the floor's 4 texture
|
||||
// variants, chosen by a deterministic cell hash). An atlas id that
|
||||
// throws (an unknown id, a sprite with no pixels) is logged **once**
|
||||
// (console, naming the id) and drawn as a plain fallback rect —
|
||||
// never silently blank, never spammed.
|
||||
//
|
||||
// Animation is fixed-step: the walk cycle is `floor(now / 125) % 2`
|
||||
// (8 steps/s, a 120 Hz display does not animate faster), the stun spin
|
||||
// and the hazard pulses step the same way, and every clock is derived
|
||||
// from the loop's `now` — the loop itself pauses on `document.hidden`
|
||||
// (`render/loop.js`), so the sprites pause with it; a large `now` jump
|
||||
// (a resume) cannot jump the animation, only land on the next step.
|
||||
//
|
||||
// The kitchen's floor is a client-side approximation: the grid never
|
||||
// leaves the server (PLAN §8), so the floor is the stations' + the
|
||||
// players' bounding box plus a one-tile margin, with the wall as the
|
||||
// rectangle's edge. The target highlight is the same kind of
|
||||
// approximation: `candidateCells(x, y, facing)` mirrors
|
||||
// `sim/interact.py`'s `candidate_cells` (the front tile, the two
|
||||
// diagonal-front, the tile under the cook) so reaching for a station
|
||||
// reads the way the server will resolve it.
|
||||
//
|
||||
// `renderStats()` — the frame census the e2e perf assertions read off
|
||||
// `window.__uc`: `drawCalls` (this frame), `frameMs` (moving average
|
||||
// of the measured draw time), `frameMsP95` (over every frame since the
|
||||
// renderer was created), `frames`, `warnedSprites`.
|
||||
|
||||
import * as atlas from "./atlas.js";
|
||||
import { facingName, splitItemShort } from "../net/decode.js";
|
||||
|
||||
/** The layer order (the task's list, frozen — docs/CLIENT.md's table). */
|
||||
export const LAYERS = Object.freeze([
|
||||
"floor",
|
||||
"floorDecals",
|
||||
"stationBases",
|
||||
"itemsUnder",
|
||||
"players",
|
||||
"carryOverlays",
|
||||
"progressRings",
|
||||
"hazards",
|
||||
"particles",
|
||||
"targetHighlight",
|
||||
]);
|
||||
|
||||
/** The walk cycle's step (ms per half-cycle: 8 steps/s, fixed-step). */
|
||||
export const WALK_STEP_MS = 125;
|
||||
|
||||
/** One sprite of the stove per state (`burning` is a hazard, drawn in
|
||||
* the hazard layer — a `fire` on the stove's cell switches the base). */
|
||||
export const STATION_STATE_VARIANT = Object.freeze({
|
||||
idle: "off",
|
||||
working: "working",
|
||||
busy: "working",
|
||||
ready: "ready",
|
||||
dirty: "off",
|
||||
blocked: "off",
|
||||
});
|
||||
|
||||
/**
|
||||
* The (kind, form) pairs the simulation can produce — the client's copy
|
||||
* of `undercooked.tools.gen_art_index.reachable_item_forms()`.
|
||||
* `tests/unit/test_client_sprite_map.py` keeps it equal to the sim's
|
||||
* fixpoint walk (a sim change that orphans a pair is a build
|
||||
* failure, naming the pair). An unreachable pair maps to the kind's
|
||||
* `raw` sprite (every kind has one): a protocol bug then draws a
|
||||
* *something*, and the atlas-throw path stays reserved for art that
|
||||
* is genuinely missing.
|
||||
*/
|
||||
export const REACHABLE_ITEM_FORMS = Object.freeze({
|
||||
tomato: ["raw", "chopped", "cooking", "cooked", "burnt"],
|
||||
onion: ["raw", "chopped", "cooking", "cooked", "burnt"],
|
||||
lettuce: ["raw", "chopped", "cooking", "cooked", "burnt"],
|
||||
dough: ["raw", "chopped", "cooking", "cooked", "burnt"],
|
||||
patty: ["raw", "chopped", "cooking", "cooked", "burnt"],
|
||||
fish: ["raw", "chopped", "cooking", "cooked", "burnt"],
|
||||
rice: ["raw", "cooking", "cooked", "burnt"],
|
||||
plate: ["raw", "dirty", "plated"],
|
||||
});
|
||||
|
||||
/** One station's atlas id (the stove's state, the others state-blind). */
|
||||
export function stationSpriteId(kind, state) {
|
||||
if (kind === "stove") {
|
||||
const variant = STATION_STATE_VARIANT[state] ?? "off";
|
||||
return `station-stove-${variant}`;
|
||||
}
|
||||
return `station-${kind}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One (kind, form) pair's atlas id. A reachable pair is direct
|
||||
* (`item-tomato-chopped`); an unreachable one (the sim cannot produce
|
||||
* it — `rice_chopped`, a non-plate `plated`) falls back to the kind's
|
||||
* `raw` sprite.
|
||||
*/
|
||||
export function itemSpriteId(kind, form) {
|
||||
const forms = REACHABLE_ITEM_FORMS[kind];
|
||||
if (forms !== undefined && forms.includes(form)) {
|
||||
return `item-${kind}-${form}`;
|
||||
}
|
||||
return `item-${kind}-raw`;
|
||||
}
|
||||
|
||||
/** One cook's atlas id (seat 0-3, facing index 0-3, frame 0-1). */
|
||||
export function cookSpriteId(seat, facing, frame) {
|
||||
const name = facingName(facing) ?? "down";
|
||||
const body = (Math.max(0, Math.trunc(seat)) % 4) + 1;
|
||||
const step = Number.isInteger(frame) ? Math.abs(frame) % 2 : 0;
|
||||
return `cook-${body}-${name}-${step}`;
|
||||
}
|
||||
|
||||
/** The floor's variant for a cell (deterministic: ~1/3 are textured). */
|
||||
export function tileSpriteId(kind, x, y) {
|
||||
if (kind !== "floor") return `tile-${kind}`;
|
||||
const n = (Math.floor(x) * 3 + Math.floor(y) * 5) % 5;
|
||||
return n < 2 ? `tile-floor-${n + 1}` : "tile-floor";
|
||||
}
|
||||
|
||||
/** The stove's cookware for a kind (the sim's `STOVE_RAW_SECONDS` half:
|
||||
* the pan at 5 s for patty/fish, the pot for the rest, rice included). */
|
||||
export function cookwareSpriteId(kind) {
|
||||
return kind === "patty" || kind === "fish" ? "station-pan" : "station-pot";
|
||||
}
|
||||
|
||||
/**
|
||||
* The reach zone per facing (the client-side mirror of
|
||||
* `sim/interact.py`'s `candidate_cells`: the front tile, the two
|
||||
* diagonal-front tiles, the tile under the cook — in priority order,
|
||||
* deduplicated). The facing is the wire's *name* (`"up"` / …).
|
||||
*/
|
||||
const CANDIDATE_OFFSETS = Object.freeze({
|
||||
up: [
|
||||
[0, -1],
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[0, 0],
|
||||
],
|
||||
right: [
|
||||
[1, 0],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
down: [
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
left: [
|
||||
[-1, 0],
|
||||
[-1, 1],
|
||||
[-1, -1],
|
||||
[0, 0],
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* The cells the reaching hand walks, in priority order — the
|
||||
* `interact.candidate_cells` mirror. `(x, y)` is the cook's continuous
|
||||
* position; the cells are the (possibly out-of-bounds) integer pairs.
|
||||
*/
|
||||
export function candidateCells(x, y, facing) {
|
||||
const base = [Math.floor(x), Math.floor(y)];
|
||||
const offsets = CANDIDATE_OFFSETS[facing] ?? CANDIDATE_OFFSETS.down;
|
||||
const cells = [];
|
||||
const seen = new Set();
|
||||
for (const [dx, dy] of offsets) {
|
||||
const key = `${base[0] + dx},${base[1] + dy}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
cells.push([base[0] + dx, base[1] + dy]);
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
const FLOOR_MARGIN = 1; // the bounding box's padding (tiles)
|
||||
const FALLBACK_WORLD = { minX: 0, minY: 0, w: 12, h: 8 };
|
||||
|
||||
/** The kitchen's bounds: the stations' + players' cells, padded. */
|
||||
function kitchenBounds(view) {
|
||||
const cells = [];
|
||||
for (const entry of view.st ?? []) {
|
||||
if (Number.isFinite(entry.x) && Number.isFinite(entry.y)) {
|
||||
cells.push([Math.trunc(entry.x), Math.trunc(entry.y)]);
|
||||
}
|
||||
}
|
||||
for (const entry of view.p ?? []) {
|
||||
if (Number.isFinite(entry.x) && Number.isFinite(entry.y)) {
|
||||
cells.push([Math.floor(entry.x), Math.floor(entry.y)]);
|
||||
}
|
||||
}
|
||||
if (cells.length === 0) return { ...FALLBACK_WORLD };
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const [cx, cy] of cells) {
|
||||
minX = Math.min(minX, cx);
|
||||
minY = Math.min(minY, cy);
|
||||
maxX = Math.max(maxX, cx);
|
||||
maxY = Math.max(maxY, cy);
|
||||
}
|
||||
minX -= FLOOR_MARGIN;
|
||||
minY -= FLOOR_MARGIN;
|
||||
return {
|
||||
minX,
|
||||
minY,
|
||||
w: maxX - minX + 1 + FLOOR_MARGIN,
|
||||
h: maxY - minY + 1 + FLOOR_MARGIN,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One renderer. `ensureReady()` awaits the atlas once (a failed load
|
||||
* throws — the composition surfaces it on the error path).
|
||||
* `drawFrame(view, nowMs)` draws the frame (and counts the draw
|
||||
* calls). `renderStats()` is the census. `dispose()` drops the
|
||||
* canvas (a new round mounts a fresh play screen — the composition
|
||||
* rebuilds the renderer).
|
||||
*/
|
||||
export function createRenderer({
|
||||
canvas = null,
|
||||
camera = null,
|
||||
fx = null,
|
||||
ownSeat = null,
|
||||
reducedMotion = () => false,
|
||||
now = null,
|
||||
} = {}) {
|
||||
const ctx = canvas !== null ? canvas.getContext("2d") : null;
|
||||
const warned = new Set();
|
||||
const frameTimes = []; // every measured draw time (ms), since creation
|
||||
const frameAvgWindow = 30;
|
||||
let drawCalls = 0;
|
||||
let frames = 0;
|
||||
let lastNow = null;
|
||||
let readyPromise = null;
|
||||
let ready = false;
|
||||
let boundsCache = null; // the kitchen's absolute corners, grow-only per runtime
|
||||
|
||||
/** The reduced-motion half (the fx.js rule, mirrored): the canvas's
|
||||
* `data-motion` attribute first (the composition sets it from
|
||||
* `?motion=0`), a forced override, then the media query. */
|
||||
function reducedNow() {
|
||||
if (canvas !== null && canvas.dataset !== null) {
|
||||
if (canvas.dataset.motion === "reduced") return true;
|
||||
}
|
||||
if (typeof reducedMotion === "function") {
|
||||
const forced = reducedMotion();
|
||||
if (forced === true) return true;
|
||||
if (forced === false) return false;
|
||||
}
|
||||
return typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
: false;
|
||||
}
|
||||
|
||||
/** Await the atlas once (the first draw's one-time cost). */
|
||||
function ensureReady() {
|
||||
if (ready) return Promise.resolve();
|
||||
if (readyPromise === null) {
|
||||
readyPromise = atlas.ready().then(() => {
|
||||
ready = true;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return readyPromise;
|
||||
}
|
||||
|
||||
function statClock() {
|
||||
return typeof now === "function" ? now() : performance.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw one sprite (a counted draw call). A throw — an unknown id, a
|
||||
* sprite with no pixels — is logged once (naming the id), suppressed
|
||||
* afterwards (no spam), and drawn as a fallback rect: the frame never
|
||||
* loses a tile to a missing symbol.
|
||||
*/
|
||||
function sprite(id, x, y, w, h, opts = {}) {
|
||||
drawCalls += 1;
|
||||
try {
|
||||
atlas.drawSync(ctx, id, x, y, w, h, opts);
|
||||
} catch (error) {
|
||||
if (!warned.has(id)) {
|
||||
warned.add(id);
|
||||
console.error(`undercooked: sprite ${id} failed (${error.message})`);
|
||||
}
|
||||
ctx.save();
|
||||
ctx.globalAlpha = (typeof opts.alpha === "number" ? opts.alpha : 1) * 0.55;
|
||||
ctx.fillStyle = "#857259";
|
||||
ctx.fillRect(x, y, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function stepFrame(nowMs) {
|
||||
// The fixed-step walk frame (8 steps/s — a 120 Hz display lands on
|
||||
// the same frame sequence a 60 Hz one does).
|
||||
return Math.floor(nowMs / WALK_STEP_MS) % 2;
|
||||
}
|
||||
|
||||
function stepIndex(nowMs, divisorMs) {
|
||||
return Math.floor(nowMs / divisorMs);
|
||||
}
|
||||
|
||||
/** Draw one player (the layer 5 body + overlays are drawn here). */
|
||||
function drawPlayer(p, nowMs, toLocal, shake, s) {
|
||||
const [lx, ly] = toLocal(p.x, p.y);
|
||||
const cx = lx + (shake !== null ? shake.dx : 0) * s;
|
||||
const cy = ly + (shake !== null ? shake.dy : 0) * s;
|
||||
const body = s * 0.95;
|
||||
const walk =
|
||||
p.s === "moving" ? stepFrame(nowMs) : 0;
|
||||
sprite(cookSpriteId(p.pi ?? 0, p.f ?? 2, walk), cx - body / 2, cy - body * 0.92, body, body);
|
||||
if (p.s === "disconnected") {
|
||||
sprite("ui-ghost", cx - s * 0.3, cy - s * 1.05, s * 0.6, s * 0.6, { alpha: 0.85 });
|
||||
}
|
||||
if (p.s === "stunned") {
|
||||
const spin = reducedNow()
|
||||
? 0
|
||||
: (stepIndex(nowMs, WALK_STEP_MS) % 4) * Math.PI * 0.5;
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy - s * 0.5);
|
||||
ctx.rotate(spin);
|
||||
sprite("cook-stun", -s * 0.42, -s * 0.42, s * 0.84, s * 0.84, { alpha: 0.9 });
|
||||
ctx.restore();
|
||||
}
|
||||
// The carried item (layer 6) with its overlay: the held item above
|
||||
// the head, the carry layer at the hand.
|
||||
if (p.h !== null && p.h !== undefined && p.h !== "") {
|
||||
const item = splitItemShort(p.h);
|
||||
if (item !== null) {
|
||||
sprite("cook-carry", cx - s * 0.3, cy - s * 0.72, s * 0.6, s * 0.6, { alpha: 0.95 });
|
||||
sprite(
|
||||
itemSpriteId(item.kind, item.form),
|
||||
cx - s * 0.25,
|
||||
cy - s * 1.32,
|
||||
s * 0.5,
|
||||
s * 0.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame. `view` is the `lerpState` output (the entity lists
|
||||
* already interpolated); `nowMs` the loop's clock (the animation's
|
||||
* only time source — the loop's pause on `document.hidden` is the
|
||||
* pause, and a resume's delta is dropped, not measured).
|
||||
*/
|
||||
function drawFrame(view, nowMs) {
|
||||
if (ctx === null) return;
|
||||
const t0 = statClock();
|
||||
lastNow = nowMs;
|
||||
drawCalls = 0;
|
||||
frames += 1;
|
||||
|
||||
const bounds = growBounds(kitchenBounds(view));
|
||||
// The size reconciliation: the canvas can grow after the fit (the
|
||||
// `hidden` un-hide, a window resize the camera's listeners miss on
|
||||
// a canvas that was detached) — re-read the CSS size only when it
|
||||
// changed, so the backing store and the fit track the element.
|
||||
if (camera !== null && canvas !== null && canvas.isConnected) {
|
||||
const css = camera.stats();
|
||||
if (canvas.clientWidth !== css.cssW || canvas.clientHeight !== css.cssH) {
|
||||
camera.resize();
|
||||
}
|
||||
}
|
||||
if (camera !== null) camera.setWorld(bounds.w, bounds.h);
|
||||
const stats =
|
||||
camera !== null
|
||||
? camera.stats()
|
||||
: { scale: 32, offX: 0, offY: 0, cssW: 1024, cssH: 640, dpr: 1 };
|
||||
const s = stats.scale;
|
||||
const toLocal = (x, y) => {
|
||||
const p = camera !== null ? camera.worldToScreen(x - bounds.minX, y - bounds.minY) : { x: 0, y: 0 };
|
||||
return [p.x, p.y];
|
||||
};
|
||||
|
||||
// The backing store's transform (DPR-aware; the math stays in CSS px).
|
||||
const dpr = stats.dpr || 1;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, stats.cssW, stats.cssH);
|
||||
drawCalls += 1;
|
||||
|
||||
// 1 — the floor (the approximated kitchen, its edge the wall).
|
||||
for (let cy = 0; cy < bounds.h; cy += 1) {
|
||||
for (let cx = 0; cx < bounds.w; cx += 1) {
|
||||
const [lx, ly] = toLocal(cx, cy);
|
||||
sprite(tileSpriteId("floor", cx + bounds.minX, cy + bounds.minY), lx, ly, s, s);
|
||||
}
|
||||
}
|
||||
const [ex, ey] = toLocal(0, 0);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "#4a4036";
|
||||
ctx.lineWidth = Math.max(2, s * 0.08);
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.strokeRect(ex, ey, bounds.w * s, bounds.h * s);
|
||||
ctx.restore();
|
||||
drawCalls += 1;
|
||||
|
||||
// 2 — floor decals (the spills: a splashed stain under everything).
|
||||
for (const hz of view.hz ?? []) {
|
||||
if (hz.t !== "spill") continue;
|
||||
const [lx, ly] = toLocal(hz.x, hz.y);
|
||||
sprite("fx-splash", lx + s * 0.05, ly + s * 0.2, s * 0.9, s * 0.55, { alpha: 0.5 });
|
||||
}
|
||||
|
||||
// 3 — the station bases (a fire on a stove's cell is the burning
|
||||
// variant — the hazard's visual half; the flame itself is layer 8).
|
||||
const fireCells = new Set(
|
||||
(view.hz ?? []).filter((hz) => hz.t === "fire").map((hz) => `${Math.trunc(hz.x)},${Math.trunc(hz.y)}`),
|
||||
);
|
||||
for (const st of view.st ?? []) {
|
||||
const [lx, ly] = toLocal(st.x, st.y);
|
||||
let id = stationSpriteId(st.k, st.s);
|
||||
if (st.k === "stove" && fireCells.has(`${Math.trunc(st.x)},${Math.trunc(st.y)}`)) {
|
||||
id = "station-stove-burning";
|
||||
}
|
||||
sprite(id, lx + s * 0.05, ly + s * 0.1, s * 0.9, s * 0.9);
|
||||
}
|
||||
|
||||
// 4 — the carried-under items (a station's content, on the fixture).
|
||||
for (const st of view.st ?? []) {
|
||||
if (st.g === null || st.g === undefined || st.g === "") continue;
|
||||
const item = splitItemShort(st.g);
|
||||
if (item === null) continue;
|
||||
const [lx, ly] = toLocal(st.x, st.y);
|
||||
if (st.k === "stove") {
|
||||
sprite(cookwareSpriteId(item.kind), lx + s * 0.18, ly + s * 0.3, s * 0.64, s * 0.64);
|
||||
}
|
||||
sprite(
|
||||
itemSpriteId(item.kind, item.form),
|
||||
lx + s * 0.28,
|
||||
ly + s * 0.22,
|
||||
s * 0.44,
|
||||
s * 0.44,
|
||||
);
|
||||
}
|
||||
|
||||
// 5 — the players, y-sorted (a deeper cook draws over a shallower).
|
||||
const players = [...(view.p ?? [])].sort((a, b) => a.y - b.y);
|
||||
for (const p of players) {
|
||||
let shake = null;
|
||||
if (fx !== null && p.i !== undefined) shake = fx.shakeOffset(p.i, nowMs);
|
||||
drawPlayer(p, nowMs, toLocal, shake, s);
|
||||
}
|
||||
|
||||
// 7 — the progress rings (a working station's arc; the states' full
|
||||
// rings). Drawn before the hazards (layer 8) so a flame sits over
|
||||
// the ring it is burning.
|
||||
for (const st of view.st ?? []) {
|
||||
const [lx, ly] = toLocal(st.x, st.y);
|
||||
const cx = lx + s * 0.5;
|
||||
const cy = ly + s * 0.5;
|
||||
const r = s * 0.46;
|
||||
let color = null;
|
||||
let fraction = null;
|
||||
const progress = Number.isFinite(st.p) ? st.p : 0;
|
||||
if (st.s === "working" && progress > 0 && progress < 1) {
|
||||
color = "#ffa63d";
|
||||
fraction = progress;
|
||||
} else if (st.s === "ready") {
|
||||
color = "#67d16c";
|
||||
fraction = 1;
|
||||
} else if (st.s === "busy") {
|
||||
color = "#ffa63d";
|
||||
fraction = 1;
|
||||
} else if (st.s === "blocked") {
|
||||
color = "#ff5a4e";
|
||||
fraction = 1;
|
||||
} else if (st.s === "dirty") {
|
||||
color = "#9a8f7a";
|
||||
fraction = 1;
|
||||
}
|
||||
if (color !== null) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = Math.max(1.5, s * 0.07);
|
||||
ctx.beginPath();
|
||||
if (fraction === 1) {
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
} else {
|
||||
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + fraction * Math.PI * 2);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
drawCalls += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 8 — the hazards (fires the animated flame, the moving obstacle a
|
||||
// solid block, the rush a pulsing border — the reduced-motion
|
||||
// fallbacks are static: same markers, no pulse).
|
||||
for (const hz of view.hz ?? []) {
|
||||
const [lx, ly] = toLocal(hz.x, hz.y);
|
||||
if (hz.t === "fire") {
|
||||
const pulse = reducedNow()
|
||||
? 1
|
||||
: 1 + 0.12 * Math.sin((stepIndex(nowMs, WALK_STEP_MS) / 4) * Math.PI * 2);
|
||||
sprite("fx-flame", lx + s * 0.12, ly + s * 0.05 * pulse, s * 0.76, s * 0.8 * pulse, { alpha: 0.95 });
|
||||
} else if (hz.t === "moving_obstacle") {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.fillStyle = "#5c5248";
|
||||
ctx.fillRect(lx + s * 0.1, ly + s * 0.1, s * 0.8, s * 0.8);
|
||||
ctx.strokeStyle = "#ff5a4e";
|
||||
ctx.lineWidth = Math.max(1.5, s * 0.06);
|
||||
ctx.strokeRect(lx + s * 0.1, ly + s * 0.1, s * 0.8, s * 0.8);
|
||||
ctx.restore();
|
||||
drawCalls += 1;
|
||||
} else if (hz.t === "rush") {
|
||||
const [rx, ry] = toLocal(0, 0);
|
||||
const alpha = reducedNow() ? 0.35 : 0.25 + 0.2 * Math.abs(Math.sin((stepIndex(nowMs, 125) / 2) * Math.PI * 2));
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "#ff5a4e";
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.lineWidth = Math.max(2, s * 0.1);
|
||||
ctx.strokeRect(rx + s * 0.05, ry + s * 0.05, bounds.w * s - s * 0.1, bounds.h * s - s * 0.1);
|
||||
ctx.restore();
|
||||
drawCalls += 1;
|
||||
}
|
||||
// a `spill` is a floor decal (layer 2) — no hazard-layer mark.
|
||||
}
|
||||
|
||||
// 9 — the FX's particles (the event effects; `update` advances the
|
||||
// pool, this draws it).
|
||||
if (fx !== null) fx.draw(ctx, toLocal, s, nowMs);
|
||||
|
||||
// 10 — the target highlight: the viewer's own cook's reach zone,
|
||||
// the `candidate_cells` mirror, faint (the server's own target,
|
||||
// approximated — reaching reads predictably).
|
||||
const own =
|
||||
typeof ownSeat === "function"
|
||||
? ownSeat()
|
||||
: ownSeat !== null && ownSeat !== undefined
|
||||
? ownSeat
|
||||
: null;
|
||||
const me =
|
||||
own !== null
|
||||
? (view.p ?? []).find((p) => p.pi === own)
|
||||
: undefined;
|
||||
if (me !== undefined) {
|
||||
const facing = facingName(me.f) ?? "down";
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "#67d16c";
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.lineWidth = Math.max(1.5, s * 0.06);
|
||||
for (const [cx, cy] of candidateCells(me.x, me.y, facing)) {
|
||||
// The candidate cells are absolute grid cells; `toLocal` wants
|
||||
// bounding-box-local coordinates — subtract the box's origin.
|
||||
const px = camera !== null
|
||||
? camera.worldToScreen(cx - bounds.minX, cy - bounds.minY)
|
||||
: { x: 0, y: 0 };
|
||||
ctx.strokeRect(px.x + s * 0.08, px.y + s * 0.08, s * 0.84, s * 0.84);
|
||||
}
|
||||
ctx.restore();
|
||||
drawCalls += 1;
|
||||
}
|
||||
|
||||
const dtMeasured = statClock() - t0;
|
||||
frameTimes.push(dtMeasured);
|
||||
return { drawCalls, bounds };
|
||||
}
|
||||
|
||||
/** The grow-only bounds cache: a wandering cook can extend the box
|
||||
* (a new cell) but never shrink it — the camera refits only when the
|
||||
* kitchen is genuinely discovered further, not on every sub-cell
|
||||
* crossing (a mid-walk re-snap is the shimmer the task forbids). */
|
||||
function growBounds(bounds) {
|
||||
const box = {
|
||||
minX: bounds.minX,
|
||||
minY: bounds.minY,
|
||||
maxX: bounds.minX + bounds.w - 1,
|
||||
maxY: bounds.minY + bounds.h - 1,
|
||||
};
|
||||
if (boundsCache === null) {
|
||||
boundsCache = box;
|
||||
} else {
|
||||
boundsCache.minX = Math.min(boundsCache.minX, box.minX);
|
||||
boundsCache.minY = Math.min(boundsCache.minY, box.minY);
|
||||
boundsCache.maxX = Math.max(boundsCache.maxX, box.maxX);
|
||||
boundsCache.maxY = Math.max(boundsCache.maxY, box.maxY);
|
||||
}
|
||||
return {
|
||||
minX: boundsCache.minX,
|
||||
minY: boundsCache.minY,
|
||||
w: boundsCache.maxX - boundsCache.minX + 1,
|
||||
h: boundsCache.maxY - boundsCache.minY + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
ready = false;
|
||||
readyPromise = null;
|
||||
boundsCache = null;
|
||||
}
|
||||
|
||||
/** The frame census (the e2e perf assertions' seam, on `window.__uc`). */
|
||||
function renderStats() {
|
||||
const recent = frameTimes.slice(-frameAvgWindow);
|
||||
const avg =
|
||||
recent.length === 0
|
||||
? 0
|
||||
: recent.reduce((sum, v) => sum + v, 0) / recent.length;
|
||||
const sorted = [...frameTimes].sort((a, b) => a - b);
|
||||
const p95 =
|
||||
sorted.length === 0
|
||||
? 0
|
||||
: sorted[Math.min(sorted.length - 1, Math.ceil(0.95 * sorted.length) - 1)];
|
||||
return {
|
||||
drawCalls,
|
||||
frames,
|
||||
frameMs: avg,
|
||||
frameMsP95: p95,
|
||||
frameMsMax: sorted.length === 0 ? 0 : sorted[sorted.length - 1],
|
||||
warnedSprites: [...warned],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ensureReady,
|
||||
drawFrame,
|
||||
renderStats,
|
||||
dispose,
|
||||
isReady: () => ready,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// Undercooked — the event FX (phase 07, task 04).
|
||||
//
|
||||
// The wire's transient events (the snapshot's `e[]`, the standalone
|
||||
// `ev`) become on-screen effects here: `chopped`'s spark, `cooked` /
|
||||
// `ready`'s steam, `served`'s pop + the score float, `burnt`'s smoke,
|
||||
// `denied`'s cross + the nudge, `stunned`'s spin, `order_missed`'s
|
||||
// flash, `score`'s float, plus `washed` / `trashed` / `picked` /
|
||||
// `handed_off`'s small marks.
|
||||
//
|
||||
// The renderer drains the event queue (`net/events.js`'s `due(now)`)
|
||||
// each frame and hands the live entries to `consume`; `consume`
|
||||
// spawns each entry's effect **once** — its own 100 ms dedup window
|
||||
// (the same key the queue uses) suppresses the frames the same queued
|
||||
// entry is repeated over, and a genuinely new event (more than the
|
||||
// window later, or the queue's tick-reset cleared the old one) spawns
|
||||
// again. The queue's TTL is the effect's lifetime source: a `served`
|
||||
// lives 1200 ms, a `chopped` 800.
|
||||
//
|
||||
// **A small particle pool, no allocation per event.** `PARTICLES` is a
|
||||
// fixed array of pre-built particle objects; `spawn` reuses them by a
|
||||
// cursor (a full pool wraps and steals the oldest), and the one-shot
|
||||
// effects (the sprite marks: the steam, the deny cross, the flash)
|
||||
// live in a second fixed `MARKS` array the same way. Nothing is
|
||||
// `new`-ed after `createFx` — a chop burst cannot grow the heap.
|
||||
//
|
||||
// `[data-motion="reduced"]` (or `prefers-reduced-motion`) is the
|
||||
// fallback: the markers stay (the deny cross, the spin mark, the
|
||||
// flash's border — the *static* versions), the particles do not
|
||||
// launch, the nudge's shake never applies (`shakeApplied` stays 0),
|
||||
// and nothing moves. `reduced()` reads the canvas's `data-motion`
|
||||
// attribute first (the composition sets it from the `?motion=0`
|
||||
// param, `docs/CLIENT.md` "The URL params"), then the media query.
|
||||
|
||||
import * as atlas from "./atlas.js";
|
||||
|
||||
const PARTICLE_COUNT = 96; // the pool size (a full burst wraps, never grows)
|
||||
const MARK_COUNT = 24; // the one-shot effect slots
|
||||
const DEDUP_WINDOW_MS = 100; // the queue's window — the same dedup rule
|
||||
|
||||
/**
|
||||
* The event → effect table. `sprite` is the atlas id of the one-shot
|
||||
* mark (the `reduced` fallback's static marker); `particle` the pool
|
||||
* particle's colour and shape (the non-reduced launch); `lifeMs` the
|
||||
* effect's own lifetime (within the queue kind's TTL).
|
||||
*/
|
||||
const EFFECTS = Object.freeze({
|
||||
chopped: { sprite: "fx-chop", particle: { color: "#ffd166" }, lifeMs: 500 },
|
||||
cooked: { sprite: "fx-steam", particle: { color: "#d9d4c8" }, lifeMs: 800 },
|
||||
ready: { sprite: "fx-steam", particle: { color: "#d9d4c8" }, lifeMs: 600 },
|
||||
washed: { sprite: "fx-steam", particle: { color: "#bfe3ff" }, lifeMs: 600 },
|
||||
served: { sprite: "fx-serve", particle: { color: "#ffe066" }, lifeMs: 1000 },
|
||||
burnt: { sprite: "fx-flame", particle: { color: "#8a8a8a" }, lifeMs: 900 },
|
||||
denied: { sprite: "fx-deny", particle: null, lifeMs: 600, nudge: true },
|
||||
stunned: { sprite: "fx-steam", particle: null, lifeMs: 800, spin: true },
|
||||
order_missed: { sprite: "fx-deny", particle: null, lifeMs: 1000, flash: true },
|
||||
score: { sprite: "fx-score", particle: { color: "#ffe066" }, lifeMs: 1000, float: true },
|
||||
trashed: { sprite: "fx-splash", particle: { color: "#9a8f7a" }, lifeMs: 500 },
|
||||
picked: { sprite: "fx-chop", particle: { color: "#d9d4c8" }, lifeMs: 350 },
|
||||
dropped: { sprite: "fx-splash", particle: { color: "#9a8f7a" }, lifeMs: 350 },
|
||||
handed_off: { sprite: "fx-score", particle: { color: "#d9d4c8" }, lifeMs: 350 },
|
||||
fire_started: { sprite: "fx-flame", particle: { color: "#ffa63d" }, lifeMs: 800 },
|
||||
ghost_on: { sprite: "ui-ghost", particle: null, lifeMs: 1200 },
|
||||
ghost_off: { sprite: "ui-ghost", particle: null, lifeMs: 800 },
|
||||
});
|
||||
|
||||
function keyOf(entry) {
|
||||
const payload =
|
||||
entry.payload !== undefined && entry.payload !== null
|
||||
? JSON.stringify(entry.payload, Object.keys(entry.payload).sort())
|
||||
: "";
|
||||
return `${entry.k}|${Math.round((entry.x ?? 0) * 100)}|${Math.round((entry.y ?? 0) * 100)}|${payload}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One FX engine. `consume(entries, view, nowMs)` — the queued entries
|
||||
* (the queue's `due(now)`), the frame (for the nudge's player lookup),
|
||||
* the clock. `update(nowMs)` — advances the particle pool (a clamped
|
||||
* `dt`: a resume's jump cannot fling the particles). `draw(ctx,
|
||||
* toLocal, tile, nowMs)` — draws the live marks + particles (the
|
||||
* renderer's layer 9). `shakeOffset(playerId, nowMs)` — the nudge's
|
||||
* decaying offset in *tiles* (the renderer adds it to the player's
|
||||
* draw position). `stats()` — the census (the e2e's reduced-motion
|
||||
* seam: `processed`, `shakeApplied`, `particlesLaunched`).
|
||||
*/
|
||||
export function createFx({
|
||||
canvas = null,
|
||||
reduced = null,
|
||||
media = null,
|
||||
} = {}) {
|
||||
const query =
|
||||
typeof media === "function"
|
||||
? media
|
||||
: () =>
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function"
|
||||
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
: false;
|
||||
|
||||
const particles = Array.from({ length: PARTICLE_COUNT }, () => ({
|
||||
active: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
born: 0,
|
||||
life: 0,
|
||||
color: "#ffffff",
|
||||
size: 1,
|
||||
}));
|
||||
let particleCursor = 0;
|
||||
const marks = Array.from({ length: MARK_COUNT }, () => ({
|
||||
active: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
born: 0,
|
||||
life: 0,
|
||||
sprite: "",
|
||||
text: "",
|
||||
color: "#ffffff",
|
||||
alpha: 1,
|
||||
}));
|
||||
let markCursor = 0;
|
||||
const seen = new Map(); // dedup key -> the last accepted spawn (nowMs)
|
||||
const shakes = new Map(); // player id -> { born, life } (the nudge's window)
|
||||
let lastNow = null;
|
||||
const stats = {
|
||||
processed: {},
|
||||
shakeApplied: 0,
|
||||
particlesLaunched: 0,
|
||||
deduped: 0,
|
||||
};
|
||||
|
||||
/** The reduced-motion half: the `data-motion` attribute first, then
|
||||
* the media query (either one is enough). */
|
||||
function reducedMotion() {
|
||||
if (typeof reduced === "function") {
|
||||
const forced = reduced();
|
||||
if (forced !== null && forced !== undefined) {
|
||||
return Boolean(forced);
|
||||
}
|
||||
}
|
||||
if (canvas !== null && canvas.dataset !== null) {
|
||||
const attr = canvas.dataset.motion;
|
||||
if (attr === "reduced") return true;
|
||||
}
|
||||
return query();
|
||||
}
|
||||
|
||||
function stealMark() {
|
||||
const slot = marks[markCursor];
|
||||
markCursor = (markCursor + 1) % MARK_COUNT;
|
||||
return slot;
|
||||
}
|
||||
|
||||
function stealParticle() {
|
||||
const slot = particles[particleCursor];
|
||||
particleCursor = (particleCursor + 1) % PARTICLE_COUNT;
|
||||
return slot;
|
||||
}
|
||||
|
||||
function launchParticles(x, y, color, nowMs, count) {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const p = stealParticle();
|
||||
p.active = true;
|
||||
p.x = x;
|
||||
p.y = y;
|
||||
const angle = (i / count) * Math.PI * 2 + (nowMs % 100) * 0.01;
|
||||
p.vx = Math.cos(angle) * 0.6;
|
||||
p.vy = Math.sin(angle) * 0.6 - 0.4;
|
||||
p.born = nowMs;
|
||||
p.life = 500 + (i % 4) * 80;
|
||||
p.color = color;
|
||||
p.size = 0.08 + (i % 3) * 0.03;
|
||||
stats.particlesLaunched += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One queued entry (its `k` is the event kind, `x`/`y` tile units,
|
||||
* `payload` the short-keyed map). Spawns the effect once; the 100 ms
|
||||
* window suppresses the same entry's repeat frames.
|
||||
*/
|
||||
function consumeOne(entry, view, nowMs) {
|
||||
const spec = EFFECTS[entry.k];
|
||||
if (spec === undefined) return; // an unlisted kind (the queue's admission gate already skipped it)
|
||||
const key = keyOf(entry);
|
||||
const last = seen.get(key);
|
||||
if (last !== undefined && nowMs - last < DEDUP_WINDOW_MS) {
|
||||
stats.deduped += 1; // the echo: the effect already plays
|
||||
return;
|
||||
}
|
||||
seen.set(key, nowMs);
|
||||
stats.processed[entry.k] = (stats.processed[entry.k] ?? 0) + 1;
|
||||
const reducedNow = reducedMotion();
|
||||
|
||||
if (spec.sprite !== "") {
|
||||
const mark = stealMark();
|
||||
mark.active = true;
|
||||
mark.x = entry.x ?? 0;
|
||||
mark.y = entry.y ?? 0;
|
||||
mark.born = nowMs;
|
||||
mark.life = spec.lifeMs;
|
||||
mark.sprite = spec.sprite;
|
||||
mark.alpha = 1;
|
||||
// The float text (the score's `+pts`, the served's `pts`).
|
||||
const pts =
|
||||
entry.payload !== null && entry.payload !== undefined
|
||||
? entry.payload.pts
|
||||
: undefined;
|
||||
mark.text =
|
||||
spec.float && Number.isFinite(pts) ? `+${pts}` : "";
|
||||
mark.color = spec.particle !== null ? spec.particle.color : "#ffffff";
|
||||
}
|
||||
if (spec.particle !== null && !reducedNow) {
|
||||
launchParticles(entry.x ?? 0, entry.y ?? 0, spec.particle.color, nowMs, 5);
|
||||
}
|
||||
if (spec.nudge && view !== null && view !== undefined) {
|
||||
const pid =
|
||||
entry.payload !== null && entry.payload !== undefined
|
||||
? entry.payload.i
|
||||
: undefined;
|
||||
if (pid !== undefined) {
|
||||
const existing = shakes.get(pid);
|
||||
if (existing === undefined || nowMs - existing.born > DEDUP_WINDOW_MS) {
|
||||
shakes.set(pid, { born: nowMs, life: 250 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The queued entries (the queue's `due(now)` — a list; a single
|
||||
* entry is tolerated) — each is spawned once.
|
||||
*/
|
||||
function consume(entries, view, nowMs) {
|
||||
const list = Array.isArray(entries) ? entries : [entries];
|
||||
for (const entry of list) consumeOne(entry, view, nowMs);
|
||||
}
|
||||
|
||||
/** The queue's prune's mirror: drop the dedup keys and the nudge
|
||||
* windows past their lifetime (the queue's `drift` does its half). */
|
||||
function prune(nowMs) {
|
||||
for (const [key, at] of [...seen]) {
|
||||
if (nowMs - at >= DEDUP_WINDOW_MS) seen.delete(key);
|
||||
}
|
||||
for (const [pid, win] of [...shakes]) {
|
||||
if (nowMs - win.born >= win.life) shakes.delete(pid);
|
||||
}
|
||||
}
|
||||
|
||||
function update(nowMs) {
|
||||
const dt =
|
||||
lastNow === null ? 0 : Math.min(100, Math.max(0, nowMs - lastNow));
|
||||
lastNow = nowMs;
|
||||
if (dt > 0) {
|
||||
const seconds = dt / 1000;
|
||||
for (const p of particles) {
|
||||
if (!p.active) continue;
|
||||
if (nowMs - p.born >= p.life) {
|
||||
p.active = false;
|
||||
continue;
|
||||
}
|
||||
p.x += p.vx * seconds;
|
||||
p.y += p.vy * seconds;
|
||||
p.vy += 1.4 * seconds; // the gravity (tiles/s²)
|
||||
}
|
||||
}
|
||||
prune(nowMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* The nudge's offset in tiles (the denied's push-back): a damped
|
||||
* 250 ms wobble along the event's arrival — zero when reduced motion
|
||||
* (the task's "no shake" half) or outside the window.
|
||||
*/
|
||||
function shakeOffset(playerId, nowMs) {
|
||||
const win = shakes.get(playerId);
|
||||
if (win === undefined || reducedMotion() || nowMs - win.born >= win.life) {
|
||||
return null;
|
||||
}
|
||||
const t = (nowMs - win.born) / win.life; // 0..1
|
||||
const wobble = Math.sin(t * Math.PI * 3) * 0.06 * (1 - t);
|
||||
stats.shakeApplied += 1;
|
||||
return { dx: wobble, dy: -Math.abs(wobble) * 0.5 };
|
||||
}
|
||||
|
||||
/** Draw the live marks + particles (the renderer's layer 9). */
|
||||
function draw(ctx, toLocal, tile, nowMs) {
|
||||
for (const mark of marks) {
|
||||
if (!mark.active) continue;
|
||||
const age = nowMs - mark.born;
|
||||
if (age >= mark.life) {
|
||||
mark.active = false;
|
||||
continue;
|
||||
}
|
||||
const [x, y] = toLocal(mark.x, mark.y);
|
||||
const alpha = 1 - age / mark.life;
|
||||
if (mark.sprite === "fx-flame" && mark.text === "" && mark.color === "#8a8a8a") {
|
||||
// The burnt's smoke: the flame sprite, grey, rising.
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha * 0.55;
|
||||
ctx.translate(0, -age * 0.0012 * tile);
|
||||
// `fx-flame` is the art's only smoke-able symbol: greyed, it
|
||||
// reads as smoke.
|
||||
drawSprite(ctx, "fx-flame", x - tile * 0.2, y - tile * 0.6, tile * 0.55, tile * 0.7, { alpha: 1 });
|
||||
ctx.restore();
|
||||
continue;
|
||||
}
|
||||
drawSprite(ctx, mark.sprite, x - tile * 0.3, y - tile * 0.55, tile * 0.6, tile * 0.55, { alpha });
|
||||
if (mark.text !== "") {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = "#ffe066";
|
||||
ctx.font = `bold ${Math.max(12, tile * 0.34)}px ui-sans-serif, system-ui, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(mark.text, x, y - tile * 0.7 - age * 0.0015 * tile);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
for (const p of particles) {
|
||||
if (!p.active) continue;
|
||||
const age = nowMs - p.born;
|
||||
if (age >= p.life) continue;
|
||||
const [x, y] = toLocal(p.x, p.y);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = (1 - age / p.life) * 0.9;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y - tile * 0.4, Math.max(1.5, p.size * tile), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// The marks draw through the atlas directly; a throw (a missing
|
||||
// symbol) cannot blank the frame — the mark's slot just does not
|
||||
// draw this frame (the renderer's own sprite helper owns the
|
||||
// once-logged fallback for the *layer* sprites).
|
||||
function drawSprite(ctx, id, x, y, w, h, opts) {
|
||||
try {
|
||||
atlas.drawSync(ctx, id, x, y, w, h, opts);
|
||||
} catch {
|
||||
/* a missing symbol: the mark's slot stays, the frame goes on */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
consume,
|
||||
update,
|
||||
draw,
|
||||
shakeOffset,
|
||||
reduced: reducedMotion,
|
||||
stats: () => ({
|
||||
processed: { ...stats.processed },
|
||||
shakeApplied: stats.shakeApplied,
|
||||
particlesLaunched: stats.particlesLaunched,
|
||||
deduped: stats.deduped,
|
||||
activeParticles: particles.filter((p) => p.active).length,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Undercooked — the single render loop (phase 07 task 03).
|
||||
//
|
||||
// One `requestAnimationFrame` loop per page — the single-owner rule:
|
||||
// exactly one loop instance exists (the composition root, `main.js`,
|
||||
// creates it), and it is the only thing that samples the buffer,
|
||||
// calls the renderer and updates the HUD
|
||||
// (`sample → render → hud.update` per frame, docs/CLIENT.md "The
|
||||
// render loop"). No screen, no module, no test spawns a second loop:
|
||||
// two owners of the clock is how the frame-time accounting goes wrong.
|
||||
//
|
||||
// The loop's lifecycle:
|
||||
//
|
||||
// * **paused when `document.hidden`** — the rAF chain stops entirely
|
||||
// (a pending frame that finds `hidden` renders nothing and does not
|
||||
// reschedule; the `visibilitychange` back to visible restarts it).
|
||||
// The delta spanning the pause is *dropped*, not measured, so a
|
||||
// resume cannot pollute the p50/p95 window.
|
||||
// * **idle when the screen is not `PLAYING`** — `active()` is the
|
||||
// composition's predicate (the screen's name); a non-active frame
|
||||
// renders nothing but keeps the chain alive, so the loop resumes on
|
||||
// the very next frame after the screen turns `PLAYING` (no event
|
||||
// needed).
|
||||
// * **`?freeze=1`** — the composition's business (it holds the *sample
|
||||
// time* at the first playing frame); the loop itself just renders
|
||||
// whatever `render(nowMs)` asks for, so a frozen render is a still
|
||||
// image without the loop pausing.
|
||||
//
|
||||
// Self-measured: every rendered frame's time (the `now` deltas) lands
|
||||
// in a sliding 120-sample window; `frameStats()` reports `frames`,
|
||||
// `running`, `hidden`, `p50` / `p95` (the `frameMs` the spec wants on
|
||||
// `window.__uc`) and `max`. The window resets on `start()`.
|
||||
|
||||
export function createRenderLoop({
|
||||
active = () => true,
|
||||
render = null,
|
||||
now = null,
|
||||
raf = null,
|
||||
unraf = null,
|
||||
owner = null,
|
||||
} = {}) {
|
||||
const clock = typeof now === "function" ? now : () => performance.now();
|
||||
const schedule = typeof raf === "function" ? raf : (fn) => requestAnimationFrame(fn);
|
||||
const cancel = typeof unraf === "function" ? unraf : (id) => cancelAnimationFrame(id);
|
||||
const visOwner = owner !== null ? owner : typeof document !== "undefined" ? document : null;
|
||||
|
||||
let running = false;
|
||||
let scheduled = null;
|
||||
let hidden = visOwner !== null ? Boolean(visOwner.hidden) : false;
|
||||
let frames = 0;
|
||||
let lastNow = null;
|
||||
let pausedAt = null; // the pause boundary (the resume's delta drops)
|
||||
const sampleWindow = 120;
|
||||
const deltas = [];
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
const wasHidden = hidden;
|
||||
hidden = visOwner !== null ? Boolean(visOwner.hidden) : false;
|
||||
if (hidden && !wasHidden) pausedAt = clock(); // mark the pause
|
||||
if (running && !hidden && scheduled === null) scheduleFrame(); // resume
|
||||
};
|
||||
|
||||
function frame() {
|
||||
scheduled = null;
|
||||
if (!running || hidden) return; // stopped or paused: wait for a resume
|
||||
const t = clock();
|
||||
if (active()) {
|
||||
if (lastNow !== null) {
|
||||
const dt = t - lastNow;
|
||||
const resumed = pausedAt !== null; // a pause just ended
|
||||
pausedAt = null; // consumed once, by the first resumed frame
|
||||
if (!resumed) {
|
||||
deltas.push(dt);
|
||||
if (deltas.length > sampleWindow) deltas.shift();
|
||||
}
|
||||
}
|
||||
lastNow = t;
|
||||
frames += 1;
|
||||
if (typeof render === "function") render(t);
|
||||
if (running && !hidden) scheduleFrame();
|
||||
} else {
|
||||
// Idle (the screen is not PLAYING): keep the chain alive so the
|
||||
// active() flip is picked up on the next frame; a fresh round
|
||||
// starts with a fresh delta, and a pending pause boundary cannot
|
||||
// survive an idle period (it would drop a real delta later).
|
||||
pausedAt = null;
|
||||
lastNow = null;
|
||||
scheduleFrame();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFrame() {
|
||||
scheduled = schedule(frame);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
frames = 0;
|
||||
lastNow = null;
|
||||
pausedAt = null;
|
||||
deltas.length = 0;
|
||||
if (visOwner !== null) {
|
||||
visOwner.addEventListener("visibilitychange", onVisibilityChange);
|
||||
}
|
||||
if (!hidden) scheduleFrame();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!running) return;
|
||||
running = false;
|
||||
if (scheduled !== null) {
|
||||
cancel(scheduled);
|
||||
scheduled = null;
|
||||
}
|
||||
if (visOwner !== null) {
|
||||
visOwner.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
/** The self-measured frame census (the `frameMs` p50/p95 on `window.__uc`). */
|
||||
function frameStats() {
|
||||
const sorted = [...deltas].sort((a, b) => a - b);
|
||||
const quantile = (q) =>
|
||||
sorted.length === 0
|
||||
? 0
|
||||
: sorted[Math.min(sorted.length - 1, Math.ceil(q * sorted.length) - 1)];
|
||||
return {
|
||||
frames,
|
||||
running,
|
||||
hidden,
|
||||
p50: quantile(0.5),
|
||||
p95: quantile(0.95),
|
||||
max: sorted.length === 0 ? 0 : sorted[sorted.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
frameStats,
|
||||
isRunning: () => running,
|
||||
isHidden: () => hidden,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Undercooked palette (phase 07, task 01).
|
||||
//
|
||||
// The canvas half of app/static/assets/palette.css: the same hex values,
|
||||
// re-exported for the 2D renderer, so a tint in the HUD and a tint on
|
||||
// the floor can never disagree. tests/unit/test_assets.py asserts the two
|
||||
// files agree in both directions; edit one and the build fails until the
|
||||
// other follows. No bundler, no framework, no remote import (A10/A12).
|
||||
|
||||
export const DEFAULT_PALETTE = Object.freeze({
|
||||
cook1: "#e0533d",
|
||||
cook2: "#3d84a8",
|
||||
cook3: "#7da84b",
|
||||
cook4: "#b0699a",
|
||||
danger: "#ff5a4e",
|
||||
hot: "#ffa63d",
|
||||
ready: "#67d16c",
|
||||
dirty: "#9a8f7a",
|
||||
});
|
||||
|
||||
// Okabe-Ito (colourblind-safe, deutan/protan) cook colours: the
|
||||
// [data-palette="cb"] block of the CSS, the same semantics.
|
||||
export const COLORBLIND_PALETTE = Object.freeze({
|
||||
...DEFAULT_PALETTE,
|
||||
cook1: "#d55e00",
|
||||
cook2: "#0072b2",
|
||||
cook3: "#009e73",
|
||||
cook4: "#cc79a7",
|
||||
});
|
||||
|
||||
/** The four seat colours, in seat order (index 0 is seat 1). */
|
||||
export const COOK_COLORS = Object.freeze([
|
||||
DEFAULT_PALETTE.cook1,
|
||||
DEFAULT_PALETTE.cook2,
|
||||
DEFAULT_PALETTE.cook3,
|
||||
DEFAULT_PALETTE.cook4,
|
||||
]);
|
||||
|
||||
/** The seat colours under [data-palette="cb"], in seat order. */
|
||||
export const COOK_COLORS_COLORBLIND = Object.freeze([
|
||||
COLORBLIND_PALETTE.cook1,
|
||||
COLORBLIND_PALETTE.cook2,
|
||||
COLORBLIND_PALETTE.cook3,
|
||||
COLORBLIND_PALETTE.cook4,
|
||||
]);
|
||||
|
||||
/** The semantic state colours shared by the HUD and the canvas. */
|
||||
export const SEMANTIC = Object.freeze({
|
||||
danger: DEFAULT_PALETTE.danger,
|
||||
hot: DEFAULT_PALETTE.hot,
|
||||
ready: DEFAULT_PALETTE.ready,
|
||||
dirty: DEFAULT_PALETTE.dirty,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Undercooked — the connecting screen (phase 07 task 02).
|
||||
//
|
||||
// The brief in-between: a room is being created (or a code is being
|
||||
// joined) and the `welcome` has not landed yet. *Cancel* is a clean
|
||||
// close of the socket — the room, once created, lives on server-side
|
||||
// (the 30 s seat grace / the GC decide its fate), so cancelling never
|
||||
// deletes anything.
|
||||
|
||||
import { el } from "./dom.js";
|
||||
|
||||
export function mount(target, data, app) {
|
||||
const text =
|
||||
data.what === "creating"
|
||||
? "Creating your kitchen…"
|
||||
: `Connecting to ${data.code ?? "…"}…`;
|
||||
target.append(
|
||||
el("div", { class: "panel connecting" },
|
||||
el("h2", {}, "Connecting"),
|
||||
el("p", { id: "connecting-text" }, text),
|
||||
el("button", {
|
||||
id: "cancel-connect",
|
||||
class: "btn",
|
||||
type: "button",
|
||||
onClick: () => app.backToLanding(),
|
||||
}, "Cancel"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function unmount() {
|
||||
/* static */
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Undercooked — the countdown screen (phase 07 task 02).
|
||||
//
|
||||
// The 3-second `COUNTDOWN` in big numerals (one `countdown` frame per
|
||||
// whole second: 3, 2, 1). The pulse is decorative: under
|
||||
// `prefers-reduced-motion` the numeral just changes (the CSS in
|
||||
// `screens.css` gates the animation), and a re-mount per numeral is the
|
||||
// only motion machinery.
|
||||
|
||||
import { el } from "./dom.js";
|
||||
|
||||
let numEl = null;
|
||||
|
||||
export function mount(target, data, app) {
|
||||
numEl = el("p", { id: "countdown-num", class: "countdown-num" }, String(data.n ?? 3));
|
||||
target.append(
|
||||
el("div", { class: "panel countdown" },
|
||||
el("h2", {}, "Get ready"),
|
||||
numEl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A `countdown` frame landed (same-screen re-show): the numeral
|
||||
* replaces itself so the (motion-gated) pulse restarts. */
|
||||
export function update(data, _app) {
|
||||
if (numEl === null) return;
|
||||
numEl.textContent = String(data.n ?? "");
|
||||
const parent = numEl.parentElement;
|
||||
if (parent !== null) {
|
||||
const fresh = el("p", { id: "countdown-num", class: "countdown-num" }, numEl.textContent);
|
||||
parent.replaceChildren(fresh);
|
||||
numEl = fresh;
|
||||
}
|
||||
}
|
||||
|
||||
export function unmount() {
|
||||
numEl = null;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Undercooked — the screens' tiny DOM helper (phase 07 task 02).
|
||||
//
|
||||
// The screens are built with `document.createElement` and `textContent`
|
||||
// only — never `innerHTML` with server data (names, messages and level
|
||||
// ids arrive from the server and must not parse as HTML). This module
|
||||
// is that rule made cheap: `el()` builds a subtree from a declarative
|
||||
// description, and every string lands in a text node or an attribute.
|
||||
|
||||
/**
|
||||
* Build a DOM node.
|
||||
*
|
||||
* `el("button", { class: "btn", type: "button", "aria-label": "Copy" },
|
||||
* el("span", {}, "Copy"), "Copy")`
|
||||
*
|
||||
* Children may be nodes, strings (become text nodes) or null (skipped).
|
||||
* Attribute values that are boolean are set/unset as flags; numbers are
|
||||
* converted; `value`/`textContent` shortcuts are not needed — `text` in
|
||||
* the attributes object sets `textContent` (and forbids children).
|
||||
*/
|
||||
export function el(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [name, value] of Object.entries(attrs)) {
|
||||
if (value === null || value === undefined || value === false) continue;
|
||||
if (name === "text") {
|
||||
node.textContent = String(value);
|
||||
} else if (name === "value") {
|
||||
node.value = value;
|
||||
} else if (name === "dataset") {
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
node.dataset[key] = item;
|
||||
}
|
||||
} else if (name.startsWith("on") && typeof value === "function") {
|
||||
// `onClick` → the DOM event `click` (listeners are case-sensitive).
|
||||
node.addEventListener(name.slice(2).toLowerCase(), value);
|
||||
} else if (value === true) {
|
||||
node.setAttribute(name, "");
|
||||
} else {
|
||||
node.setAttribute(name, String(value));
|
||||
}
|
||||
}
|
||||
if ("text" in attrs) return node;
|
||||
for (const child of children) {
|
||||
if (child === null || child === undefined) continue;
|
||||
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** A `document.createDocumentFragment` of the given children. */
|
||||
export function frag(...children) {
|
||||
const node = document.createDocumentFragment();
|
||||
for (const child of children) {
|
||||
if (child === null || child === undefined) continue;
|
||||
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 5-character code the user typed, the way the server normalises it
|
||||
* (docs/PROTOCOL.md: Crockford base32 — the confusables `I L O U` are
|
||||
* the server's to map; the client only uppercases and keeps what a code
|
||||
* can contain). Returns "" when the input is not a full code.
|
||||
*/
|
||||
export function normalizeCode(value) {
|
||||
return String(value ?? "")
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9A-Z]/g, "")
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
export const CODE_LENGTH = 5;
|
||||
@@ -0,0 +1,87 @@
|
||||
// Undercooked — the error screen (phase 07 task 02).
|
||||
//
|
||||
// Friendly copy per failure (the `err` codes of docs/PROTOCOL.md and
|
||||
// the close codes the connection classifies), with the *right* retry
|
||||
// affordance for each: a `4400` protocol mismatch must not auto-retry
|
||||
// (the client is out of date), a `4401` room-gone must not retry the
|
||||
// dead code, a `4402` full room points at another room — and the room
|
||||
// code is shown for support.
|
||||
|
||||
import { el } from "./dom.js";
|
||||
|
||||
/** code → { title, detail, retry } (retry = "a retry is offered"). */
|
||||
const COPY = {
|
||||
room_gone: {
|
||||
title: "That kitchen is gone",
|
||||
detail: "It may have closed, or the code was mistyped.",
|
||||
retry: false,
|
||||
},
|
||||
room_full: {
|
||||
title: "That kitchen is full",
|
||||
detail: "Every seat is taken. Start your own room, or try another code.",
|
||||
retry: false,
|
||||
},
|
||||
protocol_mismatch: {
|
||||
title: "Protocol mismatch",
|
||||
detail:
|
||||
"This server speaks a protocol version this client does not. Retrying will not help — the client needs an update.",
|
||||
retry: false,
|
||||
},
|
||||
evicted: {
|
||||
title: "You are on another tab",
|
||||
detail: "A second tab took this seat. Use the other tab, or come back later.",
|
||||
retry: false,
|
||||
},
|
||||
internal: {
|
||||
title: "The kitchen hit an internal error",
|
||||
detail: "The round was aborted for everyone. Start a new room.",
|
||||
retry: false,
|
||||
},
|
||||
closed: {
|
||||
title: "The connection closed",
|
||||
detail: "The server closed the socket. You can try the same room again.",
|
||||
retry: true,
|
||||
},
|
||||
unreachable: {
|
||||
title: "Cannot reach the server",
|
||||
detail: "The socket could not be opened. Check the address and try again.",
|
||||
retry: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function mount(target, data, app) {
|
||||
const code = data.code ?? "closed";
|
||||
const copy = COPY[code] ?? COPY.closed;
|
||||
const buttonRow = el(
|
||||
"div",
|
||||
{ class: "error-actions" },
|
||||
el("button", {
|
||||
id: "error-back",
|
||||
class: "btn btn-primary",
|
||||
type: "button",
|
||||
onClick: () => app.backToLanding(),
|
||||
}, "Back to landing"),
|
||||
);
|
||||
if (copy.retry && app.roomCode !== "") {
|
||||
buttonRow.prepend(
|
||||
el("button", {
|
||||
id: "error-retry",
|
||||
class: "btn",
|
||||
type: "button",
|
||||
onClick: () => app.retryJoin(),
|
||||
}, "Try again"),
|
||||
);
|
||||
}
|
||||
const children = [
|
||||
el("h2", { id: "error-title" }, copy.title),
|
||||
el("p", { id: "error-detail" }, copy.detail),
|
||||
];
|
||||
if (app.roomCode !== "") {
|
||||
children.push(el("p", { id: "error-code" }, `Room code: ${app.roomCode}`));
|
||||
}
|
||||
target.append(el("div", { class: "panel error" }, el("div", { role: "alert" }, ...children), buttonRow));
|
||||
}
|
||||
|
||||
export function unmount() {
|
||||
/* static */
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Undercooked — the landing screen (phase 07 task 02).
|
||||
//
|
||||
// The door of the app: *Create lobby* (a `POST /api/rooms`, the token in
|
||||
// the body — the session is minted server-side and returned once) and the
|
||||
// join form (code normalised to five uppercase characters, remembered in
|
||||
// `sessionStorage` so a reload rejoins fast). Server refusals that land
|
||||
// here — the process's `room_full` on create, an unreachable server —
|
||||
// render in the status region, which stays on the screen (the app only
|
||||
// leaves for a real room).
|
||||
|
||||
import { el, frag, normalizeCode, CODE_LENGTH } from "./dom.js";
|
||||
|
||||
let statusEl = null;
|
||||
let joinInput = null;
|
||||
let bannerText = "";
|
||||
|
||||
export function mount(target, _data, app) {
|
||||
bannerText = app.banner ?? "";
|
||||
statusEl = el(
|
||||
"p",
|
||||
{ id: "status", class: "status", role: "status", "aria-live": "polite" },
|
||||
bannerText || "Pick a kitchen to start.",
|
||||
);
|
||||
joinInput = el("input", {
|
||||
id: "join-code",
|
||||
name: "code",
|
||||
type: "text",
|
||||
inputmode: "text",
|
||||
autocapitalize: "characters",
|
||||
spellcheck: "false",
|
||||
maxlength: String(CODE_LENGTH),
|
||||
size: String(CODE_LENGTH),
|
||||
placeholder: "ABCDE",
|
||||
"aria-describedby": "join-hint",
|
||||
required: true,
|
||||
value: app.lastCode || "",
|
||||
});
|
||||
const form = el("form", { id: "join-form", class: "panel", autocomplete: "off" },
|
||||
el("h2", { id: "lobby-heading" }, "Kitchen lobby"),
|
||||
el("p", { id: "lobby-help", class: "tagline" },
|
||||
"Create a kitchen and read its 5-character code out loud, or join one that is already open. Nothing is saved and nothing is asked for."),
|
||||
el("button", {
|
||||
id: "create-lobby",
|
||||
class: "btn btn-primary",
|
||||
type: "button",
|
||||
onClick: () => {
|
||||
app.banner = null;
|
||||
app.createLobby();
|
||||
},
|
||||
}, "Create lobby"),
|
||||
el("label", { for: "join-code" }, "Room code"),
|
||||
el("div", { class: "join-row" }, joinInput, el("button", { class: "btn", type: "submit" }, "Join")),
|
||||
el("p", { id: "join-hint", class: "hint" },
|
||||
"Five characters, letters and digits, never ",
|
||||
el("code", {}, "I"), ", ",
|
||||
el("code", {}, "L"), ", ",
|
||||
el("code", {}, "O"), " or ",
|
||||
el("code", {}, "U"), "."),
|
||||
statusEl,
|
||||
);
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const code = normalizeCode(joinInput.value);
|
||||
joinInput.value = code;
|
||||
if (code.length !== CODE_LENGTH) {
|
||||
setBanner(`A room code is ${CODE_LENGTH} characters.`);
|
||||
joinInput.focus();
|
||||
return;
|
||||
}
|
||||
app.banner = null;
|
||||
app.join(code);
|
||||
});
|
||||
target.append(frag(form));
|
||||
}
|
||||
|
||||
export function unmount() {
|
||||
/* no timers; the app's show() clears the root */
|
||||
}
|
||||
|
||||
export function setBanner(message) {
|
||||
if (statusEl === null) return;
|
||||
bannerText = message;
|
||||
statusEl.textContent = message || "Pick a kitchen to start.";
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// Undercooked — the lobby screen (phase 07 task 02).
|
||||
//
|
||||
// The room, as the server broadcasts it: the code with a copy button
|
||||
// (the clipboard API, with a select-and-copy fallback), the seat list
|
||||
// (index, name, colour chip, ready, the crown, disconnected), the
|
||||
// *Ready* toggle, the host-only level picker (fed by `/api/levels` —
|
||||
// difficulty pips, target, time, blurb in the option's tooltip), the
|
||||
// *Start* button (enabled from `lobby.can_start`, disabled with a
|
||||
// reason otherwise — the client's mirror of `Room.can_start`), the
|
||||
// session panel (`/api/session`'s public projection) and a
|
||||
// spectator notice.
|
||||
//
|
||||
// The DOM is built once on mount; every `lobby` re-broadcast (a join,
|
||||
// a ready-up, a host change, a level pick) calls `update()`, which
|
||||
// patches the dynamic parts in place — a re-mount would drop focus off
|
||||
// the very control the player just touched.
|
||||
|
||||
import { el, frag } from "./dom.js";
|
||||
|
||||
let built = false;
|
||||
let seatsUl = null;
|
||||
let readyBtn = null;
|
||||
let readyLabel = null;
|
||||
let levelSelect = null;
|
||||
let levelLabel = null;
|
||||
let startBtn = null;
|
||||
let startReason = null;
|
||||
let sessionPanel = null;
|
||||
let specNote = null;
|
||||
let bannerEl = null;
|
||||
let codeEl = null;
|
||||
|
||||
/** The level a `lobby` frame points at, with the `welcome`'s as fallback. */
|
||||
function currentLobby(app) {
|
||||
if (app.lobby !== null) return app.lobby;
|
||||
const welcome = app.welcome;
|
||||
return {
|
||||
code: app.roomCode,
|
||||
state: "lobby",
|
||||
level: welcome !== null ? welcome.level : null,
|
||||
host_seat: null,
|
||||
can_start: false,
|
||||
seats: welcome !== null ? welcome.seats : [],
|
||||
spectators: [],
|
||||
unlocked: null,
|
||||
round_no: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's 4-colour seat palette (server/seats.py `SEAT_PALETTE`,
|
||||
* docs/PROTOCOL.md "Lobby") as CSS classes — an inline `style`
|
||||
* attribute would trip the CSP's `style-src 'self'` (a console error),
|
||||
* so the colour rides on a class. An unknown colour (the palette ever
|
||||
* grows) falls back to the neutral `.chip` skin.
|
||||
*/
|
||||
const CHIP_CLASS = {
|
||||
"#e5484d": "chip-red",
|
||||
"#3b9eff": "chip-blue",
|
||||
"#3ddc84": "chip-green",
|
||||
"#ffb224": "chip-amber",
|
||||
};
|
||||
|
||||
function seatRow(seat, ownSeatIndex) {
|
||||
const children = [
|
||||
el("span", {
|
||||
class: `chip ${CHIP_CLASS[seat.color ?? ""] ?? ""}`.trim(),
|
||||
"aria-hidden": "true",
|
||||
}),
|
||||
el("span", { class: "seat-index", "aria-hidden": "true" }, `#${seat.index + 1}`),
|
||||
el("span", { class: "seat-name" }, seat.name),
|
||||
];
|
||||
if (seat.host) children.push(el("span", { class: "crown", "aria-label": "host" }, "♛"));
|
||||
if (seat.ready) children.push(el("span", { class: "ready-badge" }, "ready"));
|
||||
if (seat.connected === false) children.push(el("span", { class: "ghost-badge" }, "disconnected"));
|
||||
const row = el("li", { class: "seat" }, frag(...children));
|
||||
if (seat.index === ownSeatIndex) row.dataset.you = "";
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderSeats(app) {
|
||||
const lobby = currentLobby(app);
|
||||
const rows = lobby.seats.map((seat) => seatRow(seat, app.you !== null ? app.you.seat : null));
|
||||
seatsUl.replaceChildren(frag(...rows));
|
||||
}
|
||||
|
||||
/** The client's mirror of `Room.can_start`'s refusal prose. */
|
||||
function startReasonFor(app) {
|
||||
const lobby = currentLobby(app);
|
||||
const ownHost = app.you !== null && app.you.host;
|
||||
if (!ownHost) return "Only the host can start";
|
||||
if (lobby.level === null) return "Pick a level first";
|
||||
return lobby.can_start ? "" : "Waiting for ready cooks";
|
||||
}
|
||||
|
||||
function renderControls(app) {
|
||||
const lobby = currentLobby(app);
|
||||
const own = app.you !== null ? lobby.seats.find((seat) => seat.index === app.you.seat) : undefined;
|
||||
// The ready toggle is a toggle on the wire: the label shows the state.
|
||||
readyLabel.textContent = own !== undefined && own.ready ? "Unready" : "Ready up";
|
||||
readyBtn.dataset.ready = own !== undefined && own.ready ? "true" : "false";
|
||||
// The picker: host-only; the selected level is the server's truth.
|
||||
const ownHost = app.you !== null && app.you.host;
|
||||
levelSelect.disabled = !ownHost;
|
||||
levelLabel.textContent = ownHost ? "Level" : "Level (host only)";
|
||||
if (ownHost && lobby.level !== null) {
|
||||
const options = levelSelect.querySelectorAll("option");
|
||||
const match = [...options].find((option) => option.value === lobby.level);
|
||||
if (match !== undefined) levelSelect.value = match.value;
|
||||
}
|
||||
// Start: enabled from `can_start`, disabled with a reason otherwise.
|
||||
const reason = startReasonFor(app);
|
||||
startBtn.disabled = reason !== "";
|
||||
startReason.textContent = reason;
|
||||
startReason.hidden = reason === "";
|
||||
}
|
||||
|
||||
function renderLevelOptions(app) {
|
||||
const levels = app.levels ?? [];
|
||||
const loading = app.levels === null || app.levels === undefined;
|
||||
levelSelect.replaceChildren(
|
||||
frag(
|
||||
el("option", { value: "" }, loading ? "Loading levels…" : "Pick a level"),
|
||||
...levels.map((level) => {
|
||||
const pips = Math.ceil(level.difficulty / 2);
|
||||
const pipText = "●".repeat(pips) + "○".repeat(5 - pips);
|
||||
return el(
|
||||
"option",
|
||||
{ value: level.id, title: level.blurb },
|
||||
`#${level.order} ${level.name} — ${level.target_score} pts / ${Math.round(level.time_limit)} s ${pipText}`,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
const lobby = currentLobby(app);
|
||||
if (lobby.level !== null) {
|
||||
const match = [...levelSelect.querySelectorAll("option")].find((option) => option.value === lobby.level);
|
||||
if (match !== undefined) levelSelect.value = match.value;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSession(app) {
|
||||
const session = app.session;
|
||||
if (session === null) {
|
||||
sessionPanel.replaceChildren(el("p", {}, "Your session: loading…"));
|
||||
return;
|
||||
}
|
||||
const bests = Object.entries(session.best ?? {}).map(([levelId, run]) =>
|
||||
el("li", {}, `${levelId} — ${run.score} pts, ${"★".repeat(run.stars)}${"☆".repeat(3 - run.stars)}`),
|
||||
);
|
||||
sessionPanel.replaceChildren(
|
||||
frag(
|
||||
el("h3", {}, "Your session"),
|
||||
el("p", {}, `Unlocked: level ${session.unlocked}`),
|
||||
el("p", {}, `Rounds: ${session.rounds_played} · Dishes served: ${session.dishes_served}`),
|
||||
el("p", {}, "Best:"),
|
||||
bests.length === 0
|
||||
? el("p", { class: "hint" }, "no best runs yet")
|
||||
: el("ul", { class: "bests" }, frag(...bests)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function renderSpectators(app) {
|
||||
const spectators = currentLobby(app).spectators;
|
||||
specNote.hidden = spectators.length === 0;
|
||||
specNote.textContent =
|
||||
spectators.length === 0 ? "" : `Watching: ${spectators.map((spec) => spec.name).join(", ")}`;
|
||||
}
|
||||
|
||||
export function mount(target, _data, app) {
|
||||
const copyButton = el("button", {
|
||||
id: "copy-code",
|
||||
class: "btn",
|
||||
type: "button",
|
||||
"aria-label": "Copy the room code",
|
||||
}, "Copy code");
|
||||
copyButton.addEventListener("click", () => void copyCode(app, copyButton));
|
||||
|
||||
codeEl = el("p", { id: "room-code", class: "room-code" }, app.roomCode || "…");
|
||||
seatsUl = el("ul", { id: "seat-list", class: "seat-list" });
|
||||
readyBtn = el("button", {
|
||||
id: "ready-btn",
|
||||
class: "btn",
|
||||
type: "button",
|
||||
onClick: () => app.ready(),
|
||||
});
|
||||
readyLabel = el("span", { class: "ready-label" }, "Ready");
|
||||
readyBtn.append(readyLabel);
|
||||
levelSelect = el("select", {
|
||||
id: "level-picker",
|
||||
"aria-labelledby": "level-label",
|
||||
}, el("option", { value: "" }, "Loading levels…"));
|
||||
levelSelect.addEventListener("change", () => {
|
||||
if (levelSelect.value !== "") app.setLevel(levelSelect.value);
|
||||
});
|
||||
levelLabel = el("label", { id: "level-label", for: "level-picker" }, "Level");
|
||||
startBtn = el("button", {
|
||||
id: "start-btn",
|
||||
class: "btn btn-primary",
|
||||
type: "button",
|
||||
disabled: true,
|
||||
onClick: () => app.start(),
|
||||
}, "Start");
|
||||
startReason = el("p", { id: "start-reason", class: "hint", role: "status" }, "");
|
||||
sessionPanel = el("div", { id: "session-panel", class: "panel session" });
|
||||
specNote = el("p", { id: "spectator-note", class: "hint", hidden: true });
|
||||
bannerEl = el("p", { id: "lobby-banner", class: "status", role: "status", "aria-live": "polite", hidden: true });
|
||||
|
||||
target.append(
|
||||
el("div", { class: "panel lobby" },
|
||||
el("div", { class: "lobby-head" },
|
||||
el("h2", {}, "Kitchen lobby"),
|
||||
el("div", { class: "code-row" }, codeEl, copyButton),
|
||||
),
|
||||
el("h3", { class: "section-label" }, "Cooks"),
|
||||
seatsUl,
|
||||
el("div", { class: "lobby-controls" }, readyBtn),
|
||||
el("div", { class: "level-pick" }, levelLabel, levelSelect),
|
||||
el("div", { class: "lobby-controls" }, startBtn, startReason),
|
||||
specNote,
|
||||
sessionPanel,
|
||||
bannerEl,
|
||||
),
|
||||
);
|
||||
built = true;
|
||||
renderAll(app);
|
||||
void app.fetchLevels().then(() => {
|
||||
if (built) {
|
||||
renderLevelOptions(app);
|
||||
renderControls(app);
|
||||
}
|
||||
});
|
||||
void app.fetchSession().then(() => {
|
||||
if (built) renderSession(app);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAll(app) {
|
||||
renderSeats(app);
|
||||
renderLevelOptions(app);
|
||||
renderControls(app);
|
||||
renderSession(app);
|
||||
renderSpectators(app);
|
||||
}
|
||||
|
||||
/** The app's same-screen re-show: a `lobby` re-broadcast while mounted
|
||||
* (the reducer keeps `app.lobby` fresh before calling this). */
|
||||
export function update(_data, app) {
|
||||
if (!built || app === undefined) return;
|
||||
codeEl.textContent = app.roomCode || "…";
|
||||
renderAll(app);
|
||||
}
|
||||
|
||||
export function unmount() {
|
||||
built = false;
|
||||
/* the app's show() clears the root */
|
||||
}
|
||||
|
||||
export function setBanner(message) {
|
||||
if (bannerEl === null) return;
|
||||
bannerEl.textContent = message ?? "";
|
||||
bannerEl.hidden = message === null || message === "";
|
||||
}
|
||||
|
||||
async function copyCode(app, button) {
|
||||
const code = app.roomCode;
|
||||
let copied = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
copied = true;
|
||||
} catch {
|
||||
// The clipboard API needs a secure context and a gesture fallback:
|
||||
// select a throwaway input and copy from it. The hidden class, not
|
||||
// an inline `style` (the CSP's `style-src 'self'` blocks the
|
||||
// latter and the console would log it).
|
||||
const input = document.createElement("input");
|
||||
input.value = code;
|
||||
input.setAttribute("readonly", "");
|
||||
input.classList.add("visually-hidden");
|
||||
document.body.append(input);
|
||||
input.select();
|
||||
try {
|
||||
copied = document.execCommand("copy");
|
||||
} catch {
|
||||
copied = false;
|
||||
}
|
||||
input.remove();
|
||||
}
|
||||
setBanner(copied ? "Code copied — read it out loud." : `Copy failed — the code is ${code}.`);
|
||||
button.setAttribute("data-copied", copied ? "true" : "false");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Undercooked — the play screen (phase 07 task 02).
|
||||
//
|
||||
// The kitchen's frame: the canvas host (`#kcanvas` — hidden until the
|
||||
// task-04 renderer sizes and unhides it), the HUD root (`#hud`, filled
|
||||
// by task 04), the *Leave* control, the reconnecting overlay (its
|
||||
// *logic* lands in phase 08; this task shows the sign), the emote row
|
||||
// (the `docs/PROTOCOL.md` whitelist: `wave`, `cheer`, `thumbs_up`,
|
||||
// `thumbs_down`, `angry`, `star`), the spectator notice (a join during
|
||||
// `COUNTDOWN`/`PLAYING`/`ROUND_END` watches, per the wire contract) and
|
||||
// the banner region (non-fatal `err` codes, e.g. a `start` refusal).
|
||||
|
||||
import { el, frag } from "./dom.js";
|
||||
|
||||
/** The emote whitelist (docs/PROTOCOL.md "EmoteMsg"): id → label. */
|
||||
const EMOTES = [
|
||||
["wave", "Wave", "👋"],
|
||||
["cheer", "Cheer", "🎉"],
|
||||
["thumbs_up", "Thumbs up", "👍"],
|
||||
["thumbs_down", "Thumbs down", "👎"],
|
||||
["angry", "Angry", "😠"],
|
||||
["star", "Star", "⭐"],
|
||||
];
|
||||
|
||||
let overlay = null;
|
||||
let bannerEl = null;
|
||||
|
||||
export function mount(target, _data, app) {
|
||||
overlay = el(
|
||||
"div",
|
||||
{ id: "reconnect-overlay", class: "overlay", hidden: true, role: "status" },
|
||||
el("p", {}, "Connection lost — reconnecting… (phase 08)"),
|
||||
);
|
||||
bannerEl = el("p", {
|
||||
id: "play-banner",
|
||||
class: "status",
|
||||
role: "status",
|
||||
"aria-live": "polite",
|
||||
hidden: true,
|
||||
});
|
||||
target.append(
|
||||
el("div", { class: "play-screen" },
|
||||
el("div", { class: "play-head" },
|
||||
el("p", {
|
||||
id: "spectator-note",
|
||||
class: "hint",
|
||||
hidden: !app.spectator,
|
||||
}, "You are watching — the room is full."),
|
||||
el("button", {
|
||||
id: "leave-btn",
|
||||
class: "btn",
|
||||
type: "button",
|
||||
onClick: () => app.leave(),
|
||||
}, "Leave"),
|
||||
),
|
||||
bannerEl,
|
||||
el("canvas", { id: "kcanvas", hidden: true, "aria-hidden": "true" }),
|
||||
el("div", { id: "hud", class: "hud" }),
|
||||
el("div", { class: "emote-row", role: "group", "aria-label": "Emotes" },
|
||||
frag(
|
||||
...EMOTES.map(([id, label, glyph]) =>
|
||||
el(
|
||||
"button",
|
||||
{
|
||||
class: "btn emote",
|
||||
type: "button",
|
||||
"aria-label": label,
|
||||
"data-emote": id,
|
||||
onClick: () => app.emote(id),
|
||||
},
|
||||
el("span", { "aria-hidden": "true" }, glyph),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
overlay,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The reconnecting overlay (populated in phase 08). */
|
||||
export function showReconnecting(visible) {
|
||||
if (overlay !== null) overlay.hidden = !visible;
|
||||
}
|
||||
|
||||
export function setBanner(message) {
|
||||
if (bannerEl === null) return;
|
||||
bannerEl.textContent = message ?? "";
|
||||
bannerEl.hidden = message === null || message === "";
|
||||
}
|
||||
|
||||
export function unmount() {
|
||||
overlay = null;
|
||||
bannerEl = null;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Undercooked — the result screen (phase 07 task 02).
|
||||
//
|
||||
// The verdict, as the `round` frame carries it: the result word, the
|
||||
// stars (1–3, the pop is decorative and `prefers-reduced-motion`-gated),
|
||||
// score against target, the session's best for the level that was
|
||||
// played, the per-player contribution list (display only — PLAN §5:
|
||||
// never competitive gatekeeping), the counters (`served` / `missed` /
|
||||
// `wasted`, the longest streak), and the three exits — *Next level*
|
||||
// (only when `round.unlocked` names a level), *Retry* (`next_level`)
|
||||
// and *Back to lobby* (`to_lobby`). The outcome is announced to screen
|
||||
// readers through an `aria-live="polite"` region.
|
||||
|
||||
import { el, frag } from "./dom.js";
|
||||
|
||||
let bestEl = null;
|
||||
let bannerEl = null;
|
||||
|
||||
function starsFor(stars) {
|
||||
const count = Math.max(0, Math.min(3, stars));
|
||||
const spans = [];
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
spans.push(
|
||||
el("span", { class: i < count ? "star filled" : "star", "aria-hidden": "true" },
|
||||
i < count ? "★" : "☆"),
|
||||
);
|
||||
}
|
||||
return el("p", {
|
||||
id: "stars",
|
||||
class: "stars",
|
||||
"aria-label": `${count} of 3 stars`,
|
||||
}, frag(...spans));
|
||||
}
|
||||
|
||||
function resultWord(result) {
|
||||
if (result === "won") return "Won";
|
||||
if (result === "lost") return "Lost";
|
||||
if (result === "aborted") return "Aborted";
|
||||
return "Round over";
|
||||
}
|
||||
|
||||
function announcement(result, score, target, stars) {
|
||||
const word = result === "won" ? "won" : result === "lost" ? "lost" : "ended";
|
||||
return `You ${word} ${score} of ${target} points with ${stars} star${stars === 1 ? "" : "s"}.`;
|
||||
}
|
||||
|
||||
export function mount(target, _data, app) {
|
||||
const round = app.round ?? {};
|
||||
const result = round.result ?? null;
|
||||
const stars = round.stars ?? 0;
|
||||
const score = round.score ?? 0;
|
||||
const targetScore = round.target ?? 0;
|
||||
|
||||
const players = (round.players ?? []).map((player) =>
|
||||
el("li", { class: "result-player" },
|
||||
el("span", { class: "seat-name" }, player.name),
|
||||
el("span", {}, `${player.score} pts · ${player.dishes_served} dish${player.dishes_served === 1 ? "" : "es"}`),
|
||||
),
|
||||
);
|
||||
const counters = round.counters ?? {};
|
||||
bestEl = el("p", { id: "best-line" }, "Best: loading…");
|
||||
bannerEl = el("p", { class: "hint", id: "result-banner", hidden: true, role: "status", "aria-live": "polite" });
|
||||
|
||||
const actions = el("div", { class: "result-actions" },
|
||||
el("button", {
|
||||
id: "retry-btn",
|
||||
class: "btn",
|
||||
type: "button",
|
||||
onClick: () => app.nextLevel(),
|
||||
}, "Retry"),
|
||||
el("button", {
|
||||
id: "to-lobby-btn",
|
||||
class: "btn btn-primary",
|
||||
type: "button",
|
||||
onClick: () => app.toLobby(),
|
||||
}, "Back to lobby"),
|
||||
);
|
||||
if (round.unlocked !== null && round.unlocked !== undefined) {
|
||||
actions.prepend(
|
||||
el("button", {
|
||||
id: "next-level-btn",
|
||||
class: "btn btn-primary",
|
||||
type: "button",
|
||||
onClick: () => app.nextLevel(),
|
||||
}, "Next level"),
|
||||
);
|
||||
}
|
||||
|
||||
target.append(
|
||||
el("div", { class: "panel result" },
|
||||
el("h2", { id: "result-word" }, resultWord(result)),
|
||||
starsFor(stars),
|
||||
el("p", { id: "score-line" }, `Score ${score} of ${targetScore}`),
|
||||
bestEl,
|
||||
el("h3", { class: "section-label" }, "Cooks"),
|
||||
players.length === 0
|
||||
? el("p", { class: "hint" }, "no players")
|
||||
: el("ul", { id: "result-players", class: "seat-list" }, frag(...players)),
|
||||
el("p", { id: "counters-line" },
|
||||
`Served ${counters.served ?? 0} · Missed ${counters.missed ?? 0} · Wasted ${counters.wasted ?? 0} · Longest streak ${round.longest_streak ?? 0}`),
|
||||
actions,
|
||||
bannerEl,
|
||||
),
|
||||
el("p", {
|
||||
id: "result-announce",
|
||||
class: "visually-hidden",
|
||||
role: "status",
|
||||
"aria-live": "polite",
|
||||
}, announcement(result, score, targetScore, stars)),
|
||||
);
|
||||
// The best for the level that was played (the session update lands
|
||||
// with the `round` frame — `round_over` records before it broadcasts).
|
||||
void app.fetchSession().then(() => {
|
||||
if (bestEl !== null) renderBest(app);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderBest(app) {
|
||||
if (bestEl === null) return;
|
||||
const round = app.round ?? {};
|
||||
const session = app.session;
|
||||
const best = session !== null && round.level_id !== undefined ? session.best?.[round.level_id] : undefined;
|
||||
bestEl.textContent =
|
||||
best !== undefined
|
||||
? `Best: ${best.score} pts, ${"★".repeat(best.stars)}${"☆".repeat(3 - best.stars)}`
|
||||
: "Best: —";
|
||||
}
|
||||
|
||||
/** Same-screen re-show: re-render the best line (a `round` re-broadcast
|
||||
* or a session refresh). */
|
||||
export function update(_data, app) {
|
||||
if (app !== undefined) renderBest(app);
|
||||
}
|
||||
|
||||
export function setBanner(message) {
|
||||
if (bannerEl === null) return;
|
||||
bannerEl.textContent = message ?? "";
|
||||
bannerEl.hidden = message === null || message === "";
|
||||
}
|
||||
|
||||
export function unmount() {
|
||||
bestEl = null;
|
||||
bannerEl = null;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Undercooked — the wire enums (phase 07 task 03).
|
||||
//
|
||||
// The client's vocabulary for the sim's StrEnums (src/undercooked/sim/model.py)
|
||||
// and the protocol's `ErrorCode` (src/undercooked/server/protocol.py). The
|
||||
// wire carries the *string values* (a StrEnum's `.value`); these frozen sets
|
||||
// are what `net/decode.js` maps against and what a frame's values are
|
||||
// checked against.
|
||||
//
|
||||
// Drift is a build failure, not a surprise: `tests/unit/test_client_enum_parity.py`
|
||||
// reads this file and asserts each set is set-equal to the Python enum, in
|
||||
// both directions (the same guard pattern as `test_protocol_doc_sync.py`).
|
||||
// Add a value to the sim and forget it here (or vice versa) and the gate
|
||||
// fails, naming the missing value.
|
||||
|
||||
/** The terrain under a grid cell (`TileKind`). */
|
||||
export const TILE_KIND = Object.freeze([
|
||||
"floor",
|
||||
"counter",
|
||||
"wall",
|
||||
"void",
|
||||
]);
|
||||
|
||||
/** The kitchen fixture types (`StationKind`). */
|
||||
export const STATION_KIND = Object.freeze([
|
||||
"spawn_shelf",
|
||||
"chop_board",
|
||||
"stove",
|
||||
"prep",
|
||||
"serve",
|
||||
"sink",
|
||||
"trash",
|
||||
]);
|
||||
|
||||
/** Ingredient / dish types (`ItemKind`). */
|
||||
export const ITEM_KIND = Object.freeze([
|
||||
"tomato",
|
||||
"onion",
|
||||
"lettuce",
|
||||
"dough",
|
||||
"patty",
|
||||
"rice",
|
||||
"fish",
|
||||
"plate",
|
||||
]);
|
||||
|
||||
/** The preparation stage of an item (`ItemForm`). */
|
||||
export const ITEM_FORM = Object.freeze([
|
||||
"raw",
|
||||
"chopped",
|
||||
"cooking",
|
||||
"cooked",
|
||||
"burnt",
|
||||
"plated",
|
||||
"dirty",
|
||||
]);
|
||||
|
||||
/** The four directions; the wire's `f` is an index into this list (PLAN §4: 0-3). */
|
||||
export const FACING = Object.freeze(["up", "right", "down", "left"]);
|
||||
|
||||
/** The transient FX/log event kinds (`EventKind`; `encode_events`' `k`). */
|
||||
export const EVENT_KIND = Object.freeze([
|
||||
"chopped",
|
||||
"cooked",
|
||||
"ready",
|
||||
"served",
|
||||
"burnt",
|
||||
"order_missed",
|
||||
"fire_started",
|
||||
"denied",
|
||||
"score",
|
||||
"stunned",
|
||||
"washed",
|
||||
"trashed",
|
||||
"round_end",
|
||||
"wasted",
|
||||
"picked",
|
||||
"dropped",
|
||||
"handed_off",
|
||||
"trashed_already_empty",
|
||||
"tick_slow",
|
||||
"err",
|
||||
"resync",
|
||||
"ghost_on",
|
||||
"ghost_off",
|
||||
"ghost_park",
|
||||
]);
|
||||
|
||||
/** The stable rejection codes (`ErrorCode`, docs/PROTOCOL.md "Error codes"). */
|
||||
export const ERROR_CODE = Object.freeze([
|
||||
"unknown_type",
|
||||
"bad_json",
|
||||
"bad_field",
|
||||
"too_big",
|
||||
"rate_limited",
|
||||
"not_seated",
|
||||
"not_host",
|
||||
"level_locked",
|
||||
"wrong_state",
|
||||
"room_full",
|
||||
"room_gone",
|
||||
"protocol_mismatch",
|
||||
"evicted",
|
||||
"internal",
|
||||
]);
|
||||
|
||||
// The extras the renderer (task 04) needs. Not in the parity gate's
|
||||
// seven, but the same discipline applies: the wire's short strings.
|
||||
|
||||
/** What a cook is doing this tick (`PlayerState`; the wire's `s`). */
|
||||
export const PLAYER_STATE = Object.freeze([
|
||||
"idle",
|
||||
"moving",
|
||||
"using",
|
||||
"stunned",
|
||||
"disconnected",
|
||||
]);
|
||||
|
||||
/** A station's lifecycle (`StationState`; the wire's `s`). */
|
||||
export const STATION_STATE = Object.freeze([
|
||||
"idle",
|
||||
"working",
|
||||
"ready",
|
||||
"busy",
|
||||
"dirty",
|
||||
"blocked",
|
||||
]);
|
||||
|
||||
/** The hazard families (`HazardKind`; the wire's `t`). */
|
||||
export const HAZARD_KIND = Object.freeze([
|
||||
"fire",
|
||||
"spill",
|
||||
"moving_obstacle",
|
||||
"rush",
|
||||
]);
|
||||
|
||||
/** The terminal outcome of a round (`RoundResult`). */
|
||||
export const ROUND_RESULT = Object.freeze(["won", "lost", "aborted"]);
|
||||
@@ -0,0 +1,118 @@
|
||||
// Undercooked — the help overlay (phase 07, task 05; the task's
|
||||
// `app/static/ui/help.js`, served from `js/ui/` like the HUD).
|
||||
//
|
||||
// The `?`-key in-game help: a body-level, fixed, dismissible panel
|
||||
// listing the key map. Created once at boot, persists across screens
|
||||
// (it is not a screen — the state machine never knows it), hidden by
|
||||
// default. `?` toggles it; `Escape` closes it (the keyboard module's
|
||||
// `Esc` branch defers to it while it is open); a click on the panel's
|
||||
// backdrop or its Close button dismisses it. No external docs needed
|
||||
// mid-game.
|
||||
//
|
||||
// The rows are `getKeyMap()`'s — `input/keyboard.js` re-exports it
|
||||
// (the canonical source) and the README's controls section renders the
|
||||
// same rows (a test keeps them identical: the browser-rendered map
|
||||
// against the README's block).
|
||||
|
||||
import { el } from "../screens/dom.js";
|
||||
|
||||
/**
|
||||
* The key map the help panel (and the README's controls section — a
|
||||
* test keeps them identical) renders: `{ keys, action }` rows in
|
||||
* display order. `keys` are joined with " / " when rendered.
|
||||
*/
|
||||
export function getKeyMap() {
|
||||
return [
|
||||
{ keys: ["W", "A", "S", "D"], action: "move (or the arrow keys)" },
|
||||
{ keys: ["Space", "Enter"], action: "action (pick, work, set down, serve)" },
|
||||
{ keys: ["1", "2", "3", "4"], action: "emote (wave, cheer, thumbs up, thumbs down)" },
|
||||
{ keys: ["?"], action: "help overlay" },
|
||||
{ keys: ["Esc"], action: "close help / leave the room" },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The help overlay itself.
|
||||
*
|
||||
* `onToggle` — optional `(visible) => void` (the census seam; the
|
||||
* keyboard module's `?`/`Esc` branches need no hook — they call
|
||||
* `toggle()` / `hide()` directly).
|
||||
*
|
||||
* Returns `{ show, hide, toggle, visible }`.
|
||||
*/
|
||||
export function createHelpOverlay({ onToggle = null } = {}) {
|
||||
let panel = null;
|
||||
let visible = false;
|
||||
|
||||
function build() {
|
||||
if (panel !== null) return panel;
|
||||
const title = el(
|
||||
"div",
|
||||
{ class: "help-title" },
|
||||
el("span", { class: "help-heading", text: "Controls" }),
|
||||
el("button", {
|
||||
class: "btn help-close",
|
||||
type: "button",
|
||||
"aria-label": "Close help",
|
||||
text: "Close",
|
||||
onClick: () => hide(),
|
||||
})
|
||||
);
|
||||
const rows = getKeyMap().map((row) =>
|
||||
el(
|
||||
"div",
|
||||
{ class: "help-row" },
|
||||
el("kbd", { class: "help-keys", text: row.keys.join(" / ") }),
|
||||
el("span", { class: "help-action", text: row.action })
|
||||
)
|
||||
);
|
||||
panel = el(
|
||||
"div",
|
||||
{
|
||||
id: "help-overlay",
|
||||
class: "help-overlay",
|
||||
role: "dialog",
|
||||
"aria-label": "Help",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
el(
|
||||
"div",
|
||||
{ class: "panel help-panel", onClick: (e) => e.stopPropagation() },
|
||||
title,
|
||||
el("div", { class: "help-rows" }, ...rows),
|
||||
el("p", {
|
||||
class: "help-foot",
|
||||
text: "Press ? or Esc to close. On the play screen, Esc leaves the room.",
|
||||
})
|
||||
)
|
||||
);
|
||||
panel.addEventListener("click", () => hide());
|
||||
return panel;
|
||||
}
|
||||
|
||||
function show() {
|
||||
const node = build();
|
||||
if (visible) return;
|
||||
node.setAttribute("aria-hidden", "false");
|
||||
document.body.append(node);
|
||||
visible = true;
|
||||
if (onToggle !== null) onToggle(true);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!visible) return;
|
||||
visible = false;
|
||||
if (panel !== null) {
|
||||
panel.setAttribute("aria-hidden", "true");
|
||||
panel.remove();
|
||||
}
|
||||
if (onToggle !== null) onToggle(false);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (visible) hide();
|
||||
else show();
|
||||
}
|
||||
|
||||
return { show, hide, toggle, visible: () => visible };
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// Undercooked — the HUD (phase 07, task 04).
|
||||
//
|
||||
// The game's readouts, all DOM (not canvas) so they stay crisp and
|
||||
// readable by assistive tech: the order rail (one ticket per order —
|
||||
// the recipe icon, the recipe name, the deadline's time bar, the
|
||||
// points; the *oldest* ticket — the rail's head — highlighted), the
|
||||
// score + target bar, the countdown timer (amber below 20 s, red below
|
||||
// 10 s, each with a **non-colour cue**: the label's text —
|
||||
// `hurry` / `now` — and the striped bar pattern, so the state reads
|
||||
// without colour), the streak indicator (the flame, from the
|
||||
// `score`/`order_missed` events — the snapshot carries no streak
|
||||
// field), the dirty-dish counter (the `dirty` stations of the latest
|
||||
// frame), the connection indicator (the socket's status) and the
|
||||
// spectator banner.
|
||||
//
|
||||
// Updates are **coalesced to one write per animation frame**: the
|
||||
// single render loop (`render/loop.js`) calls `update()` once per
|
||||
// frame, and `update` diff-writes — a node's `textContent` / class is
|
||||
// touched only when the value actually changed, so a 60 fps frame that
|
||||
// changes nothing writes nothing. The HUD owns no `requestAnimationFrame`
|
||||
// of its own (the single-owner rule) and no event listeners: it is a
|
||||
// pure function of the frame + the app's live state.
|
||||
//
|
||||
// The recipe icon is a `<img>` on the atlas's decoded `data:` URL
|
||||
// (`atlas.dataUrl("ui-ticket")` — the CSP allows `img-src data:`; the
|
||||
// bytes are the app's own, the same decode the canvas uses; a
|
||||
// `#symbol` fragment never renders from a cross-file `<img>`). The
|
||||
// streak flame rides the same path (`ui-streak`).
|
||||
|
||||
import * as atlas from "../render/atlas.js";
|
||||
import { el } from "../screens/dom.js";
|
||||
|
||||
/** The timer's colour thresholds (seconds; the task's bands). */
|
||||
export const TIMER_LOW_S = 20;
|
||||
export const TIMER_CRITICAL_S = 10;
|
||||
|
||||
/**
|
||||
* The timer's visual state for a time-left (seconds): `ok` / `low`
|
||||
* (amber, below 20 s) / `critical` (red, below 10 s) — with the
|
||||
* non-colour half: `label` (the text cue: `""` / `hurry` / `now`).
|
||||
*/
|
||||
export function timerState(timeLeftS) {
|
||||
if (timeLeftS < TIMER_CRITICAL_S) {
|
||||
return { state: "critical", label: "now" };
|
||||
}
|
||||
if (timeLeftS < TIMER_LOW_S) {
|
||||
return { state: "low", label: "hurry" };
|
||||
}
|
||||
return { state: "ok", label: "" };
|
||||
}
|
||||
|
||||
/** The socket's status → the indicator's word (the e2e asserts on it). */
|
||||
export function connectionWord(status) {
|
||||
if (status === "open") return "Live";
|
||||
if (status === "connecting") return "Connecting…";
|
||||
if (status === "mismatch") return "Out of date";
|
||||
return "Dropped";
|
||||
}
|
||||
|
||||
/**
|
||||
* One HUD. `update(view, latest, app, eventsDue, nowMs)` — the
|
||||
* `lerpState` output (the score / target / timer / players), the
|
||||
* buffer's latest decoded frame (the orders — the rail's source; the
|
||||
* latest frame, not the lerp: an order's `l` lerps but its identity is
|
||||
* discrete), the app (the connection status, the spectator flag) and
|
||||
* the event queue's live entries (the streak's events). Diff-writes
|
||||
* only.
|
||||
*/
|
||||
export function createHud({ root, app } = {}) {
|
||||
// ----------------------------------------------------------- the DOM
|
||||
const orderList = el("div", { class: "ticket-list" });
|
||||
const ticketIcon = el("img", { class: "ticket-icon", alt: "", "aria-hidden": "true" });
|
||||
const ticketIconSet = { done: false };
|
||||
const scoreEl = el("b", { id: "hud-score" }, "0");
|
||||
const targetEl = el("span", { id: "hud-score-target" }, "/—");
|
||||
const scoreBarFill = el("div", { class: "score-bar-fill" });
|
||||
const streakEl = el("span", {
|
||||
id: "hud-streak",
|
||||
class: "hud-streak",
|
||||
hidden: true,
|
||||
"aria-label": "streak",
|
||||
});
|
||||
const streakIcon = el("img", { alt: "", "aria-hidden": "true" });
|
||||
const streakNum = el("b", {}, "×2");
|
||||
streakEl.append(streakIcon, streakNum);
|
||||
const timerEl = el("b", { id: "hud-timer" }, "—");
|
||||
const timerSection = el("section", {
|
||||
class: "hud-timer",
|
||||
"aria-label": "Time left",
|
||||
});
|
||||
const timerLabel = el("span", { id: "hud-timer-label", class: "hud-timer-label" });
|
||||
timerSection.append(timerEl, timerLabel);
|
||||
const dirtyEl = el("span", { id: "hud-dirty", class: "hud-dirty" }, "Dirty 0");
|
||||
const connEl = el("span", { id: "hud-conn", class: "hud-conn", role: "status" }, "—");
|
||||
const spectatorEl = el(
|
||||
"p",
|
||||
{ id: "hud-spectator", class: "status", hidden: true },
|
||||
"You are watching — the room is full.",
|
||||
);
|
||||
|
||||
root.append(
|
||||
el("div", { class: "hud-grid" },
|
||||
el("section", { class: "hud-orders", "aria-label": "Orders" },
|
||||
el("p", { class: "section-label" }, "Orders"),
|
||||
orderList,
|
||||
),
|
||||
el("section", { class: "hud-scorebox", "aria-label": "Score" },
|
||||
el("p", { class: "hud-score-line" },
|
||||
"Score ",
|
||||
scoreEl,
|
||||
" ",
|
||||
targetEl,
|
||||
" · ",
|
||||
streakEl,
|
||||
),
|
||||
el("div", { class: "score-bar" }, scoreBarFill),
|
||||
),
|
||||
timerSection,
|
||||
el("section", { class: "hud-meta", "aria-label": "Status" },
|
||||
dirtyEl,
|
||||
connEl,
|
||||
spectatorEl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// ------------------------------------------------- the diff-write half
|
||||
const tickets = new Map(); // order id -> the <article>
|
||||
const lastStreak = { value: 0 };
|
||||
const seenEvents = new Map(); // the streak events' dedup keys (the 1.5 s window: a kind's TTL + margin)
|
||||
const EVENT_DEDUP_MS = 1500;
|
||||
|
||||
function setText(node, value) {
|
||||
const text = String(value);
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
}
|
||||
|
||||
function ensureIcon(img, id) {
|
||||
if (ticketIconSet.done && img.dataset.loaded === "1") return;
|
||||
const url = atlas.dataUrl(id);
|
||||
if (url !== null) {
|
||||
if (img.src !== url) img.src = url;
|
||||
img.dataset.loaded = "1";
|
||||
if (id === "ui-ticket") ticketIconSet.done = true;
|
||||
}
|
||||
}
|
||||
|
||||
function update(view, latest, app, eventsDue, nowMs) {
|
||||
const frame = latest !== null && latest !== undefined ? latest : view;
|
||||
|
||||
// The streak (from the events — the snapshot carries no streak
|
||||
// field): a `score` event's `stk` is the streak after the serve,
|
||||
// an `order_missed` resets it. The 1.5 s window suppresses the
|
||||
// same queued entry's repeat frames (a `score` lives 1200 ms).
|
||||
const now = typeof nowMs === "number" ? nowMs : 0;
|
||||
for (const entry of Array.isArray(eventsDue) ? eventsDue : []) {
|
||||
if (entry.k !== "score" && entry.k !== "order_missed") continue;
|
||||
const payload =
|
||||
entry.payload !== null && entry.payload !== undefined ? entry.payload : {};
|
||||
const key = `${entry.k}|${payload.stk ?? ""}|${payload.d ?? ""}`;
|
||||
const last = seenEvents.get(key);
|
||||
if (last !== undefined && now - last < EVENT_DEDUP_MS) continue;
|
||||
seenEvents.set(key, now);
|
||||
if (entry.k === "score" && Number.isFinite(payload.stk)) {
|
||||
lastStreak.value = Math.max(0, Math.trunc(payload.stk));
|
||||
} else if (entry.k === "order_missed") {
|
||||
lastStreak.value = 0;
|
||||
}
|
||||
}
|
||||
for (const [key, at] of [...seenEvents]) {
|
||||
if (now - at >= EVENT_DEDUP_MS) seenEvents.delete(key);
|
||||
}
|
||||
|
||||
// Score + target bar.
|
||||
const score = Number.isFinite(view.s) ? Math.trunc(view.s) : 0;
|
||||
const target = Number.isFinite(view.tgt) ? Math.trunc(view.tgt) : 0;
|
||||
setText(scoreEl, score);
|
||||
setText(targetEl, target > 0 ? `/${target}` : "/—");
|
||||
const fraction = target > 0 ? Math.min(1, score / target) : 0;
|
||||
const barWidth = `${Math.round(fraction * 100)}%`;
|
||||
if (scoreBarFill.style.width !== barWidth) scoreBarFill.style.width = barWidth;
|
||||
|
||||
// The streak (the flame + the ×N, visible from a 2).
|
||||
const streak = lastStreak.value;
|
||||
const visible = streak >= 2;
|
||||
if (Boolean(streakEl.hidden) === visible) {
|
||||
streakEl.hidden = !visible;
|
||||
if (visible) {
|
||||
setText(streakNum, `×${streak}`);
|
||||
streakEl.setAttribute("aria-label", `streak ${streak}`);
|
||||
ensureIcon(streakIcon, "ui-streak");
|
||||
}
|
||||
}
|
||||
|
||||
// The timer (the interpolated `tl`, seconds; the colour bands +
|
||||
// the non-colour label).
|
||||
const tl = Number.isFinite(view.tl) ? view.tl : 0;
|
||||
const shown = Math.max(0, Math.ceil(tl));
|
||||
setText(timerEl, shown);
|
||||
const { state, label } = timerState(tl);
|
||||
const classes = timerSection.className;
|
||||
const wanted =
|
||||
state === "ok" ? "hud-timer" : `hud-timer hud-timer-${state}`;
|
||||
if (classes !== wanted) timerSection.className = wanted;
|
||||
setText(timerLabel, label);
|
||||
|
||||
// The dirty-dish counter (the `dirty` stations of the frame).
|
||||
let dirty = 0;
|
||||
for (const st of frame.st ?? []) {
|
||||
if (st.s === "dirty") dirty += 1;
|
||||
}
|
||||
setText(dirtyEl, `Dirty ${dirty}`);
|
||||
|
||||
// The connection indicator.
|
||||
setText(connEl, connectionWord(app !== undefined ? app.connection.status : "closed"));
|
||||
const connClass =
|
||||
app !== undefined && app.connection.status === "open"
|
||||
? "hud-conn is-live"
|
||||
: "hud-conn is-down";
|
||||
if (connEl.className !== connClass) connEl.className = connClass;
|
||||
|
||||
// The spectator banner.
|
||||
const spectating = Boolean(app !== undefined && app.spectator);
|
||||
if (Boolean(spectatorEl.hidden) === spectating) spectatorEl.hidden = !spectating;
|
||||
|
||||
// The order rail: reconcile by id (the `o[]` of the frame), the
|
||||
// head (index 0 — the oldest: the rail is FIFO) highlighted.
|
||||
const orders = (frame.o ?? []).filter(
|
||||
(o) => o !== null && typeof o === "object",
|
||||
);
|
||||
const ids = new Set();
|
||||
orders.forEach((o, idx) => {
|
||||
ids.add(o.id);
|
||||
let ticket = tickets.get(o.id);
|
||||
if (ticket === undefined) {
|
||||
ticket = el("article", { class: "ticket", "data-order-id": String(o.id) });
|
||||
const icon = el("img", { class: "ticket-icon", alt: "", "aria-hidden": "true" });
|
||||
const name = el("span", { class: "ticket-recipe" });
|
||||
const pts = el("span", { class: "ticket-pts" });
|
||||
const bar = el("div", { class: "ticket-bar" }, el("div", { class: "ticket-bar-fill" }));
|
||||
ticket.append(icon, name, pts, bar);
|
||||
orderList.append(ticket);
|
||||
tickets.set(o.id, ticket);
|
||||
}
|
||||
ensureIcon(ticket.querySelector("img"), "ui-ticket");
|
||||
setText(ticket.querySelector(".ticket-recipe"), o.r ?? "dish");
|
||||
setText(ticket.querySelector(".ticket-pts"), Number.isFinite(o.pts) ? `${Math.trunc(o.pts)} pts` : "");
|
||||
const fill = ticket.querySelector(".ticket-bar-fill");
|
||||
const share = Number.isFinite(o.l) ? Math.max(0, Math.min(1, o.l)) : 0;
|
||||
const width = `${Math.round(share * 100)}%`;
|
||||
if (fill.style.width !== width) fill.style.width = width;
|
||||
const isOldest = idx === 0;
|
||||
ticket.classList.toggle("oldest", isOldest);
|
||||
});
|
||||
for (const [id, ticket] of [...tickets]) {
|
||||
if (!ids.has(id)) {
|
||||
ticket.remove();
|
||||
tickets.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the built DOM (the screen machine clears the root on the
|
||||
* screen change; this is the module's half, for a tidy unmount). */
|
||||
function unmount() {
|
||||
tickets.clear();
|
||||
seenEvents.clear();
|
||||
lastStreak.value = 0;
|
||||
root.replaceChildren();
|
||||
}
|
||||
|
||||
return { update, unmount, timerEl, scoreEl, orderList };
|
||||
}
|
||||
Reference in New Issue
Block a user