**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`.
198 lines
6.2 KiB
JavaScript
198 lines
6.2 KiB
JavaScript
// 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,
|
||
}),
|
||
};
|
||
}
|