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