миграция на pixi

This commit is contained in:
azykov@mail.ru
2026-09-09 13:45:30 +03:00
parent a3f2f4eeef
commit 4e30c585d1
41 changed files with 1303 additions and 899 deletions
+176
View File
@@ -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();
};
}