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