pixel-perfect rendering, all sprites have same 1:1 scale
This commit is contained in:
+3
-3
@@ -25,15 +25,15 @@ import { ResourceNode } from '../src/game/entities/ResourceNode';
|
||||
import { GameStore } from '../src/game/GameStore';
|
||||
import { costForNextLevel, UPGRADE_LIST, type UpgradeId } from '../src/game/upgrades';
|
||||
|
||||
const MAP_WIDTH = 1200;
|
||||
const MAP_HEIGHT = 800;
|
||||
const MAP_WIDTH = 400;
|
||||
const MAP_HEIGHT = 260;
|
||||
/** How many simulated seconds pass per tick — fixed, independent of how fast ticks actually run. */
|
||||
const TICK_SECONDS = Number(process.argv[2] ?? 0.1);
|
||||
/** 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. */
|
||||
const TICKS_PER_BATCH = 1000;
|
||||
/** 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
|
||||
// anywhere — this is what actually lets `npm run play` be used to verify analytics behavior.
|
||||
|
||||
@@ -85,6 +85,12 @@
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
/* Temporary — see MainMenu.ts/DebugCharacterPreview.ts. */
|
||||
.main-menu__debug-preview {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
|
||||
+15
-1
@@ -1,5 +1,6 @@
|
||||
import type { Analytics } from '../analytics';
|
||||
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()
|
||||
* 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);
|
||||
|
||||
// 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');
|
||||
version.className = 'main-menu__version';
|
||||
version.textContent = `v${__APP_VERSION__}`;
|
||||
|
||||
root.append(title, buttons, version);
|
||||
root.append(title, buttons, debugPreview, version);
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -13,31 +13,31 @@ export const TWIGS_PER_TREE = 2;
|
||||
export const TWIGS_PER_BUSH = 5;
|
||||
|
||||
/** 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. */
|
||||
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. */
|
||||
export const DROP_FALL_DURATION = 0.5;
|
||||
|
||||
/** 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. */
|
||||
export const DROP_COLLECT_DURATION = 0.25;
|
||||
|
||||
/** 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. */
|
||||
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. */
|
||||
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. */
|
||||
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) —
|
||||
* a brief flash at the moment of impact, not for however long the attack itself takes. */
|
||||
|
||||
+5
-5
@@ -10,20 +10,20 @@ export const RABBIT_ACTION_DURATION = 0.5;
|
||||
export const RABBIT_PAUSE_DURATION = 0.5;
|
||||
|
||||
/** 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
|
||||
* 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. */
|
||||
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. */
|
||||
export const RABBIT_STRIKE_RANGE = 28;
|
||||
export const RABBIT_STRIKE_RANGE = 9;
|
||||
|
||||
/** 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). */
|
||||
export const RABBIT_MAX_HP = 3;
|
||||
|
||||
@@ -27,7 +27,7 @@ export const UPGRADE_LIST: UpgradeDef[] = [
|
||||
name: 'Скорость движения',
|
||||
baseCost: 5,
|
||||
costGrowth: 1.35,
|
||||
effectAtLevel: (level) => 160 + (level - 1) * 20,
|
||||
effectAtLevel: (level) => 50 + (level - 1) * 6,
|
||||
formatEffect: (v) => `${Math.round(v)} px/с`,
|
||||
},
|
||||
{
|
||||
@@ -67,7 +67,7 @@ export const UPGRADE_LIST: UpgradeDef[] = [
|
||||
name: 'Радиус подбора',
|
||||
baseCost: 5,
|
||||
costGrowth: 1.3,
|
||||
effectAtLevel: (level) => 46 + (level - 1) * 6,
|
||||
effectAtLevel: (level) => 15 + (level - 1) * 2,
|
||||
formatEffect: (v) => `${Math.round(v)} px`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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 —
|
||||
|
||||
+19
-11
@@ -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 type { GameStore } from '../game/GameStore';
|
||||
import { CharacterView } from './entities/CharacterView';
|
||||
@@ -7,9 +7,16 @@ import { NodesView } from './entities/NodesView';
|
||||
import { RabbitsView } from './entities/RabbitsView';
|
||||
|
||||
/** 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/
|
||||
* death screen) is plain DOM, not part of this — see src/app. */
|
||||
* advances them each Pixi tick from the current GameStore state. Deliberately NOT attached to
|
||||
* `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 {
|
||||
/** 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 healthBars = new Container();
|
||||
private readonly overlay = new Sprite(Texture.WHITE);
|
||||
@@ -19,21 +26,22 @@ export class GameWorld {
|
||||
private readonly characterView: CharacterView;
|
||||
private elapsedSeconds = 0;
|
||||
|
||||
constructor(app: Application) {
|
||||
constructor() {
|
||||
this.entities.sortableChildren = true;
|
||||
// Overlay sits above health bars (9999 vs 500 in the original CSS z-indexes), tinting
|
||||
// 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.nodesView = new NodesView(app, this.entities);
|
||||
this.dropsView = new DropsView(app, this.entities);
|
||||
this.rabbitsView = new RabbitsView(app, this.entities, this.healthBars);
|
||||
this.characterView = new CharacterView(app, this.entities, this.healthBars);
|
||||
this.nodesView = new NodesView(this.entities);
|
||||
this.dropsView = new DropsView(this.entities);
|
||||
this.rabbitsView = new RabbitsView(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. */
|
||||
resize(width: number, height: number): void {
|
||||
/** Sizes the full-screen day/night tint to cover the map — called once, with the fixed
|
||||
* world/logical size (see GameCanvas), not on every canvas resize. */
|
||||
setWorldSize(width: number, height: number): void {
|
||||
this.overlay.width = width;
|
||||
this.overlay.height = height;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 { CHARACTER_SPRITE } from '../../game/sprites';
|
||||
import { chopShakeOffset, walkBobOffsetY } from '../animations';
|
||||
import { createHealthBar, setHealthBarPosition, updateHealthBar } from '../healthBar';
|
||||
import { createShadow } from '../shadow';
|
||||
import { applySpriteSize, getSpriteTexture } from '../textures';
|
||||
import { getSpriteTexture } from '../textures';
|
||||
|
||||
const CHARACTER_SIZE = 48;
|
||||
// .entity.character-shadow: width 48 height 24, margin-top 32 (offset from character.x/y).
|
||||
const SHADOW_OFFSET_Y = 32;
|
||||
const SHADOW_RADIUS_X = 24;
|
||||
const SHADOW_RADIUS_Y = 12;
|
||||
// .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;
|
||||
const SHADOW_OFFSET_Y = 11;
|
||||
const SHADOW_RADIUS_X = 8;
|
||||
const SHADOW_RADIUS_Y = 4;
|
||||
/** How far above the character's own position the hp bar floats. */
|
||||
const HEALTH_BAR_OFFSET_Y = 10;
|
||||
|
||||
/** Renders the player character: shadow, sprite (with walk-bob/hit-shake), and an hp bar shown
|
||||
* only while damaged — mirrors CharacterEntity.tsx. */
|
||||
@@ -21,11 +19,10 @@ export class CharacterView {
|
||||
private readonly sprite: Sprite;
|
||||
private readonly healthBar = createHealthBar();
|
||||
|
||||
constructor(app: Application, entities: Container, healthBars: Container) {
|
||||
const info = getSpriteTexture(app, CHARACTER_SPRITE);
|
||||
constructor(entities: Container, healthBars: Container) {
|
||||
const info = getSpriteTexture(CHARACTER_SPRITE);
|
||||
this.sprite = new Sprite(info.texture);
|
||||
this.sprite.anchor.set(0.5);
|
||||
applySpriteSize(this.sprite, info, CHARACTER_SIZE);
|
||||
|
||||
entities.addChild(this.shadow, this.sprite);
|
||||
healthBars.addChild(this.healthBar);
|
||||
|
||||
@@ -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 { ResourceType } from '../../game/types';
|
||||
import { LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE, type PixelGrid } from '../../game/sprites';
|
||||
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_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.
|
||||
const SHADOW_OFFSET_Y: Record<ResourceType, number> = { log: 8, twig: 7, meat: 7 };
|
||||
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. */
|
||||
export class DropsView {
|
||||
private readonly visuals = new Map<string, DropVisual>();
|
||||
private readonly app: Application;
|
||||
private readonly entities: Container;
|
||||
|
||||
constructor(app: Application, entities: Container) {
|
||||
this.app = app;
|
||||
constructor(entities: Container) {
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
@@ -65,10 +62,9 @@ export class DropsView {
|
||||
const radii = SHADOW_RADII[resource];
|
||||
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);
|
||||
sprite.anchor.set(0.5);
|
||||
applySpriteSize(sprite, info, DROP_SIZES[resource]);
|
||||
|
||||
this.entities.addChild(shadow, sprite);
|
||||
return { shadow, sprite };
|
||||
|
||||
@@ -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 { NodeKind } from '../../game/types';
|
||||
import { BUSH_SPRITE, TREE_SPRITE, type PixelGrid } from '../../game/sprites';
|
||||
import { chopShakeOffset } from '../animations';
|
||||
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_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 }> = {
|
||||
tree: { x: -11, y: 26, radiusX: 32, radiusY: 16 },
|
||||
bush: { x: -6, y: 16, radiusX: 20, radiusY: 10 },
|
||||
tree: { x: -3, y: 7, radiusX: 8, radiusY: 4 },
|
||||
bush: { x: -2, y: 5, radiusX: 7, radiusY: 3 },
|
||||
};
|
||||
// .entity.tree/.bush click padding (padding: Npx; margin: -Npx) expands the clickable area beyond
|
||||
// the sprite's own box — approximated here as a circle for hit-testing (see GameCanvas).
|
||||
// Expands the clickable area beyond the sprite's own box — approximated here as a circle for
|
||||
// hit-testing (see GameCanvas).
|
||||
export const NODE_CLICK_RADIUS: Record<NodeKind, number> = {
|
||||
tree: NODE_SIZES.tree / 2 + 12,
|
||||
bush: NODE_SIZES.bush / 2 + 8,
|
||||
tree: 11,
|
||||
bush: 9,
|
||||
};
|
||||
|
||||
interface NodeVisual {
|
||||
@@ -30,11 +27,9 @@ interface NodeVisual {
|
||||
* live node) — entities are added/removed as nodes are chopped down or grown in. */
|
||||
export class NodesView {
|
||||
private readonly visuals = new Map<string, NodeVisual>();
|
||||
private readonly app: Application;
|
||||
private readonly entities: Container;
|
||||
|
||||
constructor(app: Application, entities: Container) {
|
||||
this.app = app;
|
||||
constructor(entities: Container) {
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
@@ -79,10 +74,9 @@ export class NodesView {
|
||||
const offset = SHADOW_OFFSET[kind];
|
||||
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);
|
||||
sprite.anchor.set(0.5);
|
||||
applySpriteSize(sprite, info, NODE_SIZES[kind]);
|
||||
|
||||
this.entities.addChild(shadow, sprite);
|
||||
const visual: NodeVisual = { shadow, sprite };
|
||||
|
||||
@@ -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 { RABBIT_NIGHT_SPRITE, RABBIT_SPRITE } from '../../game/sprites';
|
||||
import { chopShakeOffset } from '../animations';
|
||||
import { createHealthBar, setHealthBarPosition, updateHealthBar } from '../healthBar';
|
||||
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. */
|
||||
export function rabbitClickRadius(store: GameStore): number {
|
||||
const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE;
|
||||
return size / 2 + 6;
|
||||
return size / 2 + RABBIT_GAP;
|
||||
}
|
||||
|
||||
interface RabbitVisual {
|
||||
@@ -30,12 +34,10 @@ interface RabbitVisual {
|
||||
* night/blood-moon. */
|
||||
export class RabbitsView {
|
||||
private readonly visuals = new Map<string, RabbitVisual>();
|
||||
private readonly app: Application;
|
||||
private readonly entities: Container;
|
||||
private readonly healthBars: Container;
|
||||
|
||||
constructor(app: Application, entities: Container, healthBars: Container) {
|
||||
this.app = app;
|
||||
constructor(entities: Container, healthBars: Container) {
|
||||
this.entities = entities;
|
||||
this.healthBars = healthBars;
|
||||
}
|
||||
@@ -60,12 +62,13 @@ export class RabbitsView {
|
||||
visual.shadow.position.set(rabbit.x, rabbit.y + size * 0.6);
|
||||
visual.shadow.zIndex = rabbit.y - 1;
|
||||
|
||||
const info = getSpriteTexture(store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE);
|
||||
if (visual.isNightSprite !== store.isNight) {
|
||||
const info = getSpriteTexture(this.app, store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE);
|
||||
visual.sprite.texture = info.texture;
|
||||
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;
|
||||
visual.sprite.zIndex = drawY;
|
||||
@@ -82,7 +85,7 @@ export class RabbitsView {
|
||||
visual.healthBar.visible = damaged;
|
||||
if (damaged) {
|
||||
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();
|
||||
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);
|
||||
sprite.anchor.set(0.5);
|
||||
applySpriteSize(sprite, info, RABBIT_SIZE);
|
||||
|
||||
const healthBar = createHealthBar();
|
||||
|
||||
|
||||
+18
-4
@@ -3,6 +3,15 @@ import { Graphics } from 'pixi.js';
|
||||
/** Matches `.health-bar`/`.health-bar__fill` in App.css. */
|
||||
const WIDTH = 28;
|
||||
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 {
|
||||
return new Graphics();
|
||||
@@ -16,13 +25,18 @@ export function updateHealthBar(g: Graphics, ratio: number): void {
|
||||
g.clear();
|
||||
g.rect(0, 0, WIDTH, HEIGHT)
|
||||
.fill({ color: 0x000000, alpha: 0.5 })
|
||||
.stroke({ width: 1, color: 0x1b1611 });
|
||||
.stroke({ width: BORDER_WIDTH, color: 0x1b1611 });
|
||||
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
|
||||
* 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 {
|
||||
g.position.set(x - WIDTH / 2, yAboveSprite - HEIGHT);
|
||||
g.position.set(Math.round(x - WIDTH / 2), Math.round(yAboveSprite - HEIGHT));
|
||||
}
|
||||
|
||||
+52
-20
@@ -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';
|
||||
|
||||
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
|
||||
// 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>();
|
||||
|
||||
/** Bakes a PixelGrid (a per-cell CSS-color grid, see game/sprites.ts) into a GPU texture once,
|
||||
* reusing it for every sprite instance of that kind. `scaleMode: 'nearest'` keeps upscaled edges
|
||||
* crisp/blocky, matching the original SVG rects' `shapeRendering="crispEdges"`. */
|
||||
export function getSpriteTexture(app: Application, grid: PixelGrid): SpriteTextureInfo {
|
||||
function parseHexColor(hex: string): [r: number, g: number, b: number] {
|
||||
const n = Number.parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (cached)
|
||||
return cached;
|
||||
|
||||
const rows = grid.length;
|
||||
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 x = 0; x < cols; x++) {
|
||||
const color = grid[y][x];
|
||||
if (color)
|
||||
g.rect(x, y, 1, 1).fill(color);
|
||||
if (!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({
|
||||
target: g,
|
||||
textureSourceOptions: { scaleMode: 'nearest' },
|
||||
const texture = new Texture({
|
||||
source: new BufferImageSource({
|
||||
resource: data,
|
||||
width: cols,
|
||||
height: rows,
|
||||
format: 'rgba8unorm',
|
||||
scaleMode: 'nearest',
|
||||
resolution: 1,
|
||||
}),
|
||||
});
|
||||
g.destroy();
|
||||
|
||||
const info: SpriteTextureInfo = { texture, cols, rows };
|
||||
cache.set(grid, 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user