миграция на pixi
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { Application } from 'pixi.js';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { GameWorld } from './GameWorld';
|
||||
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 PixiJS Application for the game world (see GameWorld) 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 app = new Application();
|
||||
private world: 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 it 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);
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
if (width <= 0 || height <= 0)
|
||||
return;
|
||||
|
||||
await this.app.init({
|
||||
width,
|
||||
height,
|
||||
resizeTo: container,
|
||||
backgroundAlpha: 0,
|
||||
antialias: false,
|
||||
autoDensity: true,
|
||||
resolution: window.devicePixelRatio || 1,
|
||||
});
|
||||
this.app.canvas.style.display = 'block';
|
||||
this.app.canvas.style.touchAction = 'none';
|
||||
container.appendChild(this.app.canvas);
|
||||
|
||||
this.world = new GameWorld(this.app);
|
||||
this.world.resize(width, height);
|
||||
this.app.renderer.on('resize', (w: number, h: number) => this.world?.resize(w, h));
|
||||
|
||||
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.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.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.app.destroy(true, { children: true, texture: false });
|
||||
this.world = null;
|
||||
this.store = null;
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
private readonly handleOrientationChange = (e: MediaQueryListEvent) => {
|
||||
this.isRotated = e.matches;
|
||||
};
|
||||
|
||||
private readonly tick = () => {
|
||||
if (!this.world || !this.store)
|
||||
return;
|
||||
this.world.update(this.store, this.app.ticker.deltaMS / 1000);
|
||||
};
|
||||
|
||||
/** Converts a pointer event's physical screen position into the map's own logical (x, y) — see
|
||||
* ROTATE_QUERY above. */
|
||||
private toLocalPoint(clientX: number, clientY: number, rect: DOMRect): { x: number; y: number } {
|
||||
const physicalX = clientX - rect.left;
|
||||
const physicalY = clientY - rect.top;
|
||||
if (this.isRotated)
|
||||
return { x: physicalY, y: rect.width - physicalX };
|
||||
return { x: physicalX, y: physicalY };
|
||||
}
|
||||
|
||||
/** 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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user