pixel-perfect rendering, all sprites have same 1:1 scale

This commit is contained in:
azykov@mail.ru
2026-09-09 19:10:50 +03:00
parent 4e30c585d1
commit 43f6004fb0
16 changed files with 340 additions and 137 deletions
+36 -36
View File
@@ -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 —