Files
ducoterra 95ca3fd1fa phase: 07_canvas_client_and_hud
**Final report — phase 07 final verification pass**

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

198 lines
6.2 KiB
JavaScript
Raw Permalink Blame History

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