177 lines
7.6 KiB
TypeScript
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();
|
|
};
|
|
}
|