diff --git a/CLAUDE.md b/CLAUDE.md index 1e919b0..64ead1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ ### Ключевые решения, которые нельзя сломать 1. **PixiJS — только рендер-бэкенд.** Игровой цикл, сцены, ввод, камера, изометрия — собственные (`packages/engine/src/core`, `scene`, `input`, `render`). Pixi используется как низкоуровневый слой отрисовки спрайтов. -2. **Пиксель-арт pixel-perfect.** Рендер в виртуальном разрешении 480×270 (`Game.VIRTUAL_W/H`), канвас растягивается **целым числом** (`computeScale` в `@rpg/engine`, движок сам слушает `window.resize`) с `image-rendering: pixelated`, `roundPixels: true`, `antialias: false`. При изменении разрешения следи, чтобы масштаб всегда оставался целым. +2. **Пиксель-арт pixel-perfect, резкий UI.** Логика — в виртуальном разрешении 480×270 (`Game.VIRTUAL_W/H`); CSS-размер канваса — виртуальное × **целое** (`computeScale` в `@rpg/engine`, движок сам слушает `window.resize`), а бэкинг-стор — в пикселях устройства (scale × dpr): текст/UI резкие, мир пиксельный (текстуры `AssetLoader` сэмплируются `nearest`). `roundPixels: true`, `antialias: false`. При изменении разрешения следи, чтобы CSS-масштаб оставался целым. 3. **Фиксированный шаг.** `GameLoop` (60 Гц update) + аккумулятор с ограничением 5 шагов/кадр; `InputManager.endTick()` очищает «just pressed» в конце каждого тика — не вызывать update вне цикла движка. 4. **Изометрия 2:1.** Конверсии `isoToScreen`/`screenToIsoExact` в `math/iso.ts` — источник истины для позиций. Тайловые координаты всюду именуются `{x, y}` (как `Grid` в pathfinding), экранные тоже `{x, y}`. 5. **Собственные классы движка с инъекцией хранилища.** `SaveManager` принимает `StorageLike` (в браузере — `localStorage`), чтобы тесты работали без DOM. Чистую математику (iso, A*, ECS) держи без зависимостей от Pixi — она тестируется в Vitest без браузера. diff --git a/apps/game/assets/tiles/ash.png b/apps/game/assets/tiles/ash.png index b577c98..154344a 100644 --- a/apps/game/assets/tiles/ash.png +++ b/apps/game/assets/tiles/ash.png Binary files differ diff --git a/apps/game/assets/tiles/bellflower.png b/apps/game/assets/tiles/bellflower.png index 05f7a5e..d7eb36a 100644 --- a/apps/game/assets/tiles/bellflower.png +++ b/apps/game/assets/tiles/bellflower.png Binary files differ diff --git a/apps/game/assets/tiles/grass.png b/apps/game/assets/tiles/grass.png index 7cb694c..3c705af 100644 --- a/apps/game/assets/tiles/grass.png +++ b/apps/game/assets/tiles/grass.png Binary files differ diff --git a/apps/game/assets/tiles/path.png b/apps/game/assets/tiles/path.png index fd5abfc..7f0df50 100644 --- a/apps/game/assets/tiles/path.png +++ b/apps/game/assets/tiles/path.png Binary files differ diff --git a/apps/game/assets/tiles/water.png b/apps/game/assets/tiles/water.png index c9f5881..5ed40b2 100644 --- a/apps/game/assets/tiles/water.png +++ b/apps/game/assets/tiles/water.png Binary files differ diff --git a/docs/engine/render.md b/docs/engine/render.md index 5e49df6..2ffe173 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -2,12 +2,20 @@ ## Renderer и pixel-perfect -Виртуальное разрешение (например 480×270) растягивается на экран **целым** числом — -каждый виртуальный пиксель всегда N×N экранных. Это и есть pixel-perfect: +Виртуальное разрешение (например 480×270) определяет **логическую** систему координат: +всё игровое кодирование позиций — в виртуальных пикселях. Отрисовка двухслойная: + +- CSS-размер канваса — виртуальное разрешение, растянутое **целым** числом + (`computeScale`), без зазоров и обрезки; +- бэкинг-стор канваса — в **пикселях устройства** (scale × devicePixelRatio): + UI и текст рендерятся 1:1 с экраном и остаются резкими, в том числе на HiDPI; +- мир при этом остаётся пиксельным: текстуры, загруженные через `AssetLoader`, + сэмплируются `nearest` (чёткие квадратные пиксели арта). - `computeScale(vw, vh, winW, winH)` — максимальный целый масштаб, при котором виртуальный экран влезает в окно; -- `renderer.resize(newScale)` — смена масштаба (CSS-размер канваса); +- `renderer.resize(newScale)` — смена масштаба; движок сам пересчитывает + resolution устройства и растеризацию текста (см. `PixelText`); Engine делает это автоматически при ресайзе окна (`autoResize: true`); - канвасу выставлены `image-rendering: pixelated`, `antialias: false`, `roundPixels: true`. diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index deaa9c3..63659f6 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -22,6 +22,10 @@ Чтобы заменить шрифт: свой TTF + `ensurePixelFont(url, 'МойШрифт')` — PixelText принимает `fontFamily`. +Resolution растеризации текста движок синхронизирует с масштабом канваса сам +(`setPixelTextResolution`, вызывается Renderer'ом) — глифы рендерятся 1:1 с пикселями +устройства и не «мылятся» при растяжении экрана. + ## Panel, Button, MenuList Базовый стиль UI движка: тёмные панели с однопиксельной рамкой, жёлтый акцент для фокуса. diff --git a/packages/engine/src/assets/AssetLoader.ts b/packages/engine/src/assets/AssetLoader.ts index fe743e3..8c29476 100644 --- a/packages/engine/src/assets/AssetLoader.ts +++ b/packages/engine/src/assets/AssetLoader.ts @@ -27,6 +27,8 @@ for (const key of keys) { try { const tex = await Assets.load(this.resolveUrl(key)); + // Пиксель-арт мира: nearest — чёткие квадратные пиксели на любом масштабе. + tex.source.scaleMode = 'nearest'; this.textures.set(key, tex); } catch (err) { throw new Error(`Не удалось загрузить ${this.resolveUrl(key)}: ${String(err)}`); @@ -50,6 +52,10 @@ if (!(sheet instanceof Spritesheet) || !sheet.textures) { throw new Error(`Атлас ${key} не распознан как Spritesheet (проверьте URL и формат JSON)`); } + // Кадры атласа — тоже nearest (общий источник, но пройдёмся по всем на всякий случай). + for (const tex of Object.values(sheet.textures)) { + tex.source.scaleMode = 'nearest'; + } this.atlases.set(key, sheet); return sheet; } diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index c8b8016..4e5ad8e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -94,7 +94,7 @@ // ui export { DialogueBox, type DialogueLine, type DialogueBoxOptions } from './ui/DialogueBox'; -export { PixelText, type PixelTextOptions, ensurePixelFont } from './ui/PixelText'; +export { PixelText, type PixelTextOptions, ensurePixelFont, setPixelTextResolution } from './ui/PixelText'; export { Panel, type PanelOptions } from './ui/Panel'; export { Button, type ButtonOptions } from './ui/Button'; export { MenuList, type MenuListOptions } from './ui/MenuList'; diff --git a/packages/engine/src/render/Renderer.ts b/packages/engine/src/render/Renderer.ts index d5501d6..aede832 100644 --- a/packages/engine/src/render/Renderer.ts +++ b/packages/engine/src/render/Renderer.ts @@ -1,8 +1,11 @@ import { Application, Container } from 'pixi.js'; +import { setPixelTextResolution } from '../ui/PixelText'; /** - * Рендерер: виртуальное «пиксельное» разрешение, растянутое целым числом на экран - * с image-rendering: pixelated — это и есть pixel-perfect для пиксель-арта. + * Рендерер: виртуальное «пиксельное» разрешение. CSS-размер канваса — виртуальное + * разрешение, растянутое целым числом (image-rendering: pixelated); бэкинг-стор — + * в пикселях устройства (scale × dpr), поэтому UI и текст резкие, а мир остаётся + * пиксельным (текстуры мира сэмплируются nearest — см. AssetLoader). */ export interface RendererOptions { /** Виртуальная ширина (например 480). */ @@ -79,6 +82,14 @@ private applySize(): void { const canvas = this.app.canvas; + // Бэкинг-стор — в пикселях устройства: UI и текст рендерятся 1:1 с экраном. + const dpr = window.devicePixelRatio || 1; + const resolution = Math.max(1, Math.round(this._scale * dpr)); + if (this.app.renderer.resolution !== resolution) { + this.app.renderer.resolution = resolution; + this.app.renderer.resize(this.virtualWidth, this.virtualHeight); + setPixelTextResolution(resolution); + } canvas.style.width = `${this.virtualWidth * this._scale}px`; canvas.style.height = `${this.virtualHeight * this._scale}px`; } diff --git a/packages/engine/src/ui/PixelText.ts b/packages/engine/src/ui/PixelText.ts index 3f34e69..7e6237a 100644 --- a/packages/engine/src/ui/PixelText.ts +++ b/packages/engine/src/ui/PixelText.ts @@ -15,6 +15,17 @@ let fontLoaded: Promise | null = null; /** + * Resolution растеризации текста (пикселей устройства на виртуальный пиксель). + * Задаётся Renderer'ом при смене масштаба; до этого тексты чуть мягче — не критично. + */ +let textResolution = 1; + +/** Задать resolution растеризации PixelText (вызывается движком при ресайзе). */ +export function setPixelTextResolution(resolution: number): void { + textResolution = Math.max(1, Math.round(resolution)); +} + +/** * Загрузить пиксельный шрифт. url указывает на TTF (приложение решает, где он лежит). * Повторные вызовы возвращают уже начатую загрузку. Безопасно вызывать до Pixi. */ @@ -66,6 +77,7 @@ if (options.align !== undefined) { style.align = options.align; } - super({ text: options.text, style }); + // resolution > 1: глифы растеризуются 1:1 с пикселями устройства — резкий текст. + super({ text: options.text, style, resolution: textResolution }); } } \ No newline at end of file diff --git a/tools/pixelart/gen.mjs b/tools/pixelart/gen.mjs index 71c52e5..8a2c30c 100644 --- a/tools/pixelart/gen.mjs +++ b/tools/pixelart/gen.mjs @@ -11,10 +11,10 @@ // ---------- тайлы 32x16 ---------- -/** Ромб с зерном пепла/травы. */ +/** Ромб с зерном пепла/травы. Без обводки граней: смежные тайлы сливаются в поле. */ function tileBase(base, light, dark, seed, specks) { const c = new Canvas(32, 16); - fillDiamond(c, 16, 8, 32, 16, base, light, dark); + fillDiamond(c, 16, 8, 32, 16, base); const r = rng(seed); for (let i = 0; i < specks; i++) { const x = Math.floor(r() * 32); @@ -72,9 +72,9 @@ } function ashTile() { - // Пепельный бугор: чуть светлее травы, тёмный обод по нижней грани. + // Пепельный бугор: чуть светлее травы, тоже без обводки. const c = new Canvas(32, 16); - fillDiamond(c, 16, 8, 32, 16, 'P3', 'P4', 'P2'); + fillDiamond(c, 16, 8, 32, 16, 'P3'); const r = rng(9); for (let i = 0; i < 6; i++) { const x = 3 + Math.floor(r() * 26);