pixel-perfect rendering, all sprites have same 1:1 scale
This commit is contained in:
+36
-36
@@ -1,6 +1,6 @@
|
||||
import { Application } from 'pixi.js';
|
||||
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';
|
||||
|
||||
@@ -14,46 +14,41 @@ 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). */
|
||||
/** 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 app = new Application();
|
||||
private world: GameWorld | null = null;
|
||||
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 it to the container's current (already-logical,
|
||||
* rotation-unaffected) box, and starts the game. Mirrors GameMap.tsx's mount effect. */
|
||||
/** 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);
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
// 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.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);
|
||||
await this.world.mount(container, width, height);
|
||||
this.world.app.canvas.style.touchAction = 'none';
|
||||
|
||||
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));
|
||||
this.gameWorld = new GameWorld();
|
||||
this.gameWorld.setWorldSize(width, height);
|
||||
this.world.content.addChild(this.gameWorld.container);
|
||||
|
||||
store.initialize(width, height);
|
||||
store.start();
|
||||
@@ -65,13 +60,13 @@ export class GameCanvas {
|
||||
window.addEventListener('blur', store.release);
|
||||
window.addEventListener('beforeunload', store.save);
|
||||
|
||||
this.app.ticker.add(this.tick);
|
||||
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.app.ticker.remove(this.tick);
|
||||
this.world.app.ticker.remove(this.tick);
|
||||
|
||||
if (this.container) {
|
||||
this.container.removeEventListener('pointerdown', this.handlePointerDown);
|
||||
@@ -85,8 +80,8 @@ export class GameCanvas {
|
||||
window.removeEventListener('beforeunload', this.store.save);
|
||||
}
|
||||
|
||||
this.app.destroy(true, { children: true, texture: false });
|
||||
this.world = null;
|
||||
this.world.destroy();
|
||||
this.gameWorld = null;
|
||||
this.store = null;
|
||||
this.container = null;
|
||||
}
|
||||
@@ -96,19 +91,24 @@ export class GameCanvas {
|
||||
};
|
||||
|
||||
private readonly tick = () => {
|
||||
if (!this.world || !this.store)
|
||||
if (!this.gameWorld || !this.store)
|
||||
return;
|
||||
this.world.update(this.store, this.app.ticker.deltaMS / 1000);
|
||||
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 (x, y) — see
|
||||
* ROTATE_QUERY above. */
|
||||
/** 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;
|
||||
if (this.isRotated)
|
||||
return { x: physicalY, y: rect.width - physicalX };
|
||||
return { x: physicalX, y: physicalY };
|
||||
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 —
|
||||
|
||||
Reference in New Issue
Block a user