Files
game-forest/src/pixi/GameCanvas.ts
T

177 lines
7.6 KiB
TypeScript

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<void> {
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();
};
}