import type { GameStore } from '../game/GameStore'; import { GameWorld } from './GameWorld'; import { PixelPerfectWorld } from './PixelPerfectWorld'; import { NODE_CLICK_RADIUS } from './entities/NodesView'; import { rabbitClickRadius } from './entities/RabbitsView'; /** Must mirror the @media rule in index.css that rotates #root into landscape on a portrait * touchscreen — when that rule is active, the browser paints everything rotated 90°, so a raw * PointerEvent's clientX/clientY (and the container's getBoundingClientRect()) report the * physical, unrotated screen and have to be converted back to the game's own logical space. See * toLocalPoint. (container.clientWidth/clientHeight, used for sizing, are unaffected by the * rotation transform — only paint-space values like clientX/Y and getBoundingClientRect() are.) */ const ROTATE_QUERY = '(orientation: portrait) and (pointer: coarse)'; type Target = { kind: 'node'; id: string } | { kind: 'rabbit'; id: string }; /** Owns the pixel-perfect canvas for the game world (see GameWorld/PixelPerfectWorld) and all * pointer input for it — the canvas-rendering replacement for GameMap.tsx's map div. The HUD * around it stays plain DOM (see src/app). */ export class GameCanvas { private readonly world = new PixelPerfectWorld(); private gameWorld: GameWorld | null = null; private store: GameStore | null = null; private container: HTMLElement | null = null; private isRotated = false; private readonly mql = window.matchMedia(ROTATE_QUERY); /** Mounts the canvas into `container`, sizes the world to the container's current (already- * logical, rotation-unaffected) box, and starts the game. Mirrors GameMap.tsx's mount effect. */ async mount(container: HTMLElement, store: GameStore): Promise { this.container = container; this.store = store; this.isRotated = this.mql.matches; this.mql.addEventListener('change', this.handleOrientationChange); // The game simulates and draws a virtual screen smaller than the container's real pixels — // sprites render at native 1:1 (see textures.ts), and gameplay distances (constants.ts/ // rabbits.ts/upgrades.ts) are tuned for this smaller scale. A graphics-layer-only concern: // PixelPerfectWorld's own k upscales the virtual screen back to fill the real container, // however large it actually is — the game/ layer never reads clientWidth itself. const width = Math.round(container.clientWidth / 3); const height = Math.round(container.clientHeight / 3); if (width <= 0 || height <= 0) return; await this.world.mount(container, width, height); this.world.app.canvas.style.touchAction = 'none'; this.gameWorld = new GameWorld(); this.gameWorld.setWorldSize(width, height); this.world.content.addChild(this.gameWorld.container); store.initialize(width, height); store.start(); container.addEventListener('pointerdown', this.handlePointerDown); container.addEventListener('pointermove', this.handlePointerMove); container.addEventListener('pointerup', this.handlePointerMove); container.addEventListener('pointercancel', this.handlePointerCancel); window.addEventListener('blur', store.release); window.addEventListener('beforeunload', store.save); this.world.app.ticker.add(this.tick); } /** Tears down the canvas and every listener — mirrors GameMap.tsx's unmount cleanup. */ destroy(): void { this.mql.removeEventListener('change', this.handleOrientationChange); this.world.app.ticker.remove(this.tick); if (this.container) { this.container.removeEventListener('pointerdown', this.handlePointerDown); this.container.removeEventListener('pointermove', this.handlePointerMove); this.container.removeEventListener('pointerup', this.handlePointerMove); this.container.removeEventListener('pointercancel', this.handlePointerCancel); } if (this.store) { this.store.stop(); window.removeEventListener('blur', this.store.release); window.removeEventListener('beforeunload', this.store.save); } this.world.destroy(); this.gameWorld = null; this.store = null; this.container = null; } private readonly handleOrientationChange = (e: MediaQueryListEvent) => { this.isRotated = e.matches; }; private readonly tick = () => { if (!this.gameWorld || !this.store) return; this.gameWorld.update(this.store, this.world.app.ticker.deltaMS / 1000); // Bake this frame's world at its fixed logical resolution — see PixelPerfectWorld's doc // comment for why this, rather than drawing world content directly to the screen, is what // keeps it shimmer-free and free of the uneven-pixel-size artifacts a fractional device pixel // ratio would otherwise cause. this.world.render(); }; /** Converts a pointer event's physical screen position into the map's own logical (world) (x, * y) — first undoing the portrait-rotation transform (see ROTATE_QUERY), then the world-sprite's * own scale/letterbox offset (see PixelPerfectWorld.screenToWorld). */ private toLocalPoint(clientX: number, clientY: number, rect: DOMRect): { x: number; y: number } { const physicalX = clientX - rect.left; const physicalY = clientY - rect.top; const [screenX, screenY] = this.isRotated ? [physicalY, rect.width - physicalX] : [physicalX, physicalY]; return this.world.screenToWorld(screenX, screenY); } /** Finds the node or rabbit (if any) under the given logical point, by distance to its center — * the canvas-rendering equivalent of GameMap.tsx's elementFromPoint hit-test. Among overlapping * candidates, picks whichever has the largest y (drawn frontmost — see the zIndex depth-sort in * the entity views). */ private targetAt(x: number, y: number): Target | null { const store = this.store!; let best: (Target & { y: number }) | null = null; for (const node of store.nodes) { if (Math.hypot(node.x - x, node.y - y) <= NODE_CLICK_RADIUS[node.kind] && (!best || node.y > best.y)) best = { kind: 'node', id: node.id, y: node.y }; } const radius = rabbitClickRadius(store); for (const rabbit of store.rabbits) { if (Math.hypot(rabbit.x - x, rabbit.y - y) <= radius && (!best || rabbit.y > best.y)) best = { kind: 'rabbit', id: rabbit.id, y: rabbit.y }; } return best; } /** The character's action is purely a function of the current pointer state — shared by * pointerdown/move/up so panning the cursor across a node mid-drag starts/stops chopping it, and * releasing the button stops it. Mirrors GameMap.tsx's followPointer. */ private readonly handlePointerMove = (e: PointerEvent) => { const store = this.store; if (!store) return; if ((e.buttons & 1) === 0) { store.release(); return; } const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const { x, y } = this.toLocalPoint(e.clientX, e.clientY, rect); const target = this.targetAt(x, y); if (target) { if (target.kind === 'node') store.holdNode(target.id); else store.holdRabbit(target.id); return; } // A rabbit can jump out from under an otherwise-still cursor on its own — keep chasing it // until the player points at something else or releases the button (see GameStore). if (store.heldRabbitId) return; store.moveTo(x, y); }; private readonly handlePointerDown = (e: PointerEvent) => { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); this.handlePointerMove(e); }; private readonly handlePointerCancel = () => { this.store?.release(); }; }