diff --git a/CLAUDE.md b/CLAUDE.md index 1a11426..ed6c6e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,24 +31,27 @@ ### Ключевые решения, которые нельзя сломать 1. **PixiJS — только рендер-бэкенд.** Игровой цикл, сцены, ввод, камера, изометрия — собственные (`packages/engine/src/core`, `scene`, `input`, `render`). Pixi используется как низкоуровневый слой отрисовки спрайтов. -2. **Пиксель-арт pixel-perfect.** Рендер в виртуальном разрешении 480×270 (`Game.VIRTUAL_W/H`), канвас растягивается **целым числом** (`pickScale` в `main.ts`) с `image-rendering: pixelated`, `roundPixels: true`, `antialias: false`. При изменении разрешения следи, чтобы масштаб всегда оставался целым. +2. **Пиксель-арт pixel-perfect.** Рендер в виртуальном разрешении 480×270 (`Game.VIRTUAL_W/H`), канвас растягивается **целым числом** (`computeScale` в `@rpg/engine`, движок сам слушает `window.resize`) с `image-rendering: pixelated`, `roundPixels: true`, `antialias: false`. При изменении разрешения следи, чтобы масштаб всегда оставался целым. 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 без браузера. ### Где что лежит +- `docs/engine/` — **документация движка** (по-русски): `README.md` (архитектура и принципы), `getting-started.md`, `core.md`, `render.md`, `input.md`, `maps.md`, `ui-and-dialogue.md`, `assets-audio-save.md`, `art-pipeline.md`, `recipes.md`. При изменении API движка обновляй соответствующий файл. - `docs/world.md` — библия мира (сеттинг, локации, персонажи, сюжет). **Любой новый контент сверять с ней.** - `docs/art-style.md` — арт-библия: палитра (32 цвета), размеры спрайтов, правила стиля, чеклист ассетов. -- `apps/game/src/data/` — весь контент: карта (`map.ts`, id тайлов + генератор), диалоги (`dialogues.ts`), NPC (`npcs.ts`). -- `apps/game/src/scenes/` — BootScene (грузит ассеты) → MenuScene → LocationScene; сцены меняются через `SceneManager.replace/push/pop`. -- `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация направлений), диалоги с флагами сюжета. -- Сюжетные флаги живут в `DialogueSystem.flags` и сохраняются в автосейв `autosave` (Esc в локации). +- `packages/engine/src/` — модули движка: `core/` (Engine, GameLoop, Tween, GameState, StateMachine, Settings, EventBus), `scene/` (SceneManager + fade-переходы), `render/` (Renderer, Camera, IsoDepthLayer, Particles, scale), `input/` (действия, геймпад, VirtualJoystick), `map/` (изометрия, A*, mapFormat, Tiled-импорт), `ui/` (PixelText/VT323, Panel, Button, MenuList, DialogueBox), `dialogue/` (DialogueRunner — view-агностик), `audio/` (шины master/music/sfx), `assets/` (AssetLoader + атласы), `save/`, `math/` (iso, rng, easing). +- `apps/game/src/data/` — весь контент: карта (`map.ts`), диалоги (`dialogues.ts` — графы `DialogueGraph`), NPC (`npcs.ts`). +- `apps/game/src/scenes/` — BootScene (грузит ассеты и шрифт) → MenuScene (MenuList) → LocationScene; сцены меняются через `SceneManager.replace/push/pop` (опционально с fade). +- `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация из атласа), диалоги (обёртка над DialogueRunner + DialogueBox). +- Флаги/переменные сюжета — в `GameState` (`game.state`), сериализуются в автосейв `autosave` (Esc в локации); настройки — `game.settings` (отдельный слот, не в сейвах). ### Пиксель-арт - `tools/pixelart/gen.mjs` — генератор ассетов (PNG из кода, без зависимостей): `node tools/pixelart/gen.mjs`. - Выход — `apps/game/assets/` (`tiles/*.png`, `chars/*.png`); загружается `AssetLoader` в BootScene, ключ = путь без расширения. +- Атласы (кадры героя): `gen.mjs` собирает `chars/hero_sheet.png` + `hero_sheet.json` (Pixi Spritesheet); загрузка — `assets.loadAtlas('chars/hero_sheet.json')`, кадры — `assets.frames(key, prefix)`. - Палитра — только из `tools/pixelart/palette.mjs` (она же в `docs/art-style.md`); тёплые цвета (B*/F*) — только «жизнь»: цветы, бронза, огонь. - Имена файлов и размеры спрайтов фиксированы арт-библией — при замене сгенерированного арта нарисованным руками их не менять. - Тайл «дерево» — высокий объект: `TileMapData.tall` c `TallSpec { height, ground }` (спрайт якорится низом в центр ромба, под ним рисуется ground-тайл). @@ -58,6 +61,12 @@ - **Pixi `Assets.load` без `Assets.init()` виснет навсегда** (без ошибок!) — `AssetLoader` делает init сам, не обходи его. - **`app.renderer` недоступен до завершения `app.init()`** — `Renderer.setup()` ждёт init-промис; не трогай renderer раньше. - **Изометрический «ромб» уходит в минус по X** (западный угол карты при x≈-448) — границы камеры задаются как `CameraBounds { x, y, width, height }` от западного угла, а не от (0,0). Координаты тайлов/героя/NPC — единое пространство мировых пикселей, ничего дополнительно не сдвигать. +- **Vite-алиас `@rpg/engine` — только regex** (`{ find: /^@rpg\/engine$/, replacement: ... }`). Строковый алиас перехватывает и под-пути (`@rpg/engine/assets/...`), ломая их в `index.ts/...`. +- **Игровые PNG должны попадать в dist**: в `apps/game/vite.config.ts` стоит `publicDir: 'assets'`, а `resolveUrl` не добавляет префикс `assets/`. Без этого прод-сборка пустая. +- **Шрифт движка подключается `?url`-импортом** (`import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url'`) — для этого в exports движка есть `"./assets/*"`. Обычный `new URL(...)` вне корня Vite не обрабатывает. +- **Атласы грузятся только через `assets.loadAtlas('<ключ>.json')`** — ключ с `.json` резолвится как есть, без `.png`. `Assets.load` на JSON вернёт Texture без `.textures` — не перепутай с `texture()`. +- **Fade-оверлей переходов должен быть поверх UI**: `SceneManager` поднимает его наверх `uiRoot` в `begin()` — сцены добавляют свои view позже, и «наивный» оверлей из `Engine.init()` оказывается под ними (карты просвечивают сквозь меню). +- **TweenManager.tick учитывает пересечение границы `delay`**: часть dt, приходящаяся на delay, вычитается из движения — не упрощай тик до последовательных прибавлений. - **Headless-скриншоты с `--virtual-time-budget` зависают на загрузке картинок** (колбэки декодинга не срабатывают) — для визуальных проверок использовать `tools/smoke.mjs` (puppeteer-core + системный Chromium, реальное время). - Мир/арт сверять с `docs/world.md` и `docs/art-style.md`: тёплые цвета — только жизнь; палитра — только из `tools/pixelart/palette.mjs`. diff --git a/README.md b/README.md index 90f162b..1813c87 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ```bash npm install npm run dev # http://localhost:5173 -npm test # тесты движка +npm test # 81 юнит-тест движка npm run build # прод-сборка node tools/pixelart/gen.mjs # перегенерация пиксель-арта ``` @@ -19,9 +19,11 @@ ## Структура ``` +docs/engine # документация движка: архитектура, все подсистемы, рецепты docs/world.md # библия мира: сеттинг, локации, персонажи, сюжет docs/art-style.md # арт-библия: палитра, размеры, стиль, чеклист ассетов -packages/engine # движок: цикл, сцены, ввод, камера, изометрия, A*, сейвы, диалоги +packages/engine # движок: цикл, сцены, ввод, камера, изометрия, A*, сейвы, + # диалоги, твины, GameState, UI-кит, частицы, аудио, карты apps/game # RPG: сцены, локации, NPC, диалоги, сюжет apps/game/assets # пиксель-арт (генерируется tools/pixelart) tools/pixelart # генератор арта: PNG из кода, палитра в одном месте @@ -29,6 +31,24 @@ Правила: игра использует только публичный API `@rpg/engine`; движок ничего не знает о жанре игры; тёплый цвет в арте — только жизнь (цветы, бронза, огонь). +## Документация движка + +Начни с `docs/engine/README.md` — там карта документации и принципы архитектуры. + +| Файл | О чём | +|---|---| +| `getting-started.md` | новая игра на движке с нуля: bootstrap, сцены, ассеты | +| `core.md` | Engine, GameLoop, Tween, GameState, StateMachine, Settings | +| `render.md` | Renderer, pixel-perfect, Camera, IsoDepthLayer, частицы | +| `input.md` | действия, клавиатура, мышь, тач, геймпад | +| `maps.md` | изометрия, A*, формат карт (JSON+RLE), импорт из Tiled | +| `ui-and-dialogue.md` | UI-кит, шрифт VT323, DialogueRunner и формат графов | +| `assets-audio-save.md` | ассеты и атласы, аудио-шины, сейвы | +| `art-pipeline.md` | генератор арта, палитра, замена на рисованный | +| `recipes.md` | «как сделать»: локация, NPC, квест, анимация, частицы | + +Правила: игра использует только публичный API `@rpg/engine`; движок ничего не знает о жанре игры; тёплый цвет в арте — только жизнь (цветы, бронза, огонь). + ## Мир Сорок лет назад Северные Печи горели девять дней — и пепел накрыл край. Пепел гасит звук diff --git a/apps/game/assets/chars/hero_sheet.json b/apps/game/assets/chars/hero_sheet.json new file mode 100644 index 0000000..368c4e5 --- /dev/null +++ b/apps/game/assets/chars/hero_sheet.json @@ -0,0 +1,96 @@ +{ + "frames": { + "hero_down_1": { + "frame": { + "x": 0, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "hero_down_2": { + "frame": { + "x": 16, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "hero_up_1": { + "frame": { + "x": 32, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "hero_up_2": { + "frame": { + "x": 48, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "hero_side_1": { + "frame": { + "x": 64, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "hero_side_2": { + "frame": { + "x": 80, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + } + }, + "meta": { + "image": "hero_sheet.png", + "size": { + "w": 96, + "h": 24 + }, + "scale": 1 + } +} \ No newline at end of file diff --git a/apps/game/assets/chars/hero_sheet.png b/apps/game/assets/chars/hero_sheet.png new file mode 100644 index 0000000..93591b3 --- /dev/null +++ b/apps/game/assets/chars/hero_sheet.png Binary files differ diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 4265657..4e23825 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -1,7 +1,17 @@ -import { Engine, SaveManager, AssetLoader, type Renderer, type SceneManager } from '@rpg/engine'; +import { + Engine, + SaveManager, + AssetLoader, + GameState, + Settings, + type Renderer, + type SceneManager +} from '@rpg/engine'; +// Шрифт идёт с движком (OFL): Vite кладёт файл в сборку и даёт готовый URL. +import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url'; /** - * Игровой контекст: движок, сохранения, загрузчик ассетов. + * Игровой контекст: движок, сохранения, загрузчик ассетов, состояние, настройки. * Движок ничего не знает об этом классе — он только потребляет его API. */ export class Game { @@ -11,8 +21,12 @@ readonly saves: SaveManager; readonly assets: AssetLoader; + /** Флаги и переменные прохождения. */ + readonly state = new GameState(); + /** Настройки игрока (громкости и т.п.) — вне сейвов. */ + readonly settings = new Settings(window.localStorage); - /** Все ассеты игры: ключ = путь в apps/game/assets без расширения. */ + /** Текстуры тайлов и одиночные спрайты NPC. */ static readonly ASSET_KEYS = [ 'tiles/grass', 'tiles/path', @@ -20,21 +34,22 @@ 'tiles/ash', 'tiles/bellflower', 'tiles/tree', - 'chars/hero_down_1', - 'chars/hero_down_2', - 'chars/hero_up_1', - 'chars/hero_up_2', - 'chars/hero_side_1', - 'chars/hero_side_2', 'chars/elder_irwin', 'chars/trader_mila' ]; constructor(readonly engine: Engine) { this.saves = new SaveManager(window.localStorage); - this.assets = new AssetLoader((key) => `${import.meta.env.BASE_URL}assets/${key}.png`); + // Ключи без расширения -> .png; ключи .json (атласы) резолвятся как есть. + this.assets = new AssetLoader((key) => + key.endsWith('.json') ? `${import.meta.env.BASE_URL}${key}` : `${import.meta.env.BASE_URL}${key}.png` + ); + this.settings.load(); } + /** URL пиксельного шрифта (VT323 идёт вместе с движком, OFL). */ + static readonly FONT_URL: string = fontUrl; + get scenes(): SceneManager { return this.engine.scenes; } diff --git a/apps/game/src/data/dialogues.ts b/apps/game/src/data/dialogues.ts index daca4ff..5db8aaa 100644 --- a/apps/game/src/data/dialogues.ts +++ b/apps/game/src/data/dialogues.ts @@ -1,37 +1,72 @@ -import type { DialogueLine } from '@rpg/engine'; +import type { DialogueGraph } from '@rpg/engine'; /** - * Диалоги NPC (по docs/world.md, акт 1). + * Диалоги NPC как графы для DialogueRunner (по docs/world.md, акт 1). * Мила — безгласная: говорит шёпотом, коротко. Ирвин — экономит дыхание. + * + * Флаги сюжета: + * met_elder — познакомились с Ирвином + * quest_bells_taken — Ирвин дал задание о колокольчиках + * quest_bells_done — колокольчики собраны и посажены (акт 1, дальше) + * met_mila — познакомились с Милой + * got_cloth — Мила дала вощёное полотно */ -export interface Dialogue { - id: string; - lines: DialogueLine[]; -} - -export const DIALOGUES: Record = { +export const DIALOGUES: Record = { elder_first: { - id: 'elder_first', - lines: [ - { speaker: 'Старейшина Ирвин', text: 'Вернулся. Хорошо. Слышал гул на закате? Это пепел дышит у прудов.' }, - { speaker: 'Старейшина Ирвин', text: 'Три поляны лунных колокольчиков там, у Серых прудов. Если не собрать цветы до выдоха — задохнутся.' }, - { speaker: 'Старейшина Ирвин', text: 'Собери. Посади здесь, на лугу. Поляна без цветов — поляна без завтра.' }, - { speaker: 'Звонарь', text: 'Прозвоню дорогу до прудов и вернусь до темноты.' } - ] + start: 'greet', + nodes: { + greet: { + speaker: 'Старейшина Ирвин', + text: 'Вернулся. Хорошо. Слышал гул на закате? Это пепел дышит у прудов.', + next: 'ask' + }, + ask: { + speaker: 'Старейшина Ирвин', + text: 'Три поляны лунных колокольчиков там, у Серых прудов. Если не собрать цветы до выдоха — задохнутся.', + next: 'task' + }, + task: { + speaker: 'Старейшина Ирвин', + text: 'Собери. Посади здесь, на лугу. Поляна без цветов — поляна без завтра.', + next: 'give' + }, + // Узел-действие: выдача задания без реплики. + give: { setFlags: ['met_elder', 'quest_bells_taken'], next: 'ring' }, + ring: { speaker: 'Звонарь', text: 'Прозвоню дорогу до прудов и вернусь до темноты.' } + } }, + elder_repeat: { - id: 'elder_repeat', - lines: [ - { speaker: 'Старейшина Ирвин', text: 'Цветы ждут у прудов, звонарь. А пепел не ждёт.' } - ] + start: 'again', + nodes: { + again: { + whenNot: ['quest_bells_done'], + next: 'waiting' + }, + waiting: { speaker: 'Старейшина Ирвин', text: 'Цветы ждут у прудов, звонарь. А пепел не ждёт.' } + } }, + trader_first: { - id: 'trader_first', - lines: [ - { speaker: 'Торговка Мила', text: '*звонит ручным колокольчиком дважды* ...Стой.' }, - { speaker: 'Торговка Мила', text: 'У прудов... свежий накат. Дышать поверх — голос отдашь. Как я отдала.' }, - { speaker: 'Торговка Мила', text: 'Держи вощёное полотно. На губы. И звони тихо — пепел не буди.' }, - { speaker: 'Звонарь', text: 'Спасибо, Мила. Верну и полотно, и голос — твой точно.' } - ] + start: 'stop', + nodes: { + stop: { + speaker: 'Торговка Мила', + text: '*звонит ручным колокольчиком дважды* ...Стой.', + next: 'warn' + }, + warn: { + speaker: 'Торговка Мила', + text: 'У прудов... свежий накат. Дышать поверх — голос отдашь. Как я отдала.', + next: 'gift' + }, + gift: { + speaker: 'Торговка Мила', + text: 'Держи вощёное полотно. На губы. И звони тихо — пепел не буди.', + next: 'give_cloth' + }, + give_cloth: { setFlags: ['met_mila', 'got_cloth'], next: 'reply' }, + reply: { speaker: 'Звонарь', text: 'Спасибо, Мила. Верну и полотно, и голос — твой точно.' } + } } }; \ No newline at end of file diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts index 0a3f631..d5da936 100644 --- a/apps/game/src/main.ts +++ b/apps/game/src/main.ts @@ -1,19 +1,14 @@ -import { Engine, EngineOptions } from '@rpg/engine'; +import { Engine, computeScale, ensurePixelFont, type EngineOptions } from '@rpg/engine'; import { Game } from './Game'; import { BootScene } from './scenes/BootScene'; -/** Целочисленный масштаб под текущее окно (pixel-perfect). */ -function pickScale(vw: number, vh: number): number { - return Math.max(1, Math.floor(Math.min(window.innerWidth / vw, window.innerHeight / vh))); -} - async function main(): Promise { const container = document.getElementById('game')!; const options: EngineOptions = { virtualWidth: Game.VIRTUAL_W, virtualHeight: Game.VIRTUAL_H, - scale: pickScale(Game.VIRTUAL_W, Game.VIRTUAL_H), + scale: computeScale(Game.VIRTUAL_W, Game.VIRTUAL_H, window.innerWidth, window.innerHeight), background: 0x0b0b12, fixedFps: 60, parent: container @@ -23,8 +18,19 @@ await engine.init(); engine.input.bindActions({ advance: ['Space', 'Enter'], - menu: ['Escape'] + menu: ['Escape'], + up: ['KeyW', 'ArrowUp'], + down: ['KeyS', 'ArrowDown'] }); + engine.input.bindGamepad({ + advance: [0], // A + menu: [9], // Start + up: [12], + down: [13] + }); + + // Пиксельный шрифт движка (VT323): до первого текста; фолбэк monospace безопасен. + void ensurePixelFont(Game.FONT_URL); const game = new Game(engine); await engine.scenes.push(new BootScene(game)); diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts index b4c6aef..af2ea31 100644 --- a/apps/game/src/scenes/BootScene.ts +++ b/apps/game/src/scenes/BootScene.ts @@ -1,5 +1,4 @@ -import { Container, Graphics, Text } from 'pixi.js'; -import type { Scene } from '@rpg/engine'; +import { Container, Graphics, PixelText, ensurePixelFont, type Scene } from '@rpg/engine'; import { Game } from '../Game'; import { MenuScene } from './MenuScene'; @@ -13,9 +12,10 @@ private progress = -1; constructor(private game: Game) { - const label = new Text({ + const label = new PixelText({ text: 'пепел оседает...', - style: { fontFamily: 'monospace', fontSize: 8, fill: 0x8a8a9a } + size: 10, + color: 0x8a8a9a }); label.anchor.set(0.5); label.position.set(240, 120); @@ -37,9 +37,11 @@ .load(Game.ASSET_KEYS, (p) => { this.progress = p; }) + .then(async () => await this.game.assets.loadAtlas('chars/hero_sheet.json')) + .then(() => ensurePixelFont(Game.FONT_URL)) .then(() => { this.progress = 1; - void this.game.scenes.replace(new MenuScene(this.game)); + void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); }) .catch((err) => { console.error('[boot] ошибка загрузки:', err); diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index e50e11c..f6ca127 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -1,64 +1,78 @@ -import { Container, Graphics, Text, Texture } from 'pixi.js'; import { - World, + Container, + Graphics, + IsoDepthLayer, + ParticleEmitter, + PixelText, + Sprite, + Texture, IsometricTileMap, isoToScreen, screenToIsoExact, - Sprite, DEFAULT_ISO, - type Scene, - type Camera + type Camera, + type Scene } from '@rpg/engine'; import { Game } from '../Game'; +import { MenuScene, type SaveData } from './MenuScene'; import { buildMeadowsMap, TILES } from '../data/map'; import { NPCS, type NpcDef } from '../data/npcs'; +import { DIALOGUES } from '../data/dialogues'; import { PlayerController, type HeroTextures } from '../systems/PlayerController'; import { DialogueSystem } from '../systems/DialogueSystem'; -import { MenuScene, type SaveData } from './MenuScene'; /** * Локация «Выжженные луга»: карта, герой, NPC, диалоги, автосейв по Esc. */ export class LocationScene implements Scene { - private world = new World(); + private world = new Container(); private map: IsometricTileMap; private player: PlayerController; private dialogue: DialogueSystem; private npcs: { def: NpcDef; view: Container }[] = []; - private hint: Text; - private mapLayer = new Container(); + private actors = new IsoDepthLayer(); + private hint: Container; + private ash: ParticleEmitter; private camera: Camera; - constructor( - private game: Game, - save: SaveData | null - ) { - this.camera = game.camera; - + constructor(private game: Game, save: SaveData | null) { + this.camera = game.engine.camera; const data = buildMeadowsMap(); this.map = new IsometricTileMap(data, this.tileTextures(), DEFAULT_ISO); - this.mapLayer.addChild(this.map.view); - this.game.renderer.worldRoot.addChild(this.mapLayer); + this.world.addChild(this.map.view, this.actors); + this.game.renderer.worldRoot.addChild(this.world); const startTile = save?.pos ?? { x: 14, y: 14 }; - this.player = new PlayerController(this.world, this.map, this.heroTextures(), startTile); - this.mapLayer.addChild(this.player.view); + if (save?.state) this.game.state.load(save.state); + this.player = new PlayerController(this.map, this.heroTextures(), startTile); + // Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе). + this.actors.add(this.player.view, startTile.x, startTile.y); for (const def of NPCS) { const view = this.makeNpcView(def); - this.mapLayer.addChild(view); + this.actors.add(view, def.tile.x, def.tile.y); this.npcs.push({ def, view }); } - this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, { + this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, this.game.state, { width: 480, height: 270, margin: 8 }); this.dialogue.onDialogueFinished = (id) => this.onDialogueFinished(id); - if (save?.flags) { - Object.assign(this.dialogue.flags, save.flags); - } + + // Пепел над лугами: медленные серые точки в воздухе. + this.ash = new ParticleEmitter({ + color: 0x666677, + rate: 5, + lifetime: [4, 9], + velocity: { x: [-9, -3], y: [-2, 2] }, + size: 1, + spawnArea: { width: 520, height: 300 }, + seed: 20260905 + }); + this.ash.position.set(240, 120); + this.game.renderer.worldRoot.addChild(this.ash); // Камера: следим за героем; ромб карты уходит в минус по X — // границы начинаются от его западного угла. @@ -69,53 +83,63 @@ width: size.width, height: size.height }; - const tile = this.player.currentTile(); - const c = this.tileCenter(tile.x, tile.y); - this.camera.follow(c.x, c.y); + this.updateCameraFollow(); - this.hint = new Text({ + this.hint = new Container(); + const text = new PixelText({ text: 'клик — идти · клик по NPC — говорить · Esc — меню', - style: { fontFamily: 'monospace', fontSize: 8, fill: 0x999988 } + size: 8, + color: 0x999988 }); - this.hint.position.set(6, 4); + text.position.set(6, 4); + this.hint.addChild(text); this.game.renderer.uiRoot.addChild(this.hint); } enter(): void {} exit(): void { - this.mapLayer.destroy({ children: true }); - this.hint.destroy(); + this.ash.clear(); + this.world.destroy({ children: true }); + this.ash.destroy(); + this.hint.destroy({ children: true }); } update(dt: number): void { + this.ash.update(dt); const input = this.game.engine.input; if (this.dialogue.active) { // Во время диалога клик/пробел только листают реплики. if (input.getPointer().justPressed || input.isActionJustPressed('advance')) { this.dialogue.advance(); } - } else { - if (input.isActionJustPressed('menu')) { - this.saveAndExit(); - return; - } - const pointer = input.getPointer(); - if (pointer.justPressed) { - this.handleWorldClick(pointer.x, pointer.y); - } + return; + } + if (this.game.engine.scenes.transitioning) return; + + if (input.isActionJustPressed('menu')) { + this.saveAndExit(); + return; + } + const pointer = input.getPointer(); + if (pointer.justPressed) { + this.handleWorldClick(pointer.x, pointer.y); } this.player.update(dt); + const tile = this.player.currentTile(); + this.actors.setDepth(this.player.view, tile.x, tile.y); + this.updateCameraFollow(); + } - // Камера за героем. + render(): void {} + + private updateCameraFollow(): void { const tile = this.player.currentTile(); const c = this.tileCenter(tile.x, tile.y); this.camera.follow(c.x, c.y); } - render(): void {} - /** Текстуры тайлов из загруженных ассетов (id -> Texture). */ private tileTextures(): Map { const a = this.game.assets; @@ -129,13 +153,14 @@ ]); } - /** Кадры героя из ассетов. */ + /** Кадры героя из атласа chars/hero_sheet. */ private heroTextures(): HeroTextures { - const a = this.game.assets; + const frames = (prefix: string) => + this.game.assets.frames('chars/hero_sheet.json', prefix) as [Texture, Texture]; return { - down: [a.texture('chars/hero_down_1'), a.texture('chars/hero_down_2')], - up: [a.texture('chars/hero_up_1'), a.texture('chars/hero_up_2')], - side: [a.texture('chars/hero_side_1'), a.texture('chars/hero_side_2')] + down: frames('hero_down'), + up: frames('hero_up'), + side: frames('hero_side') }; } @@ -172,15 +197,14 @@ } private talkTo(def: NpcDef): void { - const met = !!this.dialogue.flags[def.flagKey]; - if (!met) { - this.dialogue.flags[def.flagKey] = true; - } - this.dialogue.start(met ? def.dialogueRepeat : def.dialogueFirst); + const met = this.game.state.hasFlag(def.flagKey); + if (!met) this.game.state.setFlag(def.flagKey); + const id = met ? def.dialogueRepeat : def.dialogueFirst; + this.dialogue.start(DIALOGUES[id], id); } private onDialogueFinished(_id: string): void { - // Триггеры сюжета вешаются на id завершённого диалога (позже). + // Триггеры сюжета вешаются на id завершённого диалога (квесты — по флагам GameState). } private makeNpcView(def: NpcDef): Container { @@ -204,8 +228,9 @@ const pos = this.player.currentTile(); this.game.saves.save('autosave', { pos, - flags: this.dialogue.flags + state: this.game.state.serialize(), + savedAt: Date.now() } satisfies SaveData); - void this.game.scenes.replace(new MenuScene(this.game)); + void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); } } \ No newline at end of file diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts index 8298325..ec7cfab 100644 --- a/apps/game/src/scenes/MenuScene.ts +++ b/apps/game/src/scenes/MenuScene.ts @@ -1,81 +1,83 @@ -import { Container, Text } from 'pixi.js'; -import type { Scene } from '@rpg/engine'; +import { Container, MenuList, PixelText, type GameStateData, type Scene } from '@rpg/engine'; import type { Game } from '../Game'; import { LocationScene } from './LocationScene'; /** * Главное меню: название, «новая игра», «продолжить» (если есть сейв). + * Список на движковом MenuList: мышь и клавиатура/геймпад. */ export class MenuScene implements Scene { private view = new Container(); + private menu: MenuList | null = null; constructor(private game: Game) {} enter(): void { - const title = new Text({ + const title = new PixelText({ text: 'ПЕПЕЛЬНЫЕ ЛУГА', - style: { fontFamily: 'monospace', fontSize: 24, fill: 0xd8c79a } + size: 28, + color: 0xd8c79a }); title.anchor.set(0.5); - title.position.set(240, 70); + title.position.set(240, 66); this.view.addChild(title); - const subtitle = new Text({ + const subtitle = new PixelText({ text: 'пепел всё ещё дышит', - style: { fontFamily: 'monospace', fontSize: 8, fill: 0x8a8a9a } + size: 10, + color: 0x8a8a9a }); subtitle.anchor.set(0.5); - subtitle.position.set(240, 96); + subtitle.position.set(240, 94); this.view.addChild(subtitle); - this.addMenuItem('Новая игра', 140, () => { - void this.game.scenes.replace(new LocationScene(this.game, null)); - }); - - if (this.game.saves.listSlots().length > 0) { - this.addMenuItem('Продолжить', 160, () => { - const save = this.game.saves.load('autosave'); - void this.game.scenes.replace(new LocationScene(this.game, save)); + const items = [ + { + label: 'Новая игра', + onSelect: () => this.start(null) + } + ]; + if (this.game.saves.has('autosave')) { + items.unshift({ + label: 'Продолжить', + onSelect: () => { + const save = this.game.saves.load('autosave'); + this.start(save); + } }); } - const hint = new Text({ + this.menu = new MenuList({ width: 120, height: 16, gap: 3 }); + this.menu.setItems(items); + this.menu.position.set(180, 130); + this.view.addChild(this.menu); + + const hint = new PixelText({ text: 'клик по тайлу — идти · клик по NPC — говорить\nEsc в игре — меню с сохранением', - style: { - fontFamily: 'monospace', - fontSize: 8, - fill: 0x666677, - align: 'center', - lineHeight: 10 - } + size: 8, + color: 0x666677, + align: 'center', + lineHeight: 12, + wordWrapWidth: 400 }); hint.anchor.set(0.5); - hint.position.set(240, 230); + hint.position.set(240, 234); this.view.addChild(hint); this.game.renderer.uiRoot.addChild(this.view); } - private addMenuItem(label: string, y: number, onClick: () => void): void { - const item = new Text({ - text: label, - style: { fontFamily: 'monospace', fontSize: 12, fill: 0x9fb7a4 } - }); - item.anchor.set(0.5); - item.position.set(240, y); - item.eventMode = 'static'; - item.cursor = 'pointer'; - item.on('pointerdown', onClick); - item.on('pointerover', () => { - item.style.fill = 0xe8f0e9; - }); - item.on('pointerout', () => { - item.style.fill = 0x9fb7a4; - }); - this.view.addChild(item); + private start(save: SaveData | null): void { + void this.game.scenes.replace(new LocationScene(this.game, save), { duration: 0.4 }); } - update(_dt: number): void {} + update(_dt: number): void { + if (!this.menu || this.game.scenes.transitioning) return; + const input = this.game.engine.input; + if (input.isActionJustPressed('up')) this.menu.moveCursor(-1); + if (input.isActionJustPressed('down')) this.menu.moveCursor(1); + if (input.isActionJustPressed('advance')) this.menu.activate(); + } render(): void {} @@ -84,8 +86,9 @@ } } -/** Формат автосейва. */ +/** Формат автосейва: позиция героя + сериализованное состояние прохождения. */ export interface SaveData { pos: { x: number; y: number }; - flags: Record; + state: GameStateData; + savedAt: number; } \ No newline at end of file diff --git a/apps/game/src/systems/DialogueSystem.ts b/apps/game/src/systems/DialogueSystem.ts index 24c414c..fe55ef3 100644 --- a/apps/game/src/systems/DialogueSystem.ts +++ b/apps/game/src/systems/DialogueSystem.ts @@ -1,54 +1,56 @@ -import { Container } from 'pixi.js'; -import { DialogueBox, type DialogueBoxOptions } from '@rpg/engine'; -import { DIALOGUES } from '../data/dialogues'; +import { + Container, + DialogueBox, + DialogueRunner, + GameState, + type DialogueBoxOptions, + type DialogueGraph +} from '@rpg/engine'; /** - * Диалоговая система: держит активный диалог, поднимает флаги сюжета, - * листает реплики по клику/пробелу. Ничего не знает о рендере мира. + * Диалоги игры: DialogueRunner движка ходит по графам и применяет флаги + * к GameState, DialogueBox рисует реплики и варианты. */ export class DialogueSystem { private box: DialogueBox; - private activeId: string | null = null; - private lineIndex = 0; + private runner: DialogueRunner; - /** Флаги сюжета (сохраняются в сейве). */ - readonly flags: Record = {}; /** Сюжетное событие «диалог завершён» — для триггеров/квестов. */ onDialogueFinished: ((id: string) => void) | null = null; + /** id стартованного диалога (для onFinish). */ + private currentId: string | null = null; - constructor(uiRoot: Container, options: DialogueBoxOptions) { - this.box = new DialogueBox(options); + constructor(uiRoot: Container, state: GameState, options: DialogueBoxOptions) { + this.box = new DialogueBox({ + ...options, + onChoice: (index) => this.runner.pick(index) + }); uiRoot.addChild(this.box.view); + + this.runner = new DialogueRunner(state); + this.runner.setView({ + show: ({ speaker, text, choices }) => this.box.show({ speaker, text, choices }), + hide: () => this.box.hide() + }); } get active(): boolean { - return this.activeId !== null; + return this.runner.active; } - start(dialogueId: string): void { - const dialogue = DIALOGUES[dialogueId]; - if (!dialogue) throw new Error(`Нет диалога: ${dialogueId}`); - this.activeId = dialogueId; - this.lineIndex = 0; - this.box.show(dialogue.lines[0]); + /** Начать диалог по ключу из data/dialogues.ts. */ + start(graph: DialogueGraph, id: string): void { + this.currentId = id; + this.runner.onFinish = () => { + const id = this.currentId; + this.currentId = null; + if (id) this.onDialogueFinished?.(id); + }; + this.runner.start(graph); } - /** Проклик/пробел: следующая реплика или закрытие. */ + /** Клик/пробел: следующая реплика (во время выбора игнорируется). */ advance(): void { - if (!this.activeId) return; - const dialogue = DIALOGUES[this.activeId]; - this.lineIndex++; - if (this.lineIndex < dialogue.lines.length) { - this.box.show(dialogue.lines[this.lineIndex]); - } else { - this.close(); - } - } - - private close(): void { - const id = this.activeId; - this.activeId = null; - this.box.hide(); - this.onDialogueFinished?.(id!); + this.runner.advance(); } } \ No newline at end of file diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index 8050742..bf84b27 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -1,5 +1,4 @@ import { - World, IsometricTileMap, findPath, isoToScreen, @@ -10,7 +9,6 @@ Sprite, Texture, DEFAULT_ISO, - type Entity, type Vec2 } from '@rpg/engine'; @@ -28,7 +26,6 @@ * покадровая анимация ходьбы по направлению движения. */ export class PlayerController { - readonly entity: Entity; readonly view: Container; private sprite: Sprite; @@ -44,13 +41,10 @@ private speed = 56; constructor( - world: World, private map: IsometricTileMap, textures: HeroTextures, startTile: { x: number; y: number } ) { - this.entity = world.createEntity(); - world.addComponent(this.entity, 'pos', { ...startTile }); this.textures = textures; this.view = new Container(); diff --git a/apps/game/vite.config.ts b/apps/game/vite.config.ts index 1877057..315b6fe 100644 --- a/apps/game/vite.config.ts +++ b/apps/game/vite.config.ts @@ -3,10 +3,16 @@ // Движок — TS-исходники workspace-пакета: алиас гарантирует, что Vite // собирает их как исходный код, а не как предсобранный пакет. +// publicDir = apps/game/assets: PNG-ассеты раздаются с корня и копируются в dist. export default defineConfig({ + publicDir: 'assets', resolve: { - alias: { - '@rpg/engine': fileURLToPath(new URL('../../packages/engine/src/index.ts', import.meta.url)) - } + alias: [ + // Точный алиас: под-пути (@rpg/engine/assets/*) идут через exports пакета. + { + find: /^@rpg\/engine$/, + replacement: fileURLToPath(new URL('../../packages/engine/src/index.ts', import.meta.url)) + } + ] } }); \ No newline at end of file diff --git a/docs/engine/README.md b/docs/engine/README.md new file mode 100644 index 0000000..ce45bf5 --- /dev/null +++ b/docs/engine/README.md @@ -0,0 +1,84 @@ +# Документация движка @rpg/engine + +Движок — переиспользуемая основа для 2D-пиксельных игр в браузере. Первая игра на нём +— «Пепельные луга» (`apps/game`), она же живой пример использования всех API. + +## Карта документации + +| Файл | О чём | +|---|---| +| [getting-started.md](getting-started.md) | Новая игра с нуля: bootstrap, сцены, ассеты | +| [core.md](core.md) | Engine, GameLoop, EventBus, Tween, GameState, StateMachine, Settings | +| [render.md](render.md) | Renderer, pixel-perfect, камера, IsoDepthLayer, частицы | +| [input.md](input.md) | Действия, клавиатура, мышь/тач, геймпад, виртуальный джойстик | +| [maps.md](maps.md) | Изометрия, A*, формат карт, импорт из Tiled | +| [ui-and-dialogue.md](ui-and-dialogue.md) | UI-кит, PixelText, диалоговые графы | +| [assets-audio-save.md](assets-audio-save.md) | AssetLoader, атласы, AudioManager, сейвы | +| [art-pipeline.md](art-pipeline.md) | Генератор пиксель-арта, палитра, замена на рисованный арт | +| [recipes.md](recipes.md) | «Как сделать…»: готовые решения типовых задач | + +## Принципы + +### 1. Жёсткая граница API + +Игра импортирует движок **только** через `@rpg/engine` (весь API реэкспортирован из +`packages/engine/src/index.ts`). Прямые импорты `pixi.js` в игре — нарушение границы: +нужные типы реэкспортируются движком (`Container`, `Graphics`, `Text`, `Sprite`, +`Texture`). Это позволяет менять внутренности движка, не трогая игры. + +### 2. Fixed timestep + +Логика обновляется фиксированным шагом 60 Гц (`GameLoop`, аккумулятор, максимум +5 шагов за кадр). `dt` в `Scene.update` всегда одинаковый — физика и таймеры +детерминированы. Рендер происходит после каждого шага. + +### 3. Pixel-perfect + +- Виртуальное разрешение (по умолчанию 480×270) растягивается **целым** числом + на экран (`computeScale`), `image-rendering: pixelated`, `roundPixels: true`. +- Камера округляет позицию до целого пикселя — арт не «дрожит». +- Пиксельный шрифт (VT323) рисуется в тех же виртуальных пикселях. + +### 4. Движок жанронезависим + +В `packages/engine` нет ни одного упоминания контента конкретной игры: `GameState` +— механика флагов и переменных, `DialogueRunner` — механика графов. Всё содержимое +(тексты, флаги сюжета, карты) живёт в приложении. + +### 5. Инъекция вместо окружения + +Всё, что зависит от браузера, инъецируется или изолируется: `StorageLike` для сейвов, +`resolveUrl` для ассетов, `parent` для канваса. Чистые модули (математика, A*, диалоги, +формат карт) тестируются в Node без браузера — Vitest. + +## Структура пакетов + +``` +packages/engine/src/ + core/ Engine, GameLoop, EventBus, Tween+easing, GameState, StateMachine, Settings + scene/ SceneManager (стек сцен + fade-переходы) + render/ Renderer, Camera, IsoDepthLayer, Particles, computeScale + input/ InputManager (клавиатура/мышь/тач/геймпад), VirtualJoystick + map/ IsometricTileMap, pathfinding (A*), mapFormat (JSON+RLE), tiled-импортёр + math/ Vec2, изометрия, seeded RNG + dialogue/ DialogueRunner (графы диалогов, view-агностик) + ui/ DialogueBox, Panel, Button, MenuList, PixelText + anim/ FrameAnimation + audio/ AudioManager (шины master/music/sfx, кроссфейд) + assets/ AssetLoader (текстуры, атласы) + save/ SaveManager (JSON-слоты) + ecs/ World/createEntity/query/addSystem + debug/ DebugOverlay +``` + +## Известные грабли (важно!) + +Подробности — в корневом `CLAUDE.md`; краткий список: + +- `Assets.load` без предварительного `Assets.init({})` **висит навсегда** без ошибок — + `AssetLoader` делает это сам, но если грузите напрямую через Pixi, не забудьте. +- `app.renderer` недоступен до завершения `app.init()` — `Renderer.setup()` это учитывает. +- Изометрический ромб уходит в минус по X: границы камеры начинаются от + западного угла карты (`{x: -size.width/2, ...}`). +- `document.fonts` — шрифт загружается асинхронно; `ensurePixelFont()` вызывается + до создания текста, но фолбэк monospace всегда безопасен. \ No newline at end of file diff --git a/docs/engine/art-pipeline.md b/docs/engine/art-pipeline.md new file mode 100644 index 0000000..423e875 --- /dev/null +++ b/docs/engine/art-pipeline.md @@ -0,0 +1,77 @@ +# Арт-пайплайн: генерация пиксель-арта из кода + +Весь арт первой игры генерируется кодом: `tools/pixelart/*.mjs` рисуют спрайты +на собственном Canvas и кодируют PNG без сторонних зависимостей (`node:zlib` + +свой CRC32). Позже генератор можно заменить на рисованный арт — движку всё равно, +откуда текстуры. + +## Файлы + +| Файл | Что делает | +|---|---| +| `tools/pixelart/palette.mjs` | Палитра из 32 цветов (hex + RGBA) | +| `tools/pixelart/canvas.mjs` | Canvas: set/get пикселя, `fillDiamond` (изо-ромб), `fromAscii`, seeded rng | +| `tools/pixelart/png.mjs` | `encodePng` — сборка PNG вручную | +| `tools/pixelart/gen.mjs` | Генерация всех спрайтов игры → `apps/game/assets/` | + +## Запуск + +```bash +npm run art # node tools/pixelart/gen.mjs — перегенерировать все PNG +``` + +Выходные файлы кладутся в `apps/game/assets/` и грузятся в игре по ключам +(`tiles/grass`, `chars/hero_down_1`, ...). Список ключей — в `apps/game/src/Game.ts`. + +## Палитра и правила + +- 32 цвета, тёмная холодная база; **тёплые цвета — только живое** (герой, NPC, + колокольчики, огонь). Это правило сеттинга «Пепельных лугов» — см. `docs/art-style.md`. +- Размеры: тайл 32×16 (изометрия 2:1), персонаж ~16×24, высокое дерево 32×48. +- Пиксель перо = 1 виртуальный пиксель; никаких сглаженных краёв — контуры ступенчатые. + +## ASCII-спрайты + +Персонажи задаются ASCII-картами (символ → цвет палитры) — читается и правится +прямо в коде: + +```js +const heroDown = fromAscii(` + ....0000.... + ...011111... + ... +`, { '0': 'P4', '1': 'F2' }); +``` + +## Процедурные тайлы + +Тайлы земли рисуются шумом (seeded rng) + ромбы `fillDiamond` (свет — верхние склоны, +тень — нижние). Дерево: «облачная» крона из перекрывающихся окружностей, ствол, +свисающий кабель — детали сеттинга. + +## Атласы + +Если спрайтов много (анимации), генератор может собрать атлас: кадры в один PNG + +Spritesheet JSON. Формат Pixi: + +```js +const atlas = { + frames: { + hero_walk_1: { frame: { x: 0, y: 0, w: 16, h: 24 } }, + hero_walk_2: { frame: { x: 16, y: 0, w: 16, h: 24 } } + }, + meta: { image: 'hero_sheet.png', size: { w: 32, h: 24 }, scale: 1 } +}; +``` + +Загрузка в игре — `assets.loadAtlas('chars/hero_sheet')`, кадры — `assets.frames(key, 'hero_walk')`. + +## Замена генератора на рисованный арт + +Движку не важно происхождение текстур. Чтобы перейти на рисованный арт: + +1. Рисуете PNG того же размера (или атлас + JSON) в `apps/game/assets/`. +2. Ключи ассетов не меняются — код игры не трогается. +3. Палитру соблюдаете, чтобы арт не выбивался из сеттинга (`docs/art-style.md`). + +Aseprite/Piskel: экспорт в PNG с теми же именами файлов, что даёт генератор. \ No newline at end of file diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md new file mode 100644 index 0000000..1ebda20 --- /dev/null +++ b/docs/engine/assets-audio-save.md @@ -0,0 +1,108 @@ +# Ассеты, аудио, сейвы + +## AssetLoader + +Загрузчик поверх `Assets` из Pixi. URL решает приложение — движок получает функцию-резолвер: + +```ts +import { AssetLoader } from '@rpg/engine'; + +const assets = new AssetLoader((key) => `assets/${key}.png`); +await assets.load(['tiles/grass', 'chars/hero_down_1'], (p) => setProgressBar(p)); + +assets.texture('tiles/grass'); // Texture; бросает, если не загружено +assets.has('tiles/water'); +``` + +**Грабли**: `Assets.load` без предварительного `Assets.init({})` висит навсегда +без ошибок в консоли. AssetLoader делает `ensureInit()` сам. + +### Атласы + +```ts +// Spritesheet JSON должен ссылаться на PNG относительным путём — Pixi загрузит сам +const sheet = await assets.loadAtlas('chars/hero_sheet'); + +assets.frames('chars/hero_sheet', 'hero_walk'); // кадры hero_walk_1, hero_walk_2, ... +assets.animation('chars/hero_sheet', 'walk'); // готовая анимация из sheet.animations +``` + +`frames()` сортирует кадры по числу в конце имени. Пример генерации атласа — +в [art-pipeline.md](art-pipeline.md). + +## FrameAnimation + +```ts +import { FrameAnimation } from '@rpg/engine'; + +const sprite = new Sprite(frames[0]); +const anim = new FrameAnimation(sprite, frames, 8 /* fps */, true /* loop */); +anim.update(dt); // каждый тик +anim.setFrames(otherFrames, true); // сменить кадры (поворот, состояние) +``` + +## AudioManager + +Три шины (master/music/sfx), кроссфейд музыки, кэш сэмплов: + +```ts +import { AudioManager } from '@rpg/engine'; + +const audio = new AudioManager((key) => `audio/${key}.mp3`); + +// Расблокировать из обработчика пользовательского ввода (требование браузеров): +uiButton.onSelect = () => void audio.unlock(); + +await audio.load('bell'); +await audio.play('bell', 0.8); // sfx +await audio.playMusic('meadows_theme', { fade: 2 }); // зациклится с нарастанием 2 сек +audio.stopMusic(1); // затухание 1 сек +audio.setBusVolume('music', 0.6); // громкость шины +``` + +Связка с Settings (громкости применяются при изменении): + +```ts +settings.onChange((s) => { + audio.setBusVolume('master', s.master); + audio.setBusVolume('music', s.music); + audio.setBusVolume('sfx', s.sfx); +}); +``` + +`play`/`playMusic` не падают, если звук не загружен или AudioContext не разблокирован — +тихо ничего не делают. + +## SaveManager + +JSON-слоты в Storage-подобном хранилище (localStorage в браузере, Map в тестах): + +```ts +import { SaveManager } from '@rpg/engine'; + +const saves = new SaveManager(localStorage); +saves.save('autosave', { pos: { x: 5, y: 5 }, state: gameState.serialize() }); +const data = saves.load('autosave'); +saves.has('autosave'); +saves.delete('autosave'); +``` + +Что класть в сейв: позицию/прогресс игры + `gameState.serialize()`. +Настройки — отдельно через `Settings` (слот `settings`), в сейв они не попадают. + +### Рекомендуемая схема сейва игры + +```ts +interface SaveData { + pos: { x: number; y: number }; + state: GameStateData; // из gameState.serialize() + savedAt: number; // Date.now() +} + +// Загрузка: +const data = saves.load('autosave'); +if (data) gameState.load(data.state); +``` + +Версионирование контента — через `GameState.dataVersion` + `addMigration`: +при изменении формата флагов старые сейвы чинятся автоматически при загрузке. \ No newline at end of file diff --git a/docs/engine/core.md b/docs/engine/core.md new file mode 100644 index 0000000..d87f055 --- /dev/null +++ b/docs/engine/core.md @@ -0,0 +1,111 @@ +# Ядро: Engine, GameLoop, EventBus, Tween, GameState, StateMachine, Settings + +## Engine + +Собирает все подсистемы и владеет игровым циклом: + +```ts +const engine = new Engine({ virtualWidth: 480, virtualHeight: 270, scale: 3, background: 0x0b0b12, fixedFps: 60, parent }); +await engine.init(); // WebGL, ввод, оверлей fade-переходов +await engine.start(); // запуск цикла +``` + +Доступные подсистемы: `engine.renderer`, `engine.camera`, `engine.scenes`, +`engine.input`, `engine.tweens`, `engine.events`. + +Опции: `autoResize` (по умолчанию true) — пересчитывать целочисленный масштаб +при ресайзе окна. + +## GameLoop (fixed timestep) + +Логика — фиксированный шаг 60 Гц; рендер после каждого шага. `dt` в `Scene.update(dt)` +всегда `1/60`. После лага («вкладка спала») выполняется максимум 5 шагов за кадр — +игра замедляется, а не «телепортируется». + +Вручную цикл обычно не трогают; Engine.tick делает по порядку: +`input.update()` (опрос геймпада) → `tweens.update(dt)` → `scenes.update(dt)` → `input.endTick()`. + +## EventBus + +Типизированные по темам события — связь систем без прямых зависимостей: + +```ts +engine.events.on<{ gold: number }>('loot:picked', ({ gold }) => console.log(gold)); +const off = engine.events.on('quest:done', () => { ... }); +engine.events.emit('loot:picked', { gold: 5 }); +off(); // отписка +``` + +## Tween (твины и таймеры) + +Обновляется циклом автоматически. Все методы возвращают handle с `cancel()`. + +```ts +engine.tweens.to(sprite, { x: 100, alpha: 1 }, { + duration: 0.5, + ease: cubicOut, // linear, quadIn/Out, cubicIn/Out, sineInOut + delay: 0.2, // стартовые значения снимаются ПОСЛЕ delay + onDone: () => console.log('готово') +}); + +engine.tweens.delay(1.5, () => spawnEnemy()); // таймер, сек +engine.tweens.cancelFor(sprite); // отменить всё для объекта +``` + +Гарантии: значения не выходят за цель (зажим на последнем кадре), `onDone` вызывается +один раз, тик, пересекающий конец `delay`, не «съедает» часть движения. + +## GameState + +Состояние прохождения: флаги и именованные переменные. Жанронезависимо — какие флаги +есть, решает игра. Сериализуется в сейв (`GameStateData`). + +```ts +const state = new GameState(); +state.setFlag('met_elder'); +state.setVar('gold', 30); +state.getNumber('gold'); // 30 (getString/getBool — аналогично) +state.hasFlag('met_elder'); // true + +// Сейв/загрузка + миграции при изменении формата: +state.dataVersion = 2; +state.addMigration((d) => ({ ...d, version: 2, vars: { ...d.vars, gold: 0 } })); +const data = state.serialize(); // положить в сейв +state.load(data); // миграции применятся автоматически +``` + +## StateMachine + +Конечный автомат для состояний сущностей (idle/walk/attack), AI и т.п.: + +```ts +const sm = new StateMachine((state, event) => console.warn(`${state} не знает событие ${event}`)); +sm.add('idle', { enter, exit, update: (dt) => {...} }); +sm.add('chase', { enter, exit, update }); +sm.transition('idle', 'seePlayer', 'chase'); +sm.transition('chase', 'losePlayer', 'idle'); + +sm.change('idle'); // exit старого -> enter нового +sm.handleEvent('seePlayer'); // переход по таблице +sm.update(dt); // тикает текущее состояние; sm.time — время в состоянии +``` + +## Settings + +Настройки игрока (громкости, язык, прочее) в отдельном слоте хранилища — не попадают +в игровые сейвы: + +```ts +const settings = new Settings(localStorage, 'settings'); // или любой StorageLike +settings.load(); + +settings.update({ master: 0.8, music: 0.6 }); +settings.data.master; // 0.8 +settings.update({ extra: { hintSeen: true } }); +settings.getExtra('hintSeen'); + +const off = settings.onChange((s) => audio.setBusVolume('master', s.master)); +``` + +Хранилище может быть недоступно (приватный режим) — Settings работает в памяти, +не падает. \ No newline at end of file diff --git a/docs/engine/getting-started.md b/docs/engine/getting-started.md new file mode 100644 index 0000000..97f5092 --- /dev/null +++ b/docs/engine/getting-started.md @@ -0,0 +1,141 @@ +# Getting started: новая игра с нуля + +Минимальный набор шагов, чтобы получить работающее приложение на движке. + +## 1. Структура + +``` +apps/my-game/ + index.html + vite.config.ts # alias @rpg/engine -> ../../packages/engine/src/index.ts + src/ + main.ts # bootstrap + Game.ts # общий контекст игры (опционально, но удобно) + scenes/ # сцены игры +``` + +`vite.config.ts`: + +```ts +import { defineConfig } from 'vite'; +export default defineConfig({ + resolve: { + alias: { '@rpg/engine': '../../packages/engine/src/index.ts' } + } +}); +``` + +## 2. Bootstrap (main.ts) + +```ts +import { Engine, computeScale, type EngineOptions } from '@rpg/engine'; +import { BootScene } from './scenes/BootScene'; + +const VW = 480, VH = 270; // виртуальное разрешение + +async function main(): Promise { + const container = document.getElementById('game')!; + + const options: EngineOptions = { + virtualWidth: VW, + virtualHeight: VH, + scale: computeScale(VW, VH, window.innerWidth, window.innerHeight), + background: 0x0b0b12, + fixedFps: 60, + parent: container + }; + + const engine = new Engine(options); + await engine.init(); // WebGL + ввод + оверлей переходов + engine.input.bindActions({ // «действия» вместо кодов клавиш + advance: ['Space', 'Enter'], + menu: ['Escape'], + up: ['KeyW', 'ArrowUp'], + down: ['KeyS', 'ArrowDown'] + }); + engine.input.bindGamepad({ // те же действия на геймпаде (кнопки стандартной карты) + advance: [0], // A + menu: [9], // Start + up: [12], + down: [13] + }); + + await engine.scenes.push(new BootScene(engine)); + await engine.start(); +} + +void main(); +``` + +Ресайз окна обрабатывается автоматически (`autoResize: true` по умолчанию): +масштаб пересчитывается, виртуальное разрешение не меняется. + +## 3. Сцены + +Сцена — экран игры. Минимальная: + +```ts +import { Container, Text, type Scene } from '@rpg/engine'; + +export class TitleScene implements Scene { + private view = new Container(); + + constructor(engine: Engine) { + const t = new Text({ text: 'Моя игра', style: { fill: 0xffffff, fontSize: 16, fontFamily: 'monospace' } }); + this.view.addChild(t); + engine.renderer.uiRoot.addChild(this.view); + } + + enter(): void {} + exit(): void { this.view.destroy({ children: true }); } + update(dt: number): void { + if (engine.input.isActionJustPressed('advance')) { + void engine.scenes.replace(new GameScene(engine), { duration: 0.4 }); + } + } + render(): void {} +} +``` + +Стек сцен: `push` (открыть поверх), `pop` (закрыть), `replace` (заменить верхнюю). +Любой метод принимает `{ duration, color }` — fade-затемнение между сценами. + +## 4. Ассеты + +```ts +import { AssetLoader } from '@rpg/engine'; + +const assets = new AssetLoader((key) => `assets/${key}.png`); +await assets.load(['tiles/grass', 'chars/hero'], (p) => console.log(`${Math.round(p * 100)}%`)); +const tex = assets.texture('tiles/grass'); + +// Атлас (Spritesheet JSON + PNG): +const sheet = await assets.loadAtlas('chars/hero_sheet'); +const walkFrames = assets.frames('chars/hero_sheet', 'hero_walk'); +``` + +Смотрите [assets-audio-save.md](assets-audio-save.md). + +## 5. Шрифт + +```ts +import { ensurePixelFont, PixelText } from '@rpg/engine'; + +await ensurePixelFont('fonts/VT323-Regular.ttf'); // до создания текста +const label = new PixelText({ text: 'Привет', size: 10, color: 0xf0d878 }); +``` + +## 6. Сборка + +```bash +npm run dev # dev-сервер +npm run build # прод-сборка +npm run typecheck # движок + игра +npm test # Vitest +``` + +## Что дальше + +- [core.md](core.md) — игровой цикл, твины, состояние, настройки +- [maps.md](maps.md) — если игра с картами/движением по тайлам +- [recipes.md](recipes.md) — готовые решения типовых задач \ No newline at end of file diff --git a/docs/engine/input.md b/docs/engine/input.md new file mode 100644 index 0000000..59eac4d --- /dev/null +++ b/docs/engine/input.md @@ -0,0 +1,83 @@ +# Ввод: действия, клавиатура, мышь/тач, геймпад + +## Действия + +InputManager работает с **действиями** (семантическими именами), а не с кодами клавиш. +Игра один раз задаёт маппинг — дальше везде только `isActionActive` / `isActionJustPressed`: + +```ts +engine.input.bindActions({ + advance: ['Space', 'Enter'], + menu: ['Escape'], + up: ['KeyW', 'ArrowUp'], + down: ['KeyS', 'ArrowDown'], + left: ['KeyA', 'ArrowLeft'], + right: ['KeyD', 'ArrowRight'] +}); + +engine.input.isActionJustPressed('advance'); // нажато в этом тике +engine.input.isActionActive('up'); // зажато +``` + +## Геймпад + +Стандартная карта кнопок (0=A, 1=B, 8=Select, 9=Start, 12–15 — крестовина). +Опрашивается автоматически в начале каждого тика (`input.update()` в Engine.tick): + +```ts +engine.input.bindGamepad({ + advance: [0], // A + menu: [9], // Start + up: [12], down: [13], left: [14], right: [15] +}); + +engine.input.getLeftStick(); // { x, y } в -1..1 (мёртвая зона 0.15) или null +``` + +Один и тот же код работает и с клавиатурой, и с геймпадом — действие единое. + +## Указатель (мышь/тач) + +Координаты указателя пересчитываются в **виртуальные** пиксели: + +```ts +const p = engine.input.getPointer(); +p.x, p.y; // виртуальные пиксели +p.justPressed; // нажато в этом тике +p.justReleased; // отпущено в этом тике +p.down; // зажато +p.isTouch; // это тач (для мобильного управления) +``` + +## Виртуальный джойстик (тач) + +Для мобильных: джойстик появляется там, где игрок коснулся экрана. Добавляется +в uiRoot сцены; опрашивается вектор: + +```ts +import { VirtualJoystick } from '@rpg/engine'; + +const joystick = new VirtualJoystick({ + screen: { width: 480, height: 270 }, + radius: 28 +}); +engine.renderer.uiRoot.addChild(joystick); + +// в update сцены: +const v = joystick.getVector(); // { x, y } в -1..1; {0,0} если не активен +player.moveBy(v.x * speed * dt, v.y * speed * dt); +``` + +Джойстик сам обрабатывает pointer-события (`eventMode: 'static'`, hitArea на весь экран) +и скрывает ввод «клик по миру», пока активен. + +## Жизненный цикл тика + +`justPressed`-флаги очищаются в `input.endTick()` — Engine вызывает его в конце +каждого тика. Не вызывайте `endTick` вручную из сцены, иначе пропустите нажатия. + +## Раскладки + +Используйте `e.code`-имена (`KeyW`, а не `KeyЫ`) — раскладка не важна. Для +перенастраиваемых клавиш храните маппинг в Settings (`extra`) и вызывайте +`bindActions` заново при изменении. \ No newline at end of file diff --git a/docs/engine/maps.md b/docs/engine/maps.md new file mode 100644 index 0000000..5753fa7 --- /dev/null +++ b/docs/engine/maps.md @@ -0,0 +1,88 @@ +# Карты: изометрия, A*, формат карт, Tiled + +## Изометрия 2:1 + +Тайл 32×16 (`DEFAULT_ISO`). Тайловые координаты всегда `{x, y}` (целые), экранные — `{x, y}` в виртуальных пикселях: + +```ts +import { isoToScreen, screenToIso, screenToIsoExact, DEFAULT_ISO } from '@rpg/engine'; + +isoToScreen(tx, ty); // экранные координаты ВЕРХНЕЙ вершины ромба +screenToIso(px, py); // приближённый тайл (floor) +screenToIsoExact(px, py, w, h); // точное попадание в ромб или null (для кликов) +``` + +Клик по миру: сначала переведите экранные координаты в мировые (позиция worldRoot +учитывает камеру), затем `screenToIsoExact`: + +```ts +const worldX = pointer.x - engine.renderer.worldRoot.position.x; +const worldY = pointer.y - engine.renderer.worldRoot.position.y; +const tile = screenToIsoExact(worldX, worldY, map.width, map.height); +``` + +## IsometricTileMap + +Карта — числа (id тайлов) + таблица id → Texture. Блокирующие id не дают ходить; +`tall` — высокие объекты, рисуются поверх земли по строкам глубины: + +```ts +import { IsometricTileMap } from '@rpg/engine'; + +const data: TileMapData = { + width: 28, height: 28, + tiles: [...], // length = width * height + blocked: [TILE_WATER, TILE_TREE], + tall: { [TILE_TREE]: { height: 48, ground: TILE_GRASS } } // высота в px, ground — чем рисовать землю под ним +}; +const map = new IsometricTileMap(data, textures, DEFAULT_ISO); +worldRoot.addChild(map.view); + +map.isWalkable(x, y); // Grid для A* +map.screenSize; // { width, height } для границ камеры +``` + +## A* (pathfinding) + +```ts +import { findPath } from '@rpg/engine'; + +const path = findPath(map, { x: 2, y: 3 }, { x: 10, y: 8 }); +// путь без стартового тайла, включая конечный; null — пути нет +``` + +Диагонали включаются параметром `allowDiagonal = true`, но **без среза углов**: +диагональный шаг разрешён, только если оба ортогональных соседа проходимы. + +## Формат карт (JSON + RLE) + +Карты можно хранить файлами: `encodeMap` упаковывает тайлы в RLE (пары `[id, длина]`), +`parseMap` валидирует и распаковывает: + +```ts +import { encodeMap, parseMap } from '@rpg/engine'; + +const file = encodeMap(data); // { format: 'rpg-map', version: 1, encoding: 'rle', tiles: [[0, 12], [1, 3], ...] } +fs.writeFileSync('meadows.json', JSON.stringify(file)); + +const data = parseMap(JSON.parse(raw)); // бросает понятную ошибку на битых данных +``` + +`encoding: 'raw'` — плоский массив, если RLE не нужен. Файл читается и правится +текстовым редактором. + +## Импорт из Tiled + +```ts +import { fromTiledIso } from '@rpg/engine'; + +const data = fromTiledIso(tiledJson, { + layers: ['ground', 'props'], // имена слоёв снизу вверх (по умолчанию все tilelayer) + blocked: [2, 5], // id после смещения на firstgid + tall: { 3: { height: 48 } } +}); +``` + +Требования к карте в Tiled: ориентация **isometric**, размер тайла 32×16, +tilesets один с известным `firstgid`. GID из Tiled смещаются на `firstgid`, +чтобы id шли с 0; верхние слои заполняют пустые (0) клетки нижних. \ No newline at end of file diff --git a/docs/engine/recipes.md b/docs/engine/recipes.md new file mode 100644 index 0000000..5d25579 --- /dev/null +++ b/docs/engine/recipes.md @@ -0,0 +1,182 @@ +# Recipes: «как сделать…» + +Готовые решения типовых задач. Примеры взяты из живой игры (`apps/game`). + +## Локация с картой и камерой + +Смотри `apps/game/src/scenes/LocationScene.ts`. Скелет: + +```ts +export class LocationScene implements Scene { + constructor(engine: Engine) { + const data = loadMap(); // buildMap() или parseMap(json) + const map = new IsometricTileMap(data, tileTextures(), DEFAULT_ISO); + engine.renderer.worldRoot.addChild(map.view); + + const size = map.screenSize; + engine.camera.bounds = { + x: -size.width / 2, y: 0, // ромб уходит в минус по X! + width: size.width, height: size.height + }; + + // центры тайлов для позиционирования сущностей + const p = isoToScreen(tx, ty); + const cx = p.x, cy = p.y + DEFAULT_ISO.tileH / 2; + engine.camera.follow(cx, cy); + } + + update(dt: number) { + // ... движение героя ... + const c = tileCenter(hero.tile); + engine.camera.follow(c.x, c.y); // каждый тик за героем + } +} +``` + +## Click-to-move по A* + +```ts +// в update сцены: +const pointer = engine.input.getPointer(); +if (pointer.justPressed) { + const worldX = pointer.x - engine.renderer.worldRoot.position.x; + const worldY = pointer.y - engine.renderer.worldRoot.position.y; + const target = screenToIsoExact(worldX, worldY, map.width, map.height); + if (target) { + this.path = findPath(map, hero.tile, target); + } +} + +// следование пути с фиксированной скоростью: +if (this.path && this.path.length > 0) { + const next = this.path[0]; + const c = tileCenter(next.x, next.y); + // двигаем спрайт к c, по достижении — hero.tile = next, path.shift() +} +``` + +Полная версия с анимацией ходьбы и поворотами — `apps/game/src/systems/PlayerController.ts`. + +## NPC с диалогом и флагами + +```ts +// данные NPC +const NPCS = [ + { + id: 'elder', name: 'Ирвин', tile: { x: 12, y: 10 }, + sprite: 'elder_irwin', + dialogueFirst: 'elder_first', // ключ графа + dialogueRepeat: 'elder_repeat', + flagKey: 'met_elder' + } +]; + +// клик по тайлу NPC: +const npc = this.npcs.find((n) => n.def.tile.x === clicked.x && clicked.y === n.def.tile.y); +if (npc) { + const met = gameState.hasFlag(npc.def.flagKey); + if (!met) gameState.setFlag(npc.def.flagKey); + runner.start(graphs[met ? npc.def.dialogueRepeat : npc.def.dialogueFirst]); +} +``` + +## Квест на флагах + +Квест = флаги GameState + проверки в диалогах (`when`/`whenNot`) + действия при +завершении диалога: + +```ts +// В графе: NPC выдаёт квест выбором с setFlags: ['quest_bells_taken']. +// Сдача квеста — узел с when: ['quest_bells_taken'], выбор с +// setFlags: ['quest_bells_done'], clearFlags: ['quest_bells_taken']. + +runner.onFinish = (graph) => { + if (graph === graphs['elder_first'] && gameState.hasFlag('quest_bells_done')) { + gameState.setVar('gold', gameState.getNumber('gold') + 30); + engine.events.emit('quest:done', { quest: 'bells' }); + } +}; +``` + +Состояние всех квестов живёт в GameState и автоматически попадает в сейв. + +## Покадровая анимация ходьбы + +Смотри `apps/game/src/systems/PlayerController.ts`: + +```ts +type HeroTextures = { down: Texture[]; up: Texture[]; side: Texture[] }; + +const sprite = new Sprite(frames.down[0]); +sprite.anchor.set(0.5, 1); // ноги в центре тайла +const anim = new FrameAnimation(sprite, frames.down, 6); + +// смена направления: +anim.setFrames(frames.up, true); +// для right — зеркалим: +sprite.scale.x = -1; +``` + +## Атмосферные частицы (пепел, мотыльки) + +```ts +const ash = new ParticleEmitter({ + color: 0x666677, rate: 6, lifetime: [3, 7], + velocity: { x: [-8, 8], y: [-4, 4] }, + size: 1, spawnArea: { width: 480, height: 270 }, seed: 7 +}); +ash.position.set(240, 135); +engine.renderer.worldRoot.addChild(ash); + +update(dt) { ash.update(dt); } +exit() { ash.clear(); } +``` + +Мотыльки — теплый цвет (`0xf0d878`), меньше скорость, `acceleration: { y: 1.5 }`. + +## Меню с клавиатурой и мышью + +Смотри `apps/game/src/scenes/MenuScene.ts`: MenuList + `advance`/`up`/`down` действия. +Кнопки обрабатывают мышь сами; клавиатуру двигает сцена. Наведение мыши и фокус +клавиатуры совмещены: `Button.focused` подсвечивает как фокус, так и hover. + +## Fade-переход между сценами + +```ts +void engine.scenes.replace(new LocationScene(engine), { duration: 0.4 }); +// push/pop/replace принимают { duration, color } +``` + +Во время перехода ввод в сцену лучше игнорировать: + +```ts +update(dt: number) { + if (engine.scenes.transitioning) return; + // ... +} +``` + +## Сейв/загрузка (автосейв по Esc) + +```ts +// выход в меню: +saves.save('autosave', { + pos: hero.tile, + state: gameState.serialize(), + savedAt: Date.now() +} satisfies SaveData); +void engine.scenes.replace(new MenuScene(engine), { duration: 0.3 }); + +// продолжить: +const data = saves.load('autosave'); +if (data) { + gameState.load(data.state); + const scene = new LocationScene(engine, data.pos); + await engine.scenes.replace(scene, { duration: 0.3 }); +} +``` + +## Настройки громкости + +Смотри [assets-audio-save.md](assets-audio-save.md): `Settings` + `onChange` → +`audio.setBusVolume`. Экран настроек — просто `settings.update({ music: slider.value })`. \ No newline at end of file diff --git a/docs/engine/render.md b/docs/engine/render.md new file mode 100644 index 0000000..cde9525 --- /dev/null +++ b/docs/engine/render.md @@ -0,0 +1,94 @@ +# Рендер: Renderer, pixel-perfect, камера, IsoDepthLayer, частицы + +## Renderer и pixel-perfect + +Виртуальное разрешение (например 480×270) растягивается на экран **целым** числом — +каждый виртуальный пиксель всегда N×N экранных. Это и есть pixel-perfect: + +- `computeScale(vw, vh, winW, winH)` — максимальный целый масштаб, при котором + виртуальный экран влезает в окно; +- `renderer.resize(newScale)` — смена масштаба (CSS-размер канваса); + Engine делает это автоматически при ресайзе окна (`autoResize: true`); +- канвасу выставлены `image-rendering: pixelated`, `antialias: false`, `roundPixels: true`. + +Корневые контейнеры: + +```ts +engine.renderer.worldRoot // мир — сюда применяется камера +engine.renderer.uiRoot // UI поверх мира, камерой не двигается +``` + +Оверлей fade-переходов добавляется в `uiRoot` самим Engine — всегда верхний слой. + +## Camera + +Позиция камеры — центр взгляда в **мировых виртуальных пикселях**: + +```ts +const camera = engine.camera; +camera.follow(targetX, targetY); // следить за точкой +camera.bounds = { x, y, width, height }; // прямоугольник мира +``` + +Важно: границы могут начинаться с отрицательных координат. Изометрическая карта +(ромб) уходит в минус по X — её западный угол при 28×28 тайлах ≈ `-448`: + +```ts +const size = map.screenSize; // { width, height } в экранных пикселях +camera.bounds = { x: -size.width / 2, y: 0, width: size.width, height: size.height }; +``` + +Позиция округляется до целого пикселя (`apply`), поэтому арт не дрожит при движении. + +## IsoDepthLayer + +Сортировка глубины для сущностей на изометрической карте: глубина = `tx + ty` +(чем юго-восточнее, тем «ближе»). Герой, NPC, деревья добавляются сюда, а не в слой карты: + +```ts +import { IsoDepthLayer } from '@rpg/engine'; + +const actors = new IsoDepthLayer(); // sortableChildren = true внутри +engine.renderer.worldRoot.addChild(actors); + +actors.add(heroView, tileX, tileY); // добавить и выставить глубину +actors.setDepth(heroView, tileX, tileY); // обновить при движении (по смене тайла) +``` + +Слой карты (`IsometricTileMap.view`) и слой сущностей — соседи: карта рисует +высокие объекты со своей внутренней сортировкой, сущности сортируются отдельно. +Для корректного перекрытия герой/дерево должны быть в одном слое с деревьями — +либо используйте `tall`-объекты карты и держите сущности поверх (см. recipes). + +## Particles + +Эмиттер частиц для атмосферы (пепел, мотыльки, пыль, искры). Частицы — подкрашенные +квадратики 1–4 виртуальных пикселя, детерминированные по seed: + +```ts +import { ParticleEmitter } from '@rpg/engine'; + +const moths = new ParticleEmitter({ + color: 0xf0d878, + rate: 3, // частиц в секунду + lifetime: [2, 5], // сек + velocity: { x: [-6, 6], y: [-9, -3] }, + acceleration: { y: 1.5 }, // «тяжёлый» полёт мотылька + size: 2, + spawnArea: { width: 480, height: 200 }, + seed: 42 +}); +moths.position.set(240, 135); +engine.renderer.worldRoot.addChild(moths); + +// в update сцены: +moths.update(dt); +// при выходе со сцены: +moths.clear(); moths.destroy(); +``` + +## Порядок отрисовки + +Стек сцен рисуется снизу вверх; внутри сцены порядок задают дочерние контейнеры +(`worldRoot`: карта → сущности → эффекты; `uiRoot`: HUD → диалоги → fade-оверлей). +`SceneManager.render()` вызывает `render()` всех сцен в стеке (не только верхней). \ No newline at end of file diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md new file mode 100644 index 0000000..3ca0e11 --- /dev/null +++ b/docs/engine/ui-and-dialogue.md @@ -0,0 +1,155 @@ +# UI и диалоги: PixelText, Panel, Button, MenuList, DialogueRunner + +## PixelText (пиксельный шрифт) + +Движок поставляет VT323 (OFL, есть кириллица) в `packages/engine/assets/fonts/`. +Загрузка — через FontFace API, до создания текста: + +```ts +import { ensurePixelFont, PixelText } from '@rpg/engine'; + +await ensurePixelFont('fonts/VT323-Regular.ttf'); // URL решает приложение +const label = new PixelText({ + text: 'Пепел оседает...', + size: 10, // виртуальные пиксели, целые + color: 0x8a8a9a, + wordWrapWidth: 200, // опционально: перенос + align: 'center' +}); +``` + +Если шрифт не загрузился (офлайн, ошибка) — фолбэк monospace, приложение не падает. +Чтобы заменить шрифт: свой TTF + `ensurePixelFont(url, 'МойШрифт')` — PixelText +принимает `fontFamily`. + +## Panel, Button, MenuList + +Базовый стиль UI движка: тёмные панели с однопиксельной рамкой, жёлтый акцент для фокуса. + +```ts +import { Panel, Button, MenuList } from '@rpg/engine'; + +const panel = new Panel({ width: 160, height: 120, fill: 0x101018, border: 0x8899aa }); +uiRoot.addChild(panel); +``` + +Кнопка сама рисует состояния (normal/hover/pressed/focused) и обрабатывает мышь; +клавиатурная активация — через MenuList или вручную `button.activate()`: + +```ts +const button = new Button({ + label: 'Новая игра', + width: 120, height: 16, size: 10, + onSelect: () => startGame() +}); +button.focused = true; // клавиатурный фокус (визуально — рамка акцентного цвета) +``` + +Меню со списком: игра читает ввод и двигает курсор — движок не привязан к раскладке: + +```ts +const menu = new MenuList({ width: 120, height: 14, gap: 2 }); +menu.position.set(180, 120); +menu.setItems([ + { label: 'Продолжить', onSelect: () => loadGame() }, + { label: 'Новая игра', onSelect: () => newGame() }, + { label: 'Выход', onSelect: () => window.close() } +]); +uiRoot.addChild(menu); + +// в update сцены: +if (input.isActionJustPressed('up')) menu.moveCursor(-1); +if (input.isActionJustPressed('down')) menu.moveCursor(1); +if (input.isActionJustPressed('advance')) menu.activate(); +``` + +Навигация зациклена (после последнего пункта — первый). Логика курсора — чистый +класс `ListCursor`, покрыт тестами. + +## DialogueRunner (графы диалогов) + +Рантайм диалогов отделён от отрисовки: движок ходит по графу и применяет эффекты +к GameState, игра рисует реплики своим view. Граф — обычные данные (TS или JSON): + +```ts +import { DialogueRunner, type DialogueGraph } from '@rpg/engine'; + +const graphs: Record = { + elder_first: { + start: 'greet', + nodes: { + greet: { + speaker: 'Ирвин', + text: 'Ты пришёл с востока? Тогда слушай...', + next: 'quest', + }, + quest: { + speaker: 'Ирвин', + text: 'Поможешь лугам?', + choices: [ + { text: 'Да', next: 'accept', setFlags: ['quest_bells_taken'] }, + { text: 'Не сейчас', next: 'later' }, + { text: 'Уже помог', when: ['quest_bells_done'], next: 'thanks' } + ] + }, + accept: { text: 'Спасибо. Возьми эту свечу.', setFlags: ['met_elder'] }, + later: { text: 'Жду.' }, + thanks: { text: 'Ты уже сделал больше, чем я смел просить.' } + } + } +}; +``` + +Рантайм: + +```ts +const runner = new DialogueRunner(gameState, dialogueBoxView); +runner.onFinish = (graph) => { /* квесты, сейв, сцены */ }; + +runner.start(graphs['elder_first']); // показать первый узел +// в update: по клику/Space +if (runner.active) input.isActionJustPressed('advance') && runner.advance(); +runner.pick(choiceIndex); // выбор варианта (из view) + +runner.active; // идёт ли диалог +``` + +### Механика узлов + +- Узел с `text` — реплика: показывается, `advance()` уходит по `next` + (нет `next` — конец диалога). +- Узел без `text` — «действие»: применяет эффекты и уходит по `next` (или завершает). + Так строятся hub-узлы и проверки без реплик. +- `choices` — варианты игрока; показываются только прошедшие условия; + `pick(index)` применяет эффекты выбора и идёт по его `next`. +- Условия на узлах и выборах: `when` (все флаги установлены), `whenNot` + (ни один не установлен), `whenVar: { key, op, value }` (eq/ne/gt/lt/ge/le). + Не прошедший условие узел пропускается: диалог уходит по его `next` или завершается. +- Эффекты (`setFlags`, `clearFlags`, `setVars`) применяются при входе в узел + и при выборе варианта. +- Циклы из узлов без текста обрываются безопасно (MAX_STEPS). + +### View + +Любой объект с двумя методами — например, обёртка над DialogueBox: + +```ts +const view: DialogueView = { + show: ({ speaker, text, choices }) => box.show({ speaker: speaker ?? '', text }), + hide: () => box.hide() +}; +runner.setView(view); // можно заменить в любой момент +``` + +## DialogueBox + +Готовая нижняя панель для реплик (рисует имя, текст, подсказку «далее»): + +```ts +import { DialogueBox } from '@rpg/engine'; + +const box = new DialogueBox({ width: 480, height: 270, margin: 8 }); +uiRoot.addChild(box.view); +box.show({ speaker: 'Ирвин', text: 'Привет.' }); +box.hide(); +``` \ No newline at end of file diff --git a/packages/engine/package.json b/packages/engine/package.json index 2f4e158..3852881 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -5,7 +5,8 @@ "type": "module", "main": "src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./assets/*": "./assets/*" }, "dependencies": { "pixi.js": "^8.19.0" diff --git a/packages/engine/src/assets/AssetLoader.ts b/packages/engine/src/assets/AssetLoader.ts index 39bee58..fe743e3 100644 --- a/packages/engine/src/assets/AssetLoader.ts +++ b/packages/engine/src/assets/AssetLoader.ts @@ -39,12 +39,17 @@ /** * Загрузить атлас (Spritesheet JSON). JSON должен ссылаться на PNG * относительным путём — Pixi подгрузит картинку сам. + * Ключ резолвится как есть: приложение должно дать URL на .json + * (например, ключ 'chars/hero_sheet.json'). */ async loadAtlas(key: string): Promise { await this.ensureInit(); const existing = this.atlases.get(key); if (existing) return existing; const sheet = await Assets.load(this.resolveUrl(key)); + if (!(sheet instanceof Spritesheet) || !sheet.textures) { + throw new Error(`Атлас ${key} не распознан как Spritesheet (проверьте URL и формат JSON)`); + } this.atlases.set(key, sheet); return sheet; } diff --git a/packages/engine/src/save/SaveManager.ts b/packages/engine/src/save/SaveManager.ts index dcbcb0f..a8ac3bc 100644 --- a/packages/engine/src/save/SaveManager.ts +++ b/packages/engine/src/save/SaveManager.ts @@ -34,6 +34,11 @@ this.storage.removeItem(this.prefix + slot); } + /** Есть ли сохранение в слоте. */ + has(slot: string): boolean { + return this.storage.getItem(this.prefix + slot) !== null; + } + /** Список занятых слотов (без префикса). */ listSlots(): string[] { const out: string[] = []; diff --git a/packages/engine/src/scene/SceneManager.ts b/packages/engine/src/scene/SceneManager.ts index 0255316..4b0e95a 100644 --- a/packages/engine/src/scene/SceneManager.ts +++ b/packages/engine/src/scene/SceneManager.ts @@ -100,6 +100,10 @@ private begin(kind: TransitionKind, scene: Scene | undefined, tr: SceneTransition): void { if (this.pending) return; // параллельные переходы запрещены + // Оверлей должен быть поверх всего UI: сцены добавляли свои view позже. + if (this.overlay?.parent) { + this.overlay.parent.addChild(this.overlay); + } this.pending = { kind, scene, diff --git a/packages/engine/src/ui/DialogueBox.ts b/packages/engine/src/ui/DialogueBox.ts index 956384d..88abf66 100644 --- a/packages/engine/src/ui/DialogueBox.ts +++ b/packages/engine/src/ui/DialogueBox.ts @@ -1,20 +1,31 @@ -import { Container, Graphics, Text } from 'pixi.js'; +import { Container, Graphics } from 'pixi.js'; +import { PixelText } from './PixelText'; /** - * Универсальное окно диалога: имя говорящего, текст, подсказка «далее». + * Универсальное окно диалога: имя говорящего, текст, варианты ответа, подсказка «далее». * Жанронезависимо: игра сама решает, чей это диалог и что идёт дальше. + * Подходит как view для DialogueRunner. */ export interface DialogueLine { speaker: string; text: string; } +export interface DialogueChoiceItem { + index: number; + text: string; +} + export interface DialogueBoxOptions { /** Виртуальные пиксели. */ width: number; height: number; /** Отступы панели от краёв экрана. */ margin: number; + /** Выбор варианта ответа (из DialogueRunner.pick). */ + onChoice?: (index: number) => void; + /** Размер шрифта (виртуальные пиксели). */ + size?: number; } export class DialogueBox { @@ -22,60 +33,93 @@ visible = false; private panel: Graphics; - private nameText: Text; - private bodyText: Text; - private hintText: Text; + private nameText: PixelText; + private bodyText: PixelText; + private hintText: PixelText; + private choiceViews: { item: DialogueChoiceItem; label: PixelText }[] = []; + private onChoice: ((index: number) => void) | null; + private readonly width: number; + private readonly height: number; + private readonly margin: number; + private readonly size: number; constructor(options: DialogueBoxOptions) { + this.width = options.width; + this.height = options.height; + this.margin = options.margin; + this.size = options.size ?? 9; + this.onChoice = options.onChoice ?? null; + this.view = new Container(); this.view.visible = false; this.view.eventMode = 'static'; this.panel = new Graphics(); - this.nameText = new Text({ + this.nameText = new PixelText({ text: '', size: this.size, color: 0xf0d878 }); + this.bodyText = new PixelText({ text: '', - style: { fontFamily: 'monospace', fontSize: 8, fill: 0xffffcc } + size: this.size, + color: 0xffffff, + wordWrapWidth: options.width - options.margin * 4, + lineHeight: this.size + 3 }); - this.bodyText = new Text({ - text: '', - style: { - fontFamily: 'monospace', - fontSize: 8, - fill: 0xffffff, - wordWrap: true, - wordWrapWidth: options.width - options.margin * 4, - lineHeight: 10 - } - }); - this.hintText = new Text({ - text: 'далее ▸', - style: { fontFamily: 'monospace', fontSize: 8, fill: 0xaaaaaa } - }); + this.hintText = new PixelText({ text: 'далее ▸', size: this.size, color: 0xaaaaaa }); this.view.addChild(this.panel, this.nameText, this.bodyText, this.hintText); - this.layout(options); + this.layout(); } - private layout(options: DialogueBoxOptions): void { - const w = options.width; - const h = options.height; - const m = options.margin; + private layout(): void { + const w = this.width; + const h = this.height; + const m = this.margin; this.panel.rect(m, h - m - 48, w - m * 2, 48).fill({ color: 0x101018, alpha: 0.92 }); this.panel.rect(m, h - m - 48, w - m * 2, 48).stroke({ color: 0x8899aa, width: 1 }); - this.nameText.position.set(m + 4, h - m - 44); - this.bodyText.position.set(m + 4, h - m - 34); - this.hintText.position.set(w - m - 40, h - m - 8); + this.nameText.position.set(m + 5, h - m - 46); + this.bodyText.position.set(m + 5, h - m - 36); + this.hintText.position.set(w - m - 42, h - m - 10); } - show(line: DialogueLine): void { - this.nameText.text = line.speaker; + /** + * Показать реплику. Если есть choices — рисуются кликабельные варианты + * (текст реплики обычно скрыт: runner показывает вопрос предыдущим узлом). + */ + show(line: { speaker?: string; text: string; choices?: DialogueChoiceItem[] }): void { + this.nameText.text = line.speaker ?? ''; this.bodyText.text = line.text; + this.hintText.visible = !line.choices || line.choices.length === 0; this.view.visible = true; this.visible = true; + + for (const c of this.choiceViews) c.label.destroy(); + this.choiceViews = []; + if (line.choices && line.choices.length > 0) { + const m = this.margin; + let y = this.height - m - 36; + for (const item of line.choices) { + const label = new PixelText({ + text: `▸ ${item.text}`, + size: this.size, + color: 0xcccccc, + wordWrapWidth: this.width - m * 4 + }); + label.position.set(m + 5, y); + label.eventMode = 'static'; + label.cursor = 'pointer'; + label.on('pointerover', () => (label.style.fill = 0xf0d878)); + label.on('pointerout', () => (label.style.fill = 0xcccccc)); + label.on('pointertap', () => this.onChoice?.(item.index)); + this.view.addChild(label); + this.choiceViews.push({ item, label }); + y += label.height + 3; + } + } } hide(): void { this.view.visible = false; this.visible = false; + for (const c of this.choiceViews) c.label.destroy(); + this.choiceViews = []; } } \ No newline at end of file diff --git a/tools/pixelart/gen.mjs b/tools/pixelart/gen.mjs index 6ecccd8..8cc2d3e 100644 --- a/tools/pixelart/gen.mjs +++ b/tools/pixelart/gen.mjs @@ -401,4 +401,38 @@ save(elderIrwin, 'chars/elder_irwin.png'); save(traderMila, 'chars/trader_mila.png'); +// ---------- атлас героя (Spritesheet: один PNG + JSON) ---------- + +const FRAME_W = 16; +const FRAME_H = 24; +const heroFrames = [heroDown1, heroDown2, heroUp1, heroUp2, heroSide1, heroSide2]; +const heroNames = ['hero_down_1', 'hero_down_2', 'hero_up_1', 'hero_up_2', 'hero_side_1', 'hero_side_2']; + +const sheetW = FRAME_W * heroFrames.length; +const sheetCanvas = new Canvas(sheetW, FRAME_H); +const framesJson = {}; +for (let i = 0; i < heroFrames.length; i++) { + const src = heroFrames[i]; + sheetCanvas.data.set(src.data, i * FRAME_W * 4); + framesJson[heroNames[i]] = { + frame: { x: i * FRAME_W, y: 0, w: FRAME_W, h: FRAME_H }, + rotated: false, + trimmed: false, + sourceSize: { w: FRAME_W, h: FRAME_H } + }; +} +writeFileSync(OUT + 'chars/hero_sheet.png', sheetCanvas.toPng()); +writeFileSync( + OUT + 'chars/hero_sheet.json', + JSON.stringify( + { + frames: framesJson, + meta: { image: 'hero_sheet.png', size: { w: sheetW, h: FRAME_H }, scale: 1 } + }, + null, + 2 + ) +); +console.log(` chars/hero_sheet.png (+ .json): ${sheetW}x${FRAME_H}`); + console.log('Готово: ' + OUT); \ No newline at end of file diff --git a/tools/smoke.mjs b/tools/smoke.mjs index 5535420..bab5e00 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -17,7 +17,7 @@ await page.setViewport({ width: 960, height: 540 }); page.on('console', (msg) => console.log(`[консоль] ${msg.text()}`)); -page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}`)); +page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); await new Promise((r) => setTimeout(r, 4000)); @@ -26,6 +26,9 @@ await page.mouse.click(480, 283); await new Promise((r) => setTimeout(r, 2500)); +// Клик по Ирвину (тайл (13,15), герой стоит в (14,14) в центре) — диалог. +await page.mouse.click(416, 270); +await new Promise((r) => setTimeout(r, 800)); await page.screenshot({ path: shot }); console.log(`Скриншот: ${shot}`); await browser.close(); \ No newline at end of file