From 43f6004fb0c826771a8f7684fe7db5ec9cfdc950 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Wed, 9 Sep 2026 19:10:50 +0300 Subject: [PATCH] pixel-perfect rendering, all sprites have same 1:1 scale --- scripts/play.ts | 6 +- src/App.css | 6 ++ src/app/MainMenu.ts | 16 ++++- src/game/constants.ts | 14 ++-- src/game/rabbits.ts | 10 +-- src/game/upgrades.ts | 4 +- src/pixi/DebugCharacterPreview.ts | 33 +++++++++ src/pixi/GameCanvas.ts | 72 +++++++++---------- src/pixi/GameWorld.ts | 30 +++++--- src/pixi/PixelPerfectWorld.ts | 107 +++++++++++++++++++++++++++++ src/pixi/entities/CharacterView.ts | 21 +++--- src/pixi/entities/DropsView.ts | 12 ++-- src/pixi/entities/NodesView.ts | 26 +++---- src/pixi/entities/RabbitsView.ts | 26 +++---- src/pixi/healthBar.ts | 22 ++++-- src/pixi/textures.ts | 72 +++++++++++++------ 16 files changed, 340 insertions(+), 137 deletions(-) create mode 100644 src/pixi/DebugCharacterPreview.ts create mode 100644 src/pixi/PixelPerfectWorld.ts diff --git a/scripts/play.ts b/scripts/play.ts index 93e6974..91d4c68 100644 --- a/scripts/play.ts +++ b/scripts/play.ts @@ -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. diff --git a/src/App.css b/src/App.css index e308d94..e2cf3c1 100644 --- a/src/App.css +++ b/src/App.css @@ -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); diff --git a/src/app/MainMenu.ts b/src/app/MainMenu.ts index 85a83b3..313de3e 100644 --- a/src/app/MainMenu.ts +++ b/src/app/MainMenu.ts @@ -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; } diff --git a/src/game/constants.ts b/src/game/constants.ts index df9bd8f..703e174 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -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. */ diff --git a/src/game/rabbits.ts b/src/game/rabbits.ts index 9f9630a..608da48 100644 --- a/src/game/rabbits.ts +++ b/src/game/rabbits.ts @@ -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; diff --git a/src/game/upgrades.ts b/src/game/upgrades.ts index 22cab88..3413b68 100644 --- a/src/game/upgrades.ts +++ b/src/game/upgrades.ts @@ -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`, }, { diff --git a/src/pixi/DebugCharacterPreview.ts b/src/pixi/DebugCharacterPreview.ts new file mode 100644 index 0000000..88adeb3 --- /dev/null +++ b/src/pixi/DebugCharacterPreview.ts @@ -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, +): 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 }; +} diff --git a/src/pixi/GameCanvas.ts b/src/pixi/GameCanvas.ts index 4ccf549..2a8b92e 100644 --- a/src/pixi/GameCanvas.ts +++ b/src/pixi/GameCanvas.ts @@ -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 { 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 — diff --git a/src/pixi/GameWorld.ts b/src/pixi/GameWorld.ts index 9c1e52a..04ea7da 100644 --- a/src/pixi/GameWorld.ts +++ b/src/pixi/GameWorld.ts @@ -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; } diff --git a/src/pixi/PixelPerfectWorld.ts b/src/pixi/PixelPerfectWorld.ts new file mode 100644 index 0000000..adf0322 --- /dev/null +++ b/src/pixi/PixelPerfectWorld.ts @@ -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): Promise { + 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), + ); + } +} diff --git a/src/pixi/entities/CharacterView.ts b/src/pixi/entities/CharacterView.ts index 0dca880..6856c4b 100644 --- a/src/pixi/entities/CharacterView.ts +++ b/src/pixi/entities/CharacterView.ts @@ -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); diff --git a/src/pixi/entities/DropsView.ts b/src/pixi/entities/DropsView.ts index 75ec12a..c2b87cb 100644 --- a/src/pixi/entities/DropsView.ts +++ b/src/pixi/entities/DropsView.ts @@ -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 = { log: LOG_SPRITE, twig: TWIG_SPRITE, meat: MEAT_SPRITE }; -const DROP_SIZES: Record = { 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 = { log: 8, twig: 7, meat: 7 }; const SHADOW_RADII: Record = { @@ -24,11 +23,9 @@ interface DropVisual { * (mirrors ResourceDropEntity.tsx). Not interactive — drops have no click handling. */ export class DropsView { private readonly visuals = new Map(); - 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 }; diff --git a/src/pixi/entities/NodesView.ts b/src/pixi/entities/NodesView.ts index 100f874..fe33245 100644 --- a/src/pixi/entities/NodesView.ts +++ b/src/pixi/entities/NodesView.ts @@ -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 = { tree: TREE_SPRITE, bush: BUSH_SPRITE }; -const NODE_SIZES: Record = { 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 = { - 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 = { - 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(); - 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 }; diff --git a/src/pixi/entities/RabbitsView.ts b/src/pixi/entities/RabbitsView.ts index e15c28b..d16dbbf 100644 --- a/src/pixi/entities/RabbitsView.ts +++ b/src/pixi/entities/RabbitsView.ts @@ -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(); - 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(); diff --git a/src/pixi/healthBar.ts b/src/pixi/healthBar.ts index 4239a1b..75a15d9 100644 --- a/src/pixi/healthBar.ts +++ b/src/pixi/healthBar.ts @@ -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)); } diff --git a/src/pixi/textures.ts b/src/pixi/textures.ts index 7b21657..ad5ca96 100644 --- a/src/pixi/textures.ts +++ b/src/pixi/textures.ts @@ -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(); -/** 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; -}