phase: 07_canvas_client_and_hud

**Final report — phase 07 final verification pass**

- **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found.
- **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase).
- **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔.
- **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry.
- **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`.
- **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate.
- **Next pending phase:** `08_client_prediction_and_touch`.
This commit is contained in:
2026-10-10 18:03:52 -04:00
parent f26849c1f3
commit 95ca3fd1fa
78 changed files with 13650 additions and 259 deletions
+197
View File
@@ -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,
}),
};
}