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
+3 -3
View File
@@ -25,15 +25,15 @@ import { ResourceNode } from '../src/game/entities/ResourceNode';
import { GameStore } from '../src/game/GameStore'; import { GameStore } from '../src/game/GameStore';
import { costForNextLevel, UPGRADE_LIST, type UpgradeId } from '../src/game/upgrades'; import { costForNextLevel, UPGRADE_LIST, type UpgradeId } from '../src/game/upgrades';
const MAP_WIDTH = 1200; const MAP_WIDTH = 400;
const MAP_HEIGHT = 800; const MAP_HEIGHT = 260;
/** How many simulated seconds pass per tick — fixed, independent of how fast ticks actually run. */ /** How many simulated seconds pass per tick — fixed, independent of how fast ticks actually run. */
const TICK_SECONDS = Number(process.argv[2] ?? 0.1); const TICK_SECONDS = Number(process.argv[2] ?? 0.1);
/** Ticks per event-loop turn between yields — batching amortizes the yield's overhead while /** Ticks per event-loop turn between yields — batching amortizes the yield's overhead while
* still keeping Ctrl+C/output responsive within about one batch's worth of latency. */ * still keeping Ctrl+C/output responsive within about one batch's worth of latency. */
const TICKS_PER_BATCH = 1000; const TICKS_PER_BATCH = 1000;
/** How far past the nearest rabbit to aim while fleeing it. */ /** How far past the nearest rabbit to aim while fleeing it. */
const FLEE_DISTANCE = 400; const FLEE_DISTANCE = 133;
// Logs every analytics call to the console (see ConsoleAnalyticsTransport) instead of sending it // Logs every analytics call to the console (see ConsoleAnalyticsTransport) instead of sending it
// anywhere — this is what actually lets `npm run play` be used to verify analytics behavior. // anywhere — this is what actually lets `npm run play` be used to verify analytics behavior.
+6
View File
@@ -85,6 +85,12 @@
width: 260px; width: 260px;
} }
/* Temporary — see MainMenu.ts/DebugCharacterPreview.ts. */
.main-menu__debug-preview {
width: 128px;
height: 128px;
}
.menu-btn { .menu-btn {
padding: 16px; padding: 16px;
background: rgba(0, 0, 0, 0.25); background: rgba(0, 0, 0, 0.25);
+15 -1
View File
@@ -1,5 +1,6 @@
import type { Analytics } from '../analytics'; import type { Analytics } from '../analytics';
import { hasSave } from '../game/persistence'; import { hasSave } from '../game/persistence';
import { mountDebugCharacterPreview } from '../pixi/DebugCharacterPreview';
/** Builds the main menu screen — the DOM-side replacement for MainMenu.tsx. Fires analytics.menu() /** Builds the main menu screen — the DOM-side replacement for MainMenu.tsx. Fires analytics.menu()
* immediately (once per call), matching the original "once per mount" behavior since a fresh * immediately (once per call), matching the original "once per mount" behavior since a fresh
@@ -38,10 +39,23 @@ export function createMainMenu(analytics: Analytics, onNewGame: () => void, onCo
buttons.append(newGameBtn, continueBtn); buttons.append(newGameBtn, continueBtn);
// Temporary: a pixel-grid sanity check, rendered through the exact same pipeline as the game
// world — see DebugCharacterPreview.ts's doc comment. Remove this block (and the container div)
// once no longer needed.
const debugPreview = document.createElement('div');
debugPreview.className = 'main-menu__debug-preview';
// The preview's canvas sizes itself from this div's clientWidth/Height once actually laid out —
// 0 on a still-detached element — so mounting has to wait until after `root` is attached to the
// document (see App.ts, which inserts the element returned here synchronously right after this
// function returns; requestAnimationFrame's callback always runs after that).
requestAnimationFrame(() => {
void mountDebugCharacterPreview(debugPreview);
});
const version = document.createElement('div'); const version = document.createElement('div');
version.className = 'main-menu__version'; version.className = 'main-menu__version';
version.textContent = `v${__APP_VERSION__}`; version.textContent = `v${__APP_VERSION__}`;
root.append(title, buttons, version); root.append(title, buttons, debugPreview, version);
return root; return root;
} }
+7 -7
View File
@@ -13,31 +13,31 @@ export const TWIGS_PER_TREE = 2;
export const TWIGS_PER_BUSH = 5; export const TWIGS_PER_BUSH = 5;
/** How close the character must get to a node's center to start chopping it. */ /** How close the character must get to a node's center to start chopping it. */
export const INTERACTION_RADIUS = 56; export const INTERACTION_RADIUS = 19;
/** Radius of the circle around a node that ejected resources can land in. */ /** Radius of the circle around a node that ejected resources can land in. */
export const DROP_EJECT_RADIUS = 70; export const DROP_EJECT_RADIUS = 23;
/** Seconds for a resource's ballistic flight from the node to its landing spot. */ /** Seconds for a resource's ballistic flight from the node to its landing spot. */
export const DROP_FALL_DURATION = 0.5; export const DROP_FALL_DURATION = 0.5;
/** Peak height (px) of a resource's arc while falling from the node. */ /** Peak height (px) of a resource's arc while falling from the node. */
export const DROP_FALL_ARC_HEIGHT = 40; export const DROP_FALL_ARC_HEIGHT = 13;
/** Seconds for a landed resource's flight into the character's inventory. */ /** Seconds for a landed resource's flight into the character's inventory. */
export const DROP_COLLECT_DURATION = 0.25; export const DROP_COLLECT_DURATION = 0.25;
/** Peak height (px) of a resource's little hop while flying into the inventory. */ /** Peak height (px) of a resource's little hop while flying into the inventory. */
export const DROP_COLLECT_ARC_HEIGHT = 16; export const DROP_COLLECT_ARC_HEIGHT = 5;
/** Nodes (trees/bushes) won't spawn closer than this to the character's starting position. */ /** Nodes (trees/bushes) won't spawn closer than this to the character's starting position. */
export const ENTITY_MIN_DISTANCE_FROM_CENTER = 140; export const ENTITY_MIN_DISTANCE_FROM_CENTER = 47;
/** Nodes won't spawn closer than this to each other, regardless of kind. */ /** Nodes won't spawn closer than this to each other, regardless of kind. */
export const ENTITY_MIN_DISTANCE_BETWEEN = 100; export const ENTITY_MIN_DISTANCE_BETWEEN = 33;
/** Nodes won't spawn within this many px of the map edge. */ /** Nodes won't spawn within this many px of the map edge. */
export const ENTITY_EDGE_MARGIN = 70; export const ENTITY_EDGE_MARGIN = 23;
/** How long the hit-shake animation plays once a hit actually lands (character or rabbit) — /** How long the hit-shake animation plays once a hit actually lands (character or rabbit) —
* a brief flash at the moment of impact, not for however long the attack itself takes. */ * a brief flash at the moment of impact, not for however long the attack itself takes. */
+5 -5
View File
@@ -10,20 +10,20 @@ export const RABBIT_ACTION_DURATION = 0.5;
export const RABBIT_PAUSE_DURATION = 0.5; export const RABBIT_PAUSE_DURATION = 0.5;
/** Max distance covered by a single jump. */ /** Max distance covered by a single jump. */
export const RABBIT_JUMP_DISTANCE = 80; export const RABBIT_JUMP_DISTANCE = 27;
/** Peak height (px) of a jump's parabolic arc — same idea as a resource drop's arc when it flies /** Peak height (px) of a jump's parabolic arc — same idea as a resource drop's arc when it flies
* off a chopped tree. */ * off a chopped tree. */
export const RABBIT_JUMP_ARC_HEIGHT = 30; export const RABBIT_JUMP_ARC_HEIGHT = 10;
/** By day, a rabbit won't choose a jump target closer than this to the character. */ /** By day, a rabbit won't choose a jump target closer than this to the character. */
export const RABBIT_MIN_DISTANCE_FROM_CHARACTER = 130; export const RABBIT_MIN_DISTANCE_FROM_CHARACTER = 43;
/** How close the character must be, by night, for a rabbit to strike instead of jumping toward them. */ /** How close the character must be, by night, for a rabbit to strike instead of jumping toward them. */
export const RABBIT_STRIKE_RANGE = 28; export const RABBIT_STRIKE_RANGE = 9;
/** How close the character must get to hit a rabbit. */ /** How close the character must get to hit a rabbit. */
export const RABBIT_HIT_RADIUS = 40; export const RABBIT_HIT_RADIUS = 13;
/** A rabbit's max HP (fixed — unlike the character's, there's no upgrade for it). */ /** A rabbit's max HP (fixed — unlike the character's, there's no upgrade for it). */
export const RABBIT_MAX_HP = 3; export const RABBIT_MAX_HP = 3;
+2 -2
View File
@@ -27,7 +27,7 @@ export const UPGRADE_LIST: UpgradeDef[] = [
name: 'Скорость движения', name: 'Скорость движения',
baseCost: 5, baseCost: 5,
costGrowth: 1.35, costGrowth: 1.35,
effectAtLevel: (level) => 160 + (level - 1) * 20, effectAtLevel: (level) => 50 + (level - 1) * 6,
formatEffect: (v) => `${Math.round(v)} px/с`, formatEffect: (v) => `${Math.round(v)} px/с`,
}, },
{ {
@@ -67,7 +67,7 @@ export const UPGRADE_LIST: UpgradeDef[] = [
name: 'Радиус подбора', name: 'Радиус подбора',
baseCost: 5, baseCost: 5,
costGrowth: 1.3, costGrowth: 1.3,
effectAtLevel: (level) => 46 + (level - 1) * 6, effectAtLevel: (level) => 15 + (level - 1) * 2,
formatEffect: (v) => `${Math.round(v)} px`, formatEffect: (v) => `${Math.round(v)} px`,
}, },
{ {
+33
View File
@@ -0,0 +1,33 @@
import { Sprite, type ApplicationOptions } from 'pixi.js';
import { CHARACTER_SPRITE } from '../game/sprites';
import { PixelPerfectWorld } from './PixelPerfectWorld';
import { getSpriteTexture } from './textures';
// Must comfortably fit the character's own native rendered box (16x22 — its grid's own size,
// see textures.ts) or the RenderTexture bake clips it at the world's own edge, before any of the
// world-to-screen k-scaling even happens.
const WORLD_SIZE = 32;
/** Draws the character sprite once, at a fixed size, through the exact same pipeline the real
* game world uses (see PixelPerfectWorld/GameCanvas) — a standalone visual sanity check for
* "are pixels actually square and uniform on this screen," reachable from the main menu without
* starting a game. Wired into MainMenu.ts as a temporary debugging aid; safe to remove (along with
* its call site there) once no longer needed. `appOptions` is a debugging escape hatch (e.g.
* `{ preserveDrawingBuffer: true }` to read the canvas back with getImageData) — MainMenu.ts's
* call site doesn't pass any. */
export async function mountDebugCharacterPreview(
container: HTMLElement,
appOptions?: Partial<ApplicationOptions>,
): Promise<{ destroy: () => void; world: PixelPerfectWorld }> {
const world = new PixelPerfectWorld();
await world.mount(container, WORLD_SIZE, WORLD_SIZE, appOptions);
const info = getSpriteTexture(CHARACTER_SPRITE);
const sprite = new Sprite(info.texture);
sprite.anchor.set(0.5);
sprite.position.set(WORLD_SIZE / 2, WORLD_SIZE / 2);
world.content.addChild(sprite);
world.render();
return { destroy: () => world.destroy(), world };
}
+36 -36
View File
@@ -1,6 +1,6 @@
import { Application } from 'pixi.js';
import type { GameStore } from '../game/GameStore'; import type { GameStore } from '../game/GameStore';
import { GameWorld } from './GameWorld'; import { GameWorld } from './GameWorld';
import { PixelPerfectWorld } from './PixelPerfectWorld';
import { NODE_CLICK_RADIUS } from './entities/NodesView'; import { NODE_CLICK_RADIUS } from './entities/NodesView';
import { rabbitClickRadius } from './entities/RabbitsView'; 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 }; 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 — /** Owns the pixel-perfect canvas for the game world (see GameWorld/PixelPerfectWorld) and all
* the canvas-rendering replacement for GameMap.tsx's map div. The HUD around it stays plain DOM * pointer input for it — the canvas-rendering replacement for GameMap.tsx's map div. The HUD
* (see src/app). */ * around it stays plain DOM (see src/app). */
export class GameCanvas { export class GameCanvas {
private readonly app = new Application(); private readonly world = new PixelPerfectWorld();
private world: GameWorld | null = null; private gameWorld: GameWorld | null = null;
private store: GameStore | null = null; private store: GameStore | null = null;
private container: HTMLElement | null = null; private container: HTMLElement | null = null;
private isRotated = false; private isRotated = false;
private readonly mql = window.matchMedia(ROTATE_QUERY); private readonly mql = window.matchMedia(ROTATE_QUERY);
/** Mounts the canvas into `container`, sizes it to the container's current (already-logical, /** Mounts the canvas into `container`, sizes the world to the container's current (already-
* rotation-unaffected) box, and starts the game. Mirrors GameMap.tsx's mount effect. */ * logical, rotation-unaffected) box, and starts the game. Mirrors GameMap.tsx's mount effect. */
async mount(container: HTMLElement, store: GameStore): Promise<void> { async mount(container: HTMLElement, store: GameStore): Promise<void> {
this.container = container; this.container = container;
this.store = store; this.store = store;
this.isRotated = this.mql.matches; this.isRotated = this.mql.matches;
this.mql.addEventListener('change', this.handleOrientationChange); this.mql.addEventListener('change', this.handleOrientationChange);
const width = container.clientWidth; // The game simulates and draws a virtual screen smaller than the container's real pixels —
const height = container.clientHeight; // 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) if (width <= 0 || height <= 0)
return; return;
await this.app.init({ await this.world.mount(container, width, height);
width, this.world.app.canvas.style.touchAction = 'none';
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.gameWorld = new GameWorld();
this.world.resize(width, height); this.gameWorld.setWorldSize(width, height);
this.app.renderer.on('resize', (w: number, h: number) => this.world?.resize(w, h)); this.world.content.addChild(this.gameWorld.container);
store.initialize(width, height); store.initialize(width, height);
store.start(); store.start();
@@ -65,13 +60,13 @@ export class GameCanvas {
window.addEventListener('blur', store.release); window.addEventListener('blur', store.release);
window.addEventListener('beforeunload', store.save); 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. */ /** Tears down the canvas and every listener — mirrors GameMap.tsx's unmount cleanup. */
destroy(): void { destroy(): void {
this.mql.removeEventListener('change', this.handleOrientationChange); this.mql.removeEventListener('change', this.handleOrientationChange);
this.app.ticker.remove(this.tick); this.world.app.ticker.remove(this.tick);
if (this.container) { if (this.container) {
this.container.removeEventListener('pointerdown', this.handlePointerDown); this.container.removeEventListener('pointerdown', this.handlePointerDown);
@@ -85,8 +80,8 @@ export class GameCanvas {
window.removeEventListener('beforeunload', this.store.save); window.removeEventListener('beforeunload', this.store.save);
} }
this.app.destroy(true, { children: true, texture: false }); this.world.destroy();
this.world = null; this.gameWorld = null;
this.store = null; this.store = null;
this.container = null; this.container = null;
} }
@@ -96,19 +91,24 @@ export class GameCanvas {
}; };
private readonly tick = () => { private readonly tick = () => {
if (!this.world || !this.store) if (!this.gameWorld || !this.store)
return; 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 /** Converts a pointer event's physical screen position into the map's own logical (world) (x,
* ROTATE_QUERY above. */ * 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 } { private toLocalPoint(clientX: number, clientY: number, rect: DOMRect): { x: number; y: number } {
const physicalX = clientX - rect.left; const physicalX = clientX - rect.left;
const physicalY = clientY - rect.top; const physicalY = clientY - rect.top;
if (this.isRotated) const [screenX, screenY] = this.isRotated ? [physicalY, rect.width - physicalX] : [physicalX, physicalY];
return { x: physicalY, y: rect.width - physicalX }; return this.world.screenToWorld(screenX, screenY);
return { x: physicalX, y: physicalY };
} }
/** Finds the node or rabbit (if any) under the given logical point, by distance to its center — /** Finds the node or rabbit (if any) under the given logical point, by distance to its center —
+19 -11
View File
@@ -1,4 +1,4 @@
import { Container, Sprite, Texture, type Application } from 'pixi.js'; import { Container, Sprite, Texture } from 'pixi.js';
import { overlayColorComponentsForHour } from '../game/daynight'; import { overlayColorComponentsForHour } from '../game/daynight';
import type { GameStore } from '../game/GameStore'; import type { GameStore } from '../game/GameStore';
import { CharacterView } from './entities/CharacterView'; import { CharacterView } from './entities/CharacterView';
@@ -7,9 +7,16 @@ import { NodesView } from './entities/NodesView';
import { RabbitsView } from './entities/RabbitsView'; import { RabbitsView } from './entities/RabbitsView';
/** Owns every visual layer of the game world (shadows/sprites, hp bars, day-night tint) and /** Owns every visual layer of the game world (shadows/sprites, hp bars, day-night tint) and
* advances them each Pixi tick from the current GameStore state. The HUD (topbar/sidebar/menu/ * advances them each Pixi tick from the current GameStore state. Deliberately NOT attached to
* death screen) is plain DOM, not part of this — see src/app. */ * `app.stage` itself — GameCanvas renders `container` into a fixed-resolution RenderTexture (one
* texel per world/logical pixel) and displays that as a single integer-scaled sprite, so no world
* content is ever composited at a fractional/subpixel screen position. The HUD (topbar/sidebar/
* menu/death screen) is plain DOM, not part of this — see src/app. */
export class GameWorld { export class GameWorld {
/** Everything drawn for one frame of the world — render this into a RenderTexture, don't add it
* to app.stage directly (see GameCanvas). */
readonly container = new Container();
private readonly entities = new Container(); private readonly entities = new Container();
private readonly healthBars = new Container(); private readonly healthBars = new Container();
private readonly overlay = new Sprite(Texture.WHITE); private readonly overlay = new Sprite(Texture.WHITE);
@@ -19,21 +26,22 @@ export class GameWorld {
private readonly characterView: CharacterView; private readonly characterView: CharacterView;
private elapsedSeconds = 0; private elapsedSeconds = 0;
constructor(app: Application) { constructor() {
this.entities.sortableChildren = true; this.entities.sortableChildren = true;
// Overlay sits above health bars (9999 vs 500 in the original CSS z-indexes), tinting // Overlay sits above health bars (9999 vs 500 in the original CSS z-indexes), tinting
// everything underneath, including them. // everything underneath, including them.
app.stage.addChild(this.entities, this.healthBars, this.overlay); this.container.addChild(this.entities, this.healthBars, this.overlay);
this.overlay.eventMode = 'none'; this.overlay.eventMode = 'none';
this.nodesView = new NodesView(app, this.entities); this.nodesView = new NodesView(this.entities);
this.dropsView = new DropsView(app, this.entities); this.dropsView = new DropsView(this.entities);
this.rabbitsView = new RabbitsView(app, this.entities, this.healthBars); this.rabbitsView = new RabbitsView(this.entities, this.healthBars);
this.characterView = new CharacterView(app, this.entities, this.healthBars); this.characterView = new CharacterView(this.entities, this.healthBars);
} }
/** Resizes the full-screen day/night tint to cover the current map size. */ /** Sizes the full-screen day/night tint to cover the map — called once, with the fixed
resize(width: number, height: number): void { * world/logical size (see GameCanvas), not on every canvas resize. */
setWorldSize(width: number, height: number): void {
this.overlay.width = width; this.overlay.width = width;
this.overlay.height = height; this.overlay.height = height;
} }
+107
View File
@@ -0,0 +1,107 @@
import { Application, Container, RenderTexture, Sprite, type ApplicationOptions } from 'pixi.js';
/** Renders `content` (any Pixi Container) into a canvas with zero fractional-pixel scaling
* anywhere Pixi/WebGL is involved — the shared mechanics behind GameCanvas (the full game world)
* and DebugCharacterPreview (the menu's pixel-grid sanity check), so both use exactly the same
* math.
*
* `content` is rendered once into a RenderTexture sized exactly `worldWidth x worldHeight` — one
* texel per world/logical pixel — then displayed as a single sprite scaled up by a whole number
* `k`. Both the texture and the outer Application deliberately use `resolution: 1` (i.e.
* independent of devicePixelRatio) so `sprite.scale` is always a clean integer: an earlier version
* set `resolution: devicePixelRatio` and solved for a `sprite.scale` of `k / resolution` so the
* *physical* (backing-store) scale would come out to the integer `k` — this looked correct
* (`(k / resolution) * resolution === k` on paper) but empirically was not: WebGL's nearest-
* neighbor sampling of a sprite whose own *logical* scale is fractional does not reliably resolve
* to uniform per-texel pixel runs, resolution or no — verified by reading the rendered canvas back
* pixel-by-pixel (`preserveDrawingBuffer`) and finding uneven run-lengths even when the fractional
* scale's product with resolution was exactly an integer. Keeping `sprite.scale` a literal integer
* avoids asking WebGL to do that fractional-then-compensated sampling at all.
*
* The one fractional-scale step this *doesn't* eliminate is the browser's own final compositing of
* the canvas's backing store onto the physical screen at a non-integer devicePixelRatio (e.g.
* Windows' 125%/150% display scaling) — `image-rendering: pixelated` keeps that nearest-neighbor
* rather than smoothed, but nearest-neighbor upscaling by a non-integer ratio is *inherently*
* uneven (some source pixels become 1 output pixel, others 2, in whatever pattern the browser's
* compositor picks) — no web technology can avoid that for a genuinely fractional display scale.
* The goal here is only to make sure *that* unavoidable, display-driven unevenness is the sole
* source of any pixel irregularity, rather than compounding it with WebGL sampling of its own. */
export class PixelPerfectWorld {
readonly app = new Application();
/** Draw whatever should appear in the world into this container — it is never added to
* `app.stage` itself, only rendered into the fixed-resolution texture (see render()). */
readonly content = new Container();
private texture: RenderTexture | null = null;
private sprite: Sprite | null = null;
private worldWidth = 0;
private worldHeight = 0;
/** Creates the Application, sized/resized to fill `container`, and the fixed `worldWidth x
* worldHeight` texture `content` renders into. */
async mount(container: HTMLElement, worldWidth: number, worldHeight: number, appOptions?: Partial<ApplicationOptions>): Promise<void> {
this.worldWidth = worldWidth;
this.worldHeight = worldHeight;
await this.app.init({
resizeTo: container,
backgroundAlpha: 0,
antialias: false,
roundPixels: true,
// Deliberately 1, not devicePixelRatio — see the class doc comment.
resolution: 1,
autoDensity: true,
...appOptions,
});
this.app.canvas.style.display = 'block';
this.app.canvas.style.imageRendering = 'pixelated';
container.appendChild(this.app.canvas);
this.texture = RenderTexture.create({ width: worldWidth, height: worldHeight, scaleMode: 'nearest', antialias: false, resolution: 1 });
this.sprite = new Sprite(this.texture);
this.app.stage.addChild(this.sprite);
this.layout();
this.app.renderer.on('resize', () => this.layout());
}
/** Bakes the current `content` into the world texture. Call after mutating `content` (every
* frame for an animated world, once for a static debug preview). */
render(): void {
if (!this.texture)
return;
this.app.renderer.render({ container: this.content, target: this.texture, clearColor: 'transparent' });
}
/** Converts a point in the canvas's own logical (CSS-pixel) space — e.g. a pointer event's
* position relative to the canvas's bounding rect — into world/logical (x, y). */
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
if (!this.sprite)
return { x: screenX, y: screenY };
return { x: (screenX - this.sprite.x) / this.sprite.scale.x, y: (screenY - this.sprite.y) / this.sprite.scale.y };
}
destroy(): void {
this.texture?.destroy(true);
this.app.destroy(true, { children: true, texture: false });
this.texture = null;
this.sprite = null;
}
/** Recomputes the world-sprite's integer scale `k` and its (rounded, letterboxing) position
* whenever the canvas's own on-screen size changes — the fixed-size world texture itself is
* never touched. `k` is floored and never smaller than 1, so a canvas larger than the world
* upscales it in clean whole-pixel steps, and a canvas smaller than the world just lets it
* overflow (expected to be clipped by an `overflow: hidden` ancestor). */
private layout(): void {
if (!this.sprite || this.worldWidth <= 0 || this.worldHeight <= 0)
return;
const screen = this.app.screen;
const k = Math.max(1, Math.floor(Math.min(screen.width / this.worldWidth, screen.height / this.worldHeight)));
this.sprite.scale.set(k);
this.sprite.position.set(
Math.round((screen.width - this.worldWidth * k) / 2),
Math.round((screen.height - this.worldHeight * k) / 2),
);
}
}
+9 -12
View File
@@ -1,18 +1,16 @@
import { Container, Sprite, type Application } from 'pixi.js'; import { Container, Sprite } from 'pixi.js';
import type { GameStore } from '../../game/GameStore'; import type { GameStore } from '../../game/GameStore';
import { CHARACTER_SPRITE } from '../../game/sprites'; import { CHARACTER_SPRITE } from '../../game/sprites';
import { chopShakeOffset, walkBobOffsetY } from '../animations'; import { chopShakeOffset, walkBobOffsetY } from '../animations';
import { createHealthBar, setHealthBarPosition, updateHealthBar } from '../healthBar'; import { createHealthBar, setHealthBarPosition, updateHealthBar } from '../healthBar';
import { createShadow } from '../shadow'; import { createShadow } from '../shadow';
import { applySpriteSize, getSpriteTexture } from '../textures'; import { getSpriteTexture } from '../textures';
const CHARACTER_SIZE = 48; const SHADOW_OFFSET_Y = 11;
// .entity.character-shadow: width 48 height 24, margin-top 32 (offset from character.x/y). const SHADOW_RADIUS_X = 8;
const SHADOW_OFFSET_Y = 32; const SHADOW_RADIUS_Y = 4;
const SHADOW_RADIUS_X = 24; /** How far above the character's own position the hp bar floats. */
const SHADOW_RADIUS_Y = 12; const HEALTH_BAR_OFFSET_Y = 10;
// .health-bar sits `CHARACTER_SIZE * 0.5 + 6` px above the character's own position.
const HEALTH_BAR_OFFSET_Y = CHARACTER_SIZE * 0.5 + 6;
/** Renders the player character: shadow, sprite (with walk-bob/hit-shake), and an hp bar shown /** Renders the player character: shadow, sprite (with walk-bob/hit-shake), and an hp bar shown
* only while damaged — mirrors CharacterEntity.tsx. */ * only while damaged — mirrors CharacterEntity.tsx. */
@@ -21,11 +19,10 @@ export class CharacterView {
private readonly sprite: Sprite; private readonly sprite: Sprite;
private readonly healthBar = createHealthBar(); private readonly healthBar = createHealthBar();
constructor(app: Application, entities: Container, healthBars: Container) { constructor(entities: Container, healthBars: Container) {
const info = getSpriteTexture(app, CHARACTER_SPRITE); const info = getSpriteTexture(CHARACTER_SPRITE);
this.sprite = new Sprite(info.texture); this.sprite = new Sprite(info.texture);
this.sprite.anchor.set(0.5); this.sprite.anchor.set(0.5);
applySpriteSize(this.sprite, info, CHARACTER_SIZE);
entities.addChild(this.shadow, this.sprite); entities.addChild(this.shadow, this.sprite);
healthBars.addChild(this.healthBar); healthBars.addChild(this.healthBar);
+4 -8
View File
@@ -1,12 +1,11 @@
import { Container, Sprite, type Application } from 'pixi.js'; import { Container, Sprite } from 'pixi.js';
import type { GameStore } from '../../game/GameStore'; import type { GameStore } from '../../game/GameStore';
import type { ResourceType } from '../../game/types'; import type { ResourceType } from '../../game/types';
import { LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE, type PixelGrid } from '../../game/sprites'; import { LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE, type PixelGrid } from '../../game/sprites';
import { createShadow } from '../shadow'; import { createShadow } from '../shadow';
import { applySpriteSize, getSpriteTexture } from '../textures'; import { getSpriteTexture } from '../textures';
const DROP_SPRITES: Record<ResourceType, PixelGrid> = { log: LOG_SPRITE, twig: TWIG_SPRITE, meat: MEAT_SPRITE }; const DROP_SPRITES: Record<ResourceType, PixelGrid> = { log: LOG_SPRITE, twig: TWIG_SPRITE, meat: MEAT_SPRITE };
const DROP_SIZES: Record<ResourceType, number> = { log: 20, twig: 18, meat: 18 };
// .entity.log-shadow: margin-top 8. .entity.twig-shadow / .entity.meat-shadow: margin-top 7. // .entity.log-shadow: margin-top 8. .entity.twig-shadow / .entity.meat-shadow: margin-top 7.
const SHADOW_OFFSET_Y: Record<ResourceType, number> = { log: 8, twig: 7, meat: 7 }; const SHADOW_OFFSET_Y: Record<ResourceType, number> = { log: 8, twig: 7, meat: 7 };
const SHADOW_RADII: Record<ResourceType, { x: number; y: number }> = { const SHADOW_RADII: Record<ResourceType, { x: number; y: number }> = {
@@ -24,11 +23,9 @@ interface DropVisual {
* (mirrors ResourceDropEntity.tsx). Not interactive — drops have no click handling. */ * (mirrors ResourceDropEntity.tsx). Not interactive — drops have no click handling. */
export class DropsView { export class DropsView {
private readonly visuals = new Map<string, DropVisual>(); private readonly visuals = new Map<string, DropVisual>();
private readonly app: Application;
private readonly entities: Container; private readonly entities: Container;
constructor(app: Application, entities: Container) { constructor(entities: Container) {
this.app = app;
this.entities = entities; this.entities = entities;
} }
@@ -65,10 +62,9 @@ export class DropsView {
const radii = SHADOW_RADII[resource]; const radii = SHADOW_RADII[resource];
const shadow = createShadow(radii.x, radii.y); const shadow = createShadow(radii.x, radii.y);
const info = getSpriteTexture(this.app, DROP_SPRITES[resource]); const info = getSpriteTexture(DROP_SPRITES[resource]);
const sprite = new Sprite(info.texture); const sprite = new Sprite(info.texture);
sprite.anchor.set(0.5); sprite.anchor.set(0.5);
applySpriteSize(sprite, info, DROP_SIZES[resource]);
this.entities.addChild(shadow, sprite); this.entities.addChild(shadow, sprite);
return { shadow, sprite }; return { shadow, sprite };
+10 -16
View File
@@ -1,24 +1,21 @@
import { Container, Sprite, type Application } from 'pixi.js'; import { Container, Sprite } from 'pixi.js';
import type { GameStore } from '../../game/GameStore'; import type { GameStore } from '../../game/GameStore';
import type { NodeKind } from '../../game/types'; import type { NodeKind } from '../../game/types';
import { BUSH_SPRITE, TREE_SPRITE, type PixelGrid } from '../../game/sprites'; import { BUSH_SPRITE, TREE_SPRITE, type PixelGrid } from '../../game/sprites';
import { chopShakeOffset } from '../animations'; import { chopShakeOffset } from '../animations';
import { createShadow } from '../shadow'; import { createShadow } from '../shadow';
import { applySpriteSize, getSpriteTexture } from '../textures'; import { getSpriteTexture } from '../textures';
const NODE_SPRITES: Record<NodeKind, PixelGrid> = { tree: TREE_SPRITE, bush: BUSH_SPRITE }; const NODE_SPRITES: Record<NodeKind, PixelGrid> = { tree: TREE_SPRITE, bush: BUSH_SPRITE };
const NODE_SIZES: Record<NodeKind, number> = { tree: 64, bush: 40 };
// .entity.tree-shadow: width 64 height 32, margin (26, 0, 0, -11) -> center offset (-11, +26).
// .entity.bush-shadow: width 40 height 20, margin (16, 0, 0, -6) -> center offset (-6, +16).
const SHADOW_OFFSET: Record<NodeKind, { x: number; y: number; radiusX: number; radiusY: number }> = { const SHADOW_OFFSET: Record<NodeKind, { x: number; y: number; radiusX: number; radiusY: number }> = {
tree: { x: -11, y: 26, radiusX: 32, radiusY: 16 }, tree: { x: -3, y: 7, radiusX: 8, radiusY: 4 },
bush: { x: -6, y: 16, radiusX: 20, radiusY: 10 }, bush: { x: -2, y: 5, radiusX: 7, radiusY: 3 },
}; };
// .entity.tree/.bush click padding (padding: Npx; margin: -Npx) expands the clickable area beyond // Expands the clickable area beyond the sprite's own box — approximated here as a circle for
// the sprite's own box — approximated here as a circle for hit-testing (see GameCanvas). // hit-testing (see GameCanvas).
export const NODE_CLICK_RADIUS: Record<NodeKind, number> = { export const NODE_CLICK_RADIUS: Record<NodeKind, number> = {
tree: NODE_SIZES.tree / 2 + 12, tree: 11,
bush: NODE_SIZES.bush / 2 + 8, bush: 9,
}; };
interface NodeVisual { interface NodeVisual {
@@ -30,11 +27,9 @@ interface NodeVisual {
* live node) — entities are added/removed as nodes are chopped down or grown in. */ * live node) — entities are added/removed as nodes are chopped down or grown in. */
export class NodesView { export class NodesView {
private readonly visuals = new Map<string, NodeVisual>(); private readonly visuals = new Map<string, NodeVisual>();
private readonly app: Application;
private readonly entities: Container; private readonly entities: Container;
constructor(app: Application, entities: Container) { constructor(entities: Container) {
this.app = app;
this.entities = entities; this.entities = entities;
} }
@@ -79,10 +74,9 @@ export class NodesView {
const offset = SHADOW_OFFSET[kind]; const offset = SHADOW_OFFSET[kind];
const shadow = createShadow(offset.radiusX, offset.radiusY); const shadow = createShadow(offset.radiusX, offset.radiusY);
const info = getSpriteTexture(this.app, NODE_SPRITES[kind]); const info = getSpriteTexture(NODE_SPRITES[kind]);
const sprite = new Sprite(info.texture); const sprite = new Sprite(info.texture);
sprite.anchor.set(0.5); sprite.anchor.set(0.5);
applySpriteSize(sprite, info, NODE_SIZES[kind]);
this.entities.addChild(shadow, sprite); this.entities.addChild(shadow, sprite);
const visual: NodeVisual = { shadow, sprite }; const visual: NodeVisual = { shadow, sprite };
+14 -12
View File
@@ -1,17 +1,21 @@
import { Container, Graphics, Sprite, type Application } from 'pixi.js'; import { Container, Graphics, Sprite } from 'pixi.js';
import type { GameStore } from '../../game/GameStore'; import type { GameStore } from '../../game/GameStore';
import { RABBIT_NIGHT_SPRITE, RABBIT_SPRITE } from '../../game/sprites'; import { RABBIT_NIGHT_SPRITE, RABBIT_SPRITE } from '../../game/sprites';
import { chopShakeOffset } from '../animations'; import { chopShakeOffset } from '../animations';
import { createHealthBar, setHealthBarPosition, updateHealthBar } from '../healthBar'; import { createHealthBar, setHealthBarPosition, updateHealthBar } from '../healthBar';
import { drawShadow } from '../shadow'; import { drawShadow } from '../shadow';
import { applySpriteSize, getSpriteTexture } from '../textures'; import { getSpriteTexture } from '../textures';
const RABBIT_SIZE = 30; /** The rabbit sprite's own native width (its grid's col count) — the "nominal" size shadow/hp-bar/
* click geometry below is expressed relative to; blood moon doubles it via a real sprite.scale
* (see sync()), a deliberate visual size cue rather than a rendering-resolution artifact. */
const RABBIT_SIZE = 14;
const RABBIT_GAP = 3;
/** Click radius (px) for hit-testing a rabbit at its current size — used by GameCanvas. */ /** Click radius (px) for hit-testing a rabbit at its current size — used by GameCanvas. */
export function rabbitClickRadius(store: GameStore): number { export function rabbitClickRadius(store: GameStore): number {
const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE; const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE;
return size / 2 + 6; return size / 2 + RABBIT_GAP;
} }
interface RabbitVisual { interface RabbitVisual {
@@ -30,12 +34,10 @@ interface RabbitVisual {
* night/blood-moon. */ * night/blood-moon. */
export class RabbitsView { export class RabbitsView {
private readonly visuals = new Map<string, RabbitVisual>(); private readonly visuals = new Map<string, RabbitVisual>();
private readonly app: Application;
private readonly entities: Container; private readonly entities: Container;
private readonly healthBars: Container; private readonly healthBars: Container;
constructor(app: Application, entities: Container, healthBars: Container) { constructor(entities: Container, healthBars: Container) {
this.app = app;
this.entities = entities; this.entities = entities;
this.healthBars = healthBars; this.healthBars = healthBars;
} }
@@ -60,12 +62,13 @@ export class RabbitsView {
visual.shadow.position.set(rabbit.x, rabbit.y + size * 0.6); visual.shadow.position.set(rabbit.x, rabbit.y + size * 0.6);
visual.shadow.zIndex = rabbit.y - 1; visual.shadow.zIndex = rabbit.y - 1;
const info = getSpriteTexture(store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE);
if (visual.isNightSprite !== store.isNight) { if (visual.isNightSprite !== store.isNight) {
const info = getSpriteTexture(this.app, store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE);
visual.sprite.texture = info.texture; visual.sprite.texture = info.texture;
visual.isNightSprite = store.isNight; visual.isNightSprite = store.isNight;
} }
applySpriteSize(visual.sprite, getSpriteTexture(this.app, store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE), size); // Only the blood-moon ×2 ever scales this sprite — everything else renders at native 1:1.
visual.sprite.scale.set(store.isBloodMoon ? 2 : 1);
const drawY = rabbit.y - rabbit.z; const drawY = rabbit.y - rabbit.z;
visual.sprite.zIndex = drawY; visual.sprite.zIndex = drawY;
@@ -82,7 +85,7 @@ export class RabbitsView {
visual.healthBar.visible = damaged; visual.healthBar.visible = damaged;
if (damaged) { if (damaged) {
updateHealthBar(visual.healthBar, rabbit.hp / store.rabbitMaxHp); updateHealthBar(visual.healthBar, rabbit.hp / store.rabbitMaxHp);
setHealthBarPosition(visual.healthBar, rabbit.x, drawY - size * 0.5 - 6); setHealthBarPosition(visual.healthBar, rabbit.x, drawY - size * 0.5 - RABBIT_GAP);
} }
} }
@@ -102,10 +105,9 @@ export class RabbitsView {
const shadow = new Graphics(); const shadow = new Graphics();
drawShadow(shadow, RABBIT_SIZE / 2, RABBIT_SIZE / 4); drawShadow(shadow, RABBIT_SIZE / 2, RABBIT_SIZE / 4);
const info = getSpriteTexture(this.app, RABBIT_SPRITE); const info = getSpriteTexture(RABBIT_SPRITE);
const sprite = new Sprite(info.texture); const sprite = new Sprite(info.texture);
sprite.anchor.set(0.5); sprite.anchor.set(0.5);
applySpriteSize(sprite, info, RABBIT_SIZE);
const healthBar = createHealthBar(); const healthBar = createHealthBar();
+18 -4
View File
@@ -3,6 +3,15 @@ import { Graphics } from 'pixi.js';
/** Matches `.health-bar`/`.health-bar__fill` in App.css. */ /** Matches `.health-bar`/`.health-bar__fill` in App.css. */
const WIDTH = 28; const WIDTH = 28;
const HEIGHT = 5; const HEIGHT = 5;
const BORDER_WIDTH = 1;
// The fill sits inside the border's own inner edge, not flush with the bar's outer bounds — Pixi
// centers a stroke on its path by default (half the stroke width paints inward), so a fill drawn
// flush to (0,0)-(WIDTH,HEIGHT) would paint over that inward half on the top/bottom/left edges,
// visibly overlapping the border. The original CSS version didn't have this problem: `.health-
// bar__fill` was a child div sized to the *parent's content box*, which excludes its border by
// definition.
const INNER_WIDTH = WIDTH - BORDER_WIDTH * 2;
const INNER_HEIGHT = HEIGHT - BORDER_WIDTH * 2;
export function createHealthBar(): Graphics { export function createHealthBar(): Graphics {
return new Graphics(); return new Graphics();
@@ -16,13 +25,18 @@ export function updateHealthBar(g: Graphics, ratio: number): void {
g.clear(); g.clear();
g.rect(0, 0, WIDTH, HEIGHT) g.rect(0, 0, WIDTH, HEIGHT)
.fill({ color: 0x000000, alpha: 0.5 }) .fill({ color: 0x000000, alpha: 0.5 })
.stroke({ width: 1, color: 0x1b1611 }); .stroke({ width: BORDER_WIDTH, color: 0x1b1611 });
if (clamped > 0) if (clamped > 0)
g.rect(0, 0, WIDTH * clamped, HEIGHT).fill(0x4caf50); g.rect(BORDER_WIDTH, BORDER_WIDTH, INNER_WIDTH * clamped, INNER_HEIGHT).fill(0x4caf50);
} }
/** Positions a health bar so (x, yAboveSprite) is its bottom-center, same anchor as the original /** Positions a health bar so (x, yAboveSprite) is its bottom-center, same anchor as the original
* CSS transform. */ * CSS transform. Rounded to the nearest whole world-pixel: a vector shape rasterized (without
* antialiasing — see PixelPerfectWorld) at a fractional position can flip a full pixel row in or
* out depending on exactly where the fraction falls, and `yAboveSprite` tracks a jumping rabbit's
* continuously-changing height — left unrounded, the bar's fill visibly "jumps" a pixel in height
* frame to frame for as long as the jump lasts (most noticeable there specifically, since the
* position — and so the sub-pixel fraction — is otherwise constant while idle). */
export function setHealthBarPosition(g: Graphics, x: number, yAboveSprite: number): void { export function setHealthBarPosition(g: Graphics, x: number, yAboveSprite: number): void {
g.position.set(x - WIDTH / 2, yAboveSprite - HEIGHT); g.position.set(Math.round(x - WIDTH / 2), Math.round(yAboveSprite - HEIGHT));
} }
+52 -20
View File
@@ -1,4 +1,4 @@
import { Graphics, type Application, type Sprite, type Texture } from 'pixi.js'; import { BufferImageSource, Texture } from 'pixi.js';
import type { PixelGrid } from '../game/sprites'; import type { PixelGrid } from '../game/sprites';
export interface SpriteTextureInfo { export interface SpriteTextureInfo {
@@ -8,42 +8,74 @@ export interface SpriteTextureInfo {
} }
// PixelGrid constants are module-level singletons in game/sprites.ts, so a WeakMap keyed on the // PixelGrid constants are module-level singletons in game/sprites.ts, so a WeakMap keyed on the
// grid reference caches one baked texture per sprite for the app's whole lifetime. // grid reference caches one baked texture per sprite for the page's whole lifetime. Safe to share
// across every Application/renderer the page ever creates (see the doc comment below) — unlike an
// RenderTexture baked via `renderer.generateTexture()` (a GPU-side render target tied to whichever
// renderer produced it, invalidated when that renderer is destroyed — see GameCanvas.destroy()),
// a texture built from a plain CPU-side buffer has no such binding: each renderer independently
// uploads its own GPU copy from the same buffer on first use.
const cache = new WeakMap<PixelGrid, SpriteTextureInfo>(); const cache = new WeakMap<PixelGrid, SpriteTextureInfo>();
/** Bakes a PixelGrid (a per-cell CSS-color grid, see game/sprites.ts) into a GPU texture once, function parseHexColor(hex: string): [r: number, g: number, b: number] {
* reusing it for every sprite instance of that kind. `scaleMode: 'nearest'` keeps upscaled edges const n = Number.parseInt(hex.slice(1), 16);
* crisp/blocky, matching the original SVG rects' `shapeRendering="crispEdges"`. */ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
export function getSpriteTexture(app: Application, grid: PixelGrid): SpriteTextureInfo { }
/** Bakes a PixelGrid (a per-cell CSS-color grid, see game/sprites.ts) into a texture built
* directly from a raw RGBA buffer — one call, one texel per cell, deterministic `cols x rows`
* dimensions. Every sprite is drawn at this native size (no per-sprite scale.set(...) — a plain
* `new Sprite(info.texture)` renders at literal 1:1, one texel per world-pixel), so a rabbit's
* pixel and a tree's pixel are physically the same size; entity views scale up only for the rare
* deliberate exception (e.g. a blood-moon rabbit's ×2 — see RabbitsView.sync()).
*
* Deliberately NOT `Graphics.rect(x,y,1,1).fill(color)` per cell followed by
* `renderer.generateTexture()`, which has two sharp edges: (1) without an explicit `frame`,
* `generateTexture` crops to the *drawn content's bounding box*, not the full grid — a sprite
* with a fully-transparent edge row/column (common; several of ours have one) bakes a texture
* smaller than `cols x rows`, silently mismatching code that assumes the texture *is* `cols x
* rows` (as sizing code necessarily must, native or not); and (2) `generateTexture`'s
* `resolution` defaults to the *renderer's* resolution, so the same grid bakes to a different
* texel size on a HiDPI display, which — combined with (1) — is exactly the kind of "some pixel
* rows are a different height than others" artifact this class exists to rule out. A raw buffer
* has neither problem: its dimensions are exactly `cols x rows` by construction, and it carries
* no renderer/resolution dependency of its own (`resolution: 1` here just labels the buffer's own
* texel grid — nothing display-related reads it). It also can't pick up the antialiased edge
* bleed `Graphics` fills can have between adjacent same-or-different-colored rects — each texel
* is one flat byte4, no rasterization involved. */
export function getSpriteTexture(grid: PixelGrid): SpriteTextureInfo {
const cached = cache.get(grid); const cached = cache.get(grid);
if (cached) if (cached)
return cached; return cached;
const rows = grid.length; const rows = grid.length;
const cols = grid[0]?.length ?? 0; const cols = grid[0]?.length ?? 0;
const g = new Graphics(); const data = new Uint8Array(cols * rows * 4);
for (let y = 0; y < rows; y++) { for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) { for (let x = 0; x < cols; x++) {
const color = grid[y][x]; const color = grid[y][x];
if (color) if (!color)
g.rect(x, y, 1, 1).fill(color); continue;
const [r, g, b] = parseHexColor(color);
const i = (y * cols + x) * 4;
data[i] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = 255;
} }
} }
const texture = app.renderer.generateTexture({ const texture = new Texture({
target: g, source: new BufferImageSource({
textureSourceOptions: { scaleMode: 'nearest' }, resource: data,
width: cols,
height: rows,
format: 'rgba8unorm',
scaleMode: 'nearest',
resolution: 1,
}),
}); });
g.destroy();
const info: SpriteTextureInfo = { texture, cols, rows }; const info: SpriteTextureInfo = { texture, cols, rows };
cache.set(grid, info); cache.set(grid, info);
return info; return info;
} }
/** Sizes a sprite the same way PixelSprite.tsx did: `size` is the display width, height follows
* the grid's own row/col aspect ratio. */
export function applySpriteSize(sprite: Sprite, info: SpriteTextureInfo, size: number): void {
sprite.width = size;
sprite.height = info.cols > 0 ? size * (info.rows / info.cols) : size;
}