diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7bd1068
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+*.log
+.DS_Store
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ba045e1
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,48 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Язык общения
+
+**Всегда общайся с пользователем на русском.**
+
+## Команды
+
+```bash
+npm install # установка зависимостей (npm workspaces)
+npm run dev # Vite dev-сервер с HMR (apps/game)
+npm run build # прод-сборка игры
+npm test # все тесты (Vitest)
+npx vitest run packages/engine/src/map/__tests__/pathfinding.test.ts # один файл тестов
+npm run typecheck # tsc --noEmit для обоих пакетов
+```
+
+Dev-сервер по умолчанию на http://localhost:5173; для отдельного порта: `npm run dev -- --port 5199`.
+
+## Архитектура
+
+Monorepo из двух workspace-пакетов с **жёсткой границей API**:
+
+- `packages/engine` (`@rpg/engine`) — жанронезависимый движок-библиотека. **Не должен знать ничего о RPG-контенте** (квесты, предметы, сюжет). Вся игра импортирует движок **только через `packages/engine/src/index.ts`** — другие внутренние пути движка импортировать нельзя.
+- `apps/game` (`@rpg/game`) — сама RPG: контент, сцены, геймплейные системы.
+
+### Ключевые решения, которые нельзя сломать
+
+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`. При изменении разрешения следи, чтобы масштаб всегда оставался целым.
+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 без браузера.
+
+### Где что лежит
+
+- `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 в локации).
+
+## Рабочие привычки
+
+- Для задач из 3+ шагов веди список задач (todowrite/TaskCreate) и иди по нему автономно, не спрашивая подтверждения на каждый шаг.
+- Подражай существующему стилю кода; комментарии и докстринги — по-русски; хелперы держи до ~70 строк.
+- Плейсхолдеры графики рисуются `Graphics`-ом в коде (пока нет арта); настоящий пиксель-арт в будущем кладётся в `apps/game/assets/`.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..075ca08
--- /dev/null
+++ b/README.md
@@ -0,0 +1,33 @@
+# Пепельные луга
+
+Изометрическая пиксельная RPG с сюжетом — и собственный жанронезависимый игровой движок под неё.
+
+- **Движок**: свой (`packages/engine`), TypeScript, PixiJS 8 как низкоуровневый рендер-бэкенд. Спроектирован под переиспользование в других жанрах.
+- **Игра**: одиночная сюжетная RPG (`apps/game`), сначала браузер, дальше — планы на Steam и Android.
+- **Стек**: TypeScript (strict), Vite (dev + сборка), Vitest (тесты движка).
+
+## Быстрый старт
+
+```bash
+npm install
+npm run dev # http://localhost:5173
+npm test # тесты движка
+npm run build # прод-сборка
+```
+
+## Структура
+
+```
+packages/engine # движок: цикл, сцены, ввод, камера, изометрия, A*, сейвы, диалоги
+apps/game # RPG: сцены, локации, NPC, диалоги, сюжет
+```
+
+Правило: игра использует только публичный API `@rpg/engine`; движок ничего не знает о жанре игры.
+
+## Сюжет (черновик)
+
+Мир после Великого Пепелища. Степь дышит пеплом, и только лунные колокольчики — травы-возвращенцы — держат его на месте. Игрок — звонарь, чей род обязан пересеивать колокольчики и отсеивать пепел. Первый акт: выжженные луга, старейшина Ирвин и торговка Мила.
+
+## Плейсхолдеры
+
+Вся графика сейчас — процедурные плейсхолдеры (ромбы тайлов, прямоугольные «звонари»). Настоящий пиксель-арт появится в `apps/game/assets/`.
\ No newline at end of file
diff --git a/apps/game/index.html b/apps/game/index.html
new file mode 100644
index 0000000..36a54fd
--- /dev/null
+++ b/apps/game/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Пепельные луга
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/game/package.json b/apps/game/package.json
new file mode 100644
index 0000000..02fd897
--- /dev/null
+++ b/apps/game/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@rpg/game",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build"
+ },
+ "dependencies": {
+ "@rpg/engine": "*",
+ "pixi.js": "^8.19.0"
+ },
+ "devDependencies": {
+ "vite": "^6.0.0"
+ }
+}
\ No newline at end of file
diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts
new file mode 100644
index 0000000..f752368
--- /dev/null
+++ b/apps/game/src/Game.ts
@@ -0,0 +1,33 @@
+import { Engine, SaveManager, type Camera, type Container, type Renderer, type SceneManager } from '@rpg/engine';
+
+/**
+ * Игровой контекст: движок, сохранения, общие для сцен ссылки.
+ * Движок ничего не знает об этом классе — он только потребляет его API.
+ */
+export class Game {
+ /** Виртуальное разрешение (пиксель-арт). */
+ static readonly VIRTUAL_W = 480;
+ static readonly VIRTUAL_H = 270;
+
+ readonly saves: SaveManager;
+
+ constructor(readonly engine: Engine) {
+ this.saves = new SaveManager(window.localStorage);
+ }
+
+ get scenes(): SceneManager {
+ return this.engine.scenes;
+ }
+
+ get renderer(): Renderer {
+ return this.engine.renderer;
+ }
+
+ get camera(): Camera {
+ return this.engine.camera;
+ }
+
+ get worldRoot(): Container {
+ return this.engine.renderer.worldRoot;
+ }
+}
\ No newline at end of file
diff --git a/apps/game/src/data/dialogues.ts b/apps/game/src/data/dialogues.ts
new file mode 100644
index 0000000..dd17297
--- /dev/null
+++ b/apps/game/src/data/dialogues.ts
@@ -0,0 +1,35 @@
+import type { DialogueLine } from '@rpg/engine';
+
+/**
+ * Диалоги NPC. Ключ — id диалога; игра переключает диалоги по флагам сюжета.
+ */
+export interface Dialogue {
+ id: string;
+ lines: DialogueLine[];
+}
+
+export const DIALOGUES: Record = {
+ elder_first: {
+ id: 'elder_first',
+ lines: [
+ { speaker: 'Старейшина Ирвин', text: 'Ты вернулся, звонарь. Степь всё ещё дышит пеплом.' },
+ { speaker: 'Старейшина Ирвин', text: 'Три лунных колокольчика заросли по ту сторону пруда. Их свет держит пепел на месте.' },
+ { speaker: 'Старейшина Ирвин', text: 'Найди их, пока ветра не перенесли пепел на поляну. Тогда мы успеем отсеять его.' },
+ { speaker: 'Звонарь', text: 'Я принесу колокольчики, Ирвин. Степь выживала и не в такие пепелища.' }
+ ]
+ },
+ elder_repeat: {
+ id: 'elder_repeat',
+ lines: [
+ { speaker: 'Старейшина Ирвин', text: 'Колокольчики ждут тебя у пруда, звонарь. Пепел не будет ждать.' }
+ ]
+ },
+ trader_first: {
+ id: 'trader_first',
+ lines: [
+ { speaker: 'Торговка Мила', text: 'Свежий звон у тебя в походной сумке, звонарь? Не суйся к пруду без воды.' },
+ { speaker: 'Торговка Мила', text: 'Пепел там ложится на губы. Одна глотка — и голос сядет на неделю.' },
+ { speaker: 'Звонарь', text: 'Запомню. Спасибо за предостережение, Мила.' }
+ ]
+ }
+};
\ No newline at end of file
diff --git a/apps/game/src/data/map.ts b/apps/game/src/data/map.ts
new file mode 100644
index 0000000..cafc4ff
--- /dev/null
+++ b/apps/game/src/data/map.ts
@@ -0,0 +1,101 @@
+import type { TileMapData } from '@rpg/engine';
+
+/** id тайлов луга «Пепельные луга». */
+export const TILES = {
+ GRASS: 0,
+ PATH: 1,
+ WATER: 2,
+ TREE: 3,
+ ASH: 4
+} as const;
+
+const BLOCKED = [TILES.WATER, TILES.TREE];
+
+/** Высота «высоких» объектов (деревья), для объёма. */
+const TALL_OBJECTS: Record = { [TILES.TREE]: 14 };
+
+/** Детерминированный ГПСЧ, чтобы локация выглядела одинаково между запусками. */
+function mulberry32(seed: number): () => number {
+ return () => {
+ seed |= 0;
+ seed = (seed + 0x6d2b79f5) | 0;
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * Локация «Выжженные луга»: поляны травы, тропа через карту,
+ * пара прудов и рощи деревьев по краям.
+ */
+export function buildMeadowsMap(width = 28, height = 28): TileMapData {
+ const rng = mulberry32(20260905);
+ const tiles: number[] = new Array(width * height).fill(TILES.GRASS);
+ const at = (x: number, y: number) => y * width + x;
+
+ // Пятна пепла (декоративные).
+ for (let i = 0; i < 40; i++) {
+ const x = Math.floor(rng() * width);
+ const y = Math.floor(rng() * height);
+ const r = 1 + Math.floor(rng() * 2);
+ for (let dy = -r; dy <= r; dy++) {
+ for (let dx = -r; dx <= r; dx++) {
+ const nx = x + dx;
+ const ny = y + dy;
+ if (nx >= 0 && ny >= 0 && nx < width && ny < height && rng() < 0.7) {
+ tiles[at(nx, ny)] = TILES.ASH;
+ }
+ }
+ }
+ }
+
+ // Тропа: ломаная слева-сверху вниз-вправо.
+ let px = 2;
+ let py = 2;
+ while (px < width - 3 || py < height - 3) {
+ tiles[at(px, py)] = TILES.PATH;
+ if (rng() < 0.5 && px < width - 3) px++;
+ else if (py < height - 3) py++;
+ else px++;
+ }
+
+ // Пруды.
+ for (const [cx, cy] of [
+ [6, 16],
+ [19, 7]
+ ]) {
+ for (let dy = 0; dy < 3; dy++) {
+ for (let dx = 0; dx < 4; dx++) {
+ if (rng() < 0.85) tiles[at(cx + dx, cy + dy)] = TILES.WATER;
+ }
+ }
+ }
+
+ // Рощи деревьев по краям.
+ for (let i = 0; i < 60; i++) {
+ const edge = rng() < 0.5;
+ const x = edge ? Math.floor(rng() * 4) : width - 1 - Math.floor(rng() * 4);
+ const y = Math.floor(rng() * height);
+ if (tiles[at(x, y)] === TILES.GRASS || tiles[at(x, y)] === TILES.ASH) {
+ tiles[at(x, y)] = TILES.TREE;
+ }
+ }
+
+ // Стартовая поляна вокруг центра всегда свободна.
+ const cx = Math.floor(width / 2);
+ const cy = Math.floor(height / 2);
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ tiles[at(cx + dx, cy + dy)] = TILES.GRASS;
+ }
+ }
+
+ return {
+ width,
+ height,
+ tiles,
+ blocked: [...BLOCKED],
+ tall: TALL_OBJECTS
+ };
+}
\ No newline at end of file
diff --git a/apps/game/src/data/npcs.ts b/apps/game/src/data/npcs.ts
new file mode 100644
index 0000000..e19b754
--- /dev/null
+++ b/apps/game/src/data/npcs.ts
@@ -0,0 +1,35 @@
+/** NPC локации: тайл, диалоги и флаг сюжета после первого разговора. */
+export interface NpcDef {
+ id: string;
+ name: string;
+ tile: { x: number; y: number };
+ /** Первый диалог (когда флаг ещё не поднят). */
+ dialogueFirst: string;
+ /** Повторный диалог. */
+ dialogueRepeat: string;
+ /** Флаг, поднимаемый после первого диалога. */
+ flagKey: string;
+ /** Плейсхолдерные цвета (плащ, шляпа). */
+ colors: { cloak: number; hat: number };
+}
+
+export const NPCS: NpcDef[] = [
+ {
+ id: 'elder',
+ name: 'Старейшина Ирвин',
+ tile: { x: 13, y: 15 },
+ dialogueFirst: 'elder_first',
+ dialogueRepeat: 'elder_repeat',
+ flagKey: 'metElder',
+ colors: { cloak: 0x7a6a55, hat: 0x5c4f3e }
+ },
+ {
+ id: 'trader',
+ name: 'Торговка Мила',
+ tile: { x: 16, y: 12 },
+ dialogueFirst: 'trader_first',
+ dialogueRepeat: 'trader_first',
+ flagKey: 'metTrader',
+ colors: { cloak: 0x6d4459, hat: 0x4a2f3d }
+ }
+];
\ No newline at end of file
diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts
new file mode 100644
index 0000000..0a3f631
--- /dev/null
+++ b/apps/game/src/main.ts
@@ -0,0 +1,34 @@
+import { Engine, 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),
+ background: 0x0b0b12,
+ fixedFps: 60,
+ parent: container
+ };
+
+ const engine = new Engine(options);
+ await engine.init();
+ engine.input.bindActions({
+ advance: ['Space', 'Enter'],
+ menu: ['Escape']
+ });
+
+ const game = new Game(engine);
+ await engine.scenes.push(new BootScene(game));
+ await engine.start();
+}
+
+void main();
\ No newline at end of file
diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts
new file mode 100644
index 0000000..f7e3317
--- /dev/null
+++ b/apps/game/src/scenes/BootScene.ts
@@ -0,0 +1,26 @@
+import type { Scene } from '@rpg/engine';
+import type { Game } from '../Game';
+import { MenuScene } from './MenuScene';
+
+/**
+ * Загрузочная сцена: пока ассетов нет, просто ждём кадр-два и уходим в меню.
+ * Здесь же в будущем — прогрессивная загрузка текстур и звуковых пакетов.
+ */
+export class BootScene implements Scene {
+ private wait = 0.2;
+
+ constructor(private game: Game) {}
+
+ enter(): void {}
+
+ exit(): void {}
+
+ update(dt: number): void {
+ this.wait -= dt;
+ if (this.wait <= 0) {
+ void this.game.scenes.replace(new MenuScene(this.game));
+ }
+ }
+
+ render(): void {}
+}
\ No newline at end of file
diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts
new file mode 100644
index 0000000..169a5c2
--- /dev/null
+++ b/apps/game/src/scenes/LocationScene.ts
@@ -0,0 +1,182 @@
+import { Container, Graphics, Text } from 'pixi.js';
+import {
+ World,
+ IsometricTileMap,
+ isoToScreen,
+ screenToIsoExact,
+ DEFAULT_ISO,
+ type Scene,
+ type Camera
+} from '@rpg/engine';
+import { Game } from '../Game';
+import { buildMeadowsMap } from '../data/map';
+import { NPCS, type NpcDef } from '../data/npcs';
+import { PlayerController } 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 map: IsometricTileMap;
+ private player: PlayerController;
+ private dialogue: DialogueSystem;
+ private npcs: { def: NpcDef; view: Container }[] = [];
+ private hint: Text;
+ private mapLayer = new Container();
+ private camera: Camera;
+
+ constructor(
+ private game: Game,
+ save: SaveData | null
+ ) {
+ this.camera = game.camera;
+
+ const data = buildMeadowsMap();
+ this.map = new IsometricTileMap(data, new Map(), DEFAULT_ISO);
+ this.mapLayer.addChild(this.map.view);
+ this.game.renderer.worldRoot.addChild(this.mapLayer);
+
+ const startTile = save?.pos ?? { x: 14, y: 14 };
+ this.player = new PlayerController(this.world, this.map, startTile);
+ this.mapLayer.addChild(this.player.view);
+
+ for (const def of NPCS) {
+ const view = this.makeNpcView(def);
+ this.mapLayer.addChild(view);
+ this.npcs.push({ def, view });
+ }
+
+ this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, {
+ width: 480,
+ height: 270,
+ margin: 8
+ });
+ this.dialogue.onDialogueFinished = (id) => this.onDialogueFinished(id);
+ if (save?.flags) {
+ Object.assign(this.dialogue.flags, save.flags);
+ }
+
+ // Камера: следим за героем, границы — по экранному размеру карты.
+ const size = this.map.screenSize;
+ this.camera.bounds = { 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.hint = new Text({
+ text: 'клик — идти · клик по NPC — говорить · Esc — меню',
+ style: { fontFamily: 'monospace', fontSize: 8, fill: 0x999988 }
+ });
+ this.hint.position.set(6, 4);
+ this.game.renderer.uiRoot.addChild(this.hint);
+ }
+
+ enter(): void {}
+
+ exit(): void {
+ this.mapLayer.destroy({ children: true });
+ this.hint.destroy();
+ }
+
+ update(dt: number): void {
+ 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);
+ }
+ }
+
+ this.player.update(dt);
+
+ // Камера за героем.
+ const tile = this.player.currentTile();
+ const c = this.tileCenter(tile.x, tile.y);
+ this.camera.follow(c.x, c.y);
+ }
+
+ render(): void {}
+
+ /** Экранные координаты центра ромба тайла (с Origin карты). */
+ private tileCenter(tx: number, ty: number): { x: number; y: number } {
+ const p = isoToScreen(tx, ty, DEFAULT_ISO);
+ return { x: p.x, y: p.y + DEFAULT_ISO.tileH / 2 };
+ }
+
+ private handleWorldClick(px: number, py: number): void {
+ // Из координат экрана в мировые (учёт позиции камеры).
+ const worldX = px - this.game.renderer.worldRoot.position.x;
+ const worldY = py - this.game.renderer.worldRoot.position.y;
+
+ const clicked = screenToIsoExact(
+ worldX,
+ worldY,
+ this.map.data.width,
+ this.map.data.height,
+ DEFAULT_ISO
+ );
+
+ // Клик по тайлу NPC — диалог.
+ if (clicked) {
+ const npc = this.npcs.find(
+ (n) => n.def.tile.x === clicked.x && n.def.tile.y === clicked.y
+ );
+ if (npc) {
+ this.talkTo(npc.def);
+ return;
+ }
+ }
+ this.player.onWorldClick(worldX, worldY);
+ }
+
+ 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);
+ }
+
+ private onDialogueFinished(_id: string): void {
+ // Триггеры сюжета вешаются на id завершённого диалога (позже).
+ }
+
+ private makeNpcView(def: NpcDef): Container {
+ const view = new Container();
+ const body = new Graphics();
+ const p = this.tileCenter(def.tile.x, def.tile.y);
+ body.rect(-3, -9, 6, 9).fill(def.colors.cloak);
+ body.rect(-3, -12, 6, 3).fill(def.colors.hat);
+ body.rect(-1, -11, 2, 1).fill(0x1c1c22);
+ view.position.set(p.x, p.y);
+ view.addChild(body);
+
+ // Маркер «с ним можно говорить»: восклицательный штрих.
+ const marker = new Graphics();
+ marker.rect(-1, -17, 2, 4).fill(0xf0d878);
+ marker.rect(-1, -11, 2, 2).fill(0xf0d878);
+ view.addChild(marker);
+ return view;
+ }
+
+ private saveAndExit(): void {
+ const pos = this.player.currentTile();
+ this.game.saves.save('autosave', {
+ pos,
+ flags: this.dialogue.flags
+ } satisfies SaveData);
+ void this.game.scenes.replace(new MenuScene(this.game));
+ }
+}
\ No newline at end of file
diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts
new file mode 100644
index 0000000..8298325
--- /dev/null
+++ b/apps/game/src/scenes/MenuScene.ts
@@ -0,0 +1,91 @@
+import { Container, Text } from 'pixi.js';
+import type { Scene } from '@rpg/engine';
+import type { Game } from '../Game';
+import { LocationScene } from './LocationScene';
+
+/**
+ * Главное меню: название, «новая игра», «продолжить» (если есть сейв).
+ */
+export class MenuScene implements Scene {
+ private view = new Container();
+
+ constructor(private game: Game) {}
+
+ enter(): void {
+ const title = new Text({
+ text: 'ПЕПЕЛЬНЫЕ ЛУГА',
+ style: { fontFamily: 'monospace', fontSize: 24, fill: 0xd8c79a }
+ });
+ title.anchor.set(0.5);
+ title.position.set(240, 70);
+ this.view.addChild(title);
+
+ const subtitle = new Text({
+ text: 'пепел всё ещё дышит',
+ style: { fontFamily: 'monospace', fontSize: 8, fill: 0x8a8a9a }
+ });
+ subtitle.anchor.set(0.5);
+ subtitle.position.set(240, 96);
+ 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 hint = new Text({
+ text: 'клик по тайлу — идти · клик по NPC — говорить\nEsc в игре — меню с сохранением',
+ style: {
+ fontFamily: 'monospace',
+ fontSize: 8,
+ fill: 0x666677,
+ align: 'center',
+ lineHeight: 10
+ }
+ });
+ hint.anchor.set(0.5);
+ hint.position.set(240, 230);
+ 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);
+ }
+
+ update(_dt: number): void {}
+
+ render(): void {}
+
+ exit(): void {
+ this.view.destroy({ children: true });
+ }
+}
+
+/** Формат автосейва. */
+export interface SaveData {
+ pos: { x: number; y: number };
+ flags: Record;
+}
\ No newline at end of file
diff --git a/apps/game/src/systems/DialogueSystem.ts b/apps/game/src/systems/DialogueSystem.ts
new file mode 100644
index 0000000..24c414c
--- /dev/null
+++ b/apps/game/src/systems/DialogueSystem.ts
@@ -0,0 +1,54 @@
+import { Container } from 'pixi.js';
+import { DialogueBox, type DialogueBoxOptions } from '@rpg/engine';
+import { DIALOGUES } from '../data/dialogues';
+
+/**
+ * Диалоговая система: держит активный диалог, поднимает флаги сюжета,
+ * листает реплики по клику/пробелу. Ничего не знает о рендере мира.
+ */
+export class DialogueSystem {
+ private box: DialogueBox;
+ private activeId: string | null = null;
+ private lineIndex = 0;
+
+ /** Флаги сюжета (сохраняются в сейве). */
+ readonly flags: Record = {};
+ /** Сюжетное событие «диалог завершён» — для триггеров/квестов. */
+ onDialogueFinished: ((id: string) => void) | null = null;
+
+ constructor(uiRoot: Container, options: DialogueBoxOptions) {
+ this.box = new DialogueBox(options);
+ uiRoot.addChild(this.box.view);
+ }
+
+ get active(): boolean {
+ return this.activeId !== null;
+ }
+
+ 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]);
+ }
+
+ /** Проклик/пробел: следующая реплика или закрытие. */
+ 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!);
+ }
+}
\ No newline at end of file
diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts
new file mode 100644
index 0000000..24d62b7
--- /dev/null
+++ b/apps/game/src/systems/PlayerController.ts
@@ -0,0 +1,100 @@
+import {
+ World,
+ IsometricTileMap,
+ findPath,
+ isoToScreen,
+ screenToIsoExact,
+ moveTo,
+ Container,
+ Graphics,
+ DEFAULT_ISO,
+ type Entity,
+ type Vec2
+} from '@rpg/engine';
+
+/**
+ * Управление героем: путь по клику (A*), плавное движение по маршруту.
+ * Спрайт — плейсхолдер из Graphics (пока нет арта); позиция — центр ромба тайла.
+ */
+export class PlayerController {
+ readonly entity: Entity;
+ readonly view: Container;
+
+ /** Позиция в виртуальных пикселях (источник истины, ноги героя). */
+ private pos: Vec2;
+ private path: { x: number; y: number }[] = [];
+ private waypoint: Vec2 | null = null;
+ /** Скорость в виртуальных пикселях/сек. */
+ private speed = 56;
+
+ constructor(world: World, private map: IsometricTileMap, startTile: { x: number; y: number }) {
+ this.entity = world.createEntity();
+ world.addComponent(this.entity, 'pos', { ...startTile });
+
+ this.view = new Container();
+ const body = new Graphics();
+ // Плейсхолдер героя: тёмный плащ и шляпа звонаря.
+ body.rect(-3, -9, 6, 9).fill(0x3b4a63);
+ body.rect(-2, -3, 4, 3).fill(0x2a3548);
+ body.rect(-3, -12, 6, 3).fill(0x8a6d4b);
+ body.rect(-1, -11, 2, 1).fill(0x1c1c22);
+ this.view.addChild(body);
+
+ const p = isoToScreen(startTile.x, startTile.y);
+ this.pos = { x: p.x, y: p.y + DEFAULT_ISO.tileH / 2 };
+ this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y));
+ }
+
+ /** Кликом по миру выбран тайл — строим путь A* и начинаем движение. */
+ onWorldClick(worldX: number, worldY: number): void {
+ const tile = screenToIsoExact(
+ worldX,
+ worldY,
+ this.map.data.width,
+ this.map.data.height,
+ DEFAULT_ISO
+ );
+ if (!tile) return;
+ const path = findPath(this.map, this.currentTile(), tile, false);
+ if (path && path.length > 0) {
+ this.path = path;
+ this.advanceWaypoint();
+ }
+ }
+
+ update(dt: number): void {
+ if (!this.waypoint) return;
+ this.pos = moveTo(this.pos, this.waypoint, this.speed * dt);
+ if (this.pos.x === this.waypoint.x && this.pos.y === this.waypoint.y) {
+ this.advanceWaypoint();
+ }
+ this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y));
+ }
+
+ get moving(): boolean {
+ return this.waypoint !== null;
+ }
+
+ /** Текущий тайл героя. */
+ currentTile(): { x: number; y: number } {
+ return (
+ screenToIsoExact(
+ this.pos.x,
+ this.pos.y,
+ this.map.data.width,
+ this.map.data.height,
+ DEFAULT_ISO
+ ) ?? { x: 0, y: 0 }
+ );
+ }
+
+ private advanceWaypoint(): void {
+ const next = this.path.shift();
+ if (next) {
+ const p = isoToScreen(next.x, next.y);
+ this.waypoint = { x: p.x, y: p.y + DEFAULT_ISO.tileH / 2 };
+ } else {
+ this.waypoint = null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/game/tsconfig.json b/apps/game/tsconfig.json
new file mode 100644
index 0000000..282e669
--- /dev/null
+++ b/apps/game/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "types": ["vite/client"]
+ },
+ "include": ["src", "../engine/src"]
+}
\ No newline at end of file
diff --git a/apps/game/vite.config.ts b/apps/game/vite.config.ts
new file mode 100644
index 0000000..1877057
--- /dev/null
+++ b/apps/game/vite.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vite';
+import { fileURLToPath } from 'node:url';
+
+// Движок — TS-исходники workspace-пакета: алиас гарантирует, что Vite
+// собирает их как исходный код, а не как предсобранный пакет.
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@rpg/engine': fileURLToPath(new URL('../../packages/engine/src/index.ts', import.meta.url))
+ }
+ }
+});
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..e52e86b
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2234 @@
+{
+ "name": "rpg",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "rpg",
+ "version": "0.1.0",
+ "workspaces": [
+ "packages/*",
+ "apps/*"
+ ],
+ "devDependencies": {
+ "typescript": "^5.6.0",
+ "vitest": "^2.1.0"
+ }
+ },
+ "apps/game": {
+ "name": "@rpg/game",
+ "version": "0.1.0",
+ "dependencies": {
+ "@rpg/engine": "*",
+ "pixi.js": "^8.19.0"
+ },
+ "devDependencies": {
+ "vite": "^6.0.0"
+ }
+ },
+ "apps/game/node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@pixi/colord": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
+ "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
+ "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
+ "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
+ "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
+ "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
+ "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
+ "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
+ "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
+ "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
+ "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
+ "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
+ "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
+ "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
+ "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
+ "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
+ "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
+ "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
+ "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
+ "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
+ "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
+ "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
+ "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
+ "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
+ "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rpg/engine": {
+ "resolved": "packages/engine",
+ "link": true
+ },
+ "node_modules/@rpg/game": {
+ "resolved": "apps/game",
+ "link": true
+ },
+ "node_modules/@types/earcut": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
+ "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
+ "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.9",
+ "@vitest/utils": "2.1.9",
+ "chai": "^5.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
+ "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.12"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
+ "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
+ "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "2.1.9",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
+ "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.9",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
+ "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^3.0.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
+ "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.9",
+ "loupe": "^3.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@webgpu/types": {
+ "version": "0.1.72",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
+ "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.8.15",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
+ "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/earcut": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
+ "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
+ "license": "ISC"
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gifuct-js": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
+ "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
+ "license": "MIT",
+ "dependencies": {
+ "js-binary-schema-parser": "^2.0.3"
+ }
+ },
+ "node_modules/ismobilejs": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
+ "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
+ "license": "MIT"
+ },
+ "node_modules/js-binary-schema-parser": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
+ "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
+ "license": "MIT"
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/parse-svg-path": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
+ "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==",
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pixi.js": {
+ "version": "8.20.1",
+ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.20.1.tgz",
+ "integrity": "sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ==",
+ "license": "MIT",
+ "workspaces": [
+ "examples",
+ "playground"
+ ],
+ "dependencies": {
+ "@pixi/colord": "^2.9.6",
+ "@types/earcut": "^3.0.0",
+ "@webgpu/types": "^0.1.69",
+ "@xmldom/xmldom": "^0.8.15",
+ "earcut": "^3.0.2",
+ "eventemitter3": "^5.0.1",
+ "gifuct-js": "^2.1.2",
+ "ismobilejs": "^1.1.1",
+ "parse-svg-path": "^0.2.0",
+ "tiny-lru": "^11.4.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/pixijs"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
+ "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.1",
+ "@rollup/rollup-android-arm64": "4.63.1",
+ "@rollup/rollup-darwin-arm64": "4.63.1",
+ "@rollup/rollup-darwin-x64": "4.63.1",
+ "@rollup/rollup-freebsd-arm64": "4.63.1",
+ "@rollup/rollup-freebsd-x64": "4.63.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.1",
+ "@rollup/rollup-linux-arm64-musl": "4.63.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.1",
+ "@rollup/rollup-linux-loong64-musl": "4.63.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-musl": "4.63.1",
+ "@rollup/rollup-openbsd-x64": "4.63.1",
+ "@rollup/rollup-openharmony-arm64": "4.63.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.1",
+ "@rollup/rollup-win32-x64-gnu": "4.63.1",
+ "@rollup/rollup-win32-x64-msvc": "4.63.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tiny-lru": {
+ "version": "11.4.7",
+ "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
+ "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
+ "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+ "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
+ "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.7",
+ "es-module-lexer": "^1.5.4",
+ "pathe": "^1.1.2",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
+ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "2.1.9",
+ "@vitest/mocker": "2.1.9",
+ "@vitest/pretty-format": "^2.1.9",
+ "@vitest/runner": "2.1.9",
+ "@vitest/snapshot": "2.1.9",
+ "@vitest/spy": "2.1.9",
+ "@vitest/utils": "2.1.9",
+ "chai": "^5.1.2",
+ "debug": "^4.3.7",
+ "expect-type": "^1.1.0",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2",
+ "std-env": "^3.8.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.1",
+ "tinypool": "^1.0.1",
+ "tinyrainbow": "^1.2.0",
+ "vite": "^5.0.0",
+ "vite-node": "2.1.9",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "2.1.9",
+ "@vitest/ui": "2.1.9",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "packages/engine": {
+ "name": "@rpg/engine",
+ "version": "0.1.0",
+ "dependencies": {
+ "pixi.js": "^8.19.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e4551fa
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "rpg",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "workspaces": [
+ "packages/*",
+ "apps/*"
+ ],
+ "scripts": {
+ "dev": "npm run dev --workspace @rpg/game",
+ "build": "npm run build --workspace @rpg/game",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit -p packages/engine && tsc --noEmit -p apps/game"
+ },
+ "devDependencies": {
+ "typescript": "^5.6.0",
+ "vitest": "^2.1.0"
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/package.json b/packages/engine/package.json
new file mode 100644
index 0000000..2f4e158
--- /dev/null
+++ b/packages/engine/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@rpg/engine",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "dependencies": {
+ "pixi.js": "^8.19.0"
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/anim/FrameAnimation.ts b/packages/engine/src/anim/FrameAnimation.ts
new file mode 100644
index 0000000..c0da8bf
--- /dev/null
+++ b/packages/engine/src/anim/FrameAnimation.ts
@@ -0,0 +1,49 @@
+import { Texture, Sprite } from 'pixi.js';
+
+/**
+ * Покадровая анимация на спрайте: список кадров + fps, зацикливание.
+ * Кадры можно менять на лету (повороты, состояния).
+ */
+export class FrameAnimation {
+ private frames: Texture[];
+ private fps: number;
+ private loop: boolean;
+ private t = 0;
+ private index = 0;
+
+ constructor(
+ private sprite: Sprite,
+ frames: Texture[],
+ fps = 8,
+ loop = true
+ ) {
+ this.frames = frames;
+ this.fps = fps;
+ this.loop = loop;
+ }
+
+ setFrames(frames: Texture[], reset = false): void {
+ this.frames = frames;
+ if (reset) {
+ this.index = 0;
+ this.t = 0;
+ }
+ }
+
+ update(dt: number): void {
+ if (this.frames.length === 0) return;
+ this.t += dt * this.fps;
+ while (this.t >= 1) {
+ this.t -= 1;
+ if (this.index + 1 < this.frames.length) {
+ this.index++;
+ } else if (this.loop) {
+ this.index = 0;
+ } else {
+ this.t = 0;
+ break;
+ }
+ }
+ this.sprite.texture = this.frames[this.index] ?? this.sprite.texture;
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/assets/AssetLoader.ts b/packages/engine/src/assets/AssetLoader.ts
new file mode 100644
index 0000000..7a268d0
--- /dev/null
+++ b/packages/engine/src/assets/AssetLoader.ts
@@ -0,0 +1,32 @@
+import { Assets, Texture } from 'pixi.js';
+
+/**
+ * Загрузчик ассетов поверх Assets из PixiJS: группы, прогресс, готовые Texture.
+ * URL ассетов передаётся функцией-резолвером (приложение решает, где лежат файлы).
+ */
+export class AssetLoader {
+ private textures = new Map();
+
+ constructor(private resolveUrl: (key: string) => string) {}
+
+ /** Загрузить группу ключей; onProgress получает долю 0..1. */
+ async load(keys: string[], onProgress?: (p: number) => void): Promise {
+ let done = 0;
+ for (const key of keys) {
+ const tex = await Assets.load(this.resolveUrl(key));
+ this.textures.set(key, tex);
+ done++;
+ onProgress?.(done / keys.length);
+ }
+ }
+
+ texture(key: string): Texture {
+ const t = this.textures.get(key);
+ if (!t) throw new Error(`Текстура не загружена: ${key}`);
+ return t;
+ }
+
+ has(key: string): boolean {
+ return this.textures.has(key);
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts
new file mode 100644
index 0000000..ffa82ce
--- /dev/null
+++ b/packages/engine/src/audio/AudioManager.ts
@@ -0,0 +1,44 @@
+/**
+ * Минимальная обёртка WebAudio: загрузка сэмплов в буферы и проигрывание.
+ * Музыку и сложные графы можно достроить позже на этой же базе.
+ */
+export class AudioManager {
+ private ctx: AudioContext | null = null;
+ private buffers = new Map();
+ private urls = new Map();
+
+ constructor(private resolveUrl: (key: string) => string) {}
+
+ /** Расблокировать аудио — вызвать из обработчика пользовательского ввода. */
+ async unlock(): Promise {
+ if (!this.ctx) {
+ this.ctx = new AudioContext();
+ }
+ if (this.ctx.state === 'suspended') {
+ await this.ctx.resume();
+ }
+ }
+
+ async load(key: string): Promise {
+ this.urls.set(key, this.resolveUrl(key));
+ }
+
+ /** Проиграть (если звук не загружен — тихо ничего не делает). */
+ async play(key: string, volume = 1): Promise {
+ if (!this.ctx) return;
+ let buf = this.buffers.get(key);
+ const url = this.urls.get(key);
+ if (!buf && url) {
+ const res = await fetch(url);
+ buf = await this.ctx.decodeAudioData(await res.arrayBuffer());
+ this.buffers.set(key, buf);
+ }
+ if (!buf) return;
+ const source = this.ctx.createBufferSource();
+ source.buffer = buf;
+ const gain = this.ctx.createGain();
+ gain.gain.value = volume;
+ source.connect(gain).connect(this.ctx.destination);
+ source.start();
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/core/Engine.ts b/packages/engine/src/core/Engine.ts
new file mode 100644
index 0000000..d208cff
--- /dev/null
+++ b/packages/engine/src/core/Engine.ts
@@ -0,0 +1,73 @@
+import { Application } from 'pixi.js';
+import { Renderer, RendererOptions } from '../render/Renderer';
+import { Camera } from '../render/Camera';
+import { GameLoop } from './GameLoop';
+import { EventBus } from './EventBus';
+import { InputManager } from '../input/InputManager';
+import { SceneManager } from '../scene/SceneManager';
+
+/**
+ * Собирает подсистемы движка. Приложение создаёт Engine, добавляет стартовую
+ * сцену через scenes.replace() и вызывает start().
+ */
+export interface EngineOptions extends RendererOptions {
+ /** FPS фиксированного шага. */
+ fixedFps: number;
+}
+
+export class Engine {
+ readonly renderer: Renderer;
+ readonly camera: Camera;
+ readonly events = new EventBus();
+ readonly scenes = new SceneManager();
+ input!: InputManager;
+ private loop: GameLoop;
+ private started = false;
+
+ constructor(options: EngineOptions) {
+ this.renderer = new Renderer(options);
+ this.camera = new Camera(options.virtualWidth, options.virtualHeight);
+ this.loop = new GameLoop(options.fixedFps, {
+ update: (dt) => this.tick(dt),
+ render: () => this.render()
+ });
+ }
+
+ /**
+ * Инициализация WebGL и ввода. Вызвать до scenes.replace()/start().
+ * Возвращает готовый Application (для прямого доступа к Pixi при необходимости).
+ */
+ async init(): Promise {
+ await this.renderer.setup();
+ this.input = new InputManager(
+ this.renderer.app.canvas,
+ this.renderer.virtualWidth,
+ this.renderer.virtualHeight
+ );
+ return this.renderer.app;
+ }
+
+ async start(): Promise {
+ if (this.started) return;
+ this.started = true;
+ this.loop.start();
+ }
+
+ stop(): void {
+ this.loop.stop();
+ this.input?.dispose();
+ this.events.clear();
+ }
+
+ /** Один фиксированный шаг. */
+ private tick(dt: number): void {
+ this.scenes.update(dt);
+ this.input?.endTick();
+ }
+
+ private render(): void {
+ this.camera.apply(this.renderer.worldRoot);
+ this.scenes.render();
+ this.renderer.render();
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/core/EventBus.ts b/packages/engine/src/core/EventBus.ts
new file mode 100644
index 0000000..941696c
--- /dev/null
+++ b/packages/engine/src/core/EventBus.ts
@@ -0,0 +1,30 @@
+/**
+ * Простая шина событий с типизированными темами.
+ * Подписка: on('enemy:killed', handler); шина ничего не знает о жанре.
+ */
+export class EventBus {
+ private listeners = new Map void>>();
+
+ on(topic: string, handler: (payload: T) => void): () => void {
+ let set = this.listeners.get(topic);
+ if (!set) {
+ set = new Set();
+ this.listeners.set(topic, set);
+ }
+ const wrapped = handler as (payload: unknown) => void;
+ set.add(wrapped);
+ return () => set!.delete(wrapped);
+ }
+
+ emit(topic: string, payload?: T): void {
+ const set = this.listeners.get(topic);
+ if (!set) return;
+ for (const handler of [...set]) {
+ handler(payload);
+ }
+ }
+
+ clear(): void {
+ this.listeners.clear();
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/core/GameLoop.ts b/packages/engine/src/core/GameLoop.ts
new file mode 100644
index 0000000..f76d506
--- /dev/null
+++ b/packages/engine/src/core/GameLoop.ts
@@ -0,0 +1,61 @@
+/**
+ * Игровой цикл: фиксированный шаг обновления (60 Гц по умолчанию) + отрисовка
+ * после каждого шага (рендер с последним состоянием, без интерполяции —
+ * на 2D-пиксель-арте это стандартный компромисс).
+ */
+export interface LoopCallbacks {
+ /** dt в секундах, всегда фиксированный. */
+ update: (dt: number) => void;
+ render: () => void;
+}
+
+export class GameLoop {
+ /** Длительность фиксированного шага, сек. */
+ readonly step: number;
+ /** Максимум шагов за кадр (защита от «догоняния» после лагов). */
+ maxStepsPerFrame = 5;
+
+ private callbacks: LoopCallbacks;
+ private rafId = 0;
+ private lastTime = 0;
+ private accumulator = 0;
+ private running = false;
+
+ constructor(fps: number, callbacks: LoopCallbacks) {
+ this.step = 1 / fps;
+ this.callbacks = callbacks;
+ }
+
+ start(): void {
+ if (this.running) return;
+ this.running = true;
+ this.lastTime = performance.now();
+ this.rafId = requestAnimationFrame(this.tick);
+ }
+
+ stop(): void {
+ this.running = false;
+ cancelAnimationFrame(this.rafId);
+ }
+
+ private tick = (now: number): void => {
+ if (!this.running) return;
+ this.rafId = requestAnimationFrame(this.tick);
+
+ this.accumulator += Math.min((now - this.lastTime) / 1000, 0.25);
+ this.lastTime = now;
+
+ let steps = 0;
+ while (this.accumulator >= this.step && steps < this.maxStepsPerFrame) {
+ this.callbacks.update(this.step);
+ this.accumulator -= this.step;
+ steps++;
+ }
+ if (steps === this.maxStepsPerFrame) {
+ // Отбрасываем накопившийся долг, чтобы не зациклиться.
+ this.accumulator = 0;
+ }
+
+ this.callbacks.render();
+ };
+}
\ No newline at end of file
diff --git a/packages/engine/src/debug/DebugOverlay.ts b/packages/engine/src/debug/DebugOverlay.ts
new file mode 100644
index 0000000..516682e
--- /dev/null
+++ b/packages/engine/src/debug/DebugOverlay.ts
@@ -0,0 +1,40 @@
+import { Text, Container } from 'pixi.js';
+
+/**
+ * Дебаг-оверлей: FPS и произвольные строки статистики.
+ * Добавьте view в uiRoot и вызывайте update(dt) каждый тик.
+ */
+export class DebugOverlay {
+ readonly view: Container;
+ private text: Text;
+ private lines: string[] = [];
+ private acc = 0;
+ private frames = 0;
+ private fps = 0;
+
+ constructor(enabled = true) {
+ this.view = new Container();
+ this.view.visible = enabled;
+ this.text = new Text({
+ text: '',
+ style: { fontFamily: 'monospace', fontSize: 8, fill: 0x00ff88, lineHeight: 10 }
+ });
+ this.view.addChild(this.text);
+ }
+
+ /** Задать строки статуса (fps подставляется автоматически). */
+ setLines(lines: string[]): void {
+ this.lines = lines;
+ }
+
+ update(dt: number): void {
+ this.acc += dt;
+ this.frames++;
+ if (this.acc >= 0.5) {
+ this.fps = Math.round(this.frames / this.acc);
+ this.acc = 0;
+ this.frames = 0;
+ }
+ this.text.text = `fps ${this.fps}\n${this.lines.join('\n')}`;
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/ecs/__tests__/ecs.test.ts b/packages/engine/src/ecs/__tests__/ecs.test.ts
new file mode 100644
index 0000000..dd0c484
--- /dev/null
+++ b/packages/engine/src/ecs/__tests__/ecs.test.ts
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest';
+import { World, type System } from '../ecs';
+
+describe('ECS', () => {
+ it('создание, компоненты, запросы', () => {
+ const world = new World();
+ const a = world.createEntity();
+ const b = world.createEntity();
+
+ world.addComponent(a, 'pos', { x: 1, y: 2 });
+ world.addComponent(a, 'hp', { value: 10 });
+ world.addComponent(b, 'pos', { x: 0, y: 0 });
+
+ expect(world.query('pos')).toHaveLength(2);
+ expect(world.query('pos', 'hp')).toEqual([a]);
+ expect(world.getComponent<{ value: number }>(a, 'hp')!.value).toBe(10);
+
+ world.destroyEntity(a);
+ expect(world.isAlive(a)).toBe(false);
+ expect(world.query('pos')).toEqual([b]);
+ });
+
+ it('системы обновляются по порядку', () => {
+ const world = new World();
+ const log: string[] = [];
+ const makeSys = (name: string): System => ({
+ update(_w, _dt) {
+ log.push(name);
+ }
+ });
+ world.addSystem(makeSys('first'));
+ world.addSystem(makeSys('second'));
+ world.update(1 / 60);
+ expect(log).toEqual(['first', 'second']);
+ });
+});
\ No newline at end of file
diff --git a/packages/engine/src/ecs/ecs.ts b/packages/engine/src/ecs/ecs.ts
new file mode 100644
index 0000000..18d14a4
--- /dev/null
+++ b/packages/engine/src/ecs/ecs.ts
@@ -0,0 +1,79 @@
+/**
+ * Минимальный ECS: сущность — число, компоненты — объекты по имени, системы — функции.
+ * Хватает для 2D-игр: без метапрограммирования, всё видно в отладчике.
+ */
+
+export type Entity = number;
+
+export interface System {
+ /** Вызывается каждый тик фиксированного шага. dt — секунды. */
+ update(world: World, dt: number): void;
+}
+
+export class World {
+ private nextId = 1;
+ private alive = new Set();
+ /** entity -> (имя компонента -> компонент). */
+ private components = new Map>();
+ private systems: System[] = [];
+
+ createEntity(): Entity {
+ const e = this.nextId++;
+ this.alive.add(e);
+ this.components.set(e, new Map());
+ return e;
+ }
+
+ destroyEntity(e: Entity): void {
+ this.alive.delete(e);
+ this.components.delete(e);
+ }
+
+ isAlive(e: Entity): boolean {
+ return this.alive.has(e);
+ }
+
+ addComponent(e: Entity, name: string, component: T): T {
+ this.components.get(e)!.set(name, component);
+ return component;
+ }
+
+ getComponent(e: Entity, name: string): T | undefined {
+ return this.components.get(e)?.get(name) as T | undefined;
+ }
+
+ hasComponent(e: Entity, name: string): boolean {
+ return this.components.get(e)?.has(name) ?? false;
+ }
+
+ removeComponent(e: Entity, name: string): void {
+ this.components.get(e)?.delete(name);
+ }
+
+ /** Все живые сущности с указанным компонентом. */
+ query(...names: string[]): Entity[] {
+ const out: Entity[] = [];
+ for (const e of this.alive) {
+ const map = this.components.get(e)!;
+ let ok = true;
+ for (const n of names) {
+ if (!map.has(n)) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok) out.push(e);
+ }
+ return out;
+ }
+
+ addSystem(system: System): void {
+ this.systems.push(system);
+ }
+
+ update(dt: number): void {
+ for (const s of this.systems) {
+ s.update(this, dt);
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts
new file mode 100644
index 0000000..2067fd9
--- /dev/null
+++ b/packages/engine/src/index.ts
@@ -0,0 +1,56 @@
+/**
+ * Публичный API движка. Игры импортируют ТОЛЬКО отсюда.
+ */
+
+// re-export PixiJS-типов, чтобы игре не нужен был прямой импорт pixi.js
+export { Container, Graphics, Text, Sprite, Texture } from 'pixi.js';
+
+// core
+export { Engine, type EngineOptions } from './core/Engine';
+export { GameLoop } from './core/GameLoop';
+export { EventBus } from './core/EventBus';
+
+// scene
+export { SceneManager, type Scene } from './scene/SceneManager';
+
+// ecs
+export { World, type Entity, type System } from './ecs/ecs';
+
+// math
+export { vec2, add, sub, len, dist, moveTo, type Vec2 } from './math/Vec2';
+export {
+ isoToScreen,
+ screenToIso,
+ screenToIsoExact,
+ DEFAULT_ISO,
+ type IsoLayout
+} from './math/iso';
+
+// map
+export { IsometricTileMap, type TileMapData } from './map/IsometricTileMap';
+export { findPath, type Grid } from './map/pathfinding';
+
+// render
+export { Renderer, type RendererOptions } from './render/Renderer';
+export { Camera } from './render/Camera';
+
+// input
+export { InputManager, type PointerState } from './input/InputManager';
+
+// anim
+export { FrameAnimation } from './anim/FrameAnimation';
+
+// ui
+export { DialogueBox, type DialogueLine, type DialogueBoxOptions } from './ui/DialogueBox';
+
+// audio
+export { AudioManager } from './audio/AudioManager';
+
+// assets
+export { AssetLoader } from './assets/AssetLoader';
+
+// save
+export { SaveManager, type StorageLike } from './save/SaveManager';
+
+// debug
+export { DebugOverlay } from './debug/DebugOverlay';
\ No newline at end of file
diff --git a/packages/engine/src/input/InputManager.ts b/packages/engine/src/input/InputManager.ts
new file mode 100644
index 0000000..9189b74
--- /dev/null
+++ b/packages/engine/src/input/InputManager.ts
@@ -0,0 +1,160 @@
+/**
+ * Ввод: клавиатура (маппинг «действие -> клавиши») и указатель (мышь/тач)
+ * в координатах виртуального разрешения.
+ */
+
+export interface PointerState {
+ /** Виртуальные пиксели. */
+ x: number;
+ y: number;
+ down: boolean;
+ /** Нажато в этом кадре. */
+ justPressed: boolean;
+ /** Отпущено в этом кадре. */
+ justReleased: boolean;
+ /** true, если это событие тача (для мобильного управления). */
+ isTouch: boolean;
+}
+
+export interface PointerEventHandlers {
+ onPointerDown?: (p: PointerState) => void;
+ onPointerUp?: (p: PointerState) => void;
+ onPointerMove?: (p: PointerState) => void;
+}
+
+export class InputManager {
+ private keysDown = new Set();
+ private keysPressed = new Set();
+ private keyActions = new Map();
+
+ private pointer: PointerState = {
+ x: 0,
+ y: 0,
+ down: false,
+ justPressed: false,
+ justReleased: false,
+ isTouch: false
+ };
+
+ private canvas: HTMLCanvasElement;
+ private virtualWidth: number;
+ private virtualHeight: number;
+ private handlers: PointerEventHandlers;
+
+ constructor(
+ canvas: HTMLCanvasElement,
+ virtualWidth: number,
+ virtualHeight: number,
+ handlers: PointerEventHandlers = {}
+ ) {
+ this.canvas = canvas;
+ this.virtualWidth = virtualWidth;
+ this.virtualHeight = virtualHeight;
+ this.handlers = handlers;
+
+ window.addEventListener('keydown', this.onKeyDown);
+ window.addEventListener('keyup', this.onKeyUp);
+ canvas.addEventListener('pointerdown', this.onPointerDown);
+ canvas.addEventListener('pointerup', this.onPointerUp);
+ canvas.addEventListener('pointermove', this.onPointerMove);
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault());
+ }
+
+ /** Маппинг: действие -> список кодов клавиш (e.code). */
+ bindActions(map: Record): void {
+ for (const [action, codes] of Object.entries(map)) {
+ for (const code of codes) {
+ this.keyActions.set(code, action);
+ }
+ }
+ }
+
+ /** Действие активно (зажато). */
+ isActionActive(action: string): boolean {
+ for (const [code, a] of this.keyActions) {
+ if (a === action && this.keysDown.has(code)) return true;
+ }
+ return false;
+ }
+
+ /** Действие нажато именно в этом тике. */
+ isActionJustPressed(action: string): boolean {
+ for (const code of this.keysPressed) {
+ if (this.keyActions.get(code) === action) return true;
+ }
+ return false;
+ }
+
+ isKeyDown(code: string): boolean {
+ return this.keysDown.has(code);
+ }
+
+ wasKeyPressed(code: string): boolean {
+ return this.keysPressed.has(code);
+ }
+
+ getPointer(): Readonly {
+ return this.pointer;
+ }
+
+ /** Вызывается в конце каждого тика: очищает «just pressed/released». */
+ endTick(): void {
+ this.keysPressed.clear();
+ this.pointer.justPressed = false;
+ this.pointer.justReleased = false;
+ }
+
+ dispose(): void {
+ window.removeEventListener('keydown', this.onKeyDown);
+ window.removeEventListener('keyup', this.onKeyUp);
+ this.canvas.removeEventListener('pointerdown', this.onPointerDown);
+ this.canvas.removeEventListener('pointerup', this.onPointerUp);
+ this.canvas.removeEventListener('pointermove', this.onPointerMove);
+ }
+
+ private onKeyDown = (e: KeyboardEvent): void => {
+ if (!this.keysDown.has(e.code)) {
+ this.keysPressed.add(e.code);
+ }
+ this.keysDown.add(e.code);
+ };
+
+ private onKeyUp = (e: KeyboardEvent): void => {
+ this.keysDown.delete(e.code);
+ };
+
+ private toVirtual(e: PointerEvent): { x: number; y: number } {
+ const rect = this.canvas.getBoundingClientRect();
+ return {
+ x: ((e.clientX - rect.left) / rect.width) * this.virtualWidth,
+ y: ((e.clientY - rect.top) / rect.height) * this.virtualHeight
+ };
+ }
+
+ private onPointerDown = (e: PointerEvent): void => {
+ const { x, y } = this.toVirtual(e);
+ this.pointer.x = x;
+ this.pointer.y = y;
+ this.pointer.down = true;
+ this.pointer.justPressed = true;
+ this.pointer.isTouch = e.pointerType === 'touch';
+ this.handlers.onPointerDown?.(this.pointer);
+ };
+
+ private onPointerUp = (e: PointerEvent): void => {
+ const { x, y } = this.toVirtual(e);
+ this.pointer.x = x;
+ this.pointer.y = y;
+ this.pointer.down = false;
+ this.pointer.justReleased = true;
+ this.pointer.isTouch = e.pointerType === 'touch';
+ this.handlers.onPointerUp?.(this.pointer);
+ };
+
+ private onPointerMove = (e: PointerEvent): void => {
+ const { x, y } = this.toVirtual(e);
+ this.pointer.x = x;
+ this.pointer.y = y;
+ this.handlers.onPointerMove?.(this.pointer);
+ };
+}
\ No newline at end of file
diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts
new file mode 100644
index 0000000..3cf1552
--- /dev/null
+++ b/packages/engine/src/map/IsometricTileMap.ts
@@ -0,0 +1,119 @@
+import { Container, Graphics, Texture, Sprite } from 'pixi.js';
+import { Grid } from './pathfinding';
+import { IsoLayout, DEFAULT_ISO, isoToScreen } from '../math/iso';
+
+/**
+ * Изометрическая тайл-карта.
+ * Данные карты — числа (id тайлов); отрисовка — по таблице id -> Texture.
+ * Проходимость определяется набором блокирующих id; тайлы «высоких» объектов
+ * (стены, деревья) рисуются поверх земли по строкам глубины.
+ */
+export interface TileMapData {
+ width: number;
+ height: number;
+ /** Индексы тайлов, length = width * height. */
+ tiles: number[];
+ /** Тайлы с этими id непроходимы. */
+ blocked: number[];
+ /** Тайлы с этими id рисуются как вертикальные объекты (высота heightPx). */
+ tall?: Record;
+}
+
+export class IsometricTileMap implements Grid {
+ readonly data: TileMapData;
+ readonly iso: IsoLayout;
+ readonly view: Container;
+
+ private blockedSet: Set;
+
+ get width(): number {
+ return this.data.width;
+ }
+
+ get height(): number {
+ return this.data.height;
+ }
+
+ constructor(data: TileMapData, textures: Map, iso: IsoLayout = DEFAULT_ISO) {
+ this.data = data;
+ this.iso = iso;
+ this.blockedSet = new Set(data.blocked);
+ this.view = new Container();
+
+ const ground = new Container();
+ const objects = new Container();
+ this.view.addChild(ground, objects);
+
+ // Рисуем по строкам (ty) внутри — для простоты; сортировка глубины по (tx + ty)
+ // для объектов обеспечивается порядком добавления по диагоналям ниже.
+ const cells: { tx: number; ty: number; id: number }[] = [];
+ for (let ty = 0; ty < data.height; ty++) {
+ for (let tx = 0; tx < data.width; tx++) {
+ cells.push({ tx, ty, id: data.tiles[ty * data.width + tx] });
+ }
+ }
+ // Глубина = tx + ty (классика изометрии).
+ cells.sort((a, b) => a.tx + a.ty - (b.tx + b.ty));
+
+ for (const cell of cells) {
+ const tex = textures.get(cell.id);
+ const p = isoToScreen(cell.tx, cell.ty, iso);
+ if (tex) {
+ const s = new Sprite(tex);
+ s.anchor.set(0.5, 0);
+ s.position.set(p.x, p.y);
+ ground.addChild(s);
+ } else {
+ // Нет текстуры — плейсхолдер-ромб: зелёный (проходимо) или коричневый (блок).
+ const g = new Graphics();
+ const hw = iso.tileW / 2;
+ const hh = iso.tileH / 2;
+ g.poly([p.x, p.y, p.x + hw, p.y + hh, p.x, p.y + iso.tileH, p.x - hw, p.y + hh]);
+ g.fill(this.blockedSet.has(cell.id) ? 0x4a3b2a : 0x2d5a27);
+ ground.addChild(g);
+ }
+
+ const tallHeight = data.tall?.[cell.id];
+ if (tallHeight !== undefined) {
+ // Столб высоты: ромб сверху + тёмные грани, чтобы объект читался объёмным.
+ const block = new Graphics();
+ block.poly([
+ p.x, p.y - tallHeight,
+ p.x + iso.tileW / 2, p.y + iso.tileH / 2 - tallHeight,
+ p.x, p.y + iso.tileH - tallHeight,
+ p.x - iso.tileW / 2, p.y + iso.tileH / 2 - tallHeight
+ ]);
+ block.fill(0x6b5a44);
+ // Грани вниз на высоту tallHeight.
+ block.poly([
+ p.x - iso.tileW / 2, p.y + iso.tileH / 2 - tallHeight,
+ p.x, p.y + iso.tileH - tallHeight,
+ p.x, p.y + iso.tileH,
+ p.x - iso.tileW / 2, p.y + iso.tileH / 2
+ ]);
+ block.fill(0x4a3b2a);
+ block.poly([
+ p.x + iso.tileW / 2, p.y + iso.tileH / 2 - tallHeight,
+ p.x, p.y + iso.tileH - tallHeight,
+ p.x, p.y + iso.tileH,
+ p.x + iso.tileW / 2, p.y + iso.tileH / 2
+ ]);
+ block.fill(0x352a1e);
+ objects.addChild(block);
+ }
+ }
+ }
+
+ isWalkable(x: number, y: number): boolean {
+ if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return false;
+ const id = this.data.tiles[y * this.data.width + x];
+ return !this.blockedSet.has(id);
+ }
+
+ /** Размер карты в экранных координатах (для ограничения камеры). */
+ get screenSize(): { width: number; height: number } {
+ const halfW = (this.iso.tileW / 2) * (this.data.width + this.data.height);
+ const halfH = (this.iso.tileH / 2) * (this.data.width + this.data.height);
+ return { width: halfW, height: halfH };
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/map/__tests__/pathfinding.test.ts b/packages/engine/src/map/__tests__/pathfinding.test.ts
new file mode 100644
index 0000000..ecc8e2c
--- /dev/null
+++ b/packages/engine/src/map/__tests__/pathfinding.test.ts
@@ -0,0 +1,67 @@
+import { describe, it, expect } from 'vitest';
+import { findPath, type Grid } from '../pathfinding';
+
+function makeGrid(width: number, height: number, walls: string[] = []): Grid {
+ const wallSet = new Set(walls);
+ return {
+ width,
+ height,
+ isWalkable: (x, y) => !wallSet.has(`${x},${y}`)
+ };
+}
+
+describe('A*', () => {
+ it('прямой путь по пустой сетке', () => {
+ const path = findPath(makeGrid(10, 10), { x: 0, y: 0 }, { x: 3, y: 0 });
+ expect(path).toEqual([
+ { x: 1, y: 0 },
+ { x: 2, y: 0 },
+ { x: 3, y: 0 }
+ ]);
+ });
+
+ it('обходит стену', () => {
+ const grid = makeGrid(5, 5, ['1,0', '1,1', '1,2']);
+ const path = findPath(grid, { x: 0, y: 0 }, { x: 2, y: 0 });
+ expect(path).not.toBeNull();
+ expect(path!.some((p) => p.x === 1 && (p.y === 0 || p.y === 1 || p.y === 2))).toBe(false);
+ expect(path![path!.length - 1]).toEqual({ x: 2, y: 0 });
+ });
+
+ it('цель заблокирована — null', () => {
+ const grid = makeGrid(5, 5, ['2,2']);
+ expect(findPath(grid, { x: 0, y: 0 }, { x: 2, y: 2 })).toBeNull();
+ });
+
+ it('цель вне сетки — null', () => {
+ const grid = makeGrid(5, 5);
+ expect(findPath(grid, { x: 0, y: 0 }, { x: 9, y: 9 })).toBeNull();
+ });
+
+ it('старт == цель — пустой путь', () => {
+ const grid = makeGrid(5, 5);
+ expect(findPath(grid, { x: 2, y: 2 }, { x: 2, y: 2 })).toEqual([]);
+ });
+
+ it('диагональ без среза углов', () => {
+ // Стена (1,0) срезает угол диагонали (0,0)->(1,1) — она запрещена,
+ // путь идёт в обход через (0,1).
+ const grid = makeGrid(5, 5, ['1,0']);
+ const path = findPath(grid, { x: 0, y: 0 }, { x: 1, y: 1 }, true);
+ expect(path).not.toBeNull();
+ expect(path![0]).toEqual({ x: 0, y: 1 });
+ });
+
+ it('угол срезан со всех сторон — пути нет', () => {
+ // Из (0,0) заблокированы оба ортогональных соседа: без среза углов
+ // ни шага, ни диагонали сделать нельзя.
+ const grid = makeGrid(5, 5, ['1,0', '0,1']);
+ expect(findPath(grid, { x: 0, y: 0 }, { x: 2, y: 2 }, true)).toBeNull();
+ });
+
+ it('диагональ разрешена, когда соседи свободны', () => {
+ const grid = makeGrid(5, 5);
+ const path = findPath(grid, { x: 0, y: 0 }, { x: 1, y: 1 }, true);
+ expect(path).toEqual([{ x: 1, y: 1 }]);
+ });
+});
\ No newline at end of file
diff --git a/packages/engine/src/map/pathfinding.ts b/packages/engine/src/map/pathfinding.ts
new file mode 100644
index 0000000..5059b19
--- /dev/null
+++ b/packages/engine/src/map/pathfinding.ts
@@ -0,0 +1,93 @@
+/**
+ * Поиск пути A* по тайловой сетке.
+ * Движение 4-стороннее; диагонали опциональны (с запретом «среза углов» через блокеры).
+ */
+
+export interface Grid {
+ width: number;
+ height: number;
+ /** Проходим ли тайл. */
+ isWalkable(x: number, y: number): boolean;
+}
+
+interface Node {
+ x: number;
+ y: number;
+ g: number;
+ f: number;
+ parent: Node | null;
+}
+
+function heuristic(a: { x: number; y: number }, b: { x: number; y: number }): number {
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
+}
+
+/**
+ * Возвращает путь (без стартового тайла, включая конечный) или null, если пути нет.
+ * Включение диагоналей добавляет шаги (±1, ±1), но только если оба ортогональных
+ * соседа проходимы (без среза углов).
+ */
+export function findPath(
+ grid: Grid,
+ start: { x: number; y: number },
+ goal: { x: number; y: number },
+ allowDiagonal = false
+): { x: number; y: number }[] | null {
+ if (!grid.isWalkable(goal.x, goal.y)) return null;
+ if (start.x === goal.x && start.y === goal.y) return [];
+
+ const key = (x: number, y: number) => y * grid.width + x;
+ const open: Node[] = [];
+ const best = new Map();
+ const startNode: Node = { x: start.x, y: start.y, g: 0, f: heuristic(start, goal), parent: null };
+ open.push(startNode);
+ best.set(key(start.x, start.y), 0);
+
+ const dirs4 = [
+ { x: 1, y: 0 },
+ { x: -1, y: 0 },
+ { x: 0, y: 1 },
+ { x: 0, y: -1 }
+ ];
+
+ while (open.length > 0) {
+ // Извлекаем узел с минимальным f (карты маленькие, линейного поиска достаточно).
+ let idx = 0;
+ for (let i = 1; i < open.length; i++) {
+ if (open[i].f < open[idx].f) idx = i;
+ }
+ const cur = open.splice(idx, 1)[0];
+
+ if (cur.x === goal.x && cur.y === goal.y) {
+ const path: { x: number; y: number }[] = [];
+ for (let n: Node | null = cur; n !== null; n = n.parent) {
+ path.unshift({ x: n.x, y: n.y });
+ }
+ path.shift(); // убираем стартовый тайл
+ return path;
+ }
+
+ const neighbors = allowDiagonal
+ ? [...dirs4, { x: 1, y: 1 }, { x: 1, y: -1 }, { x: -1, y: 1 }, { x: -1, y: -1 }]
+ : dirs4;
+
+ for (const d of neighbors) {
+ const nx = cur.x + d.x;
+ const ny = cur.y + d.y;
+ if (nx < 0 || ny < 0 || nx >= grid.width || ny >= grid.height) continue;
+ if (!grid.isWalkable(nx, ny)) continue;
+ if (d.x !== 0 && d.y !== 0) {
+ // Диагональ разрешена только без среза углов.
+ if (!grid.isWalkable(cur.x + d.x, cur.y) || !grid.isWalkable(cur.x, cur.y + d.y)) continue;
+ }
+
+ const g = cur.g + 1;
+ const k = key(nx, ny);
+ if (best.has(k) && best.get(k)! <= g) continue;
+ best.set(k, g);
+ open.push({ x: nx, y: ny, g, f: g + heuristic({ x: nx, y: ny }, goal), parent: cur });
+ }
+ }
+
+ return null;
+}
\ No newline at end of file
diff --git a/packages/engine/src/math/Vec2.ts b/packages/engine/src/math/Vec2.ts
new file mode 100644
index 0000000..0aeefa0
--- /dev/null
+++ b/packages/engine/src/math/Vec2.ts
@@ -0,0 +1,36 @@
+/**
+ * Простейшая 2D-векторная математика (виртуальные пиксели).
+ */
+export interface Vec2 {
+ x: number;
+ y: number;
+}
+
+export function vec2(x: number, y: number): Vec2 {
+ return { x, y };
+}
+
+export function add(a: Vec2, b: Vec2): Vec2 {
+ return { x: a.x + b.x, y: a.y + b.y };
+}
+
+export function sub(a: Vec2, b: Vec2): Vec2 {
+ return { x: a.x - b.x, y: a.y - b.y };
+}
+
+export function len(a: Vec2): number {
+ return Math.hypot(a.x, a.y);
+}
+
+export function dist(a: Vec2, b: Vec2): number {
+ return len(sub(a, b));
+}
+
+/** Перемещение из a в b не дальше, чем на step. */
+export function moveTo(a: Vec2, b: Vec2, step: number): Vec2 {
+ const d = dist(a, b);
+ if (d <= step || d === 0) {
+ return { x: b.x, y: b.y };
+ }
+ return { x: a.x + (b.x - a.x) * (step / d), y: a.y + (b.y - a.y) * (step / d) };
+}
\ No newline at end of file
diff --git a/packages/engine/src/math/__tests__/iso.test.ts b/packages/engine/src/math/__tests__/iso.test.ts
new file mode 100644
index 0000000..0a65c69
--- /dev/null
+++ b/packages/engine/src/math/__tests__/iso.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'vitest';
+import { isoToScreen, screenToIso, screenToIsoExact } from '../iso';
+
+describe('изометрия', () => {
+ it('нулевая точка — origin', () => {
+ expect(isoToScreen(0, 0)).toEqual({ x: 0, y: 0 });
+ });
+
+ it('оси дают ромб 2:1', () => {
+ expect(isoToScreen(1, 0)).toEqual({ x: 16, y: 8 });
+ expect(isoToScreen(0, 1)).toEqual({ x: -16, y: 8 });
+ expect(isoToScreen(2, 3)).toEqual({ x: -16, y: 40 });
+ });
+
+ it('screenToIso — обратная к isoToScreen в центрах тайлов', () => {
+ for (let tx = 0; tx < 6; tx++) {
+ for (let ty = 0; ty < 6; ty++) {
+ const c = isoToScreen(tx + 0.5, ty + 0.5); // центр ромба
+ const back = screenToIso(c.x, c.y);
+ expect(back.x).toBe(tx);
+ expect(back.y).toBe(ty);
+ }
+ }
+ });
+
+ it('screenToIsoExact попадает ровно в тайл, не в соседний', () => {
+ // Точка чуть ниже центра ромба (0,0) — остаётся в (0,0).
+ const center = isoToScreen(0, 0);
+ expect(screenToIsoExact(center.x, center.y + 5, 4, 4)).toEqual({ x: 0, y: 0 });
+ // Точка близко к левой грани, но внутри ромба (0,0).
+ const edge = isoToScreen(0, 0);
+ expect(screenToIsoExact(edge.x - 8, edge.y + 4, 4, 4)).toEqual({ x: 0, y: 0 });
+ // Точка внутри ромба соседнего тайла (-1, 0) — он вне карты -> null.
+ expect(screenToIsoExact(edge.x - 16, edge.y, 4, 4)).toBeNull();
+ // Вне карты — null.
+ expect(screenToIsoExact(-100, -100, 4, 4)).toBeNull();
+ });
+});
\ No newline at end of file
diff --git a/packages/engine/src/math/iso.ts b/packages/engine/src/math/iso.ts
new file mode 100644
index 0000000..b81887a
--- /dev/null
+++ b/packages/engine/src/math/iso.ts
@@ -0,0 +1,72 @@
+/**
+ * Изометрия 2:1 (классический «ромб»).
+ *
+ * Сетка — целочисленные координаты тайлов (tx, ty).
+ * Экран — виртуальные пиксели. Тайл рисуется ромбом шириной tileW и высотой tileH (2:1).
+ *
+ * Формулы:
+ * screenX = (tx - ty) * (tileW / 2)
+ * screenY = (tx + ty) * (tileH / 2)
+ * Обратная:
+ * tx = (screenX / (tileW/2) + screenY / (tileH/2)) / 2
+ * ty = (screenY / (tileH/2) - screenX / (tileW/2)) / 2
+ */
+
+export interface IsoLayout {
+ /** Ширина ромба тайла (пиксели, чётное). */
+ tileW: number;
+ /** Высота ромба тайла (пиксели, tileW / 2). */
+ tileH: number;
+ /** Экранные координаты верхнего левого угла нулевого тайла. */
+ originX: number;
+ originY: number;
+}
+
+/** Стандартный тайл 32x16. */
+export const DEFAULT_ISO: IsoLayout = { tileW: 32, tileH: 16, originX: 0, originY: 0 };
+
+export function isoToScreen(tx: number, ty: number, iso: IsoLayout = DEFAULT_ISO): { x: number; y: number } {
+ return {
+ x: Math.round((tx - ty) * (iso.tileW / 2) + iso.originX),
+ y: Math.round((tx + ty) * (iso.tileH / 2) + iso.originY)
+ };
+}
+
+export function screenToIso(sx: number, sy: number, iso: IsoLayout = DEFAULT_ISO): { x: number; y: number } {
+ const halfW = iso.tileW / 2;
+ const halfH = iso.tileH / 2;
+ const dx = (sx - iso.originX) / halfW;
+ const dy = (sy - iso.originY) / halfH;
+ return { x: Math.floor((dx + dy) / 2), y: Math.floor((dy - dx) / 2) };
+}
+
+/**
+ * Точное попадание внутрь ромба тайла (floor может дать соседний тайл на границе).
+ * Возвращает координаты тайла, ромб которого содержит точку, или null.
+ */
+export function screenToIsoExact(
+ sx: number,
+ sy: number,
+ mapW: number,
+ mapH: number,
+ iso: IsoLayout = DEFAULT_ISO
+): { x: number; y: number } | null {
+ const cand = screenToIso(sx, sy, iso);
+ for (let dy = 0; dy <= 1; dy++) {
+ for (let dx = 0; dx <= 1; dx++) {
+ const tx = cand.x + dx;
+ const ty = cand.y + dy;
+ const c = isoToScreen(tx, ty, iso);
+ const rx = sx - c.x;
+ const ry = sy - c.y;
+ // Ромб: |x/halfW| + |y/halfH| <= 1
+ if (Math.abs(rx) / (iso.tileW / 2) + Math.abs(ry) / (iso.tileH / 2) <= 1) {
+ if (tx >= 0 && ty >= 0 && tx < mapW && ty < mapH) {
+ return { x: tx, y: ty };
+ }
+ return null;
+ }
+ }
+ }
+ return null;
+}
\ No newline at end of file
diff --git a/packages/engine/src/render/Camera.ts b/packages/engine/src/render/Camera.ts
new file mode 100644
index 0000000..1c21f04
--- /dev/null
+++ b/packages/engine/src/render/Camera.ts
@@ -0,0 +1,45 @@
+import { Container } from 'pixi.js';
+
+/**
+ * Камера виртуального разрешения: позиция — центр взгляда в мировых (виртуальных) пикселях.
+ * Округляет смещение до целого пикселя, чтобы пиксель-арт не «дрожал».
+ */
+export class Camera {
+ x = 0;
+ y = 0;
+ /** Границы прокрутки (опционально). */
+ bounds: { width: number; height: number } | null = null;
+
+ constructor(
+ private readonly viewWidth: number,
+ private readonly viewHeight: number
+ ) {}
+
+ follow(targetX: number, targetY: number): void {
+ this.x = targetX;
+ this.y = targetY;
+ this.clamp();
+ }
+
+ apply(container: Container): void {
+ const halfW = Math.floor(this.viewWidth / 2);
+ const halfH = Math.floor(this.viewHeight / 2);
+ container.position.set(halfW - Math.round(this.x), halfH - Math.round(this.y));
+ }
+
+ private clamp(): void {
+ if (!this.bounds) return;
+ const halfW = this.viewWidth / 2;
+ const halfH = this.viewHeight / 2;
+ if (this.bounds.width > this.viewWidth) {
+ this.x = Math.min(Math.max(this.x, halfW), this.bounds.width - halfW);
+ } else {
+ this.x = this.bounds.width / 2;
+ }
+ if (this.bounds.height > this.viewHeight) {
+ this.y = Math.min(Math.max(this.y, halfH), this.bounds.height - halfH);
+ } else {
+ this.y = this.bounds.height / 2;
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/render/Renderer.ts b/packages/engine/src/render/Renderer.ts
new file mode 100644
index 0000000..5a2c8b7
--- /dev/null
+++ b/packages/engine/src/render/Renderer.ts
@@ -0,0 +1,70 @@
+import { Application, Container } from 'pixi.js';
+
+/**
+ * Рендерер: виртуальное «пиксельное» разрешение, растянутое целым числом на экран
+ * с image-rendering: pixelated — это и есть pixel-perfect для пиксель-арта.
+ */
+export interface RendererOptions {
+ /** Виртуальная ширина (например 480). */
+ virtualWidth: number;
+ /** Виртуальная высота (например 270). */
+ virtualHeight: number;
+ /** Целочисленный множитель масштаба. */
+ scale: number;
+ /** Цвет фона. */
+ background: number;
+ /** Родительский элемент для канваса. */
+ parent: HTMLElement;
+}
+
+export class Renderer {
+ readonly app: Application;
+ /** Корневой контейнер мира (к нему применяется камера). */
+ readonly worldRoot: Container;
+ /** Контейнер UI поверх мира (камерой не двигается). */
+ readonly uiRoot: Container;
+
+ readonly virtualWidth: number;
+ readonly virtualHeight: number;
+ readonly scale: number;
+
+ private readonly parentEl: HTMLElement;
+
+ constructor(options: RendererOptions) {
+ this.virtualWidth = options.virtualWidth;
+ this.virtualHeight = options.virtualHeight;
+ this.scale = options.scale;
+ this.parentEl = options.parent;
+
+ this.app = new Application();
+ void this.app.init({
+ width: options.virtualWidth,
+ height: options.virtualHeight,
+ backgroundColor: options.background,
+ antialias: false,
+ roundPixels: true,
+ autoStart: false,
+ sharedTicker: false
+ });
+
+ this.worldRoot = new Container();
+ this.uiRoot = new Container();
+ }
+
+ /** Дождаться инициализации WebGL и добавить канвас на страницу. */
+ async setup(): Promise {
+ await this.app.renderer.init;
+ this.app.stage.addChild(this.worldRoot, this.uiRoot);
+
+ const canvas = this.app.canvas;
+ canvas.style.imageRendering = 'pixelated';
+ canvas.style.width = `${this.virtualWidth * this.scale}px`;
+ canvas.style.height = `${this.virtualHeight * this.scale}px`;
+ this.parentEl.appendChild(canvas);
+ }
+
+ /** Рендер кадра (вызывается игровым циклом). */
+ render(): void {
+ this.app.renderer.render(this.app.stage);
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/save/SaveManager.ts b/packages/engine/src/save/SaveManager.ts
new file mode 100644
index 0000000..dcbcb0f
--- /dev/null
+++ b/packages/engine/src/save/SaveManager.ts
@@ -0,0 +1,48 @@
+/**
+ * Сохранения в JSON-слотах. Хранилище инъекцией (localStorage или любой
+ * Storage-подобный объект) — чтобы работать и в тестах без браузера.
+ */
+export interface StorageLike {
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+ removeItem(key: string): void;
+ key(index: number): string | null;
+ readonly length: number;
+}
+
+export class SaveManager {
+ constructor(
+ private storage: StorageLike,
+ private prefix = 'save:'
+ ) {}
+
+ save(slot: string, data: unknown): void {
+ this.storage.setItem(this.prefix + slot, JSON.stringify(data));
+ }
+
+ load(slot: string): T | null {
+ const raw = this.storage.getItem(this.prefix + slot);
+ if (raw === null) return null;
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+ }
+
+ delete(slot: string): void {
+ this.storage.removeItem(this.prefix + slot);
+ }
+
+ /** Список занятых слотов (без префикса). */
+ listSlots(): string[] {
+ const out: string[] = [];
+ for (let i = 0; i < this.storage.length; i++) {
+ const key = this.storage.key(i);
+ if (key && key.startsWith(this.prefix)) {
+ out.push(key.slice(this.prefix.length));
+ }
+ }
+ return out;
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/save/__tests__/SaveManager.test.ts b/packages/engine/src/save/__tests__/SaveManager.test.ts
new file mode 100644
index 0000000..0eb41c9
--- /dev/null
+++ b/packages/engine/src/save/__tests__/SaveManager.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest';
+import { SaveManager, type StorageLike } from '../SaveManager';
+
+function memoryStorage(): StorageLike {
+ const map = new Map();
+ return {
+ getItem: (k) => map.get(k) ?? null,
+ setItem: (k, v) => void map.set(k, v),
+ removeItem: (k) => void map.delete(k),
+ key: (i) => [...map.keys()][i] ?? null,
+ get length() {
+ return map.size;
+ }
+ };
+}
+
+describe('SaveManager', () => {
+ it('сохраняет и загружает по слотам', () => {
+ const sm = new SaveManager(memoryStorage());
+ sm.save('slot1', { x: 3, y: 4 });
+ expect(sm.load<{ x: number; y: number }>('slot1')).toEqual({ x: 3, y: 4 });
+ expect(sm.load('unknown')).toBeNull();
+ });
+
+ it('битый JSON -> null', () => {
+ const storage = memoryStorage();
+ storage.setItem('save:broken', '{oops');
+ const sm = new SaveManager(storage);
+ expect(sm.load('broken')).toBeNull();
+ });
+
+ it('listSlots и delete', () => {
+ const sm = new SaveManager(memoryStorage());
+ sm.save('a', 1);
+ sm.save('b', 2);
+ expect(sm.listSlots().sort()).toEqual(['a', 'b']);
+ sm.delete('a');
+ expect(sm.listSlots()).toEqual(['b']);
+ });
+});
\ No newline at end of file
diff --git a/packages/engine/src/scene/SceneManager.ts b/packages/engine/src/scene/SceneManager.ts
new file mode 100644
index 0000000..bec8884
--- /dev/null
+++ b/packages/engine/src/scene/SceneManager.ts
@@ -0,0 +1,48 @@
+/**
+ * Сцена — экран игры (меню, локация, бой). Менеджер держит стек,
+ * но в 2D-RPG чаще всего достаточно push/pop/replace.
+ */
+export interface Scene {
+ /** Вызывается один раз при входе. */
+ enter(): void | Promise;
+ exit(): void | Promise;
+ /** dt в секундах, фиксированный шаг. */
+ update(dt: number): void;
+ render(): void;
+}
+
+export class SceneManager {
+ private stack: Scene[] = [];
+
+ async push(scene: Scene): Promise {
+ this.stack.push(scene);
+ await scene.enter();
+ }
+
+ async pop(): Promise {
+ const scene = this.stack.pop();
+ if (scene) {
+ await scene.exit();
+ }
+ }
+
+ /** Заменить верхнюю сцену (например меню -> локация). */
+ async replace(scene: Scene): Promise {
+ await this.pop();
+ await this.push(scene);
+ }
+
+ get current(): Scene | undefined {
+ return this.stack[this.stack.length - 1];
+ }
+
+ update(dt: number): void {
+ this.current?.update(dt);
+ }
+
+ render(): void {
+ for (const scene of this.stack) {
+ scene.render();
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/ui/DialogueBox.ts b/packages/engine/src/ui/DialogueBox.ts
new file mode 100644
index 0000000..956384d
--- /dev/null
+++ b/packages/engine/src/ui/DialogueBox.ts
@@ -0,0 +1,81 @@
+import { Container, Graphics, Text } from 'pixi.js';
+
+/**
+ * Универсальное окно диалога: имя говорящего, текст, подсказка «далее».
+ * Жанронезависимо: игра сама решает, чей это диалог и что идёт дальше.
+ */
+export interface DialogueLine {
+ speaker: string;
+ text: string;
+}
+
+export interface DialogueBoxOptions {
+ /** Виртуальные пиксели. */
+ width: number;
+ height: number;
+ /** Отступы панели от краёв экрана. */
+ margin: number;
+}
+
+export class DialogueBox {
+ readonly view: Container;
+ visible = false;
+
+ private panel: Graphics;
+ private nameText: Text;
+ private bodyText: Text;
+ private hintText: Text;
+
+ constructor(options: DialogueBoxOptions) {
+ this.view = new Container();
+ this.view.visible = false;
+ this.view.eventMode = 'static';
+
+ this.panel = new Graphics();
+ this.nameText = new Text({
+ text: '',
+ style: { fontFamily: 'monospace', fontSize: 8, fill: 0xffffcc }
+ });
+ 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.view.addChild(this.panel, this.nameText, this.bodyText, this.hintText);
+ this.layout(options);
+ }
+
+ private layout(options: DialogueBoxOptions): void {
+ const w = options.width;
+ const h = options.height;
+ const m = options.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);
+ }
+
+ show(line: DialogueLine): void {
+ this.nameText.text = line.speaker;
+ this.bodyText.text = line.text;
+ this.view.visible = true;
+ this.visible = true;
+ }
+
+ hide(): void {
+ this.view.visible = false;
+ this.visible = false;
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json
new file mode 100644
index 0000000..9fe9be8
--- /dev/null
+++ b/packages/engine/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
\ No newline at end of file
diff --git a/tsconfig.base.json b/tsconfig.base.json
new file mode 100644
index 0000000..84105cc
--- /dev/null
+++ b/tsconfig.base.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "forceConsistentCasingInFileNames": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "noEmit": true
+ }
+}
\ No newline at end of file