diff --git a/.gitignore b/.gitignore index 0643ca2..2a0faa3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,10 @@ *.log .DS_Store # Сырые AI-листы (перегенерируются npm run aiart gen по фиксированному seed) -apps/game/tools/aiart/sheets/ +v1/apps/game/tools/aiart/sheets/ # Сырые AI-клипы аудио (перегенерируются gen_audioldm2.py по seed) -apps/game/tools/aiaudio/review/ +v1/apps/game/tools/aiaudio/review/ # B4: сырьё и кандидаты AI-арта (в assets/ попадают вручную после апрува) -apps/game/tools/aiart/review-b4/ +v1/apps/game/tools/aiart/review-b4/ __pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index e3d08bb..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,104 +0,0 @@ -# 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 для обоих пакетов -npm run lint # eslint (typescript-eslint, flat config) на оба пакета + тулзы -npm run guard # гвард границы движок/игра (импорты, JSON) -npm run check:fast # typecheck + guard + все тесты (~30 с, это же делает pre-commit хук) -npm run art # перегенерация пиксель-арта из apps/game/tools/pixelart -npm run art:lint # линтер арта: палитра/слоты/«мыло» по apps/game/assets -npm run aiart # пайплайн AI-атласов: gen/build/promote (apps/game/tools/aiart) -node apps/game/tools/aiart/tryon.mjs # примерка AI-сборки в игре до promote (скриншот /tmp/rpg_tryon.png) -npm run dialogues # визуальный редактор графов диалогов (порт 5299) -npm run audio # перегенерация WAV (apps/game/tools/audio/gen.mjs) -npm run maps # перезапись карт-файлов (RUN_MAPS_GEN=1; обычный npm test только сверяет fresh) -npm run agent:check # полный прогон проверок через агентный мост (JSON) -node apps/game/tools/agent.mjs run apps/game/tools/checks/xxx.mjs # один сценарий проверки -node apps/game/tools/agent.mjs snapshot --new-game # снапшот игры (JSON) -node apps/game/tools/smoke.mjs # смоук-тест в реальном Chromium (скриншот + консоль) -node apps/game/tools/smoke-act1.mjs # полный прогон акта 1 через агентный мост -node apps/game/tools/final-smoke.mjs # интеграционный смоук: 3 поездки луга<->Звенец, бой, guard аудио -``` - -Dev-сервер по умолчанию на http://localhost:5173; для отдельного порта: `npm run dev -- --port 5199`. - -## Архитектура - -Monorepo из двух workspace-пакетов с **жёсткой границей API**: - -- `packages/engine` (`@rpg/engine`) — жанронезависимый движок-библиотека. **Не должен знать ничего о RPG-контенте** (квесты, предметы, сюжет). Вся игра импортирует движок **только через `packages/engine/src/index.ts`** — другие внутренние пути движка импортировать нельзя. Границу проверяет гвард `npm run guard` (проба `guard:boundary` в `agent:check`). -- `apps/game` (`@rpg/game`) — сама RPG: контент, сцены, геймплейные системы. - -### Ключевые решения, которые нельзя сломать - -1. **PixiJS — только рендер-бэкенд.** Игровой цикл, сцены, ввод, камера, изометрия — собственные (`packages/engine/src/core`, `scene`, `input`, `render`). Pixi используется как низкоуровневый слой отрисовки спрайтов. -2. **Пиксель-арт pixel-perfect, резкий UI.** Логика — в виртуальном разрешении 480×270 (`Game.VIRTUAL_W/H`); CSS-размер канваса — виртуальное × **целое** (`computeScale` в `@rpg/engine`, движок сам слушает `window.resize`), а бэкинг-стор — в пикселях устройства (scale × dpr): текст/UI резкие, мир пиксельный (текстуры `AssetLoader` сэмплируются `nearest`). `roundPixels: true`, `antialias: false`. При изменении разрешения следи, чтобы CSS-масштаб оставался целым. -3. **Фиксированный шаг.** `GameLoop` (60 Гц update) + аккумулятор с ограничением 5 шагов/кадр; `InputManager.endTick()` очищает «just pressed» в конце каждого тика — не вызывать update вне цикла движка. -4. **Изометрия 2:1, мир в юнитах.** Источник истины для позиций — **мировые юниты** (float в плоскости изометрии, 1 юнит = 1 тайл): `worldToScreen`/`screenToWorld`/`worldDist`/`worldNorm` в `math/iso.ts`. Две «линейки»: точка проецируется анизотропно (`tileW/2` px по X, `tileH/2` по Y), скаляры (дистанция, радиус, скорость, высота) — через `unitsToPx` (`px = units·tileW`, 32 px/юнит). Округление до целого px — только на границе мир→экран. Тайловые координаты всюду именуются `{x, y}` (как `Grid` в pathfinding); для них есть мосты `tileToWorld`/`worldToTile`. -5. **Собственные классы движка с инъекцией хранилища.** `SaveManager` принимает `StorageLike` (в браузере — `localStorage`), чтобы тесты работали без DOM. Чистую математику (iso, A*, ECS) держи без зависимостей от Pixi — она тестируется в Vitest без браузера. - -### Где что лежит - -- `docs/llms.txt` — **точка входа для ИИ-агента**: карта всех док одной строкой на док. -- `docs/engine/` — **документация движка** (по-русски): `README.md` (архитектура и принципы), `getting-started.md`, `core.md`, `render.md`, `anim.md` (клипы/аниматоры/тик), `input.md`, `maps.md`, `inventory.md` (модель Inventory), `registry.md` (SceneRegistry), `ui-and-dialogue.md`, `cutscene.md`, `assets-audio-save.md`, `art-pipeline.md`, `recipes.md`, `agent.md` (агентный мост), `practices.md` (живой документ практик агента). При изменении API движка обновляй соответствующий файл и `practices.md` (если появился новый приём) **в том же коммите**. -- `docs/demo.md` — **внутренняя проектная дока среза**: матрица «подсистема движка → где показана в игре» + статус, боевая модель, архитектура, дорожная карта. -- `docs/plan.md` — **живой план развития**: актуальные задачи, проработка крупных направлений (AI-генерация спрайтов/аудио, ограничения железа). Новые планы вести здесь. -- `docs/world.md` — библия мира (сеттинг, локации, персонажи, сюжет). **Любой новый контент сверять с ней.** -- `docs/art-style.md` — арт-библия: палитра (32 цвета), размеры спрайтов, правила стиля, чеклист ассетов. -- `packages/engine/src/` — модули движка: `core/` (Engine, GameLoop, Tween, GameState, StateMachine, Settings, EventBus), `scene/` (SceneManager + fade-переходы), `render/` (Renderer, Camera, IsoDepthLayer, Particles, scale), `input/` (действия, геймпад, VirtualJoystick), `map/` (изометрия, A*, mapFormat, Tiled-импорт), `ui/` (PixelText/VT323, Panel, Button, MenuList, DialogueBox), `dialogue/` (DialogueRunner — view-агностик), `audio/` (шины master/music/sfx), `assets/` (AssetLoader + атласы), `save/`, `math/` (iso, rng, easing). -- `apps/game/src/data/` — весь контент: карта (`map.ts`), переходы и области (`locations.ts` — `TransitionDef`/`AREAS`), интерактивные объекты (`interactables.ts` — `INTERACTABLES`), диалоги (JSON-графы в `data/dialogues/`, реестр `dialogues.ts` — графы `DialogueGraph`), NPC (`npcs.ts`); `validate.ts` — runtime-валидация контента → `Invariant[]` (проверяется в `agent:invariants` и юнит-тестах); `dialogueRules.ts` — чистые правила графов (общие с CLI `npm run dialogues:dry` и редактором). -- `apps/game/src/agent/` — контентный слой агентного моста: `snapshot.ts` (сборка слоёв), `GameAgent.ts` (`window.__agent`, только DEV). -- `apps/game/tools/` — **сценарии применения тулз под эту игру** (знают контент: палитру, тайлы, локации, сценарии акта): `agent.mjs` (диспетчер агентных проверок) + `lib.mjs` (маяк загрузки поверх движкового каркаса), `checks/`, `pixelart/` (`gen.mjs` + `palette.mjs`), `audio/gen.mjs`, `aiart/`, `maps/`, смоуки; `guards/boundary.mjs` — сценарий запуска гварда (`npm run guard`, входит в `agent:check`). -- `packages/engine/tools/` — **жанронезависимые тулзы движка** (ничего не знают об игре, палитра/маяк/корни — параметры): `png.mjs` (PNG-кодек), `canvas.mjs` (мини-канвас, ASCII-карты), `imaging.mjs` (кроп/квантизация/обзорный лист), `wav.mjs` (WAV-энкодер + синтез), `agent-lib.mjs` (браузерный каркас агентных проверок), `boundary.mjs` (правила гварда + тесты). Импорт из игры — только через `@rpg/engine/tools/*` (см. `exports` движка). Правило: тулза знает контент игры — ей место в `apps/game/tools`; нет — в движке. -- `apps/game/src/scenes/` — BootScene (грузит ассеты и шрифт) → MenuScene (MenuList) → LocationScene; сцены меняются через `SceneManager.replace/push/pop` (опционально с fade). LocationScene — только оркестратор вьюх: клик-роутинг вынесен в `systems/ClickRouting.ts` (`resolveClick` — чистый резолвер приоритетов + `InteractionRouter`), агентный мост — в `agent/SceneAgentView.ts`. -- `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация из атласа), диалоги (обёртка над DialogueRunner + DialogueBox), интерактивные объекты (`Interactables.ts` — резолв реакций, used-флаги, `InteractSink`), маршрутизация кликов по миру (`ClickRouting.ts` — приоритеты: NPC → переход/заперто → интерактив → цветок → враг → движение). -- Флаги/переменные сюжета — в `GameState` (`game.state`), сериализуются в автосейв `autosave` (Esc в локации); настройки — `game.settings` (отдельный слот, не в сейвах). **Флаги/вары — только через реестры `data/ids.ts`** (`FLAGS`/`VARS`, типы `FlagId`/`VarId`, `usedFlag(id)` для used:): строковое упоминание вне реестра ловится `validateReferences()` как error, незадействованный ключ — как warn. - -### Пиксель-арт - -- `apps/game/tools/pixelart/gen.mjs` — генератор ассетов (PNG из кода, без зависимостей): `node apps/game/tools/pixelart/gen.mjs`. -- Выход — `apps/game/assets/` (`tiles/*.png`, `chars/*.png`); загружается `AssetLoader` в BootScene, ключ = путь без расширения. -- Атласы (кадры героя): `gen.mjs` собирает `chars/hero_sheet.png` + `hero_sheet.json` (Pixi Spritesheet); загрузка — `assets.loadAtlas('chars/hero_sheet.json')`, кадры — `assets.frames(key, prefix)`. -- Палитра — только из `apps/game/tools/pixelart/palette.mjs` (она же в `docs/art-style.md`); тёплые цвета (B*/F*) — только «жизнь»: цветы, бронза, огонь. -- Имена файлов и размеры спрайтов фиксированы арт-библией — при замене сгенерированного арта нарисованным руками их не менять. -- Тайл «дерево» — высокий объект: `TileMapData.tall` c `TallSpec { height, ground }` (спрайт якорится низом в центр ромба, под ним рисуется ground-тайл). - -### Известные грабли (уже собранные) - -- **Pixi `Assets.load` без `Assets.init()` виснет навсегда** (без ошибок!) — `AssetLoader` делает init сам, не обходи его. -- **`app.renderer` недоступен до завершения `app.init()`** — `Renderer.setup()` ждёт init-промис; не трогай renderer раньше. -- **Изометрический «ромб» уходит в минус по X** (экранный bbox западного угла при 28×28 ≈ -448) — но границы камеры задаются в юнитах через `map.worldBounds` (`{0, 0, w, h}`), клэмп через проекцию сам учитывает отрицательный угол. -- **Vite-алиас `@rpg/engine` — только regex** (`{ find: /^@rpg\/engine$/, replacement: ... }`). Строковый алиас перехватывает и под-пути (`@rpg/engine/assets/...`), ломая их в `index.ts/...`. -- **Игровые PNG должны попадать в dist**: в `apps/game/vite.config.ts` стоит `publicDir: 'assets'`, а `resolveUrl` не добавляет префикс `assets/`. Без этого прод-сборка пустая. -- **Шрифт движка подключается `?url`-импортом** (`import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url'`) — для этого в exports движка есть `"./assets/*"`. Обычный `new URL(...)` вне корня Vite не обрабатывает. -- **Атласы грузятся только через `assets.loadAtlas('<ключ>.json')`** — ключ с `.json` резолвится как есть, без `.png`. `Assets.load` на JSON вернёт Texture без `.textures` — не перепутай с `texture()`. -- **Fade-оверлей переходов должен быть поверх UI**: `SceneManager` поднимает его наверх `uiRoot` в `begin()` — сцены добавляют свои view позже, и «наивный» оверлей из `Engine.init()` оказывается под ними (карты просвечивают сквозь меню). -- **TweenManager.tick учитывает пересечение границы `delay`**: часть dt, приходящаяся на delay, вычитается из движения — не упрощай тик до последовательных прибавлений. -- **Headless-скриншоты с `--virtual-time-budget` зависают на загрузке картинок** (колбэки декодинга не срабатывают) — для визуальных проверок использовать `apps/game/tools/smoke.mjs` (puppeteer-core + системный Chromium, реальное время). -- **`npm run dev -- --port 5199` не пробрасывает порт** через два слоя `npm run` (Vite получает `5199` как root-путь и встаёт на 5173) — отдельный порт задавать напрямую: `npx vite --port 5199` в `apps/game`. -- **В headless Chromium `ctx.resume()` без флага `--autoplay-policy=no-user-gesture-required` не резолвится** — смоук-скрипты обязаны передавать этот флаг, а навигация не должна ждать `.then` у `audio.play`. -- **Одна клавиша маппится на несколько действий** (`bindActions` дополняет, а не перезаписывает): Space = `advance` в диалоге и `attack` вне его — не развести обратно нельзя, гардится уровнем сцены. -- **Координаты указателя и worldRoot — виртуальные пиксели** (480×270), экранные CSS-пиксели — это виртуальные × масштаб: `экран = мир + worldRoot.position` (перевод в CSS — домножением на scale). Цепочка клика по миру: `pointer px − worldRoot.position → screenToWorld → юниты → worldToTile`. Не смешивай пространства: позиция героя/врагов — юниты, вью — px. -- **Поворот героя (facing) — по экранным компонентам мирового смещения** (`worldToScreen(delta)`), не по мировым: экранные оси — мировые диагонали, кадры down/up/side соответствуют экранному направлению. -- **Клик по тайлу вне экрана не работает**: если целевой тайл за краем канваса (виртуальный y > 270 или x < 0), клик уходит мимо канваса — герой не двигается. В смоук-скриптах целиться в промежуточный тайл рядом с героем по направлению к цели (шаг по доминирующей оси). -- Мир/арт сверять с `docs/world.md` и `docs/art-style.md`: тёплые цвета — только жизнь; палитра — только из `apps/game/tools/pixelart/palette.mjs`. -- **Агентный мост** (`window.__agent`, DEV): предпочитай `snapshot`/`agent:check` скриншотам — состояние игры читается снапшотом, скриншот только для визуальных вопросов. Fade-переход молча глотает параллельные `begin()` и клики (`transitioning`) — перед действием `waitFor('!s.transitioning')`. Инъекция ввода и шаг — атомарны (один JS-стек): высокоуровневые `tapTile/press/key/walkTo` моста уже атомарны, сырые `inject*` из страницы + отдельный шаг — гонка с rAF. Подробности: `docs/engine/agent.md`, приёмы: `docs/engine/practices.md` (пополнять в том же коммите). - -## Рабочие привычки - -- Для задач из 3+ шагов веди список задач (todowrite/TaskCreate) и иди по нему автономно, не спрашивая подтверждения на каждый шаг. -- Подражай существующему стилю кода; комментарии и докстринги — по-русски; хелперы держи до ~70 строк. -- Плейсхолдеры графики рисуются `Graphics`-ом в коде (пока нет арта); настоящий пиксель-арт в будущем кладётся в `apps/game/assets/`. \ No newline at end of file diff --git a/README.md b/README.md index ca78436..0bed6fd 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Изометрическая пиксельная RPG с сюжетом — и собственный жанронезависимый игровой движок под неё. -- **Движок**: свой (`packages/engine`), TypeScript, PixiJS 8 как низкоуровневый рендер-бэкенд. Спроектирован под переиспользование в других жанрах. -- **Игра**: одиночная сюжетная RPG (`apps/game`), сначала браузер, дальше — планы на Steam и Android. +- **Движок**: свой (`v1/packages/engine`), TypeScript, PixiJS 8 как низкоуровневый рендер-бэкенд. Спроектирован под переиспользование в других жанрах. +- **Игра**: одиночная сюжетная RPG (`v1/apps/game`), сначала браузер, дальше — планы на Steam и Android. - **Стек**: TypeScript (strict), Vite (dev + сборка), Vitest (тесты движка). ## Быстрый старт @@ -24,14 +24,14 @@ docs/demo.md # проектная дока среза: матрица «подсистема → где показана» docs/world.md # библия мира: сеттинг, локации, персонажи, сюжет docs/art-style.md # арт-библия: палитра, размеры, стиль, чеклист ассетов -packages/engine # движок: цикл, сцены, ввод, камера, изометрия, A*, сейвы, +v1/packages/engine # движок: цикл, сцены, ввод, камера, изометрия, A*, сейвы, # диалоги, твины, GameState, UI-кит, частицы, аудио, карты -apps/game # RPG: сцены, локации, NPC, диалоги, бой, сюжет -apps/game/assets # пиксель-арт и карты (apps/game/tools/pixelart, apps/game/tools/maps) -apps/game/tools/pixelart # генератор арта: PNG из кода, палитра в одном месте -apps/game/tools/audio # генератор WAV: звуки и музыкальные петли из кода -apps/game/tools/maps # генерация карт-файлов через encodeMap (+тесты формата) -packages/engine/tools # жанронезависимые тулзы: PNG-кодек, канвас, WAV-тулкит, гвард, каркас агентных проверок +v1/apps/game # RPG: сцены, локации, NPC, диалоги, бой, сюжет +v1/apps/game/assets # пиксель-арт и карты (v1/apps/game/tools/pixelart, v1/apps/game/tools/maps) +v1/apps/game/tools/pixelart # генератор арта: PNG из кода, палитра в одном месте +v1/apps/game/tools/audio # генератор WAV: звуки и музыкальные петли из кода +v1/apps/game/tools/maps # генерация карт-файлов через encodeMap (+тесты формата) +v1/packages/engine/tools # жанронезависимые тулзы: PNG-кодек, канвас, WAV-тулкит, гвард, каркас агентных проверок ``` Правила: игра использует только публичный API `@rpg/engine`; движок ничего не знает о жанре игры; тёплый цвет в арте — только жизнь (цветы, бронза, огонь). diff --git a/apps/game/assets/audio/ambience/houses.wav b/apps/game/assets/audio/ambience/houses.wav deleted file mode 100644 index 98e3962..0000000 --- a/apps/game/assets/audio/ambience/houses.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/ambience/old_siding.wav b/apps/game/assets/audio/ambience/old_siding.wav deleted file mode 100644 index f590253..0000000 --- a/apps/game/assets/audio/ambience/old_siding.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/ambience/ponds.wav b/apps/game/assets/audio/ambience/ponds.wav deleted file mode 100644 index 0912cb4..0000000 --- a/apps/game/assets/audio/ambience/ponds.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/ambience/ponds_night.wav b/apps/game/assets/audio/ambience/ponds_night.wav deleted file mode 100644 index ff5793f..0000000 --- a/apps/game/assets/audio/ambience/ponds_night.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/ambience/ponds_water.wav b/apps/game/assets/audio/ambience/ponds_water.wav deleted file mode 100644 index e12bcf4..0000000 --- a/apps/game/assets/audio/ambience/ponds_water.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/ambience/rust_grove.wav b/apps/game/assets/audio/ambience/rust_grove.wav deleted file mode 100644 index dba7d52..0000000 --- a/apps/game/assets/audio/ambience/rust_grove.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/ambience/tower_hum.wav b/apps/game/assets/audio/ambience/tower_hum.wav deleted file mode 100644 index f7cb40c..0000000 --- a/apps/game/assets/audio/ambience/tower_hum.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/ambience/wind.wav b/apps/game/assets/audio/ambience/wind.wav deleted file mode 100644 index 9983afd..0000000 --- a/apps/game/assets/audio/ambience/wind.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/ambience/zvenets.wav b/apps/game/assets/audio/ambience/zvenets.wav deleted file mode 100644 index c01c316..0000000 --- a/apps/game/assets/audio/ambience/zvenets.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/ambience/zvenets_night.wav b/apps/game/assets/audio/ambience/zvenets_night.wav deleted file mode 100644 index 4e9740f..0000000 --- a/apps/game/assets/audio/ambience/zvenets_night.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/clint.png b/apps/game/assets/chars/clint.png deleted file mode 100644 index e540b1a..0000000 --- a/apps/game/assets/chars/clint.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/clump_crawler_1.png b/apps/game/assets/chars/clump_crawler_1.png deleted file mode 100644 index 47d4dff..0000000 --- a/apps/game/assets/chars/clump_crawler_1.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/clump_crawler_2.png b/apps/game/assets/chars/clump_crawler_2.png deleted file mode 100644 index c3b7ff5..0000000 --- a/apps/game/assets/chars/clump_crawler_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/clump_heavy_1.png b/apps/game/assets/chars/clump_heavy_1.png deleted file mode 100644 index 661c43c..0000000 --- a/apps/game/assets/chars/clump_heavy_1.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/clump_heavy_2.png b/apps/game/assets/chars/clump_heavy_2.png deleted file mode 100644 index b73288b..0000000 --- a/apps/game/assets/chars/clump_heavy_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/clump_spitter_1.png b/apps/game/assets/chars/clump_spitter_1.png deleted file mode 100644 index 9df11c0..0000000 --- a/apps/game/assets/chars/clump_spitter_1.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/clump_spitter_2.png b/apps/game/assets/chars/clump_spitter_2.png deleted file mode 100644 index 4e1b5d9..0000000 --- a/apps/game/assets/chars/clump_spitter_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/clumps_sheet.json b/apps/game/assets/chars/clumps_sheet.json deleted file mode 100644 index 1d098a7..0000000 --- a/apps/game/assets/chars/clumps_sheet.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "frames": { - "clump_crawler_1": { - "frame": { - "x": 0, - "y": 0, - "w": 20, - "h": 20 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 20, - "h": 20 - } - }, - "clump_crawler_2": { - "frame": { - "x": 20, - "y": 0, - "w": 20, - "h": 20 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 20, - "h": 20 - } - }, - "clump_spitter_1": { - "frame": { - "x": 40, - "y": 0, - "w": 20, - "h": 20 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 20, - "h": 20 - } - }, - "clump_spitter_2": { - "frame": { - "x": 60, - "y": 0, - "w": 20, - "h": 20 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 20, - "h": 20 - } - }, - "clump_heavy_1": { - "frame": { - "x": 80, - "y": 0, - "w": 20, - "h": 20 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 20, - "h": 20 - } - }, - "clump_heavy_2": { - "frame": { - "x": 100, - "y": 0, - "w": 20, - "h": 20 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 20, - "h": 20 - } - } - }, - "meta": { - "image": "clumps_sheet.png", - "size": { - "w": 120, - "h": 20 - }, - "scale": 1 - }, - "animations": { - "clump_crawler": [ - "clump_crawler_1", - "clump_crawler_2" - ], - "clump_spitter": [ - "clump_spitter_1", - "clump_spitter_2" - ], - "clump_heavy": [ - "clump_heavy_1", - "clump_heavy_2" - ] - } -} \ No newline at end of file diff --git a/apps/game/assets/chars/clumps_sheet.png b/apps/game/assets/chars/clumps_sheet.png deleted file mode 100644 index aa8b68a..0000000 --- a/apps/game/assets/chars/clumps_sheet.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/elder_irwin.png b/apps/game/assets/chars/elder_irwin.png deleted file mode 100644 index 038b7b9..0000000 --- a/apps/game/assets/chars/elder_irwin.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/fauna_deer_1.png b/apps/game/assets/chars/fauna_deer_1.png deleted file mode 100644 index d7724fe..0000000 --- a/apps/game/assets/chars/fauna_deer_1.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/fauna_deer_2.png b/apps/game/assets/chars/fauna_deer_2.png deleted file mode 100644 index d7d0d60..0000000 --- a/apps/game/assets/chars/fauna_deer_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/fauna_sheet.json b/apps/game/assets/chars/fauna_sheet.json deleted file mode 100644 index 7ba44b5..0000000 --- a/apps/game/assets/chars/fauna_sheet.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "frames": { - "fauna_deer_1": { - "frame": { - "x": 0, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "fauna_deer_2": { - "frame": { - "x": 16, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - } - }, - "meta": { - "image": "fauna_sheet.png", - "size": { - "w": 32, - "h": 24 - }, - "scale": 1 - }, - "animations": { - "fauna_deer_walk": [ - "fauna_deer_1", - "fauna_deer_2" - ] - } -} \ No newline at end of file diff --git a/apps/game/assets/chars/fauna_sheet.png b/apps/game/assets/chars/fauna_sheet.png deleted file mode 100644 index b08f29b..0000000 --- a/apps/game/assets/chars/fauna_sheet.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/hero_down_1.png b/apps/game/assets/chars/hero_down_1.png deleted file mode 100644 index 996100a..0000000 --- a/apps/game/assets/chars/hero_down_1.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/hero_down_2.png b/apps/game/assets/chars/hero_down_2.png deleted file mode 100644 index e7b22d3..0000000 --- a/apps/game/assets/chars/hero_down_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/hero_lamp.png b/apps/game/assets/chars/hero_lamp.png deleted file mode 100644 index d7f4891..0000000 --- a/apps/game/assets/chars/hero_lamp.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/hero_sheet.json b/apps/game/assets/chars/hero_sheet.json deleted file mode 100644 index 68b9132..0000000 --- a/apps/game/assets/chars/hero_sheet.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "frames": { - "hero_down_1": { - "frame": { - "x": 0, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "hero_down_2": { - "frame": { - "x": 16, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "hero_up_1": { - "frame": { - "x": 32, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "hero_up_2": { - "frame": { - "x": 48, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "hero_side_1": { - "frame": { - "x": 64, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "hero_side_2": { - "frame": { - "x": 80, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - } - }, - "meta": { - "image": "hero_sheet.png", - "size": { - "w": 96, - "h": 24 - }, - "scale": 1 - }, - "animations": { - "hero_walk_down": [ - "hero_down_1", - "hero_down_2" - ], - "hero_walk_up": [ - "hero_up_1", - "hero_up_2" - ], - "hero_walk_side": [ - "hero_side_1", - "hero_side_2" - ] - } -} \ No newline at end of file diff --git a/apps/game/assets/chars/hero_sheet.png b/apps/game/assets/chars/hero_sheet.png deleted file mode 100644 index b85284e..0000000 --- a/apps/game/assets/chars/hero_sheet.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/hero_side_1.png b/apps/game/assets/chars/hero_side_1.png deleted file mode 100644 index e2ae35b..0000000 --- a/apps/game/assets/chars/hero_side_1.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/hero_side_2.png b/apps/game/assets/chars/hero_side_2.png deleted file mode 100644 index b29df09..0000000 --- a/apps/game/assets/chars/hero_side_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/hero_up_1.png b/apps/game/assets/chars/hero_up_1.png deleted file mode 100644 index 8cfd256..0000000 --- a/apps/game/assets/chars/hero_up_1.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/hero_up_2.png b/apps/game/assets/chars/hero_up_2.png deleted file mode 100644 index 395d62e..0000000 --- a/apps/game/assets/chars/hero_up_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/npcs_sheet.json b/apps/game/assets/chars/npcs_sheet.json deleted file mode 100644 index bf57c26..0000000 --- a/apps/game/assets/chars/npcs_sheet.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "frames": { - "elder_irwin_1": { - "frame": { - "x": 0, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "elder_irwin_2": { - "frame": { - "x": 16, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "trader_mila_1": { - "frame": { - "x": 32, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "trader_mila_2": { - "frame": { - "x": 48, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "clint_1": { - "frame": { - "x": 64, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "clint_2": { - "frame": { - "x": 80, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - } - }, - "meta": { - "image": "npcs_sheet.png", - "size": { - "w": 96, - "h": 24 - }, - "scale": 1 - }, - "animations": { - "elder_irwin_idle": [ - "elder_irwin_1", - "elder_irwin_2" - ], - "trader_mila_idle": [ - "trader_mila_1", - "trader_mila_2" - ], - "clint_idle": [ - "clint_1", - "clint_2" - ] - } -} \ No newline at end of file diff --git a/apps/game/assets/chars/npcs_sheet.png b/apps/game/assets/chars/npcs_sheet.png deleted file mode 100644 index dbaa542..0000000 --- a/apps/game/assets/chars/npcs_sheet.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/chars/trader_mila.png b/apps/game/assets/chars/trader_mila.png deleted file mode 100644 index b71dad8..0000000 --- a/apps/game/assets/chars/trader_mila.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/maps/house_elder.map b/apps/game/assets/maps/house_elder.map deleted file mode 100644 index dcb6e9b..0000000 --- a/apps/game/assets/maps/house_elder.map +++ /dev/null @@ -1 +0,0 @@ -{"format":"rpg-map","version":1,"width":11,"height":9,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"encoding":"rle","tiles":[[9,12],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,6],[8,1],[9,5]]} \ No newline at end of file diff --git a/apps/game/assets/maps/machine_hall.map b/apps/game/assets/maps/machine_hall.map deleted file mode 100644 index af77033..0000000 --- a/apps/game/assets/maps/machine_hall.map +++ /dev/null @@ -1 +0,0 @@ -{"format":"rpg-map","version":1,"width":26,"height":22,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"encoding":"rle","tiles":[[9,27],[8,11],[21,2],[8,11],[9,2],[8,24],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,24],[9,14],[8,1],[9,12]]} \ No newline at end of file diff --git a/apps/game/assets/maps/meadows.map b/apps/game/assets/maps/meadows.map deleted file mode 100644 index 14a80fb..0000000 --- a/apps/game/assets/maps/meadows.map +++ /dev/null @@ -1 +0,0 @@ -{"format":"rpg-map","version":1,"width":56,"height":56,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"variants":{"0":[0,11,12],"1":[1,14],"3":[3,22,23],"4":[4,13],"7":[7,24,25,26,27],"28":[28,30,31],"29":[29,32,33]},"decor":[{"id":16,"x":7,"y":0,"dx":-3,"dy":-2},{"id":16,"x":17,"y":0,"dx":-4,"dy":2},{"id":15,"x":30,"y":0,"dx":1,"dy":1},{"id":16,"x":38,"y":0,"dx":-3,"dy":0},{"id":16,"x":39,"y":0,"dx":-3,"dy":1},{"id":15,"x":2,"y":1,"dx":-2,"dy":-2},{"id":16,"x":8,"y":1,"dx":-2,"dy":1},{"id":15,"x":22,"y":1,"dx":3,"dy":1},{"id":15,"x":24,"y":1,"dx":-3,"dy":-1},{"id":17,"x":43,"y":1,"dx":1,"dy":1},{"id":17,"x":44,"y":1,"dx":1,"dy":1},{"id":17,"x":3,"y":2,"dx":-3,"dy":2},{"id":17,"x":8,"y":2,"dx":-2,"dy":0},{"id":15,"x":10,"y":2,"dx":-3,"dy":1},{"id":16,"x":20,"y":2,"dx":-2,"dy":2},{"id":16,"x":28,"y":2,"dx":-4,"dy":0},{"id":16,"x":31,"y":2,"dx":3,"dy":1},{"id":15,"x":42,"y":2,"dx":4,"dy":0},{"id":17,"x":7,"y":3,"dx":1,"dy":0},{"id":16,"x":52,"y":3,"dx":-1,"dy":-2},{"id":16,"x":15,"y":4,"dx":-2,"dy":1},{"id":16,"x":17,"y":4,"dx":3,"dy":1},{"id":17,"x":26,"y":4,"dx":-1,"dy":0},{"id":17,"x":33,"y":4,"dx":2,"dy":-1},{"id":17,"x":35,"y":4,"dx":-1,"dy":-2},{"id":15,"x":42,"y":4,"dx":-3,"dy":0},{"id":16,"x":51,"y":4,"dx":-3,"dy":1},{"id":16,"x":14,"y":5,"dx":0,"dy":0},{"id":16,"x":28,"y":5,"dx":-4,"dy":0},{"id":16,"x":34,"y":5,"dx":-3,"dy":0},{"id":15,"x":35,"y":5,"dx":2,"dy":1},{"id":16,"x":38,"y":5,"dx":-2,"dy":2},{"id":15,"x":43,"y":5,"dx":3,"dy":-2},{"id":15,"x":51,"y":5,"dx":-2,"dy":1},{"id":16,"x":52,"y":5,"dx":-1,"dy":-1},{"id":15,"x":0,"y":6,"dx":1,"dy":1},{"id":17,"x":1,"y":6,"dx":1,"dy":1},{"id":16,"x":29,"y":6,"dx":4,"dy":0},{"id":17,"x":39,"y":6,"dx":4,"dy":0},{"id":16,"x":46,"y":6,"dx":-2,"dy":-2},{"id":17,"x":51,"y":6,"dx":2,"dy":-1},{"id":15,"x":1,"y":7,"dx":0,"dy":1},{"id":16,"x":3,"y":7,"dx":-1,"dy":0},{"id":16,"x":15,"y":7,"dx":-1,"dy":0},{"id":15,"x":17,"y":7,"dx":3,"dy":-1},{"id":16,"x":18,"y":7,"dx":0,"dy":1},{"id":15,"x":24,"y":7,"dx":4,"dy":-1},{"id":16,"x":37,"y":7,"dx":1,"dy":0},{"id":15,"x":38,"y":7,"dx":-3,"dy":-1},{"id":15,"x":39,"y":7,"dx":-3,"dy":-2},{"id":17,"x":47,"y":7,"dx":0,"dy":-1},{"id":15,"x":51,"y":7,"dx":3,"dy":-1},{"id":17,"x":26,"y":8,"dx":4,"dy":2},{"id":16,"x":30,"y":8,"dx":1,"dy":-2},{"id":16,"x":32,"y":8,"dx":-4,"dy":-2},{"id":15,"x":35,"y":8,"dx":-1,"dy":0},{"id":16,"x":38,"y":8,"dx":-2,"dy":-2},{"id":15,"x":45,"y":8,"dx":-2,"dy":0},{"id":15,"x":47,"y":8,"dx":4,"dy":0},{"id":17,"x":2,"y":9,"dx":3,"dy":0},{"id":16,"x":20,"y":9,"dx":-4,"dy":-2},{"id":16,"x":24,"y":9,"dx":-2,"dy":1},{"id":17,"x":33,"y":9,"dx":-3,"dy":0},{"id":16,"x":50,"y":9,"dx":2,"dy":1},{"id":15,"x":15,"y":10,"dx":-3,"dy":0},{"id":15,"x":18,"y":10,"dx":0,"dy":-1},{"id":15,"x":20,"y":10,"dx":0,"dy":2},{"id":16,"x":22,"y":10,"dx":-3,"dy":1},{"id":15,"x":25,"y":10,"dx":2,"dy":-1},{"id":15,"x":49,"y":10,"dx":-3,"dy":2},{"id":16,"x":51,"y":10,"dx":1,"dy":2},{"id":17,"x":17,"y":11,"dx":3,"dy":2},{"id":15,"x":18,"y":11,"dx":3,"dy":-2},{"id":17,"x":21,"y":11,"dx":1,"dy":1},{"id":16,"x":29,"y":11,"dx":-1,"dy":0},{"id":16,"x":31,"y":11,"dx":-3,"dy":0},{"id":15,"x":37,"y":11,"dx":1,"dy":0},{"id":17,"x":1,"y":12,"dx":2,"dy":-1},{"id":15,"x":9,"y":12,"dx":-3,"dy":1},{"id":17,"x":10,"y":12,"dx":0,"dy":0},{"id":17,"x":22,"y":12,"dx":4,"dy":-2},{"id":16,"x":26,"y":12,"dx":-4,"dy":2},{"id":16,"x":36,"y":12,"dx":0,"dy":1},{"id":15,"x":40,"y":12,"dx":-2,"dy":-1},{"id":17,"x":46,"y":12,"dx":-3,"dy":0},{"id":15,"x":16,"y":13,"dx":1,"dy":1},{"id":17,"x":28,"y":13,"dx":2,"dy":-1},{"id":16,"x":46,"y":13,"dx":-1,"dy":2},{"id":17,"x":1,"y":14,"dx":0,"dy":-1},{"id":16,"x":31,"y":14,"dx":1,"dy":-1},{"id":16,"x":36,"y":14,"dx":-2,"dy":-2},{"id":16,"x":0,"y":15,"dx":-3,"dy":1},{"id":15,"x":13,"y":15,"dx":1,"dy":2},{"id":17,"x":21,"y":15,"dx":-3,"dy":-2},{"id":16,"x":47,"y":15,"dx":0,"dy":-1},{"id":16,"x":35,"y":16,"dx":1,"dy":-1},{"id":16,"x":1,"y":17,"dx":-3,"dy":-1},{"id":17,"x":27,"y":17,"dx":2,"dy":1},{"id":16,"x":31,"y":17,"dx":4,"dy":1},{"id":16,"x":34,"y":17,"dx":2,"dy":2},{"id":16,"x":46,"y":17,"dx":2,"dy":1},{"id":15,"x":16,"y":18,"dx":4,"dy":2},{"id":15,"x":34,"y":18,"dx":-3,"dy":0},{"id":17,"x":48,"y":18,"dx":2,"dy":1},{"id":17,"x":54,"y":18,"dx":2,"dy":2},{"id":17,"x":1,"y":19,"dx":-3,"dy":0},{"id":16,"x":12,"y":19,"dx":1,"dy":0},{"id":16,"x":13,"y":19,"dx":-1,"dy":0},{"id":15,"x":21,"y":19,"dx":0,"dy":0},{"id":16,"x":29,"y":19,"dx":2,"dy":0},{"id":15,"x":44,"y":19,"dx":4,"dy":1},{"id":17,"x":55,"y":19,"dx":0,"dy":-1},{"id":16,"x":10,"y":20,"dx":-3,"dy":1},{"id":15,"x":24,"y":20,"dx":-2,"dy":1},{"id":17,"x":36,"y":20,"dx":-2,"dy":-1},{"id":17,"x":25,"y":21,"dx":-1,"dy":0},{"id":15,"x":37,"y":21,"dx":-4,"dy":2},{"id":16,"x":47,"y":21,"dx":-1,"dy":2},{"id":15,"x":3,"y":22,"dx":-4,"dy":1},{"id":15,"x":11,"y":22,"dx":0,"dy":-1},{"id":16,"x":21,"y":22,"dx":2,"dy":0},{"id":16,"x":22,"y":22,"dx":2,"dy":-1},{"id":15,"x":55,"y":22,"dx":-4,"dy":0},{"id":17,"x":3,"y":23,"dx":3,"dy":0},{"id":16,"x":34,"y":23,"dx":2,"dy":-1},{"id":15,"x":41,"y":23,"dx":-2,"dy":-2},{"id":15,"x":46,"y":23,"dx":2,"dy":1},{"id":16,"x":50,"y":23,"dx":2,"dy":0},{"id":15,"x":9,"y":24,"dx":1,"dy":-1},{"id":15,"x":28,"y":24,"dx":3,"dy":-2},{"id":15,"x":32,"y":24,"dx":4,"dy":-1},{"id":16,"x":39,"y":24,"dx":2,"dy":0},{"id":15,"x":1,"y":25,"dx":-3,"dy":1},{"id":15,"x":11,"y":25,"dx":-1,"dy":1},{"id":15,"x":23,"y":25,"dx":3,"dy":-1},{"id":17,"x":25,"y":25,"dx":2,"dy":-1},{"id":17,"x":28,"y":25,"dx":1,"dy":1},{"id":17,"x":32,"y":25,"dx":-3,"dy":1},{"id":15,"x":44,"y":25,"dx":-1,"dy":-1},{"id":15,"x":0,"y":26,"dx":-1,"dy":-2},{"id":17,"x":16,"y":26,"dx":2,"dy":-1},{"id":17,"x":17,"y":26,"dx":-4,"dy":2},{"id":15,"x":26,"y":26,"dx":-3,"dy":-1},{"id":17,"x":31,"y":26,"dx":-3,"dy":-2},{"id":15,"x":51,"y":26,"dx":0,"dy":0},{"id":15,"x":6,"y":27,"dx":-1,"dy":-1},{"id":16,"x":11,"y":27,"dx":3,"dy":0},{"id":17,"x":16,"y":27,"dx":3,"dy":1},{"id":15,"x":29,"y":27,"dx":2,"dy":0},{"id":16,"x":36,"y":27,"dx":4,"dy":-1},{"id":16,"x":38,"y":27,"dx":1,"dy":0},{"id":17,"x":50,"y":27,"dx":-3,"dy":-2},{"id":17,"x":53,"y":27,"dx":1,"dy":-1},{"id":15,"x":14,"y":28,"dx":-4,"dy":1},{"id":17,"x":16,"y":29,"dx":-3,"dy":-1},{"id":17,"x":20,"y":29,"dx":-1,"dy":-1},{"id":15,"x":29,"y":29,"dx":0,"dy":1},{"id":16,"x":33,"y":29,"dx":-2,"dy":2},{"id":16,"x":37,"y":29,"dx":0,"dy":2},{"id":15,"x":7,"y":30,"dx":2,"dy":-1},{"id":15,"x":9,"y":30,"dx":0,"dy":2},{"id":16,"x":21,"y":30,"dx":3,"dy":1},{"id":17,"x":41,"y":30,"dx":-4,"dy":0},{"id":17,"x":28,"y":32,"dx":1,"dy":1},{"id":15,"x":32,"y":32,"dx":-1,"dy":-1},{"id":17,"x":33,"y":32,"dx":0,"dy":-2},{"id":17,"x":41,"y":32,"dx":-3,"dy":0},{"id":16,"x":50,"y":32,"dx":0,"dy":-2},{"id":15,"x":11,"y":33,"dx":-3,"dy":2},{"id":17,"x":54,"y":33,"dx":-3,"dy":1},{"id":15,"x":2,"y":34,"dx":1,"dy":-2},{"id":16,"x":24,"y":34,"dx":3,"dy":0},{"id":15,"x":38,"y":34,"dx":3,"dy":0},{"id":15,"x":14,"y":35,"dx":1,"dy":0},{"id":16,"x":53,"y":35,"dx":2,"dy":-1},{"id":15,"x":4,"y":36,"dx":-2,"dy":1},{"id":17,"x":6,"y":36,"dx":1,"dy":-1},{"id":16,"x":10,"y":36,"dx":0,"dy":0},{"id":17,"x":18,"y":36,"dx":-1,"dy":-1},{"id":16,"x":24,"y":36,"dx":0,"dy":0},{"id":16,"x":26,"y":36,"dx":0,"dy":-2},{"id":17,"x":32,"y":36,"dx":-1,"dy":-2},{"id":15,"x":35,"y":36,"dx":-1,"dy":-1},{"id":17,"x":36,"y":36,"dx":-1,"dy":-1},{"id":15,"x":40,"y":36,"dx":0,"dy":0},{"id":15,"x":43,"y":36,"dx":1,"dy":1},{"id":17,"x":12,"y":37,"dx":-2,"dy":-1},{"id":16,"x":27,"y":37,"dx":0,"dy":0},{"id":15,"x":48,"y":37,"dx":-1,"dy":2},{"id":15,"x":50,"y":37,"dx":4,"dy":2},{"id":15,"x":55,"y":37,"dx":2,"dy":1},{"id":16,"x":3,"y":38,"dx":-3,"dy":2},{"id":17,"x":8,"y":38,"dx":2,"dy":0},{"id":16,"x":28,"y":38,"dx":-1,"dy":-1},{"id":15,"x":34,"y":38,"dx":0,"dy":0},{"id":17,"x":25,"y":39,"dx":3,"dy":2},{"id":17,"x":29,"y":39,"dx":2,"dy":0},{"id":15,"x":33,"y":39,"dx":3,"dy":-2},{"id":16,"x":51,"y":39,"dx":-1,"dy":-1},{"id":16,"x":52,"y":39,"dx":-1,"dy":0},{"id":16,"x":53,"y":39,"dx":-2,"dy":0},{"id":15,"x":54,"y":39,"dx":-4,"dy":-2},{"id":16,"x":4,"y":40,"dx":-2,"dy":1},{"id":17,"x":41,"y":40,"dx":-2,"dy":-2},{"id":16,"x":52,"y":40,"dx":1,"dy":2},{"id":17,"x":10,"y":41,"dx":-3,"dy":2},{"id":16,"x":22,"y":41,"dx":0,"dy":0},{"id":16,"x":23,"y":41,"dx":-3,"dy":-1},{"id":15,"x":31,"y":41,"dx":1,"dy":1},{"id":15,"x":43,"y":41,"dx":2,"dy":-1},{"id":15,"x":47,"y":41,"dx":2,"dy":0},{"id":16,"x":53,"y":41,"dx":3,"dy":1},{"id":17,"x":8,"y":42,"dx":1,"dy":-1},{"id":16,"x":14,"y":42,"dx":0,"dy":-1},{"id":17,"x":19,"y":42,"dx":1,"dy":1},{"id":16,"x":23,"y":42,"dx":-2,"dy":1},{"id":15,"x":28,"y":42,"dx":3,"dy":-1},{"id":16,"x":35,"y":42,"dx":2,"dy":0},{"id":17,"x":41,"y":42,"dx":2,"dy":-2},{"id":15,"x":48,"y":42,"dx":0,"dy":1},{"id":16,"x":52,"y":42,"dx":-1,"dy":1},{"id":17,"x":8,"y":43,"dx":-2,"dy":0},{"id":15,"x":16,"y":43,"dx":-1,"dy":1},{"id":15,"x":0,"y":44,"dx":2,"dy":2},{"id":15,"x":30,"y":44,"dx":1,"dy":0},{"id":17,"x":43,"y":44,"dx":-1,"dy":0},{"id":16,"x":14,"y":45,"dx":0,"dy":1},{"id":15,"x":31,"y":45,"dx":3,"dy":2},{"id":15,"x":37,"y":45,"dx":-2,"dy":2},{"id":15,"x":38,"y":45,"dx":1,"dy":1},{"id":15,"x":42,"y":45,"dx":-2,"dy":1},{"id":15,"x":46,"y":45,"dx":-2,"dy":-2},{"id":15,"x":1,"y":46,"dx":-1,"dy":2},{"id":16,"x":15,"y":46,"dx":-2,"dy":-1},{"id":17,"x":29,"y":46,"dx":3,"dy":1},{"id":15,"x":41,"y":46,"dx":-1,"dy":0},{"id":17,"x":42,"y":46,"dx":4,"dy":-1},{"id":15,"x":44,"y":46,"dx":1,"dy":1},{"id":15,"x":52,"y":46,"dx":-3,"dy":-2},{"id":16,"x":54,"y":46,"dx":0,"dy":0},{"id":15,"x":4,"y":47,"dx":3,"dy":2},{"id":16,"x":15,"y":47,"dx":1,"dy":2},{"id":16,"x":22,"y":47,"dx":-3,"dy":-1},{"id":16,"x":24,"y":47,"dx":1,"dy":2},{"id":16,"x":27,"y":47,"dx":-4,"dy":1},{"id":16,"x":33,"y":47,"dx":-1,"dy":2},{"id":15,"x":38,"y":47,"dx":-2,"dy":-1},{"id":17,"x":37,"y":48,"dx":3,"dy":0},{"id":15,"x":41,"y":48,"dx":0,"dy":0},{"id":15,"x":45,"y":48,"dx":0,"dy":-2},{"id":17,"x":46,"y":48,"dx":-4,"dy":1},{"id":16,"x":34,"y":49,"dx":1,"dy":-1},{"id":17,"x":46,"y":49,"dx":-4,"dy":2},{"id":15,"x":49,"y":49,"dx":-3,"dy":-1},{"id":17,"x":1,"y":50,"dx":-1,"dy":1},{"id":16,"x":8,"y":50,"dx":4,"dy":1},{"id":16,"x":22,"y":50,"dx":3,"dy":-1},{"id":16,"x":31,"y":50,"dx":1,"dy":-2},{"id":16,"x":32,"y":50,"dx":-4,"dy":-1},{"id":15,"x":37,"y":50,"dx":-2,"dy":0},{"id":16,"x":54,"y":50,"dx":3,"dy":-1},{"id":16,"x":17,"y":51,"dx":-3,"dy":-1},{"id":15,"x":18,"y":51,"dx":-2,"dy":0},{"id":16,"x":9,"y":52,"dx":-1,"dy":0},{"id":16,"x":10,"y":52,"dx":-1,"dy":0},{"id":16,"x":13,"y":52,"dx":2,"dy":2},{"id":15,"x":19,"y":52,"dx":0,"dy":1},{"id":15,"x":25,"y":52,"dx":3,"dy":-1},{"id":15,"x":26,"y":52,"dx":0,"dy":0},{"id":15,"x":29,"y":52,"dx":-4,"dy":0},{"id":17,"x":40,"y":52,"dx":3,"dy":-1},{"id":16,"x":52,"y":52,"dx":3,"dy":0},{"id":16,"x":11,"y":53,"dx":1,"dy":1},{"id":16,"x":13,"y":53,"dx":-2,"dy":-1},{"id":16,"x":34,"y":53,"dx":-2,"dy":1},{"id":16,"x":52,"y":53,"dx":0,"dy":-1},{"id":16,"x":16,"y":54,"dx":-1,"dy":1},{"id":16,"x":17,"y":54,"dx":0,"dy":1},{"id":15,"x":19,"y":54,"dx":3,"dy":-1},{"id":15,"x":41,"y":54,"dx":-2,"dy":-1},{"id":16,"x":44,"y":54,"dx":1,"dy":-2},{"id":17,"x":6,"y":55,"dx":2,"dy":-2},{"id":15,"x":25,"y":55,"dx":-3,"dy":-1},{"id":15,"x":34,"y":55,"dx":1,"dy":-1}],"encoding":"rle","tiles":[[0,3],[3,1],[0,1],[4,1],[3,1],[4,1],[0,4],[4,8],[0,18],[4,4],[0,7],[3,2],[0,2],[3,1],[0,3],[1,1],[0,3],[4,2],[3,1],[4,3],[0,1],[4,1],[0,1],[4,7],[0,4],[4,1],[0,1],[4,1],[0,9],[4,5],[0,7],[4,2],[3,1],[0,3],[3,2],[1,2],[0,2],[4,1],[3,1],[4,1],[0,1],[4,1],[0,2],[4,10],[0,3],[4,1],[0,1],[4,5],[0,5],[4,4],[0,3],[4,8],[0,3],[3,1],[0,2],[1,2],[0,1],[1,1],[0,2],[4,3],[0,1],[4,12],[0,1],[4,4],[0,1],[4,2],[0,5],[4,3],[0,4],[4,2],[0,1],[4,1],[0,2],[3,1],[0,1],[4,1],[0,6],[1,5],[0,4],[4,1],[0,1],[4,8],[0,1],[4,1],[0,3],[4,4],[0,6],[4,3],[0,3],[4,10],[0,3],[3,1],[0,3],[3,1],[4,1],[0,1],[1,1],[0,4],[4,5],[0,1],[4,11],[0,2],[4,4],[0,2],[4,3],[0,4],[4,5],[0,1],[4,1],[0,1],[4,1],[0,2],[3,1],[0,2],[3,2],[4,1],[3,1],[4,1],[1,1],[0,4],[4,5],[0,1],[4,17],[0,9],[4,1],[0,1],[4,3],[0,9],[3,1],[4,1],[0,1],[4,1],[3,1],[1,1],[0,5],[4,1],[0,1],[4,1],[0,2],[4,6],[0,1],[4,6],[0,1],[4,3],[0,2],[4,1],[0,14],[3,1],[0,5],[3,3],[4,2],[1,1],[0,5],[4,2],[0,1],[4,1],[0,3],[4,1],[0,2],[4,1],[0,1],[4,1],[0,1],[4,6],[0,1],[28,1],[0,2],[4,2],[0,9],[3,1],[0,12],[3,1],[0,1],[1,1],[0,4],[4,5],[0,3],[4,3],[0,1],[4,9],[0,5],[4,2],[0,10],[4,5],[0,4],[4,1],[3,3],[1,1],[0,13],[4,2],[0,1],[29,1],[0,1],[4,7],[0,15],[3,1],[0,2],[4,3],[0,5],[3,1],[0,3],[1,1],[0,18],[4,1],[29,1],[4,4],[0,3],[5,1],[0,14],[4,3],[0,1],[4,1],[0,1],[3,1],[0,1],[4,3],[3,1],[0,1],[1,2],[0,14],[4,5],[0,2],[4,1],[0,2],[4,4],[0,1],[4,1],[0,10],[3,1],[0,1],[4,1],[0,2],[4,1],[0,1],[3,1],[0,2],[3,1],[0,3],[3,1],[1,1],[0,9],[28,1],[0,4],[4,2],[0,4],[4,2],[0,2],[4,2],[0,1],[4,1],[29,1],[4,1],[0,2],[4,3],[0,2],[4,1],[0,3],[4,1],[0,1],[4,3],[0,6],[3,1],[0,2],[1,1],[0,14],[4,1],[0,1],[4,1],[0,5],[4,2],[0,1],[4,4],[2,2],[0,1],[2,4],[0,2],[4,7],[0,2],[4,2],[3,2],[0,2],[3,1],[0,1],[1,1],[0,22],[4,6],[0,2],[2,6],[0,1],[4,8],[0,10],[1,2],[0,19],[4,8],[0,1],[2,4],[0,1],[2,2],[0,1],[5,1],[4,7],[0,2],[3,1],[4,2],[3,1],[4,1],[3,3],[0,1],[1,4],[0,16],[4,1],[0,1],[4,5],[0,2],[2,1],[0,1],[2,5],[0,1],[4,1],[0,1],[4,3],[0,2],[4,1],[0,3],[3,4],[0,1],[3,1],[4,3],[0,1],[4,1],[1,4],[0,15],[4,3],[0,1],[4,1],[0,2],[2,4],[0,1],[2,1],[0,5],[3,1],[28,1],[0,5],[3,1],[0,1],[3,6],[0,1],[4,1],[0,2],[4,2],[0,1],[1,1],[0,10],[4,4],[0,1],[4,2],[0,27],[3,1],[0,3],[4,3],[0,4],[1,1],[0,5],[4,1],[0,1],[4,1],[0,3],[4,2],[0,1],[4,1],[0,17],[3,1],[0,9],[3,1],[0,1],[3,1],[0,1],[3,2],[28,1],[0,1],[4,1],[0,1],[3,1],[4,2],[1,1],[0,5],[4,4],[0,2],[29,1],[4,3],[0,8],[4,3],[0,1],[4,1],[0,3],[4,4],[0,9],[3,1],[0,1],[3,1],[4,4],[0,1],[4,1],[3,1],[4,1],[1,1],[0,5],[4,3],[0,3],[4,1],[0,1],[4,2],[0,3],[4,2],[0,1],[4,2],[0,1],[4,4],[0,3],[4,4],[3,2],[0,3],[3,1],[0,3],[3,1],[0,1],[3,1],[0,6],[4,2],[1,2],[4,2],[0,1],[4,2],[0,6],[4,1],[0,5],[4,1],[0,1],[4,3],[28,1],[4,1],[0,2],[4,1],[0,3],[4,1],[3,1],[0,1],[4,2],[0,8],[3,1],[0,1],[3,1],[0,9],[1,1],[0,1],[4,1],[29,1],[4,4],[0,10],[4,8],[0,5],[4,1],[0,1],[4,3],[0,7],[3,1],[0,9],[28,1],[0,2],[1,1],[0,1],[4,1],[0,1],[4,2],[0,11],[4,5],[29,1],[4,1],[0,1],[4,3],[0,3],[4,5],[0,1],[3,1],[0,8],[3,1],[0,12],[4,2],[3,1],[0,2],[28,1],[0,9],[4,5],[0,2],[4,1],[0,1],[4,5],[0,5],[4,3],[0,4],[3,1],[0,2],[3,1],[0,3],[4,1],[0,1],[4,1],[0,5],[29,1],[0,1],[4,1],[0,2],[4,3],[0,1],[4,1],[0,4],[4,1],[0,2],[4,5],[0,2],[4,6],[0,6],[4,1],[0,1],[3,1],[0,2],[3,3],[0,1],[29,1],[0,2],[4,3],[0,5],[1,33],[0,1],[1,2],[0,1],[3,1],[4,4],[0,1],[4,1],[0,4],[4,1],[0,1],[4,1],[0,8],[4,7],[0,3],[29,1],[4,3],[0,1],[4,4],[0,2],[4,5],[0,3],[4,1],[0,3],[3,2],[4,4],[3,1],[4,1],[0,4],[29,1],[0,7],[4,5],[0,1],[4,1],[0,2],[4,1],[0,4],[4,3],[0,4],[29,1],[0,2],[4,1],[0,1],[4,3],[0,2],[4,1],[0,1],[4,3],[0,1],[4,3],[0,1],[3,1],[4,1],[3,1],[0,8],[4,4],[0,1],[4,7],[0,6],[4,3],[0,1],[4,2],[0,5],[4,3],[0,1],[4,1],[0,2],[3,1],[4,3],[0,1],[3,2],[4,2],[3,1],[4,1],[3,2],[0,4],[2,4],[4,1],[2,1],[0,1],[4,1],[28,1],[4,4],[0,9],[4,2],[0,1],[4,3],[0,7],[4,1],[0,1],[4,4],[0,1],[4,1],[0,1],[3,1],[4,4],[0,1],[4,1],[0,5],[2,3],[0,1],[2,3],[4,1],[0,1],[4,4],[0,5],[29,1],[0,6],[4,1],[0,1],[4,1],[0,7],[4,2],[0,6],[3,2],[0,3],[3,1],[0,1],[3,2],[29,1],[0,3],[2,4],[0,1],[2,2],[4,1],[5,1],[4,1],[0,1],[4,2],[0,19],[4,1],[0,2],[4,3],[0,1],[3,1],[0,1],[4,1],[3,1],[0,2],[3,1],[0,2],[3,4],[0,1],[29,1],[4,2],[0,1],[2,1],[0,1],[2,4],[0,2],[4,2],[0,1],[4,5],[0,13],[4,1],[0,1],[4,1],[0,5],[3,1],[4,1],[0,1],[4,1],[0,2],[3,2],[0,5],[3,1],[0,1],[4,2],[5,1],[2,6],[0,6],[4,5],[0,15],[4,1],[0,6],[4,1],[0,1],[4,1],[3,1],[0,2],[4,1],[3,2],[4,1],[3,1],[0,4],[4,2],[0,4],[29,1],[0,9],[4,3],[0,28],[4,1],[3,1],[4,1],[3,1],[0,20],[4,4],[0,9],[29,1],[0,1],[4,1],[0,9],[4,4],[0,2],[3,1],[4,1],[3,1],[4,1],[0,1],[4,1],[0,8],[4,5],[0,5],[4,1],[0,1],[4,3],[0,10],[4,3],[0,6],[3,1],[0,1],[4,4],[0,3],[3,1],[4,4],[3,2],[0,7],[4,2],[0,1],[4,1],[0,20],[4,2],[0,3],[10,1],[0,6],[4,3],[0,1],[3,2],[4,3],[0,1],[4,1],[3,1],[0,7],[4,3],[0,1],[4,1],[0,3],[4,3],[0,1],[4,1],[0,22],[4,2],[0,1],[4,4],[0,1],[3,1],[0,3],[3,1],[0,8],[4,2],[0,1],[4,2],[0,2],[4,6],[0,8],[4,2],[0,12],[3,1],[4,5],[3,5],[0,1],[3,1],[0,6],[28,1],[0,1],[4,2],[0,1],[4,2],[0,2],[4,6],[0,7],[4,3],[0,12],[4,5],[0,1],[4,1],[0,1],[3,1],[0,1],[3,2],[0,1],[3,2],[0,3],[4,1],[0,1],[4,1],[0,7],[4,6],[0,7],[4,2],[0,13],[4,1],[0,1],[4,1],[0,1],[4,2],[0,2],[3,1],[0,2],[3,3],[0,3],[4,5],[0,5],[4,6],[0,23],[4,1],[0,1],[4,1],[3,1],[4,1],[3,1],[4,1],[0,3],[3,1],[0,6],[4,2],[0,1],[4,1],[0,1],[4,1],[0,4],[4,6],[0,24],[3,1],[28,1],[0,2],[4,1],[0,1],[3,1],[0,4],[3,1],[0,4],[4,6],[0,4],[4,8],[0,5],[4,3],[0,15],[3,1],[0,1],[4,2],[0,3],[3,1],[0,2],[3,3],[4,2],[0,1],[4,1],[0,1],[4,1],[0,1],[4,1],[0,4],[4,6],[0,1],[4,1],[0,3],[4,2],[0,2],[4,1],[0,17],[4,2],[0,1],[3,1],[0,1],[3,1],[0,2],[3,1],[0,1],[4,7],[0,1],[4,1],[0,6],[4,2],[0,2],[4,2],[0,3],[4,6],[0,17],[3,1],[4,1],[0,3],[3,1],[0,1],[3,2],[4,6],[0,2],[4,1],[0,1],[4,1],[0,4],[4,2],[0,7],[4,5],[0,14],[3,1],[4,4],[0,2],[3,4],[4,1],[0,1],[4,3],[0,1],[4,2],[0,3],[4,2],[0,13],[4,5],[0,13],[4,3],[3,1],[4,2],[0,3],[3,4],[4,5],[0,1],[4,2],[0,1],[4,3],[0,14],[4,1],[0,2],[4,2],[0,11],[3,1],[4,6],[0,1],[3,2],[0,3],[3,3],[4,10],[0,15],[4,3],[0,6],[4,1],[0,1],[4,3],[0,1],[3,1],[0,3],[4,1],[0,1],[4,1],[0,3],[3,2],[4,2],[0,1],[3,1],[0,1],[4,1],[0,1],[4,2],[0,1],[4,4],[0,6],[28,1],[0,7],[28,1],[0,9],[4,5],[0,5],[4,1],[3,1],[4,1],[0,2],[3,1],[0,1],[4,1],[0,3],[3,1],[0,1],[4,2],[0,1],[4,5],[0,25],[4,4],[0,10]]} \ No newline at end of file diff --git a/apps/game/assets/maps/old_siding.map b/apps/game/assets/maps/old_siding.map deleted file mode 100644 index 017631f..0000000 --- a/apps/game/assets/maps/old_siding.map +++ /dev/null @@ -1 +0,0 @@ -{"format":"rpg-map","version":1,"width":48,"height":40,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"variants":{"0":[0,11,12],"1":[1,14],"3":[3,22,23],"4":[4,13],"7":[7,24,25,26,27],"28":[28,30,31],"29":[29,32,33]},"decor":[{"id":16,"x":5,"y":0,"dx":-1,"dy":0},{"id":16,"x":13,"y":0,"dx":-4,"dy":0},{"id":16,"x":18,"y":0,"dx":-2,"dy":-1},{"id":16,"x":24,"y":0,"dx":-2,"dy":-2},{"id":16,"x":28,"y":0,"dx":0,"dy":-1},{"id":16,"x":30,"y":0,"dx":-2,"dy":2},{"id":16,"x":13,"y":1,"dx":3,"dy":1},{"id":16,"x":17,"y":1,"dx":0,"dy":-2},{"id":16,"x":37,"y":1,"dx":1,"dy":1},{"id":16,"x":39,"y":1,"dx":1,"dy":1},{"id":16,"x":43,"y":1,"dx":-1,"dy":2},{"id":16,"x":2,"y":2,"dx":-2,"dy":-1},{"id":16,"x":8,"y":2,"dx":-3,"dy":-1},{"id":16,"x":12,"y":2,"dx":-1,"dy":-1},{"id":16,"x":22,"y":2,"dx":-3,"dy":-1},{"id":16,"x":43,"y":2,"dx":0,"dy":0},{"id":16,"x":46,"y":2,"dx":0,"dy":0},{"id":16,"x":19,"y":3,"dx":-3,"dy":1},{"id":16,"x":24,"y":3,"dx":1,"dy":-1},{"id":16,"x":17,"y":4,"dx":1,"dy":0},{"id":16,"x":19,"y":4,"dx":-1,"dy":-1},{"id":16,"x":22,"y":4,"dx":1,"dy":1},{"id":16,"x":29,"y":4,"dx":2,"dy":0},{"id":16,"x":1,"y":5,"dx":-3,"dy":0},{"id":16,"x":3,"y":5,"dx":-3,"dy":1},{"id":16,"x":41,"y":5,"dx":-2,"dy":1},{"id":16,"x":44,"y":5,"dx":-1,"dy":-1},{"id":16,"x":27,"y":6,"dx":2,"dy":0},{"id":16,"x":31,"y":6,"dx":-2,"dy":1},{"id":16,"x":17,"y":7,"dx":-2,"dy":-2},{"id":16,"x":27,"y":7,"dx":0,"dy":1},{"id":16,"x":32,"y":7,"dx":-3,"dy":1},{"id":16,"x":45,"y":7,"dx":-2,"dy":-1},{"id":16,"x":5,"y":8,"dx":-3,"dy":0},{"id":16,"x":27,"y":8,"dx":-3,"dy":-2},{"id":16,"x":28,"y":8,"dx":3,"dy":2},{"id":16,"x":8,"y":9,"dx":1,"dy":0},{"id":16,"x":14,"y":9,"dx":-4,"dy":-1},{"id":16,"x":31,"y":9,"dx":-1,"dy":1},{"id":16,"x":38,"y":9,"dx":0,"dy":1},{"id":16,"x":0,"y":10,"dx":3,"dy":-2},{"id":16,"x":2,"y":10,"dx":-4,"dy":0},{"id":16,"x":4,"y":10,"dx":0,"dy":0},{"id":16,"x":10,"y":10,"dx":-3,"dy":-1},{"id":16,"x":21,"y":10,"dx":-4,"dy":-1},{"id":16,"x":40,"y":10,"dx":-3,"dy":-2},{"id":16,"x":42,"y":10,"dx":3,"dy":1},{"id":16,"x":9,"y":11,"dx":-2,"dy":2},{"id":16,"x":12,"y":11,"dx":0,"dy":1},{"id":16,"x":13,"y":11,"dx":3,"dy":2},{"id":16,"x":31,"y":11,"dx":-3,"dy":-1},{"id":16,"x":41,"y":11,"dx":-4,"dy":-2},{"id":16,"x":42,"y":11,"dx":3,"dy":-1},{"id":16,"x":43,"y":11,"dx":-1,"dy":1},{"id":16,"x":1,"y":12,"dx":1,"dy":2},{"id":16,"x":4,"y":12,"dx":0,"dy":2},{"id":16,"x":7,"y":12,"dx":-1,"dy":2},{"id":16,"x":25,"y":12,"dx":1,"dy":0},{"id":16,"x":13,"y":13,"dx":-2,"dy":1},{"id":16,"x":16,"y":13,"dx":1,"dy":1},{"id":16,"x":20,"y":13,"dx":2,"dy":2},{"id":16,"x":4,"y":14,"dx":-3,"dy":-1},{"id":16,"x":44,"y":14,"dx":0,"dy":-2},{"id":16,"x":3,"y":15,"dx":3,"dy":1},{"id":16,"x":19,"y":15,"dx":-1,"dy":0},{"id":16,"x":20,"y":15,"dx":0,"dy":-2},{"id":16,"x":21,"y":15,"dx":-1,"dy":0},{"id":16,"x":28,"y":15,"dx":-2,"dy":-2},{"id":16,"x":35,"y":15,"dx":-1,"dy":1},{"id":16,"x":38,"y":15,"dx":0,"dy":2},{"id":16,"x":46,"y":15,"dx":-4,"dy":-1},{"id":16,"x":2,"y":16,"dx":4,"dy":1},{"id":16,"x":11,"y":16,"dx":3,"dy":0},{"id":16,"x":21,"y":16,"dx":3,"dy":0},{"id":16,"x":7,"y":17,"dx":-1,"dy":2},{"id":16,"x":31,"y":17,"dx":1,"dy":1},{"id":16,"x":32,"y":17,"dx":-4,"dy":1},{"id":16,"x":42,"y":17,"dx":3,"dy":0},{"id":16,"x":27,"y":18,"dx":-3,"dy":-1},{"id":16,"x":39,"y":18,"dx":-1,"dy":0},{"id":16,"x":40,"y":18,"dx":1,"dy":2},{"id":16,"x":41,"y":18,"dx":-3,"dy":-2},{"id":16,"x":21,"y":19,"dx":2,"dy":1},{"id":16,"x":30,"y":19,"dx":1,"dy":-1},{"id":16,"x":0,"y":21,"dx":3,"dy":-1},{"id":16,"x":35,"y":21,"dx":-2,"dy":1},{"id":16,"x":37,"y":21,"dx":3,"dy":-2},{"id":16,"x":29,"y":22,"dx":-3,"dy":-2},{"id":16,"x":38,"y":22,"dx":4,"dy":1},{"id":16,"x":42,"y":22,"dx":-1,"dy":1},{"id":16,"x":14,"y":23,"dx":-1,"dy":-2},{"id":16,"x":33,"y":23,"dx":0,"dy":2},{"id":16,"x":36,"y":23,"dx":-2,"dy":-1},{"id":16,"x":42,"y":23,"dx":-1,"dy":-2},{"id":16,"x":10,"y":24,"dx":-1,"dy":-1},{"id":16,"x":21,"y":24,"dx":1,"dy":1},{"id":16,"x":30,"y":24,"dx":3,"dy":1},{"id":16,"x":31,"y":24,"dx":2,"dy":2},{"id":16,"x":34,"y":24,"dx":-2,"dy":-1},{"id":16,"x":46,"y":24,"dx":3,"dy":1},{"id":16,"x":1,"y":25,"dx":0,"dy":0},{"id":16,"x":4,"y":25,"dx":2,"dy":2},{"id":16,"x":20,"y":25,"dx":-1,"dy":-2},{"id":16,"x":33,"y":25,"dx":-2,"dy":2},{"id":16,"x":1,"y":26,"dx":3,"dy":2},{"id":16,"x":25,"y":26,"dx":-3,"dy":1},{"id":16,"x":0,"y":27,"dx":3,"dy":1},{"id":16,"x":3,"y":27,"dx":4,"dy":0},{"id":16,"x":6,"y":27,"dx":3,"dy":0},{"id":16,"x":11,"y":27,"dx":-2,"dy":-1},{"id":16,"x":14,"y":27,"dx":2,"dy":-2},{"id":16,"x":20,"y":27,"dx":-4,"dy":-2},{"id":16,"x":25,"y":27,"dx":3,"dy":-2},{"id":16,"x":29,"y":27,"dx":1,"dy":0},{"id":16,"x":36,"y":27,"dx":-1,"dy":-2},{"id":16,"x":39,"y":27,"dx":-4,"dy":-1},{"id":16,"x":46,"y":27,"dx":-3,"dy":-1},{"id":16,"x":14,"y":28,"dx":-4,"dy":2},{"id":16,"x":29,"y":28,"dx":-1,"dy":1},{"id":16,"x":38,"y":28,"dx":0,"dy":2},{"id":16,"x":40,"y":28,"dx":0,"dy":1},{"id":16,"x":42,"y":29,"dx":0,"dy":-1},{"id":17,"x":1,"y":30,"dx":1,"dy":1},{"id":16,"x":4,"y":30,"dx":2,"dy":1},{"id":16,"x":7,"y":30,"dx":-1,"dy":1},{"id":15,"x":21,"y":30,"dx":0,"dy":1},{"id":16,"x":30,"y":30,"dx":-2,"dy":-1},{"id":16,"x":41,"y":30,"dx":-1,"dy":0},{"id":16,"x":8,"y":31,"dx":-1,"dy":1},{"id":16,"x":15,"y":31,"dx":-2,"dy":0},{"id":16,"x":36,"y":31,"dx":4,"dy":-1},{"id":16,"x":12,"y":32,"dx":2,"dy":2},{"id":16,"x":15,"y":32,"dx":3,"dy":-1},{"id":16,"x":17,"y":32,"dx":-3,"dy":-1},{"id":16,"x":24,"y":32,"dx":2,"dy":1},{"id":16,"x":32,"y":32,"dx":3,"dy":0},{"id":16,"x":36,"y":32,"dx":0,"dy":-2},{"id":16,"x":43,"y":32,"dx":2,"dy":1},{"id":16,"x":4,"y":33,"dx":-1,"dy":1},{"id":16,"x":16,"y":33,"dx":0,"dy":0},{"id":16,"x":26,"y":33,"dx":2,"dy":1},{"id":16,"x":43,"y":33,"dx":4,"dy":0},{"id":16,"x":17,"y":34,"dx":-2,"dy":-2},{"id":16,"x":22,"y":34,"dx":-4,"dy":1},{"id":16,"x":26,"y":34,"dx":2,"dy":1},{"id":16,"x":35,"y":34,"dx":2,"dy":2},{"id":16,"x":2,"y":35,"dx":3,"dy":2},{"id":16,"x":26,"y":35,"dx":3,"dy":1},{"id":16,"x":28,"y":35,"dx":-1,"dy":1},{"id":16,"x":43,"y":35,"dx":-3,"dy":1},{"id":16,"x":10,"y":36,"dx":0,"dy":1},{"id":16,"x":11,"y":36,"dx":2,"dy":1},{"id":16,"x":27,"y":36,"dx":-1,"dy":1},{"id":16,"x":28,"y":36,"dx":-4,"dy":2},{"id":16,"x":31,"y":36,"dx":-1,"dy":1},{"id":16,"x":33,"y":36,"dx":3,"dy":-2},{"id":16,"x":42,"y":36,"dx":-1,"dy":-1},{"id":16,"x":10,"y":37,"dx":-4,"dy":-1},{"id":16,"x":18,"y":37,"dx":2,"dy":1},{"id":16,"x":31,"y":37,"dx":-4,"dy":-1},{"id":16,"x":8,"y":38,"dx":3,"dy":1},{"id":16,"x":11,"y":38,"dx":-1,"dy":1},{"id":16,"x":14,"y":38,"dx":-2,"dy":0},{"id":16,"x":27,"y":38,"dx":-1,"dy":-1}],"encoding":"rle","tiles":[[4,10],[0,1],[4,89],[0,1],[4,42],[3,1],[4,1],[3,1],[4,43],[3,1],[4,11],[0,1],[4,24],[29,1],[4,4],[0,1],[4,4],[3,1],[4,7],[3,1],[4,19],[29,1],[4,24],[3,1],[4,11],[28,1],[4,8],[19,3],[4,16],[0,1],[4,7],[3,1],[4,20],[19,2],[4,1],[19,1],[4,44],[19,2],[4,2],[19,1],[4,8],[29,1],[4,11],[3,1],[4,1],[0,1],[4,1],[3,1],[4,1],[3,1],[4,16],[19,2],[4,3],[19,1],[4,7],[0,1],[4,7],[3,1],[4,26],[19,2],[4,4],[19,1],[4,20],[3,1],[4,20],[19,2],[4,5],[19,1],[28,1],[4,22],[3,1],[4,9],[0,1],[7,1],[4,5],[19,2],[4,6],[19,1],[4,39],[19,2],[4,7],[19,1],[4,4],[28,1],[4,10],[3,1],[4,22],[19,2],[4,8],[19,1],[4,20],[3,1],[4,16],[19,2],[4,3],[0,1],[4,5],[19,1],[4,10],[3,2],[4,2],[3,1],[4,2],[3,1],[4,18],[19,2],[4,10],[19,1],[4,12],[3,1],[4,3],[3,1],[4,18],[19,2],[4,11],[19,1],[4,1],[0,1],[4,11],[3,1],[4,20],[19,2],[4,11],[0,1],[19,1],[4,7],[3,1],[4,8],[3,1],[4,8],[0,1],[4,7],[19,2],[4,13],[19,1],[4,7],[0,1],[4,3],[1,20],[4,1],[19,2],[1,3],[4,11],[19,1],[4,7],[3,1],[4,23],[19,2],[4,2],[1,1],[4,43],[19,2],[4,2],[1,1],[4,43],[19,2],[4,2],[1,1],[4,17],[0,1],[4,25],[19,2],[4,2],[1,1],[4,5],[7,1],[4,2],[29,1],[4,7],[0,1],[4,7],[29,1],[4,3],[28,1],[4,14],[19,2],[4,2],[1,1],[4,17],[3,1],[4,13],[9,1],[4,11],[19,2],[4,2],[1,1],[4,22],[3,2],[4,19],[19,2],[4,2],[1,1],[4,43],[19,2],[4,2],[1,1],[4,25],[3,2],[4,16],[19,2],[4,2],[1,1],[4,22],[0,1],[4,19],[0,1],[19,2],[4,2],[1,2],[4,15],[3,1],[4,26],[19,2],[4,3],[1,1],[20,1],[4,41],[19,2],[4,27],[3,1],[4,11],[0,1],[4,69],[28,1],[4,29],[0,1],[4,24],[3,1],[4,6],[3,1],[4,42],[0,1],[4,21],[29,1],[4,22],[3,1],[4,2],[3,1],[4,4],[3,1],[4,40],[3,1],[4,5],[3,1],[4,40],[3,1],[4,4]]} \ No newline at end of file diff --git a/apps/game/assets/maps/ponds.map b/apps/game/assets/maps/ponds.map deleted file mode 100644 index bcca04f..0000000 --- a/apps/game/assets/maps/ponds.map +++ /dev/null @@ -1 +0,0 @@ -{"format":"rpg-map","version":1,"width":48,"height":48,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"variants":{"0":[0,11,12],"1":[1,14],"3":[3,22,23],"4":[4,13],"7":[7,24,25,26,27],"28":[28,30,31],"29":[29,32,33]},"decor":[{"id":15,"x":3,"y":0,"dx":-2,"dy":2},{"id":16,"x":13,"y":0,"dx":0,"dy":0},{"id":16,"x":25,"y":0,"dx":1,"dy":-1},{"id":16,"x":37,"y":0,"dx":-4,"dy":-2},{"id":16,"x":43,"y":0,"dx":-4,"dy":0},{"id":16,"x":47,"y":0,"dx":-2,"dy":1},{"id":16,"x":13,"y":1,"dx":2,"dy":1},{"id":16,"x":22,"y":1,"dx":3,"dy":1},{"id":16,"x":39,"y":1,"dx":-1,"dy":0},{"id":15,"x":34,"y":2,"dx":3,"dy":-2},{"id":16,"x":47,"y":2,"dx":-4,"dy":0},{"id":16,"x":6,"y":3,"dx":0,"dy":-1},{"id":16,"x":23,"y":3,"dx":-3,"dy":0},{"id":16,"x":12,"y":4,"dx":-1,"dy":-1},{"id":16,"x":15,"y":4,"dx":2,"dy":-1},{"id":16,"x":28,"y":4,"dx":2,"dy":-2},{"id":16,"x":32,"y":4,"dx":0,"dy":2},{"id":15,"x":36,"y":4,"dx":-2,"dy":-1},{"id":16,"x":37,"y":4,"dx":-3,"dy":1},{"id":16,"x":0,"y":5,"dx":1,"dy":-1},{"id":16,"x":23,"y":5,"dx":3,"dy":0},{"id":16,"x":26,"y":5,"dx":3,"dy":-1},{"id":17,"x":31,"y":5,"dx":1,"dy":-1},{"id":16,"x":11,"y":6,"dx":1,"dy":0},{"id":16,"x":18,"y":6,"dx":-3,"dy":0},{"id":16,"x":21,"y":6,"dx":-3,"dy":-2},{"id":16,"x":34,"y":6,"dx":3,"dy":-1},{"id":16,"x":37,"y":6,"dx":-4,"dy":-2},{"id":16,"x":47,"y":6,"dx":-1,"dy":1},{"id":16,"x":4,"y":7,"dx":2,"dy":-1},{"id":17,"x":7,"y":7,"dx":4,"dy":0},{"id":16,"x":11,"y":7,"dx":-4,"dy":0},{"id":16,"x":21,"y":7,"dx":1,"dy":2},{"id":16,"x":1,"y":8,"dx":2,"dy":1},{"id":16,"x":2,"y":8,"dx":1,"dy":-2},{"id":16,"x":14,"y":8,"dx":2,"dy":0},{"id":16,"x":28,"y":8,"dx":4,"dy":-1},{"id":17,"x":41,"y":8,"dx":3,"dy":2},{"id":16,"x":47,"y":8,"dx":-2,"dy":2},{"id":16,"x":17,"y":9,"dx":0,"dy":0},{"id":16,"x":28,"y":9,"dx":3,"dy":0},{"id":16,"x":33,"y":9,"dx":0,"dy":-1},{"id":16,"x":17,"y":10,"dx":-2,"dy":-2},{"id":16,"x":22,"y":10,"dx":3,"dy":-1},{"id":16,"x":28,"y":10,"dx":-1,"dy":1},{"id":16,"x":37,"y":10,"dx":2,"dy":1},{"id":16,"x":8,"y":11,"dx":1,"dy":1},{"id":16,"x":9,"y":11,"dx":-1,"dy":1},{"id":16,"x":17,"y":11,"dx":0,"dy":-1},{"id":16,"x":21,"y":11,"dx":0,"dy":-1},{"id":16,"x":22,"y":11,"dx":-1,"dy":-2},{"id":16,"x":25,"y":11,"dx":-4,"dy":-2},{"id":17,"x":34,"y":11,"dx":-2,"dy":-1},{"id":17,"x":41,"y":11,"dx":3,"dy":0},{"id":16,"x":1,"y":12,"dx":1,"dy":0},{"id":15,"x":9,"y":12,"dx":3,"dy":-2},{"id":16,"x":10,"y":12,"dx":4,"dy":1},{"id":16,"x":16,"y":12,"dx":-3,"dy":1},{"id":16,"x":19,"y":12,"dx":1,"dy":-2},{"id":16,"x":26,"y":12,"dx":4,"dy":1},{"id":16,"x":3,"y":13,"dx":0,"dy":1},{"id":16,"x":31,"y":13,"dx":-1,"dy":0},{"id":16,"x":33,"y":13,"dx":2,"dy":-1},{"id":16,"x":37,"y":13,"dx":1,"dy":1},{"id":17,"x":43,"y":13,"dx":0,"dy":-1},{"id":16,"x":1,"y":14,"dx":-1,"dy":0},{"id":16,"x":5,"y":14,"dx":2,"dy":-2},{"id":15,"x":41,"y":14,"dx":-3,"dy":1},{"id":16,"x":42,"y":14,"dx":-1,"dy":-1},{"id":16,"x":3,"y":15,"dx":4,"dy":-1},{"id":16,"x":13,"y":15,"dx":1,"dy":0},{"id":16,"x":16,"y":15,"dx":0,"dy":2},{"id":16,"x":44,"y":15,"dx":2,"dy":1},{"id":16,"x":41,"y":16,"dx":2,"dy":2},{"id":16,"x":45,"y":16,"dx":-2,"dy":-2},{"id":16,"x":46,"y":16,"dx":4,"dy":-1},{"id":16,"x":45,"y":17,"dx":-4,"dy":1},{"id":16,"x":47,"y":17,"dx":3,"dy":1},{"id":15,"x":38,"y":18,"dx":-2,"dy":0},{"id":16,"x":1,"y":19,"dx":2,"dy":0},{"id":16,"x":40,"y":19,"dx":-3,"dy":-1},{"id":16,"x":3,"y":21,"dx":1,"dy":-1},{"id":15,"x":6,"y":22,"dx":-2,"dy":2},{"id":15,"x":7,"y":22,"dx":2,"dy":-2},{"id":15,"x":9,"y":22,"dx":2,"dy":-1},{"id":16,"x":39,"y":23,"dx":-1,"dy":0},{"id":16,"x":46,"y":23,"dx":0,"dy":0},{"id":16,"x":1,"y":24,"dx":2,"dy":-2},{"id":16,"x":3,"y":24,"dx":-3,"dy":0},{"id":16,"x":2,"y":25,"dx":2,"dy":0},{"id":16,"x":46,"y":26,"dx":2,"dy":1},{"id":16,"x":4,"y":27,"dx":1,"dy":-2},{"id":16,"x":38,"y":27,"dx":-2,"dy":1},{"id":16,"x":41,"y":27,"dx":4,"dy":-1},{"id":16,"x":3,"y":28,"dx":2,"dy":-2},{"id":16,"x":47,"y":28,"dx":-2,"dy":2},{"id":16,"x":41,"y":29,"dx":0,"dy":0},{"id":15,"x":3,"y":30,"dx":-3,"dy":0},{"id":16,"x":9,"y":30,"dx":-3,"dy":1},{"id":16,"x":40,"y":30,"dx":-4,"dy":0},{"id":16,"x":41,"y":30,"dx":0,"dy":-2},{"id":16,"x":43,"y":30,"dx":0,"dy":-1},{"id":16,"x":41,"y":31,"dx":4,"dy":-2},{"id":16,"x":31,"y":32,"dx":-2,"dy":-2},{"id":16,"x":36,"y":32,"dx":-2,"dy":-2},{"id":16,"x":47,"y":32,"dx":-3,"dy":1},{"id":17,"x":36,"y":33,"dx":2,"dy":0},{"id":16,"x":39,"y":33,"dx":2,"dy":0},{"id":16,"x":45,"y":33,"dx":2,"dy":2},{"id":16,"x":7,"y":34,"dx":3,"dy":0},{"id":15,"x":27,"y":34,"dx":1,"dy":1},{"id":16,"x":35,"y":34,"dx":-3,"dy":1},{"id":16,"x":39,"y":34,"dx":1,"dy":0},{"id":16,"x":1,"y":35,"dx":-1,"dy":-1},{"id":16,"x":31,"y":35,"dx":2,"dy":-1},{"id":16,"x":8,"y":36,"dx":-1,"dy":1},{"id":16,"x":12,"y":36,"dx":-3,"dy":0},{"id":17,"x":17,"y":36,"dx":-1,"dy":-1},{"id":16,"x":40,"y":36,"dx":2,"dy":-2},{"id":16,"x":45,"y":36,"dx":-1,"dy":-1},{"id":16,"x":46,"y":36,"dx":3,"dy":2},{"id":16,"x":9,"y":37,"dx":2,"dy":-1},{"id":16,"x":13,"y":37,"dx":-2,"dy":1},{"id":15,"x":16,"y":37,"dx":2,"dy":-1},{"id":16,"x":33,"y":37,"dx":1,"dy":1},{"id":16,"x":39,"y":37,"dx":-1,"dy":0},{"id":16,"x":40,"y":37,"dx":-1,"dy":1},{"id":16,"x":41,"y":37,"dx":-4,"dy":1},{"id":16,"x":10,"y":38,"dx":-3,"dy":1},{"id":16,"x":15,"y":38,"dx":2,"dy":0},{"id":17,"x":18,"y":38,"dx":1,"dy":2},{"id":16,"x":44,"y":38,"dx":-3,"dy":-1},{"id":16,"x":45,"y":38,"dx":-3,"dy":0},{"id":16,"x":5,"y":39,"dx":1,"dy":-1},{"id":16,"x":17,"y":39,"dx":-3,"dy":1},{"id":16,"x":3,"y":40,"dx":-2,"dy":0},{"id":16,"x":16,"y":40,"dx":3,"dy":-2},{"id":16,"x":21,"y":40,"dx":-1,"dy":-1},{"id":16,"x":29,"y":40,"dx":-2,"dy":2},{"id":16,"x":41,"y":40,"dx":2,"dy":0},{"id":16,"x":5,"y":41,"dx":-1,"dy":-1},{"id":16,"x":2,"y":42,"dx":3,"dy":-2},{"id":15,"x":6,"y":42,"dx":3,"dy":0},{"id":16,"x":9,"y":42,"dx":-3,"dy":2},{"id":16,"x":42,"y":42,"dx":-2,"dy":1},{"id":16,"x":43,"y":42,"dx":-3,"dy":2},{"id":16,"x":47,"y":42,"dx":4,"dy":1},{"id":16,"x":5,"y":43,"dx":2,"dy":1},{"id":16,"x":8,"y":43,"dx":3,"dy":2},{"id":16,"x":21,"y":43,"dx":-1,"dy":0},{"id":17,"x":28,"y":43,"dx":2,"dy":-1},{"id":15,"x":32,"y":43,"dx":-1,"dy":-2},{"id":16,"x":38,"y":43,"dx":-2,"dy":-2},{"id":16,"x":41,"y":43,"dx":2,"dy":2},{"id":16,"x":42,"y":43,"dx":-3,"dy":-1},{"id":16,"x":46,"y":43,"dx":-1,"dy":0},{"id":16,"x":0,"y":44,"dx":-1,"dy":1},{"id":16,"x":2,"y":44,"dx":3,"dy":2},{"id":15,"x":30,"y":44,"dx":4,"dy":-1},{"id":16,"x":35,"y":44,"dx":1,"dy":0},{"id":16,"x":37,"y":44,"dx":3,"dy":-1},{"id":16,"x":42,"y":44,"dx":1,"dy":-1},{"id":16,"x":14,"y":45,"dx":1,"dy":0},{"id":16,"x":17,"y":45,"dx":-3,"dy":1},{"id":16,"x":25,"y":45,"dx":-4,"dy":0},{"id":15,"x":29,"y":45,"dx":4,"dy":1},{"id":16,"x":30,"y":45,"dx":-2,"dy":2},{"id":16,"x":36,"y":45,"dx":2,"dy":1},{"id":16,"x":39,"y":45,"dx":-1,"dy":0},{"id":16,"x":40,"y":45,"dx":0,"dy":1},{"id":16,"x":9,"y":46,"dx":-3,"dy":2},{"id":16,"x":11,"y":46,"dx":2,"dy":-2},{"id":16,"x":21,"y":46,"dx":1,"dy":-1},{"id":16,"x":22,"y":46,"dx":-3,"dy":2},{"id":16,"x":24,"y":46,"dx":3,"dy":0},{"id":16,"x":3,"y":47,"dx":-1,"dy":2},{"id":16,"x":18,"y":47,"dx":3,"dy":-1},{"id":16,"x":20,"y":47,"dx":1,"dy":1},{"id":15,"x":30,"y":47,"dx":3,"dy":1}],"encoding":"rle","tiles":[[4,1],[0,5],[4,26],[0,1],[4,1],[0,2],[4,8],[3,1],[4,4],[3,2],[0,3],[4,23],[0,1],[4,1],[0,4],[4,10],[3,1],[4,3],[0,1],[1,1],[0,3],[4,3],[0,2],[4,18],[0,2],[4,1],[0,4],[4,13],[3,2],[0,3],[4,3],[0,1],[4,1],[0,2],[4,16],[0,3],[4,1],[0,1],[4,1],[0,1],[4,8],[3,2],[4,6],[1,1],[0,1],[4,2],[28,1],[0,2],[4,5],[0,1],[4,2],[0,1],[4,4],[28,1],[4,4],[0,2],[4,2],[0,1],[4,1],[0,2],[4,15],[0,2],[1,3],[0,1],[4,6],[0,1],[4,2],[0,1],[4,5],[29,1],[4,3],[0,3],[4,4],[0,2],[4,8],[3,1],[4,6],[0,3],[1,3],[4,6],[0,1],[4,1],[0,2],[4,23],[3,1],[4,6],[3,1],[4,1],[3,1],[4,1],[0,2],[4,1],[1,1],[4,4],[0,3],[4,1],[0,2],[4,10],[29,1],[4,2],[0,1],[4,6],[0,1],[4,1],[0,3],[3,1],[4,4],[3,3],[4,1],[0,2],[4,1],[1,1],[4,1],[0,2],[4,1],[0,2],[4,1],[0,3],[4,12],[0,1],[4,2],[0,1],[4,4],[0,4],[4,3],[3,1],[4,4],[0,1],[4,2],[0,2],[1,2],[4,2],[0,2],[4,2],[29,1],[4,7],[0,1],[4,2],[0,2],[4,1],[0,1],[4,1],[0,2],[4,7],[0,5],[3,1],[4,2],[3,1],[4,7],[1,1],[0,1],[4,1],[0,3],[4,9],[0,2],[4,1],[0,1],[4,3],[0,4],[4,2],[0,1],[4,1],[0,5],[4,2],[3,1],[4,2],[3,1],[4,7],[1,1],[0,2],[4,1],[28,1],[4,10],[0,2],[4,1],[0,2],[4,2],[0,2],[4,4],[0,3],[3,1],[0,1],[4,1],[3,1],[4,11],[0,1],[4,1],[1,1],[0,1],[4,1],[0,2],[4,11],[0,2],[4,1],[0,1],[4,2],[0,1],[4,1],[0,1],[4,2],[5,1],[0,3],[4,1],[0,2],[3,2],[4,1],[3,1],[4,7],[0,1],[4,2],[1,2],[4,8],[2,2],[4,1],[2,1],[4,1],[0,1],[4,1],[0,1],[4,1],[2,1],[4,8],[0,1],[28,1],[0,3],[4,1],[0,1],[4,6],[3,1],[4,3],[0,2],[4,2],[1,1],[4,4],[2,1],[0,1],[2,2],[4,1],[2,3],[4,2],[0,1],[2,2],[4,6],[0,1],[4,1],[0,2],[4,1],[0,1],[4,7],[3,1],[4,3],[3,1],[4,3],[0,1],[4,2],[1,1],[4,4],[0,1],[2,13],[0,1],[2,1],[4,1],[2,1],[4,2],[0,1],[4,1],[0,2],[4,14],[0,1],[5,1],[4,1],[0,1],[4,1],[1,2],[4,2],[2,19],[4,2],[0,1],[4,1],[0,1],[4,3],[3,1],[4,4],[3,2],[4,5],[0,3],[2,3],[1,1],[2,21],[4,1],[2,2],[0,1],[4,1],[0,1],[4,3],[3,1],[4,9],[29,1],[4,1],[0,1],[4,1],[2,1],[4,1],[2,1],[1,3],[2,18],[4,2],[2,1],[4,1],[0,1],[2,1],[4,3],[3,1],[4,9],[0,4],[2,1],[0,1],[4,1],[2,3],[1,4],[2,17],[4,2],[2,1],[0,1],[4,5],[3,1],[4,4],[3,1],[4,3],[0,2],[4,1],[0,2],[2,7],[1,1],[2,21],[4,4],[3,3],[4,3],[3,1],[4,4],[0,2],[2,1],[4,1],[2,7],[1,1],[2,20],[4,1],[2,1],[4,2],[3,1],[4,2],[28,1],[4,5],[3,1],[4,1],[0,2],[4,1],[0,1],[2,8],[1,1],[2,19],[4,1],[2,1],[4,9],[3,1],[4,4],[0,2],[2,2],[4,1],[2,7],[1,1],[2,19],[4,2],[2,1],[4,2],[3,1],[4,11],[2,1],[4,1],[2,9],[1,3],[2,18],[4,18],[2,1],[4,1],[2,9],[1,3],[2,17],[4,3],[0,2],[4,3],[0,2],[3,1],[4,1],[3,1],[4,5],[2,12],[1,2],[2,14],[4,2],[28,1],[4,1],[0,1],[4,1],[3,1],[4,3],[3,1],[4,1],[0,1],[4,5],[2,1],[4,1],[2,13],[1,1],[2,14],[4,5],[3,1],[4,1],[3,2],[4,1],[0,2],[4,2],[3,1],[4,5],[2,1],[4,1],[2,11],[1,1],[2,15],[4,5],[3,1],[4,3],[0,4],[4,5],[2,1],[4,1],[2,12],[1,2],[2,12],[4,11],[0,1],[4,1],[0,2],[4,7],[2,2],[29,1],[2,10],[1,6],[2,7],[4,9],[3,1],[4,3],[3,1],[4,2],[3,1],[4,5],[2,2],[4,1],[2,23],[4,6],[3,1],[4,3],[3,1],[4,1],[0,2],[4,8],[2,1],[4,1],[2,2],[0,1],[2,1],[0,1],[2,13],[4,1],[2,3],[4,8],[3,1],[4,2],[3,1],[4,2],[0,1],[3,2],[4,8],[2,1],[4,2],[0,1],[2,1],[4,1],[2,13],[4,2],[2,1],[4,1],[2,1],[0,1],[4,1],[0,1],[4,4],[3,2],[4,4],[0,2],[3,1],[4,8],[5,1],[4,2],[0,1],[4,1],[0,2],[2,8],[0,1],[2,1],[4,1],[2,3],[4,1],[2,1],[4,1],[5,1],[0,2],[4,11],[3,1],[0,1],[4,11],[0,1],[4,1],[0,5],[2,1],[0,2],[2,1],[0,2],[4,1],[2,1],[4,3],[29,1],[4,2],[0,1],[4,1],[0,1],[4,10],[3,1],[4,2],[3,1],[4,11],[0,5],[4,1],[0,2],[4,2],[0,2],[4,24],[3,1],[4,1],[0,1],[4,9],[0,2],[4,1],[0,5],[4,1],[0,2],[4,22],[3,2],[4,15],[0,5],[4,13],[0,1],[4,15],[0,1],[4,1],[0,1],[4,13],[0,2],[4,1],[0,1],[4,11],[0,2],[4,6],[3,1],[4,5],[3,1],[4,7],[28,1],[4,9],[0,1],[29,1],[4,1],[0,1],[4,9],[0,1],[4,1],[0,3],[4,13],[3,1],[4,3],[0,2],[4,11],[0,1],[29,1],[0,5],[4,6],[0,2],[4,1],[0,2],[4,17],[0,3],[4,11],[0,1],[4,1],[0,2],[5,1],[4,7],[29,1],[4,1],[0,3],[4,9],[3,1],[4,2],[3,3],[4,2],[0,2],[4,15],[0,3],[4,1],[0,2],[4,1],[0,1],[4,1],[0,3],[4,1],[0,1],[4,12],[3,1],[4,1],[3,1],[4,15],[0,2],[4,6],[0,1],[4,2],[0,4],[4,2],[0,1],[4,9],[3,1],[4,21],[0,1],[4,6],[0,1],[4,1],[0,1],[4,1],[0,1],[4,17],[3,1],[4,1],[3,1],[4,3],[28,1],[4,2],[0,1],[4,7],[0,2],[4,8],[0,1],[4,1],[0,2],[4,6],[29,1],[4,6],[3,1],[4,10],[0,2],[4,17],[0,1],[4,1],[0,2],[4,12],[3,1],[4,4]]} \ No newline at end of file diff --git a/apps/game/assets/maps/rust_grove.map b/apps/game/assets/maps/rust_grove.map deleted file mode 100644 index 411fdc3..0000000 --- a/apps/game/assets/maps/rust_grove.map +++ /dev/null @@ -1 +0,0 @@ -{"format":"rpg-map","version":1,"width":48,"height":40,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"variants":{"0":[0,11,12],"1":[1,14],"3":[3,22,23],"4":[4,13],"7":[7,24,25,26,27],"28":[28,30,31],"29":[29,32,33]},"decor":[{"id":16,"x":10,"y":0,"dx":1,"dy":1},{"id":16,"x":3,"y":1,"dx":3,"dy":2},{"id":16,"x":26,"y":1,"dx":-3,"dy":1},{"id":16,"x":42,"y":1,"dx":-2,"dy":1},{"id":16,"x":46,"y":1,"dx":3,"dy":0},{"id":16,"x":14,"y":2,"dx":3,"dy":1},{"id":16,"x":23,"y":2,"dx":-1,"dy":-2},{"id":16,"x":16,"y":5,"dx":0,"dy":-1},{"id":16,"x":9,"y":6,"dx":2,"dy":1},{"id":16,"x":12,"y":6,"dx":1,"dy":0},{"id":16,"x":20,"y":6,"dx":-2,"dy":-1},{"id":16,"x":47,"y":6,"dx":-3,"dy":1},{"id":16,"x":2,"y":7,"dx":-4,"dy":1},{"id":16,"x":10,"y":7,"dx":3,"dy":2},{"id":16,"x":16,"y":7,"dx":2,"dy":-1},{"id":16,"x":17,"y":7,"dx":-2,"dy":2},{"id":16,"x":20,"y":7,"dx":-2,"dy":0},{"id":16,"x":37,"y":8,"dx":1,"dy":1},{"id":16,"x":0,"y":9,"dx":-1,"dy":-1},{"id":16,"x":15,"y":9,"dx":-2,"dy":2},{"id":16,"x":27,"y":9,"dx":2,"dy":1},{"id":15,"x":38,"y":9,"dx":-3,"dy":-2},{"id":16,"x":23,"y":10,"dx":3,"dy":-1},{"id":16,"x":2,"y":11,"dx":3,"dy":2},{"id":16,"x":21,"y":11,"dx":-2,"dy":0},{"id":16,"x":31,"y":12,"dx":1,"dy":-2},{"id":16,"x":10,"y":13,"dx":-3,"dy":-1},{"id":16,"x":29,"y":13,"dx":-3,"dy":0},{"id":16,"x":36,"y":13,"dx":-3,"dy":1},{"id":16,"x":44,"y":13,"dx":2,"dy":2},{"id":16,"x":45,"y":13,"dx":-1,"dy":-2},{"id":16,"x":13,"y":15,"dx":-2,"dy":1},{"id":16,"x":30,"y":15,"dx":3,"dy":2},{"id":16,"x":33,"y":15,"dx":-2,"dy":0},{"id":16,"x":22,"y":16,"dx":2,"dy":-2},{"id":16,"x":26,"y":16,"dx":-3,"dy":0},{"id":16,"x":30,"y":16,"dx":4,"dy":1},{"id":16,"x":32,"y":16,"dx":3,"dy":0},{"id":16,"x":37,"y":16,"dx":0,"dy":-1},{"id":16,"x":12,"y":17,"dx":-4,"dy":0},{"id":16,"x":27,"y":17,"dx":-2,"dy":-1},{"id":16,"x":19,"y":18,"dx":-3,"dy":-2},{"id":16,"x":29,"y":18,"dx":3,"dy":-1},{"id":16,"x":19,"y":19,"dx":2,"dy":-1},{"id":16,"x":43,"y":19,"dx":2,"dy":-2},{"id":16,"x":44,"y":19,"dx":-2,"dy":1},{"id":16,"x":27,"y":20,"dx":0,"dy":-2},{"id":16,"x":20,"y":21,"dx":2,"dy":0},{"id":16,"x":21,"y":21,"dx":-2,"dy":-2},{"id":16,"x":24,"y":21,"dx":1,"dy":0},{"id":16,"x":1,"y":22,"dx":3,"dy":1},{"id":16,"x":16,"y":22,"dx":1,"dy":0},{"id":16,"x":19,"y":22,"dx":1,"dy":-2},{"id":16,"x":46,"y":22,"dx":0,"dy":1},{"id":16,"x":47,"y":22,"dx":0,"dy":0},{"id":16,"x":2,"y":23,"dx":-3,"dy":0},{"id":16,"x":20,"y":23,"dx":4,"dy":0},{"id":16,"x":33,"y":23,"dx":2,"dy":1},{"id":16,"x":41,"y":23,"dx":3,"dy":-1},{"id":16,"x":47,"y":23,"dx":-2,"dy":-2},{"id":15,"x":0,"y":24,"dx":-3,"dy":-1},{"id":16,"x":16,"y":24,"dx":1,"dy":2},{"id":16,"x":27,"y":24,"dx":0,"dy":0},{"id":16,"x":6,"y":25,"dx":-3,"dy":0},{"id":16,"x":38,"y":25,"dx":-2,"dy":-1},{"id":16,"x":2,"y":26,"dx":2,"dy":-1},{"id":16,"x":13,"y":26,"dx":1,"dy":1},{"id":16,"x":8,"y":28,"dx":0,"dy":2},{"id":16,"x":31,"y":28,"dx":-1,"dy":-1},{"id":16,"x":7,"y":30,"dx":2,"dy":0},{"id":16,"x":32,"y":30,"dx":2,"dy":-1},{"id":16,"x":43,"y":30,"dx":1,"dy":1},{"id":16,"x":23,"y":31,"dx":-2,"dy":2},{"id":16,"x":24,"y":31,"dx":0,"dy":1},{"id":16,"x":25,"y":31,"dx":1,"dy":1},{"id":16,"x":17,"y":32,"dx":-3,"dy":0},{"id":16,"x":21,"y":32,"dx":4,"dy":0},{"id":16,"x":14,"y":33,"dx":2,"dy":2},{"id":16,"x":16,"y":33,"dx":2,"dy":1},{"id":16,"x":22,"y":33,"dx":2,"dy":0},{"id":16,"x":14,"y":34,"dx":-1,"dy":1},{"id":16,"x":22,"y":34,"dx":2,"dy":1},{"id":16,"x":12,"y":35,"dx":-2,"dy":1},{"id":16,"x":21,"y":35,"dx":1,"dy":1},{"id":16,"x":11,"y":36,"dx":4,"dy":-1},{"id":16,"x":17,"y":37,"dx":-2,"dy":-2},{"id":16,"x":23,"y":37,"dx":-2,"dy":2},{"id":16,"x":24,"y":37,"dx":-3,"dy":-1},{"id":16,"x":34,"y":37,"dx":0,"dy":-1},{"id":16,"x":0,"y":38,"dx":1,"dy":2},{"id":16,"x":27,"y":38,"dx":1,"dy":0},{"id":16,"x":29,"y":38,"dx":-2,"dy":1},{"id":16,"x":34,"y":38,"dx":-2,"dy":-1}],"encoding":"rle","tiles":[[3,8],[4,3],[3,5],[4,1],[3,3],[4,1],[3,1],[4,1],[3,2],[4,1],[3,2],[4,1],[3,1],[4,1],[3,7],[4,1],[3,4],[4,1],[3,3],[4,1],[3,3],[4,2],[3,5],[4,2],[3,1],[4,1],[3,2],[4,1],[3,6],[4,1],[3,2],[4,1],[3,1],[4,1],[3,5],[4,1],[3,7],[4,1],[3,3],[4,1],[3,7],[29,1],[3,2],[28,1],[3,1],[4,1],[3,2],[4,1],[3,1],[0,1],[3,4],[4,3],[3,10],[4,1],[3,6],[4,2],[3,11],[4,1],[3,1],[4,1],[3,2],[4,1],[3,4],[4,3],[3,1],[4,1],[3,4],[4,2],[3,1],[4,1],[3,1],[4,3],[3,2],[4,1],[3,4],[4,2],[3,15],[4,1],[3,4],[29,1],[3,4],[4,4],[3,4],[4,6],[3,3],[4,1],[3,3],[4,1],[3,3],[4,1],[3,6],[4,2],[3,1],[4,3],[3,5],[4,3],[3,2],[4,1],[3,3],[4,1],[3,6],[4,4],[3,1],[4,1],[3,1],[4,1],[3,6],[4,2],[3,6],[4,1],[3,3],[4,2],[3,2],[4,4],[3,4],[4,1],[3,2],[4,1],[3,8],[18,1],[4,2],[3,1],[0,1],[4,2],[3,3],[4,1],[3,4],[4,1],[3,2],[4,1],[3,2],[4,1],[3,4],[4,1],[3,2],[4,1],[3,2],[4,5],[3,1],[4,1],[3,3],[0,1],[3,3],[4,1],[3,1],[4,1],[3,1],[4,3],[3,3],[4,2],[3,1],[4,2],[3,1],[4,1],[3,5],[4,2],[3,1],[4,1],[3,1],[4,1],[3,2],[18,1],[4,1],[3,1],[4,1],[3,1],[4,1],[3,1],[4,2],[3,3],[4,1],[3,3],[4,3],[3,1],[4,2],[3,3],[4,2],[3,8],[4,4],[3,7],[4,1],[18,1],[3,3],[4,6],[3,1],[4,1],[3,1],[4,1],[3,2],[4,1],[3,2],[4,1],[3,3],[4,3],[3,1],[0,1],[3,3],[4,1],[3,6],[4,2],[3,3],[4,1],[3,2],[4,1],[3,1],[4,1],[18,1],[3,1],[0,1],[4,2],[3,2],[4,5],[18,1],[4,1],[3,1],[4,1],[3,2],[4,3],[3,1],[4,2],[3,1],[4,1],[3,2],[4,1],[3,1],[4,2],[3,6],[4,1],[3,4],[4,1],[3,2],[4,2],[3,2],[4,1],[3,3],[4,1],[3,1],[4,3],[3,1],[18,1],[3,1],[4,3],[3,1],[4,1],[3,1],[4,3],[3,4],[0,1],[3,4],[4,1],[3,1],[4,1],[3,1],[4,2],[0,1],[4,3],[3,1],[4,1],[3,1],[4,1],[3,1],[4,3],[3,1],[4,1],[3,3],[4,1],[29,1],[3,5],[4,2],[3,1],[4,1],[3,1],[4,5],[3,2],[4,1],[3,1],[4,1],[3,2],[4,4],[3,1],[4,2],[3,1],[4,1],[3,5],[4,5],[3,2],[4,2],[3,3],[4,3],[3,2],[4,4],[3,1],[4,1],[3,1],[4,4],[3,1],[4,1],[3,4],[4,4],[3,1],[4,1],[3,2],[4,3],[3,2],[4,7],[3,3],[4,4],[3,2],[4,4],[3,2],[4,1],[3,1],[4,5],[3,3],[4,6],[3,5],[4,1],[3,3],[4,3],[3,2],[4,2],[3,3],[4,1],[3,1],[4,15],[3,2],[4,2],[3,2],[4,1],[3,1],[4,2],[3,1],[4,2],[3,2],[4,2],[3,1],[4,5],[3,2],[4,1],[3,1],[4,3],[3,2],[4,11],[3,1],[4,5],[3,2],[4,1],[29,1],[3,1],[4,1],[3,2],[4,2],[3,1],[4,1],[3,6],[4,2],[3,1],[4,2],[3,2],[4,2],[3,1],[4,17],[3,5],[4,1],[3,1],[4,3],[3,4],[4,1],[3,2],[4,2],[3,2],[4,2],[3,1],[4,1],[3,1],[4,3],[3,1],[4,2],[3,2],[4,13],[3,1],[4,1],[3,1],[4,1],[3,1],[4,1],[3,2],[4,1],[3,2],[4,2],[3,1],[4,4],[3,2],[4,1],[3,3],[4,1],[3,1],[4,1],[3,1],[4,2],[3,1],[4,3],[3,1],[4,13],[3,1],[4,1],[3,1],[4,1],[28,1],[3,1],[4,1],[3,1],[4,1],[3,2],[29,1],[4,3],[3,3],[1,17],[4,13],[1,16],[4,3],[3,9],[4,1],[3,1],[4,1],[3,1],[4,2],[3,1],[4,13],[3,2],[4,1],[3,2],[4,3],[3,2],[4,1],[3,5],[4,3],[3,1],[4,1],[3,3],[28,1],[4,1],[3,3],[4,1],[3,2],[4,17],[3,2],[4,1],[3,1],[4,1],[3,3],[4,2],[3,4],[4,3],[3,1],[4,2],[3,4],[4,1],[3,1],[4,1],[3,1],[4,1],[3,1],[4,3],[3,1],[4,14],[3,1],[4,1],[3,1],[4,2],[3,1],[4,2],[3,1],[4,1],[3,3],[4,1],[3,1],[4,1],[0,1],[3,1],[4,1],[3,1],[4,1],[3,3],[4,1],[3,4],[4,1],[3,2],[4,1],[3,1],[4,1],[3,1],[4,9],[3,1],[4,3],[3,2],[4,1],[3,1],[4,1],[29,1],[4,3],[3,3],[4,1],[3,3],[4,1],[3,3],[4,2],[3,1],[4,1],[3,1],[4,2],[3,1],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[4,4],[3,1],[4,1],[3,1],[4,2],[3,1],[4,1],[3,5],[4,1],[3,1],[4,4],[3,1],[4,1],[3,2],[4,1],[3,3],[4,1],[3,4],[4,3],[3,1],[4,3],[3,1],[4,4],[3,3],[4,3],[3,3],[4,3],[3,2],[4,3],[3,1],[4,1],[3,3],[4,2],[3,1],[4,2],[3,3],[4,1],[3,8],[0,1],[3,4],[4,1],[3,2],[4,3],[3,1],[4,3],[3,1],[4,2],[3,1],[4,1],[3,5],[4,4],[3,4],[4,3],[3,5],[4,1],[3,1],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[4,5],[3,1],[4,2],[3,2],[4,6],[0,1],[3,1],[4,2],[3,1],[4,1],[18,1],[3,1],[18,1],[3,2],[4,2],[3,2],[4,2],[3,11],[4,2],[3,4],[4,1],[3,1],[4,1],[3,4],[4,3],[3,2],[4,2],[3,2],[4,3],[3,3],[4,1],[3,2],[4,1],[3,3],[4,1],[3,4],[4,2],[3,2],[4,1],[3,1],[4,2],[3,6],[4,2],[3,2],[18,1],[3,1],[18,1],[3,1],[4,1],[3,1],[4,3],[3,2],[4,4],[3,1],[4,2],[3,2],[4,1],[3,4],[4,2],[3,3],[4,1],[3,5],[4,1],[3,1],[4,1],[3,2],[4,1],[3,1],[4,2],[3,1],[4,1],[3,1],[4,1],[3,2],[4,1],[3,1],[4,3],[3,1],[4,2],[3,1],[4,3],[3,2],[4,1],[3,3],[4,1],[3,3],[4,1],[3,1],[4,1],[3,2],[4,1],[3,1],[4,3],[3,6],[4,1],[3,3],[4,1],[3,1],[4,1],[3,2],[4,2],[3,1],[4,1],[3,1],[4,2],[3,1],[4,6],[3,1],[4,2],[3,1],[4,1],[3,1],[4,4],[3,4],[4,1],[3,6],[28,1],[3,2],[4,1],[3,1],[4,1],[3,1],[4,4],[3,4],[4,3],[3,1],[4,1],[3,1],[4,2],[3,10],[4,1],[3,11],[4,1],[3,1],[4,1],[3,1],[4,2],[3,2],[4,1],[3,1],[4,4],[3,2],[4,4],[3,1],[4,2],[3,3],[4,1],[3,1],[4,4],[3,1],[4,2],[3,1],[4,1],[3,1],[28,1],[4,2],[3,4],[4,1],[3,7],[4,1],[3,2],[4,1],[3,2],[4,1],[3,1],[4,1],[3,1],[4,1],[3,1],[4,1],[3,2],[4,2],[3,2],[4,1],[3,1],[29,1],[3,1],[4,1],[3,1],[4,2],[3,2],[4,1],[3,3],[4,1],[3,4],[4,2],[3,7],[4,2],[3,1],[4,1],[3,1],[4,4],[3,2],[4,2],[3,1],[4,2],[3,3],[4,1],[3,5],[4,3],[3,2],[4,1],[3,5],[4,1],[3,4],[4,1],[3,4],[4,2],[3,1],[4,2],[3,1],[4,2],[3,3],[4,2],[3,1],[29,1],[3,2],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[4,1],[3,1],[4,1],[3,1],[4,2],[3,4],[4,1],[3,7],[4,1],[3,7],[4,3],[3,4],[4,1],[3,2],[4,1],[3,5],[4,6],[3,1],[4,4],[3,2],[4,2],[3,3],[4,1],[3,8],[4,1],[3,6],[4,4],[3,3],[4,2],[3,2],[4,1],[3,1],[4,2],[3,2],[4,1],[3,1],[4,2],[3,4],[4,2],[3,3],[4,1],[3,1],[4,1],[3,5]]} \ No newline at end of file diff --git a/apps/game/assets/maps/shop.map b/apps/game/assets/maps/shop.map deleted file mode 100644 index dcb6e9b..0000000 --- a/apps/game/assets/maps/shop.map +++ /dev/null @@ -1 +0,0 @@ -{"format":"rpg-map","version":1,"width":11,"height":9,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"encoding":"rle","tiles":[[9,12],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,6],[8,1],[9,5]]} \ No newline at end of file diff --git a/apps/game/assets/maps/zvenets.map b/apps/game/assets/maps/zvenets.map deleted file mode 100644 index 6e37910..0000000 --- a/apps/game/assets/maps/zvenets.map +++ /dev/null @@ -1 +0,0 @@ -{"format":"rpg-map","version":1,"width":56,"height":40,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"props":[{"id":34,"x":46,"y":24,"w":2,"h":2,"ground":4}],"variants":{"0":[0,11,12],"1":[1,14],"3":[3,22,23],"4":[4,13],"7":[7,24,25,26,27],"28":[28,30,31],"29":[29,32,33]},"decor":[{"id":16,"x":14,"y":0,"dx":1,"dy":1},{"id":16,"x":23,"y":0,"dx":0,"dy":1},{"id":16,"x":26,"y":0,"dx":3,"dy":1},{"id":16,"x":31,"y":0,"dx":0,"dy":0},{"id":16,"x":37,"y":0,"dx":3,"dy":0},{"id":16,"x":39,"y":0,"dx":3,"dy":2},{"id":16,"x":49,"y":0,"dx":-1,"dy":1},{"id":16,"x":3,"y":1,"dx":1,"dy":0},{"id":16,"x":5,"y":1,"dx":4,"dy":1},{"id":15,"x":13,"y":1,"dx":-1,"dy":-2},{"id":16,"x":39,"y":1,"dx":4,"dy":1},{"id":17,"x":45,"y":1,"dx":0,"dy":0},{"id":16,"x":55,"y":2,"dx":3,"dy":-1},{"id":16,"x":21,"y":3,"dx":1,"dy":-1},{"id":16,"x":27,"y":3,"dx":-2,"dy":-2},{"id":16,"x":34,"y":3,"dx":1,"dy":-2},{"id":16,"x":36,"y":3,"dx":0,"dy":0},{"id":16,"x":3,"y":4,"dx":1,"dy":-1},{"id":16,"x":4,"y":4,"dx":3,"dy":2},{"id":16,"x":11,"y":4,"dx":2,"dy":0},{"id":16,"x":13,"y":4,"dx":2,"dy":1},{"id":16,"x":18,"y":4,"dx":1,"dy":0},{"id":16,"x":51,"y":4,"dx":1,"dy":-1},{"id":16,"x":5,"y":5,"dx":0,"dy":2},{"id":16,"x":19,"y":5,"dx":0,"dy":1},{"id":16,"x":31,"y":5,"dx":-3,"dy":-1},{"id":15,"x":5,"y":6,"dx":4,"dy":0},{"id":16,"x":13,"y":6,"dx":2,"dy":2},{"id":16,"x":18,"y":6,"dx":-3,"dy":-1},{"id":16,"x":24,"y":6,"dx":-2,"dy":2},{"id":16,"x":41,"y":6,"dx":1,"dy":0},{"id":16,"x":48,"y":6,"dx":-2,"dy":1},{"id":15,"x":6,"y":7,"dx":-1,"dy":-1},{"id":16,"x":13,"y":7,"dx":3,"dy":1},{"id":16,"x":20,"y":7,"dx":-4,"dy":2},{"id":16,"x":26,"y":8,"dx":3,"dy":-2},{"id":16,"x":47,"y":8,"dx":3,"dy":-2},{"id":15,"x":9,"y":9,"dx":0,"dy":2},{"id":16,"x":17,"y":10,"dx":3,"dy":2},{"id":16,"x":20,"y":10,"dx":-3,"dy":-2},{"id":16,"x":38,"y":10,"dx":-3,"dy":1},{"id":16,"x":45,"y":10,"dx":0,"dy":-2},{"id":16,"x":12,"y":11,"dx":2,"dy":-1},{"id":16,"x":17,"y":11,"dx":-3,"dy":1},{"id":16,"x":46,"y":11,"dx":0,"dy":1},{"id":15,"x":51,"y":11,"dx":2,"dy":0},{"id":16,"x":8,"y":12,"dx":1,"dy":-1},{"id":16,"x":14,"y":12,"dx":-4,"dy":1},{"id":16,"x":41,"y":12,"dx":3,"dy":2},{"id":16,"x":45,"y":12,"dx":3,"dy":0},{"id":16,"x":0,"y":13,"dx":1,"dy":-2},{"id":16,"x":4,"y":13,"dx":-3,"dy":1},{"id":16,"x":8,"y":13,"dx":3,"dy":-1},{"id":16,"x":42,"y":13,"dx":3,"dy":2},{"id":16,"x":44,"y":13,"dx":0,"dy":2},{"id":16,"x":50,"y":13,"dx":-1,"dy":2},{"id":16,"x":54,"y":13,"dx":-1,"dy":2},{"id":16,"x":49,"y":14,"dx":-3,"dy":2},{"id":16,"x":55,"y":14,"dx":-2,"dy":-2},{"id":16,"x":6,"y":15,"dx":0,"dy":1},{"id":16,"x":8,"y":15,"dx":0,"dy":-1},{"id":16,"x":10,"y":15,"dx":-4,"dy":1},{"id":16,"x":30,"y":15,"dx":-1,"dy":-1},{"id":16,"x":47,"y":15,"dx":3,"dy":-1},{"id":16,"x":51,"y":15,"dx":-1,"dy":-1},{"id":16,"x":6,"y":16,"dx":-1,"dy":2},{"id":16,"x":8,"y":16,"dx":3,"dy":2},{"id":16,"x":11,"y":16,"dx":1,"dy":1},{"id":16,"x":38,"y":16,"dx":2,"dy":1},{"id":16,"x":44,"y":16,"dx":0,"dy":1},{"id":16,"x":47,"y":16,"dx":1,"dy":0},{"id":16,"x":3,"y":17,"dx":2,"dy":1},{"id":16,"x":11,"y":17,"dx":3,"dy":-2},{"id":16,"x":1,"y":18,"dx":2,"dy":-2},{"id":16,"x":14,"y":18,"dx":-2,"dy":-1},{"id":16,"x":48,"y":18,"dx":0,"dy":-1},{"id":16,"x":4,"y":19,"dx":-1,"dy":0},{"id":16,"x":6,"y":19,"dx":3,"dy":-1},{"id":17,"x":9,"y":19,"dx":3,"dy":0},{"id":16,"x":14,"y":19,"dx":-1,"dy":2},{"id":16,"x":54,"y":19,"dx":-1,"dy":2},{"id":16,"x":1,"y":20,"dx":-4,"dy":2},{"id":16,"x":29,"y":20,"dx":1,"dy":1},{"id":16,"x":40,"y":20,"dx":-1,"dy":0},{"id":16,"x":4,"y":21,"dx":-1,"dy":1},{"id":16,"x":8,"y":21,"dx":-1,"dy":-2},{"id":17,"x":16,"y":21,"dx":-4,"dy":1},{"id":16,"x":17,"y":21,"dx":4,"dy":-2},{"id":16,"x":13,"y":22,"dx":2,"dy":-1},{"id":16,"x":52,"y":22,"dx":0,"dy":-2},{"id":16,"x":5,"y":23,"dx":0,"dy":-1},{"id":16,"x":46,"y":23,"dx":1,"dy":-1},{"id":16,"x":55,"y":23,"dx":-3,"dy":1},{"id":16,"x":5,"y":24,"dx":-2,"dy":-1},{"id":16,"x":17,"y":24,"dx":-1,"dy":1},{"id":16,"x":49,"y":24,"dx":4,"dy":-1},{"id":15,"x":3,"y":25,"dx":-2,"dy":-1},{"id":15,"x":10,"y":25,"dx":2,"dy":-2},{"id":16,"x":17,"y":25,"dx":-3,"dy":-2},{"id":15,"x":34,"y":25,"dx":-1,"dy":-1},{"id":16,"x":41,"y":25,"dx":-3,"dy":-1},{"id":16,"x":42,"y":25,"dx":-2,"dy":0},{"id":16,"x":3,"y":26,"dx":2,"dy":0},{"id":17,"x":7,"y":26,"dx":-3,"dy":-1},{"id":16,"x":13,"y":26,"dx":-2,"dy":0},{"id":16,"x":45,"y":26,"dx":-4,"dy":0},{"id":16,"x":51,"y":26,"dx":-2,"dy":0},{"id":16,"x":13,"y":27,"dx":-1,"dy":-1},{"id":16,"x":16,"y":27,"dx":-3,"dy":1},{"id":16,"x":46,"y":27,"dx":3,"dy":-2},{"id":16,"x":53,"y":27,"dx":3,"dy":2},{"id":16,"x":12,"y":28,"dx":2,"dy":1},{"id":17,"x":19,"y":28,"dx":-2,"dy":-2},{"id":16,"x":40,"y":28,"dx":0,"dy":-2},{"id":16,"x":41,"y":28,"dx":2,"dy":1},{"id":15,"x":2,"y":29,"dx":0,"dy":-2},{"id":16,"x":6,"y":29,"dx":-4,"dy":-1},{"id":15,"x":20,"y":29,"dx":-2,"dy":-1},{"id":16,"x":46,"y":29,"dx":0,"dy":1},{"id":16,"x":52,"y":29,"dx":-1,"dy":1},{"id":16,"x":53,"y":29,"dx":-4,"dy":0},{"id":16,"x":54,"y":29,"dx":-3,"dy":1},{"id":16,"x":0,"y":30,"dx":3,"dy":-2},{"id":16,"x":1,"y":30,"dx":2,"dy":-2},{"id":16,"x":25,"y":30,"dx":1,"dy":0},{"id":16,"x":34,"y":30,"dx":-2,"dy":1},{"id":16,"x":36,"y":30,"dx":-2,"dy":1},{"id":16,"x":37,"y":30,"dx":0,"dy":-2},{"id":16,"x":41,"y":30,"dx":4,"dy":-1},{"id":16,"x":48,"y":30,"dx":2,"dy":1},{"id":15,"x":20,"y":31,"dx":2,"dy":1},{"id":16,"x":29,"y":31,"dx":0,"dy":-1},{"id":16,"x":36,"y":31,"dx":-1,"dy":-2},{"id":16,"x":48,"y":31,"dx":1,"dy":1},{"id":16,"x":49,"y":31,"dx":3,"dy":-1},{"id":16,"x":32,"y":32,"dx":0,"dy":0},{"id":16,"x":37,"y":32,"dx":-2,"dy":2},{"id":16,"x":41,"y":32,"dx":-1,"dy":-2},{"id":16,"x":52,"y":32,"dx":-1,"dy":-2},{"id":16,"x":54,"y":32,"dx":3,"dy":1},{"id":16,"x":7,"y":33,"dx":1,"dy":1},{"id":16,"x":10,"y":33,"dx":-1,"dy":0},{"id":16,"x":48,"y":33,"dx":-2,"dy":1},{"id":15,"x":0,"y":34,"dx":4,"dy":-1},{"id":16,"x":12,"y":34,"dx":0,"dy":-1},{"id":16,"x":33,"y":34,"dx":2,"dy":-2},{"id":16,"x":38,"y":34,"dx":3,"dy":1},{"id":16,"x":53,"y":34,"dx":0,"dy":-1},{"id":15,"x":20,"y":35,"dx":-1,"dy":1},{"id":16,"x":35,"y":35,"dx":2,"dy":-2},{"id":16,"x":39,"y":35,"dx":-2,"dy":1},{"id":16,"x":47,"y":35,"dx":-3,"dy":-1},{"id":16,"x":55,"y":35,"dx":-2,"dy":-2},{"id":16,"x":11,"y":36,"dx":1,"dy":-1},{"id":16,"x":14,"y":36,"dx":-2,"dy":0},{"id":16,"x":26,"y":36,"dx":0,"dy":2},{"id":16,"x":39,"y":36,"dx":0,"dy":0},{"id":16,"x":42,"y":36,"dx":0,"dy":-2},{"id":16,"x":45,"y":36,"dx":2,"dy":-1},{"id":16,"x":4,"y":37,"dx":-3,"dy":1},{"id":16,"x":10,"y":37,"dx":3,"dy":1},{"id":16,"x":15,"y":37,"dx":-3,"dy":1},{"id":16,"x":34,"y":37,"dx":0,"dy":-2},{"id":16,"x":47,"y":37,"dx":1,"dy":1},{"id":16,"x":49,"y":37,"dx":-2,"dy":1},{"id":16,"x":53,"y":37,"dx":3,"dy":-1},{"id":16,"x":55,"y":37,"dx":-1,"dy":0},{"id":16,"x":6,"y":38,"dx":-3,"dy":2},{"id":16,"x":7,"y":38,"dx":0,"dy":2},{"id":16,"x":38,"y":38,"dx":-3,"dy":-2},{"id":16,"x":41,"y":38,"dx":-1,"dy":-1},{"id":16,"x":45,"y":38,"dx":2,"dy":0},{"id":16,"x":7,"y":39,"dx":2,"dy":1},{"id":16,"x":16,"y":39,"dx":2,"dy":0},{"id":16,"x":19,"y":39,"dx":-3,"dy":0},{"id":16,"x":22,"y":39,"dx":0,"dy":0},{"id":16,"x":26,"y":39,"dx":2,"dy":-1},{"id":16,"x":38,"y":39,"dx":-4,"dy":-1},{"id":16,"x":43,"y":39,"dx":-2,"dy":-2},{"id":16,"x":44,"y":39,"dx":-3,"dy":-2},{"id":16,"x":46,"y":39,"dx":1,"dy":0},{"id":16,"x":52,"y":39,"dx":-1,"dy":1}],"encoding":"rle","tiles":[[4,10],[0,4],[4,5],[0,1],[4,12],[0,2],[4,11],[0,3],[4,17],[0,1],[4,3],[0,1],[4,4],[0,2],[4,10],[0,4],[4,11],[0,3],[4,4],[3,2],[4,7],[3,1],[4,3],[0,1],[4,8],[0,3],[4,10],[0,1],[4,10],[0,2],[4,1],[0,3],[4,18],[0,4],[4,16],[0,1],[4,12],[0,2],[4,11],[3,1],[4,41],[0,4],[4,12],[3,1],[0,1],[4,2],[0,5],[4,33],[0,2],[4,9],[3,1],[0,1],[4,1],[0,7],[4,34],[0,2],[4,1],[0,1],[4,7],[3,1],[4,1],[0,1],[4,4],[3,1],[0,3],[4,1],[0,1],[4,8],[29,1],[4,34],[3,1],[4,1],[0,2],[4,3],[0,5],[4,39],[0,1],[4,2],[0,2],[4,2],[0,1],[4,1],[3,1],[4,1],[3,1],[0,6],[4,39],[0,4],[4,6],[3,1],[0,1],[4,1],[0,2],[4,3],[0,1],[4,19],[0,1],[4,4],[27,1],[4,11],[0,4],[3,1],[4,8],[28,1],[4,3],[0,2],[4,19],[0,3],[4,15],[0,5],[4,6],[3,1],[4,5],[0,1],[4,1],[0,1],[7,1],[4,1],[0,3],[1,7],[4,1],[1,13],[4,10],[0,5],[3,1],[4,16],[0,1],[10,1],[0,1],[1,11],[4,1],[1,5],[4,1],[1,4],[4,9],[0,1],[4,1],[0,1],[4,1],[0,1],[4,6],[3,2],[4,9],[0,2],[4,1],[1,1],[0,1],[1,8],[6,1],[1,11],[4,10],[0,1],[4,1],[0,1],[4,17],[0,3],[4,1],[1,5],[4,1],[1,6],[4,1],[1,8],[4,1],[0,2],[4,28],[0,2],[4,1],[0,1],[1,3],[4,1],[1,16],[4,1],[1,1],[4,1],[0,3],[4,8],[3,1],[4,1],[3,1],[4,16],[0,3],[1,3],[4,1],[1,18],[0,1],[4,1],[0,1],[4,8],[3,2],[4,5],[3,1],[4,5],[0,4],[4,3],[0,2],[4,1],[1,22],[4,1],[0,3],[4,15],[3,1],[4,1],[3,1],[4,2],[0,2],[4,8],[1,5],[4,1],[1,16],[0,3],[26,1],[4,14],[1,21],[4,1],[1,4],[0,1],[4,1],[1,10],[4,11],[3,1],[4,1],[1,2],[4,10],[0,2],[4,5],[0,1],[4,1],[1,22],[4,3],[29,1],[4,12],[0,2],[4,2],[3,1],[4,3],[0,1],[4,2],[0,1],[4,6],[0,1],[1,12],[4,1],[1,2],[4,2],[1,4],[4,11],[3,1],[4,4],[0,2],[4,4],[0,1],[4,2],[0,1],[4,8],[1,12],[4,1],[1,1],[4,1],[1,7],[4,8],[0,3],[4,1],[0,3],[4,1],[0,2],[3,1],[4,4],[0,2],[4,9],[1,8],[4,1],[1,9],[4,1],[1,3],[4,10],[0,1],[3,1],[4,1],[3,1],[0,2],[4,1],[0,1],[4,1],[0,2],[3,1],[0,1],[4,3],[0,1],[4,7],[1,4],[4,1],[1,9],[0,1],[1,1],[0,1],[1,5],[4,8],[0,1],[4,1],[0,1],[3,2],[4,1],[0,1],[3,1],[4,1],[0,1],[3,1],[4,1],[3,1],[4,1],[0,2],[4,1],[0,2],[4,7],[1,16],[4,1],[1,4],[4,9],[0,2],[4,2],[0,2],[4,2],[0,1],[4,1],[0,2],[4,2],[0,1],[4,1],[0,2],[4,8],[0,1],[1,21],[4,9],[0,2],[4,1],[0,1],[4,1],[0,1],[3,1],[0,1],[4,1],[3,1],[0,2],[4,14],[0,4],[24,1],[4,1],[0,1],[4,5],[0,2],[25,1],[4,18],[3,1],[4,3],[0,4],[4,13],[0,1],[4,1],[0,5],[4,7],[0,3],[4,26],[3,1],[4,12],[0,7],[4,7],[0,1],[4,20],[3,1],[4,2],[3,1],[4,2],[3,1],[4,15],[0,1],[4,1],[0,4],[4,2],[0,1],[4,34],[3,1],[4,11],[0,10],[4,24],[3,1],[4,8],[3,1],[4,12],[0,2],[4,2],[0,2],[4,1],[0,1],[4,1],[0,1],[4,11],[29,1],[4,16],[3,1],[0,2],[4,7],[29,1],[4,11],[0,2],[4,1],[0,1],[4,1],[0,1],[4,27],[3,1],[4,1],[0,3],[4,2],[3,1],[4,14],[0,2],[4,1],[0,4],[4,29],[0,3],[4,19],[0,1],[4,34],[3,1],[4,50],[0,1],[4,6],[3,1],[4,9],[28,1],[4,19],[0,2],[4,15],[0,1],[4,1],[0,1],[3,2],[4,4],[3,1],[4,28],[0,3],[4,21]]} \ No newline at end of file diff --git a/apps/game/assets/tiles/ash.png b/apps/game/assets/tiles/ash.png deleted file mode 100644 index 64299da..0000000 --- a/apps/game/assets/tiles/ash.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/ash_2.png b/apps/game/assets/tiles/ash_2.png deleted file mode 100644 index 9108d44..0000000 --- a/apps/game/assets/tiles/ash_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/bell_rope.png b/apps/game/assets/tiles/bell_rope.png deleted file mode 100644 index 3583885..0000000 --- a/apps/game/assets/tiles/bell_rope.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/bellflower.png b/apps/game/assets/tiles/bellflower.png deleted file mode 100644 index 212022c..0000000 --- a/apps/game/assets/tiles/bellflower.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/boulder.png b/apps/game/assets/tiles/boulder.png deleted file mode 100644 index 21b65b1..0000000 --- a/apps/game/assets/tiles/boulder.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/boulder_2.png b/apps/game/assets/tiles/boulder_2.png deleted file mode 100644 index 3625344..0000000 --- a/apps/game/assets/tiles/boulder_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/boulder_3.png b/apps/game/assets/tiles/boulder_3.png deleted file mode 100644 index 8219d43..0000000 --- a/apps/game/assets/tiles/boulder_3.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/bush.png b/apps/game/assets/tiles/bush.png deleted file mode 100644 index 3c7eba4..0000000 --- a/apps/game/assets/tiles/bush.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/bush_2.png b/apps/game/assets/tiles/bush_2.png deleted file mode 100644 index 0c6b220..0000000 --- a/apps/game/assets/tiles/bush_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/bush_3.png b/apps/game/assets/tiles/bush_3.png deleted file mode 100644 index 69a3695..0000000 --- a/apps/game/assets/tiles/bush_3.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/chest.png b/apps/game/assets/tiles/chest.png deleted file mode 100644 index 163b9f9..0000000 --- a/apps/game/assets/tiles/chest.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/counter.png b/apps/game/assets/tiles/counter.png deleted file mode 100644 index 6d85c49..0000000 --- a/apps/game/assets/tiles/counter.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/decor_bald.png b/apps/game/assets/tiles/decor_bald.png deleted file mode 100644 index 7fbb154..0000000 --- a/apps/game/assets/tiles/decor_bald.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/decor_stone.png b/apps/game/assets/tiles/decor_stone.png deleted file mode 100644 index 20fc4c5..0000000 --- a/apps/game/assets/tiles/decor_stone.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/decor_tuft.png b/apps/game/assets/tiles/decor_tuft.png deleted file mode 100644 index 8e17f52..0000000 --- a/apps/game/assets/tiles/decor_tuft.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/floor.png b/apps/game/assets/tiles/floor.png deleted file mode 100644 index 6a1de4b..0000000 --- a/apps/game/assets/tiles/floor.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/grass.png b/apps/game/assets/tiles/grass.png deleted file mode 100644 index 8ead393..0000000 --- a/apps/game/assets/tiles/grass.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/grass_2.png b/apps/game/assets/tiles/grass_2.png deleted file mode 100644 index 2999cf4..0000000 --- a/apps/game/assets/tiles/grass_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/grass_3.png b/apps/game/assets/tiles/grass_3.png deleted file mode 100644 index 0ade55d..0000000 --- a/apps/game/assets/tiles/grass_3.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/hearth.png b/apps/game/assets/tiles/hearth.png deleted file mode 100644 index 1850270..0000000 --- a/apps/game/assets/tiles/hearth.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/house.png b/apps/game/assets/tiles/house.png deleted file mode 100644 index d5820b1..0000000 --- a/apps/game/assets/tiles/house.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/house_2.png b/apps/game/assets/tiles/house_2.png deleted file mode 100644 index 1529b35..0000000 --- a/apps/game/assets/tiles/house_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/house_3.png b/apps/game/assets/tiles/house_3.png deleted file mode 100644 index f6d5f29..0000000 --- a/apps/game/assets/tiles/house_3.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/house_4.png b/apps/game/assets/tiles/house_4.png deleted file mode 100644 index c10460a..0000000 --- a/apps/game/assets/tiles/house_4.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/house_5.png b/apps/game/assets/tiles/house_5.png deleted file mode 100644 index 8f12ce9..0000000 --- a/apps/game/assets/tiles/house_5.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/house_big.png b/apps/game/assets/tiles/house_big.png deleted file mode 100644 index 29c4be7..0000000 --- a/apps/game/assets/tiles/house_big.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/machine.png b/apps/game/assets/tiles/machine.png deleted file mode 100644 index 000d932..0000000 --- a/apps/game/assets/tiles/machine.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/mote.png b/apps/game/assets/tiles/mote.png deleted file mode 100644 index 3f7d3cf..0000000 --- a/apps/game/assets/tiles/mote.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/note.png b/apps/game/assets/tiles/note.png deleted file mode 100644 index ea6ba26..0000000 --- a/apps/game/assets/tiles/note.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/path.png b/apps/game/assets/tiles/path.png deleted file mode 100644 index 7ab718c..0000000 --- a/apps/game/assets/tiles/path.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/path_2.png b/apps/game/assets/tiles/path_2.png deleted file mode 100644 index 2283872..0000000 --- a/apps/game/assets/tiles/path_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/rail.png b/apps/game/assets/tiles/rail.png deleted file mode 100644 index 4c4f8ad..0000000 --- a/apps/game/assets/tiles/rail.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/resonator.png b/apps/game/assets/tiles/resonator.png deleted file mode 100644 index ff132bf..0000000 --- a/apps/game/assets/tiles/resonator.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/signpost.png b/apps/game/assets/tiles/signpost.png deleted file mode 100644 index c6f20d8..0000000 --- a/apps/game/assets/tiles/signpost.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/spit.png b/apps/game/assets/tiles/spit.png deleted file mode 100644 index c571f94..0000000 --- a/apps/game/assets/tiles/spit.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/stairs.png b/apps/game/assets/tiles/stairs.png deleted file mode 100644 index 96bdcea..0000000 --- a/apps/game/assets/tiles/stairs.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/tone_tree.png b/apps/game/assets/tiles/tone_tree.png deleted file mode 100644 index 50006e9..0000000 --- a/apps/game/assets/tiles/tone_tree.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/tower.png b/apps/game/assets/tiles/tower.png deleted file mode 100644 index a7564da..0000000 --- a/apps/game/assets/tiles/tower.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/tree.png b/apps/game/assets/tiles/tree.png deleted file mode 100644 index 99bd669..0000000 --- a/apps/game/assets/tiles/tree.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/tree_2.png b/apps/game/assets/tiles/tree_2.png deleted file mode 100644 index 5ba00cd..0000000 --- a/apps/game/assets/tiles/tree_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/tree_2_sway.png b/apps/game/assets/tiles/tree_2_sway.png deleted file mode 100644 index b437dec..0000000 --- a/apps/game/assets/tiles/tree_2_sway.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/tree_3.png b/apps/game/assets/tiles/tree_3.png deleted file mode 100644 index 7955876..0000000 --- a/apps/game/assets/tiles/tree_3.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/tree_3_sway.png b/apps/game/assets/tiles/tree_3_sway.png deleted file mode 100644 index a408056..0000000 --- a/apps/game/assets/tiles/tree_3_sway.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/tree_sway.png b/apps/game/assets/tiles/tree_sway.png deleted file mode 100644 index 1d83975..0000000 --- a/apps/game/assets/tiles/tree_sway.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/wall.png b/apps/game/assets/tiles/wall.png deleted file mode 100644 index 9f52e1c..0000000 --- a/apps/game/assets/tiles/wall.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/water_1.png b/apps/game/assets/tiles/water_1.png deleted file mode 100644 index 8fe3ff8..0000000 --- a/apps/game/assets/tiles/water_1.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/water_2.png b/apps/game/assets/tiles/water_2.png deleted file mode 100644 index e4f9879..0000000 --- a/apps/game/assets/tiles/water_2.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/well.png b/apps/game/assets/tiles/well.png deleted file mode 100644 index 3e84997..0000000 --- a/apps/game/assets/tiles/well.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/tiles/wire.png b/apps/game/assets/tiles/wire.png deleted file mode 100644 index f193cbd..0000000 --- a/apps/game/assets/tiles/wire.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/ui/icon_bellflower.png b/apps/game/assets/ui/icon_bellflower.png deleted file mode 100644 index cdad729..0000000 --- a/apps/game/assets/ui/icon_bellflower.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/ui/icon_clock.png b/apps/game/assets/ui/icon_clock.png deleted file mode 100644 index df5fca8..0000000 --- a/apps/game/assets/ui/icon_clock.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/ui/icon_cloth.png b/apps/game/assets/ui/icon_cloth.png deleted file mode 100644 index c2089d8..0000000 --- a/apps/game/assets/ui/icon_cloth.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/ui/icon_flask.png b/apps/game/assets/ui/icon_flask.png deleted file mode 100644 index 161bc59..0000000 --- a/apps/game/assets/ui/icon_flask.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/ui/icon_map.png b/apps/game/assets/ui/icon_map.png deleted file mode 100644 index e1e5f23..0000000 --- a/apps/game/assets/ui/icon_map.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/ui/icon_mote.png b/apps/game/assets/ui/icon_mote.png deleted file mode 100644 index 99aba24..0000000 --- a/apps/game/assets/ui/icon_mote.png +++ /dev/null Binary files differ diff --git a/apps/game/assets/ui/icon_salt.png b/apps/game/assets/ui/icon_salt.png deleted file mode 100644 index 83bb73e..0000000 --- a/apps/game/assets/ui/icon_salt.png +++ /dev/null Binary files differ diff --git a/apps/game/index.html b/apps/game/index.html deleted file mode 100644 index db2fd6c..0000000 --- a/apps/game/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - Пепельные луга - - - -
- - - \ No newline at end of file diff --git a/apps/game/package.json b/apps/game/package.json deleted file mode 100644 index 43c6a00..0000000 --- a/apps/game/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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": { - "puppeteer-core": "^25.10.0", - "vite": "^6.0.0" - } -} diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts deleted file mode 100644 index 6c3d598..0000000 --- a/apps/game/src/Game.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { - Engine, - SaveManager, - AssetLoader, - AudioManager, - GameState, - Settings, - Inventory, - parseMap, - RATE, - renderMusicStereo, - type MusicHandle, - type Renderer, - type SceneManager, - type TileMapData -} from '@rpg/engine'; -import { AUDIO_KEYS } from './data/audio'; -import { THEMES } from './data/music'; -import { GameClock } from './systems/Clock'; -import { registerStateMigrations } from './data/migrations'; -// Шрифт идёт с движком (OFL): Vite кладёт файл в сборку и даёт готовый URL. -import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url'; - -/** - * Игровой контекст: движок, сохранения, загрузчик ассетов, состояние, настройки. - * Движок ничего не знает об этом классе — он только потребляет его API. - */ -export class Game { - /** Виртуальное разрешение (пиксель-арт). */ - static readonly VIRTUAL_W = 480; - static readonly VIRTUAL_H = 270; - - readonly saves: SaveManager; - readonly assets: AssetLoader; - /** Звуки и музыка (WAV из apps/game/tools/audio/gen.mjs). */ - readonly audio = new AudioManager((key) => `${import.meta.env.BASE_URL}audio/${key}.wav`); - /** Флаги и переменные прохождения. */ - readonly state = new GameState(); - /** Сумка героя (предметы квестов и торговли). */ - readonly inventory = new Inventory(); - /** Время суток (непрерывный цикл; 1 с реального = 1 игровая минута). */ - readonly clock = new GameClock(); - /** Настройки игрока (громкости и т.п.) — вне сейвов. */ - readonly settings = new Settings(window.localStorage); - - /** - * Активная локация (для автосейва при закрытии/скрытии вкладки и для - * оверлея карты: область + тайл героя). null — вне локации; сумка/оверлеи - * поверх не мешают: локация зарегистрирована. - */ - activeLocation: { - autosave(): void; - /** id области текущей локации (карта из Game.mapFiles). */ - readonly areaId: string; - /** Текущий тайл героя (мигающий маркер на карте). */ - heroTile(): { x: number; y: number }; - } | null = null; - - /** Текстуры тайлов и одиночные спрайты NPC. */ - static readonly ASSET_KEYS = [ - 'tiles/grass', - 'tiles/grass_2', - 'tiles/grass_3', - 'tiles/path', - 'tiles/path_2', - 'tiles/water_1', - 'tiles/water_2', - 'tiles/ash', - 'tiles/ash_2', - 'tiles/decor_tuft', - 'tiles/decor_stone', - 'tiles/decor_bald', - 'tiles/bellflower', - 'tiles/tree', - 'tiles/tree_2', - 'tiles/tree_3', - 'tiles/tree_sway', - 'tiles/tree_2_sway', - 'tiles/tree_3_sway', - 'tiles/house', - 'tiles/house_2', - 'tiles/house_3', - 'tiles/house_4', - 'tiles/house_5', - 'tiles/house_big', - 'tiles/boulder', - 'tiles/boulder_2', - 'tiles/boulder_3', - 'tiles/bush', - 'tiles/bush_2', - 'tiles/bush_3', - 'tiles/tower', - 'tiles/floor', - 'tiles/wall', - 'tiles/well', - 'tiles/chest', - 'tiles/wire', - 'tiles/rail', - 'tiles/stairs', - 'tiles/machine', - // AI-арт B4 (задача 5): интерактивы и снаряд плевка. - 'tiles/signpost', - 'tiles/mote', - 'tiles/note', - 'tiles/spit', - 'tiles/bell_rope', - 'tiles/counter', - 'tiles/resonator', - 'tiles/tone_tree', - 'tiles/hearth', - // Иконки предметов сумки 8×8 (арт-библия §4). - 'ui/icon_clock', - 'ui/icon_cloth', - 'ui/icon_bellflower', - 'ui/icon_salt', - 'ui/icon_flask', - 'ui/icon_mote', - 'ui/icon_map', - 'chars/hero_lamp' - ]; - - /** Ключи аудио (WAV): sfx + амбиент-лупы областей (реестр в data/audio.ts). */ - static readonly AUDIO_KEYS = AUDIO_KEYS; - - constructor(readonly engine: Engine) { - this.saves = new SaveManager(window.localStorage); - // Миграции формата state (старые сейвы поднимаются при load). - registerStateMigrations(this.state); - // Ключи без расширения -> .png; ключи .json (атласы) резолвятся как есть. - this.assets = new AssetLoader((key) => - key.endsWith('.json') ? `${import.meta.env.BASE_URL}${key}` : `${import.meta.env.BASE_URL}${key}.png` - ); - this.settings.load(); - // Настройки громкости -> шины аудио (до unlock шин нет — применится после). - this.settings.onChange((s) => { - this.audio.setBusVolume('master', s.master); - this.audio.setBusVolume('ambience', s.ambience); - this.audio.setBusVolume('sfx', s.sfx); - }); - // Применить текущие значения сразу. - this.audio.setBusVolume('master', this.settings.data.master); - this.audio.setBusVolume('ambience', this.settings.data.ambience); - this.audio.setBusVolume('sfx', this.settings.data.sfx); - // Хук для смоук-тестов (apps/game/tools/smoke*.mjs): доступ к контексту из страницы. - if (import.meta.env.DEV) { - (window as unknown as { __game?: Game }).__game = this; - // Шпион аудио: последние 24 запуска (sfx + лупы) — для агентных - // проверок звука (на слух в headless не проверить, tools/checks/audio.mjs). - const audioLog: { key: string; t: number; volume: number; pan: number }[] = []; - this.audio.onPlayed = (key, opts) => { - audioLog.push({ key, t: Math.round(performance.now()), volume: opts.volume ?? 1, pan: opts.pan ?? 0 }); - if (audioLog.length > 24) audioLog.shift(); - }; - (window as unknown as { __gameAudioLog?: typeof audioLog }).__gameAudioLog = audioLog; - // Агентный мост: window.__agent (см. docs/engine/agent.md, apps/game/tools/agent.mjs). - import('./agent/GameAgent').then(({ registerAgent }) => registerAgent(this)); - } - } - - /** URL пиксельного шрифта (VT323 идёт вместе с движком, OFL). */ - static readonly FONT_URL: string = fontUrl; - - /** Карты локаций, распарсенные из assets/maps/.map (формат rpg-map). */ - readonly mapFiles = new Map(); - - /** Загрузить и распарсить карту локации (вызывается в BootScene). */ - async loadMap(id: string): Promise { - const res = await fetch(`${import.meta.env.BASE_URL}maps/${id}.map`); - if (!res.ok) throw new Error(`Карта ${id}: HTTP ${res.status}`); - const data = parseMap(await res.json()); - this.mapFiles.set(id, data); - return data; - } - - get scenes(): SceneManager { - return this.engine.scenes; - } - - get renderer(): Renderer { - return this.engine.renderer; - } - - get camera() { - return this.engine.camera; - } - - get worldRoot() { - return this.engine.renderer.worldRoot; - } - - /** Амбиент области с кроссфейдом; повторный вызов с тем же ключом не перезапускает его. */ - playAmbience(key: string, volume = 0.7): void { - if (this.ambienceKey === key) return; - this.ambienceKey = key; - if (key === '') { - this.audio.stopAmbience(1.5); - return; - } - void this.audio.playAmbience(key, { fade: 1.5, volume }); - } - - /** - * Зарегистрировать и декодировать аудио в фоне (ленивый префетч амбиента - * области при входе; playAmbience/playLoop и сами умеют грузить на месте — - * это только чтобы первый вход в область не тормозил). - */ - preloadAudio(keys: readonly string[]): void { - for (const key of keys) { - this.audio.register(key); - void this.audio.preload(key); - } - } - - /** Текущий амбиент (защита от перезапуска того же ключа; '' — тишина). */ - private ambienceKey: string | null = null; - - /** - * Тема области (спек-музыка из data/music.ts, компилируется в рантайме): - * кроссфейд при смене, повторный вызов с тем же ключом не перезапускает, - * '' — тишина. Буфер темы кэшируется — компиляция один раз на ключ. - */ - playTheme(key: string): void { - if (this.themeKey === key) return; - this.themeKey = key; - this.themeHandle?.stop(2); - this.themeHandle = null; - const spec = THEMES[key]; - if (!spec) return; - let buf = this.themeBuffers.get(key); - if (!buf) { - // Тема компилируется синхронно, вне async-цепочки play* — контекст - // заводим на месте (prepare), иначе до первого жеста будет тишина. - this.audio.prepare(); - const created = this.audio.createBuffer(renderMusicStereo(spec), RATE, 2); - if (!created) { - this.themeKey = null; // до unlock — компиляция при следующем входе - return; - } - buf = created; - this.themeBuffers.set(key, buf); - } - void this.audio.playLoopBuffer(`music/${key}`, buf, { fade: 2, volume: 0.5 }).then((handle) => { - // Тема сменилась, пока луп заводился, — гасим без хвостов. - if (this.themeKey !== key) { - handle?.stop(1); - return; - } - this.themeHandle = handle; - }); - } - - private themeKey: string | null = null; - private themeHandle: MusicHandle | null = null; - private themeBuffers = new Map(); -} \ No newline at end of file diff --git a/apps/game/src/agent/GameAgent.ts b/apps/game/src/agent/GameAgent.ts deleted file mode 100644 index f0662e5..0000000 --- a/apps/game/src/agent/GameAgent.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { - EngineAgent, - dayNightFactor, - type AgentHost, - type Invariant, - type JsonValue, - type SceneAgent, - type SnapshotLayer -} from '@rpg/engine'; -import type { Game } from '../Game'; -import { - AGENT_SNAPSHOT_KEYS, - gameLayer, - type DialogueSnapshot, - type GameSnapshot, - type InteractableSnapshot -} from './snapshot'; -import { validateContent } from '../data/validate'; -import { usedFlag } from '../data/ids'; - -/** - * Агентный мост игры: поверх движкового каркаса (EngineAgent) добавляет - * контентные слои (флаги/вары/инвентарь/сцена) и помощники высокого уровня - * (ходьба по маршруту, диалоги, «новая игра»). Регистрируется как - * `window.__agent` (только DEV) — инструменты в apps/game/tools/ ходят через него. - */ - -export interface AgentApi { - version: 1; - snapshot(): GameSnapshot; - invariants(): Invariant[]; - /** n фиксированных шагов (детерминированно, без реального ожидания). */ - step(n?: number, opts?: { render?: boolean }): { tick: number }; - /** pred — строка-выражение над снапшотом (из браузера функцию не передать). */ - waitFor( - pred: string, - opts?: { timeoutTicks?: number; render?: boolean } - ): Promise; - /** Клик в виртуальных пикселях (480×270). */ - tapVirtual(vx: number, vy: number): void; - /** Реальный DOM-клик (Pixi-кнопки меню). */ - uiTap(vx: number, vy: number): void; - /** Действие по маппингу (advance/attack/...). */ - press(action: string, holdTicks?: number): void; - /** Сырой код клавиши (e.code). */ - key(code: string): void; - /** Whitelist-команда сцены (scene:sleepAll, scene:teleport, ...). */ - command(name: string, args?: JsonValue): JsonValue; - /** Пойти в тайл по маршруту A* (в сцене). false — путь не найден/не дошёл. */ - walkTo(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise; - /** Подойти к тайлу (цель может быть непроходима). null — соседей нет/не дошёл. */ - approach(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise<{ x: number; y: number } | null>; - /** Дождаться и долистать активный диалог. false — диалог не открылся. */ - runDialogue(timeoutTicks?: number): Promise; - /** Дождаться выбора в диалоге и выбрать вариант по тексту. false — не дождались/нет варианта. */ - pickChoiceByText(text: string, timeoutTicks?: number): Promise; - /** «Новая игра» из меню. */ - newGame(): Promise; - /** id текущей локации (null — сцена не локация). */ - currentArea(): string | null; - /** Масштаб и позиция worldRoot (проверки зума). */ - worldRootView(): { scale: number; position: { x: number; y: number } }; -} - -/** Действие «листать диалог» (маппится и на Space, и на Enter). */ -const ADVANCE = 'advance'; - -/** Итог waitFor: error заполняется только при отклонённом предикате. */ -export interface WaitResult { - ok: boolean; - snapshot: GameSnapshot; - ticks: number; - /** Предикат ссылался на неизвестный ключ (опечатка) — исполнение не начато. */ - error?: string; -} - -/** Top-level обращения `s.<ключ>` в строке-предикате (вложенные пути не трогаем). */ -export function predKeys(pred: string): string[] { - const re = /(?:^|[^.\w$])s\.([A-Za-z_$][\w$]*)/g; - const keys = new Set(); - let m: RegExpExecArray | null; - while ((m = re.exec(pred)) !== null) keys.add(m[1]!); - return [...keys]; -} - -export class GameAgent { - private engineAgent: EngineAgent; - - constructor(private game: Game) { - this.engineAgent = new EngineAgent( - game.engine as unknown as AgentHost, - () => (game.scenes.current as unknown as SceneAgent | undefined) ?? null - ); - } - - /** Масштаб и позиция worldRoot (проверки зума из tools/checks/zoom.mjs). */ - worldRootView(): { scale: number; position: { x: number; y: number } } { - const root = this.game.worldRoot; - return { scale: root.scale.x, position: { x: root.position.x, y: root.position.y } }; - } - - snapshot(): GameSnapshot { - const s = this.engineAgent.snapshot() as GameSnapshot; - // Слой игры поверх движкового и сценического. - Object.assign(s, gameLayer({ - flags: this.game.state.allFlags, - vars: { ...this.game.state.serialize().vars }, - inventory: this.game.inventory.all, - time: { - minutes: this.game.clock.minutes, - label: this.game.clock.label, - night: dayNightFactor(this.game.clock.hours) - } - })); - return s; - } - - invariants(): Invariant[] { - // Контентная валидация живёт здесь (не в сцене), чтобы работать и в меню. - const list: Invariant[] = [...this.engineAgent.invariants(), ...validateContent(this.game.mapFiles)]; - // used-флаги и снапшот идут разными путями — сверяем их на месте. - const s = this.snapshot(); - const flags = new Set(s.flags ?? []); - for (const o of (s.interactables ?? []) as InteractableSnapshot[]) { - if (flags.has(usedFlag(o.id)) !== o.used) { - list.push({ - id: 'interact-used-consistent', - severity: 'error', - message: `объект ${o.id}: флаг used=${flags.has(usedFlag(o.id))}, снапшот used=${o.used}`, - where: 'agent/GameAgent' - }); - } - } - return list; - } - - step(n = 1, opts?: { render?: boolean }): { tick: number } { - return this.engineAgent.step(n, opts); - } - - async waitFor( - pred: string, - opts?: { timeoutTicks?: number; render?: boolean } - ): Promise { - // Опечатка в ключе предиката — тихий false на весь таймаут. Отклоняем - // сразу: unknown key -> { ok: false, error }, шаги не тратятся. - const unknown = predKeys(pred).filter((k) => !AGENT_SNAPSHOT_KEYS.includes(k)); - if (unknown.length > 0) { - return { - ok: false, - snapshot: this.snapshot(), - ticks: 0, - error: `неизвестный ключ s.${unknown[0]} в предикате (см. AGENT_SNAPSHOT_KEYS)` - }; - } - // Строка-выражение приходит из apps/game/tools/ (доверенная среда репозитория). - const fn = new Function('s', `"use strict"; return (${pred});`) as (s: SnapshotLayer) => boolean; - const limit = opts?.timeoutTicks ?? 600; - const render = opts?.render ?? false; - // Ждём по ПОЛНОМУ снапшоту игры (flags/vars/inventory поверх сцены): - // engineAgent.waitFor видит только движковый + сценический слои. - for (let i = 0; i < limit; i++) { - this.step(1, { render }); - const s = this.snapshot(); - let ok = false; - try { - ok = fn(s); - } catch { - // предикат упал — трактуем как «не совпало» - } - if (ok) return { ok: true, snapshot: s, ticks: i + 1 }; - } - return { ok: false, snapshot: this.snapshot(), ticks: limit }; - } - - tapVirtual(vx: number, vy: number): void { - this.engineAgent.tapVirtual(vx, vy); - } - - uiTap(vx: number, vy: number): void { - this.engineAgent.uiTap(vx, vy); - } - - press(action: string, holdTicks = 1): void { - this.engineAgent.press(action, holdTicks); - } - - key(code: string): void { - this.engineAgent.key(code); - } - - command(name: string, args?: JsonValue): JsonValue { - return this.engineAgent.command(name, args) as JsonValue; - } - - /** - * Идти в тайл: сцена строит A* и ведёт героя сама (scene:walk), мост ждёт - * тайла цели. Без `!moving`: step-переход срабатывает, как только округлённый - * тайл героя становится целевым (сцена замирает ещё в moving), — окна «на - * тайле и стоит» может не быть. false — пути нет. - */ - async walkTo(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise { - const started = this.command('scene:walk', { x: tx, y: ty }); - if (started !== true) return false; - const wait = await this.waitFor( - `s.hero && s.hero.tile && s.hero.tile.x === ${tx} && s.hero.tile.y === ${ty}`, - { timeoutTicks: opts?.timeoutTicks ?? 600 } - ); - return wait.ok; - } - - /** - * Подойти к тайлу (цель может быть непроходима: стены, объекты, NPC-тайлы, - * тела врагов). Сцена ведёт к ближайшему проходимому соседу (scene:approach). - * Возврат — тайл прибытия или null (соседей нет / не дошёл). - */ - async approach( - tx: number, - ty: number, - opts?: { timeoutTicks?: number } - ): Promise<{ x: number; y: number } | null> { - const dest = this.command('scene:approach', { x: tx, y: ty }); - const d = dest as { x?: unknown; y?: unknown } | null; - if (!d || typeof d.x !== 'number' || typeof d.y !== 'number') return null; - const wait = await this.waitFor( - `s.hero && s.hero.tile && s.hero.tile.x === ${d.x} && s.hero.tile.y === ${d.y} && !s.hero.moving`, - { timeoutTicks: opts?.timeoutTicks ?? 600 } - ); - return wait.ok ? { x: d.x, y: d.y } : null; - } - - /** Долистать активный диалог: жать advance, пока он открыт. */ - async runDialogue(timeoutTicks = 600): Promise { - const opened = await this.waitFor('s.dialogue != null', { timeoutTicks }); - if (!opened.ok) return false; - for (let i = 0; i < 50; i++) { - const d = this.snapshot().dialogue as DialogueSnapshot | null; - if (!d) return true; // диалог закрылся — завершён - if (d.waitingForChoice) { - this.command('scene:pickChoice', { index: 0 }); - } else { - this.press(ADVANCE); - } - this.step(2); - } - return !this.snapshot().dialogue; - } - - /** - * Дождаться выбора в диалоге и выбрать вариант по тексту (через - * scene:pickChoiceByText — порядок вариантов сценарию знать не надо). - */ - async pickChoiceByText(text: string, timeoutTicks = 600): Promise { - const opened = await this.waitFor('s.dialogue != null && s.dialogue.waitingForChoice', { - timeoutTicks - }); - if (!opened.ok) return false; - const picked = this.command('scene:pickChoiceByText', { text }); - this.step(2); - return picked === true; - } - - /** «Новая игра» из меню (команда сцены — без координат кнопок). */ - async newGame(): Promise { - // begin() молча отбрасывает команды во время fade-перехода — ждём его конца. - await this.waitFor('!s.transitioning', { timeoutTicks: 1200 }); - this.command('menu:newGame'); - await this.waitFor('s.scene === "location"', { timeoutTicks: 1200 }); - } - - currentArea(): string | null { - const s = this.snapshot(); - return typeof s.area === 'string' ? s.area : null; - } -} - -/** Зарегистрировать window.__agent (только DEV). */ -export function registerAgent(game: Game): AgentApi { - const agent = new GameAgent(game); - const api: AgentApi = { - version: 1, - snapshot: () => agent.snapshot(), - invariants: () => agent.invariants(), - step: (n, opts) => agent.step(n, opts), - waitFor: (pred, opts) => agent.waitFor(pred, opts), - tapVirtual: (vx, vy) => agent.tapVirtual(vx, vy), - uiTap: (vx, vy) => agent.uiTap(vx, vy), - press: (action, holdTicks) => agent.press(action, holdTicks), - key: (code) => agent.key(code), - command: (name, args) => agent.command(name, args), - walkTo: (tx, ty, opts) => agent.walkTo(tx, ty, opts), - approach: (tx, ty, opts) => agent.approach(tx, ty, opts), - runDialogue: (timeoutTicks) => agent.runDialogue(timeoutTicks), - pickChoiceByText: (text, timeoutTicks) => agent.pickChoiceByText(text, timeoutTicks), - newGame: () => agent.newGame(), - currentArea: () => agent.currentArea(), - worldRootView: () => agent.worldRootView() - }; - (window as unknown as { __agent?: AgentApi }).__agent = api; - return api; -} \ No newline at end of file diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts deleted file mode 100644 index 25e70eb..0000000 --- a/apps/game/src/agent/SceneAgentView.ts +++ /dev/null @@ -1,467 +0,0 @@ -import { - dayNightFactor, - unitsToPx, - findPath, - findPathToNeighbor, - checkFinite, - checkRange, - checkWalkable, - mergeInvariants, - tileToWorld, - type Invariant, - type Grid, - type IsometricTileMap, - type JsonValue, - type SceneAgent, - type SceneRegistry, - type SnapshotLayer, - type PostFx, - type ShimmerState, - type Camera, - type SoundKind, - type SoundSpec, - type Vec2 -} from '@rpg/engine'; -import type { CutsceneRunner } from '@rpg/engine'; -import type { Game } from '../Game'; -import type { AreaDef, HazardDef } from '../data/locations'; -import type { NpcDef } from '../data/npcs'; -import { PLAYER_COMBAT } from '../systems/combat/stats'; -import type { CombatWorld } from '../systems/combat/CombatWorld'; -import type { PlayerCombat } from '../systems/combat/PlayerCombat'; -import type { PlayerController } from '../systems/PlayerController'; -import type { Interactables } from '../systems/Interactables'; -import type { DialogueSystem } from '../systems/DialogueSystem'; -import type { - HeroSnapshot, - EnemySnapshot, - NpcSnapshot, - TransitionSnapshot, - LocationSnapshot, - InteractableSnapshot -} from './snapshot'; -import { heroLayer, enemiesLayer, npcsLayer, dialogueLayer, collisionLayer, lightingLayer } from './snapshot'; -import type { GameLighting } from '../systems/Lighting'; -import { MAX_LIGHTS } from '../systems/Lighting'; -import { hasLineOfSight } from '../systems/combat/los'; - -/** Допустимые kind спек-синтеза (как в движке, строками — спек идёт по мосту). */ -const SOUND_KINDS: readonly string[] = ['hit', 'chime', 'scrape', 'hum', 'tone']; - -/** Аргументы whitelist-команд моста (все опциональны; типы проверяют обработчики). */ -interface CommandArgs { - x?: number; - y?: number; - level?: number; - id?: string; - value?: number | string | boolean; - flag?: string; - index?: number; - hours?: number; - text?: string; - from?: { x?: number; y?: number }; - to?: { x?: number; y?: number }; - key?: string; - volume?: number; - spec?: Partial; - effect?: string; -} - -/** Обработчик команды моста: deps сцены + разобранные аргументы. */ -type CommandHandler = (d: SceneAgentDeps, a: CommandArgs) => JsonValue; - -/** Аргументы-тайлы x/y заданы числами (общая проверка тайловых команд). */ -function hasXY(a: CommandArgs): a is CommandArgs & { x: number; y: number } { - return typeof a.x === 'number' && typeof a.y === 'number'; -} - -/** Таблица whitelist-команд (name → обработчик); неизвестное имя — null в agentCommand. */ -const COMMANDS: Record = { - 'scene:sleepAll': (d) => { - for (const [, en] of d.combat.enemies) en.brain.putToSleep(9999); - return true; - }, - 'scene:noise': (d, a) => { - // Шум в тайле (юниты моста — тайлы): уровень 0.35 слышат бодрые, 0.7+ будит спящих. - if (!hasXY(a)) return null; - d.combat.noise(tileToWorld(a.x, a.y), typeof a.level === 'number' ? a.level : 1); - return true; - }, - 'scene:damageEnemy': (d, a) => { - // Урон живому сгустку (проверки отступления); id — фильтр по виду. - if (typeof a.value !== 'number') return null; - for (const [e, en] of d.combat.enemies) { - if (en.brain.dead) continue; - if (typeof a.id === 'string' && en.kind.id !== a.id) continue; - d.combat.damageEnemy(e, en, a.value, d.player.position); - return true; - } - return false; - }, - 'scene:damagePlayer': (d, a) => { - // Урон герою полным боевым путём (CombatWorld.deps → onPlayerDamaged): - // вспышка, шейк, виньетка при низком hp. - if (typeof a.value !== 'number') return null; - d.damagePlayer(a.value); - return true; - }, - 'scene:give': (d, a) => { - if (typeof a.id !== 'string') return null; - d.game.inventory.add(a.id); - return true; - }, - 'scene:setVar': (d, a) => { - if (typeof a.id !== 'string') return null; - d.game.state.setVar(a.id, a.value ?? 0); - return true; - }, - 'scene:setFlag': (d, a) => { - if (typeof a.flag !== 'string') return null; - d.game.state.setFlag(a.flag); - return true; - }, - 'scene:setTime': (d, a) => { - // Перемотка времени суток (часы → минуты) — детерминизм проверок света. - if (typeof a.hours !== 'number') return null; - d.game.clock.set(a.hours * 60); - return true; - }, - 'scene:teleport': (d, a) => { - if (!hasXY(a)) return null; - d.player.teleportTo({ x: a.x, y: a.y }); - d.followCamera(true); - return true; - }, - 'scene:route': (d, a) => { - if (!hasXY(a)) return null; - // Маршрут как у героя: тайлы NPC заняты, иначе мост ведёт сквозь тело. - return findPath(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }, false) ?? null; - }, - 'scene:walk': (d, a) => { - // Пойти в тайл (как герой): A* + followPath, без инъекции клика. - if (!hasXY(a)) return null; - const path = findPath(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }, false); - if (!path || path.length === 0) return false; - d.player.followPath(path); - return true; - }, - 'scene:approach': (d, a) => { - // Подойти к краю (не)проходимого тайла: цель может быть стеной/объектом/NPC. - // Возврат — тайл прибытия ({x, y}); уже рядом — текущий тайл героя. - if (!hasXY(a)) return null; - const path = findPathToNeighbor(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }); - if (path === null) return null; - const dest = path.length > 0 ? path[path.length - 1]! : d.player.currentTile(); - if (path.length > 0) d.player.followPath(path); - return { x: dest.x, y: dest.y }; - }, - 'scene:walkable': (d, a) => { - // Проходим ли тайл (юниты моста — тайлы; стены, вода и footprint пропов блокируют). - if (!hasXY(a)) return null; - return d.map.isWalkable(Math.floor(a.x), Math.floor(a.y)); - }, - 'scene:raycast': (d, a) => { - // Прямая видимость между тайлами: true — чисто, false — стена/дом на отрезке. - const f = a.from; - const t = a.to; - if (!f || !t || typeof f.x !== 'number' || typeof f.y !== 'number' || typeof t.x !== 'number' || typeof t.y !== 'number') { - return null; - } - return hasLineOfSight(tileToWorld(f.x, f.y), tileToWorld(t.x, t.y), d.combat.opaque); - }, - 'scene:pickChoice': (d, a) => { - if (typeof a.index !== 'number') return null; - d.dialogue.pickChoice(a.index); - return true; - }, - 'scene:pickChoiceByText': (d, a) => { - // Выбор по тексту реплики — сценариям не надо знать порядок вариантов. - if (typeof a.text !== 'string') return null; - return d.dialogue.pickChoiceByText(a.text); - }, - 'scene:skipCutscene': (d) => { - if (!d.cutscene.active) return false; - d.cutscene.skip(); // until форсируются — скип конечен даже на шаге-ожидании - return true; - }, - 'scene:synthesize': (d, a) => { - // Звук по описанию (спек → playSpec): агент без слуха описывает - // звук параметрами; факт запуска виден в DEV-логе аудио. - const s = a.spec; - if (!s || !SOUND_KINDS.includes(s.kind as SoundKind) || typeof s.dur !== 'number') { - return null; - } - const spec: SoundSpec = { kind: s.kind as SoundKind, dur: s.dur }; - if (typeof s.low === 'number') spec.low = s.low; - if (typeof s.high === 'number') spec.high = s.high; - if (typeof s.tone === 'number') spec.tone = s.tone; - if (typeof s.freq === 'number') spec.freq = s.freq; - if (typeof s.power === 'number') spec.power = s.power; - if (typeof s.seed === 'number') spec.seed = s.seed; - if (typeof s.peak === 'number') spec.peak = s.peak; - if (typeof s.loop === 'boolean') spec.loop = s.loop; - void d.game.audio.playSpec( - typeof a.key === 'string' ? a.key : 'agent/synth', - spec, - typeof a.volume === 'number' ? a.volume : 0.6 - ); - return true; - }, - 'scene:postfx': (d, a) => { - // Пост-эффекты для проверок: импульс блюра/аберрации, марево зоной, сброс. - // effect: blur | aberration | shimmer | clear; точка — тайл (x/y), - // level — радиус зоны в юнитах, value — сила (px выборки / px сдвига / amp). - const center = hasXY(a) - ? (() => { - const w = tileToWorld(a.x!, a.y!); - return d.camera.toScreen(w.x, w.y); - })() - : undefined; - const radius = - typeof a.level === 'number' ? unitsToPx(a.level) * d.camera.zoomLevel : undefined; - switch (a.effect) { - case 'blur': - d.postfx.pulseBlur( - { - strength: typeof a.value === 'number' ? a.value : 6, - center, - radius: radius ?? 0 - }, - { attack: 0.05, hold: 0.1, decay: 0.5 } - ); - return true; - case 'aberration': - d.postfx.pulseAberration( - typeof a.value === 'number' ? a.value : 3, - { attack: 0.02, hold: 0.06, decay: 0.4 } - ); - return true; - case 'shimmer': - d.setShimmer({ - amp: typeof a.value === 'number' ? a.value : 1.4, - wavelength: 26, - speed: 1.8, - center, - radius: radius ?? unitsToPx(2.5) * d.camera.zoomLevel - }); - return true; - case 'clear': - d.postfx.clear(); - d.setShimmer(undefined); // вернуть авто-зоны (источники области) - return true; - default: - return null; - } - } -}; - -/** - * Агентный фасад сцены локации: слой снапшота, инварианты, whitelist-команды. - * Вынесен из LocationScene (SceneAgent), чтобы сцена оставалась оркестратором - * вьюх, а мосту хватало узкого deps-объекта. Слои снапшота собираются теми же - * фабриками, что тестируются в snapshot.test.ts, — расхождение невозможно. - */ -export interface SceneAgentDeps { - game: Game; - map: IsometricTileMap; - area: AreaDef; - player: PlayerController; - playerCombat: PlayerCombat; - combat: CombatWorld; - /** Определения NPC области (вьюхи не нужны мосту). */ - npcs: readonly NpcDef[]; - interactables: Interactables; - /** Реестр объектов сцены (для инварианта согласованности). */ - registry: SceneRegistry; - /** Grid путей с занятыми NPC-тайлами (для scene:route — как у героя). */ - walkGrid(): Grid; - dialogue: DialogueSystem; - cutscene: CutsceneRunner; - lastToast(): { text: string; tick: number } | null; - /** Ближайшая цель кнопки действия (или null) — из роутера взаимодействия. */ - interactHint(): { kind: string; label: string; tile: { x: number; y: number } } | null; - inHazard(): HazardDef | null; - /** Урон герою полным боевым путём (как у сгустков — for scene:damagePlayer). */ - damagePlayer(damage: number, from?: Vec2): void; - /** Игровая обвязка освещения (ambient + источники для снапшота). */ - lighting(): GameLighting; - /** Камера (проекция мир→экран для точечных пост-эффектов). */ - camera: Camera; - /** Пост-эффекты сцены (scene:postfx — блюр/аберрация/марево). */ - postfx: PostFx; - /** - * Ручная зона марева: перекрывает авто-источники области, пока не снять. - * undefined возвращает авто-режим (ставит scene:postfx clear). - */ - setShimmer(z: ShimmerState | null | undefined): void; - /** Камера в ногах героя (snap — телепорты). */ - followCamera(snap: boolean): void; -} - -export class SceneAgentView implements SceneAgent { - constructor(private deps: SceneAgentDeps) {} - - /** Контентный слой снапшота — см. snapshot.ts. */ - agentSnapshot(): SnapshotLayer { - const d = this.deps; - const hero: HeroSnapshot = { - tile: d.player.currentTile(), - pos: d.player.position, - hp: d.playerCombat.hp, - maxHp: PLAYER_COMBAT.maxHp, - facing: d.player.dir, - moving: d.player.moving, - invuln: d.playerCombat.invuln, - inHazard: d.inHazard()?.name ?? null - }; - const enemies: EnemySnapshot[] = []; - for (const [, en] of d.combat.enemies) { - enemies.push({ - kind: en.kind.id, - state: en.brain.state, - hp: en.hp, - pos: en.pos, - asleep: en.brain.asleep, - dead: en.brain.dead - }); - } - const npcs: NpcSnapshot[] = d.npcs.map((def) => ({ - id: def.id, - name: def.name, - tile: def.tile, - met: d.game.state.hasFlag(def.flagKey) - })); - const transitions: TransitionSnapshot[] = d.area.transitions.map((t) => ({ - tile: t.tile, - to: t.target.kind === 'area' ? t.target.area : '', - trigger: t.trigger ?? 'step', - label: t.label ?? null - })); - const interactables: InteractableSnapshot[] = (d.area.interactables ?? []).map((def) => ({ - id: def.id, - tile: def.tile, - label: def.label ?? null, - used: d.interactables.isUsed(def.id) - })); - const layer: LocationSnapshot = { - scene: 'location', - area: d.area.id, - areaName: d.area.name, - hero: heroLayer(hero).hero, - enemies: enemiesLayer(enemies).enemies, - npcs: npcsLayer(npcs).npcs, - transitions, - interactables, - collision: collisionLayer(d.map.data).collision, - lighting: lightingLayer(d.lighting().snapshot()).lighting, - dialogue: dialogueLayer(d.dialogue.agentState).dialogue, - cutscene: { active: d.cutscene.active }, - lastToast: d.lastToast(), - interactHint: d.interactHint() - }; - return layer; - } - - /** Инварианты сцены: валидность контента + целостность героя/врагов. */ - agentInvariants(): Invariant[] { - const where = 'scene/LocationScene'; - const d = this.deps; - const heroTile = d.player.currentTile(); - const heroPos = d.player.position; - const enemyPos: Record = {}; - const enemyChecks: Invariant[] = []; - for (const [e, en] of d.combat.enemies) { - enemyPos[`enemy#${e}.x`] = en.pos.x; - enemyPos[`enemy#${e}.y`] = en.pos.y; - if (!en.brain.dead && !d.map.isWalkable(Math.floor(en.pos.x), Math.floor(en.pos.y))) { - enemyChecks.push({ - id: 'enemy-in-wall', - severity: 'error', - message: `${en.kind.id} в непроходимом тайле (${en.pos.x},${en.pos.y})`, - where - }); - } - } - return mergeInvariants( - checkFinite( - { 'hero.pos.x': heroPos.x, 'hero.pos.y': heroPos.y, ...enemyPos }, - where - ), - checkRange('hero.hp', d.playerCombat.hp, 0, PLAYER_COMBAT.maxHp, where), - checkWalkable('герой', heroTile, d.map, where), - enemyChecks, - this.registryInvariant(where), - this.lightingInvariant(where), - this.timeInvariant(where), - d.combat.agentInvariants() - ); - } - - /** Время суток в допустимых пределах: конечные минуты 0..1439, night 0..1, label «H:MM». */ - private timeInvariant(where: string): Invariant[] { - const bad: Invariant = { - id: 'time-bounded', - severity: 'error', - message: 'время суток вне допустимых пределов', - where - }; - const t = { - minutes: this.deps.game.clock.minutes, - label: this.deps.game.clock.label, - night: dayNightFactor(this.deps.game.clock.hours) - }; - if (!Number.isFinite(t.minutes) || t.minutes < 0 || t.minutes >= 1440) return [bad]; - if (!Number.isFinite(t.night) || t.night < 0 || t.night > 1) return [bad]; - if (!/^\d{1,2}:\d{2}$/.test(t.label)) return [bad]; - return []; - } - - /** Свет в допустимых пределах: конечные значения, интенсивность 0..2, виньетка 0..1, источников ≤24. */ - private lightingInvariant(where: string): Invariant[] { - const bad: Invariant = { - id: 'lighting-bounded', - severity: 'error', - message: 'источники света вне допустимых пределов', - where - }; - const l = this.deps.lighting().snapshot(); - if (l.sources.length > MAX_LIGHTS || !Number.isFinite(l.ambient)) return [bad]; - if (!Number.isFinite(l.vignette) || l.vignette < 0 || l.vignette > 1) return [bad]; - for (const s of l.sources) { - if (!Number.isFinite(s.x) || !Number.isFinite(s.y) || s.intensity < 0 || s.intensity > 2) return [bad]; - } - return []; - } - - /** Реестр согласован с ECS: у каждого живого врага есть запись, позиции совпадают. */ - private registryInvariant(where: string): Invariant[] { - const out: Invariant[] = []; - for (const [e, en] of this.deps.combat.enemies) { - if (en.brain.dead) continue; - const obj = this.deps.registry.get(`enemy:${e}`); - if (!obj) { - out.push({ - id: 'registry-consistent', - severity: 'error', - message: `враг #${e} (${en.kind.id}) нет в реестре сцены`, - where - }); - } else if (obj.pos.x !== en.pos.x || obj.pos.y !== en.pos.y) { - out.push({ - id: 'registry-consistent', - severity: 'error', - message: `враг #${e} (${en.kind.id}): позиция реестра разошлась с ECS`, - where - }); - } - } - return out; - } - - /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */ - agentCommand(name: string, args?: JsonValue): JsonValue { - const handler = COMMANDS[name]; - if (!handler) return null; - return handler(this.deps, (args ?? {}) as CommandArgs); - } -} \ No newline at end of file diff --git a/apps/game/src/agent/__tests__/snapshot.test.ts b/apps/game/src/agent/__tests__/snapshot.test.ts deleted file mode 100644 index beb287d..0000000 --- a/apps/game/src/agent/__tests__/snapshot.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - AGENT_SNAPSHOT_KEYS, - collisionLayer, - dialogueLayer, - enemiesLayer, - gameLayer, - heroLayer, - lightingLayer, - npcsLayer, - type EnemySnapshot, - type GameSnapshot, - type DialogueSnapshot, - type LocationSnapshot, - type NpcSnapshot -} from '../snapshot'; -import { predKeys } from '../GameAgent'; - -describe('снапшот агента — слои', () => { - it('герой: позиция округляется до 3 знаков, остальное копируется', () => { - const layer = heroLayer({ - tile: { x: 12, y: 8 }, - pos: { x: 1.23456, y: 5.0001 }, - hp: 9, - maxHp: 12, - facing: 'side', - moving: false, - invuln: false, - inHazard: null - }); - expect(layer.hero).toMatchObject({ - tile: { x: 12, y: 8 }, - pos: { x: 1.235, y: 5 }, - hp: 9, - maxHp: 12, - facing: 'side' - }); - }); - - it('враги: поля на месте, позиция округлена', () => { - const e: EnemySnapshot = { - kind: 'ash', - state: 'chase', - hp: 4, - pos: { x: 0.123456, y: 2.5 }, - asleep: true, - dead: false - }; - const layer = enemiesLayer([e]); - expect(layer.enemies).toEqual([ - { kind: 'ash', state: 'chase', hp: 4, pos: { x: 0.123, y: 2.5 }, asleep: true, dead: false } - ]); - }); - - it('слой коллизий: blocked по тайлам, пропы отдельным списком', () => { - const data = { - width: 2, - height: 2, - tiles: [0, 1, 0, 0], - blocked: [1], - props: [{ id: 2, x: 0, y: 1, w: 2, h: 1 }] - }; - const layer = collisionLayer(data); - expect(layer.collision).toEqual({ - width: 2, - height: 2, - blocked: [0, 1, 1, 1], // стена (1,0) + весь footprint пропа - props: [{ x: 0, y: 1, w: 2, h: 1 }] - }); - }); - - it('свет: ambient как есть, источники с округлением координат и интенсивности', () => { - const layer = lightingLayer({ - ambient: 0x54586a, - vignette: 0.34567, - sources: [ - { id: 'hearth', x: 123.4567, y: 67.8912, color: 0xf2b45a, intensity: 0.87654 }, - { id: 'lamp', x: 240, y: 135, color: 0xf2b45a, intensity: 0.5 } - ] - }); - expect(layer.lighting).toEqual({ - ambient: 0x54586a, - vignette: 0.346, - sources: [ - { id: 'hearth', x: 123.457, y: 67.891, color: 0xf2b45a, intensity: 0.877 }, - { id: 'lamp', x: 240, y: 135, color: 0xf2b45a, intensity: 0.5 } - ] - }); - }); - - it('свет: пустой список источников допустим (дневная улица)', () => { - const layer = lightingLayer({ ambient: 0xffffff, vignette: 0, sources: [] }); - expect(layer.lighting).toEqual({ ambient: 0xffffff, vignette: 0, sources: [] }); - }); - - it('NPC: копия с met, без ссылок на источник', () => { - const n: NpcSnapshot = { id: 'elder', name: 'Ирвин', tile: { x: 20, y: 12 }, met: false }; - const layer = npcsLayer([n]); - const out = layer.npcs[0]!; - expect(out).toEqual(n); - expect(out).not.toBe(n); - }); - - it('диалог: объект проходит, null остаётся null', () => { - const d: DialogueSnapshot = { - id: 'elder', - nodeId: 'n1', - speaker: 'Ирвин', - text: 'Привет', - mood: null, - tags: [], - choices: [], - path: ['n1'], - waitingForChoice: false - }; - expect(dialogueLayer(d).dialogue).toEqual(d); - expect(dialogueLayer(null).dialogue).toBeNull(); - }); - - it('слой игры: флаги/вары/сумка/время копируются защитно', () => { - const flags = ['metElder']; - const vars = { flowers: 2 }; - const inv = [{ id: 'flower', count: 2 }]; - const time = { minutes: 480, label: '8:00', night: 0 }; - const layer = gameLayer({ flags, vars, inventory: inv, time }); - expect(layer).toEqual({ - flags: ['metElder'], - vars: { flowers: 2 }, - inventory: [{ id: 'flower', count: 2 }], - time: { minutes: 480, label: '8:00', night: 0 } - }); - // Мутация источника не меняет слой. - flags.push('x'); - vars.flowers = 99; - inv[0]!.count = 99; - time.minutes = 999; - expect(layer.flags).toEqual(['metElder']); - expect(layer.vars).toEqual({ flowers: 2 }); - expect(layer.inventory[0]!.count).toBe(2); - expect(layer.time.minutes).toBe(480); - }); -}); - -describe('AGENT_SNAPSHOT_KEYS — анти-дрейф', () => { - const locationSample: LocationSnapshot = { - scene: 'location', - area: 'meadows', - areaName: 'Выжженные луга', - hero: { - tile: { x: 0, y: 0 }, - pos: { x: 0, y: 0 }, - hp: 10, - maxHp: 10, - facing: 'down', - moving: false, - invuln: false, - inHazard: null - }, - enemies: [], - npcs: [], - transitions: [], - interactables: [], - collision: { width: 1, height: 1, blocked: [0], props: [] }, - lighting: { ambient: 0xffffff, vignette: 0, sources: [] }, - dialogue: null, - cutscene: null, - lastToast: null, - interactHint: null - }; - - it('все top-level ключи сцены и слоя игры есть в списке', () => { - const gamePart: GameSnapshot = gameLayer({ - flags: [], - vars: {}, - inventory: [], - time: { minutes: 480, label: '8:00', night: 0 } - }); - const keys = new Set([...Object.keys(locationSample), ...Object.keys(gamePart)]); - expect([...keys].filter((k) => !AGENT_SNAPSHOT_KEYS.includes(k))).toEqual([]); - }); - - it('движковые ключи (слоя EngineAgent) не утеряны из списка', () => { - for (const k of ['tick', 'fps', 'fixedStep', 'scenes', 'transitioning', 'camera', 'pointer']) { - expect(AGENT_SNAPSHOT_KEYS).toContain(k); - } - }); - - it('в списке нет лишних (список = сцена + игра + движок)', () => { - const locationKeys = Object.keys(locationSample); - const gameKeys = ['flags', 'vars', 'inventory', 'time']; - const extra = AGENT_SNAPSHOT_KEYS.filter( - (k) => !locationKeys.includes(k) && !gameKeys.includes(k) && !['tick', 'fps', 'fixedStep', 'scenes', 'transitioning', 'camera', 'pointer'].includes(k) - ); - expect(extra).toEqual([]); - }); -}); - -describe('predKeys — top-level ключи предиката', () => { - it('одиночное обращение и вложенный путь', () => { - expect(predKeys('s.hero')).toEqual(['hero']); - expect(predKeys('s.hero && s.hero.tile.x === 1')).toEqual(['hero']); - }); - - it('несколько ключей и сложные выражения', () => { - expect(predKeys('!s.transitioning && s.area === "zvenets"')).toEqual( - expect.arrayContaining(['transitioning', 'area']) - ); - expect(predKeys('s.vars.motes >= 1 || s.flags.includes("x")')).toEqual( - expect.arrayContaining(['vars', 'flags']) - ); - expect(predKeys('(s.dialogue != null)')).toEqual(['dialogue']); - }); - - it('не ловит не-обращения (строки, другие объекты)', () => { - expect(predKeys('s.scene === "location"')).toEqual(['scene']); - expect(predKeys('s.x === 1')).toEqual(['x']); // pointer-слой — легальный ключ движка - expect(predKeys('foo.s.bar === 1')).toEqual([]); // не наш s - }); -}); \ No newline at end of file diff --git a/apps/game/src/agent/snapshot.ts b/apps/game/src/agent/snapshot.ts deleted file mode 100644 index 95d0535..0000000 --- a/apps/game/src/agent/snapshot.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { gridOf, ENGINE_SNAPSHOT_KEYS, type SnapshotLayer, type TileMapData } from '@rpg/engine'; - -/** - * Контентный слой снапшота агентного моста. Чистые функции — тестируются - * в Vitest без браузера; LocationScene/GameAgent подставляют живые данные. - */ - -/** Top-level ключи полного снапшота игры: движковый слой + сцена + слой игры. */ -export const AGENT_SNAPSHOT_KEYS: readonly string[] = [ - ...ENGINE_SNAPSHOT_KEYS, - // слой сцены локации (LocationSnapshot) - 'scene', - 'area', - 'areaName', - 'hero', - 'enemies', - 'npcs', - 'transitions', - 'interactables', - 'collision', - 'lighting', - 'dialogue', - 'cutscene', - 'lastToast', - 'interactHint', - // слой игры (gameLayer) - 'flags', - 'vars', - 'inventory', - 'time' -]; - -/* - * Все снапшот-типы ниже — type alias'ы, не interface: у alias'ов есть неявная - * индексная подпись, поэтому они присваиваются JsonValue/SnapshotLayer без - * кастов (interface — нет). Рассинхрон слоя и типа — ошибка компиляции. - */ - -/** Герой. */ -export type HeroSnapshot = { - tile: { x: number; y: number }; - pos: { x: number; y: number }; - hp: number; - maxHp: number; - facing: string; - moving: boolean; - invuln: boolean; - inHazard: string | null; -}; - -/** Враг. */ -export type EnemySnapshot = { - kind: string; - state: string | null; - hp: number; - pos: { x: number; y: number }; - asleep: boolean; - dead: boolean; -}; - -/** NPC. */ -export type NpcSnapshot = { - id: string; - name: string; - tile: { x: number; y: number }; - met: boolean; -}; - -/** Активный диалог (или null). */ -export type DialogueSnapshot = { - id: string | null; - nodeId: string | null; - speaker: string | null; - text: string | null; - /** Настроение реплики (презентационное; на геймплей не влияет). */ - mood: string | null; - /** Свободные метки узла — для фильтров проверок. */ - tags: string[]; - choices: string[]; - /** Показанные узлы по порядку — как диалог был пройден. */ - path: string[]; - waitingForChoice: boolean; -} - -/** Переход (дверь/выход/портал) — из данных локации. */ -export type TransitionSnapshot = { - tile: { x: number; y: number }; - to: string; - /** Как срабатывает: шагом на тайл или кликом. */ - trigger: 'step' | 'click'; - label: string | null; -} - -/** Интерактивный объект области (used — для once-объектов). */ -export type InteractableSnapshot = { - id: string; - tile: { x: number; y: number }; - label: string | null; - used: boolean; -} - -/** Источник света (пост-мерцание). */ -export type LightSourceSnapshot = { - id: string; - x: number; - y: number; - color: number; - intensity: number; -} - -/** Освещение сцены: ambient (multiply-цвет) + виньетка + активные источники. */ -export type LightingSnapshot = { - ambient: number; - /** Целевая интенсивность виньетки 0..1. */ - vignette: number; - sources: LightSourceSnapshot[]; -} - -/** Карта коллизий для агента: стены/вода по тайлам + крупные пропы. */ -export type CollisionSnapshot = { - width: number; - height: number; - /** Проходимость по тайлам, построчно: 0 — свободен, 1 — блок (включая footprint пропов). */ - blocked: number[]; - /** Крупные объекты с footprint'ом (дома и т.п.). */ - props: { x: number; y: number; w: number; h: number }[]; -} - -/** Контентный слой сцены локации (структурно = SnapshotLayer). */ -export type LocationSnapshot = { - scene: 'location'; - area: string; - areaName: string; - hero: HeroSnapshot; - enemies: EnemySnapshot[]; - npcs: NpcSnapshot[]; - transitions: TransitionSnapshot[]; - interactables: InteractableSnapshot[]; - collision: CollisionSnapshot; - /** Освещение области (ambient + активные источники). */ - lighting: LightingSnapshot; - dialogue: DialogueSnapshot | null; - cutscene: { active: boolean } | null; - /** Последний тост (текст + тик) — единственный канал текста реакций. */ - lastToast: { text: string; tick: number } | null; - /** Ближайшая цель кнопки действия (или null — вне радиуса взаимодействия). */ - interactHint: { kind: string; label: string; tile: { x: number; y: number } } | null; -} - -/** Время суток (ход игры — непрерывный, 1 с реального = 1 игровая минута). */ -export type TimeSnapshot = { - /** Целые минуты суток 0..1439. */ - minutes: number; - /** Строка «8:24» (как на часах предмета). */ - label: string; - /** Фактор ночи 0..1 (dayNightFactor по часам). */ - night: number; -} - -/** Полный снапшот игры: движковый слой + сценический + слой игры. */ -export type GameSnapshot = SnapshotLayer & { - /** Флаги прохождения. */ - flags: string[]; - /** Переменные прохождения. */ - vars: Record; - /** Сумка. */ - inventory: { id: string; count: number }[]; - /** Время суток. */ - time: TimeSnapshot; -}; - -/** Округление до 3 знаков — JSON читабельнее, точность в юнитах не теряется. */ -function r(v: number): number { - return Math.round(v * 1000) / 1000; -} - -/** Слой карты коллизий: blocked по тайлам (gridOf учитывает footprint пропов). */ -export function collisionLayer(data: TileMapData): SnapshotLayer & { collision: CollisionSnapshot } { - const grid = gridOf(data); - const blocked: number[] = []; - for (let y = 0; y < data.height; y++) { - for (let x = 0; x < data.width; x++) { - blocked.push(grid.isWalkable(x, y) ? 0 : 1); - } - } - const collision: CollisionSnapshot = { - width: data.width, - height: data.height, - blocked, - props: (data.props ?? []).map((p) => ({ x: p.x, y: p.y, w: p.w ?? 1, h: p.h ?? 1 })) - }; - return { collision }; -} - -/** Слой героя. */ -export function heroLayer(o: HeroSnapshot): SnapshotLayer & { hero: HeroSnapshot } { - return { - hero: { - tile: { x: o.tile.x, y: o.tile.y }, - pos: { x: r(o.pos.x), y: r(o.pos.y) }, - hp: o.hp, - maxHp: o.maxHp, - facing: o.facing, - moving: o.moving, - invuln: o.invuln, - inHazard: o.inHazard - } - }; -} - -/** Слой врагов. */ -export function enemiesLayer(enemies: EnemySnapshot[]): SnapshotLayer & { enemies: EnemySnapshot[] } { - return { - enemies: enemies.map((e) => ({ - kind: e.kind, - state: e.state, - hp: e.hp, - pos: { x: r(e.pos.x), y: r(e.pos.y) }, - asleep: e.asleep, - dead: e.dead - })) - }; -} - -/** Слой NPC. */ -export function npcsLayer(npcs: NpcSnapshot[]): SnapshotLayer & { npcs: NpcSnapshot[] } { - return { - npcs: npcs.map((n) => ({ - id: n.id, - name: n.name, - tile: { x: n.tile.x, y: n.tile.y }, - met: n.met - })) - }; -} - -/** Слой диалога. */ -export function dialogueLayer(d: DialogueSnapshot | null): SnapshotLayer & { dialogue: DialogueSnapshot | null } { - return { dialogue: d ? { ...d } : null }; -} - -/** Слой освещения: ambient + виньетка + активные источники (координаты экранные px). */ -export function lightingLayer(o: LightingSnapshot): SnapshotLayer & { lighting: LightingSnapshot } { - return { - lighting: { - ambient: o.ambient, - vignette: r(o.vignette), - sources: o.sources.map((s) => ({ - id: s.id, - x: r(s.x), - y: r(s.y), - color: s.color, - intensity: r(s.intensity) - })) - } - }; -} - -/** Слой уровня игры (вне сцен). */ -export function gameLayer(o: { - flags: string[]; - vars: Record; - inventory: { id: string; count: number }[]; - time: TimeSnapshot; -}): GameSnapshot { - return { - flags: [...o.flags], - vars: { ...o.vars }, - inventory: o.inventory.map((s) => ({ id: s.id, count: s.count })), - time: { ...o.time } - }; -} \ No newline at end of file diff --git a/apps/game/src/data/__tests__/dialogueRules.test.ts b/apps/game/src/data/__tests__/dialogueRules.test.ts deleted file mode 100644 index 7d78a48..0000000 --- a/apps/game/src/data/__tests__/dialogueRules.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { DialogueGraph } from '@rpg/engine'; -import { analyzeGraph, checkGraph, type GraphRefs } from '../dialogueRules'; - -const refs = (): GraphRefs => ({ - flags: new Set(['met_elder', 'quest_bells_taken']), - vars: new Set(['flowers']), - items: new Set(['cloth']), - customs: new Set(['plant_flowers']), - strings: new Set(['line.key']) -}); - -describe('checkGraph — ссылки и структура', () => { - it('чистый граф — ошибок нет', () => { - const g: DialogueGraph = { - start: 'a', - nodes: { a: { text: 'Привет', next: 'b' }, b: { text: 'Пока' } } - }; - expect(checkGraph('g', g, refs()).filter((i) => i.severity === 'error')).toEqual([]); - }); - - it('битые ссылки: флаг, вар, предмет, custom, textKey, next, start', () => { - const g: DialogueGraph = { - start: 'a', - nodes: { - a: { - text: 'x', - when: ['нет_такого'], - whenVars: [{ key: 'нет_вара', op: 'ge', value: 1 }], - hasItem: ['salt'], - do: [ - { kind: 'giveItem', id: 'нет_предмета' }, - { kind: 'custom', id: 'нет_эффекта' } - ], - textKey: 'нет.строки', - next: 'в_никуда' - } - } - }; - const ids = checkGraph('g', g, refs()).map((i) => i.id); - expect(ids).toContain('flag-unknown'); - expect(ids).toContain('var-unknown'); - expect(ids).toContain('item-unknown'); - expect(ids).toContain('do-item-unknown'); - expect(ids).toContain('do-custom-unknown'); - expect(ids).toContain('string-unknown'); - expect(ids).toContain('dialogue-next'); - // битый start - const ids2 = checkGraph('g', { start: 'нет', nodes: {} }, refs()).map((i) => i.id); - expect(ids2).toContain('dialogue-start'); - }); - - it('правильные ссылки проходят, упоминания пишутся в used-множества', () => { - const usedFlags = new Set(); - const usedVars = new Set(); - const g: DialogueGraph = { - start: 'a', - nodes: { - a: { - when: ['met_elder'], - whenVars: [{ key: 'flowers', op: 'ge', value: 3 }], - setFlags: ['quest_bells_taken'], - do: [{ kind: 'giveItem', id: 'cloth' }, { kind: 'custom', id: 'plant_flowers' }], - text: 'ok' - } - } - }; - expect(checkGraph('g', g, { ...refs(), usedFlags, usedVars })).toEqual([]); - expect([...usedFlags]).toEqual(['met_elder', 'quest_bells_taken']); - expect([...usedVars]).toEqual(['flowers']); - }); -}); - -describe('analyzeGraph — достижимость', () => { - it('сироты и тихие концы', () => { - const g: DialogueGraph = { - start: 'a', - nodes: { - a: { text: 'ok', next: 'silent' }, - lost: { text: 'никто не ссылается' }, - silent: { setFlags: ['met_elder'] } // нет next и текста — молча кончается - } - }; - const a = analyzeGraph(g); - expect(a.reachable).toEqual(['a', 'silent']); - expect(a.orphans).toEqual(['lost']); - expect(a.silentEnds).toEqual(['silent']); - }); - - it('цикл без текста — error, цикл с текстом — не цикл-ошибка', () => { - const g: DialogueGraph = { - start: 'a', - nodes: { - a: { text: 'Слово', next: 'hub' }, - hub: { next: 'hub' } // действие само в себя - } - }; - const a = analyzeGraph(g); - expect(a.textlessCycles).toEqual([['hub']]); - expect(a.silentEnds).toEqual([]); - }); - - it('выборы считаются рёбрами: цель выбора достижима', () => { - const g: DialogueGraph = { - start: 'q', - nodes: { - q: { text: '?', choices: [{ text: 'Да', next: 'yes' }] }, - yes: { text: 'Да' } - } - }; - const a = analyzeGraph(g); - expect(a.orphans).toEqual([]); - expect(a.reachable).toEqual(['q', 'yes']); - }); -}); \ No newline at end of file diff --git a/apps/game/src/data/__tests__/dialogues.test.ts b/apps/game/src/data/__tests__/dialogues.test.ts deleted file mode 100644 index a3d7737..0000000 --- a/apps/game/src/data/__tests__/dialogues.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { DialogueRunner, type DialogueView } from '@rpg/engine'; -import { GameState } from '@rpg/engine'; -import { DIALOGUES } from '../dialogues'; - -/** Заглушка view: запоминает показанные реплики. */ -function stubView(): DialogueView & { shown: string[] } { - const shown: string[] = []; - return { - shown, - show: (n: { text: string }) => void shown.push(n.text), - hide: () => undefined - }; -} - -describe('trader_repeat — ветка по вару motes', () => { - it('без мотов — только базовая реплика', () => { - const view = stubView(); - const runner = new DialogueRunner(new GameState(), view); - runner.start(DIALOGUES.trader_repeat); - runner.advance(); - expect(view.shown).toHaveLength(1); - expect(view.shown[0]).toContain('Звони тихо'); - }); - - it('с мотами (motes >= 1) — добавляется реплика о мота́х', () => { - const view = stubView(); - const state = new GameState(); - state.setVar('motes', 2); - const runner = new DialogueRunner(state, view); - runner.start(DIALOGUES.trader_repeat); - runner.advance(); - expect(view.shown).toHaveLength(2); - expect(view.shown[1]).toContain('Моты'); - }); -}); \ No newline at end of file diff --git a/apps/game/src/data/__tests__/interactables.test.ts b/apps/game/src/data/__tests__/interactables.test.ts deleted file mode 100644 index a3f5a6d..0000000 --- a/apps/game/src/data/__tests__/interactables.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { resolveInteractable, type InteractableDef, type InteractCtx } from '../interactables'; - -const ctx = (over: Partial = {}): InteractCtx => ({ - hasFlag: (f) => f === 'quest_bells_done', - hasItem: (i) => i === 'cloth', - getVar: (id) => (id === 'motes' ? 3 : 0), - used: (id) => id === 'spent', - ...over -}); - -const def = (over: Partial = {}): InteractableDef => ({ - id: 'obj', - pos: { x: 5.5, y: 5.5 }, - tile: { x: 5, y: 5 }, - kind: 'examine', - responses: [{ text: 'обычная' }], - ...over -}); - -describe('resolveInteractable — выбор реакции', () => { - it('без when — первая реакция', () => { - const pick = resolveInteractable(def(), ctx()); - expect(pick?.ok && pick.response.text).toBe('обычная'); - }); - - it('первая подходящая по when побеждает', () => { - const d = def({ - responses: [ - { when: { flag: 'quest_bells_done' }, text: 'после' }, - { text: 'до' } - ] - }); - expect(resolveInteractable(d, ctx())?.ok && 'после').toBe('после'); - expect(resolveInteractable(d, ctx({ hasFlag: () => false }))?.ok && 'до').toBe('до'); - }); - - it('notFlag и varAtLeast учитываются', () => { - const d = def({ responses: [{ when: { notFlag: 'quest_bells_done' }, text: 'x' }] }); - expect(resolveInteractable(d, ctx())).toBeNull(); - const v = def({ responses: [{ when: { varAtLeast: { id: 'motes', value: 5 } }, text: 'x' }] }); - expect(resolveInteractable(v, ctx())).toBeNull(); - expect(resolveInteractable(v, ctx({ getVar: () => 7 }))?.ok).toBe(true); - }); -}); - -describe('resolveInteractable — once и requiresItem', () => { - it('once: использованный молчит, неиспользованный реагирует', () => { - const d = def({ id: 'chest', once: true, responses: [{ text: 'сундук' }] }); - expect(resolveInteractable(d, ctx())?.ok).toBe(true); - expect(resolveInteractable(d, ctx({ used: () => true }))).toBeNull(); - }); - - it('requiresItem без предмета — отказ; с предметом реагирует', () => { - const d = def({ - requiresItem: 'salt', - lockedText: 'Нужна соль.', - responses: [{ text: 'взял' }] - }); - expect(resolveInteractable(d, ctx())).toEqual({ ok: false, lockedText: 'Нужна соль.' }); - expect(resolveInteractable(d, ctx({ hasItem: () => true }))?.ok).toBe(true); - }); - - it('requiresItem без lockedText — отказ по умолчанию', () => { - const pick = resolveInteractable(def({ requiresItem: 'salt' }), ctx()); - expect(pick && !pick.ok && pick.lockedText.length > 0).toBe(true); - }); - - it('once-использованный с requiresItem — отказ важнее молчания', () => { - const d = def({ once: true, requiresItem: 'salt', responses: [{ text: 'x' }] }); - expect(resolveInteractable(d, ctx({ used: () => true }))).toMatchObject({ ok: false }); - }); -}); \ No newline at end of file diff --git a/apps/game/src/data/__tests__/items.test.ts b/apps/game/src/data/__tests__/items.test.ts deleted file mode 100644 index 2e52f50..0000000 --- a/apps/game/src/data/__tests__/items.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ITEMS, itemName, useItemLine } from '../items'; - -describe('useItemLine — применение предметов', () => { - it('часы показывают замороженное время сумки', () => { - expect(useItemLine('clock', { label: '8:24' })).toBe('Время: 8:24'); - expect(useItemLine('clock', { label: '0:05' })).toBe('Время: 0:05'); - }); - - it('не-юзабельные предметы — null (сумка молчит)', () => { - expect(useItemLine('cloth', { label: '8:24' })).toBeNull(); - expect(useItemLine('flower', { label: '8:24' })).toBeNull(); - expect(useItemLine('', { label: '8:24' })).toBeNull(); - }); -}); - -describe('реестр предметов', () => { - it('часы описаны в реестре (валидатор диалогов держит giveItem clock)', () => { - expect(itemName('clock')).toBe('Медные часы'); - expect(ITEMS.clock.desc.length).toBeGreaterThan(0); - }); -}); \ No newline at end of file diff --git a/apps/game/src/data/__tests__/migrations.test.ts b/apps/game/src/data/__tests__/migrations.test.ts deleted file mode 100644 index 5d20426..0000000 --- a/apps/game/src/data/__tests__/migrations.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { GameState } from '@rpg/engine'; -import { migrateV1toV2, migrateV2toV3, registerStateMigrations, STATE_DATA_VERSION } from '../migrations'; - -describe('миграции GameStateData', () => { - it('v1→v2: знакомства переименованы, остальное перенесено, version=2', () => { - const out = migrateV1toV2({ - version: 1, - flags: ['metElder', 'quest_bells_taken', 'metTrader'], - vars: { flowers: 3 } - }); - expect(out.version).toBe(2); - expect(out.flags).toEqual(['met_elder', 'quest_bells_taken', 'met_mila']); - expect(out.vars).toEqual({ flowers: 3 }); - }); - - it('v2→v3: флаг evening удалён, остальное перенесено, version=3', () => { - const out = migrateV2toV3({ - version: 2, - flags: ['evening', 'quest_bells_done', 'hint_bells'], - vars: { flowers: 3 } - }); - expect(out.version).toBe(3); - expect(out.flags).toEqual(['quest_bells_done', 'hint_bells']); - expect(out.vars).toEqual({ flowers: 3 }); - }); - - it('старый сейв v1 читается state.load с миграцией до текущей версии', () => { - const s = new GameState(); - registerStateMigrations(s); - expect(s.dataVersion).toBe(STATE_DATA_VERSION); - s.load({ version: 1, flags: ['metElder', 'metTrader'], vars: {} }); - expect(s.hasFlag('met_elder')).toBe(true); - expect(s.hasFlag('met_mila')).toBe(true); - expect(s.hasFlag('metElder')).toBe(false); - expect(s.hasFlag('metTrader')).toBe(false); - }); - - it('сейв текущей версии миграций не получает', () => { - const s = new GameState(); - registerStateMigrations(s); - s.load({ version: STATE_DATA_VERSION, flags: ['quest_bells_done'], vars: {} }); - expect(s.hasFlag('quest_bells_done')).toBe(true); - expect(s.allFlags).toEqual(['quest_bells_done']); - // сериализация — в текущей версии - expect(s.serialize().version).toBe(STATE_DATA_VERSION); - }); -}); \ No newline at end of file diff --git a/apps/game/src/data/__tests__/quests.test.ts b/apps/game/src/data/__tests__/quests.test.ts deleted file mode 100644 index 703eaa3..0000000 --- a/apps/game/src/data/__tests__/quests.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { GameState, Inventory } from '@rpg/engine'; -import { QUESTS, QUEST_FLOWERS, activeStage, questDialogueFor, questLog, questTracker } from '../quests'; -import { applyDialogueEffect, type DialogueEffectSink } from '../../systems/DialogueEffects'; -import { DIALOGUE_CUSTOM } from '../effects'; - -const bells = QUESTS[0]!; - -describe('квест «Три цветка» — стадии', () => { - it('до знакомства квест не взят, стадий нет', () => { - const s = new GameState(); - expect(questLog(s)).toEqual([]); - }); - - it('после взятия активна стадия сбора; диалог сдачи не готов', () => { - const s = new GameState(); - s.setFlag('quest_bells_taken'); - expect(activeStage(s, bells)?.dialogue).toBe('elder_hand_in'); - expect(questDialogueFor(s, 'elder')).toBeNull(); // цветов мало - expect(questLog(s)).toEqual([ - { done: true, text: 'Пепел дышит у прудов. Поговорить с Ирвином' }, - { done: false, text: `Собрать три колокольчика — Серые пруды, северо-запад лугов (0/${QUEST_FLOWERS})` } - ]); - }); - - it('собрано достаточно — сдача готова', () => { - const s = new GameState(); - s.setFlag('quest_bells_taken'); - s.setVar('flowers', QUEST_FLOWERS); - expect(questDialogueFor(s, 'elder')).toBe('elder_hand_in'); - expect(questLog(s)).toEqual([ - { done: true, text: 'Пепел дышит у прудов. Поговорить с Ирвином' }, - { done: false, text: `Собрать три колокольчика — Серые пруды, северо-запад лугов (3/3)` } - ]); - }); - - it('после сдачи — эпилог с Милой, до разговора с ней активен', () => { - const s = new GameState(); - s.setFlag('quest_bells_taken'); - s.setVar('flowers', QUEST_FLOWERS); - s.setFlag('quest_bells_done'); - expect(questDialogueFor(s, 'trader')).toBe('trader_after'); - expect(questDialogueFor(s, 'elder')).toBeNull(); // Ирвин — обычный repeat - expect(questLog(s)).toEqual([ - { done: true, text: 'Пепел дышит у прудов. Поговорить с Ирвином' }, - { done: true, text: `Собрать три колокольчика — Серые пруды, северо-запад лугов (3/3)` }, - { done: false, text: 'Поговорить с Милой — поляна у тропы гудит' } - ]); - // Разговор с Милой (act2_hook) завершает и эпилог. - s.setFlag('act2_hook'); - expect(questLog(s).every((e) => e.done)).toBe(true); - }); -}); - -describe('трекер HUD (questTracker)', () => { - it('в свежем состоянии — цель взятия (поговорить с Ирвином)', () => { - const s = new GameState(); - expect(questTracker(s)).toBe('Пепел дышит у прудов. Поговорить с Ирвином'); - }); - - it('на стадии сбора показывает прогресс', () => { - const s = new GameState(); - s.setFlag('quest_bells_taken'); - s.setVar('flowers', 1); - expect(questTracker(s)).toBe(`Собрать три колокольчика — Серые пруды, северо-запад лугов (1/3)`); - }); - - it('после завершения квеста колокольчиков — цель следующего квеста', () => { - const s = new GameState(); - s.setFlag('quest_bells_taken'); - s.setFlag('quest_bells_done'); - s.setFlag('act2_hook'); - expect(questTracker(s)).toBe('Резонатор Клинта молчит. Поговорить с Клинтом'); - }); -}); - -describe('эффекты диалогов', () => { - it('applyDialogueEffect: give/take меняют сумку, unknown игнорируется', () => { - const inv = new Inventory(); - const sink: DialogueEffectSink = { - giveItem: (id, count) => void inv.add(id, count), - takeItem: (id, count) => void inv.remove(id, count), - playSound: () => {}, - showToast: () => {}, - custom: () => {} - }; - applyDialogueEffect({ kind: 'giveItem', id: 'cloth', count: 2 }, sink); - expect(inv.count('cloth')).toBe(2); - applyDialogueEffect({ kind: 'takeItem', id: 'cloth' }, sink); - expect(inv.count('cloth')).toBe(1); - // неизвестный kind — тихий игнор (валидатор ловит как error) - applyDialogueEffect({ kind: 'custom', id: 'nope' } as never, sink); - applyDialogueEffect({ kind: 'toast', text: '' }, sink); - expect(inv.count('cloth')).toBe(1); - }); - - it('custom доходит до приёмника с именем из реестра', () => { - const got: string[] = []; - const sink: DialogueEffectSink = { - giveItem: () => {}, - takeItem: () => {}, - playSound: () => {}, - showToast: () => {}, - custom: (name) => got.push(name) - }; - applyDialogueEffect({ kind: 'custom', id: DIALOGUE_CUSTOM.plant_flowers }, sink); - expect(got).toEqual(['plant_flowers']); - }); - - it('квест завершён, но эпилог (без doneFlag) остаётся активной стадией', () => { - const s = new GameState(); - s.setFlag('quest_bells_taken'); - s.setFlag('quest_bells_done'); - expect(activeStage(s, bells)?.npc).toBe('trader'); - }); -}); \ No newline at end of file diff --git a/apps/game/src/data/__tests__/saves.test.ts b/apps/game/src/data/__tests__/saves.test.ts deleted file mode 100644 index 7be8159..0000000 --- a/apps/game/src/data/__tests__/saves.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { SAVE_SLOTS, canDelete, slotTitle } from '../saves'; - -describe('слоты сейвов', () => { - it('autosave первый и неудаляемый вручную', () => { - expect(SAVE_SLOTS[0]).toBe('autosave'); - expect(canDelete('autosave')).toBe(false); - expect(canDelete('slot1')).toBe(true); - }); - - it('подписи слотов человекочитаемы', () => { - expect(slotTitle('autosave')).toBe('Автосохранение'); - expect(slotTitle('slot2')).toBe('Слот 2'); - }); -}); \ No newline at end of file diff --git a/apps/game/src/data/__tests__/sfxSpecs.test.ts b/apps/game/src/data/__tests__/sfxSpecs.test.ts deleted file mode 100644 index a611a5c..0000000 --- a/apps/game/src/data/__tests__/sfxSpecs.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Парити-тест спек-синтеза: renderSpec(SPEC_SFX[key]) должен совпадать с - * формулой бывшего файлового генератора (tools/audio/gen.mjs) — миграция - * на playSpec не изменила звучание. Агент без слуха, поэтому сверяем - * численно: bell_hit/whoosh бит-в-бит (формулы совпали дословно), остальные - * — с допуском 1e-5 (перегруппировка умножений даёт расхождение ~1e-15, - * допуск ловит любую реальную ошибку формулы: частоту, вес, огибающую). - */ -import { describe, expect, it } from 'vitest'; -import { renderSpec } from '@rpg/engine'; -import { bandNoise, decay, normalize, synth } from '@rpg/engine/tools/synth.mjs'; -import { - ashDie, - ashHiss, - bellHit, - bellLow, - chestOpen, - chime, - deerStartle, - hurt, - motePickup, - note, - pickup, - spit, - uiClick, - whoosh -} from '../../../tools/audio/gen.mjs'; -import { SPEC_SFX } from '../sfxSpecs'; - -/** Максимум модуля разности двух сэмпл-массивов. */ -function maxDiff(a: Float32Array, b: Float32Array): number { - expect(a.length).toBe(b.length); - let max = 0; - for (let i = 0; i < a.length; i++) max = Math.max(max, Math.abs(a[i]! - b[i]!)); - return max; -} - -describe('паритет SPEC_SFX с формулами gen.mjs', () => { - it('sfx/bell_hit — бит-в-бит равен bellHit()', () => { - const spec = renderSpec(SPEC_SFX['sfx/bell_hit']!); - const wav = bellHit(); - expect(spec.length).toBe(wav.length); - for (let i = 0; i < wav.length; i++) expect(spec[i]).toBe(wav[i]); - }); - - it('sfx/whoosh — бит-в-бит равен whoosh()', () => { - const spec = renderSpec(SPEC_SFX['sfx/whoosh']!); - const wav = whoosh(); - expect(spec.length).toBe(wav.length); - for (let i = 0; i < wav.length; i++) expect(spec[i]).toBe(wav[i]); - }); - - const EPS = 1e-5; - const CASES: [string, () => Float32Array][] = [ - ['sfx/bell_low', bellLow], - ['sfx/ash_hiss', ashHiss], - ['sfx/ash_die', ashDie], - ['sfx/spit', spit], - ['sfx/hurt', hurt], - ['sfx/mote_pickup', motePickup], - ['sfx/pickup', pickup], - ['sfx/chest_open', chestOpen], - ['sfx/note', note], - ['sfx/deer_startle', deerStartle], - ['sfx/ui_click', uiClick], - ['sfx/chime', chime] - ]; - for (const [key, gen] of CASES) { - it(`${key} — совпадает с формулой gen.mjs (eps ${EPS})`, () => { - expect(maxDiff(renderSpec(SPEC_SFX[key]!), gen())).toBeLessThan(EPS); - }); - } - - it('sfx/step_water — совпадает с формулой всплеска (eps)', () => { - // Формула бывшего sfx/step_water.wav (seed 255 в gen.mjs); у спека - // сид 255+... — нет, базовый спек шагов взял сиды StepVariants - // (253-256), поэтому здесь сверяем саму формулу с сидом 255. - const gen = (): Float32Array => { - const dur = 0.18; - const noise = bandNoise(155, 600, 3200, dur); - const s = synth(dur, (t) => (noise() * 0.6 + Math.sin(2 * Math.PI * 300 * t) * 0.2) * decay(t, dur, 2)); - return normalize(s, 0.3); - }; - const spec: typeof SPEC_SFX[string] = { ...SPEC_SFX['sfx/step_water']!, seed: 155 }; - expect(maxDiff(renderSpec(spec), gen())).toBeLessThan(EPS); - }); -}); \ No newline at end of file diff --git a/apps/game/src/data/__tests__/transitions.test.ts b/apps/game/src/data/__tests__/transitions.test.ts deleted file mode 100644 index 4e75cab..0000000 --- a/apps/game/src/data/__tests__/transitions.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { resolveTransition, type TransitionCtx } from '../transitions'; -import type { TransitionDef } from '../locations'; - -const ctx: TransitionCtx = { - hasFlag: (f) => f === 'door_open', - hasItem: (i) => i === 'salt' -}; - -const at = (x: number, y: number) => ({ x, y }); - -describe('resolveTransition — выбор по тайлу/триггеру', () => { - const defs: TransitionDef[] = [ - { tile: at(2, 2), target: { kind: 'area', area: 'ponds', entry: at(3, 3) } }, - { tile: at(5, 5), label: 'Дверь', target: { kind: 'area', area: 'zvenets', entry: at(1, 1) }, trigger: 'click' }, - { tile: at(9, 6), target: { kind: 'return' }, trigger: 'click' } - ]; - - it('step-переход: наступил на тайл — сработал', () => { - const pick = resolveTransition(defs, at(2, 2), 'step', ctx); - expect(pick?.ok).toBe(true); - }); - - it('step не срабатывает на click-переходе того же тайла', () => { - expect(resolveTransition(defs, at(5, 5), 'step', ctx)).toBeNull(); - expect(resolveTransition(defs, at(5, 5), 'click', ctx)?.ok).toBe(true); - }); - - it('клик мимо переходов — null', () => { - expect(resolveTransition(defs, at(20, 20), 'click', ctx)).toBeNull(); - }); - - it('return-переход выбирается по kind цели, не по тайлу входа', () => { - const pick = resolveTransition(defs, at(9, 6), 'click', ctx); - expect(pick?.ok && pick.def.target.kind === 'return').toBe(true); - }); -}); - -describe('resolveTransition — условия и отказ', () => { - it('requiresFlag не выполнен — отказ с lockedText', () => { - const defs: TransitionDef[] = [ - { - tile: at(4, 4), - target: { kind: 'area', area: 'zvenets', entry: at(1, 1) }, - requiresFlag: 'gate_open', - lockedText: 'Заперто.' - } - ]; - const pick = resolveTransition(defs, at(4, 4), 'step', ctx); - expect(pick).toEqual({ ok: false, lockedText: 'Заперто.' }); - // Флаг поставлен — пускает. - expect(resolveTransition(defs, at(4, 4), 'step', { hasFlag: () => true, hasItem: () => false })?.ok).toBe(true); - }); - - it('requiresItem не выполнен — отказ; с предметом пускает', () => { - const defs: TransitionDef[] = [ - { - tile: at(4, 4), - target: { kind: 'area', area: 'zvenets', entry: at(1, 1) }, - requiresItem: 'salt', - lockedText: 'Нужна соль.' - } - ]; - expect(resolveTransition(defs, at(4, 4), 'step', { hasFlag: () => false, hasItem: () => false })).toEqual({ - ok: false, - lockedText: 'Нужна соль.' - }); - expect(resolveTransition(defs, at(4, 4), 'step', ctx)?.ok).toBe(true); - }); - - it('без lockedText — текст отказа по умолчанию', () => { - const pick = resolveTransition( - [{ tile: at(4, 4), target: { kind: 'area', area: 'zvenets', entry: at(1, 1) }, requiresFlag: 'x' }], - at(4, 4), - 'step', - ctx - ); - expect(pick?.ok).toBe(false); - expect(pick && !pick.ok && pick.lockedText.length > 0).toBe(true); - }); -}); - -describe('resolveTransition — дисарм входа', () => { - const defs: TransitionDef[] = [ - { tile: at(3, 3), target: { kind: 'area', area: 'meadows', entry: at(9, 9) } } - ]; - - it('step-переход на тайле входа игнорируется, пока герой не покинул его', () => { - expect(resolveTransition(defs, at(3, 3), 'step', ctx, at(3, 3))).toBeNull(); - }); - - it('после ухода с тайла входа переход снова работает', () => { - expect(resolveTransition(defs, at(3, 3), 'step', ctx, at(10, 10))?.ok).toBe(true); - expect(resolveTransition(defs, at(3, 3), 'step', ctx, null)?.ok).toBe(true); - }); - - it('дисарм не глушит click-переходы на том же тайле', () => { - const clickDefs: TransitionDef[] = [ - { tile: at(3, 3), target: { kind: 'return' }, trigger: 'click' } - ]; - expect(resolveTransition(clickDefs, at(3, 3), 'click', ctx, at(3, 3))?.ok).toBe(true); - }); -}); \ No newline at end of file diff --git a/apps/game/src/data/__tests__/validate.test.ts b/apps/game/src/data/__tests__/validate.test.ts deleted file mode 100644 index bc4115d..0000000 --- a/apps/game/src/data/__tests__/validate.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { DialogueGraph, TileMapData } from '@rpg/engine'; -import { - TILES, - buildMeadowsMap, - buildPondsMap, - buildZvenetsMap, - buildRustGroveMap, - buildOldSidingMap, - buildMachineHallMap -} from '../map'; -import { AREAS } from '../locations'; -import { ROOM_TONES } from '../sfxSpecs'; -import { NPCS } from '../npcs'; -import { DIALOGUES } from '../dialogues'; -import { FLAGS } from '../ids'; -import { validateContent, validateDialogue, validateAudio, validateLocations, validateNpcs, validateReferences } from '../validate'; - -/** Карта целиком из проходимой травы (или с одиночной стеной). */ -function flatMap(w: number, h: number, wall?: { x: number; y: number }): TileMapData { - const tiles = new Array(w * h).fill(TILES.GRASS); - if (wall) tiles[wall.y * w + wall.x] = TILES.HOUSE; - return { width: w, height: h, tiles, blocked: [TILES.WATER, TILES.TREE, TILES.TOWER, TILES.HOUSE] }; -} - -/** Карты всех локаций (как их грузит BootScene) — все области, не только акта 1. */ -function realMaps(): Map { - return new Map([ - ['meadows', buildMeadowsMap()], - ['ponds', buildPondsMap()], - ['zvenets', buildZvenetsMap()], - ['rust_grove', buildRustGroveMap()], - ['old_siding', buildOldSidingMap()], - ['machine_hall', buildMachineHallMap()] - ]); -} - -describe('validateContent — реальный контент чист', () => { - it('ошибок нет (warn допустим)', () => { - const errs = validateContent(realMaps()).filter((i) => i.severity === 'error'); - expect(errs).toEqual([]); - }); -}); - -describe('validateReferences — реестры флагов/варов', () => { - it('реальный контент: только флаг знакомств вне контента (dead), ошибок нет', () => { - const inv = validateReferences(); - expect(inv.filter((i) => i.severity === 'error')).toEqual([]); - // got_cloth ставится диалогом и нигде не читается, но ставится — упомянут. - // hint_bells ставится кодом сцены (подсказка прудов) — в контенте не упомянут. - const dead = inv.filter((i) => i.id === 'flag-dead').map((i) => i.message); - expect(dead).toEqual(['флаг «hint_bells» из реестра не упоминается в контенте']); - }); - - it('неизвестный флаг/вар в диалоге — error', () => { - const g: DialogueGraph = { start: 'a', nodes: { a: { text: 'a', setFlags: ['quest_bels_done'], whenVar: { key: 'moats', op: 'ge', value: 1 } } } }; - const backup = DIALOGUES['test_ref']; - (DIALOGUES as Record)['test_ref'] = g; - try { - const inv = validateReferences(); - expect(inv.some((i) => i.id === 'flag-unknown' && i.message.includes('quest_bels_done'))).toBe(true); - expect(inv.some((i) => i.id === 'var-unknown' && i.message.includes('moats'))).toBe(true); - } finally { - if (backup === undefined) delete (DIALOGUES as Record)['test_ref']; - else (DIALOGUES as Record)['test_ref'] = backup; - } - }); - - it('битая ссылка квеста на NPC/диалог — error', () => { - // Симулируем опечатку: подменяем NPCS/QUESTS нельзя (const), поэтому - // проверяем негатив через фейковый NPC-деф на уровне флага flagKey. - const broken = { ...NPCS[0]!, flagKey: 'metEllder' as keyof typeof FLAGS }; - const backup = NPCS[0]!; - (NPCS as unknown as { [0]: typeof broken })[0] = broken; - try { - const inv = validateReferences(); - expect(inv.some((i) => i.id === 'flag-unknown' && i.message.includes('metEllder'))).toBe(true); - } finally { - (NPCS as unknown as { [0]: typeof backup })[0] = backup; - } - }); -}); - -describe('validateDialogue', () => { - it('битый start и битый next — ошибки', () => { - const g: DialogueGraph = { - start: 'нет', - nodes: { - a: { text: 'a', next: 'нет2' }, - b: { text: 'b', choices: [{ text: 'x', next: 'нет3' }] } - } - }; - const ids = validateDialogue('test', g).map((i) => i.id); - expect(ids).toContain('dialogue-start'); - expect(ids).toContain('dialogue-next'); - }); - - it('узел-сирота — warn, связный граф чист', () => { - const ok: DialogueGraph = { start: 'a', nodes: { a: { text: 'a', next: 'b' }, b: { text: 'b', end: true } } }; - expect(validateDialogue('ok', ok)).toEqual([]); - const orphan: DialogueGraph = { start: 'a', nodes: { a: { text: 'a', end: true }, dead: { text: 'x', end: true } } }; - expect(validateDialogue('orphan', orphan).map((i) => i.id)).toEqual(['dialogue-orphan']); - }); -}); - -describe('validateNpcs', () => { - it('проходимая карта без NPC в стенах — чисто; стена под NPC ловится', () => { - const maps = new Map([['zvenets', flatMap(56, 40)]]); - expect(validateNpcs(maps)).toEqual([]); - // NPCS статичен — подменяем карту: стена на тайле Ирвина даст 'in-wall'. - const elder = AREAS.zvenets.npcs[0]!; - const walled = new Map([['zvenets', flatMap(28, 20, { x: elder.tile.x, y: elder.tile.y })]]); - const inv = validateNpcs(walled); - expect(inv.some((i) => i.id === 'in-wall' && i.message.includes(elder.id))).toBe(true); - }); -}); - -describe('validateLocations', () => { - it('переход в несуществующую область — ошибки target нет, guard на карту', () => { - const maps = new Map([['meadows', flatMap(28, 28)]]); - // AREAS статичен; проверяем чистую функцию через реальные данные: - // meadows -> ponds/zvenets существуют, но их карт в maps нет — - // guard на отсутствующую карту не роняет проверку. - const inv = validateLocations(maps); - expect(inv.every((i) => i.severity === 'warn' || i.where?.includes('meadows'))).toBe(true); - }); - - it('спавн в стене ловится', () => { - const spawn = AREAS.meadows.spawn; - const maps = new Map([['meadows', flatMap(28, 28, spawn)]]); - const inv = validateLocations(maps); - expect(inv.some((i) => i.id === 'in-wall' && i.message.includes('spawn'))).toBe(true); - }); -}); -describe('validateAudio — аудио-ключи контента', () => { - it('неизвестный ambience области — error', () => { - const backup = AREAS.meadows.ambience; - (AREAS.meadows as { ambience?: string }).ambience = 'ambience/tundra'; - try { - const inv = validateAudio(); - expect(inv.some((i) => i.id === 'audio-key' && i.message.includes('ambience/tundra'))).toBe(true); - } finally { - (AREAS.meadows as { ambience?: string }).ambience = backup; - } - }); - - it('sfx-ключ реакции вне спек-реестра — error', () => { - const area = AREAS.meadows; - const backup = area.interactables; - (area as { interactables?: unknown }).interactables = [ - { id: 'x', kind: 'signpost', tile: { x: 1, y: 1 }, responses: [{ sound: 'sfx/nats' }] } - ]; - try { - const inv = validateAudio(); - expect(inv.some((i) => i.id === 'audio-key' && i.message.includes('sfx/nats'))).toBe(true); - } finally { - (area as { interactables?: unknown }).interactables = backup; - } - }); - - it('roomtone с ключом-не-областью — error; реальные контент чист', () => { - (ROOM_TONES as Record).cave = { kind: 'hum', tone: 40, dur: 4, power: 2, peak: 0.2 }; - try { - const inv = validateAudio(); - expect(inv.some((i) => i.id === 'audio-key' && i.message.includes('cave'))).toBe(true); - } finally { - delete (ROOM_TONES as Record).cave; - } - expect(validateAudio().filter((i) => i.severity === 'error')).toEqual([]); - }); -}); - -describe('validateReferences — requiresItem и return-правило интерьеров', () => { - it('переход с неизвестным requiresItem — error', () => { - const t = AREAS.ponds.transitions[0]!; - const backup = t.requiresItem; - (t as { requiresItem?: string }).requiresItem = 'rope'; - try { - const inv = validateReferences(); - expect(inv.some((i) => i.id === 'item-unknown' && i.message.includes('rope'))).toBe(true); - } finally { - (t as { requiresItem?: string }).requiresItem = backup; - } - }); - - it('накат с неизвестным requiresItem — error', () => { - const h = AREAS.ponds.hazards![0]!; - const backup = h.requiresItem; - (h as { requiresItem?: string }).requiresItem = 'mask'; - try { - const inv = validateReferences(); - expect(inv.some((i) => i.id === 'item-unknown' && i.message.includes('mask'))).toBe(true); - } finally { - (h as { requiresItem?: string }).requiresItem = backup; - } - }); - - it('неизвестный whenFlag источника света — error', () => { - const s = AREAS.zvenets.lighting!.sources![0]!; - const backup = s.whenFlag; - (s as { whenFlag?: string }).whenFlag = 'evening_yes'; - try { - const inv = validateReferences(); - expect(inv.some((i) => i.id === 'flag-unknown' && i.message.includes('evening_yes'))).toBe(true); - } finally { - (s as { whenFlag?: string }).whenFlag = backup; - } - }); - - it('интерьер без return-перехода — error', () => { - const area = AREAS.shop; - const backup = area.transitions; - (area as { transitions: typeof backup }).transitions = []; - try { - const inv = validateReferences(); - expect(inv.some((i) => i.id === 'interior-return' && i.where?.includes('shop'))).toBe(true); - } finally { - (area as { transitions: typeof backup }).transitions = backup; - } - }); -}); - -describe('validateLocations — патрули и источники света', () => { - it('точка патруля вне карты — out-of-bounds', () => { - const e = AREAS.meadows.enemies[2]!; - const backup = e.patrol; - (e as { patrol?: typeof backup }).patrol = { points: [{ x: 19, y: 6 }, { x: 500, y: 0 }], pauseSec: 1 }; - try { - const inv = validateLocations(realMaps()); - expect(inv.some((i) => i.id === 'out-of-bounds' && i.message.includes('патруль#1'))).toBe(true); - } finally { - (e as { patrol?: typeof backup }).patrol = backup; - } - }); - - it('дубль id источника света и источник без at/tiles — error', () => { - const area = AREAS.shop; - const backup = area.lighting; - (area as { lighting?: typeof backup }).lighting = { - ambient: 0x5a5e70, - sources: [ - { id: 'a', at: { x: 1.5, y: 1.5 } }, - { id: 'a', at: { x: 2.5, y: 2.5 } }, - { id: 'b' } - ] - }; - try { - const inv = validateLocations(new Map([['shop', flatMap(10, 10)]])); - expect(inv.filter((i) => i.id === 'light-id').length).toBe(1); - expect(inv.some((i) => i.id === 'light-source-empty' && i.message.includes('b'))).toBe(true); - } finally { - (area as { lighting?: typeof backup }).lighting = backup; - } - }); -}); diff --git a/apps/game/src/data/assetSets.ts b/apps/game/src/data/assetSets.ts deleted file mode 100644 index ae24885..0000000 --- a/apps/game/src/data/assetSets.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { AssetLoader, Texture } from '@rpg/engine'; -import { DECOR, TILES } from './map'; -import { ENEMY_KINDS, type EnemyKindId } from './enemies'; -import type { HeroTextures } from '../systems/PlayerController'; - -/** - * Наборы текстур сцены из загруженных ассетов: соответствие «данные ↔ вьюхи» - * живёт здесь, а не в LocationScene. Имена файлов фиксированы арт-библией. - */ - -/** Текстуры тайлов из загруженных ассетов (id -> Texture) — для IsometricTileMap. */ -export function tileTextures(a: AssetLoader): Map { - return new Map([ - [TILES.GRASS, a.texture('tiles/grass')], - [TILES.GRASS_V1, a.texture('tiles/grass_2')], - [TILES.GRASS_V2, a.texture('tiles/grass_3')], - [TILES.PATH, a.texture('tiles/path')], - [TILES.PATH_V1, a.texture('tiles/path_2')], - [TILES.WATER, a.texture('tiles/water_1')], - [TILES.ASH, a.texture('tiles/ash')], - [TILES.ASH_V1, a.texture('tiles/ash_2')], - [DECOR.TUFT, a.texture('tiles/decor_tuft')], - [DECOR.STONE, a.texture('tiles/decor_stone')], - [DECOR.BALD, a.texture('tiles/decor_bald')], - [TILES.BELLFLOWER, a.texture('tiles/bellflower')], - [TILES.TREE, a.texture('tiles/tree')], - [TILES.TREE_V1, a.texture('tiles/tree_2')], - [TILES.TREE_V2, a.texture('tiles/tree_3')], - [TILES.TOWER, a.texture('tiles/tower')], - [TILES.HOUSE, a.texture('tiles/house')], - [TILES.HOUSE_V1, a.texture('tiles/house_2')], - [TILES.HOUSE_V2, a.texture('tiles/house_3')], - [TILES.HOUSE_V3, a.texture('tiles/house_4')], - [TILES.HOUSE_V4, a.texture('tiles/house_5')], - [TILES.HOUSE_BIG, a.texture('tiles/house_big')], - [TILES.BOULDER, a.texture('tiles/boulder')], - [TILES.BOULDER_2, a.texture('tiles/boulder_2')], - [TILES.BOULDER_3, a.texture('tiles/boulder_3')], - [TILES.BUSH, a.texture('tiles/bush')], - [TILES.BUSH_2, a.texture('tiles/bush_2')], - [TILES.BUSH_3, a.texture('tiles/bush_3')], - [TILES.FLOOR, a.texture('tiles/floor')], - [TILES.WALL, a.texture('tiles/wall')], - [TILES.WELL, a.texture('tiles/well')], - // Акт 2: роща и разъезд. - [TILES.WIRE, a.texture('tiles/wire')], - [TILES.RAIL, a.texture('tiles/rail')], - [TILES.STAIRS, a.texture('tiles/stairs')], - [TILES.MACHINE, a.texture('tiles/machine')] - ]); -} - -/** Кадры сгустков из атласа chars/clumps_sheet: префиксы — из EnemyKindDef.frames. */ -export function enemyTextures(a: AssetLoader): Map { - const out = new Map(); - for (const kind of Object.values(ENEMY_KINDS)) { - const prefix = kind.frames[0]!.replace(/_\d+$/, ''); // clump_crawler_1 -> clump_crawler - out.set(kind.id, a.frames('chars/clumps_sheet.json', prefix) as [Texture, Texture]); - } - return out; -} - -/** Кадры героя из атласа chars/hero_sheet (именованные анимации генератора). */ -export function heroTextures(a: AssetLoader): HeroTextures { - return { - down: a.animation('chars/hero_sheet.json', 'hero_walk_down') as [Texture, Texture], - up: a.animation('chars/hero_sheet.json', 'hero_walk_up') as [Texture, Texture], - side: a.animation('chars/hero_sheet.json', 'hero_walk_side') as [Texture, Texture] - }; -} \ No newline at end of file diff --git a/apps/game/src/data/audio.ts b/apps/game/src/data/audio.ts deleted file mode 100644 index 6bec064..0000000 --- a/apps/game/src/data/audio.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Ключи аудио: спек-синтез sfx (sfxSpecs.ts, WAV не нужен) и файловые лупы - * (ambience из tools/audio/gen.mjs). Отдельный модуль (без `?url`-импортов - * Game) — чтобы валидатор мог сверять ключи контента с реестром в node-тестах. - */ -import { SPEC_SFX } from './sfxSpecs'; - -/** Спек-синтезированные sfx (без WAV, компилирует движок). */ -export const SPEC_SFX_KEYS: readonly string[] = Object.keys(SPEC_SFX); - -/** Файловые sfx (WAV) — пусто: все sfx переведены на спек-синтез. */ -export const SFX_KEYS: readonly string[] = []; - -/** Амбиент-лупы областей (день + ночь: кроссфейд по GameClock). Луга — без амбиента. */ -export const AMBIENCE_KEYS = [ - 'ambience/ponds', - 'ambience/zvenets', - 'ambience/ponds_night', - 'ambience/zvenets_night', - 'ambience/rust_grove', - 'ambience/old_siding' -] as const; - -/** Локальные амбиент-слои (вода, гул, дома) и глобальные (ветер). */ -export const AMBIENCE_LAYER_KEYS = ['ambience/ponds_water', 'ambience/tower_hum', 'ambience/houses', 'ambience/wind'] as const; - -/** Все аудио-ключи игры (валидация контента; префетч только файловых). */ -export const AUDIO_KEYS: readonly string[] = [ - ...SFX_KEYS, - ...SPEC_SFX_KEYS, - ...AMBIENCE_KEYS, - ...AMBIENCE_LAYER_KEYS -]; - -/** - * Файловые ключи аудио области (ambience + локальные слои) — для ленивого - * префетча: сцена загрузки декодирует только стартовую область, остальные - * декодируются при входе (playAmbience/playLoop умеют грузить на месте). - */ -export function areaAudioKeys(area: { - ambience?: string; - ambienceLayers?: readonly { key: string }[]; -}): string[] { - return [area.ambience ?? '', ...(area.ambienceLayers ?? []).map((l) => l.key)].filter((k) => k.length > 0); -} \ No newline at end of file diff --git a/apps/game/src/data/dialogueRules.ts b/apps/game/src/data/dialogueRules.ts deleted file mode 100644 index b52caf6..0000000 --- a/apps/game/src/data/dialogueRules.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { DialogueChoice, DialogueGraph, DialogueNode, Invariant } from '@rpg/engine'; - -/** - * Чистые правила диалоговых графов: реестры — параметры (не импорты), поэтому - * один и тот же checkGraph работает в validate.ts (юнит-тесты, agent:invariants), - * в CLI dry-run и в сервере визуального редактора. Истина одна. - */ - -export interface GraphRefs { - /** Допустимые id: флаги, вары, предметы, custom-эффекты, диалоги, строки. */ - flags: ReadonlySet; - vars: ReadonlySet; - items: ReadonlySet; - customs: ReadonlySet; - strings: ReadonlySet; - /** Куда записать упоминания (для «мёртвых» сущностей реестра) — опционально. */ - usedFlags?: Set; - usedVars?: Set; -} - -/** Результат обхода графа: кто достижим и где подозрительные места. */ -export interface Reachability { - /** Достижимые от start узлы (BFS по next/choices). */ - reachable: string[]; - /** Узлы, в которые нет ни одной ссылки. */ - orphans: string[]; - /** Циклы из узлов без текста и без выборов (раннер обрывает по MAX_STEPS). */ - textlessCycles: string[][]; - /** Достижимые концы без реплики (диалог закончится молча). */ - silentEnds: string[]; -} - -/** Ссылки узла/выбора, которые надо сверить с реестрами. */ -function refsOf(n: DialogueNode | DialogueChoice): { - flags: string[]; - vars: string[]; - items: string[]; - doOps: NonNullable; - textKey?: string; -} { - return { - flags: [...(n.when ?? []), ...(n.whenNot ?? []), ...(n.setFlags ?? []), ...(n.clearFlags ?? [])], - vars: [ - ...(n.whenVar ? [n.whenVar.key] : []), - ...(n.whenVars ?? []).map((c) => c.key), - ...Object.keys(n.setVars ?? {}) - ], - items: [...(n.hasItem ?? [])], - doOps: n.do ?? [], - textKey: n.textKey - }; -} - -function checkSpot( - out: Invariant[], - where: string, - refs: GraphRefs, - spot: string, - n: DialogueNode | DialogueChoice, - next: string | undefined, - graph: DialogueGraph -): void { - const r = refsOf(n); - for (const f of r.flags) { - if (!refs.flags.has(f)) out.push({ id: 'flag-unknown', severity: 'error', message: `${spot}: флаг «${f}» вне реестра FLAGS`, where }); - refs.usedFlags?.add(f); - } - for (const v of r.vars) { - if (!refs.vars.has(v)) out.push({ id: 'var-unknown', severity: 'error', message: `${spot}: вар «${v}» вне реестра VARS`, where }); - refs.usedVars?.add(v); - } - for (const it of r.items) { - if (!refs.items.has(it)) out.push({ id: 'item-unknown', severity: 'error', message: `${spot}: предмет «${it}» вне реестра ITEMS`, where }); - } - for (const op of r.doOps) { - if ((op.kind === 'giveItem' || op.kind === 'takeItem') && (op.id === undefined || !refs.items.has(op.id))) { - out.push({ id: 'do-item-unknown', severity: 'error', message: `${spot}: do[].${op.kind} — предмет «${op.id}» вне реестра ITEMS`, where }); - } - if (op.kind === 'custom' && (op.id === undefined || !refs.customs.has(op.id))) { - out.push({ id: 'do-custom-unknown', severity: 'error', message: `${spot}: do[].custom — имя «${op.id}» вне реестра DIALOGUE_CUSTOM`, where }); - } - } - if (r.textKey !== undefined && !refs.strings.has(r.textKey)) { - out.push({ id: 'string-unknown', severity: 'error', message: `${spot}: textKey «${r.textKey}» вне реестра строк`, where }); - } - if (next !== undefined && !graph.nodes[next]) { - out.push({ id: 'dialogue-next', severity: 'error', message: `${spot}: next «${next}» не существует`, where }); - } -} - -/** Все правила одного графа: ссылки, next, сироты, циклы, тихие концы. */ -export function checkGraph(id: string, graph: DialogueGraph, refs: GraphRefs): Invariant[] { - const out: Invariant[] = []; - const where = `data/dialogue/${id}`; - if (!graph.nodes[graph.start]) { - out.push({ id: 'dialogue-start', severity: 'error', message: `start «${graph.start}» не существует`, where }); - } - for (const [nid, node] of Object.entries(graph.nodes)) { - checkSpot(out, where, refs, `узел «${nid}»`, node, node.next, graph); - (node.choices ?? []).forEach((c, i) => { - checkSpot(out, where, refs, `узел «${nid}», выбор#${i}`, c, c.next, graph); - }); - } - const a = analyzeGraph(graph); - for (const o of a.orphans) { - out.push({ id: 'dialogue-orphan', severity: 'warn', message: `узел «${o}» недостижим`, where }); - } - for (const cycle of a.textlessCycles) { - out.push({ id: 'textless-cycle', severity: 'error', message: `цикл без текста: ${cycle.join(' → ')}`, where }); - } - for (const end of a.silentEnds) { - out.push({ id: 'silent-end', severity: 'warn', message: `диалог может кончиться молча в узле «${end}»`, where }); - } - return out; -} - -/** Исходящие рёбра узла (next + next выборов). */ -function edgesOf(graph: DialogueGraph, id: string): string[] { - const n = graph.nodes[id]; - if (!n) return []; - const out = n.next !== undefined ? [n.next] : []; - for (const c of n.choices ?? []) { - if (c.next !== undefined) out.push(c.next); - } - return out; -} - -/** Обход графа от start: достижимость, сироты, текстовые циклы, тихие концы. */ -export function analyzeGraph(graph: DialogueGraph): Reachability { - const reachable = new Set(); - if (graph.nodes[graph.start]) { - const queue = [graph.start]; - while (queue.length > 0) { - const id = queue.shift()!; - if (reachable.has(id)) continue; - reachable.add(id); - for (const next of edgesOf(graph, id)) { - if (graph.nodes[next] && !reachable.has(next)) queue.push(next); - } - } - } - const orphans = Object.keys(graph.nodes).filter((id) => !reachable.has(id)); - - // Циклы без текста: DFS с окраской; цикл собираем из стека. - const color = new Map(); // 1 — в стеке, 2 — готово - const stack: string[] = []; - const cycles: string[][] = []; - const visit = (id: string): void => { - color.set(id, 1); - stack.push(id); - for (const next of edgesOf(graph, id)) { - if (!graph.nodes[next]) continue; - const c = color.get(next); - if (c === 1) { - const cycle = stack.slice(stack.indexOf(next)); - const textless = cycle.every((cid) => { - const n = graph.nodes[cid]!; - return n.text === undefined && n.textKey === undefined && (n.choices?.length ?? 0) === 0; - }); - if (textless) cycles.push(cycle); - } else if (c === undefined) { - visit(next); - } - } - stack.pop(); - color.set(id, 2); - }; - for (const id of reachable) { - if (!color.has(id)) visit(id); - } - - const silentEnds = [...reachable].filter((id) => { - const n = graph.nodes[id]!; - return n.next === undefined && (n.choices?.length ?? 0) === 0 && n.text === undefined && n.textKey === undefined; - }); - - return { reachable: [...reachable], orphans, textlessCycles: cycles, silentEnds }; -} \ No newline at end of file diff --git a/apps/game/src/data/dialogues.ts b/apps/game/src/data/dialogues.ts deleted file mode 100644 index c9860f8..0000000 --- a/apps/game/src/data/dialogues.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { DialogueGraph } from '@rpg/engine'; -import elderFirst from './dialogues/elder_first.json'; -import elderRepeat from './dialogues/elder_repeat.json'; -import elderHandIn from './dialogues/elder_hand_in.json'; -import traderFirst from './dialogues/trader_first.json'; -import traderRepeat from './dialogues/trader_repeat.json'; -import traderAfter from './dialogues/trader_after.json'; -import clintFirst from './dialogues/clint_first.json'; -import clintRepeat from './dialogues/clint_repeat.json'; -import clintTune from './dialogues/clint_tune.json'; - -/** - * Диалоги NPC как графы (по docs/world.md, акты 1–2). Источник истины — JSON - * в data/dialogues/ (формат 4 пробела + \n): их правит и визуальный редактор, - * и человек руками. Флаги/вары/предметы — строками, сверяются с реестрами - * validate.ts (checkGraph). Мила — безгласная: шёпотом, коротко. - * Ирвин — экономит дыхание. Клинт — ворчит и чинит. - */ -export const DIALOGUES: Record = { - elder_first: elderFirst as unknown as DialogueGraph, - elder_repeat: elderRepeat as unknown as DialogueGraph, - elder_hand_in: elderHandIn as unknown as DialogueGraph, - trader_first: traderFirst as unknown as DialogueGraph, - trader_repeat: traderRepeat as unknown as DialogueGraph, - trader_after: traderAfter as unknown as DialogueGraph, - clint_first: clintFirst as unknown as DialogueGraph, - clint_repeat: clintRepeat as unknown as DialogueGraph, - clint_tune: clintTune as unknown as DialogueGraph -}; \ No newline at end of file diff --git a/apps/game/src/data/dialogues/clint_first.json b/apps/game/src/data/dialogues/clint_first.json deleted file mode 100644 index 0179533..0000000 --- a/apps/game/src/data/dialogues/clint_first.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "start": "grunt", - "nodes": { - "grunt": { - "speaker": "Клинт", - "text": "*не поднимая головы* Не звенит. Ржавый весь. Уходи.", - "next": "squint" - }, - "squint": { - "speaker": "Клинт", - "text": "*прищуривается* Звонарь?... От Ирвина? Того, что книгу ведёт?", - "next": "half_tone" - }, - "half_tone": { - "speaker": "Клинт", - "text": "Скажи ему: колокол башни — на пол-тона ниже, чем в книге. Книге веры нет, если уши молчат.", - "next": "work" - }, - "work": { - "speaker": "Клинт", - "text": "А у меня тут своя беда: резонатор ржавый стоит. Молчит, скотина, третий месяц.", - "setFlags": ["met_clint"], - "next": "offer" - }, - "offer": { - "speaker": "Клинт", - "text": "Деревья в Ржавой роще — за восточной околицей, — как механизмы, кабелями оплетены. Прозвени их: кто отзовётся — тот и тон даст.", - "setFlags": ["quest_resonator_taken"], - "next": "reply" - }, - "reply": { - "speaker": "Звонарь", - "text": "*кивает*" - } - } -} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/clint_repeat.json b/apps/game/src/data/dialogues/clint_repeat.json deleted file mode 100644 index c2694d8..0000000 --- a/apps/game/src/data/dialogues/clint_repeat.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "start": "grumble", - "nodes": { - "grumble": { - "speaker": "Клинт", - "text": "Опять ты. Хорошо. Слушай: у Машины есть голос — низкий, через пепел. Все её боятся, а зря.", - "setFlags": ["machine_hook"], - "next": "hint" - }, - "hint": { - "speaker": "Клинт", - "text": "Дышит она в такт. Кто найдёт верный звон — уложит спать, как ребёнка.", - "next": "half_tone" - }, - "half_tone": { - "speaker": "Клинт", - "text": "И про пол-тона не забудь. Книга врёт, уши — нет.", - "end": true - } - } -} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/clint_tune.json b/apps/game/src/data/dialogues/clint_tune.json deleted file mode 100644 index e0a64fc..0000000 --- a/apps/game/src/data/dialogues/clint_tune.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "start": "count", - "nodes": { - "count": { - "speaker": "Клинт", - "text": "Три тона, слышишь? Соберёшь — поддел резонатор под них. Мои руки уже не слышат.", - "next": "accept" - }, - "accept": { - "setFlags": ["quest_resonator_done"], - "do": [{ "kind": "custom", "id": "tune_resonator" }], - "next": "ring" - }, - "ring": { - "speaker": "Звонарь", - "text": "Пусть гудит. Пол-тона — тоже тон." - } - } -} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/elder_first.json b/apps/game/src/data/dialogues/elder_first.json deleted file mode 100644 index 7fddc97..0000000 --- a/apps/game/src/data/dialogues/elder_first.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "start": "greet", - "nodes": { - "greet": { - "speaker": "Старейшина Ирвин", - "text": "Вернулся. Хорошо. Слышал гул на закате? Это пепел дышит у прудов.", - "next": "ask" - }, - "ask": { - "speaker": "Старейшина Ирвин", - "text": "Три лунных колокольчика цветут у Серых прудов — за северо-западным краем лугов, у старого колодца. Если не собрать до выдоха — задохнутся.", - "next": "task" - }, - "task": { - "speaker": "Старейшина Ирвин", - "text": "Собери. Посади здесь, на лугу. Поляна без цветов — поляна без завтра.", - "choices": [ - { - "text": "Прозвоню дорогу до прудов и вернусь до темноты.", - "next": "give" - } - ] - }, - "give": { - "setFlags": ["met_elder", "quest_bells_taken"], - "do": [{ "kind": "giveItem", "id": "clock" }, { "kind": "giveItem", "id": "map_scroll" }], - "speaker": "Старейшина Ирвин", - "text": "Держи старые медные часы — стрелка не врёт, темнота по ней приходит. И набросок окрестностей: я чертил его для звонарей. Тропы помнит, дома помнит.", - "next": "ring" - }, - "ring": { - "speaker": "Звонарь", - "text": "Прозвоню дорогу до прудов и вернусь до темноты." - } - } -} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/elder_hand_in.json b/apps/game/src/data/dialogues/elder_hand_in.json deleted file mode 100644 index 8b5a334..0000000 --- a/apps/game/src/data/dialogues/elder_hand_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "start": "count", - "nodes": { - "count": { - "speaker": "Старейшина Ирвин", - "text": "Три цветка. Живые. Сажай у тропы здесь, в Звенце. Серая земля примет.", - "next": "accept" - }, - "accept": { - "setFlags": ["quest_bells_done"], - "do": [{ "kind": "custom", "id": "plant_flowers" }], - "next": "ring" - }, - "ring": { - "speaker": "Звонарь", - "text": "Пусть гудят. Это твой голос, Ирвин, — теперь в земле." - } - } -} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/elder_repeat.json b/apps/game/src/data/dialogues/elder_repeat.json deleted file mode 100644 index f0b962e..0000000 --- a/apps/game/src/data/dialogues/elder_repeat.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "start": "waiting", - "nodes": { - "waiting": { - "speaker": "Старейшина Ирвин", - "text": "Цветы ждут у прудов, звонарь. А пепел не ждёт." - } - } -} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/trader_after.json b/apps/game/src/data/dialogues/trader_after.json deleted file mode 100644 index 9943f91..0000000 --- a/apps/game/src/data/dialogues/trader_after.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "start": "listen", - "nodes": { - "listen": { - "speaker": "Торговка Мила", - "text": "*смотрит под ноги* ...Слышишь? Гул снизу. Это разъезд. Это Машина дышит.", - "setFlags": ["act2_hook"], - "end": true - } - } -} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/trader_first.json b/apps/game/src/data/dialogues/trader_first.json deleted file mode 100644 index fff3661..0000000 --- a/apps/game/src/data/dialogues/trader_first.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "start": "stop", - "nodes": { - "stop": { - "speaker": "Торговка Мила", - "text": "*звонит ручным колокольчиком дважды* ...Стой.", - "next": "warn" - }, - "warn": { - "speaker": "Торговка Мила", - "text": "У прудов... свежий накат. Дышать поверх — голос отдашь. Как я отдала.", - "next": "gift" - }, - "gift": { - "speaker": "Торговка Мила", - "text": "Держи вощёное полотно. На губы. И звони тихо — пепел не буди.", - "next": "give_cloth" - }, - "give_cloth": { - "setFlags": ["met_mila", "got_cloth"], - "do": [{ "kind": "giveItem", "id": "cloth" }], - "next": "reply" - }, - "reply": { - "speaker": "Звонарь", - "text": "Спасибо, Мила. Верну и полотно, и голос — твой точно." - } - } -} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/trader_repeat.json b/apps/game/src/data/dialogues/trader_repeat.json deleted file mode 100644 index 1e6ce5c..0000000 --- a/apps/game/src/data/dialogues/trader_repeat.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "start": "quiet", - "nodes": { - "quiet": { - "speaker": "Торговка Мила", - "text": "*колокольчик один раз* ...Звони тихо. Пепел просыпается от гулкого.", - "next": "motes_note" - }, - "motes_note": { - "speaker": "Торговка Мила", - "text": "*замечает искру в сумке* ...Моты? Серая земля с искрой — редкость. Принеси три — поменяю на соль.", - "whenVar": { "key": "motes", "op": "ge", "value": 1 } - } - } -} \ No newline at end of file diff --git a/apps/game/src/data/effects.ts b/apps/game/src/data/effects.ts deleted file mode 100644 index 08e7463..0000000 --- a/apps/game/src/data/effects.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Реестр имён сюжетных эффектов диалогов (как FLAGS/VARS — валидатор ловит - * опечатки). Имена сцена реализует в своём EffectSink; give/take/sound/toast - * — общие и сюда не входят. - */ -export const DIALOGUE_CUSTOM = { - plant_flowers: 'plant_flowers', // сдача «Трёх цветков»: кат-сцена посадки - tune_resonator: 'tune_resonator', // сдача резонатора: кат-сцена настройки - lull_machine: 'lull_machine' // Старая Машина: кат-сцена правильного звона -} as const; - -export type DialogueCustomId = keyof typeof DIALOGUE_CUSTOM; \ No newline at end of file diff --git a/apps/game/src/data/enemies.ts b/apps/game/src/data/enemies.ts deleted file mode 100644 index 3563e50..0000000 --- a/apps/game/src/data/enemies.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Враги демо-боя: «пепельные сгустки» — оживший пепел, просыпается от гулкого звука. - * Чистые данные (без Pixi) — используются боевой системой и тестами. - */ - -export type EnemyKindId = 'crawler' | 'spitter' | 'heavy'; - -export interface EnemyKindDef { - id: EnemyKindId; - /** Здоровье. */ - hp: number; - /** Скорость, мировых юнитов/сек (1 юнит = 1 тайл). */ - speed: number; - /** Радиус тела для попаданий, юнитов. */ - radius: number; - /** Дистанция начала атаки, юнитов. */ - attackRange: number; - /** Урон атаки. */ - attackDamage: number; - /** Пауза между атаками, сек. */ - attackCooldown: number; - /** Замах до удара, сек (у ползуна 0 — контактный урон). */ - windup: number; - /** Восстановление после удара, сек. */ - recover: number; - /** Плевун держит дистанцию (юнитов) — отходит при сближении. */ - keepDistance?: number; - /** Дроп «пепельной пыли». */ - motes: number; - /** Кадры из атласа chars/clumps_sheet.json. */ - frames: [string, string]; - // --- ИИ (необязательные: withDefaults подставляет разумные значения) --- - - /** Дистанция обнаружения героя (при прямой видимости), юнитов. */ - aggroRange?: number; - /** Секунд без прямой видимости, после которых враг отступает к дому. */ - deaggroSec?: number; - /** Ниже этой доли hp — отступает (0 — не отступает никогда). */ - fleeBelowHpFraction?: number; - /** При отступлении убегает, пока герой ближе этого радиуса, юнитов. */ - fleeRange?: number; - /** Радиус слуха шума (уровень >= 0.35), юнитов. */ - hearRadius?: number; - /** Секунд настороженности без новой информации, после которых враг возвращается. */ - warySec?: number; -} - -/** Патруль отдельного спавна: тайлы маршрута (проходится туда-обратно) и пауза на точке. */ -export interface EnemyPatrolDef { - points: { x: number; y: number }[]; - pauseSec: number; -} - -/** Дефолты полей ИИ (контент задаёт только отличия). */ -export function withDefaults(kind: EnemyKindDef): EnemyKindDef { - return { - ...kind, - aggroRange: kind.aggroRange ?? 4, - deaggroSec: kind.deaggroSec ?? 2.5, - fleeBelowHpFraction: kind.fleeBelowHpFraction ?? 0, - fleeRange: kind.fleeRange ?? 4, - hearRadius: kind.hearRadius ?? 5, - warySec: kind.warySec ?? 2.5 - }; -} - -export const ENEMY_KINDS: Record = { - crawler: { - id: 'crawler', - hp: 3, - speed: 0.7, - radius: 0.2, - attackRange: 0.4, - attackDamage: 1, - attackCooldown: 0.8, - windup: 0, - recover: 0.3, - motes: 1, - frames: ['clump_crawler_1', 'clump_crawler_2'], - // Сторожит пепел, шатаясь между кустов; слышит шаги вблизи. - hearRadius: 3.5 - }, - spitter: { - id: 'spitter', - hp: 2, - speed: 0.55, - radius: 0.2, - attackRange: 3.0, - attackDamage: 1, - attackCooldown: 2.2, - windup: 0.35, - recover: 0.4, - keepDistance: 2.25, - motes: 1, - frames: ['clump_spitter_1', 'clump_spitter_2'], - // Обстреливает издали и не отступает — стоит насмерть. - aggroRange: 5 - }, - heavy: { - id: 'heavy', - hp: 8, - speed: 0.45, - radius: 0.3, - attackRange: 0.8, - attackDamage: 2, - attackCooldown: 2, - windup: 0.6, - recover: 0.5, - motes: 3, - frames: ['clump_heavy_1', 'clump_heavy_2'], - // Крупный сгусток: глуховат, но с трудом убитый — отступает при малом hp. - hearRadius: 4, - fleeBelowHpFraction: 0.35, - fleeRange: 4.5, - warySec: 3 - } -}; - -/** Точки спавна и патрули врагов задаются в data/locations.ts (AREAS[*].enemies). */ diff --git a/apps/game/src/data/ids.ts b/apps/game/src/data/ids.ts deleted file mode 100644 index 5ce34dd..0000000 --- a/apps/game/src/data/ids.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Реестры строковых сущностей сюжета: флаги и вары GameState. - * Единственная точка правды: контент (диалоги, интерактивы, квесты, переходы) - * и код сцены ссылаются на них через константы, а data/validate.ts сверяет - * каждое строковое упоминание с реестром — опечатка падает в тестах и - * agent:invariants, а не молча создаёт новый флаг. - */ - -/** - * Флаги сюжета: ключ = значение = id (строка в GameState/сейве). - * Знакомства ставятся самими графами диалогов (dialogueFirst), а не кодом - * сцены; старые ключи metElder/metTrader мигрируют (data/migrations.ts). - */ -export const FLAGS = { - /** Познакомились со старейшиной (диалог elder_first). */ - met_elder: 'met_elder', - /** Ирвин дал задание о колокольчиках (диалог elder_first; doneFlag стадии 1). */ - quest_bells_taken: 'quest_bells_taken', - /** Колокольчики собраны и посажены (диалог elder_hand_in; doneFlag стадии 2). */ - quest_bells_done: 'quest_bells_done', - /** Познакомились с Милой (диалог trader_first). */ - met_mila: 'met_mila', - /** Мила дала вощёное полотно (диалог trader_first). */ - got_cloth: 'got_cloth', - /** Колокол башни звенел (интерактив bell_rope). */ - rang_tower: 'rang_tower', - /** Прочитана записка в доме Ирвина (интерактив note_elder). */ - read_note: 'read_note', - /** Подсказка «как собирать колокольчики» уже показана (вход в пруды). */ - hint_bells: 'hint_bells', - /** Акт 2 начался: Мила указала на разъезд («Машина дышит», trader_after). */ - act2_hook: 'act2_hook', - /** Познакомились с Клинтом (диалог clint_first; doneFlag стадии 1 резонатора). */ - met_clint: 'met_clint', - /** Клинт просит найти верный звон для резонатора (clint_first; стадия 2). */ - quest_resonator_taken: 'quest_resonator_taken', - /** Резонатор настроен (диалог clint_tune; doneFlag стадии 2). */ - quest_resonator_done: 'quest_resonator_done', - /** Клинт указал на Машину (clint_repeat; doneFlag эпилога резонатора). */ - machine_hook: 'machine_hook', - /** Дерево-механизм №1 прозвенело (интерактив tone_tree_1). */ - tone_tree_1: 'tone_tree_1', - /** Дерево-механизм №2 прозвенело (интерактив tone_tree_2). */ - tone_tree_2: 'tone_tree_2', - /** Дерево-механизм №3 прозвенело (интерактив tone_tree_3). */ - tone_tree_3: 'tone_tree_3', - /** Старая Машина уложена спать правильным звоном (интерактив old_machine). */ - machine_lulled: 'machine_lulled' -} as const; - -export type FlagId = keyof typeof FLAGS; - -/** Вары прогресса (числовые; значение живёт в GameState.vars): ключ = id. */ -export const VARS = { - /** Пепельные моты в сумке (подбор наростов, победы над врагами). */ - motes: 'motes', - /** Собранные лунные колокольчики (квест «Три цветка»). */ - flowers: 'flowers', - /** Здоровье героя между входами в локацию. */ - hp: 'hp', - /** Побеждённые враги (отладка/статистика). */ - kills: 'kills', - /** Смерти героя (статистика). */ - deaths: 'deaths', - /** Резонансы башни (кат-сцена, статистика). */ - resonances: 'resonances', - /** Собранные тоны деревьев-механизмов (квест резонатора). */ - tones: 'tones' -} as const; - -export type VarId = keyof typeof VARS; - -/** Синтезируемый флаг одноразового интерактива (once): used:. */ -export function usedFlag(id: string): string { - return `used:${id}`; -} \ No newline at end of file diff --git a/apps/game/src/data/interactables.ts b/apps/game/src/data/interactables.ts deleted file mode 100644 index 2275b2b..0000000 --- a/apps/game/src/data/interactables.ts +++ /dev/null @@ -1,396 +0,0 @@ -import type { Vec2 } from '@rpg/engine'; -import type { ItemId } from './items'; -import type { AreaId } from './locations'; -import { FLAGS, VARS } from './ids'; -import { QUEST_TONES, QUEST_TONE_TILES } from './quests'; - -/** - * Интерактивные объекты области: сундуки, очаги, записки, прилавки, столбы - * у троп, подборные сгустки, рычаги. Первая подходящая реакция (по `when`) - * срабатывает; эффекты применяет рантайм systems/Interactables.ts через sink. - */ - -/** Условие реакции: все указанные поля должны выполняться. */ -export interface InteractWhen { - /** Флаг поднят. */ - flag?: string; - /** Флаг не поднят. */ - notFlag?: string; - /** Вар не меньше значения. */ - varAtLeast?: { id: string; value: number }; -} - -/** Реакция объекта: текст (тост) + необязательные эффекты. */ -export interface InteractResponse { - /** Первая реакция с выполненным условием срабатывает (без when — всегда). */ - when?: InteractWhen; - text: string; - setFlags?: string[]; - clearFlags?: string[]; - setVar?: { id: string; value: number }; - /** Прибавить к вару (подбор мотов: +1 за каждый нарост). */ - addVar?: { id: string; by: number }; - /** Выдать предмет (в сумку). */ - gives?: ItemId; - /** Сколько штук gives (по умолчанию 1). */ - count?: number; - /** Ключ звука (sfx/...). */ - sound?: string; - /** Тост вместо text (после эффектов); с varKey — дописывает « (N/goal)». */ - toast?: { text: string; varKey?: string; goal?: number }; - /** Сюжетный эффект (DIALOGUE_CUSTOM): сцена запускает катсцену/событие. */ - custom?: string; -} - -/** Вьюха объекта из ассетов: если задана — рисуется вместо плейсхолдера Graphics. */ -export interface InteractableView { - /** Ключ текстуры в ассетах (tiles/…, chars/…). */ - tex: string; - /** Якорь по Y в пикселях кадра от низа (по умолчанию низ спрайта). */ - anchorY?: number; -} - -/** Объект мира, с которым герой взаимодействует кликом. */ -export interface InteractableDef { - id: string; - /** Позиция в мировых юнитах (центр тайла = координата + 0.5). */ - pos: Vec2; - /** Тайл привязки (floor от pos; вычисляется — в контенте не писать). */ - tile: { x: number; y: number }; - kind: 'examine' | 'pickup' | 'container' | 'lever' | 'signpost'; - /** Вьюха из ассетов (без неё — плейсхолдер Graphics по kind). */ - view?: InteractableView; - /** Подпись при близости (и в снапшоте). */ - label?: string; - /** Одноразовый: помечается флагом used: и больше не реагирует. */ - once?: boolean; - /** Без предмета — отказ с lockedText. */ - requiresItem?: ItemId; - lockedText?: string; - responses: InteractResponse[]; -} - -/** Что резолверу нужно знать о герое и мире (подставляется из GameState/сумки). */ -export interface InteractCtx { - hasFlag(flag: string): boolean; - hasItem(id: ItemId): boolean; - getVar(id: string): number; - /** Одноразовый объект уже использован (флаг used:). */ - used(id: string): boolean; -} - -/** Результат взаимодействия: реакция / отказ (заперто) / ничего (пусто). */ -export type InteractPick = - | { ok: true; response: InteractResponse } - | { ok: false; lockedText: string } - | null; - -/** Условие when выполнено (все указанные поля). */ -function matches(when: InteractWhen | undefined, ctx: InteractCtx): boolean { - if (!when) return true; - if (when.flag !== undefined && !ctx.hasFlag(when.flag)) return false; - if (when.notFlag !== undefined && ctx.hasFlag(when.notFlag)) return false; - if (when.varAtLeast && ctx.getVar(when.varAtLeast.id) < when.varAtLeast.value) return false; - return true; -} - -/** - * Чистый выбор реакции: первая подходящая по `when`; одноразовые объекты - * после использования молчат (null); requiresItem без предмета — отказ. - */ -export function resolveInteractable(def: InteractableDef, ctx: InteractCtx): InteractPick { - if (def.requiresItem && !ctx.hasItem(def.requiresItem)) { - return { ok: false, lockedText: def.lockedText ?? 'Ничего не выйдет.' }; - } - if (def.once && ctx.used(def.id)) return null; - const response = def.responses.find((r) => matches(r.when, ctx)); - if (!response) return null; - return { ok: true, response }; -} - -/** Контент по областям (см. docs/world.md): интерьеры и улицы (pos — первичен). */ -const INTERACTABLES_RAW: Partial[]>> = { - // --- луга: столбы у троп и подборные пепельные моты (зачаток экономики) --- - meadows: [ - { - id: 'sign_meadows_east', - pos: { x: 48.5, y: 27.5 }, - kind: 'signpost', - view: { tex: 'tiles/signpost' }, - label: 'Столб у тропы', - responses: [ - { - text: '«Звенец» — стёрто, но различимо. Дальше на восток: иди на звон.', - sound: 'sfx/note' - } - ] - }, - { - id: 'sign_meadows_west', - pos: { x: 2.5, y: 3.5 }, - kind: 'signpost', - view: { tex: 'tiles/signpost' }, - label: 'Столб у тропы', - responses: [ - { - text: '«Серые пруды» — стрелка на северо-запад. Ниже приписка: «туман лжёт».', - sound: 'sfx/note' - } - ] - }, - { - id: 'mote_1', - pos: { x: 13.5, y: 28.5 }, - kind: 'pickup', - view: { tex: 'tiles/mote' }, - label: 'Пепельный мот', - once: true, - responses: [ - { - text: 'Сгусток серой земли с тёплой искрой. Мот — то, ради чего сажают колокольчики.', - gives: 'mote', - addVar: { id: VARS.motes, by: 1 }, - sound: 'sfx/mote_pickup' - } - ] - }, - { - id: 'mote_2', - pos: { x: 16.5, y: 38.5 }, - kind: 'pickup', - view: { tex: 'tiles/mote' }, - label: 'Пепельный мот', - once: true, - responses: [ - { - text: 'Ещё один мот. Земля здесь отдаёт их неохотно.', - gives: 'mote', - addVar: { id: VARS.motes, by: 1 }, - sound: 'sfx/mote_pickup' - } - ] - }, - { - id: 'mote_3', - pos: { x: 16.5, y: 20.5 }, - kind: 'pickup', - view: { tex: 'tiles/mote' }, - label: 'Пепельный мот', - once: true, - responses: [ - { - text: 'Третий мот за день. Сумка тихо позвякивает.', - gives: 'mote', - addVar: { id: VARS.motes, by: 1 }, - sound: 'sfx/mote_pickup' - } - ] - } - ], - // --- роща (акт 2): деревья-механизмы для звона и резонатор Клинта --- - // Реакция дерева-механизма: до квеста молчит; с квестом — звон и тон - // (свой флаг на дерево — addVar без повтора); после настройки гудит в лад. - rust_grove: [ - ...(['tone_tree_1', 'tone_tree_2', 'tone_tree_3'] as const).map((id, i): Omit => { - const [tx, ty] = QUEST_TONE_TILES[i]; - return { - id, - pos: { x: tx + 0.5, y: ty + 0.5 }, - kind: 'examine', - view: { tex: 'tiles/tone_tree' }, - label: 'Дерево-механизм', - responses: [ - { - when: { flag: FLAGS.quest_resonator_done }, - text: 'Дерево гудит в лад с резонатором. Кабели тёплые.', - sound: 'sfx/bell_low' - }, - { - when: { notFlag: FLAGS.quest_resonator_taken }, - text: 'Кабели оплетают ствол, но дерево молчит. Не сейчас.', - sound: 'sfx/ash_hiss' - }, - { - when: { notFlag: FLAGS[id] }, - text: 'Звон колокольчика — и кабели отвечают: дерево отдаёт свой тон.', - toast: { text: 'Тон', varKey: VARS.tones, goal: QUEST_TONES }, - setFlags: [FLAGS[id]], - addVar: { id: VARS.tones, by: 1 }, - sound: 'sfx/bell_hit' - }, - { text: 'Это дерево уже прозвенело. Тон унесён.', sound: 'sfx/note' } - ] - }; - }), - { - id: 'clint_resonator', - pos: { x: 24.5, y: 16.5 }, - kind: 'lever', - view: { tex: 'tiles/resonator' }, - label: 'Резонатор Клинта', - responses: [ - { - when: { flag: FLAGS.quest_resonator_done }, - text: 'Резонатор держит тон ровно. Клинт поддел его под пол-тона.', - sound: 'sfx/bell_low' - }, - { - text: 'Медные колокола на стальной раме. Ржавый, молчит. Клинту нужен верный звон.', - sound: 'sfx/ash_hiss' - } - ] - } - ], - ponds: [ - { - id: 'sign_ponds', - pos: { x: 5.5, y: 3.5 }, - kind: 'signpost', - view: { tex: 'tiles/signpost' }, - label: 'Столб у берега', - responses: [ - { - text: '«Не дышать накатом» — буквы вросли в ржавчину. Кто-то дописал: «голос дороже соли».', - sound: 'sfx/note' - } - ] - } - ], - zvenets: [ - { - id: 'sign_zvenets', - pos: { x: 3.5, y: 19.5 }, - kind: 'signpost', - view: { tex: 'tiles/signpost' }, - label: 'Столб у входа', - responses: [ - { - text: '«Звенец. Мастерская мира». Ниже чужим почерком: «кузня чинит резонаторы, лавка меняет соль».', - sound: 'sfx/note' - } - ] - }, - { - id: 'bell_rope', - pos: { x: 27.5, y: 13.5 }, - kind: 'lever', - view: { tex: 'tiles/bell_rope' }, - label: 'Верёвка колокола', - responses: [ - { - when: { notFlag: FLAGS.rang_tower }, - text: 'Верёвка в ладони. Колокол качнулся — низкий тон ушёл в землю. Кто-то услышит.', - setFlags: [FLAGS.rang_tower], - sound: 'sfx/bell_low' - }, - { - text: 'Колокол качнулся снова. Звон тот же: пол-тона ниже, чем в звонной книге.', - sound: 'sfx/bell_low' - } - ] - } - ], - house_elder: [ - { - id: 'chest_elder', - pos: { x: 3.5, y: 3.5 }, - kind: 'container', - view: { tex: 'tiles/chest' }, - label: 'Сундук', - once: true, - responses: [ - { - text: 'В сундуке — запас вощёного полотна.', - gives: 'cloth', - sound: 'sfx/chest_open' - } - ] - }, - { - id: 'hearth_elder', - pos: { x: 8.5, y: 0.5 }, - kind: 'examine', - view: { tex: 'tiles/hearth' }, - label: 'Очаг', - responses: [ - { - when: { flag: FLAGS.quest_bells_done }, - text: 'Очаг горит ровнее. Пепел отступает — и тепло возвращается.', - sound: 'sfx/bell_low' - }, - { text: 'В очаге тлеют щепки. Тепло — редкость теперь.', sound: 'sfx/ash_hiss' } - ] - }, - { - id: 'note_elder', - pos: { x: 10.5, y: 4.5 }, - kind: 'examine', - view: { tex: 'tiles/note' }, - label: 'Записка', - once: true, - responses: [ - { - text: '«Тоны уходят вниз. Если колокол смолкнет — не буди Молчание».', - setFlags: [FLAGS.read_note], - sound: 'sfx/note' - } - ] - } - ], - shop: [ - { - id: 'counter_shop', - pos: { x: 5.5, y: 3.5 }, - kind: 'examine', - view: { tex: 'tiles/counter' }, - label: 'Прилавок', - responses: [ - { - when: { flag: FLAGS.quest_bells_done }, - text: 'На прилавке — соль и цветы в плошке. Поляна гудит — Мила звонит чаще.', - sound: 'sfx/bell_hit' - }, - { - text: 'Соль, вощёное полотно, вода в бутлях. Цен Мила не пишет — звонит.', - sound: 'sfx/bell_hit' - } - ] - } - ], - // --- зал Машины (финал акта 2): усыпить её правильным звоном --- - machine_hall: [ - { - id: 'old_machine', - pos: { x: 13.5, y: 1.5 }, - kind: 'lever', - view: { tex: 'tiles/machine' }, - label: 'Старая Машина', - responses: [ - { - when: { flag: FLAGS.machine_lulled }, - text: 'Машина спит. Угольки в устье погасли — впервые за годы.', - sound: 'sfx/bell_low' - }, - { - when: { notFlag: FLAGS.quest_resonator_done }, - text: 'Устье дышит жаром сквозь рёбра. Резонатор не настроен — Машине не спеть.', - sound: 'sfx/ash_hiss' - }, - { - // Правильный звон: настроенный резонатор + верный тон. - text: 'Резонатор ловит дыхание. Пол-тона вниз — и Машина отвечает сном.', - setFlags: [FLAGS.machine_lulled], - custom: 'lull_machine', - sound: 'sfx/bell_low' - } - ] - } - ] -}; -/** Тайл привязки вычисляется из pos при загрузке модуля. */ -export const INTERACTABLES: Partial> = Object.fromEntries( - Object.entries(INTERACTABLES_RAW).map(([area, defs]) => [ - area, - defs.map((d) => ({ ...d, tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } })) - ]) -); diff --git a/apps/game/src/data/items.ts b/apps/game/src/data/items.ts deleted file mode 100644 index 066ec08..0000000 --- a/apps/game/src/data/items.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Реестр предметов (контент по docs/world.md). Id — стабильные ключи сейва. - * Описания — стиль сеттинга: кратко, меланхолично. - */ -export interface ItemDef { - id: ItemId; - name: string; - desc: string; -} - -export type ItemId = 'cloth' | 'bellflower' | 'salt' | 'water_flask' | 'mote' | 'clock' | 'map_scroll'; - -export const ITEMS: Record = { - clock: { - id: 'clock', - name: 'Медные часы', - desc: 'Старые, с одной стрелкой. Ирвин: «Стрелка не врёт — темнота по ней приходит».' - }, - cloth: { - id: 'cloth', - name: 'Вощёное полотно', - desc: 'Маска от наката. Дышать поверх свежего пепла — потерять голос.' - }, - bellflower: { - id: 'bellflower', - name: 'Лунный колокольчик', - desc: 'Трава-возвращенец. Вытягивает пепел из земли. Посаженный — надежда.' - }, - salt: { - id: 'salt', - name: 'Соль', - desc: 'Край держится на обмене. Соль здесь дороже золота.' - }, - water_flask: { - id: 'water_flask', - name: 'Вода в медном бутле', - desc: 'Мутная, но чистая. Пруды отдают воду неохотно.' - }, - mote: { - id: 'mote', - name: 'Пепельный мот', - desc: 'Сгусток серой земли с тёплой искрой — то, что колокольчик вытянул из пепла. Мила меняет моты на товар.' - }, - map_scroll: { - id: 'map_scroll', - name: 'Карта окрестностей', - desc: 'Набросок Ирвина: тропы, дома, дальние края. Местность тут меняется быстрее чернил.' - } -}; - -/** Безопасное имя по id (незнакомый id — сам id). */ -export function itemName(id: string): string { - return ITEMS[id as ItemId]?.name ?? id; -} - -/** - * Строка от «применения» предмета (юза́бельные — только часы). Чистая функция: - * null — предмет без эффекта (сумка молчит). label — текущее время сумки - * (в сумке время заморожено — LocationScene не тикается). - */ -export function useItemLine(id: string, ctx: { label: string }): string | null { - return id === 'clock' ? `Время: ${ctx.label}` : null; -} \ No newline at end of file diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts deleted file mode 100644 index d56d352..0000000 --- a/apps/game/src/data/locations.ts +++ /dev/null @@ -1,503 +0,0 @@ -import type { NpcDef } from './npcs'; -import type { EnemyKindId, EnemyPatrolDef } from './enemies'; -import { NPCS } from './npcs'; -import type { ItemId } from './items'; -import { TILES } from './map'; -import { FLAGS } from './ids'; -import { INTERACTABLES, type InteractableDef } from './interactables'; - -/** - * Области демо-среза (улица и интерьер — общий контекст). Карты хранятся - * файлами assets/maps/.map (формат rpg-map, RLE) и загружаются через - * parseMap в BootScene. - */ -export type AreaId = - | 'meadows' - | 'ponds' - | 'zvenets' - | 'rust_grove' - | 'old_siding' - | 'machine_hall' - | 'house_elder' - | 'shop'; - -/** Куда ведёт переход: в другую область (с точкой входа) или назад, откуда вошли. */ -export type TransitionTarget = - | { kind: 'area'; area: AreaId; entry: { x: number; y: number } } - | { kind: 'return' }; - -/** - * Единый механизм переходов: выходы между локациями (step), двери/порталы/ - * колодцы (click). Первая подходящая по тайлу и триггеру — срабатывает. - */ -export interface TransitionDef { - /** Тайл-триггер. */ - tile: { x: number; y: number }; - /** Подпись для подсказки при близости (позже) и снапшота. */ - label?: string; - target: TransitionTarget; - /** step — наступил (по умолчанию), click — клик по тайлу (двери, порталы). */ - trigger?: 'step' | 'click'; - /** Условия: не выполнены — переход не срабатывает, герой получает lockedText. */ - requiresFlag?: string; - requiresItem?: ItemId; - lockedText?: string; - /** Длительность fade (сек), по умолчанию 0.4. */ - duration?: number; -} - -/** Зона опасности (накат): герой без нужного предмета еле идёт и рискует голосом. */ -export interface HazardDef { - name: string; - /** Тайлы низины (свежий пласт пепла). */ - tiles: { x: number; y: number }[]; - /** Без чего нельзя дышать здесь. */ - requiresItem: ItemId; -} - -/** Локальный амбиент-слой: громкость из дистанции до ближайшего тайла/точки. */ -export interface AmbienceLayerDef { - /** Ключ лупа (Game.AUDIO_KEYS). */ - key: string; - /** Тайлы-источники (иды из TILES); пусто/нет — точки positions или точка at. */ - tiles?: number[]; - /** Явные точки-источники в тайлах (например, тайлы зоны наката). */ - positions?: { x: number; y: number }[]; - /** Точка-источник в тайлах (центр башни и т.п.). */ - at?: { x: number; y: number }; - /** Радиус слышимости в юнитах (за ним слой молчит); нет at/tiles — глобальный слой. */ - radius?: number; - /** Громкость (для глобального — константа; по умолчанию 0.5). */ - volume?: number; -} - -/** Источник света: точка в тайлах или все тайлы заданных id; радиус в юнитах. */ -export interface LightSourceDef { - id: string; - /** Центр источника в тайлах (центр ромба: +0.5); для at/tiles — одно из двух. */ - at?: { x: number; y: number }; - /** Альтернатива: свет на каждом тайле этого id (позиции из карты). */ - tiles?: number[]; - /** Радиус в юнитах (1 юнит = тайл; по умолчанию 3). */ - radius?: number; - /** Цвет свечения (по умолчанию 0xf2b45a — тёплый F1). */ - color?: number; - /** Яркость (по умолчанию 0.9). */ - intensity?: number; - /** Амплитуда мерцания 0..1 (по умолчанию 0 — ровный). */ - flicker?: number; - /** Источник активен только при наличии (whenFlag) / отсутствии (notFlag) флага. */ - whenFlag?: string; - notFlag?: string; - /** Ночной источник: горит при факторе ночи ≥ 0.5 (вечер/ночь по часам). */ - whenNight?: boolean; -} - -/** Освещение области: ambient (multiply-цвет) + статические источники. */ -export interface LightingDef { - /** 0xffffff — не трогает сцену; тёмный/цветной — тон и затемнение сцены. */ - ambient?: number; - /** Ночной ambient (когда стоит флаг вечера); нет — ночь не меняет тон. */ - nightAmbient?: number; - /** Закатный тон (золотой час); нет — движковый DEFAULT_DUSK_COLOR. */ - duskColor?: number; - sources?: LightSourceDef[]; -} - -export interface AreaDef { - id: AreaId; - name: string; - /** Амбиент области (кроссфейд при переходе, ключ из Game.AUDIO_KEYS); нет — тишина. */ - ambience?: string; - /** Музыкальная тема области (ключ из data/music.ts, спек-музыка); нет — тишина. */ - theme?: string; - /** Стартовый тайл (новая игра / респаун / фолбэк return). */ - spawn: { x: number; y: number }; - npcs: NpcDef[]; - enemies: { kind: EnemyKindId; tile: { x: number; y: number }; patrol?: EnemyPatrolDef }[]; - transitions: TransitionDef[]; - /** Интерактивные объекты (сундуки, очаги, прилавки). */ - interactables?: InteractableDef[]; - /** Атмосферные частицы: 'ash' — пепел, 'fog' — туман прудов, 'none' — интерьер. */ - atmosphere: 'ash' | 'fog' | 'none'; - /** Освещение: ambient-тон + статические источники (очаг, окна). */ - lighting?: LightingDef; - /** Локальные амбиент-слои поверх областного (вода, гул башни, дома). */ - ambienceLayers?: AmbienceLayerDef[]; - /** Зоны наката (необязательно). */ - hazards?: HazardDef[]; - /** Сборный тайл (лунный колокольчик): клик по нему — сбор. Нет — в области не собирается. */ - harvestTile?: number; - /** Спавны пейзажных оленей (тайлы). */ - fauna?: { x: number; y: number }[]; -} - -/** Зона наката прудов (низины у берегов): тайлы — ещё и источники слоя ashflow. */ -const HAZARD_PONDS: HazardDef = { - name: 'Свежий накат', - requiresItem: 'cloth', - tiles: [ - { x: 10, y: 32 }, - { x: 12, y: 34 }, - { x: 14, y: 35 }, - { x: 16, y: 36 }, - { x: 18, y: 36 }, - { x: 20, y: 37 }, - { x: 22, y: 37 }, - { x: 24, y: 38 }, - { x: 26, y: 37 }, - { x: 28, y: 36 }, - { x: 30, y: 35 }, - { x: 32, y: 34 }, - { x: 8, y: 18 }, - { x: 6, y: 16 }, - { x: 30, y: 12 }, - { x: 32, y: 10 } - ] -}; - -/** Зона наката разъезда (наметало у рельсов, у люка). */ -const HAZARD_SIDING: HazardDef = { - name: 'Накат у рельсов', - requiresItem: 'cloth', - tiles: [ - { x: 26, y: 29 }, - { x: 27, y: 29 }, - { x: 29, y: 29 }, - { x: 28, y: 28 }, - { x: 29, y: 30 }, - { x: 30, y: 29 } - ] -}; - -export const AREAS: Record = { - meadows: { - id: 'meadows', - name: 'Выжженные луга', - // Амбиента нет (по фидбеку): тишина выжженных лугов — только музыка и шаги. - theme: 'meadows', - spawn: { x: 16, y: 28 }, - npcs: [], - enemies: [ - { kind: 'crawler', tile: { x: 16, y: 18 } }, - { kind: 'crawler', tile: { x: 20, y: 14 } }, - // Плевун кружит у северо-восточных бугров. - { - kind: 'spitter', - tile: { x: 38, y: 12 }, - patrol: { points: [{ x: 38, y: 12 }, { x: 44, y: 10 }], pauseSec: 1.2 } - }, - { kind: 'crawler', tile: { x: 44, y: 42 } }, - { kind: 'crawler', tile: { x: 36, y: 24 } }, - // Тяжеловес бродит вдоль юго-западной низины. - { - kind: 'heavy', - tile: { x: 12, y: 44 }, - patrol: { points: [{ x: 12, y: 44 }, { x: 8, y: 40 }], pauseSec: 2 } - } - ], - transitions: [ - // Северо-западный угол: тропа на Серые пруды. - { tile: { x: 2, y: 2 }, target: { kind: 'area', area: 'ponds', entry: { x: 4, y: 4 } } }, - // Тропа в Звенец (деревня-мастерская). - { tile: { x: 54, y: 28 }, target: { kind: 'area', area: 'zvenets', entry: { x: 3, y: 20 } } }, - // Встречный колодец: звон в Звенец (клик по тайлу колодца). - { - tile: { x: 44, y: 40 }, - label: 'Старый колодец', - target: { kind: 'area', area: 'zvenets', entry: { x: 16, y: 14 } }, - trigger: 'click', - duration: 0.2 - } - ], - atmosphere: 'ash', - // Солнечный выжженный день: тёплый почти-белый. Вечером — золотой час - // и синяя ночь: прорези колокольчиков несут свет. - lighting: { - ambient: 0xf7f4ea, - nightAmbient: 0x3e4658, - sources: [ - { - id: 'bells', - tiles: [TILES.BELLFLOWER], - color: 0xf2cd68, - radius: 1.0, - intensity: 0.65, - flicker: 0.15, - whenNight: true - } - ] - }, - interactables: INTERACTABLES.meadows, - // Амбиента нет (по фидбеку): на лугах тишина — только музыка и шаги. - // Безгласные олени у берегов прудов — подходят к звону. - fauna: [ - { x: 18, y: 36 }, - { x: 24, y: 40 } - ] - }, - ponds: { - id: 'ponds', - name: 'Серые пруды', - ambience: 'ambience/ponds', - spawn: { x: 4, y: 4 }, - npcs: [], - enemies: [ - { kind: 'crawler', tile: { x: 8, y: 22 } }, - { kind: 'crawler', tile: { x: 18, y: 36 } }, - { kind: 'spitter', tile: { x: 40, y: 20 } }, - { kind: 'spitter', tile: { x: 36, y: 36 } }, - { kind: 'heavy', tile: { x: 28, y: 36 } } - ], - transitions: [ - // Обратно на луга. - { tile: { x: 2, y: 2 }, target: { kind: 'area', area: 'meadows', entry: { x: 3, y: 3 } } } - ], - atmosphere: 'fog', - // Светлый день у прудов (туман даёт молочность сам); ночью — глухая - // синева, туман читается прорезями. - lighting: { ambient: 0xf2f0e8, nightAmbient: 0x444c60 }, - // Вода прудов слышна у берега — тихий плеск поверх ветрового амбиента. - // Глобальный ветер + вода у берегов (громкость — от дистанции). - ambienceLayers: [ - { key: 'ambience/wind', volume: 0.3 }, - // Вода — только вплотную к берегу и совсем тихо (у нас лужи, не море). - { key: 'ambience/ponds_water', tiles: [TILES.WATER], radius: 2.5, volume: 0.22 } - ], - // Свежий накат — низины у берегов: без вощёного полотна здесь не дышать. - hazards: [HAZARD_PONDS], - interactables: INTERACTABLES.ponds, - // Лунные колокольчики собираются только у прудов (квест «Три цветка»). - harvestTile: TILES.BELLFLOWER, - // Один олень на северо-западном берегу. - fauna: [{ x: 8, y: 12 }] - }, - zvenets: { - id: 'zvenets', - name: 'Звенец', - ambience: 'ambience/zvenets', - spawn: { x: 28, y: 20 }, - npcs: NPCS, - enemies: [], - transitions: [ - // Тропа обратно на луга. - { tile: { x: 2, y: 20 }, target: { kind: 'area', area: 'meadows', entry: { x: 53, y: 28 } } }, - // Колодец у дома Ирвина: телепорт на луга (клик; встречный лугам). - { - tile: { x: 16, y: 13 }, - label: 'Старый колодец', - target: { kind: 'area', area: 'meadows', entry: { x: 44, y: 41 } }, - trigger: 'click', - duration: 0.2 - }, - // Двери: клик по дому — внутрь (вход у проёма; выход — kind 'return'). - { - tile: { x: 13, y: 12 }, - label: 'Дом Ирвина', - target: { kind: 'area', area: 'house_elder', entry: { x: 5, y: 7 } }, - trigger: 'click', - duration: 0.2 - }, - { - tile: { x: 43, y: 19 }, - label: 'Лавка Милы', - target: { kind: 'area', area: 'shop', entry: { x: 5, y: 7 } }, - trigger: 'click', - duration: 0.2 - }, - // Восточный край: тропа в Ржавую рощу (акт 2). - { tile: { x: 54, y: 20 }, target: { kind: 'area', area: 'rust_grove', entry: { x: 2, y: 20 } } } - ], - interactables: INTERACTABLES.zvenets, - atmosphere: 'ash', - // Светлый тёплый день деревни; вечером — золотой час, синяя ночь и - // тёплые окна трёх домов; после звонка — холодный отсвет от башни. - lighting: { - ambient: 0xf5f2ea, - nightAmbient: 0x454c62, - sources: [ - // Окна — на теле спрайта дома (тело занимает тайл выше-левее базового); - // днём не горят, зажигаются вечером. - { id: 'window_elder', at: { x: 12.5, y: 11.5 }, radius: 1.2, intensity: 0.65, flicker: 0.1, whenNight: true }, - { id: 'window_north', at: { x: 36.5, y: 9.5 }, radius: 1.2, intensity: 0.65, flicker: 0.1, whenNight: true }, - { id: 'window_shop', at: { x: 42.5, y: 18.5 }, radius: 1.2, intensity: 0.65, flicker: 0.1, whenNight: true }, - { - id: 'tower_resonance', - at: { x: 28, y: 14 }, - color: 0x4a737f, - radius: 2.5, - intensity: 0.6, - flicker: 0.25, - whenFlag: FLAGS.rang_tower - } - ] - }, - // Гул колокола башни и скрипы вагонных щитов — слышны вблизи источников. - ambienceLayers: [ - { key: 'ambience/tower_hum', at: { x: 28, y: 14 }, radius: 8, volume: 0.5 }, - { - key: 'ambience/houses', - tiles: [TILES.HOUSE, TILES.HOUSE_V1, TILES.HOUSE_V2, TILES.HOUSE_V3, TILES.HOUSE_V4], - radius: 5, - volume: 0.4 - } - ] - }, - // --- акт 2: роща, разъезд, подземелье --- - rust_grove: { - id: 'rust_grove', - name: 'Ржавая роща', - ambience: 'ambience/rust_grove', - spawn: { x: 2, y: 20 }, - npcs: [], - enemies: [ - { kind: 'crawler', tile: { x: 12, y: 12 } }, - { kind: 'crawler', tile: { x: 34, y: 14 } }, - { - kind: 'spitter', - tile: { x: 36, y: 32 }, - patrol: { points: [{ x: 36, y: 32 }, { x: 40, y: 28 }], pauseSec: 1.4 } - }, - { kind: 'heavy', tile: { x: 14, y: 30 } } - ], - transitions: [ - // Тропа обратно в Звенец. - { tile: { x: 1, y: 20 }, target: { kind: 'area', area: 'zvenets', entry: { x: 53, y: 20 } } }, - // Тропа на восток: к Старому разъезду. - { tile: { x: 46, y: 20 }, target: { kind: 'area', area: 'old_siding', entry: { x: 2, y: 20 } } } - ], - atmosphere: 'ash', - interactables: INTERACTABLES.rust_grove, - // Роща чуть темнее лугов: мёртвый лес глушит свет; ночью — глухая синева. - lighting: { ambient: 0xe9e6da, nightAmbient: 0x39405a }, - // Ветер в проводах: гул башни не слышен, только кабельный звон. - ambienceLayers: [{ key: 'ambience/wind', volume: 0.4 }] - }, - old_siding: { - id: 'old_siding', - name: 'Старый разъезд', - ambience: 'ambience/old_siding', - spawn: { x: 2, y: 20 }, - npcs: [], - enemies: [ - { kind: 'crawler', tile: { x: 14, y: 18 } }, - { - kind: 'spitter', - tile: { x: 36, y: 16 }, - patrol: { points: [{ x: 36, y: 16 }, { x: 38, y: 12 }], pauseSec: 1.2 } - }, - { - kind: 'heavy', - tile: { x: 36, y: 32 }, - patrol: { points: [{ x: 36, y: 32 }, { x: 32, y: 33 }], pauseSec: 2 } - } - ], - transitions: [ - // Тропа обратно в рощу. - { tile: { x: 1, y: 20 }, target: { kind: 'area', area: 'rust_grove', entry: { x: 45, y: 20 } } }, - // Люк-ступени вниз, в зал Старой Машины (наступил — спустился). - { - tile: { x: 28, y: 31 }, - label: 'Люк в подземелье', - target: { kind: 'area', area: 'machine_hall', entry: { x: 13, y: 19 } }, - duration: 0.6 - } - ], - atmosphere: 'ash', - // Разъезд темнее рощи: рельсы тянут свет в землю; ночью — глубокая тьма. - lighting: { ambient: 0xe4e2d8, nightAmbient: 0x343a50 }, - // Гул снизу: Машина дышит у люка (та же нота, что башня Звенца). - ambienceLayers: [ - { key: 'ambience/wind', volume: 0.25 }, - { key: 'ambience/tower_hum', at: { x: 28, y: 31 }, radius: 7, volume: 0.45 } - ], - // Свежий накат наметало у рельсов: без полотна рядом с люком не дышать. - hazards: [HAZARD_SIDING] - }, - machine_hall: { - id: 'machine_hall', - name: 'Зал Старой Машины', - spawn: { x: 13, y: 19 }, - npcs: [], - enemies: [], - transitions: [ - // Проём в нижней стене: шаг — назад наверх, к люку. - { tile: { x: 13, y: 21 }, target: { kind: 'return' }, duration: 0.2 } - ], - atmosphere: 'none', - interactables: INTERACTABLES.machine_hall, - // Под землёй темно; единственный свет — холодное тление устья печи. - lighting: { - ambient: 0x3e4252, - sources: [ - { - id: 'machine_mouth', - at: { x: 12.5, y: 1.5 }, - color: 0x4a737f, - radius: 3.5, - intensity: 0.55, - flicker: 0.3 - } - ] - }, - // Гул здесь — везде: это её грудная клетка. - ambienceLayers: [{ key: 'ambience/tower_hum', volume: 0.6 }] - }, - // --- интерьеры: рамка WALL, пол FLOOR, дверной проём снизу --- - house_elder: { - id: 'house_elder', - name: 'Дом Ирвина', - spawn: { x: 5, y: 7 }, - npcs: [], - enemies: [], - transitions: [ - // Проём в нижней стене: шаг наружу — назад, откуда вошёл. - { tile: { x: 5, y: 8 }, target: { kind: 'return' }, duration: 0.2 } - ], - interactables: INTERACTABLES.house_elder, - atmosphere: 'none', - // Тёмный холодный интерьер (без света окна выглядят как улица); - // очаг: тлеет, пока квест не закрыт, после — горит ровнее и ярче. - lighting: { - ambient: 0x54586a, - sources: [ - { - id: 'hearth_low', - at: { x: 8.5, y: 0.5 }, - radius: 4, - intensity: 0.75, - flicker: 0.35, - notFlag: FLAGS.quest_bells_done - }, - { - id: 'hearth_high', - at: { x: 8.5, y: 0.5 }, - radius: 5, - intensity: 1.05, - flicker: 0.2, - whenFlag: FLAGS.quest_bells_done - } - ] - } - }, - shop: { - id: 'shop', - name: 'Лавка Милы', - spawn: { x: 5, y: 7 }, - npcs: [], - enemies: [], - transitions: [{ tile: { x: 5, y: 8 }, target: { kind: 'return' }, duration: 0.2 }], - interactables: INTERACTABLES.shop, - atmosphere: 'none', - // Полутёмная лавка: лампа над прилавком. - lighting: { - ambient: 0x5a5e70, - sources: [{ id: 'counter_lamp', at: { x: 5.5, y: 3.5 }, radius: 3, intensity: 0.7, flicker: 0.05 }] - } - } -}; - -/** Область по id (фолбэк 'meadows' — для старых сейвов и пустых id). */ -export function areaOf(id: string | null | undefined): AreaDef { - return AREAS[(id ?? 'meadows') as AreaId] ?? AREAS.meadows; -} \ No newline at end of file diff --git a/apps/game/src/data/map.ts b/apps/game/src/data/map.ts deleted file mode 100644 index ae420dc..0000000 --- a/apps/game/src/data/map.ts +++ /dev/null @@ -1,775 +0,0 @@ -import { createRng, type TileMapData, type TallSpec, type DecorData, type PropData } from '@rpg/engine'; - -/** id тайлов луга «Пепельные луга». */ -export const TILES = { - GRASS: 0, - PATH: 1, - WATER: 2, - TREE: 3, - ASH: 4, - BELLFLOWER: 5, - TOWER: 6, - HOUSE: 7, - FLOOR: 8, - WALL: 9, - WELL: 10, - // --- акт 2 --- - /** Бухта кабеля/труб в Ржавой роще (низкий tall-объект). */ - WIRE: 18, - /** Рельсы Старого разъезда (ходовые: шпалы с нитями). */ - RAIL: 19, - /** Люк-ступени вниз в зал Машины (ходовой тайл, step-переход). */ - STAIRS: 20, - /** Сама Старая Машина (tall-объект зала, непроходима). */ - MACHINE: 21, - // Варианты текстур (микс по хэшу координат) — в tiles никогда не попадают. - GRASS_V1: 11, - GRASS_V2: 12, - ASH_V1: 13, - PATH_V1: 14, - /** Деревья других видов (варианты TREE распределителем ниже; в tiles не попадают). */ - TREE_V1: 22, - TREE_V2: 23, - /** - * Дома других фасадов. Обычно раздаются вариантами HOUSE по хэшу координат, - * но могут ставиться в tiles и явно — деревня Звенец показывает разные - * фасада намеренно (там немного домов, хэш-микс не гарантирует разнообразия). - * V1/V2 — низкие (времянка, сарай), V3/V4 — высокие 32x80 (амбар, двухэтажный). - */ - HOUSE_V1: 24, - HOUSE_V2: 25, - HOUSE_V3: 26, - HOUSE_V4: 27, - /** Валун — крупный непроходимый камень (луга, пруды, роща, разъезд). */ - BOULDER: 28, - /** Большой мёртвый куст — непроходимое облако ветвей. */ - BUSH: 29, - /** Валуны других форм (варианты BOULDER): скол-монолит и плоский камень. */ - BOULDER_2: 30, - BOULDER_3: 31, - /** Кусты других видов (варианты BUSH): раскидистый и высокий узкий. */ - BUSH_2: 32, - BUSH_3: 33, - /** Большой дом 2×2 (спрайт-проп 64×80, footprint блокируется движком). */ - HOUSE_BIG: 34 -} as const; - -const BLOCKED = [ - TILES.WATER, - TILES.TREE, - TILES.TOWER, - TILES.HOUSE, - TILES.HOUSE_V1, - TILES.HOUSE_V2, - TILES.HOUSE_V3, - TILES.HOUSE_V4, - TILES.WALL, - TILES.WELL, - TILES.WIRE, - TILES.MACHINE, - TILES.BOULDER, - TILES.BUSH, - TILES.BOULDER_2, - TILES.BOULDER_3, - TILES.BUSH_2, - TILES.BUSH_3 -]; - -/** Все id деревьев (вьюхи крон/частицы/соседство — общий случай). */ -export const TREE_TILES: readonly number[] = [TILES.TREE, TILES.TREE_V1, TILES.TREE_V2]; - -/** Дерево — высокий объект 1.5 мировых юнита (48 px), под ним трава. */ -export const TALL_OBJECTS: Record = { - [TILES.TREE]: { height: 1.5, ground: TILES.GRASS }, - [TILES.TREE_V1]: { height: 1.5, ground: TILES.GRASS }, - [TILES.TREE_V2]: { height: 1.5, ground: TILES.GRASS }, - [TILES.TOWER]: { height: 2.5, ground: TILES.PATH }, - [TILES.HOUSE]: { height: 1.75, ground: TILES.ASH, shadow: true }, - [TILES.HOUSE_V1]: { height: 1.75, ground: TILES.ASH, shadow: true }, - [TILES.HOUSE_V2]: { height: 1.75, ground: TILES.ASH, shadow: true }, - // Высокие дома 32x80 — ростом с башню (2.5 юнита). - [TILES.HOUSE_V3]: { height: 2.5, ground: TILES.ASH, shadow: true }, - [TILES.HOUSE_V4]: { height: 2.5, ground: TILES.ASH, shadow: true }, - // Валун и куст: приземистые препятствия (высота спрайта ≤ 1.5 — не резать куллингом). - [TILES.BOULDER]: { height: 1.05, ground: TILES.ASH }, - [TILES.BUSH]: { height: 1.25, ground: TILES.ASH }, - [TILES.BOULDER_2]: { height: 1.25, ground: TILES.ASH }, - [TILES.BOULDER_3]: { height: 0.75, ground: TILES.ASH }, - [TILES.BUSH_2]: { height: 1.0, ground: TILES.ASH }, - [TILES.BUSH_3]: { height: 1.45, ground: TILES.ASH }, - // Стены интерьеров — вертикальные объекты, под ними пол. - [TILES.WALL]: { height: 1, ground: TILES.FLOOR }, - // Бухта кабеля — низкая груда на пепле. - [TILES.WIRE]: { height: 0.6, ground: TILES.ASH }, - // Старая Машина — ростом с башню, стоит на полу зала. - [TILES.MACHINE]: { height: 2.5, ground: TILES.FLOOR } -}; - -/** - * Микс текстур наземных тайлов (движок выбирает по хэшу координат): - * трава и тропа — из трёх/двух вариантов, пепел — с редким вторым оттенком. - */ -const GROUND_VARIANTS: Record = { - [TILES.GRASS]: [TILES.GRASS, TILES.GRASS_V1, TILES.GRASS_V2], - [TILES.PATH]: [TILES.PATH, TILES.PATH_V1], - [TILES.ASH]: [TILES.ASH, TILES.ASH_V1] -}; - -/** Микс высоких объектов: деревья трёх видов, дома пяти фасадов, камни/кусты — по три. */ -const TALL_VARIANTS: Record = { - [TILES.TREE]: [TILES.TREE, TILES.TREE_V1, TILES.TREE_V2], - [TILES.HOUSE]: [TILES.HOUSE, TILES.HOUSE_V1, TILES.HOUSE_V2, TILES.HOUSE_V3, TILES.HOUSE_V4], - [TILES.BOULDER]: [TILES.BOULDER, TILES.BOULDER_2, TILES.BOULDER_3], - [TILES.BUSH]: [TILES.BUSH, TILES.BUSH_2, TILES.BUSH_3] -}; - -/** Полная таблица вариантов для карт (земля + высокие объекты). */ -const TILE_VARIANTS: Record = { ...GROUND_VARIANTS, ...TALL_VARIANTS }; - -/** id текстур мелкого декора (не тайлы — в tiles не попадают). */ -export const DECOR = { - /** Микро-кустик (сухая трава). */ - TUFT: 15, - /** Камешек. */ - STONE: 16, - /** Проплешина (тёмное пятно на траве). */ - BALD: 17 -} as const; - -/** - * Детерминированно набросать декор по готовой карте: кустики и проплешины - * на траве, камешки на пепле (~10% наземных тайлов). Декор не ставится - * на тропу/воду/высокие объекты — только GRASS и ASH. - */ -function buildDecor(tiles: number[], width: number, height: number, seed: number): DecorData[] { - const r = createRng(seed + 7919); - const out: DecorData[] = []; - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - if (r.next() >= 0.1) continue; - const id = tiles[y * width + x]; - // лёгкое смещение от центра ромба — декор не по сетке - // (+0: Math.round может вернуть -0, который JSON теряет — fresh-тест разъезжается) - const dx = Math.round((r.next() - 0.5) * 8) + 0; - const dy = Math.round((r.next() - 0.5) * 4) + 0; - if (id === TILES.GRASS) { - out.push({ id: r.next() < 0.6 ? DECOR.TUFT : DECOR.BALD, x, y, dx, dy }); - } else if (id === TILES.ASH) { - out.push({ id: DECOR.STONE, x, y, dx, dy }); - } - } - } - return out; -} - -/** - * Разбросать валуны и кусты по свободной земле (GRASS/ASH), не затирая тропы, - * воду и высокие объекты. Ставится до расчисток переходов/полян — важные тайлы - * все равно перезаписываются после. Часть попыток может не найти место — это - * допустимо (мелкий разброс у плотных карт). - */ -function scatterRocksAndBushes( - tiles: number[], - width: number, - height: number, - rng: { next: () => number }, - boulders: number, - bushes: number -): void { - const free = (x: number, y: number) => - tiles[y * width + x] === TILES.GRASS || tiles[y * width + x] === TILES.ASH; - const place = (id: number, count: number) => { - for (let i = 0; i < count * 24 && count > 0; i++) { - const x = 1 + Math.floor(rng.next() * (width - 2)); - const y = 1 + Math.floor(rng.next() * (height - 2)); - if (!free(x, y)) continue; - tiles[y * width + x] = id; - count--; - } - }; - place(TILES.BOULDER, boulders); - place(TILES.BUSH, bushes); -} - -/** - * Локация «Выжженные луга»: поляны травы, тропа от северо-западного угла - * (пруды) через стартовую поляну на восток (Звенец), пара прудов и рощи - * деревьев по краям. - */ -export function buildMeadowsMap(width = 56, height = 56): TileMapData { - const seed = 20260905; - const rng = createRng(seed); - const tiles: number[] = new Array(width * height).fill(TILES.GRASS); - const at = (x: number, y: number) => y * width + x; - - // Пятна пепла (декоративные). - for (let i = 0; i < 110; i++) { - const x = Math.floor(rng.next() * width); - const y = Math.floor(rng.next() * height); - const r = 1 + Math.floor(rng.next() * 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.next() < 0.7) { - tiles[at(nx, ny)] = TILES.ASH; - } - } - } - } - - // Тропа: монотонная ломаная к цели (случайный выбор оси) — детерминированно. - const walkPath = (sx: number, sy: number, tx: number, ty: number) => { - let px = sx; - let py = sy; - while (px !== tx || py !== ty) { - tiles[at(px, py)] = TILES.PATH; - if (px !== tx && (py === ty || rng.next() < 0.5)) px += Math.sign(tx - px); - else py += Math.sign(ty - py); - } - tiles[at(tx, ty)] = TILES.PATH; - }; - // От северо-западного перехода к стартовой поляне и от неё на восток к Звенцу. - walkPath(2, 2, 16, 28); - walkPath(16, 28, 52, 28); - - // Пруды (крупнее, с рваными краями). - for (const [cx, cy] of [ - [12, 32], - [38, 14] - ]) { - for (let dy = 0; dy < 5; dy++) { - for (let dx = 0; dx < 7; dx++) { - if (rng.next() < 0.85) tiles[at(cx + dx, cy + dy)] = TILES.WATER; - } - } - } - - // Лунные колокольчики у берегов прудов (квестовый декор, проходимы). - for (const [fx, fy] of [ - [20, 34], - [11, 36], - [46, 16], - [35, 11] - ]) { - if (tiles[at(fx, fy)] === TILES.GRASS || tiles[at(fx, fy)] === TILES.ASH) { - tiles[at(fx, fy)] = TILES.BELLFLOWER; - } - } - - // Рощи по краям (в основном) и одинокие деревья на полянах. - for (let i = 0; i < 240; i++) { - const edge = rng.next() < 0.8; - const x = edge ? Math.floor(rng.next() * 8) : width - 1 - Math.floor(rng.next() * 8); - const y = Math.floor(rng.next() * height); - if (tiles[at(x, y)] === TILES.GRASS || tiles[at(x, y)] === TILES.ASH) { - tiles[at(x, y)] = TILES.TREE; - } - } - - // Валуны и кусты на полянах. - scatterRocksAndBushes(tiles, width, height, rng, 10, 14); - - // Стартовая поляна в центре-западе всегда свободна. - const cx = 16; - const cy = 28; - for (let dy = -2; dy <= 2; dy++) { - for (let dx = -2; dx <= 2; dx++) { - tiles[at(cx + dx, cy + dy)] = TILES.GRASS; - } - } - - // Пробуждение стартовой поляны: валун, кусты и деревья в первом кадре от - // спавна — новое сразу видно. Ставим с проверкой: не затирать тропу/воду. - const safePlace = (x: number, y: number, id: number) => { - if (tiles[at(x, y)] !== TILES.PATH && tiles[at(x, y)] !== TILES.WATER) tiles[at(x, y)] = id; - }; - safePlace(13, 25, TILES.BOULDER); // у северо-западной тропы - safePlace(20, 32, TILES.BOULDER); - safePlace(11, 30, TILES.BUSH); // южнее поляны - safePlace(19, 24, TILES.BUSH); // восточнее, не на тропе (x>16) - safePlace(12, 21, TILES.TREE); // деревца-ориентиры у поляны - safePlace(13, 22, TILES.TREE); - safePlace(21, 26, TILES.TREE); - - // Тропа к Серым прудам: северо-западный угол (переход между локациями). - // Вокруг тайлов перехода расчищаем поляну — рощи не должны запирать выход. - for (let dy = -1; dy <= 2; dy++) { - for (let dx = -1; dx <= 2; dx++) { - const nx = 2 + dx; - const ny = 2 + dy; - if (nx >= 0 && ny >= 0 && nx < width && ny < height) { - tiles[at(nx, ny)] = dx === dy || dx + 1 === dy ? TILES.PATH : TILES.GRASS; - } - } - } - tiles[at(2, 2)] = TILES.PATH; - tiles[at(3, 3)] = TILES.PATH; - - // Тропа в Звенец: восточный край, вокруг тайлов перехода тоже расчищаем. - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -2; dx <= 0; dx++) { - const nx = 54 + dx; - const ny = 28 + dy; - if (nx >= 0 && ny >= 0 && nx < width && ny < height) { - tiles[at(nx, ny)] = dx === -1 && dy === 0 ? TILES.PATH : TILES.GRASS; - } - } - } - tiles[at(54, 28)] = TILES.PATH; - - // Встречный колодец на лугах: телепорт обратно в Звенец (клик по тайлу). - tiles[at(44, 40)] = TILES.WELL; - - // Знак у восточной тропы (см. INTERACTABLES.meadows): scatter не должен - // ставить на его тайл дерево/куст. - tiles[at(48, 27)] = TILES.ASH; - - return { - width, - height, - tiles, - blocked: [...BLOCKED], - tall: TALL_OBJECTS, - variants: TILE_VARIANTS, - decor: buildDecor(tiles, width, height, seed) - }; -} - -/** - * Локация «Ржавая роща»: мёртвый лес, оплетённый кабелями и трубами. - * Деревья стоят как механизмы — плотная роща с бухтами кабеля (WIRE), - * тропа с запада (из Звенца) на восток (к разъезду) через поляну - * мастерской Клинта в центре. - */ -export function buildRustGroveMap(width = 48, height = 40): TileMapData { - const seed = 20260908; - const rng = createRng(seed); - const tiles: number[] = new Array(width * height).fill(TILES.ASH); - const at = (x: number, y: number) => y * width + x; - - // Редкие проплешины чахлой травы. - for (let i = 0; i < 24; i++) { - const x = Math.floor(rng.next() * width); - const y = Math.floor(rng.next() * height); - if (tiles[at(x, y)] === TILES.ASH) tiles[at(x, y)] = TILES.GRASS; - } - - // Плотная роща: деревья гуще к краям, реже к центру. - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const d = Math.hypot((x - width / 2) / (width / 2), (y - height / 2) / (height / 2)); - if (rng.next() < 0.16 + d * 0.5 && (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS)) { - tiles[at(x, y)] = TILES.TREE; - } - } - } - - // Бухты кабеля: короткие цепочки по 1–3 тайла (как протянутые шлейфы). - for (const [bx, by, dir] of [ - [10, 8, 1], - [32, 6, -1], - [16, 30, 1], - [36, 28, -1], - [24, 10, 0] - ] as [number, number, number][]) { - for (let i = 0; i < 3; i++) { - const nx = bx + i * dir; - const ny = by + (i % 2); - if (nx >= 0 && ny >= 0 && nx < width && ny < height && tiles[at(nx, ny)] !== TILES.TREE) { - tiles[at(nx, ny)] = TILES.WIRE; - } - } - } - // Тон-деревья квеста резонатора (см. QUEST_TONE_TILES в data/quests.ts): - // гарантируем бухту кабеля на их тайлах — квест не должен зависеть от шума. - for (const [tx, ty] of [ - [32, 6], - [12, 10], - [20, 30] - ]) { - tiles[at(tx, ty)] = TILES.WIRE; - } - - // Валуны и кусты в прогалинах. - scatterRocksAndBushes(tiles, width, height, rng, 6, 8); - - // Тропа: с запада (вход из Звенца) на восток (к разъезду) — до самого - // тайла перехода (46,20), чтобы A* всегда доводил. - for (let x = 1; x <= 46; x++) tiles[at(x, 20)] = TILES.PATH; - - // Спавны врагов расчищаем: роща плотная — дерево могло вырасти на тайле. - for (const [ex, ey] of [ - [12, 12], - [34, 14], - [36, 32], - [40, 28], - [14, 30] - ]) { - if (tiles[at(ex, ey)] === TILES.TREE || tiles[at(ex, ey)] === TILES.WIRE) { - tiles[at(ex, ey)] = TILES.ASH; - } - } - - // Поляна мастерской Клинта: расчистка и утоптанный пепел вокруг центра. - for (let dy = -4; dy <= 4; dy++) { - for (let dx = -6; dx <= 6; dx++) { - const nx = 24 + dx; - const ny = 20 + dy; - if (nx >= 0 && ny >= 0 && nx < width && ny < height && Math.abs(dx) + Math.abs(dy) <= 8) { - tiles[at(nx, ny)] = TILES.ASH; - } - } - } - // Пятачок тропы у поляны (сюда встанет резонатор Клинта). - tiles[at(24, 16)] = TILES.ASH; - - // Входы/выходы: тайлы переходов всегда проходимы. - tiles[at(2, 20)] = TILES.PATH; - tiles[at(46, 20)] = TILES.PATH; - - return { - width, - height, - tiles, - blocked: [...BLOCKED], - tall: TALL_OBJECTS, - variants: TILE_VARIANTS, - decor: buildDecor(tiles, width, height, seed) - }; -} - -/** - * Локация «Старый разъезд»: руины узловой станции. Через карту идут рельсы, - * вдоль них — вагоны-времянки (HOUSE) и редкие сухие деревья. Южнее стрелки — - * люк-ступени (STAIRS) вниз в зал Старой Машины. - */ -export function buildOldSidingMap(width = 48, height = 40): TileMapData { - const seed = 20260909; - const rng = createRng(seed); - const tiles: number[] = new Array(width * height).fill(TILES.ASH); - const at = (x: number, y: number) => y * width + x; - - // Редкие проплешины травы. - for (let i = 0; i < 20; i++) { - const x = Math.floor(rng.next() * width); - const y = Math.floor(rng.next() * height); - if (tiles[at(x, y)] === TILES.ASH) tiles[at(x, y)] = TILES.GRASS; - } - - // Рельсы: главная линия поперёк карты (вертикальная нить в изометрии). - for (let y = 6; y <= 32; y++) { - tiles[at(22, y)] = TILES.RAIL; - tiles[at(23, y)] = TILES.RAIL; - } - // Стрелка: боковая ветка к северо-востоку. - for (let x = 24; x <= 38; x++) tiles[at(x, 6 + (x - 24))] = TILES.RAIL; - - // Руины станции: пара вагонов-времянок и останки стены. - tiles[at(16, 12)] = TILES.HOUSE; - tiles[at(32, 24)] = TILES.HOUSE; - tiles[at(10, 26)] = TILES.WALL; - - // Редкие сухие деревья по краям. - for (let i = 0; i < 44; i++) { - const edge = rng.next() < 0.5; - const x = edge ? Math.floor(rng.next() * 6) : width - 1 - Math.floor(rng.next() * 6); - const y = Math.floor(rng.next() * height); - if (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS) { - tiles[at(x, y)] = TILES.TREE; - } - } - - // Валуны и кусты среди руин. - scatterRocksAndBushes(tiles, width, height, rng, 6, 6); - - // Тропа от западного входа к рельсам, через них и к люку. - for (let x = 1; x <= 20; x++) tiles[at(x, 20)] = TILES.PATH; - for (let x = 24; x <= 26; x++) tiles[at(x, 20)] = TILES.PATH; - for (let y = 21; y <= 30; y++) tiles[at(26, y)] = TILES.PATH; - tiles[at(27, 30)] = TILES.PATH; - tiles[at(27, 31)] = TILES.PATH; - - // Люк-ступени вниз (step-переход в зал Машины), рядом тропа. - tiles[at(28, 31)] = TILES.STAIRS; - - // Вход всегда проходим; спавны врагов (см. AREAS.old_siding.enemies) - // scatter не должен закрывать валуном/кустом. - tiles[at(2, 20)] = TILES.PATH; - for (const [x, y] of [[14, 18], [36, 16], [36, 32]]) tiles[at(x, y)] = TILES.ASH; - - return { - width, - height, - tiles, - blocked: [...BLOCKED], - tall: TALL_OBJECTS, - variants: TILE_VARIANTS, - decor: buildDecor(tiles, width, height, seed) - }; -} - -/** - * Зал Старой Машины (подземелье разъезда): тесный интерьер с плитами пола, - * у северной стены — сама Машина (два тайла корпуса). Рельсы подходят к ней - * со стороны входа. Дверной проём снизу (kind 'return' наверх). - */ -export function buildMachineHallMap(): TileMapData { - const width = 26; - const height = 22; - const tiles: number[] = new Array(width * height).fill(TILES.WALL); - const at = (x: number, y: number) => y * width + x; - for (let y = 1; y < height - 1; y++) { - for (let x = 1; x < width - 1; x++) { - tiles[at(x, y)] = TILES.FLOOR; - } - } - // Машина: корпус в два тайла у северной стены. - tiles[at(12, 1)] = TILES.MACHINE; - tiles[at(13, 1)] = TILES.MACHINE; - // Рельсы подходят к Машине — её кормили составами. - for (let y = 3; y <= 19; y++) tiles[at(13, y)] = TILES.RAIL; - // Дверной проём в нижней стене (step-переход kind 'return'). - tiles[at(13, height - 1)] = TILES.FLOOR; - return { - width, - height, - tiles, - blocked: [...BLOCKED], - tall: TALL_OBJECTS - }; -} - -/** - * Локация «Серые пруды»: опасная зона — большая гладь воды, туман, - * много пепла и сгустков. Тропа начинается в северо-западном углу - * (обратный переход на луга). - */ -export function buildPondsMap(width = 48, height = 48): TileMapData { - const seed = 20260906; - const rng = createRng(seed); - const tiles: number[] = new Array(width * height).fill(TILES.ASH); - const at = (x: number, y: number) => y * width + x; - - // Пятна чахлой травы. - for (let i = 0; i < 50; i++) { - const x = Math.floor(rng.next() * width); - const y = Math.floor(rng.next() * height); - const r = 1 + Math.floor(rng.next() * 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.next() < 0.6) { - tiles[at(nx, ny)] = TILES.GRASS; - } - } - } - } - - // Большая гладь в центре с рваными берегами. - const cx = Math.floor(width / 2); - const cy = Math.floor(height / 2); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const d = Math.hypot((x - cx) / 15, (y - cy) / 11); - if (d + (rng.next() - 0.5) * 0.35 < 1) tiles[at(x, y)] = TILES.WATER; - } - } - - // Лунные колокольчики у берегов (квестовые цветы растут только здесь). - for (const [fx, fy] of [ - [8, 16], - [38, 12], - [36, 34], - [12, 34], - [24, 42] - ]) { - if (tiles[at(fx, fy)] !== TILES.WATER) tiles[at(fx, fy)] = TILES.BELLFLOWER; - } - - // Тропа от северо-западного входа к берегу. - let px = 4; - let py = 4; - while (py < 30 || px < 30) { - tiles[at(px, py)] = TILES.PATH; - if (rng.next() < 0.45 && px < 30) px++; - else if (py < 30) py++; - else px++; - } - - // Сухие деревья по краям. - for (let i = 0; i < 92; i++) { - const edge = rng.next() < 0.5; - const x = edge ? Math.floor(rng.next() * 6) : width - 1 - Math.floor(rng.next() * 6); - const y = Math.floor(rng.next() * height); - if (tiles[at(x, y)] !== TILES.WATER) tiles[at(x, y)] = TILES.TREE; - } - - // Валуны и кусты у берегов. - scatterRocksAndBushes(tiles, width, height, rng, 8, 10); - - // Поляна у входа свободна; тайлы перехода — тропа. - for (let dy = 0; dy <= 1; dy++) { - for (let dx = 0; dx <= 1; dx++) { - tiles[at(4 + dx, 4 + dy)] = dy + dx === 0 ? TILES.PATH : TILES.GRASS; - } - } - tiles[at(2, 2)] = TILES.PATH; - tiles[at(4, 4)] = TILES.PATH; - - return { - width, - height, - tiles, - blocked: [...BLOCKED], - tall: TALL_OBJECTS, - variants: TILE_VARIANTS, - decor: buildDecor(tiles, width, height, seed) - }; -} -/** - * Интерьер 11×9: рамка WALL, пол FLOOR, дверной проём снизу (5,8). - * Мебель (сундуки/прилавки) — интерактивные объекты на тайлах пола - * (см. data/interactables.ts), не отдельные тайлы. - */ -function buildInteriorMap(): TileMapData { - const width = 11; - const height = 9; - const tiles: number[] = new Array(width * height).fill(TILES.WALL); - const at = (x: number, y: number) => y * width + x; - for (let y = 1; y < height - 1; y++) { - for (let x = 1; x < width - 1; x++) { - tiles[at(x, y)] = TILES.FLOOR; - } - } - // Дверной проём в нижней стене (step-переход kind 'return' наружу). - tiles[at(5, height - 1)] = TILES.FLOOR; - return { - width, - height, - tiles, - blocked: [...BLOCKED], - tall: TALL_OBJECTS - }; -} - -/** Дом Ирвина: сундук, очаг в северной стене, записка у восточной стены. */ -export function buildHouseElderMap(): TileMapData { - return buildInteriorMap(); -} - -/** Лавка Милы: прилавок в центре, полки вдоль северной стены. */ -export function buildShopMap(): TileMapData { - return buildInteriorMap(); -} - -/** - * Локация «Звенец»: деревня-мастерская вокруг старой железнодорожной - * водонапорной башни с колоколом. Дома из вагонных щитов, крыши из - * платформ, посреди площади — звонное место. Безопасный хаб (без врагов). - */ -export function buildZvenetsMap(width = 56, height = 40): TileMapData { - const seed = 20260907; - const rng = createRng(seed); - const tiles: number[] = new Array(width * height).fill(TILES.ASH); - const at = (x: number, y: number) => y * width + x; - - // Пятна чахлой травы по краям. - for (let i = 0; i < 40; i++) { - const x = Math.floor(rng.next() * width); - const y = Math.floor(rng.next() * height); - const r = 1 + Math.floor(rng.next() * 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.next() < 0.6) { - tiles[at(nx, ny)] = TILES.GRASS; - } - } - } - } - - // Площадь: утоптанный пепел в центре карты. - for (let y = 12; y <= 27; y++) { - for (let x = 18; x <= 39; x++) { - if (rng.next() < 0.9) tiles[at(x, y)] = TILES.PATH; - } - } - - // Знак у входа (см. INTERACTABLES.zvenets): scatter не занял бы его деревом. - tiles[at(3, 19)] = TILES.ASH; - - // Тропа: от западного края (переход на луга) к площади. - let px = 2; - let py = 20; - while (px < 18 || py !== 20) { - tiles[at(px, py)] = TILES.PATH; - if (px < 18 && (py === 20 || rng.next() < 0.6)) px++; - else if (py !== 20) py += py < 20 ? 1 : -1; - else px++; - } - - // Сухие деревья по краям. - for (let i = 0; i < 60; i++) { - const edge = rng.next() < 0.5; - const x = edge ? Math.floor(rng.next() * 6) : width - 1 - Math.floor(rng.next() * 6); - const y = Math.floor(rng.next() * height); - if (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS) { - tiles[at(x, y)] = TILES.TREE; - } - } - - // Пара кустов по краю деревни (валун один — у рощи за площадью). - scatterRocksAndBushes(tiles, width, height, rng, 2, 4); - - // Башня с колоколом — север площади. - tiles[at(28, 14)] = TILES.TOWER; - - // Дома деревни: все фасады показаны намеренно (вариантные id прямо в - // тайлах — хэш-микс не гарантирует разнообразия). Дом Ирвина у колодца, - // двухэтажный на севере, амбар-лавка Милы на востоке, пара времянок югом. - tiles[at(13, 12)] = TILES.HOUSE; - tiles[at(37, 10)] = TILES.HOUSE_V4; - tiles[at(43, 19)] = TILES.HOUSE_V3; - tiles[at(23, 28)] = TILES.HOUSE_V1; - tiles[at(33, 28)] = TILES.HOUSE_V2; - - // Большой дом 2×2 на юго-востоке: первый проп-объект с footprint в игре - // (коллизия и глубина — по всему прямоугольнику, земля под ним — пепел). - const props: PropData[] = [ - { id: TILES.HOUSE_BIG, x: 46, y: 24, w: 2, h: 2, ground: TILES.ASH } - ]; - - // Старый колодец у дома Ирвина: телепорт на луга (см. AREAS, transitions). - tiles[at(16, 13)] = TILES.WELL; - - // Стартовая поляна и тайл перехода всегда свободны. - tiles[at(28, 20)] = TILES.GRASS; - tiles[at(2, 20)] = TILES.PATH; - tiles[at(3, 20)] = TILES.PATH; - - // Тропа в Ржавую рощу: восточный край (вокруг тоже расчищаем). - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -2; dx <= 0; dx++) { - const nx = 54 + dx; - const ny = 20 + dy; - if (nx >= 0 && ny >= 0 && nx < width && ny < height) { - tiles[at(nx, ny)] = dx === -1 && dy === 0 ? TILES.PATH : TILES.ASH; - } - } - } - tiles[at(54, 20)] = TILES.PATH; - - return { - width, - height, - tiles, - blocked: [...BLOCKED], - tall: TALL_OBJECTS, - variants: TILE_VARIANTS, - decor: buildDecor(tiles, width, height, seed), - props - }; -} diff --git a/apps/game/src/data/migrations.ts b/apps/game/src/data/migrations.ts deleted file mode 100644 index 200e756..0000000 --- a/apps/game/src/data/migrations.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { GameState, type GameStateData } from '@rpg/engine'; -import { FLAGS } from './ids'; - -/** - * Миграции GameStateData (ось state-версии, отдельная от SAVE_VERSION): - * регистрируются в Game один раз, применяются в state.load при чтении сейва. - * Контракт движка: миграция сама повышает version; от старых версий к новым. - */ - -/** Текущая версия формата данных состояния. */ -export const STATE_DATA_VERSION = 3; - -/** v1→v2: знакомства metElder/metTrader → единый snake_case реестра FLAGS. */ -export function migrateV1toV2(data: GameStateData): GameStateData { - const rename = (f: string): string => - f === 'metElder' ? FLAGS.met_elder : f === 'metTrader' ? FLAGS.met_mila : f; - return { version: 2, flags: data.flags.map(rename), vars: { ...data.vars } }; -} - -/** v2→v3: флаг evening удалён — вечер теперь наступает по внутриигровым часам. */ -export function migrateV2toV3(data: GameStateData): GameStateData { - return { - version: 3, - flags: data.flags.filter((f) => f !== 'evening'), - vars: { ...data.vars } - }; -} - -/** Зарегистрировать все миграции (зовёт Game при создании состояния). */ -export function registerStateMigrations(state: GameState): void { - state.dataVersion = STATE_DATA_VERSION; - state.addMigration(migrateV1toV2); - state.addMigration(migrateV2toV3); -} \ No newline at end of file diff --git a/apps/game/src/data/music.ts b/apps/game/src/data/music.ts deleted file mode 100644 index 922cbf0..0000000 --- a/apps/game/src/data/music.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createRng, type MusicSpec, type NoteEvent } from '@rpg/engine'; - -/** - * Музыкальные темы: правило-базовый генератор (детерминированный, сиды) + - * компиляция движковым секвенсором (renderMusicStereo → playLoopBuffer) прямо - * в рантайме — WAV-файлов для музыки нет. Тема лугов — пепельная - * ля-минорная пентатоника: пэд-дрон + редкая плак-мелодия восьмыми. - */ - -/** Ля-минорная пентатоника от A3 (A C D E G + октава). */ -const PENTATONIC = [57, 60, 62, 64, 67, 69, 72] as const; - -/** Дрон-пэд: тоника/субмедианта по половинам лупа. */ -function drone(from: number, to: number, beats: number): NoteEvent[] { - return [ - { beat: 0, dur: beats / 2, midi: from, vel: 0.5 }, - { beat: beats / 2, dur: beats / 2, midi: to, vel: 0.5 } - ]; -} - -/** Мелодия восьмыми с паузами (вероятность ноты 0.55) — сид фиксирует рисунок. */ -function melody(seed: number, beats: number): NoteEvent[] { - const rng = createRng(seed); - const notes: NoteEvent[] = []; - for (let eighth = 0; eighth < beats * 2; eighth++) { - if (!rng.chance(0.55)) continue; - notes.push({ - beat: eighth * 0.5, - dur: 0.5, - midi: PENTATONIC[Math.floor(rng.next() * PENTATONIC.length)]!, - vel: 0.35 + rng.next() * 0.25 - }); - } - return notes; -} - -/** Тема лугов: 16 долей на 70 bpm, дрон A2→G2 + плак по пентатонике (стерео). */ -export const MEADOW_THEME: MusicSpec = { - bpm: 70, - beats: 16, - tracks: [ - { instrument: 'pad', notes: drone(45, 43, 16), gain: 0.9, pan: -0.3 }, - { instrument: 'pluck', notes: melody(20260907, 16), pan: 0.3 } - ] -}; - -/** Ритмичный рифф боя: восьмые с пропусками, низкая пентатоника. */ -function riff(seed: number, beats: number): NoteEvent[] { - const rng = createRng(seed); - const low = [45, 48, 50, 52, 55] as const; // A2-минорная низь - const notes: NoteEvent[] = []; - for (let eighth = 0; eighth < beats * 2; eighth++) { - if (!rng.chance(0.7)) continue; - notes.push({ - beat: eighth * 0.5, - dur: 0.5, - midi: low[Math.floor(rng.next() * low.length)]!, - vel: 0.5 + rng.next() * 0.3 - }); - } - return notes; -} - -/** Редкие высокие «тревожные» ноты колокола для tension-стема. */ -function sparseBells(seed: number, beats: number): NoteEvent[] { - const rng = createRng(seed); - const notes: NoteEvent[] = []; - for (let beat = 0; beat < beats; beat++) { - if (!rng.chance(0.15)) continue; - notes.push({ beat, dur: 2, midi: rng.chance(0.5) ? 81 : 84, vel: 0.4 + rng.next() * 0.2 }); - } - return notes; -} - -/** Тема боя: 112 bpm, глухой дрон A1 + настойчивый низкий рифф. */ -export const COMBAT_THEME: MusicSpec = { - bpm: 112, - beats: 8, - tracks: [ - { instrument: 'pad', notes: drone(33, 33, 8), gain: 1, pan: 0 }, - { instrument: 'pluck', notes: riff(20260908, 8), pan: -0.2 } - ] -}; - -/** - * Tension-стем: тихий луп «настороженности» (враг wary) — низкий дрон - * A1 + редкие высокие колокола. Играется на шине music ПОД темой области, - * громкость ведёт threat-тик (см. LocationScene.updateMusic). - */ -export const TENSION_STEM: MusicSpec = { - bpm: 70, - beats: 16, - tracks: [ - { instrument: 'pad', notes: [{ beat: 0, dur: 16, midi: 33, vel: 0.7 }], gain: 0.9, pan: 0.2 }, - { instrument: 'bell', notes: sparseBells(20260909, 16), gain: 0.5, pan: -0.4 } - ] -}; - -/** Реестр тем (ключ → спек); область ссылается на тему полем `theme`. */ -export const THEMES: Record = { - meadows: MEADOW_THEME, - combat: COMBAT_THEME -}; \ No newline at end of file diff --git a/apps/game/src/data/npcs.ts b/apps/game/src/data/npcs.ts deleted file mode 100644 index ddb1042..0000000 --- a/apps/game/src/data/npcs.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { Vec2 } from '@rpg/engine'; -import { FLAGS, type FlagId } from './ids'; - -/** - * NPC локации: позиция в юнитах, спрайт/атлас, диалоги и флаг сюжета - * после первого разговора. Тайл привязки вычисляется из pos при загрузке - * модуля — снапшот/валидатор/клики читают `tile`, реестр объектов — `pos`. - */ -export interface NpcDef { - id: string; - name: string; - /** Позиция ног в мировых юнитах (центр тайла = координата + 0.5). */ - pos: Vec2; - /** Тайл привязки (floor от pos; вычисляется — в контенте не писать). */ - tile: { x: number; y: number }; - /** Ключ спрайта в ассетах (chars/.png). */ - sprite: string; - /** Атлас idle-анимации (по умолчанию chars/npcs_sheet.json). */ - atlas?: string; - /** Первый диалог (когда флаг ещё не поднят). */ - dialogueFirst: string; - /** Повторный диалог. */ - dialogueRepeat: string; - /** Флаг, поднимаемый после первого диалога. */ - flagKey: FlagId; -} - -const NPCS_RAW: Omit[] = [ - { - id: 'elder', - name: 'Старейшина Ирвин', - // Не вплотную ни к двери дома (13,12), ни к колодцу (16,13): кнопка - // действия берёт ближайшую цель, E у двери/колодца не должен звать - // Ирвина — он стоит юго-западнее, вне радиуса (1.5) их тайлов подхода. - pos: { x: 12.5, y: 15.5 }, - sprite: 'elder_irwin', - dialogueFirst: 'elder_first', - dialogueRepeat: 'elder_repeat', - flagKey: FLAGS.met_elder - }, - { - id: 'trader', - name: 'Торговка Мила', - // Не вплотную к двери лавки (43,19): E у двери не должен звать Милу. - pos: { x: 40.5, y: 20.5 }, - sprite: 'trader_mila', - dialogueFirst: 'trader_first', - dialogueRepeat: 'trader_repeat', - flagKey: FLAGS.met_mila - } -]; - -export const NPCS: NpcDef[] = NPCS_RAW.map((d) => ({ - ...d, - tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } -})); - -/** - * Размещения NPC по флагу: персонаж появляется в области, когда флаг сюжета - * поднят (и пропадает, если сценарий его снимет). Так акт 2 заселяет рощу - * только после крючка Милы — контент не живёт вне своей главы. - */ -export interface NpcPlacement { - /** id области (из locations.ts — строкой, чтобы не зациклить импорт). */ - area: string; - /** Флаг, при котором NPC стоит на карте. */ - flag: FlagId; - def: NpcDef; -} - -/** Клинт из raw-определения (общий с NPCS тайловый мост). */ -function npcFrom(d: Omit): NpcDef { - return { ...d, tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } }; -} - -export const NPC_PLACEMENTS: NpcPlacement[] = [ - { - // Кузнец-починщик стоит у своего резонатора в роще после крючка Милы. - area: 'rust_grove', - flag: FLAGS.act2_hook, - def: npcFrom({ - id: 'clint', - name: 'Клинт', - pos: { x: 23.5, y: 17.5 }, - sprite: 'clint', - dialogueFirst: 'clint_first', - dialogueRepeat: 'clint_repeat', - flagKey: FLAGS.met_clint - }) - } -]; - -/** NPC области: статичные (area.npcs) + размещения по поднятым флагам. */ -export function npcsForArea(area: string, hasFlag: (f: FlagId) => boolean): NpcDef[] { - return NPC_PLACEMENTS.filter((p) => p.area === area && hasFlag(p.flag)).map((p) => p.def); -} \ No newline at end of file diff --git a/apps/game/src/data/quests.ts b/apps/game/src/data/quests.ts deleted file mode 100644 index 963ef3a..0000000 --- a/apps/game/src/data/quests.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { evalConditions, type DialogueConditions, type DialogueWorld, type GameState } from '@rpg/engine'; -import { FLAGS, VARS, type FlagId, type VarId } from './ids'; - -/** - * Квестовая система (контент по docs/world.md). - * Квест — декларативный набор стадий; состояние живёт во флагах/варах - * GameState (ставятся графами диалогов), статус выводится из состояния. - * Готовность стадии — те же условия, что и в графах диалогов (данные, не - * замыкания — валидатор и dry-run их видят). Сцена спрашивает только - * «какой диалог у NPC». - */ - -/** Квест «Три цветка» (docs/world.md, акт 1): собрать и посадить колокольчики. */ -export const QUEST_FLOWERS = 3; - -/** Тайлы посадки поляны у тропы (Звенец): collectFlowers расползается от них. */ -export const QUEST_PLANT_TILES: readonly (readonly [number, number])[] = [ - [24, 22], - [25, 23], - [24, 24] -]; - -/** Башня Звенца: тайл (камера кат-сцены сдачи идёт сюда). */ -export const QUEST_TOWER_TILE = { x: 28, y: 14 }; - -/** Зелень у подножия башни: визуальный финал акта 1. */ -export const QUEST_TOWER_GREEN: readonly (readonly [number, number])[] = [ - [27, 15], - [29, 15], - [28, 16] -]; - -/** Квест резонатора (акт 2): сколько тонов собрать у деревьев-механизмов. */ -export const QUEST_TONES = 3; - -/** WIRE-деревья рощи для звона (тайлы интерактивов tone_tree_1..3; достижимы от входа). */ -export const QUEST_TONE_TILES: readonly (readonly [number, number])[] = [ - [32, 6], - [12, 10], - [20, 30] -]; - -/** Одна стадия квеста. */ -export interface QuestStage { - /** Чей диалог ведёт стадию (id из NpcDef). */ - npc: string; - /** Строка журнала, пока стадия активна. */ - text: string; - /** Прогресс N/M по var-ключу (если стадия считается в штуках). */ - progressKey?: VarId; - goal?: number; - /** Диалог стадии: взятие (первая) или сдача (промежуточная). */ - dialogue?: string; - /** Готовность диалога — условия как в графах (по умолчанию сразу). */ - ready?: DialogueConditions; - /** Флаг, которым граф диалога завершает стадию. Без него стадия — «эпилог». */ - doneFlag?: FlagId; -} - -export interface QuestDef { - id: string; - title: string; - /** Стадии по порядку; активная — первая незавершённая. */ - stages: QuestStage[]; -} - -export const QUESTS: QuestDef[] = [ - { - id: 'bells', - title: 'Три цветка', - stages: [ - // Взятие: Ирвин рассказывает про поляны у прудов. - { - npc: 'elder', - text: 'Пепел дышит у прудов. Поговорить с Ирвином', - dialogue: 'elder_first', - doneFlag: FLAGS.quest_bells_taken - }, - // Сбор: активная строка журнала с прогрессом; сдача — Ирвину. - { - npc: 'elder', - text: 'Собрать три колокольчика — Серые пруды, северо-запад лугов', - progressKey: VARS.flowers, - goal: QUEST_FLOWERS, - dialogue: 'elder_hand_in', - ready: { whenVars: [{ key: VARS.flowers, op: 'ge', value: QUEST_FLOWERS }] }, - doneFlag: FLAGS.quest_bells_done - }, - // Эпилог: крючок акта 1 — разговор с Милой после посадки. - { - npc: 'trader', - text: 'Поговорить с Милой — поляна у тропы гудит', - dialogue: 'trader_after', - doneFlag: FLAGS.act2_hook - } - ] - }, - { - // Акт 2: Клинт и его молчащий резонатор (docs/world.md, «Ржавая роща»). - id: 'resonator', - title: 'Голос для резонатора', - stages: [ - // Взятие: ворчливое знакомство — тот же граф ставит met_clint. - { - npc: 'clint', - text: 'Резонатор Клинта молчит. Поговорить с Клинтом', - dialogue: 'clint_first', - doneFlag: FLAGS.quest_resonator_taken - }, - // Сбор тонов: деревья-механизмы рощи (интерактивы), сдача Клинту. - { - npc: 'clint', - text: 'Прозвенеть у деревьев-механизмов — Ржавая роща, восток Звенца', - progressKey: VARS.tones, - goal: QUEST_TONES, - dialogue: 'clint_tune', - ready: { whenVars: [{ key: VARS.tones, op: 'ge', value: QUEST_TONES }] }, - doneFlag: FLAGS.quest_resonator_done - }, - // Эпилог: подсказка о Машине — мост к финалу акта. - { - npc: 'clint', - text: 'Зайти к Клинту — резонатор держит тон', - dialogue: 'clint_repeat', - doneFlag: FLAGS.machine_hook - } - ] - } -]; - -/** Активная стадия: первая незавершённая (эпилог без doneFlag — тоже стадия). */ -export function activeStage(state: GameState, quest: QuestDef): QuestStage | null { - for (const stage of quest.stages) { - if (stage.doneFlag !== undefined && state.hasFlag(stage.doneFlag)) continue; - return stage; - } - return null; -} - -/** - * Диалог NPC по квест-стадиям: активная стадия этого NPC с готовым диалогом. - * world — предикаты мира (сумка) для hasItem-условий. null — repeat-диалог. - */ -export function questDialogueFor(state: GameState, npcId: string, world?: DialogueWorld): string | null { - for (const quest of QUESTS) { - const stage = activeStage(state, quest); - if (!stage || stage.npc !== npcId || !stage.dialogue) continue; - if (stage.ready && !evalConditions(stage.ready, state, world)) continue; - return stage.dialogue; - } - return null; -} - -/** Одна строка журнала квестов. */ -export interface QuestEntry { - done: boolean; - text: string; -} - -/** Журнал: достигнутые стадии квестов. Взятия-стадия видна после принятия — - * журнал не «знает» о квесте раньше героя. */ -export function questLog(state: GameState): QuestEntry[] { - const entries: QuestEntry[] = []; - for (const quest of QUESTS) { - let reached = true; - quest.stages.forEach((stage, i) => { - const done = stage.doneFlag === undefined || state.hasFlag(stage.doneFlag); - // Взятия-стадия (первая) видна, только когда принята (done); - // остальные достигнутые стадии — всегда. - const show = i > 0 || done; - if (show && reached) { - const progress = - stage.progressKey !== undefined && stage.goal !== undefined - ? ` (${Math.min(state.getNumber(stage.progressKey), stage.goal)}/${stage.goal})` - : ''; - entries.push({ done, text: `${stage.text}${progress}` }); - } - if (!done) reached = false; - }); - } - return entries; -} - -/** Трекер HUD: текст первой активной стадии с прогрессом (null — активных нет). */ -export function questTracker(state: GameState): string | null { - for (const quest of QUESTS) { - const stage = activeStage(state, quest); - if (!stage) continue; - const progress = - stage.progressKey !== undefined && stage.goal !== undefined - ? ` (${Math.min(state.getNumber(stage.progressKey), stage.goal)}/${stage.goal})` - : ''; - return `${stage.text}${progress}`; - } - return null; -} \ No newline at end of file diff --git a/apps/game/src/data/saves.ts b/apps/game/src/data/saves.ts deleted file mode 100644 index 60005bf..0000000 --- a/apps/game/src/data/saves.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Слоты сохранений и их подписи: конфиг вместо хардкода в сцене. - * autosave — служебный слот автосейва (Esc в локации), только загрузка. - */ - -export const SAVE_SLOTS = ['autosave', 'slot1', 'slot2', 'slot3'] as const; - -/** Человекочитаемый заголовок слота (для экрана сейвов). */ -export function slotTitle(slot: string): string { - return slot === 'autosave' ? 'Автосохранение' : slot.replace('slot', 'Слот '); -} - -/** Автосейв перезаписывается игрой — удалять его вручную нельзя. */ -export function canDelete(slot: string): boolean { - return slot !== 'autosave'; -} \ No newline at end of file diff --git a/apps/game/src/data/sfxSpecs.ts b/apps/game/src/data/sfxSpecs.ts deleted file mode 100644 index c13c70f..0000000 --- a/apps/game/src/data/sfxSpecs.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Sfx на спек-синтезе: ключ → SoundSpec (компилирует движок, WAV не нужен). - * Формулы — бывшие генераторы tools/audio/gen.mjs (функции экспортированы - * там же); парити-тест sfxSpecs.test.ts сверяет renderSpec с ними численно, - * поэтому миграция не изменила звучание. - */ -import type { AudioManager, PlayOptions, SoundSpec } from '@rpg/engine'; - -/** Реестр спек-звуков (ключи валидируются тем же validate-тестом, что и WAV). */ -export const SPEC_SFX: Record = { - // Бывший sfx/bell_hit.wav (bellHit): 4 партиала + клик, normalize 0.7. - 'sfx/bell_hit': { kind: 'chime', dur: 0.35, freq: 520, power: 5, seed: 1, peak: 0.7 }, - // Бывший sfx/whoosh.wav (whoosh): воздух «0 → 1 → 0», normalize 0.4. - 'sfx/whoosh': { kind: 'scrape', dur: 0.7, low: 200, high: 2400, seed: 160, swell: true, peak: 0.4 }, - // Бывший sfx/bell_low.wav (bellLow): три тона с тремоло 6 Гц. - 'sfx/bell_low': { - kind: 'tone', dur: 0.9, freq: 110, w: 0.5, power: 2, peak: 0.6, - wobbleHz: 6, wobbleDepth: 0.3, - layers: [{ freq: 165, w: 0.25 }, { freq: 220.5, w: 0.12 }] - }, - // Бывший sfx/ash_hiss.wav (ashHiss): шипение с нарастанием 0.08 с. - 'sfx/ash_hiss': { kind: 'scrape', dur: 0.4, low: 900, high: 4200, seed: 11, power: 2, attack: 0.08, peak: 0.55 }, - // Бывший sfx/ash_die.wav (ashDie): шум, громкость спадает вдвое к концу. - 'sfx/ash_die': { kind: 'scrape', dur: 0.6, low: 300, high: 2400, seed: 23, power: 3, fadeTo: 0.5, peak: 0.6 }, - // Бывший sfx/spit.wav (spit): тон со свипом 700→350 Гц + шипящий хвост. - 'sfx/spit': { - kind: 'tone', dur: 0.2, freq: 700, freqTo: 350, w: 0.6, power: 3, peak: 0.5, - layers: [{ low: 2000, high: 6000, seed: 37, w: 0.4 }] - }, - // Бывший sfx/hurt.wav (hurt): глухой удар 90 Гц + низкий шум. - 'sfx/hurt': { - kind: 'tone', dur: 0.25, freq: 90, w: 0.8, power: 3, peak: 0.65, - layers: [{ low: 80, high: 400, seed: 41, w: 0.3, dur: 0.125, power: 2 }] - }, - // Бывший sfx/mote_pickup.wav (motePickup): двойной звон 1240+1860 Гц - // (у верхнего партиала суммарная крутизна 4+2.5 — два затухания gen.mjs). - 'sfx/mote_pickup': { - kind: 'tone', dur: 0.45, freq: 1240, w: 0.4, power: 2.5, peak: 0.4, - layers: [{ freq: 1860, w: 0.2, power: 6.5 }] - }, - // Бывший sfx/pickup.wav (pickup): «пок» со взлётом 520→740 Гц. - 'sfx/pickup': { kind: 'tone', dur: 0.14, freq: 520, freqTo: 740, power: 2.5, peak: 0.4 }, - // Бывший sfx/chest_open.wav (chestOpen): скрип петель + удар крышки 110 Гц. - 'sfx/chest_open': { - kind: 'scrape', dur: 0.55, low: 300, high: 1400, seed: 157, power: 1.5, w: 0.35, attack: 0.1, peak: 0.45, - layers: [{ freq: 110, w: 0.7, dur: 0.12, power: 2 }] - }, - // Бывший sfx/note.wav (note): шелест с гулким «воблером» 3.2 Гц. - 'sfx/note': { - kind: 'scrape', dur: 0.35, low: 1800, high: 6000, seed: 159, power: 1.5, peak: 0.3, - wobbleHz: 3.2, wobbleDepth: 0.6, wobbleBase: 0.4, wobblePhase: 1 - }, - // Бывший sfx/deer_startle.wav (deerStartle): выдох + стук копыт (блип 90 Гц). - 'sfx/deer_startle': { - kind: 'scrape', dur: 0.4, low: 150, high: 900, seed: 161, power: 2, w: 0.7, attack: 0.05, peak: 0.4, - layers: [{ freq: 90, w: 0.6, dur: 0.08, delay: 0.2 }] - }, - // Бывший sfx/ui_click.wav (uiClick): короткий чистый тон 880 Гц. - 'sfx/ui_click': { kind: 'tone', dur: 0.06, freq: 880, power: 3, peak: 0.4 }, - // Бывший sfx/chime.wav (chime): колокольчик Милы, 1560 Гц + партиал 1.61×. - 'sfx/chime': { - kind: 'tone', dur: 0.5, freq: 1560, w: 0.5, power: 3, peak: 0.45, - layers: [{ freq: 1560 * 1.61, w: 0.25 }] - }, - // Шаги (бывшие sfx/step_*.wav): до unlock играет спек, варианты — - // renderSpec с сидами seed+i·10 (см. systems/StepVariants.ts). - 'sfx/step_grass': { kind: 'hit', dur: 0.12, low: 400, high: 2000, seed: 253, power: 2, peak: 0.3 }, - 'sfx/step_ash': { kind: 'hit', dur: 0.14, low: 200, high: 1100, seed: 254, power: 2.5, peak: 0.3 }, - 'sfx/step_water': { - kind: 'hit', dur: 0.18, low: 600, high: 3200, seed: 255, power: 2, w: 0.6, peak: 0.3, - layers: [{ freq: 300, w: 0.2 }] - }, - 'sfx/step_floor': { - kind: 'hit', dur: 0.1, low: 150, high: 800, seed: 256, power: 3, w: 0.4, peak: 0.3, - layers: [{ freq: 140, w: 0.5 }] - }, - // Дальний шорох пепла («мир живой»): медленный вздох шума, панорама/вариации - // задаёт тик сцены (LocationScene.updateAmbient). - 'sfx/rustle': { - kind: 'scrape', dur: 0.5, low: 400, high: 1800, seed: 263, power: 1.2, attack: 0.15, peak: 0.25 - } -}; - -/** - * Room tone интерьеров: тихий луп-гул «внутри помещения» (шина ambience). - * Ключи — строковые id областей (см. locations.ts), БЕЗ типа AreaId — иначе - * цикл импортов audio.ts → sfxSpecs → locations → interactables → audio.ts. - * Проверка «ключи ⊆ AREAS» — в validate-тестах. - */ -export const ROOM_TONES: Record = { - // Дом Ирвина: очаг + скрип половиц — низкий гул с медленным «дыханием». - house_elder: { kind: 'hum', tone: 52, dur: 8, power: 2, peak: 0.3 }, - // Лавка Милы: чуть выше и с лёгким тремоло (колокольчики на витрине). - shop: { kind: 'hum', tone: 66, dur: 8, power: 3, peak: 0.28, wobbleHz: 0.4, wobbleDepth: 0.15 } -}; - -/** - * Единая точка проигрывания sfx по ключу: спек-звук — через playSpec - * (движок компилирует сам), файловый — через play. Все play-сайты игры - * зовут этот хелпер, а не audio.play напрямую. - */ -export function playSfx(audio: AudioManager, key: string, opts?: number | PlayOptions): void { - const spec = SPEC_SFX[key]; - if (spec) void audio.playSpec(key, spec, opts); - else void audio.play(key, opts); -} \ No newline at end of file diff --git a/apps/game/src/data/transitions.ts b/apps/game/src/data/transitions.ts deleted file mode 100644 index 720e612..0000000 --- a/apps/game/src/data/transitions.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { TransitionDef } from './locations'; -import type { ItemId } from './items'; - -/** - * Чистый выбор перехода: по тайлу и триггеру, с условиями и дисармом. - * Вынесен из сцены — тестируется в Vitest без браузера. - */ - -/** Что резолверу нужно знать о герое (подставляется из GameState/Inventory). */ -export interface TransitionCtx { - hasFlag(flag: string): boolean; - hasItem(id: ItemId): boolean; -} - -/** Результат выбора: переход доступен / отказ (заперт) / ничего на этом тайле. */ -export type TransitionPick = - | { ok: true; def: TransitionDef } - | { ok: false; lockedText: string } - | null; - -/** - * Первый переход на тайле с этим триггером. step-переходы на disarmTile - * игнорируются (герой только что вошёл через него — иначе немедленный вылет). - */ -export function resolveTransition( - defs: TransitionDef[], - tile: { x: number; y: number }, - trigger: 'step' | 'click', - ctx: TransitionCtx, - disarmTile?: { x: number; y: number } | null -): TransitionPick { - const def = defs.find( - (t) => - t.tile.x === tile.x && - t.tile.y === tile.y && - (t.trigger ?? 'step') === trigger && - !(trigger === 'step' && disarmTile && t.tile.x === disarmTile.x && t.tile.y === disarmTile.y) - ); - if (!def) return null; - if (def.requiresFlag && !ctx.hasFlag(def.requiresFlag)) { - return { ok: false, lockedText: def.lockedText ?? 'Не пускает.' }; - } - if (def.requiresItem && !ctx.hasItem(def.requiresItem)) { - return { ok: false, lockedText: def.lockedText ?? 'Нужен предмет.' }; - } - return { ok: true, def }; -} \ No newline at end of file diff --git a/apps/game/src/data/validate.ts b/apps/game/src/data/validate.ts deleted file mode 100644 index 7885e11..0000000 --- a/apps/game/src/data/validate.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { - checkBounds, - checkWalkable, - gridOf, - mergeInvariants, - type Invariant, - type TileMapData, - type DialogueGraph -} from '@rpg/engine'; -import { AREAS } from './locations'; -import { NPCS, NPC_PLACEMENTS } from './npcs'; -import { DIALOGUES } from './dialogues'; -import { ENEMY_KINDS } from './enemies'; -import { QUESTS } from './quests'; -import { FLAGS, VARS } from './ids'; -import { ITEMS } from './items'; -import { TILES } from './map'; -import { AMBIENCE_KEYS, AMBIENCE_LAYER_KEYS } from './audio'; -import { THEMES } from './music'; -import { ROOM_TONES, SPEC_SFX } from './sfxSpecs'; -import { DIALOGUE_CUSTOM } from './effects'; -import { checkGraph, type GraphRefs } from './dialogueRules'; - -/** - * Runtime-валидация контента → инварианты (замена JSON Schema: истина одна — - * в TS-типах; здесь ловим то, что типы не выражают: ссылки, границы, стены). - * Вызывается из GameAgent.invariants() и из юнит-теста — битый контент падает - * сразу в тестах, а не в рантайме игры. Правила графов диалогов — общие с CLI - * dry-run и редактором (data/dialogueRules.ts). - */ - -const WHERE = 'data'; - -/** Реестры для checkGraph (чистые правила знают только множества id). */ -export function graphRefs(usedFlags?: Set, usedVars?: Set): GraphRefs { - return { - flags: new Set(Object.keys(FLAGS)), - vars: new Set(Object.keys(VARS)), - items: new Set(Object.keys(ITEMS)), - customs: new Set(Object.keys(DIALOGUE_CUSTOM)), - strings: new Set(), - usedFlags, - usedVars - }; -} - -/** Граф диалога: все правила checkGraph (ссылки, next, сироты, циклы, концы). */ -export function validateDialogue(id: string, graph: DialogueGraph): Invariant[] { - return checkGraph(id, graph, graphRefs()); -} - -/** NPC: не в стенах, в границах своей области (включая размещения по флагам). */ -export function validateNpcs(maps: Map): Invariant[] { - const out: Invariant[] = []; - for (const npc of NPCS) { - for (const area of Object.values(AREAS)) { - if (!area.npcs.some((n) => n.id === npc.id)) continue; - const map = maps.get(area.id); - if (!map) continue; - const grid = gridOf(map); - const check = mergeInvariants( - checkBounds(`NPC ${npc.id}`, npc.tile, map.width, map.height, WHERE), - checkWalkable(`NPC ${npc.id}`, npc.tile, grid, WHERE) - ); - out.push(...check); - } - } - // Размещения: тайл своей области должен быть проходим всегда — флаг может - // подняться в любой момент, NPC не должен появиться в стене. - for (const p of NPC_PLACEMENTS) { - const map = maps.get(p.area); - if (!map) continue; - const grid = gridOf(map); - out.push(...mergeInvariants( - checkBounds(`NPC ${p.def.id} (placement)`, p.def.tile, map.width, map.height, WHERE), - checkWalkable(`NPC ${p.def.id} (placement)`, p.def.tile, grid, WHERE) - )); - } - return out; -} - -/** Области: спавн/враги проходимы, переходы ведут в существующие области и проходимые entry. */ -export function validateLocations(maps: Map): Invariant[] { - const out: Invariant[] = []; - for (const loc of Object.values(AREAS)) { - const map = maps.get(loc.id); - if (!map) continue; - const grid = gridOf(map); - const where = `${WHERE}/area/${loc.id}`; - out.push(...mergeInvariants( - checkBounds('spawn', loc.spawn, map.width, map.height, where), - checkWalkable('spawn', loc.spawn, grid, where) - )); - loc.enemies.forEach((e, i) => { - out.push(...mergeInvariants( - checkBounds(`враг#${i}`, e.tile, map.width, map.height, where), - checkWalkable(`враг#${i}`, e.tile, grid, where) - )); - // Патруль: точки в границах и проходимы (иначе враг врежется в стену). - (e.patrol?.points ?? []).forEach((p, j) => { - out.push(...mergeInvariants( - checkBounds(`враг#${i} патруль#${j}`, p, map.width, map.height, where), - checkWalkable(`враг#${i} патруль#${j}`, p, grid, where) - )); - }); - }); - // Зоны наката: тайлы в границах карты. - for (const [i, h] of (loc.hazards ?? []).entries()) { - h.tiles.forEach((t, j) => { - out.push(...mergeInvariants(checkBounds(`накат#${i} тайл#${j}`, t, map.width, map.height, where))); - }); - } - // Источники света: центр в границах (дробные at — содержащий тайл), - // id тайлов-светильников существуют; дубли id в одной области — ошибка. - const lightIds = new Set(); - for (const [i, s] of (loc.lighting?.sources ?? []).entries()) { - const lw = `${where}/light#${i}`; - if (lightIds.has(s.id)) { - out.push({ id: 'light-id', severity: 'error', message: `источник света «${s.id}» уже есть в области`, where: lw }); - } - lightIds.add(s.id); - if (s.at) out.push(...mergeInvariants(checkBounds(`light#${i} at`, { x: Math.floor(s.at.x), y: Math.floor(s.at.y) }, map.width, map.height, lw))); - for (const tileId of s.tiles ?? []) { - if (!(Object.values(TILES) as number[]).includes(tileId)) { - out.push({ id: 'light-tile', severity: 'error', message: `light#${i}: тайл ${tileId} не из TILES`, where: lw }); - } - } - if (!s.at && !(s.tiles && s.tiles.length > 0)) { - out.push({ id: 'light-source-empty', severity: 'error', message: `light#${i} «${s.id}»: ни at, ни tiles — источник никогда не включится`, where: lw }); - } - } - for (const obj of loc.interactables ?? []) { - out.push(...mergeInvariants(checkBounds(`объект ${obj.id}`, obj.tile, map.width, map.height, where))); - } - for (const [i, t] of loc.transitions.entries()) { - // Тайл-триггер в границах своей карты (может быть и непроходим — колодец). - out.push(...mergeInvariants(checkBounds(`переход#${i}`, t.tile, map.width, map.height, where))); - if (t.target.kind !== 'area') continue; - const to: string = t.target.area; - if (!(to in AREAS)) { - out.push({ id: 'transition-target', severity: 'error', message: `переход#${i} -> «${to}»: область не существует`, where }); - continue; - } - const targetMap = maps.get(to); - if (targetMap) { - out.push(...mergeInvariants( - checkBounds(`переход#${i} entry (${to})`, t.target.entry, targetMap.width, targetMap.height, where), - checkWalkable(`переход#${i} entry (${to})`, t.target.entry, gridOf(targetMap), where) - )); - } - } - } - return out; -} - -/** Виды врагов: базовая числовая согласованность. */ -export function validateEnemies(): Invariant[] { - const out: Invariant[] = []; - for (const kind of Object.values(ENEMY_KINDS)) { - const where = `${WHERE}/enemy/${kind.id}`; - if (kind.hp <= 0) out.push({ id: 'enemy-hp', severity: 'error', message: `hp = ${kind.hp}`, where }); - if (kind.speed < 0) out.push({ id: 'enemy-speed', severity: 'error', message: `speed = ${kind.speed}`, where }); - } - return out; -} - -/** - * Референциальная целостность строковых сущностей: каждое упоминание флага/ - * вара в контенте существует в реестрах ids.ts (error), каждый ключ реестра - * где-то упомянут (warn — «мёртвый» флаг); ссылки квестов на NPC/диалоги — - * error. Движковые типы — строки, поэтому реестр сверяется только здесь. - */ -export function validateReferences(): Invariant[] { - const out: Invariant[] = []; - const flagsUsed = new Set(); - const varsUsed = new Set(); - - const checkFlag = (flag: string, where: string, what: string): void => { - flagsUsed.add(flag); - if (!(flag in FLAGS)) { - out.push({ id: 'flag-unknown', severity: 'error', message: `${what}: флаг «${flag}» вне реестра FLAGS`, where }); - } - }; - const checkVar = (id: string, where: string, what: string): void => { - varsUsed.add(id); - if (!(id in VARS)) { - out.push({ id: 'var-unknown', severity: 'error', message: `${what}: вар «${id}» вне реестра VARS`, where }); - } - }; - - // Графы диалогов — общие правила checkGraph (и упоминания для dead-проверки). - for (const [id, graph] of Object.entries(DIALOGUES)) { - out.push(...checkGraph(id, graph, graphRefs(flagsUsed, varsUsed))); - } - for (const area of Object.values(AREAS)) { - const where = `${WHERE}/area/${area.id}`; - for (const [i, t] of area.transitions.entries()) { - const tw = `переход#${i} (${t.tile.x},${t.tile.y})`; - if (t.requiresFlag !== undefined) checkFlag(t.requiresFlag, where, tw); - if (t.requiresItem !== undefined && !(t.requiresItem in ITEMS)) { - out.push({ id: 'item-unknown', severity: 'error', message: `${tw}: requiresItem «${t.requiresItem}» вне реестра ITEMS`, where }); - } - } - // Условные источники света: опечатка во флаге = свет никогда не включится. - for (const [i, s] of (area.lighting?.sources ?? []).entries()) { - const lw = `${where}/light#${i}`; - if (s.whenFlag !== undefined) checkFlag(s.whenFlag, lw, 'whenFlag'); - if (s.notFlag !== undefined) checkFlag(s.notFlag, lw, 'notFlag'); - } - // Низины: полотно без реестра предметов = маска никогда не сработает. - for (const [i, h] of (area.hazards ?? []).entries()) { - if (!(h.requiresItem in ITEMS)) { - out.push({ id: 'item-unknown', severity: 'error', message: `накат#${i} «${h.name}»: requiresItem «${h.requiresItem}» вне реестра ITEMS`, where }); - } - } - // Интерьеры (atmosphere 'none') выходят только назад: ровно один return-переход. - if (area.atmosphere === 'none') { - const returns = area.transitions.filter((t) => t.target.kind === 'return').length; - if (returns !== 1) { - out.push({ id: 'interior-return', severity: 'error', message: `интерьер должен иметь ровно один kind:'return' переход (сейчас ${returns})`, where }); - } - } - for (const obj of area.interactables ?? []) { - const w = `${where}/interactable/${obj.id}`; - for (const r of obj.responses) { - if (r.when?.flag !== undefined) checkFlag(r.when.flag, w, 'when.flag'); - if (r.when?.notFlag !== undefined) checkFlag(r.when.notFlag, w, 'when.notFlag'); - for (const f of r.setFlags ?? []) checkFlag(f, w, 'setFlags'); - for (const f of r.clearFlags ?? []) checkFlag(f, w, 'clearFlags'); - if (r.setVar) checkVar(r.setVar.id, w, 'setVar.id'); - if (r.addVar) checkVar(r.addVar.id, w, 'addVar.id'); - if (r.custom !== undefined && !(r.custom in DIALOGUE_CUSTOM)) { - out.push({ id: 'custom-unknown', severity: 'error', message: `${obj.id}: custom «${r.custom}» вне реестра DIALOGUE_CUSTOM`, where: w }); - } - } - } - } - for (const quest of QUESTS) { - const where = `${WHERE}/quest/${quest.id}`; - for (const [i, stage] of quest.stages.entries()) { - if (stage.doneFlag !== undefined) checkFlag(stage.doneFlag, where, `стадия#${i}.doneFlag`); - if (stage.progressKey !== undefined) checkVar(stage.progressKey, where, `стадия#${i}.progressKey`); - // NPC квеста — из основной шпаргалки или размещений по флагам. - if (![...NPCS, ...NPC_PLACEMENTS.map((p) => p.def)].some((n) => n.id === stage.npc)) { - out.push({ id: 'npc-ref', severity: 'error', message: `стадия#${i}: NPC «${stage.npc}» не существует`, where }); - } - if (stage.dialogue !== undefined && !(stage.dialogue in DIALOGUES)) { - out.push({ id: 'dialogue-ref', severity: 'error', message: `стадия#${i}: диалог «${stage.dialogue}» не существует`, where }); - continue; - } - // Стадия с doneFlag должна выставляться setFlags в графе стадии — - // иначе диалог никогда её не завершит. - if (stage.doneFlag !== undefined && stage.dialogue !== undefined) { - const graph = DIALOGUES[stage.dialogue]; - const sets = new Set(); - for (const node of Object.values(graph.nodes)) { - for (const n of [node, ...(node.choices ?? [])]) { - for (const f of n.setFlags ?? []) sets.add(f); - } - } - if (!sets.has(stage.doneFlag)) { - out.push({ id: 'quest-stage-unreachable', severity: 'error', message: `стадия#${i}: doneFlag «${stage.doneFlag}» не выставляется в графе «${stage.dialogue}»`, where }); - } - } - } - } - for (const npc of [...NPCS, ...NPC_PLACEMENTS.map((p) => p.def)]) { - checkFlag(npc.flagKey, `${WHERE}/npc/${npc.id}`, 'flagKey'); - if (!(npc.dialogueFirst in DIALOGUES)) { - out.push({ id: 'dialogue-ref', severity: 'error', message: `NPC ${npc.id}: dialogueFirst «${npc.dialogueFirst}» не существует`, where: `${WHERE}/npc/${npc.id}` }); - } - if (!(npc.dialogueRepeat in DIALOGUES)) { - out.push({ id: 'dialogue-ref', severity: 'error', message: `npc ${npc.id}: dialogueRepeat «${npc.dialogueRepeat}» не существует`, where: `${WHERE}/npc/${npc.id}` }); - } - } - - // Мёртвые сущности реестра: ключ не упомянут нигде в контенте (warn). - for (const flag of Object.keys(FLAGS)) { - if (!flagsUsed.has(flag)) { - out.push({ id: 'flag-dead', severity: 'warn', message: `флаг «${flag}» из реестра не упоминается в контенте`, where: WHERE }); - } - } - for (const id of Object.keys(VARS)) { - if (!varsUsed.has(id)) { - out.push({ id: 'var-dead', severity: 'warn', message: `вар «${id}» из реестра не упоминается в контенте`, where: WHERE }); - } - } - return out; -} - -/** - * Аудио-ключи контента: опечатка в ключе = тишина в рантайме (никто не упадёт). - * Лупы/темы/слои — по реестрам audio.ts и music.ts; sfx реакций и реплик — по - * спек-реестру; roomtone/* — ключ обязан быть id существующей области. - */ -export function validateAudio(): Invariant[] { - const out: Invariant[] = []; - const sfx = new Set(Object.keys(SPEC_SFX)); - for (const area of Object.values(AREAS)) { - const where = `${WHERE}/area/${area.id}`; - if (area.ambience !== undefined && !AMBIENCE_KEYS.includes(area.ambience as never)) { - out.push({ id: 'audio-key', severity: 'error', message: `ambience «${area.ambience}» вне AMBIENCE_KEYS`, where }); - } - if (area.theme !== undefined && !(area.theme in THEMES)) { - out.push({ id: 'audio-key', severity: 'error', message: `theme «${area.theme}» вне THEMES`, where }); - } - (area.ambienceLayers ?? []).forEach((layer, i) => { - if (!AMBIENCE_LAYER_KEYS.includes(layer.key as never)) { - out.push({ id: 'audio-key', severity: 'error', message: `ambienceLayer#${i} «${layer.key}» вне AMBIENCE_LAYER_KEYS`, where }); - } - }); - for (const obj of area.interactables ?? []) { - for (const [i, r] of obj.responses.entries()) { - if (r.sound !== undefined && !sfx.has(r.sound)) { - out.push({ id: 'audio-key', severity: 'error', message: `${obj.id} response#${i}: sfx «${r.sound}» вне спек-реестра`, where: `${where}/${obj.id}` }); - } - } - } - } - for (const [id, graph] of Object.entries(DIALOGUES)) { - for (const [nodeId, node] of Object.entries(graph.nodes)) { - for (const n of [node, ...(node.choices ?? [])]) { - for (const op of n.do ?? []) { - if (op.kind === 'sound' && op.id !== undefined && !sfx.has(op.id)) { - out.push({ id: 'audio-key', severity: 'error', message: `узел «${nodeId}»: sfx «${op.id}» вне спек-реестра`, where: `${WHERE}/dialogue/${id}` }); - } - } - } - } - } - for (const key of Object.keys(ROOM_TONES)) { - if (!(key in AREAS)) { - out.push({ id: 'audio-key', severity: 'error', message: `roomtone «${key}»: области не существует`, where: `${WHERE}/roomtone` }); - } - } - return out; -} - -/** Весь контент разом (для снапшота моста и тестов). */ -export function validateContent(maps: Map): Invariant[] { - return mergeInvariants( - ...Object.entries(DIALOGUES).map(([id, g]) => validateDialogue(id, g)), - validateNpcs(maps), - validateLocations(maps), - validateEnemies(), - validateAudio(), - validateReferences() - ); -} \ No newline at end of file diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts deleted file mode 100644 index 9d0070f..0000000 --- a/apps/game/src/main.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Engine, computeScale, ensurePixelFont, type EngineOptions } from '@rpg/engine'; -import { Game } from './Game'; -import { BootScene } from './scenes/BootScene'; - -async function main(): Promise { - const container = document.getElementById('game')!; - - const options: EngineOptions = { - virtualWidth: Game.VIRTUAL_W, - virtualHeight: Game.VIRTUAL_H, - scale: computeScale(Game.VIRTUAL_W, Game.VIRTUAL_H, window.innerWidth, window.innerHeight), - background: 0x0b0b12, - fixedFps: 60, - maxFps: 30, // лок отрисовки: шаги логики копятся и выполняются пачкой - parent: container - }; - - const engine = new Engine(options); - await engine.init(); - engine.input.bindActions({ - advance: ['Space', 'Enter'], - attack: ['Space'], // вне диалога: тап — удар, удержание — резонанс - interact: ['KeyE'], // действие вблизи: NPC/объекты/цветы/переходы (только в локации) - menu: ['Escape'], - inventory: ['KeyI'], - debug: ['F3'], - debugChar: ['KeyP'], - debugZoom: ['KeyZ'], // отладка зума: уровни 1 <-> 2 (целые — пиксель-точность) - up: ['KeyW', 'ArrowUp'], - down: ['KeyS', 'ArrowDown'], - left: ['KeyA', 'ArrowLeft'], - right: ['KeyD', 'ArrowRight'] - }); - engine.input.bindGamepad({ - advance: [0], // A - attack: [2], // X - interact: [0], // A — действие в локации (в диалоге та же кнопка = advance) - menu: [9], // Start - inventory: [3], // Y - up: [12], - down: [13], - left: [14], - right: [15] - }); - - // Пиксельный шрифт движка (VT323): до первого текста; фолбэк monospace безопасен. - void ensurePixelFont(Game.FONT_URL); - - const game = new Game(engine); - - // Аудио разрешено только после жеста пользователя: разблокируем на первом вводе. - const unlockAudio = (): void => { - void game.audio.unlock(); - window.removeEventListener('pointerdown', unlockAudio); - window.removeEventListener('keydown', unlockAudio); - }; - window.addEventListener('pointerdown', unlockAudio); - window.addEventListener('keydown', unlockAudio); - - // Автосейв при закрытии/скрытии вкладки: localStorage.write синхронен — - // успеваем до выгрузки (активная локация знает своё состояние). - const saveOnLeave = (): void => { - game.activeLocation?.autosave(); - }; - window.addEventListener('beforeunload', saveOnLeave); - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') saveOnLeave(); - }); - - 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 deleted file mode 100644 index b772595..0000000 --- a/apps/game/src/scenes/BootScene.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Container, Graphics, PixelText, ensurePixelFont, type Scene } from '@rpg/engine'; -import { Game } from '../Game'; -import { AREAS, areaOf } from '../data/locations'; -import { AMBIENCE_KEYS, AMBIENCE_LAYER_KEYS, areaAudioKeys } from '../data/audio'; -import { MenuScene } from './MenuScene'; - -/** Атласы персонажей (Spritesheet JSON + PNG). */ -const ATLAS_KEYS = ['chars/hero_sheet.json', 'chars/clumps_sheet.json', 'chars/fauna_sheet.json', 'chars/npcs_sheet.json']; - -/** - * Загрузочная сцена: грузит ассеты с прогресс-баром, затем меню. - */ -export class BootScene implements Scene { - private view = new Container(); - private bar: Graphics; - private started = false; - private progress = -1; - - constructor(private game: Game) { - const label = new PixelText({ - text: 'пепел оседает...', - size: 11, - color: 0x8a8a9a - }); - label.anchor.set(0.5); - label.position.set(240, 120); - this.bar = new Graphics(); - this.view.addChild(label, this.bar); - this.game.renderer.uiRoot.addChild(this.view); - } - - enter(): void {} - - exit(): void { - this.view.destroy({ children: true }); - } - - update(_dt: number): void { - if (!this.started) { - this.started = true; - void this.loadAll(); - } - if (this.progress >= 0) { - this.drawBar(this.progress); - this.progress = -1; // перерисовать один раз на изменение - } - } - - /** - * Параллельная загрузка всех групп с общим прогресс-баром: текстуры (0.6), - * атласы (0.15), карты (0.15), шрифт + амбиент стартовой области (0.1). - * Остальные лупы амбиента декодируются лениво при входе в область - * (LocationScene.enter -> game.preloadAudio) — до меню их ждать не нужно. - */ - private async loadAll(): Promise { - const areaIds = Object.keys(AREAS); - let texP = 0; - let atlasP = 0; - let mapP = 0; - const setProgress = () => { - this.progress = texP * 0.6 + atlasP * 0.15 + mapP * 0.15; - }; - try { - await Promise.all([ - this.game.assets.load(Game.ASSET_KEYS, (p) => { - texP = p; - setProgress(); - }), - ...ATLAS_KEYS.map((key) => - this.game.assets.loadAtlas(key).then(() => { - atlasP += 1 / ATLAS_KEYS.length; - setProgress(); - }) - ), - ...areaIds.map((id) => - this.game.loadMap(id).then(() => { - mapP += 1 / areaIds.length; - setProgress(); - }) - ), - ensurePixelFont(Game.FONT_URL), - (async () => { - // Регистрация ключей дешёвая (URL в карту); декодируем только - // лупы стартовой области, чтобы амбиент сразу не тормозил. - for (const key of [...AMBIENCE_KEYS, ...AMBIENCE_LAYER_KEYS]) { - this.game.audio.register(key); - } - await Promise.all(areaAudioKeys(areaOf(undefined)).map((k) => this.game.audio.preload(k))); - })() - ]); - this.progress = 1; - console.log('[boot] ассеты загружены, меню...'); // маяк для смоук-тестов - void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); - } catch (err) { - console.error('[boot] ошибка загрузки:', err); - } - } - - private drawBar(p: number): void { - this.bar.clear(); - this.bar.rect(180, 136, 120, 6).fill(0x23232b); - this.bar.rect(180, 136, Math.round(120 * p), 6).fill(0xd99a32); - } - - render(): void {} -} \ No newline at end of file diff --git a/apps/game/src/scenes/InventoryScene.ts b/apps/game/src/scenes/InventoryScene.ts deleted file mode 100644 index fa29265..0000000 --- a/apps/game/src/scenes/InventoryScene.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { playSfx } from '../data/sfxSpecs'; -import { Container, MenuList, MenuSceneBase, Panel, PixelText, Sprite } from '@rpg/engine'; -import { Game } from '../Game'; -import { questLog } from '../data/quests'; -import { itemName, useItemLine, type ItemId } from '../data/items'; -import { MapScene } from './MapScene'; - -/** - * Сумка и журнал квестов (панель поверх локации, push/pop). - * Содержимое собирается из флагов/варов GameState при каждом открытии. - * Список сумки с курсором: Enter — применить предмет (часы показывают время, - * время в сумке заморожено — локация не тикается). Журнал — текст без фокуса. - */ - -/** Иконка предмета 8×8 (арт-библия §4): ui/icon_.png. */ -const ITEM_ICONS: Record = { - clock: 'ui/icon_clock', - cloth: 'ui/icon_cloth', - bellflower: 'ui/icon_bellflower', - salt: 'ui/icon_salt', - water_flask: 'ui/icon_flask', - mote: 'ui/icon_mote', - map_scroll: 'ui/icon_map' -}; -export class InventoryScene extends MenuSceneBase { - /** Иконки строк сумки (синхронизируются с окном скролла). */ - private icons: Container | null = null; - - constructor( - private game: Game, - private onBack: () => void - ) { - super( - { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, - { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', extra: ['inventory'] } - ); - } - - override update(dt: number): void { - super.update(dt); - this.syncIcons(); - } - - /** Иконки следуют за окном скролла: видимость и строка — как у кнопок. */ - private syncIcons(): void { - if (!this.icons || !this.menu) return; - const win = this.menu.window ?? { first: 0, last: this.icons.children.length }; - this.icons.children.forEach((icon, i) => { - icon.visible = i >= win.first && i < win.last; - icon.position.y = 7 + (i - win.first) * 15; - }); - } - - protected build(): void { - const panel = new Panel({ width: 300, height: 222 }); - panel.position.set((Game.VIRTUAL_W - 300) / 2, (Game.VIRTUAL_H - 222) / 2); - - const title = new PixelText({ text: 'ЗВОНАРЬ', size: 14, color: 0xd8c79a }); - title.anchor.set(0.5); - title.position.set(150, 14); - panel.addChild(title); - - const addLine = (text: string, color: number, x: number, y: number): void => { - const t = new PixelText({ text, size: 10, color }); - t.position.set(x, y); - panel.addChild(t); - }; - - // Статус-строка: подсказка до первого применения, результат — после. - const result = new PixelText({ text: 'Enter — применить, Esc — назад', size: 9, color: 0xf2d49a }); - result.position.set(16, 28); - panel.addChild(result); - - // Сумка: список с курсором и скроллом (5 строк), Enter — применить. - addLine('Сумка', 0x999988, 16, 40); - const slots = this.game.inventory.all; - if (slots.length > 0) { - this.menu = new MenuList({ width: 260, height: 14, gap: 1, size: 10, visibleRows: 5 }); - this.menu.position.set(20, 52); - this.menu.setItems( - slots.map((slot) => ({ - label: slot.count > 1 ? `${itemName(slot.id)} ×${slot.count}` : itemName(slot.id), - value: '', // метка прижата влево — освобождаем место под иконку - onSelect: () => this.useItem(slot.id, result) - })) - ); - panel.addChild(this.menu); - // Иконки 8×8 слева от строк: тикаются с окном скролла (syncIcons). - this.icons = new Container(); - this.icons.position.set(20, 52); - slots.forEach((slot, i) => { - const icon = new Sprite(this.game.assets.texture(ITEM_ICONS[slot.id as ItemId])); - icon.anchor.set(0.5, 0.5); - icon.position.set(-6, 7 + i * 15); - this.icons!.addChild(icon); - }); - panel.addChild(this.icons); - } else { - addLine('— пусто —', 0x777788, 24, 54); - } - - // Журнал квестов — текущие задачи (незавершённые достигнутые стадии), - // без фокуса; перенос по ширине панели, шаг — по фактической высоте. - addLine('Журнал', 0x999988, 16, 132); - let y = 146; - let shown = 0; - for (const entry of questLog(this.game.state)) { - if (entry.done) continue; - const line = new PixelText({ - text: `· ${entry.text}`, - size: 10, - color: 0xaaaaaa, - wordWrapWidth: 264 - }); - line.position.set(24, y); - panel.addChild(line); - y += Math.ceil(line.height) + 3; - shown++; - } - if (shown === 0) addLine('— текущих задач нет —', 0x777788, 24, 146); - - this.view.addChild(panel); - this.game.renderer.uiRoot.addChild(this.view); - } - - /** Применить предмет: юза́бельные дают строку-результат, остальные молчат. */ - private useItem(id: string, result: PixelText): void { - // Карта окрестностей — не строка, а оверлей поверх сумки. - if (id === 'map_scroll') { - playSfx(this.game.audio, 'sfx/ui_click'); - void this.game.scenes.push( - new MapScene(this.game, () => void this.game.scenes.pop()), - { duration: 0.2 } - ); - return; - } - const line = useItemLine(id, { label: this.game.clock.label }); - if (line === null) return; - playSfx(this.game.audio, 'sfx/ui_click'); - result.text = line; - } - - /** Esc или клавиша инвентаря — назад в локацию. */ - protected onCancel(): void { - this.close(); - } - - protected onAction(action: string): void { - if (action === 'inventory') this.close(); - } - - private close(): void { - playSfx(this.game.audio, 'sfx/ui_click'); - this.onBack(); - } -} \ No newline at end of file diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts deleted file mode 100644 index a9e1043..0000000 --- a/apps/game/src/scenes/LocationScene.ts +++ /dev/null @@ -1,1026 +0,0 @@ -import { - Container, - IsoDepthLayer, - Lighting, - Sprite, - SpriteMotion, - Texture, - IsometricTileMap, - FxLayer, - emberPreset, - readSeconds, - createRng, - worldToScreen, - tileToWorld, - DebugOverlay, - SpriteDebugView, - VirtualJoystick, - CutsceneRunner, - dayNightFactor, - PostFx, - unitsToPx, - sunShadow, - type Camera, - type GameState, - type Invariant, - type JsonValue, - type Scene, - type SnapshotLayer, - type ShimmerParams, - type ShimmerState, - type Vec2, - type DialogueWorld -} from '@rpg/engine'; -import { Game } from '../Game'; -import { MenuScene } from './MenuScene'; -import { SAVE_VERSION, type SaveData } from './saveData'; -import { InventoryScene } from './InventoryScene'; -import { TILES, TREE_TILES } from '../data/map'; -import { areaAudioKeys } from '../data/audio'; -import { playSfx } from '../data/sfxSpecs'; -import { areaOf, type AreaDef, type AreaId, type TransitionDef } from '../data/locations'; -import { AudioSystem } from '../systems/AudioSystem'; -import type { StepSurface } from '../systems/StepVariants'; -import { InteractionRouter } from '../systems/InteractRouting'; -import { SceneAgentView } from '../agent/SceneAgentView'; -import type { NpcDef } from '../data/npcs'; -import { npcsForArea } from '../data/npcs'; -import { ActorWalker } from '../systems/cutscene/CutsceneActors'; -import { DIALOGUES } from '../data/dialogues'; -import { questDialogueFor, questTracker } from '../data/quests'; -import { DIALOGUE_CUSTOM, type DialogueCustomId } from '../data/effects'; -import type { ItemId } from '../data/items'; -import { FLAGS, VARS } from '../data/ids'; -import { PlayerController } from '../systems/PlayerController'; -import { GameShadows } from '../systems/Shadows'; -import { FaunaSystem } from '../systems/fauna/FaunaSystem'; -import { DialogueSystem } from '../systems/DialogueSystem'; -import { CombatWorld } from '../systems/combat/CombatWorld'; -import { CombatViews } from '../systems/combat/CombatViews'; -import { CombatFlow } from '../systems/combat/CombatFlow'; -import { PLAYER_COMBAT } from '../systems/combat/stats'; -import { Interactables } from '../systems/Interactables'; -import { GameLighting, MAX_LIGHTS, type HeroLampDef } from '../systems/Lighting'; -import { SceneObjects } from '../systems/SceneObjects'; -import { InteractHint } from '../systems/InteractHint'; -import { LocationAudio } from '../systems/LocationAudio'; -import { InteractableViews, makeExitGlow } from '../systems/InteractableViews'; -import { LocationHud } from '../systems/LocationHud'; -import { Atmosphere } from '../systems/Atmosphere'; -import { FlowerQuest } from '../systems/quest/FlowerQuest'; -import { ResonatorQuest } from '../systems/quest/ResonatorQuest'; -import { enemyTextures, heroTextures, tileTextures } from '../data/assetSets'; - -/** Лампа героя: тёплый, компактный, слегка мерцает (прорезает ночную тьму). */ -const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.0, intensity: 1.0, flicker: 0.12 }; - -/** - * Источники марева (пост-эффект «горячий воздух») по id интерактива: - * усыпленная Машина не дымит; очаг горит всегда. radiusUnits — юниты. - */ -const SHIMMER_SOURCES: Record = { - old_machine: { - amp: 1.4, - wavelength: 26, - speed: 1.8, - radiusUnits: 2.5, - visible: (s) => !s.hasFlag(FLAGS.machine_lulled) - }, - hearth_elder: { amp: 0.9, wavelength: 22, speed: 1.3, radiusUnits: 2, visible: () => true } -}; - -/** Осыпание пепла с крон: период спавна порции (сек) на все видимые деревья. */ -const CROWN_ASH_PERIOD = 0.9; - -/** - * Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc. - * Сцена — оркестратор вьюх: аудио/атмосфера/квест/вьюхи объектов живут - * своими подсистемами (systems/), а здесь только их сборка и тик. - */ -export class LocationScene implements Scene { - private world = new Container(); - private map: IsometricTileMap; - private player: PlayerController; - private dialogue: DialogueSystem; - /** Сюжетные custom-эффекты, отложенные до конца диалога. */ - private pendingCustom = new Set(); - private npcs: { def: NpcDef; view: Container; body: Sprite }[] = []; - private actors = new IsoDepthLayer(); - /** Аудио локации: слои/roomtone/шорохи/музыка угрозы. */ - private locationAudio: LocationAudio; - /** Атмосфера: пепел/мотыли/дымка, зоны наката, виньетка. */ - private atmosphere: Atmosphere; - /** Квест «Три цветка»: сбор/посадка/кат-сцена сдачи. */ - private flowers: FlowerQuest; - /** Квест резонатора (акт 2): кат-сцена настройки у Клинта. */ - private resonator: ResonatorQuest; - /** Вьюхи интерактивных объектов и NPC (фабрики + растворение). */ - private interactViews: InteractableViews; - /** Мигание при неуязвимости — процедурный blink (duty живой). */ - private heroMotion: SpriteMotion; - /** Название локации, подсказка, счётчик мотов (одна вьюха в uiRoot). */ - private hud: LocationHud; - private camera: Camera; - - // --- бой --- - private combat: CombatWorld; - private combatViews: CombatViews; - /** Боевая обвязка героя: ввод/резонанс/урон/респаун/hp-бар/кольцо заряда. */ - private flow: CombatFlow; - /** Слой мировых эффектов (кольца, частицы, растворения) — тикается engine.fx. */ - private fx: FxLayer; - /** Слой UI-эффектов (тосты): clear() не гасит мировые эффекты. */ - private uiFx: FxLayer; - /** Маршрутизация клика/переходов/отложенных взаимодействий. */ - private router: InteractionRouter; - /** Подпись «[E] Имя» над ближайшей целью кнопки действия. */ - private hint: InteractHint; - /** Интерактивные объекты области (сундуки, очаги, прилавки). */ - private interactables: Interactables; - /** Объекты сцены на движковом реестре (кто на тайле — для кликов/коллизий). */ - private objects: SceneObjects; - /** Освещение: движковый слой (lightRoot) + игровая обвязка. */ - private lightView: Lighting; - /** Пост-эффекты мира: блюр резонанса, аберрация урона, марево источников. */ - private postfx: PostFx; - /** Тени мира: блобы актёров + направленные тени tall-объектов. */ - private shadows: GameShadows; - /** - * Ручная зона марева моста (scene:postfx shimmer): перекрывает авто-источники. - * undefined — авто-режим (SHIMMER_SOURCES), null — выключено насовсем. - */ - private manualShimmer: ShimmerState | null | undefined; - private lighting: GameLighting; - /** Пейзажная фауна (безгласные олени). */ - private fauna: FaunaSystem; - /** Позиционный звук мира + события боя (звук — подпиской). */ - private worldAudio: AudioSystem; - /** off() подписок worldAudio (снять в exit). */ - private audioOff: () => void; - /** off() подписок на шину событий боя (снять в exit). */ - private eventOffs: (() => void)[] = []; - /** Кат-сцены (раннер шагов; на время сцены геймплей на паузе). */ - private cutscene = new CutsceneRunner(); - /** Ходоки актёров кат-сцен, по def NPC (создаются лениво). */ - private walkers = new Map(); - /** Тач-джойстик (активен только для касаний). */ - private joystick: VirtualJoystick; - private debug: DebugOverlay; - private charDebug: SpriteDebugView; - /** Последний тост (текст + тик) — канал текста для агентного моста. */ - private lastToast: { text: string; tick: number } | null = null; - /** Агентный мост сцены (снапшот/инварианты/команды). */ - private agentView: SceneAgentView; - /** Позиции тайлов-источников амбиент-слоёв, по id (собираются один раз). */ - private layerTilePos = new Map(); - /** Тайлы деревьев области (все виды) — источники осыпающегося пепла. */ - private crownTiles: Vec2[] = []; - /** Осыпание пепла с крон: накопитель периода, rng и счётчик seed частиц. */ - private crownAshT = 0; - private readonly crownAshRng = createRng(20260908); - private crownAshSeed = 1; - - constructor( - private game: Game, - save: SaveData | null, - private area: AreaDef, - /** Точка входа при переходе из другой области (приоритетнее save). */ - entry?: { x: number; y: number }, - /** Откуда вошли (для target kind 'return'): область + тайл триггера. */ - private returnTo?: { area: AreaId; entry: { x: number; y: number } } - ) { - this.camera = game.engine.camera; - const data = this.game.mapFiles.get(area.id)!; - this.map = new IsometricTileMap(data, tileTextures(this.game.assets)); - // Живая вода: один общий таймлайн на все клетки с id WATER (2 кадра). - this.map.setTileAnimation( - TILES.WATER, - [this.game.assets.texture('tiles/water_1'), this.game.assets.texture('tiles/water_2')], - 2 - ); - // Качание крон: у каждого вида дерева свои 2 кадра и свой таймлайн - // (fps слегка разный — виды качаются не в такт; деревья одного вида — - // синхронно, как вода). - for (const [id, name, fps] of [ - [TILES.TREE, 'tree', 1.5], - [TILES.TREE_V1, 'tree_2', 1.7], - [TILES.TREE_V2, 'tree_3', 1.3] - ] as const) { - this.map.setTileAnimation( - id, - [this.game.assets.texture(`tiles/${name}`), this.game.assets.texture(`tiles/${name}_sway`)], - fps - ); - } - this.world.addChild(this.map.view, this.actors); - // Выход интерьера: бронзовый контур проёма + подпись (у kind 'return'). - const exit = area.transitions.find((t) => t.target.kind === 'return'); - if (exit) { - const glow = makeExitGlow(this.tileCenter(exit.tile.x, exit.tile.y)); - this.world.addChildAt(glow, 1); // поверх пола карты, под акторами - } - // Источники амбиент-слоёв: позиции тайлов по id — один проход по карте. - for (let y = 0; y < data.height; y++) { - for (let x = 0; x < data.width; x++) { - const id = data.tiles[y * data.width + x]!; - const list = this.layerTilePos.get(id) ?? []; - list.push({ x, y }); - this.layerTilePos.set(id, list); - } - } - // Тайлы деревьев (все виды) — для осыпания пепла с крон. - this.crownTiles = TREE_TILES.flatMap((id) => this.layerTilePos.get(id) ?? []); - // Tall-объекты (деревья, дома-тайлы, валуны) — в общий depth-слой: - // герой за деревом/домом действительно прячется (глубина tx+ty, как у героев). - this.map.setTallDepthLayer(this.actors); - // Крупные объекты карты (дома) — в общий depth-слой по footprint'у. - for (const p of this.map.propViews) this.actors.addRect(p.view, p.prop.x, p.prop.y, p.prop.w, p.prop.h); - // Тени: ground-слой между картой и актёрами + направленные тени tall-объектов. - // data.tall — таблица «id тайла → TallSpec»; tall-ячейки ищем в tiles. - this.shadows = new GameShadows(this.game.renderer); - this.world.addChildAt(this.shadows.ground, 1); // поверх пола, под акторами - for (let idx = 0; idx < data.tiles.length; idx++) { - const raw = data.tall?.[data.tiles[idx]!]; - if (!raw) continue; - const spec = typeof raw === 'number' ? { height: raw } : raw; - const tx = idx % data.width; - const ty = Math.floor(idx / data.width); - this.shadows.addStatic(this.tileCenter(tx, ty), 22, unitsToPx(spec.height)); - } - this.game.renderer.worldRoot.addChild(this.world); - // Слой мировых эффектов (раньше вьюх объектов: consume растворяет через него). - this.fx = new FxLayer(); - this.world.addChild(this.fx); - this.game.engine.fx.add(this.fx); - // Единый реестр объектов сцены: NPC, интерактивы, пропы (враги/фауна - // добавляются позже своими системами). - this.objects = new SceneObjects(area, this.map); - - const startTile = entry ?? save?.pos ?? area.spawn; - // Тайл входа: step-переходы на нём глушатся, пока герой с него не ушёл - // (иначе вошёл в дверь — и немедленно вылетел обратно). - const disarmTile = entry ? { ...startTile } : null; - if (save?.state) this.game.state.load(save.state); - // Позиционный звук: слушатель — герой. - this.worldAudio = new AudioSystem({ - audio: this.game.audio, - events: this.game.engine.events, - getListener: () => this.player.position - }); - this.audioOff = this.worldAudio.attach(); - this.player = new PlayerController(this.map, heroTextures(this.game.assets), startTile, () => { - this.worldAudio.playStep(this.stepSurface(this.player.currentTile()), this.player.position, 0.35); - // Шаги — тихий шум (0.35): бодрых сгустков настораживает, спящих не будит. - this.combat.noise(this.player.position, 0.35); - }); - // Тела: герой не проходит сквозь NPC (расталкивание через реестр). - this.player.setBodies(this.objects.registry); - // Блоб-тень героя (первый ребёнок вьюхи — рисуется под спрайтом). - this.shadows.attachActor(this.player.view, 9, 12); - - // Мигание героя при неуязвимости — процедурный blink (duty меняется на лету). - this.heroMotion = new SpriteMotion(this.player.view, { blink: { period: 0.25, duty: 1 } }); - this.game.engine.fx.add(this.heroMotion); - - // Видимый фонарь на поясе (правый бок): свет HERO_LAMP — «от фонаря». - const lampSprite = new Sprite(this.game.assets.texture('chars/hero_lamp')); - lampSprite.anchor.set(0.5); - lampSprite.position.set(4, -10); - this.player.view.addChild(lampSprite); - - // Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе). - this.actors.add(this.player.view, startTile.x, startTile.y); - this.interactViews = new InteractableViews({ - assets: this.game.assets, - addFx: (u) => this.game.engine.fx.add(u), - fx: this.fx - }); - // NPC области: статичные из контента + размещения по флагам (акт 2). - // Снапшот/клики читают this.npcs — размещения подхватываются сами. - const placedNpcs = [...area.npcs, ...npcsForArea(area.id, (f) => this.game.state.hasFlag(f))]; - for (const def of placedNpcs) { - const made = this.interactViews.createNpcView(def, (tx, ty) => this.tileCenter(tx, ty)); - this.actors.add(made.view, def.tile.x, def.tile.y); - this.shadows.attachActor(made.view, 9, 12); - this.npcs.push({ def, view: made.view, body: made.body }); - if (!area.npcs.includes(def)) this.objects.addNpc(def); - } - - // Интерактивные объекты: те же вьюхи в actors-слое, реакции — через Interactables. - this.interactables = new Interactables(area.interactables ?? [], this.game.state, { - hasItem: (id) => this.game.inventory.has(id), - give: (id, n) => this.game.inventory.add(id, n), - playSound: (k) => this.worldAudio.playAt(k, this.player.position, 0.6), - showToast: (t) => this.showToast(t), - onCustom: (name) => this.onSceneCustom(name), - onConsumed: (def) => this.interactViews.consume(def, this.tileCenter, (s) => this.combatViews.hitBurst(s)) - }); - for (const def of area.interactables ?? []) { - // Одноразовые использованные объекты (моты, сундуки) не рисуем: - // флаг used живёт в GameState, вьюха при повторном входе возвращаться не должна. - if (def.once && this.interactables.isUsed(def.id)) continue; - const made = this.interactViews.createInteractableView(def, (tx, ty) => this.tileCenter(tx, ty)); - this.actors.add(made.view, def.tile.x, def.tile.y); - } - - // --- боевая система: враги на ECS, звуки/события/урон героя — через колбэки --- - this.combat = new CombatWorld( - { - map: this.map, - events: this.game.engine.events, - getPlayerPos: () => this.player.position, - damagePlayer: (dmg, from) => this.flow.onPlayerDamaged(dmg, from), - registry: this.objects.registry - }, - (kind) => { - // счётчики прогресса — в vars GameState - this.game.state.setVar(VARS.kills, this.game.state.getNumber(VARS.kills) + 1); - this.game.state.setVar(VARS.motes, this.game.state.getNumber(VARS.motes) + kind.motes); - } - ); - for (const s of area.enemies) { - this.combat.spawnEnemy(s.kind, tileToWorld(s.tile.x, s.tile.y), s.patrol ? { patrol: s.patrol } : undefined); - } - // Слой UI-эффектов (тосты) поверх uiRoot. - this.uiFx = new FxLayer(); - this.game.renderer.uiRoot.addChild(this.uiFx); - this.game.engine.fx.add(this.uiFx); - this.combatViews = new CombatViews( - this.combat, - this.actors, - enemyTextures(this.game.assets), - this.world, - this.fx, - this.game.assets, - this.shadows - ); - - // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира (в CombatFlow). - - // Пейзажная фауна: безгласные олени у берегов (сама по себе, вне боя). - this.fauna = new FaunaSystem( - this.actors, - this.game.engine.events, - data, - (area.fauna ?? []).map((t) => tileToWorld(t.x, t.y)), - this.objects.registry - ); - this.fauna.setFrames( - this.game.assets.animation('chars/fauna_sheet.json', 'fauna_deer_walk') as [Texture, Texture] - ); - // Мотыли над потревоженным пеплом: серые, дрейф вверх от точки звона - // (через слой эффектов: тик и зачистка — сами; раньше эмиттер не тикался). - this.eventOffs.push( - this.game.engine.events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => { - const s = worldToScreen(origin.x, origin.y); - this.fx.burst(6, { x: s.x, y: s.y - 8 }, emberPreset(0x8a8a96, { - blend: 'normal', - fadeIn: 0, - lifetime: [1.2, 2.2], - velocity: { x: [-6, 6], y: [-14, -8] }, - seed: ((origin.x * 13 + origin.y) | 0) || 1 - })); - }) - ); - - this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, this.game.state, { - width: Game.VIRTUAL_W, - height: Game.VIRTUAL_H, - margin: 8, - typewriter: 40, - moodColors: { sad: 0x9aaad8, angry: 0xd89a9a, warm: 0xd8c79a } - }); - this.dialogue.onDialogueFinished = () => this.onDialogueFinished(); - this.dialogue.onLineShown = () => void this.game.audio.play('sfx/chime', 0.5); - this.dialogue.registerSink({ - giveItem: (id, count) => this.game.inventory.add(id, count), - takeItem: (id, count) => this.game.inventory.remove(id, count), - playSound: (key) => void this.game.audio.play(key, 0.8), - showToast: (text) => this.showToast(text), - custom: (name) => { - // Сюжетные эффекты — по завершении диалога (кат-сцена не рвёт реплику). - this.pendingCustom.add(name); - } - }); - - // Квест «Три цветка»: сбор/посадка/кат-сцена — свои изменения тайлов. - this.flowers = new FlowerQuest( - this.game, - this.map, - this.layerTilePos, - (tx, ty) => this.tileCenter(tx, ty), - (s) => this.combatViews.hitBurst(s), - (t) => this.showToast(t), - this.cutscene - ); - - // Квест резонатора (акт 2): сбор тона — интерактивы рощи, сдача — катсцена. - this.resonator = new ResonatorQuest( - this.game, - (tx, ty) => this.tileCenter(tx, ty), - (s) => this.combatViews.hitBurst(s), - (t) => this.showToast(t), - this.cutscene - ); - - // Маршрутизация взаимодействия (кнопка действия) и step-переходов. - this.router = new InteractionRouter({ - game: this.game, - map: this.map, - area, - player: this.player, - interactables: this.interactables, - objects: this.objects, - disarmTile, - callbacks: { - showToast: (t) => this.showToast(t), - playUiClick: () => playSfx(this.game.audio, 'sfx/ui_click', 0.4), - talkTo: (d) => this.talkTo(d), - collectFlower: (x, y) => this.flowers.collectFlower(x, y), - useTransition: (d) => this.useTransition(d) - } - }); - - // Атмосфера локации: пепел/мотыли/дымка, зоны наката, виньетка. - this.atmosphere = new Atmosphere({ - game: this.game, - area, - worldRoot: this.game.renderer.worldRoot, - uiRoot: this.game.renderer.uiRoot, - addFx: (u) => this.game.engine.fx.add(u), - player: this.player, - heroHp: () => this.flow.playerCombat.hp, - vignette: { - level: () => this.lighting.vignetteLevel, - set: (level, fade) => this.lighting.setVignette(level, fade) - }, - showToast: (t) => this.showToast(t) - }); - - // Аудио локации: roomtone, слои, шорохи, музыка угрозы, день/ночь. - this.locationAudio = new LocationAudio({ - audio: this.game.audio, - worldAudio: this.worldAudio, - area, - layerTilePos: this.layerTilePos, - heroPos: () => this.player.position, - playTheme: (key) => this.game.playTheme(key), - playAmbience: (key) => this.game.playAmbience(key), - nightFactor: () => dayNightFactor(this.game.clock.hours), - threat: () => this.flow.threat() - }); - - // Освещение: ambient области + статические источники (очаг, окна, лампа). - this.lightView = new Lighting({ - width: Game.VIRTUAL_W, - height: Game.VIRTUAL_H, - renderer: this.game.renderer, - maxLights: MAX_LIGHTS - }); - this.game.renderer.lightRoot.addChild(this.lightView); - this.lighting = new GameLighting({ - area, - lighting: this.lightView, - layerTilePos: this.layerTilePos, - camera: this.camera, - heroPos: () => this.player.position, - hasFlag: (f) => this.game.state.hasFlag(f), - timeHours: () => this.game.clock.hours, - lamp: HERO_LAMP - }); - - // Пост-эффекты: фильтр на корне мира — блюр/аберрация/марево (см. postSim). - this.postfx = new PostFx(this.game.renderer.worldRoot, Game.VIRTUAL_W, Game.VIRTUAL_H); - - // HUD: название локации, подсказка управления, счётчик мотов. - this.hud = new LocationHud(area.name); - this.game.renderer.uiRoot.addChild(this.hud.view); - - // Боевая обвязка героя: ввод атаки, резонанс, урон/респаун, hp-бар. - this.flow = new CombatFlow( - { - game: this.game, - combat: this.combat, - combatViews: this.combatViews, - player: this.player, - lighting: this.lighting, - camera: this.camera, - postfx: this.postfx, - fx: this.fx, - spawn: area.spawn, - inHazard: () => this.atmosphere.inHazard !== null, - snapCamera: () => this.updateCameraFollow(true) - }, - this.game.state.getNumber(VARS.hp) || PLAYER_COMBAT.maxHp - ); - this.game.renderer.uiRoot.addChild(this.flow.healthBar); - - // Подпись «[E] Имя» над ближайшей целью действия: поверх акторов мира. - this.hint = new InteractHint(); - this.world.addChild(this.hint.view); - - // Агентный мост сцены (снапшот/инварианты/команды). - this.agentView = new SceneAgentView({ - game: this.game, - map: this.map, - area, - player: this.player, - playerCombat: this.flow.playerCombat, - combat: this.combat, - npcs: this.npcs.map((n) => n.def), - interactables: this.interactables, - registry: this.objects.registry, - walkGrid: () => this.objects.walkGrid(), - dialogue: this.dialogue, - cutscene: this.cutscene, - lastToast: () => this.lastToast, - interactHint: () => this.router.interactTarget(), - inHazard: () => this.atmosphere.inHazard, - damagePlayer: (dmg, from) => this.flow.onPlayerDamaged(dmg, from ?? this.player.position), - lighting: () => this.lighting, - camera: this.camera, - postfx: this.postfx, - setShimmer: (z) => { - this.manualShimmer = z; - }, - followCamera: (snap) => this.updateCameraFollow(snap) - }); - - // Камера: следим за героем; границы — мировой прямоугольник карты (юниты). - this.camera.bounds = this.map.worldBounds; - // Камера-«окно»: герой ходит в центральной зоне свободно, у виртуальной - // границы экрана толкает камеру (движение остаётся плавным). - this.camera.deadZonePx = { width: 180, height: 120 }; - this.updateCameraFollow(true); - - // Тач-джойстик: перехватывает касания (мышь проходит насквозь). - this.joystick = new VirtualJoystick({ screen: { width: Game.VIRTUAL_W, height: Game.VIRTUAL_H } }); - this.joystick.eventMode = 'none'; // включается при первом касании - this.game.renderer.uiRoot.addChild(this.joystick); - - // Дебаг-оверлей (F3). - this.debug = new DebugOverlay(this.game.engine.fpsMeter, false); - this.game.renderer.uiRoot.addChild(this.debug.view); - - // Дебаг спрайта героя (P): текущий кадр, увеличенный с пиксельной сеткой. - this.charDebug = new SpriteDebugView({ zoom: 8 }); - this.charDebug.view.position.set(390, 120); - this.game.renderer.uiRoot.addChild(this.charDebug.view); - - // Сцена готова: регистрируемся как активная локация (автосейв при - // закрытии вкладки — даже когда поверх открыта сумка/другая сцена). - this.game.activeLocation = this; - } - - /** id области — для оверлея карты (Game.activeLocation). */ - get areaId(): string { - return this.area.id; - } - - /** Текущий тайл героя — для мигающего маркера на карте. */ - heroTile(): { x: number; y: number } { - return this.player.currentTile(); - } - - enter(): void { - console.log('[location]', this.area.id); // маяк для смоук-тестов - // Амбиент локации: день/ночь по текущим часам (кроссфейд; '' — тишина). - this.locationAudio.startAmbient(); - this.game.playTheme(this.area.theme ?? ''); - // Файловые лупы области — в фоне (сцена загрузки декодировала только стартовую). - this.game.preloadAudio(areaAudioKeys(this.area)); - this.locationAudio.startRoomTone(); - // Первая подсказка сбора: как брать лунные колокольчики (один раз). - if (this.area.id === 'ponds' && !this.game.state.hasFlag(FLAGS.hint_bells)) { - this.game.state.setFlag(FLAGS.hint_bells); - this.showToast('Лунные колокольчики в земле: подойди и нажми E.'); - } - } - - exit(): void { - // Сняться с регистрации (replace: новый LocationScene уже занял слот — - // чистим только свой). - if (this.game.activeLocation === this) this.game.activeLocation = null; - for (const off of this.eventOffs) off(); - this.eventOffs = []; - this.audioOff(); - this.worldAudio.setLayers([]); // лупы локальных слоёв: иначе звучат поверх следующей сцены - this.locationAudio.exit(); - this.dialogue.destroy(); - this.fauna.exit(); - this.combatViews.exit(); - this.flow.exit(); - this.fx.destroy({ children: true }); - this.uiFx.destroy({ children: true }); - this.world.destroy({ children: true }); - this.atmosphere.destroy(); - this.lighting.destroy(); - this.lightView.destroy({ children: true }); - this.postfx.dispose(); - this.shadows.destroy(); - this.hud.destroy(); - this.hint.destroy(); - this.joystick.destroy({ children: true }); - this.debug.view.destroy({ children: true }); - this.charDebug.view.destroy({ children: true }); - } - - update(dt: number): void { - this.map.update(dt); // анимированные тайлы (вода, кроны) — no-op без анимаций - this.updateCrownAsh(dt); - this.worldAudio.update(dt); - this.locationAudio.updateLayers(); - this.locationAudio.updateDayNightAmbient(); - this.game.clock.advance(dt); // время суток идёт и в диалогах/катсценах: свет меняется - this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов - this.lightView.update(dt); // время/лерп ambient/кадры к спрайтам - this.shadows.update(sunShadow(this.game.clock.hours)); // тени ползут за солнцем - this.postfx.update(dt); - this.updateShimmer(); - this.atmosphere.updateVignette(); - this.fauna.update(dt); - // Ходоки актёров: тикаются всегда (исполнители until в кат-сценах). - if (this.walkers.size > 0) for (const w of this.walkers.values()) w.update(dt); - // Кат-сцена и диалог живут в одном тике: сцена может вести диалог - // посреди кат-сцены (call(talkTo) + until(диалог закрыт)). Геймплей - // на паузе, пока активна хоть одна из них; камера при кат-сцене — - // под контролем раннера (follow не тикаем). - const input = this.game.engine.input; - // Гвард «было занято до тика»: клавиша, закрывшая диалог (advance/attack - // мульти-биндинг), не должна в тот же тик ударить/переоткрыть разговор. - const wasBusy = this.cutscene.active || this.dialogue.active; - if (this.cutscene.active) this.cutscene.update(dt); - if (this.dialogue.active) { - // Во время диалога up/down листают варианты, клик/пробел — дальше/выбор. - this.dialogue.update(dt); - if (input.isActionJustPressed('up')) this.dialogue.moveCursor(-1); - if (input.isActionJustPressed('down')) this.dialogue.moveCursor(1); - if (input.getPointer().justPressed || input.isActionJustPressed('advance')) { - this.dialogue.advance(); - } - } - if (this.cutscene.active || this.dialogue.active) return; - if (this.game.engine.scenes.transitioning) return; - - if (input.isActionJustPressed('menu')) { - this.saveAndExit(); - return; - } - - if (input.isActionJustPressed('inventory')) { - playSfx(this.game.audio, 'sfx/ui_click'); - void this.game.scenes.push( - new InventoryScene(this.game, () => void this.game.scenes.pop()), - { duration: 0.2 } - ); - return; - } - - if (input.isActionJustPressed('debug')) { - this.debug.view.visible = !this.debug.view.visible; - } - if (input.isActionJustPressed('debugChar')) { - this.charDebug.view.visible = !this.charDebug.view.visible; - } - if (input.isActionJustPressed('debugZoom')) { - // Отладка зума: целые уровни 1 <-> 2; snap перекрывает dead zone. - this.camera.setZoom(this.camera.zoomLevel > 1 ? 1 : 2); - this.updateCameraFollow(true); - } - - // --- ввод боя: тап = короткий удар, удержание = заряд резонанса --- - if (!wasBusy && input.isActionJustPressed('attack')) { - this.flow.onAttackPressed(); - } - if (!wasBusy && input.isActionJustReleased('attack')) { - this.flow.onAttackReleased(); - } - // --- кнопка действия: ближайшая цель в радиусе (E / геймпад A) --- - if (!wasBusy && input.isActionJustPressed('interact')) { - this.router.resolveInteract(); - } - - // Касание отдаём джойстику (тач-устройство: движение стиком). - if (input.getPointer().justPressed && input.getPointer().isTouch) { - this.joystick.eventMode = 'static'; - } - - // --- боевой мир --- - this.combat.update(dt); - this.combatViews.sync(dt); - this.locationAudio.updateMusic(dt); - this.flow.update(dt); - - // Мигание героя при неуязвимости: duty blink-оживителя живой. - this.heroMotion.params.blink!.duty = this.flow.playerCombat.invuln ? 0.5 : 1; - - const mv = this.moveVector(); - if (mv.x !== 0 || mv.y !== 0) { - this.player.moveFree(mv, dt); // нулевой вектор в moveFree = stop() — не вызываем - } else { - this.player.update(dt); - } - const tile = this.player.currentTile(); - this.hud.setMotes(this.game.state.getNumber(VARS.motes)); - this.hud.setQuest(questTracker(this.game.state)); - this.atmosphere.updateHazard(tile); - this.actors.setDepth(this.player.view, tile.x, tile.y); - this.updateCameraFollow(); - this.router.checkTransitions(tile); - // Подпись кнопки действия: одна вьюха над ближайшей целью (или скрыта). - this.hint.show(this.router.interactTarget()); - - // Отладочные строки собираются только при видимой панели (F3): иначе — - // три template-строки и массив каждый тик впустую. - if (this.debug.view.visible) { - this.debug.setLines([ - `tile ${tile.x},${tile.y}`, - `pos ${Math.round(this.player.position.x)},${Math.round(this.player.position.y)}`, - `hp ${this.flow.playerCombat.hp} kills ${this.game.state.getNumber(VARS.kills)}` - ]); - } - this.debug.update(dt); - if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture); - } - - render(): void {} - - /** Вектор прямого движения: тач-джойстик приоритетнее, иначе клавиши + стик геймпада. */ - private moveVector(): Vec2 { - if (this.joystick.active) return this.joystick.getVector(); - const input = this.game.engine.input; - const stick = input.getLeftStick(); - const x = (input.isActionActive('right') ? 1 : 0) - (input.isActionActive('left') ? 1 : 0); - const y = (input.isActionActive('down') ? 1 : 0) - (input.isActionActive('up') ? 1 : 0); - return { - x: Math.max(-1, Math.min(1, (stick?.x ?? 0) + x)), - y: Math.max(-1, Math.min(1, (stick?.y ?? 0) + y)) - }; - } - - // ---------- агентный мост (SceneAgent → SceneAgentView) ---------- - - /** Контентный слой снапшота — см. apps/game/src/agent/snapshot.ts. */ - agentSnapshot(): SnapshotLayer { - return this.agentView.agentSnapshot(); - } - - /** Инварианты сцены: валидность контента + целостность героя/врагов. */ - agentInvariants(): Invariant[] { - return this.agentView.agentInvariants(); - } - - /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */ - agentCommand(name: string, args?: JsonValue): JsonValue { - return this.agentView.agentCommand(name, args); - } - - // ---------- остальное ---------- - - /** Ходок актёра по def (лениво); depth-слой обновляется при смене тайла. */ - walkerFor(def: NpcDef): ActorWalker { - let walker = this.walkers.get(def); - if (!walker) { - const record = this.npcs.find((n) => n.def === def); - walker = new ActorWalker(this.map, record!.view, def.tile, (t) => - this.actors.setDepth(record!.view, t.x, t.y) - ); - this.walkers.set(def, walker); - } - return walker; - } - - /** - * Зоны марева (пост-эффект «горячий воздух»): первый источник на экране. - * Каждый тик — камера едет, точка должна следовать за объектом. - */ - private updateShimmer(): void { - if (this.manualShimmer !== undefined) { - this.postfx.setShimmer(this.manualShimmer); - return; - } - let zone: ShimmerState | null = null; - for (const def of this.area.interactables ?? []) { - const src = SHIMMER_SOURCES[def.id]; - if (!src || !src.visible(this.game.state)) continue; - const w = tileToWorld(def.tile.x + 0.5, def.tile.y + 0.5); - const s = this.camera.toScreen(w.x, w.y); - const radius = unitsToPx(src.radiusUnits) * this.camera.zoomLevel; - // Зона видна, пока её центр в пределах экрана с запасом радиуса. - if ( - s.x < -radius || s.x > Game.VIRTUAL_W + radius || - s.y < -radius || s.y > Game.VIRTUAL_H + radius - ) continue; - zone = { amp: src.amp, wavelength: src.wavelength, speed: src.speed, center: s, radius }; - break; - } - this.postfx.setShimmer(zone); - } - - private updateCameraFollow(snap = false): void { - // Непрерывное следование за ногами героя; snap — телепорты/спавн. - const p = this.player.position; - if (snap) this.camera.snap(p.x, p.y); - else this.camera.follow(p.x, p.y); - this.cullMap(); - } - - /** Culling тайлов: окно камеры в px слоя карты (экран − позиция мира, /зум). */ - private cullMap(): void { - const root = this.game.renderer.worldRoot; - const z = root.scale.x; - this.map.cullToRect({ - x: -root.position.x / z, - y: -root.position.y / z, - width: Game.VIRTUAL_W / z, - height: Game.VIRTUAL_H / z - }); - } - - /** Экранные координаты центра ромба тайла (с Origin карты). */ - private tileCenter(tx: number, ty: number): { x: number; y: number } { - const u = tileToWorld(tx, ty); - return worldToScreen(u.x, u.y); - } - - /** Поверхность под ногами (трава/пепел/вода/пол) — для вариативных шагов. */ - private stepSurface(tile: Vec2): StepSurface { - const d = this.map.data; - const id = d.tiles[tile.y * d.width + tile.x]!; - if (id === TILES.WATER) return 'water'; - if (id === TILES.ASH || id === TILES.HOUSE || id === TILES.TOWER) return 'ash'; - if (id === TILES.FLOOR || id === TILES.PATH) return 'floor'; - return 'grass'; - } - - /** Предикаты мира для условий (сумка героя). */ - private dialogueWorld(): DialogueWorld { - return { hasItem: (id) => this.game.inventory.count(id as ItemId) > 0 }; - } - - private talkTo(def: NpcDef): void { - // Флаг знакомства поднимает сам граф dialogueFirst; сцена только читает. - const met = this.game.state.hasFlag(def.flagKey); - // Сюжетная ветка — из квест-стадии; иначе обычный диалог. - const id = !met - ? def.dialogueFirst - : (questDialogueFor(this.game.state, def.id, this.dialogueWorld()) ?? def.dialogueRepeat); - this.dialogue.start(DIALOGUES[id], id); - } - - private onDialogueFinished(): void { - // Сюжетные custom-эффекты графа — только когда реплики доскажены. - if (this.pendingCustom.has(DIALOGUE_CUSTOM.plant_flowers)) { - this.pendingCustom.delete(DIALOGUE_CUSTOM.plant_flowers); - this.flowers.startHandInCutscene(); - } - if (this.pendingCustom.has(DIALOGUE_CUSTOM.tune_resonator)) { - this.pendingCustom.delete(DIALOGUE_CUSTOM.tune_resonator); - this.resonator.startTuneCutscene(); - } - } - - /** Сюжетные custom-эффекты интерактивов (вне диалогов). */ - private onSceneCustom(name: string): void { - if (name === DIALOGUE_CUSTOM.lull_machine) this.startMachineLullCutscene(); - } - - /** - * Финал акта 2 — «правильный звон»: камера спускается к устью, резонатор - * держит верный тон, Машина засыпает. Резонанс — тот же пульс CombatWorld, - * что и удержание атаки героя (догоняет бодрых сгустков зала). - */ - private startMachineLullCutscene(): void { - const at = tileToWorld(6, 2); // устье печи (свет machine_mouth) - const s = worldToScreen(at.x, at.y); - this.cutscene.play([ - // Спуск камеры к Машине, наезд на устье. - { kind: 'cameraMove', x: at.x, y: at.y, seconds: 1.2, camera: this.game.camera }, - { kind: 'cameraZoom', camera: this.game.camera, z: 2, seconds: 0.8 }, - // Правильный звон: бронза держит тон, тряска, кольцо резонанса. - { - kind: 'burst', - fn: () => { - void this.game.audio.play('sfx/bell_low', 1); - this.game.camera.addShake(2.5, 0.6); - this.combatViews.hitBurst(s); - }, - seconds: 1 - }, - // Тот же резонанс, что у героя: доспать всем, кто не спал. - { kind: 'call', fn: () => this.combat.resonancePulse(at) }, - // Дыхание обрывается: угольки гаснут. - { - kind: 'burst', - fn: () => { - void this.game.audio.play('sfx/ash_die', 0.8); - this.combatViews.hitBurst(s); - }, - seconds: 1.2 - }, - // Отъезд и тост-финал акта. - { kind: 'cameraZoom', camera: this.game.camera, z: 1, seconds: 0.8 }, - { kind: 'call', fn: () => this.showToast('Машина дышит тише. Впервые за годы — тише.') } - ]); - } - - /** - * Осыпающийся пепел с крон деревьев: редкий спавн 1–2 частиц с случайной - * кроны (детерминированный rng), падают вниз и тают — жизнь леса вне боя. - */ - private updateCrownAsh(dt: number): void { - if (this.crownTiles.length === 0) return; // интерьеры и пустые карты - this.crownAshT += dt; - if (this.crownAshT < CROWN_ASH_PERIOD) return; - this.crownAshT = 0; - const t = this.crownTiles[Math.floor(this.crownAshRng.next() * this.crownTiles.length)]!; - const c = this.tileCenter(t.x, t.y); - this.fx.burst( - 1 + Math.floor(this.crownAshRng.next() * 2), - { x: c.x, y: c.y - 34 }, // середина кроны (дерево 48px от низа ромба) - emberPreset(0x8a8a96, { - blend: 'normal', - fadeIn: 0.2, - lifetime: [1.5, 2.6], - velocity: { x: [-3, 3], y: [4, 9] }, - seed: this.crownAshSeed++ - }) - ); - } - - /** Всплывающая подсказка: появляется, держится, растворяется вверх (FxLayer). */ - private showToast(text: string): void { - this.lastToast = { text, tick: this.game.engine.tickCount }; - // Новый тост вытесняет старый (раньше наслаивались). - this.uiFx.clear(); - const hold = readSeconds(text); // базовые 1.4 с + ~45 мс на символ, не дольше 6 - // y ниже строки «Цель:» (LocationHud) — тост и трекер не накладываются. - this.uiFx.floatText(text, { x: 240, y: 56 }, { - duration: hold + 0.75, - hold, - fadeIn: 0.25, - fadeOut: 0.5, - rise: 8, - // Перенос по ширине экрана: длинный тост в 2 строки, не за края. - style: { size: 11, color: 0xd8c79a, wordWrapWidth: 440 } - }); - } - - /** Куда ведёт переход: область + точка входа ('return' — откуда вошли). */ - private targetEntry(def: TransitionDef): { area: AreaDef; entry: { x: number; y: number } } { - const t = def.target; - if (t.kind === 'area') return { area: areaOf(t.area), entry: t.entry }; - return { - area: areaOf(this.returnTo?.area), - entry: this.returnTo?.entry ?? this.area.spawn - }; - } - - /** Выполнить переход: новая сцена с fade (длительность — из определения). */ - private useTransition(def: TransitionDef): void { - const next = this.targetEntry(def); - // Автосейв до fade: закрыл игру на переходе — вернёшься на эту же тропу. - this.autosave(); - playSfx(this.game.audio, 'sfx/whoosh', 0.5); - void this.game.scenes.replace( - new LocationScene(this.game, null, next.area, next.entry, { - area: this.area.id, - entry: this.player.currentTile() - }), - { duration: def.duration ?? 0.4 } - ); - } - - /** - * Сериализовать прохождение без записи (общий источник autosave: - * Esc, переходы, закрытие вкладки). - */ - private buildSaveData(): SaveData { - this.game.state.setVar(VARS.hp, this.flow.playerCombat.hp); - return { - version: SAVE_VERSION, - area: this.area.id, - pos: this.player.currentTile(), - state: this.game.state.serialize(), - items: this.game.inventory.serialize().items, - clock: { minutes: this.game.clock.minutes }, - returnTo: this.returnTo ?? null, - savedAt: Date.now() - }; - } - - /** Записать autosave (localStorage синхронен — безопасно и из beforeunload). */ - autosave(): void { - const data = this.buildSaveData(); - this.game.saves.save( - 'autosave', - data, - { title: 'Автосохранение', savedAt: data.savedAt, version: SAVE_VERSION, extras: { area: this.area.id } } - ); - } - - private saveAndExit(): void { - this.autosave(); - void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); - } -} \ No newline at end of file diff --git a/apps/game/src/scenes/MapScene.ts b/apps/game/src/scenes/MapScene.ts deleted file mode 100644 index 30df4d9..0000000 --- a/apps/game/src/scenes/MapScene.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { - Graphics, - MenuSceneBase, - Panel, - PixelText, - Sprite, - Texture, - renderMinimap, - type MinimapImage, - type TileMapData -} from '@rpg/engine'; -import { Game } from '../Game'; -import { TILES } from '../data/map'; -import { areaOf } from '../data/locations'; -import { playSfx } from '../data/sfxSpecs'; - -/** - * Палитра миникарты: id тайла → цвет (0xRRGGBB, из арт-палитры). Варианты - * тайлов красятся как базовый. Незаданный — тёмный fallback движка. - */ -const MAP_COLORS: Record = { - [TILES.GRASS]: 0x4a5340, // G1 — трава - [TILES.GRASS_V1]: 0x4a5340, - [TILES.GRASS_V2]: 0x4a5340, - [TILES.PATH]: 0x77634f, // T1 — протоптанная земля - [TILES.PATH_V1]: 0x77634f, - [TILES.WATER]: 0x3a4356, // C0 — вода - [TILES.TREE]: 0x343b2c, // G0 — тёмная крона - [TILES.TREE_V1]: 0x343b2c, - [TILES.TREE_V2]: 0x343b2c, - [TILES.ASH]: 0x3e3e48, // P3 — пепел - [TILES.ASH_V1]: 0x3e3e48, - [TILES.BELLFLOWER]: 0xf2b45a, // F1 — цветок (тёплый) - [TILES.TOWER]: 0x26262e, // M0 — высокая башня - [TILES.HOUSE]: 0x5a4a3c, // T0 — крыши - [TILES.HOUSE_V1]: 0x5a4a3c, - [TILES.HOUSE_V2]: 0x5a4a3c, - [TILES.HOUSE_V3]: 0x5a4a3c, - [TILES.HOUSE_V4]: 0x5a4a3c, - [TILES.HOUSE_BIG]: 0x5a4a3c, - [TILES.BOULDER]: 0x767681, // P5 - [TILES.BOULDER_2]: 0x767681, - [TILES.BOULDER_3]: 0x767681, - [TILES.BUSH]: 0x343b2c, // G0 - [TILES.BUSH_2]: 0x343b2c, - [TILES.BUSH_3]: 0x343b2c, - [TILES.FLOOR]: 0x44454e, // M1 — полы интерьеров - [TILES.WALL]: 0x26262e, // M0 — стены - [TILES.WELL]: 0x8a6a2e, // B0 — латунный колодец - [TILES.WIRE]: 0x5c3b2e, // R1 — бухты кабеля - [TILES.RAIL]: 0x767681, // P5 — металл рельсов - [TILES.STAIRS]: 0x77634f, - [TILES.MACHINE]: 0x26262e -}; - -/** Цвета маркеров: герой (мигает), переходы, NPC. */ -const HERO_COLOR = 0xf2d49a; -const TRANSITION_COLOR = 0xb2b2bb; -const NPC_COLOR = 0xf2b45a; -/** Цвет дорог-линий на листе мира (тропа). */ -const ROAD_COLOR = 0x77634f; // T1 - -/** Часть листа мира: область и её оффсет в тайлах листа. */ -interface WorldPart { - id: string; - name: string; - x: number; - y: number; - map: TileMapData; -} - -/** - * Раскладка листа мира «Низина Звенца» (оффсеты в тайлах, cell = 1): - * пруды на северо-западе, луга в центре, восточнее — деревня, роща, разъезд. - * Интерьеры домов и зал Машины на карте мира не показаны. - */ -function worldParts(game: Game): WorldPart[] { - const defs: [id: string, name: string, x: number, y: number][] = [ - ['ponds', 'Серые пруды', 0, 0], - ['meadows', 'Выжженные луга', 53, 10], - ['zvenets', 'Звенец', 114, 20], - ['rust_grove', 'Ржавая роща', 167, 20], - // Разъезд сдвинут вправо: подписи соседних областей не слипаются. - ['old_siding', 'Старый разъезд', 231, 20] - ]; - return defs.flatMap(([id, name, x, y]) => { - const map = game.mapFiles.get(id); - return map ? [{ id, name, x, y, map }] : []; - }); -} - -/** Дороги мира: пары тайлов областей (связки переходов, тропы и колодцы). */ -const WORLD_ROADS: { a: [string, number, number]; b: [string, number, number] }[] = [ - { a: ['ponds', 2, 2], b: ['meadows', 2, 2] }, // тропа на пруды - { a: ['meadows', 54, 28], b: ['zvenets', 2, 20] }, // тропа в Звенец - { a: ['meadows', 44, 40], b: ['zvenets', 16, 13] }, // колодцы (телепорт) - { a: ['zvenets', 54, 20], b: ['rust_grove', 1, 20] }, // дорога в рощу - { a: ['rust_grove', 46, 20], b: ['old_siding', 1, 20] } // дорога на разъезд -]; - -/** RGBA-картинка движка → пиксельная текстура (nearest, без размытия). */ -function textureFromMinimap(img: { width: number; height: number; rgba: Uint8Array }): Texture { - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d')!; - ctx.putImageData(new ImageData(new Uint8ClampedArray(img.rgba), img.width, img.height), 0, 0); - const tex = Texture.from(canvas); - tex.source.scaleMode = 'nearest'; - return tex; -} - -/** - * Склеить миниатюры областей в лист мира: каждая область рендерится - * renderMinimap (cell = 1), строки копируются в общий RGBA по оффсету. - * Промежутки между областями остаются прозрачными (alpha 0). - */ -function composeWorldSheet(parts: WorldPart[]): MinimapImage { - const width = Math.max(...parts.map((p) => p.x + p.map.width)); - const height = Math.max(...parts.map((p) => p.y + p.map.height)); - const rgba = new Uint8Array(width * height * 4); // всё прозрачено - for (const p of parts) { - const img = renderMinimap(p.map, { colors: MAP_COLORS, cell: 1 }); - for (let row = 0; row < img.height; row++) { - const src = row * img.width * 4; - const dst = ((p.y + row) * width + p.x) * 4; - rgba.set(img.rgba.subarray(src, src + img.width * 4), dst); - } - } - return { width, height, rgba }; -} - -/** - * Карта мира «Низина Звенца» (предмет map_scroll): композит миниатюр всех - * внешних областей на одном листе, дороги между переходами, маркеры текущей - * области и мигающая точка героя. Интерьеры и зал Машины не показаны. - * Esc/E/клавиша сумки — назад. - */ -export class MapScene extends MenuSceneBase { - /** Мигающий маркер героя. */ - private hero: Graphics | null = null; - private blinkT = 0; - - constructor( - private game: Game, - private onBack: () => void - ) { - super( - { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, - { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', extra: ['inventory'] } - ); - } - - protected build(): void { - const parts = worldParts(this.game); - if (parts.length === 0) return; // карты ещё не загружены — не бывает - - const panel = new Panel({ width: 300, height: 222 }); - panel.position.set((Game.VIRTUAL_W - 300) / 2, (Game.VIRTUAL_H - 222) / 2); - - const title = new PixelText({ text: 'Низина Звенца', size: 14, color: 0xd8c79a }); - title.anchor.set(0.5); - title.position.set(150, 14); - panel.addChild(title); - - const sheet = composeWorldSheet(parts); - const sprite = new Sprite(textureFromMinimap(sheet)); - sprite.position.set(150 - sheet.width / 2, 46); - panel.addChild(sprite); - - // Дороги между областями: тропы и колодцы, по связкам переходов. - const roads = new Graphics(); - roads.lineStyle(1, ROAD_COLOR, 1); - for (const road of WORLD_ROADS) { - const a = parts.find((p) => p.id === road.a[0]); - const b = parts.find((p) => p.id === road.b[0]); - if (!a || !b) continue; - roads.moveTo(sprite.position.x + a.x + road.a[1] + 0.5, sprite.position.y + a.y + road.a[2] + 0.5); - roads.lineTo(sprite.position.x + b.x + road.b[1] + 0.5, sprite.position.y + b.y + road.b[2] + 0.5); - } - panel.addChild(roads); - - // Подписи областей под миниатюрами. - for (const p of parts) { - const label = new PixelText({ text: p.name, size: 8, color: 0xb2b2bb }); - label.anchor.set(0.5, 0); - label.position.set( - sprite.position.x + p.x + p.map.width / 2, - sprite.position.y + p.y + p.map.height + 2 - ); - panel.addChild(label); - } - - this.addMarkers(panel, sprite.position.x, sprite.position.y, parts); - - const hint = new PixelText({ text: 'Esc — назад', size: 9, color: 0xf2d49a }); - hint.position.set(16, 204); - panel.addChild(hint); - - this.view.addChild(panel); - this.game.renderer.uiRoot.addChild(this.view); - } - - /** Маркеры текущей области: переходы, NPC и мигающий герой. */ - private addMarkers(panel: Panel, sheetX: number, sheetY: number, parts: WorldPart[]): void { - const areaId = this.game.activeLocation?.areaId; - const part = areaId ? parts.find((p) => p.id === areaId) : undefined; - if (!part) return; // интерьер/подземелье: на карте мира маркеров нет - const area = areaOf(part.id); - const g = new Graphics(); - const at = (x: number, y: number): { x: number; y: number } => ({ - x: sheetX + part.x + x - 1, - y: sheetY + part.y + y - 1 - }); - // Переходы — светлые клетки, NPC — тёплые. - for (const t of area.transitions) { - const p = at(t.tile.x, t.tile.y); - g.rect(p.x, p.y, 3, 3).fill(TRANSITION_COLOR); - } - for (const npc of area.npcs) { - const p = at(Math.floor(npc.pos.x), Math.floor(npc.pos.y)); - g.rect(p.x, p.y, 3, 3).fill(NPC_COLOR); - } - panel.addChild(g); - - // Герой — мигающая клетка поверх (update). - const heroTile = this.game.activeLocation!.heroTile(); - this.hero = new Graphics(); - this.hero.rect(0, 0, 4, 4).fill(HERO_COLOR); - const hp = at(heroTile.x, heroTile.y); - this.hero.position.set(hp.x - 0.5, hp.y - 0.5); - panel.addChild(this.hero); - } - - update(dt: number): void { - super.update(dt); - // Мигание маркера героя (локация не тикается — мерцает сама карта). - if (this.hero) { - this.blinkT += dt; - this.hero.visible = Math.floor(this.blinkT / 0.4) % 2 === 0; - } - } - - /** Esc или клавиша сумки — назад (в сумку, если карта открыта из неё). */ - protected onCancel(): void { - this.close(); - } - - protected onAction(): void { - this.close(); - } - - private close(): void { - playSfx(this.game.audio, 'sfx/ui_click'); - this.onBack(); - } -} \ No newline at end of file diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts deleted file mode 100644 index 2e5ff13..0000000 --- a/apps/game/src/scenes/MenuScene.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { playSfx } from '../data/sfxSpecs'; -import { - MenuList, - MenuSceneBase, - PixelText, - type Invariant, - type JsonValue, - type SnapshotLayer -} from '@rpg/engine'; -import type { Game } from '../Game'; -import { areaOf } from '../data/locations'; -import { LocationScene } from './LocationScene'; -import { SaveSlotsScene } from './SaveSlotsScene'; -import { SettingsScene } from './SettingsScene'; -import { normalizeSave, type SaveData } from './saveData'; -import { START_MINUTES } from '../systems/Clock'; - -/** - * Главное меню: название, «новая игра», «продолжить» (если есть сейв). - * Список на движковом MenuList: мышь и клавиатура/геймпад. - */ -export class MenuScene extends MenuSceneBase { - constructor(private game: Game) { - super( - { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, - { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu' } - ); - } - - protected build(): void { - const title = new PixelText({ - text: 'ПЕПЕЛЬНЫЕ ЛУГА', - size: 28, - color: 0xd8c79a - }); - title.anchor.set(0.5); - title.position.set(240, 66); - this.view.addChild(title); - - const subtitle = new PixelText({ - text: 'пепел всё ещё дышит', - size: 11, - color: 0x8a8a9a - }); - subtitle.anchor.set(0.5); - subtitle.position.set(240, 94); - this.view.addChild(subtitle); - - const items = [ - { - label: 'Новая игра', - // Звук не блокирует навигацию: в suspended-контексте play может не дойти. - onSelect: () => { - playSfx(this.game.audio, 'sfx/ui_click'); - this.startNewGame(); - } - }, - { - label: 'Сейвы', - onSelect: () => { - playSfx(this.game.audio, 'sfx/ui_click'); - void this.game.scenes.push( - new SaveSlotsScene( - this.game, - (save) => void this.game.scenes.pop().then(() => this.start(save)), - () => void this.game.scenes.pop() - ) - ); - } - }, - { - label: 'Настройки', - onSelect: () => { - playSfx(this.game.audio, 'sfx/ui_click'); - void this.game.scenes.push( - new SettingsScene(this.game, () => void this.game.scenes.pop()) - ); - } - } - ]; - if (this.game.saves.has('autosave')) { - items.unshift({ - label: 'Продолжить', - onSelect: () => { - playSfx(this.game.audio, 'sfx/ui_click'); - const save = this.game.saves.load('autosave'); - this.start(save); - } - }); - } - - this.menu = new MenuList({ width: 120, height: 16, gap: 3 }); - this.menu.setItems(items); - this.menu.position.set(180, 130); - this.view.addChild(this.menu); - - const hint = new PixelText({ - text: 'клик по тайлу — идти · клик по NPC — говорить\nEsc в игре — меню с сохранением', - size: 9, - color: 0x666677, - align: 'center', - lineHeight: 13, - wordWrapWidth: 400 - }); - hint.anchor.set(0.5); - hint.position.set(240, 234); - this.view.addChild(hint); - - this.game.renderer.uiRoot.addChild(this.view); - } - - /** Новая игра: сброс прогресса, сумки и часов, старт на лугах. */ - private startNewGame(): void { - this.game.state.reset(); - this.game.inventory.clear(); - this.game.clock.reset(); - this.start(null); - } - - // --- агентный мост: сцена отдаёт снапшот и принимает команды --- - - agentSnapshot(): SnapshotLayer { - return { scene: 'menu' }; - } - - agentInvariants(): Invariant[] { - return []; - } - - agentCommand(name: string): JsonValue { - if (name === 'menu:newGame') { - this.startNewGame(); - return true; - } - return null; - } - - private start(save: SaveData | null): void { - if (save) { - const norm = normalizeSave(save); - this.game.inventory.load({ items: norm.items }); - this.game.clock.set(norm.clock?.minutes ?? START_MINUTES); - void this.game.scenes.replace( - new LocationScene(this.game, norm, areaOf(norm.area), undefined, norm.returnTo ?? undefined), - { duration: 0.4 } - ); - } else { - this.game.inventory.clear(); - void this.game.scenes.replace(new LocationScene(this.game, null, areaOf(undefined)), { - duration: 0.4 - }); - } - } -} \ No newline at end of file diff --git a/apps/game/src/scenes/SaveSlotsScene.ts b/apps/game/src/scenes/SaveSlotsScene.ts deleted file mode 100644 index d153d74..0000000 --- a/apps/game/src/scenes/SaveSlotsScene.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { playSfx } from '../data/sfxSpecs'; -import { MenuList, MenuSceneBase, Panel, PixelText } from '@rpg/engine'; -import { Game } from '../Game'; -import { areaOf } from '../data/locations'; -import { SAVE_SLOTS, canDelete, slotTitle } from '../data/saves'; -import type { SaveData } from './saveData'; - -/** - * Меню сейвов: autosave (только загрузка) + 3 именованных слота - * (загрузка/удаление). Панель поверх меню (push/pop). Подписи — из меты - * слота; у старых сейвов без меты — полная загрузка (как раньше). - */ -export class SaveSlotsScene extends MenuSceneBase { - constructor( - private game: Game, - private onPick: (save: SaveData) => void, - private onBack: () => void - ) { - super( - { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, - { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', extra: ['attack'] } - ); - } - - protected build(): void { - const panel = new Panel({ width: 240, height: 150 }); - panel.position.set((Game.VIRTUAL_W - 240) / 2, (Game.VIRTUAL_H - 150) / 2); - - const title = new PixelText({ text: 'СЕЙВЫ', size: 14, color: 0xd8c79a }); - title.anchor.set(0.5); - title.position.set(120, 14); - panel.addChild(title); - - this.menu = new MenuList({ width: 200, height: 14, gap: 3, size: 10 }); - this.menu.position.set(20, 36); - panel.addChild(this.menu); - - this.view.addChild(panel); - this.game.renderer.uiRoot.addChild(this.view); - this.rebuild(); - } - - /** Delete на выбранном именованном слоте — стереть. */ - protected onAction(action: string, index: number): void { - if (action === 'attack') this.deleteAt(index); - } - - protected onCancel(): void { - this.back(); - } - - /** Пересобрать список слотов из SaveManager. */ - private rebuild(): void { - const items: { label: string; disabled?: boolean; onSelect?: () => void }[] = []; - const slots = this.game.saves.listSlots(); - for (const slot of SAVE_SLOTS) { - const meta = this.game.saves.slotMeta(slot); - const save = meta - ? null - : slots.includes(slot) - ? this.game.saves.load(slot)! - : null; - if (!meta && !save) { - items.push({ label: `${slotTitle(slot)} — пусто —`, disabled: true }); - continue; - } - const label = meta - ? this.metaLabel(slot, meta) - : this.saveLabel(slot, save!); - items.push({ - label, - onSelect: () => { - playSfx(this.game.audio, 'sfx/ui_click'); - const picked = save ?? this.game.saves.load(slot)!; - this.onPick(picked); - } - }); - } - items.push({ label: 'Назад', onSelect: () => this.back() }); - this.menu!.setItems(items); - } - - /** Подпись из меты: локация из extras, время из savedAt. */ - private metaLabel(slot: string, meta: { savedAt?: number; extras?: Record }): string { - const area = typeof meta.extras?.area === 'string' ? meta.extras.area : undefined; - const when = meta.savedAt ? this.formatTime(meta.savedAt) : '--:--'; - return `${slotTitle(slot)} ${this.areaName(area)} ${when}`; - } - - /** Подпись из полного сейва (старые сейвы без меты). */ - private saveLabel(slot: string, save: SaveData): string { - const areaId = save.area ?? (save as { location?: string }).location; - return `${slotTitle(slot)} ${this.areaName(areaId)} ${this.formatTime(save.savedAt)}`; - } - - private formatTime(ts: number): string { - const date = new Date(ts); - return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; - } - - private areaName(areaId: string | undefined): string { - // Имя из реестра областей: интерьеры раньше показывались как «луга». - return areaId ? areaOf(areaId).name : 'луга'; - } - - /** Удалить сейв в выбранной строке (именованные слоты; autosave защищён). */ - private deleteAt(index: number): void { - const slot = SAVE_SLOTS[index]; - if (!slot || !canDelete(slot) || !this.game.saves.listSlots().includes(slot)) return; - this.game.saves.delete(slot); - playSfx(this.game.audio, 'sfx/ui_click'); - this.rebuild(); - } - - private back(): void { - playSfx(this.game.audio, 'sfx/ui_click'); - this.onBack(); - } -} \ No newline at end of file diff --git a/apps/game/src/scenes/SettingsScene.ts b/apps/game/src/scenes/SettingsScene.ts deleted file mode 100644 index e6cc3fe..0000000 --- a/apps/game/src/scenes/SettingsScene.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { playSfx } from '../data/sfxSpecs'; -import { MenuList, MenuSceneBase, Panel, PixelText } from '@rpg/engine'; -import { Game } from '../Game'; - -/** - * Настройки: громкости шин аудио. Панель поверх вызывающей сцены (push/pop), - * изменения сразу применяются через settings.onChange -> AudioManager. - * Громкости рисуются текстовыми барами, left/right/Enter — подстройка. - */ -export class SettingsScene extends MenuSceneBase { - constructor( - private game: Game, - private onBack: () => void - ) { - super( - { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, - { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', left: 'left', right: 'right' } - ); - } - - protected build(): void { - const panel = new Panel({ width: 220, height: 130 }); - panel.position.set((Game.VIRTUAL_W - 220) / 2, (Game.VIRTUAL_H - 130) / 2); - - const title = new PixelText({ text: 'НАСТРОЙКИ', size: 14, color: 0xd8c79a }); - title.anchor.set(0.5); - title.position.set(110, 14); - panel.addChild(title); - - this.menu = new MenuList({ width: 180, height: 16, gap: 4, size: 10 }); - this.menu.position.set(20, 36); - panel.addChild(this.menu); - - this.view.addChild(panel); - this.game.renderer.uiRoot.addChild(this.view); - this.rebuild(); - } - - /** Enter — на 10% вверх. */ - protected onConfirm(index: number): void { - this.adjust(index, 0.1); - } - - /** Влево/вправо — подстройка громкости выбранной строки. */ - protected onAdjust(delta: number, index: number): void { - this.adjust(index, delta * 0.1); - } - - protected onCancel(): void { - this.back(); - } - - /** Список собирается один раз; громкости обновляются value-слотом. */ - private rebuild(): void { - this.menu!.setItems([ - { label: 'Общая', value: this.volumeBars(this.game.settings.data.master), onSelect: () => this.adjust(0, 0.1) }, - { label: 'Амбиент', value: this.volumeBars(this.game.settings.data.ambience), onSelect: () => this.adjust(1, 0.1) }, - { label: 'Звуки', value: this.volumeBars(this.game.settings.data.sfx), onSelect: () => this.adjust(2, 0.1) }, - { label: 'Назад', onSelect: () => this.back() } - ]); - } - - private volumeBars(v: number): string { - const bars = Math.round(v * 10); - return `${'▮'.repeat(bars)}${'▯'.repeat(10 - bars)}`; - } - - private adjust(index: number, delta: number): void { - if (index < 0 || index > 2) { - this.back(); - return; - } - const s = this.game.settings.data; - const key = (['master', 'ambience', 'sfx'] as const)[index]; - const value = Math.round(Math.min(1, Math.max(0, s[key] + delta)) * 10) / 10; - this.game.settings.update({ [key]: value }); - if (delta > 0) playSfx(this.game.audio, 'sfx/ui_click'); - this.menu!.setValue(index, this.volumeBars(value)); - } - - private back(): void { - playSfx(this.game.audio, 'sfx/ui_click'); - this.onBack(); - } -} \ No newline at end of file diff --git a/apps/game/src/scenes/__tests__/saveData.test.ts b/apps/game/src/scenes/__tests__/saveData.test.ts deleted file mode 100644 index d01898d..0000000 --- a/apps/game/src/scenes/__tests__/saveData.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { SAVE_VERSION, normalizeSave, type SaveData } from '../saveData'; - -/** Сейв заданной версии с минимальным содержимым. */ -function saveOf(version: number): SaveData { - return { - version, - area: 'meadows', - pos: { x: 1, y: 2 }, - state: { version: 2, flags: ['evening'], vars: {} }, - items: { flower: 1 }, - savedAt: 0 - } as SaveData; -} - -describe('normalizeSave', () => { - it('v3 (без clock): время дефолтится в старт 8:00, версия поднимается', () => { - const out = normalizeSave(saveOf(3)); - expect(out.version).toBe(SAVE_VERSION); - expect(out.clock).toEqual({ minutes: 480 }); - expect(out.items).toEqual({ flower: 1 }); - }); - - it('v4: проходит без изменений (тот же объект)', () => { - const s = saveOf(SAVE_VERSION); - s.clock = { minutes: 1230 }; - expect(normalizeSave(s)).toBe(s); - }); -}); \ No newline at end of file diff --git a/apps/game/src/scenes/saveData.ts b/apps/game/src/scenes/saveData.ts deleted file mode 100644 index 43cf9b6..0000000 --- a/apps/game/src/scenes/saveData.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { GameStateData } from '@rpg/engine'; -import type { AreaId } from '../data/locations'; -import { START_MINUTES } from '../systems/Clock'; - -/** - * Формат сейва: область, позиция героя, сериализованное состояние прохождения, - * сумка и время суток. У старых сейвов нет `version`/`items` — normalizeSave - * дорастает их. - */ -export const SAVE_VERSION = 4; - -/** Откуда герой вошёл в область (для переходов kind 'return'). */ -export interface ReturnToData { - area: AreaId; - entry: { x: number; y: number }; -} - -export interface SaveData { - version: number; - /** В какой области герой (фолбэк 'meadows' для старых сейвов). */ - area: string; - pos: { x: number; y: number }; - state: GameStateData; - /** Содержимое сумки: itemId -> количество. */ - items: Record; - /** Время суток в минутах (v4; нет — старт 8:00). */ - clock?: { minutes: number } | null; - /** Откуда вошли в область (v3; null/undefined — вход не через переход). */ - returnTo?: ReturnToData | null; - savedAt: number; -} - -/** Дополнить сейв старого формата до текущего (без записи). */ -export function normalizeSave(save: SaveData): SaveData { - if (save.version === SAVE_VERSION) return save; - // v1/v2: area зовётся location, returnTo ещё нет; v3: clock ещё нет. - const legacy = save as SaveData & { location?: string }; - return { - ...save, - version: SAVE_VERSION, - area: save.area ?? legacy.location ?? 'meadows', - returnTo: save.returnTo ?? null, - items: save.items ?? {}, - clock: save.clock ?? { minutes: START_MINUTES } - }; -} \ No newline at end of file diff --git a/apps/game/src/systems/Atmosphere.ts b/apps/game/src/systems/Atmosphere.ts deleted file mode 100644 index 9f9ca7d..0000000 --- a/apps/game/src/systems/Atmosphere.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { Container, Graphics, ParticleEmitter, type Updatable } from '@rpg/engine'; -import { Game } from '../Game'; -import type { AreaDef, HazardDef } from '../data/locations'; -import type { PlayerController } from './PlayerController'; - -/** - * Атмосфера локации: пепел/мотыли (частицы мира), дымка зон наката (UI-слой), - * механика низин (замедление без маски, тост при входе) и виньетка опасности. - */ - -/** Виньетка: цель при низком hp / в накате без маски и фейд перехода. */ -const VIGNETTE_LOW_HP = 0.3; -const VIGNETTE_HAZARD = 0.35; -const VIGNETTE_FADE = 0.5; - -export interface AtmosphereDeps { - game: Game; - area: AreaDef; - /** Слои: частицы — в мир, дымка — под UI (index 0 uiRoot). */ - worldRoot: Container; - uiRoot: Container; - /** Реестр тикающихся (engine.fx). */ - addFx(u: Updatable): void; - player: PlayerController; - /** Текущий hp героя (виньетка при низком). */ - heroHp: () => number; - /** Виньетка движкового света: уровень + цель с фейдом. */ - vignette: { level(): number; set(intensity: number, fadeSec: number): void }; - showToast(text: string): void; -} - -export class Atmosphere { - /** Пепел на лугах/деревне (в интерьерах null). */ - readonly ash: ParticleEmitter | null; - /** Тёплые мотыльки над лугами (светящиеся, дрейф по синусу). */ - readonly moths: ParticleEmitter | null; - /** Оверлей дымки (зоны наката). Виден только в низинах. */ - readonly fog: Graphics; - /** Зона наката, в которой герой был в прошлом кадре (тост при входе). */ - inHazard: HazardDef | null = null; - /** Текущая зона наката перекрыта полотном (для виньетки). */ - hazardMasked = false; - /** Тайл прошлого поиска зоны и его результат — поиск только при смене тайла. */ - private lastTile: { x: number; y: number } | null = null; - private lastHazard: HazardDef | null = null; - - constructor(private deps: AtmosphereDeps) { - const area = deps.area; - // Пепел на лугах, туман над прудами, в интерьере — ничего. - this.ash = - area.atmosphere === 'none' - ? null - : area.atmosphere === 'fog' - ? new ParticleEmitter({ - color: 0x7a7a88, - rate: 6, - lifetime: [5, 10], - velocity: { x: [-3, 3], y: [-1, 1] }, - size: 2, - spawnArea: { width: 560, height: 340 }, - seed: 20260906 - }) - : new ParticleEmitter({ - color: 0x666677, - rate: 9, - lifetime: [4, 9], - velocity: { x: [-7, -2], y: [1, 4] }, // ветер + медленное падение - size: 1, - spawnArea: { width: 520, height: 300 }, - seed: 20260905 - }); - if (this.ash) { - this.ash.position.set(240, 120); - deps.worldRoot.addChild(this.ash); - // Пепел тикается реестром эффектов (engine.fx) — без ручного update. - deps.addFx(this.ash); - } - - // Тёплые мотыльки над лугами: светятся (add), дышат по синусу, в интерьере их нет. - this.moths = - area.atmosphere === 'ash' - ? new ParticleEmitter({ - colors: [0xf0d878, 0xd8b050], - blend: 'add', - rate: 2, - lifetime: [3, 6], - velocity: { x: [-4, 4], y: [-2, 2] }, - size: 1, - wobble: 8, - fadeIn: 0.6, - spawnArea: { width: 520, height: 300 }, - seed: 20260907 - }) - : null; - if (this.moths) { - this.moths.position.set(240, 120); - deps.worldRoot.addChild(this.moths); - deps.addFx(this.moths); - } - - // Дымка наката: полупрозрачная пелена на весь экран (в низинах без маски — гуще). - this.fog = new Graphics().rect(0, 0, Game.VIRTUAL_W, Game.VIRTUAL_H).fill(0x5a6a78); - this.fog.alpha = 0; - deps.uiRoot.addChildAt(this.fog, 0); - } - - /** Зона наката под ногами: без маски герой еле идёт (тост при входе). */ - updateHazard(tile: { x: number; y: number }): void { - const { game, area, player } = this.deps; - // Зона зависит только от тайла: ищем заново лишь при его смене - // (поиск — замыкание + some по всем зонам, иначе он в каждом тике). - if (!this.lastTile || this.lastTile.x !== tile.x || this.lastTile.y !== tile.y) { - this.lastTile = { x: tile.x, y: tile.y }; - this.lastHazard = - (area.hazards ?? []).find((h) => - h.tiles.some((t) => t.x === tile.x && t.y === tile.y) - ) ?? null; - } - const hazard = this.lastHazard; - const masked = hazard !== null && game.inventory.has(hazard.requiresItem); - player.speedMul = hazard !== null && !masked ? 0.5 : 1; - - // Вход в низину: предупреждение один раз за вход. - if (hazard !== null && this.inHazard === null) { - this.deps.showToast( - masked ? `${hazard.name}: полотно держит — но пепел у самых губ.` : `${hazard.name}! Дышать поверх — потерять голос.` - ); - void game.audio.play('sfx/ash_hiss', 0.5); - } - this.inHazard = hazard; - this.hazardMasked = masked; - - // Дымка: плотная без маски, лёгкая с ней, вне низин её нет. - this.fog.alpha = hazard === null ? 0 : masked ? 0.12 : 0.28; - } - - /** Виньетка: низкий hp или накат без маски — края экрана темнеют. */ - updateVignette(): void { - const target = Math.max( - this.deps.heroHp() <= 2 ? VIGNETTE_LOW_HP : 0, - this.inHazard !== null && !this.hazardMasked ? VIGNETTE_HAZARD : 0 - ); - if (this.deps.vignette.level() !== target) this.deps.vignette.set(target, VIGNETTE_FADE); - } - - /** Погасить/уничтожить свои вьюхи (exit сцены; engine.fx дочистит сам). */ - destroy(): void { - this.ash?.clear(); - this.moths?.clear(); - this.ash?.destroy(); - this.moths?.destroy(); - this.fog.destroy(); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/AudioSystem.ts b/apps/game/src/systems/AudioSystem.ts deleted file mode 100644 index 54a107b..0000000 --- a/apps/game/src/systems/AudioSystem.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { - worldToScreen, - EventBus, - type AudioManager, - type MusicHandle, - type Vec2 -} from '@rpg/engine'; -import { Game } from '../Game'; -import { playSfx } from '../data/sfxSpecs'; -import { StepVariants, type StepSurface } from './StepVariants'; - -/** - * Позиционный звук мира: громкость и панорама от расстояния до героя, - * звуки боя — подпиской на события шины (симуляция чиста), локальные - * амбиент-слои — лупами в шине ambience с тик-фейдом. - */ - -/** Минимальный интервал между запусками одного ключа (полифония). */ -const MIN_INTERVAL = 0.08; - -export interface AudioSystemDeps { - audio: AudioManager; - events: EventBus; - /** Позиция слушателя (герой), мировые юниты. */ - getListener: () => Vec2; -} - -export interface AudioLayerDef { - key: string; - /** Целевая громкость (по умолчанию 0.5). */ - volume?: number; - /** Сек до целевой громкости. */ - fade?: number; -} - -export class AudioSystem { - private clock = 0; - private lastAt = new Map(); - private offs: (() => void)[] = []; - private layers = new Map(); - /** Ключи запрошенных слоёв (и до асинхронного старта лупов). */ - private wanted = new Set(); - /** Вариативные шаги (процедурные буферы; до unlock — файловый шаг). */ - private steps: StepVariants; - - constructor(private deps: AudioSystemDeps) { - this.steps = new StepVariants(deps.audio); - } - - /** Тик: часы полифонии + доводка громкости слоёв к целям (fade сек до target). */ - update(dt: number): void { - this.clock += dt; - for (const layer of this.layers.values()) { - if (!layer.handle) continue; // луп ещё грузится - const target = layer.def.volume ?? 0.5; - const rate = Math.abs(target) / Math.max(0.001, layer.def.fade ?? 1); - layer.volume = - layer.volume < target - ? Math.min(target, layer.volume + rate * dt) - : Math.max(target, layer.volume - rate * dt); - layer.handle.setVolume(layer.volume); - } - } - - /** - * Звук в точке мира: затухание по дистанции (степень 1.5) и панорама - * по экранному смещению от слушателя. За радиусом — тишина. - */ - playAt(key: string, pos: Vec2, base = 1, radius = 12): void { - const listener = this.deps.getListener(); - const d = Math.hypot(pos.x - listener.x, pos.y - listener.y); - const falloff = Math.max(0, 1 - d / radius); - const volume = base * Math.pow(falloff, 1.5); - if (volume < 0.02) return; - // Панорама — по разности экранных X источника и слушателя (камера не нужна). - const dx = worldToScreen(pos.x, pos.y).x - worldToScreen(listener.x, listener.y).x; - const pan = Math.max(-0.8, Math.min(0.8, dx / (Game.VIRTUAL_W / 2))); - if (Math.abs(pan) >= 0.01) this.play(key, { volume, pan }); - else this.play(key, { volume }); - } - - /** - * Шаг по поверхности ('grass'|'ash'|'water'|'floor'): процедурный вариант - * из StepVariants (до unlock — файловый шаг), дедуп по поверхности. - */ - playStep(surface: StepSurface, pos: Vec2, base = 1): void { - const listener = this.deps.getListener(); - const d = Math.hypot(pos.x - listener.x, pos.y - listener.y); - const volume = base * Math.max(0, 1 - d / 4); - if (volume < 0.02) return; - if ((this.lastAt.get(`step:${surface}`) ?? -1) + MIN_INTERVAL > this.clock) return; - this.lastAt.set(`step:${surface}`, this.clock); - this.steps.play(surface, volume); - } - - /** Подписки на события боя и фауны; возвращает off() для exit() сцены. */ - attach(): () => void { - const on = this.deps.events; - this.offs = [ - on.on<{ mode: string; origin: Vec2 }>('combat:attack', ({ mode, origin }) => { - if (mode === 'cone') this.playAt('sfx/bell_hit', origin, 0.8); - else this.playAt('sfx/bell_low', origin, 0.9); - }), - on.on<{ pos: Vec2 }>('combat:spit', ({ pos }) => this.playAt('sfx/spit', pos, 0.6)), - on.on<{ pos: Vec2 }>('combat:awake', ({ pos }) => this.playAt('sfx/ash_hiss', pos, 0.5)), - on.on<{ tile: Vec2 }>('combat:kill', ({ tile }) => this.playAt('sfx/ash_die', tile, 0.8)), - on.on('combat:playerHit', () => this.play('sfx/hurt', { volume: 0.8 })), - on.on<{ pos: Vec2 }>('fauna:startle', ({ pos }) => this.playAt('sfx/deer_startle', pos, 0.7)) - ]; - return () => this.off(); - } - - /** Отписаться от событий (сцена сменяется). */ - off(): void { - for (const off of this.offs) off(); - this.offs = []; - } - - /** - * Локальные амбиент-слои (вода, гул, дома) — лупы в шине ambience поверх - * областного. Вызывать каждый тик с текущим набором: лишние стопятся, - * громкости доводятся в update. - */ - setLayers(defs: AudioLayerDef[]): void { - this.wanted = new Set(defs.map((d) => d.key)); - for (const [key, layer] of this.layers) { - if (!this.wanted.has(key)) { - layer.handle?.stop(1); // pending-луп остановит .then (слой уже не нужен) - this.layers.delete(key); - } - } - for (const def of defs) { - const existing = this.layers.get(def.key); - if (existing) { - existing.def = def; - continue; - } - // Синхронная пометка «слой занят»: повторный вызов до резолва лупа - // не перезапускает его (playLoop асинхронен). - const pending: { def: AudioLayerDef; handle: MusicHandle | null; volume: number } = { - def, - handle: null, - volume: 0.001 - }; - this.layers.set(def.key, pending); - void this.deps.audio - .playLoop(def.key, { bus: 'ambience', fade: def.fade ?? 1, volume: 0.001 }) - .then((handle) => { - // Слой уже не нужен или луп не декодировался — без хвостов. - if (!handle || !this.wanted.has(def.key)) { - if (this.layers.get(def.key) === pending) this.layers.delete(def.key); - handle?.stop(0.5); - return; - } - pending.handle = handle; - }); - } - } - - private play(key: string, opts: { volume: number; rate?: number; pan?: number }): void { - if ((this.lastAt.get(key) ?? -1) + MIN_INTERVAL > this.clock) return; - this.lastAt.set(key, this.clock); - playSfx(this.deps.audio, key, opts); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/Clock.ts b/apps/game/src/systems/Clock.ts deleted file mode 100644 index ba651da..0000000 --- a/apps/game/src/systems/Clock.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Внутреигровое время суток. Темп: 1 секунда реального времени = 1 игровая - * минута (сутки = 24 минуты реального). Чистый класс без Pixi — тестируется - * в Vitest; живёт на Game (LocationScene пересоздаётся при переходах). - */ - -/** Игровых минут за секунду реального времени. */ -export const GAME_MIN_PER_SEC = 1; -/** Минут в сутках. */ -export const DAY_MINUTES = 1440; -/** Время старта новой игры (8:00). */ -export const START_MINUTES = 8 * 60; - -export class GameClock { - /** Сырые минуты суток (float, 0..1440). */ - private m = START_MINUTES; - - /** Целые минуты суток 0..1439. */ - get minutes(): number { - return Math.floor(this.m) % DAY_MINUTES; - } - - /** Часы суток float 0..24 — вход кривой dayNightFactor. */ - get hours(): number { - return this.m / 60; - } - - /** Строка для часов предмета: «8:00», «13:05» (часы без ведущего нуля). */ - get label(): string { - const m = this.minutes; - const h = Math.floor(m / 60); - const mm = m % 60; - return `${h}:${mm < 10 ? '0' : ''}${mm}`; - } - - /** Продвинуть время на dtSec секунд реального времени. */ - advance(dtSec: number): void { - this.m = (this.m + dtSec * GAME_MIN_PER_SEC) % DAY_MINUTES; - } - - /** Перемотка (мост/загрузка): mod 1440, отрицательные — в диапазон. */ - set(minutes: number): void { - this.m = ((minutes % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES; - } - - /** Новая игра: 8:00. */ - reset(): void { - this.set(START_MINUTES); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/DialogueEffects.ts b/apps/game/src/systems/DialogueEffects.ts deleted file mode 100644 index 8042470..0000000 --- a/apps/game/src/systems/DialogueEffects.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { DialogueEffectOp } from '@rpg/engine'; -import type { ItemId } from '../data/items'; -import type { DialogueCustomId } from '../data/effects'; - -/** - * Приёмник диалоговых эффектов: игра реализует его (сцена/системы), - * движок только эмитит операции из do[]. Сюда стекается giveItem/takeItem, - * звук, всплывашка и сюжетные custom-эффекты. - */ -export interface DialogueEffectSink { - giveItem(id: ItemId, count: number): void; - takeItem(id: ItemId, count: number): void; - playSound(key: string): void; - showToast(text: string): void; - custom(name: DialogueCustomId, payload: Record | undefined): void; -} - -/** - * Применить одну операцию эффекта к приёмнику. Неизвестный kind — ошибка - * валидатора контента, рантайм его молча игнорирует. - */ -export function applyDialogueEffect(op: DialogueEffectOp, sink: DialogueEffectSink): void { - switch (op.kind) { - case 'giveItem': - if (op.id) sink.giveItem(op.id as ItemId, op.count ?? 1); - return; - case 'takeItem': - if (op.id) sink.takeItem(op.id as ItemId, op.count ?? 1); - return; - case 'sound': - if (op.id) sink.playSound(op.id); - return; - case 'toast': - if (op.text) sink.showToast(op.text); - return; - case 'custom': - sink.custom((op.id ?? '') as DialogueCustomId, op.payload); - return; - default: - return; - } -} \ No newline at end of file diff --git a/apps/game/src/systems/DialogueSystem.ts b/apps/game/src/systems/DialogueSystem.ts deleted file mode 100644 index 52f5003..0000000 --- a/apps/game/src/systems/DialogueSystem.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - Container, - DialogueBox, - DialogueRunner, - GameState, - type DialogueBoxOptions, - type DialogueGraph -} from '@rpg/engine'; -import { applyDialogueEffect, type DialogueEffectSink } from './DialogueEffects'; - -/** - * Диалоги игры: DialogueRunner движка ходит по графам и применяет флаги - * к GameState, DialogueBox рисует реплики и варианты, do[]-эффекты идут - * в зарегистрированный EffectSink. - */ -export class DialogueSystem { - private box: DialogueBox; - private runner: DialogueRunner; - private sink: DialogueEffectSink | null = null; - - /** Сюжетное событие «диалог завершён» — для триггеров/квестов. */ - onDialogueFinished: ((id: string) => void) | null = null; - /** Звук на каждую новую реплику (колокольчик). */ - onLineShown: (() => void) | null = null; - /** id стартованного диалога (для onFinish). */ - private currentId: string | null = null; - - constructor(uiRoot: Container, state: GameState, options: DialogueBoxOptions) { - this.box = new DialogueBox({ - ...options, - onChoice: (index) => this.runner.pick(index) - }); - uiRoot.addChild(this.box.view); - - this.runner = new DialogueRunner(state); - this.runner.setView({ - show: ({ speaker, text, mood, choices }) => { - this.box.show({ speaker, text, mood, choices }); - this.onLineShown?.(); - }, - hide: () => this.box.hide() - }); - this.runner.onEffect = (op) => { - if (this.sink) applyDialogueEffect(op, this.sink); - }; - } - - /** Зарегистрировать приёмник do[]-эффектов (сцена с геймплеем). */ - registerSink(sink: DialogueEffectSink): void { - this.sink = sink; - } - - /** Уничтожить панель диалога (exit сцены — иначе копится в uiRoot). */ - destroy(): void { - this.box.view.destroy({ children: true }); - } - - get active(): boolean { - return this.runner.active; - } - - /** Начать диалог по ключу из data/dialogues.ts. */ - start(graph: DialogueGraph, id: string): void { - this.currentId = id; - this.runner.onFinish = () => { - const id = this.currentId; - this.currentId = null; - if (id) this.onDialogueFinished?.(id); - }; - this.runner.start(graph); - } - - /** Клик/пробел: догнать печать → активировать вариант под курсором → дальше. */ - advance(): void { - if (this.box.revealing) { - this.box.skipReveal(); - return; - } - if (this.runner.waitingForChoice) { - this.box.activate(); - return; - } - this.runner.advance(); - } - - /** Листать варианты up/down (клавиатура/геймпад). */ - moveCursor(delta: number): void { - this.box.moveCursor(delta); - } - - /** Печать текста; звать из update сцены. */ - update(dt: number): void { - this.box.update(dt); - } - - /** Выбрать показанный вариант (агентный мост; индексы — из agentState.choices). */ - pickChoice(index: number): void { - this.runner.pick(index); - } - - /** Выбрать вариант по тексту (агентный мост). false — такого варианта нет. */ - pickChoiceByText(text: string): boolean { - const index = this.runner.choices.findIndex((c) => c.text === text); - if (index < 0) return false; - this.runner.pick(index); - return true; - } - - /** Состояние диалога для агентного моста (снапшот; не для логики). */ - get agentState(): { - id: string | null; - nodeId: string | null; - speaker: string | null; - text: string | null; - mood: string | null; - tags: string[]; - choices: string[]; - path: string[]; - waitingForChoice: boolean; - } | null { - if (!this.runner.active) return null; - const node = this.runner.node; - return { - id: this.currentId, - nodeId: this.runner.nodeId, - speaker: node?.speaker ?? null, - text: node?.text ?? null, - mood: node?.mood ?? null, - tags: node?.tags ?? [], - choices: this.runner.choices.map((c) => c.text), - path: this.runner.path, - waitingForChoice: this.runner.waitingForChoice - }; - } -} \ No newline at end of file diff --git a/apps/game/src/systems/InteractHint.ts b/apps/game/src/systems/InteractHint.ts deleted file mode 100644 index 500047b..0000000 --- a/apps/game/src/systems/InteractHint.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Container, PixelText, worldToScreen } from '@rpg/engine'; - -/** Цель кнопки действия (от роутера): kind — для будущих иконок. */ -export interface InteractTargetInfo { - kind: string; - label: string; - tile: { x: number; y: number }; -} - -/** - * Подпись «[E] Имя» над ближайшей целью кнопки действия. Один вью на сцену - * (в worldRoot — масштабируется зумом камеры): позиция над тайлом цели, - * видна только когда цель есть. Заменяет прежние подписи per-object — - * NPC впервые получают подписи тоже. - */ -export class InteractHint { - readonly view = new Container(); - private text: PixelText; - - constructor() { - this.text = new PixelText({ text: '', size: 9, color: 0xd8c79a }); - this.text.anchor.set(0.5, 1); - this.view.addChild(this.text); - this.view.visible = false; - } - - /** Показать цель (null — скрыть). Центр тайла переводится в px внутри worldRoot. */ - show(target: InteractTargetInfo | null): void { - if (!target) { - this.view.visible = false; - return; - } - const c = worldToScreen(target.tile.x + 0.5, target.tile.y + 0.5); - this.text.text = `[E] ${target.label}`; - this.view.position.set(Math.round(c.x), Math.round(c.y) - 20); - this.view.visible = true; - } - - destroy(): void { - this.view.destroy({ children: true }); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/InteractRouting.ts b/apps/game/src/systems/InteractRouting.ts deleted file mode 100644 index be4b549..0000000 --- a/apps/game/src/systems/InteractRouting.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { tileToWorld, type IsometricTileMap, type TileMapData, type Vec2 } from '@rpg/engine'; -import type { AreaDef, TransitionDef } from '../data/locations'; -import { resolveTransition, type TransitionCtx } from '../data/transitions'; -import type { NpcDef } from '../data/npcs'; -import type { InteractableDef } from '../data/interactables'; -import type { Interactables } from './Interactables'; -import type { SceneObjects } from './SceneObjects'; -import type { PlayerController } from './PlayerController'; -import type { Game } from '../Game'; - -/** - * Маршрутизация взаимодействия: кнопка действия (ближайшая цель в радиусе) - * и step-переходы с дисармом тайла входа. Чистый пикер (pickInteract) - * тестируется в Vitest без Pixi; InteractionRouter — рантайм поверх него. - * Сцена остаётся оркестратором вьюх. (Клик-роутинг удалён: движение — - * WASD/стик/джойстик, взаимодействие — кнопкой действия.) - */ - -/** Колбэки роутера в сцену: всё, что трогает вьюхи/катсцены/замену сцен. */ -export interface RouterCallbacks { - showToast(text: string): void; - playUiClick(): void; - talkTo(def: NpcDef): void; - collectFlower(x: number, y: number): void; - useTransition(def: TransitionDef): void; -} - -/** Зависимости рантайма роутера (всё уже собрано сценой). */ -export interface RouterDeps { - game: Game; - map: IsometricTileMap; - area: AreaDef; - player: PlayerController; - interactables: Interactables; - /** Реестр объектов сцены: «кто на тайле» (NPC/интерактивы, grid путей). */ - objects: SceneObjects; - /** Тайл входа в область (step-переходы на нём дисармованы). */ - disarmTile: { x: number; y: number } | null; - callbacks: RouterCallbacks; -} - -/** Радиус взаимодействия (юнитов): от ног героя до центра тайла цели. */ -const INTERACT_RANGE = 1.5; - -/** Дистанция до центра тайла цели: истинная евклидова линейка (юнит = 1 тайл). */ -function tileDist(playerPos: Vec2, tx: number, ty: number): number { - const c = tileToWorld(tx, ty); - return Math.hypot(c.x - playerPos.x, c.y - playerPos.y); -} - -/** - * Радиус взаимодействия: истинная евклидова линейка (юнит = 1 тайл), не - * проекционная. Проекция анизотропна (2:1) — «юниты» в ней вытянуты вдоль - * мировой диагонали, и радиус в inCircleW давал зону в 2-3 тайла: объект - * срабатывал издалека, до подхода героя. Орто-сосед 1.0 ✓, диагональный - * сосед 1.414 ✓ (findPathToNeighbor приводит в него), 2 тайла ✗. - */ -export function inInteractRangePos(playerPos: Vec2, tx: number, ty: number): boolean { - return tileDist(playerPos, tx, ty) <= INTERACT_RANGE; -} - -/** Кандидат кнопки действия: то, с чем можно взаимодействовать в радиусе. */ -export interface InteractCandidate { - kind: 'npc' | 'transition' | 'locked' | 'interact' | 'flower'; - tile: { x: number; y: number }; - /** Дистанция от героя до центра тайла (юниты) — решает выбор. */ - dist: number; - /** Человекочитаемое имя для подписи «[E] …». */ - label: string; - npc?: NpcDef; - inter?: InteractableDef; - trans?: TransitionDef; - /** Текст «заперто» для kind 'locked'. */ - text?: string; - /** Тайл цветка (kind 'flower'). */ - x?: number; - y?: number; -} - -/** Приоритет при ничьей по дистанции: NPC важнее перехода, тот — объекта, объект — цветка. */ -const PICK_PRIORITY: Record = { - npc: 0, - transition: 1, - locked: 1, - interact: 2, - flower: 3 -}; - -/** Эпсилон ничьей по дистанции (юниты): ближе — выигрывает дистанция, иначе приоритет. */ -const PICK_EPS = 0.05; - -/** - * Выбор цели кнопки действия: ближайший кандидат в радиусе; при ничьей - * (~эпсилон) — по приоритету kind. Чистая функция — тестируется в Vitest. - */ -export function pickInteract(candidates: InteractCandidate[]): InteractCandidate | null { - let best: InteractCandidate | null = null; - for (const c of candidates) { - if (c.dist > INTERACT_RANGE) continue; - if ( - best === null || - c.dist < best.dist - PICK_EPS || - (c.dist <= best.dist + PICK_EPS && PICK_PRIORITY[c.kind] < PICK_PRIORITY[best.kind]) - ) { - best = c; - } - } - return best; -} - -/** - * Рантайм маршрутизации: кнопка действия (ближайшая цель в радиусе) и - * step-переходы с дисармом тайла входа. - */ -export class InteractionRouter { - private disarmTile: { x: number; y: number } | null; - private prevStepTile: { x: number; y: number } | null = null; - - constructor(private deps: RouterDeps) { - this.disarmTile = deps.disarmTile; - } - - /** Радиус взаимодействия (для подписей объектов в сцене). */ - inInteractRange(tx: number, ty: number): boolean { - return inInteractRangePos(this.deps.player.position, tx, ty); - } - - /** - * Единый механизм переходов (step-триггер): герой наступил на тайл. - * На тайле входа step-переходы дисармованы, пока герой с него не ушёл. - */ - checkTransitions(tile: { x: number; y: number }): void { - const fresh = - this.prevStepTile === null || - this.prevStepTile.x !== tile.x || - this.prevStepTile.y !== tile.y; - this.prevStepTile = tile; - // Ушёл с тайла входа — дисарм снят, переходы снова работают. - if (this.disarmTile && (tile.x !== this.disarmTile.x || tile.y !== this.disarmTile.y)) { - this.disarmTile = null; - } - const pick = resolveTransition( - this.deps.area.transitions, - tile, - 'step', - this.transitionCtx(), - this.disarmTile - ); - if (!pick) return; - if (!pick.ok) { - // «Заперто» — не спамим: только при приходе на тайл. - if (fresh) { - this.deps.callbacks.showToast(pick.lockedText); - this.deps.callbacks.playUiClick(); - } - return; - } - this.deps.callbacks.useTransition(pick.def); - } - - // ---------- кнопка действия: ближайшая цель в радиусе ---------- - - /** Кандидаты кнопки действия вокруг героя: NPC, click-переходы, интерактивы, цветы. */ - interactCandidates(): InteractCandidate[] { - const player = this.deps.player.position; - const heroTile = this.deps.player.currentTile(); - const out: InteractCandidate[] = []; - for (const def of this.deps.objects.npcs()) { - out.push({ - kind: 'npc', - npc: def, - tile: def.tile, - dist: tileDist(player, def.tile.x, def.tile.y), - label: def.name - }); - } - for (const t of this.deps.area.transitions) { - if ((t.trigger ?? 'step') !== 'click') continue; - const pick = resolveTransition(this.deps.area.transitions, t.tile, 'click', this.transitionCtx(), null); - if (!pick) continue; - out.push({ - kind: pick.ok ? 'transition' : 'locked', - trans: pick.ok ? pick.def : undefined, - text: pick.ok ? undefined : pick.lockedText, - tile: t.tile, - dist: tileDist(player, t.tile.x, t.tile.y), - label: t.label ?? 'проход' - }); - } - for (const def of this.deps.area.interactables ?? []) { - if (def.once && this.deps.interactables.isUsed(def.id)) continue; - out.push({ - kind: 'interact', - inter: def, - tile: def.tile, - dist: tileDist(player, def.tile.x, def.tile.y), - label: def.label ?? def.id - }); - } - // Цветы: скан окна ±2 тайла вокруг героя по сборному тайлу области. - if (this.deps.area.harvestTile !== undefined) { - for (let dy = -2; dy <= 2; dy++) { - for (let dx = -2; dx <= 2; dx++) { - const x = heroTile.x + dx; - const y = heroTile.y + dy; - if (!this.isFlower(x, y)) continue; - out.push({ kind: 'flower', x, y, tile: { x, y }, dist: tileDist(player, x, y), label: 'лунный колокольчик' }); - } - } - } - return out; - } - - /** Ближайшая цель кнопки действия — для подписи «[E] имя» и снапшота моста. */ - interactTarget(): { kind: InteractCandidate['kind']; label: string; tile: { x: number; y: number } } | null { - const c = pickInteract(this.interactCandidates()); - return c && { kind: c.kind, label: c.label, tile: c.tile }; - } - - /** Кнопка действия: сработать ближайшей целью в радиусе (вне радиуса — ничего). */ - resolveInteract(): void { - const c = pickInteract(this.interactCandidates()); - if (!c) return; - switch (c.kind) { - case 'npc': - this.deps.callbacks.talkTo(c.npc!); - return; - case 'transition': - this.deps.callbacks.useTransition(c.trans!); - return; - case 'locked': - this.deps.callbacks.showToast(c.text ?? 'Не пускает.'); - this.deps.callbacks.playUiClick(); - return; - case 'interact': - this.deps.interactables.tryInteract(c.inter!); - return; - case 'flower': - this.deps.callbacks.collectFlower(c.x!, c.y!); - } - } - - /** Сборный тайл области (лунный колокольчик); нет harvestTile — не собирается. */ - private isFlower(x: number, y: number): boolean { - const harvest = this.deps.area.harvestTile; - if (harvest === undefined) return false; - const d: TileMapData = this.deps.map.data; - return d.tiles[y * d.width + x] === harvest; - } - - /** Контекст условий перехода: флаги GameState, сумка героя. */ - private transitionCtx(): TransitionCtx { - return { - hasFlag: (f) => this.deps.game.state.hasFlag(f), - hasItem: (id) => this.deps.game.inventory.has(id) - }; - } -} \ No newline at end of file diff --git a/apps/game/src/systems/InteractableViews.ts b/apps/game/src/systems/InteractableViews.ts deleted file mode 100644 index d09fb23..0000000 --- a/apps/game/src/systems/InteractableViews.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { Container, FxLayer, Graphics, PixelText, Sprite, SpriteAnimator, SpriteMotion, type AssetLoader, type Updatable } from '@rpg/engine'; -import type { InteractableDef } from '../data/interactables'; -import type { NpcDef } from '../data/npcs'; - -/** - * Вьюхи NPC и интерактивных объектов: фабрики спрайтов/плейсхолдеров с - * подписями + растворение использованных объектов. Сцена только вызывает - * фабрики и держит слои глубины. - */ - -/** Вьюха интерактивного объекта + её части (тело — для кликов, подпись — близость). */ -export interface InteractableView { - def: InteractableDef; - view: Container; - body: Graphics | Sprite; - label: PixelText; -} - -export interface InteractableViewDeps { - assets: AssetLoader; - /** Реестр тикающихся (engine.fx): пульс мота и idle-анимация NPC. */ - addFx(u: Updatable): void; - /** Слой мировых эффектов: растворение использованной вьюхи. */ - fx: FxLayer; -} - -/** Экранный центр ромба тайла (worldToScreen ∘ tileToWorld). */ -export type TileCenter = (tx: number, ty: number) => { x: number; y: number }; - -export class InteractableViews { - /** Созданные вьюхи интерактивов (подписи, клики по телу, растворение). */ - readonly items: InteractableView[] = []; - - constructor(private deps: InteractableViewDeps) {} - - /** Вьюха интерактивного объекта: спрайт из def.view или плейсхолдер + подпись. */ - createInteractableView(def: InteractableDef, center: TileCenter): InteractableView { - const view = new Container(); - const p = center(def.tile.x, def.tile.y); - view.position.set(p.x, p.y); - let g: Graphics | Sprite; - if (def.view?.tex) { - // Настоящий арт из def.view: якорь низом в центр ромба (или задан). - const sprite = new Sprite(this.deps.assets.texture(def.view.tex)); - sprite.anchor.set(0.5, def.view.anchorY ?? 1); - g = sprite; - } else { - // Плейсхолдер-ящик по роду (пока нет арта конкретного объекта). - g = new Graphics(); - switch (def.kind) { - case 'signpost': // столбик с доской - g.rect(-1, -10, 2, 10).fill(0x4a3b2a); - g.rect(-6, -14, 12, 5).fill(0x5c4a33); - g.rect(-6, -14, 12, 1).fill(0x8a6d3a); - break; - case 'lever': // рычаг: основание и наклонная рукоять - g.rect(-3, -2, 6, 2).fill(0x4a3b2a); - g.rect(0, -8, 2, 7).fill(0x8a6d3a); - g.rect(-2, -10, 4, 2).fill(0x8a6d3a); - break; - case 'pickup': // пепельная насыпь, из которой теплится мот - g.rect(-6, -2, 12, 3).fill(0x3a3a3a); - g.rect(-4, -4, 8, 2).fill(0x4a4a4a); - g.rect(-2, -5, 4, 1).fill(0x5a5a5a); - break; - default: // контейнер/предмет осмотра: ящик с бронзовой кромкой - g.rect(-5, -12, 10, 12).fill(0x4a3b2a); - g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо - } - } - view.addChild(g); - // У мота — тёплая искра поверх насыпи: заметный пульс видно издалека. - if (def.kind === 'pickup') { - const spark = new Graphics(); - spark.rect(-1, -1, 3, 3).fill(0xf0d878); - spark.position.set(0, -5); - view.addChild(spark); - this.deps.addFx( - new SpriteMotion(spark, { pulse: { min: 0.3, max: 1, period: 1.1 } }, { - seed: def.tile.x * 31 + def.tile.y - }) - ); - } - const label = new PixelText({ text: def.label ?? def.id, size: 9, color: 0xd8c79a }); - label.anchor.set(0.5, 1); - label.position.set(0, -16); - label.visible = false; - view.addChild(label); - const made = { def, view, body: g, label }; - this.items.push(made); - return made; - } - - /** - * Одноразовый объект поднят (sink.onConsumed): вьюха растворяется, из - * тайла вылетают пеплинки — состояние мира меняется на глазах. - */ - consume(def: InteractableDef, center: TileCenter, burst: (s: { x: number; y: number }) => void): void { - const iv = this.items.find((v) => v.def.id === def.id); - if (!iv) return; - this.items.splice(this.items.indexOf(iv), 1); - const w = center(def.tile.x, def.tile.y); - burst(w); - // Вьюха тает и удаляется сама (пресет; removeChild+destroy внутри). - this.deps.fx.fade(iv.view, { duration: 0.5 }); - } - - /** NPC: спрайт с idle-дыханием кадрами атласа + маркер «с ним можно говорить». */ - createNpcView(def: NpcDef, center: TileCenter): { view: Container; body: Sprite } { - const view = new Container(); - const p = center(def.tile.x, def.tile.y); - view.position.set(p.x, p.y); - - const sprite = new Sprite(); - sprite.anchor.set(0.5, 1); // ноги в центре ромба - // idle-дыхание кадрами из атласа (по умолчанию — общий лист NPC). - const idle = this.deps.assets.animation( - def.atlas ?? 'chars/npcs_sheet.json', - `${def.sprite}_idle` - ); - sprite.texture = idle[0]!; - const animator = new SpriteAnimator( - sprite, - { idle: { frames: idle, fps: 1.2 } }, - 'idle', - { randomPhase: def.tile.y * 64 + def.tile.x } - ); - this.deps.addFx(animator); - view.addChild(sprite); - - // Маркер «с ним можно говорить»: восклицательный штрих над плечом - // (высота — от реального кадра атласа; сбоку, чтобы не закрывать лицо). - const frameH = idle[0]!.height; - const marker = new Graphics(); - marker.rect(5, -(frameH + 3), 2, 4).fill(0xf0d878); - marker.rect(5, -(frameH - 3), 2, 2).fill(0xf0d878); - view.addChild(marker); - return { view, body: sprite }; - } -} - -/** Бронзовый контур проёма + подпись «выход» — маркер интерьеров (kind 'return'). */ -export function makeExitGlow(p: { x: number; y: number }): Container { - const view = new Container(); - const glow = new Graphics(); - glow - .moveTo(0, -8) - .lineTo(16, 0) - .lineTo(0, 8) - .closePath() - .stroke({ color: 0x8a6d3a, width: 1 }); - glow.position.set(p.x, p.y); - view.addChild(glow); - const label = new PixelText({ text: 'выход', size: 9, color: 0xd8c79a }); - label.anchor.set(0.5, 1); - label.position.set(p.x, p.y - 12); - view.addChild(label); - return view; -} \ No newline at end of file diff --git a/apps/game/src/systems/Interactables.ts b/apps/game/src/systems/Interactables.ts deleted file mode 100644 index 9f0adc5..0000000 --- a/apps/game/src/systems/Interactables.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { GameState } from '@rpg/engine'; -import type { ItemId } from '../data/items'; -import { usedFlag } from '../data/ids'; -import { - resolveInteractable, - type InteractableDef, - type InteractCtx, - type InteractPick, - type InteractResponse -} from '../data/interactables'; - -/** - * Рантайм интерактивных объектов области: поиск по тайлу, флаги used:, - * применение эффектов реакции через sink (сцена подставляет сумку/звук/тост). - */ - -/** Эффекты реакции — мост между чистым резолвером и игрой. */ -export interface InteractSink { - hasItem(id: ItemId): boolean; - give(id: ItemId, count: number): void; - playSound(key: string): void; - showToast(text: string): void; - /** Сюжетный эффект реакции (DIALOGUE_CUSTOM): сцена запускает катсцену. */ - onCustom?(name: string): void; - /** - * Одноразовый объект «поднят» (once, реакция ok): сцена убирает вьюху с - * поля (fade) и даёт визуальный отклик. Вызывается после apply. - */ - onConsumed?(def: InteractableDef, response: InteractResponse): void; -} - -export class Interactables { - constructor( - private defs: InteractableDef[], - private state: GameState, - private sink: InteractSink - ) {} - - /** Объект на тайле (или null). */ - defAt(x: number, y: number): InteractableDef | null { - return this.defs.find((d) => d.tile.x === x && d.tile.y === y) ?? null; - } - - /** Одноразовый объект уже использован (флаг used: в GameState). */ - isUsed(id: string): boolean { - return this.state.hasFlag(usedFlag(id)); - } - - /** Взаимодействие: выбор реакции + применение эффектов. */ - tryInteract(def: InteractableDef): InteractPick { - const ctx: InteractCtx = { - hasFlag: (f) => this.state.hasFlag(f), - hasItem: (i) => this.sink.hasItem(i), - getVar: (id) => this.state.getNumber(id), - used: (id) => this.isUsed(id) - }; - const pick = resolveInteractable(def, ctx); - if (pick?.ok) { - this.apply(def, pick.response); - if (def.once) this.sink.onConsumed?.(def, pick.response); - } - return pick; - } - - /** Применить эффекты реакции (порядок: флаги -> вар -> предмет -> звук -> тост). */ - private apply(def: InteractableDef, r: InteractResponse): void { - for (const f of r.setFlags ?? []) this.state.setFlag(f); - for (const f of r.clearFlags ?? []) this.state.clearFlag(f); - if (r.setVar) this.state.setVar(r.setVar.id, r.setVar.value); - if (r.addVar) this.state.setVar(r.addVar.id, this.state.getNumber(r.addVar.id) + r.addVar.by); - if (r.gives) this.sink.give(r.gives, r.count ?? 1); - if (def.once) this.state.setFlag(usedFlag(def.id)); - if (r.sound) this.sink.playSound(r.sound); - if (r.custom) this.sink.onCustom?.(r.custom); - if (r.toast) { - // Тост с прогрессом (как у цветов): «Тон (2/3)». - const n = r.toast.varKey !== undefined ? this.state.getNumber(r.toast.varKey) : null; - const progress = - n !== null && r.toast.goal !== undefined ? ` (${Math.min(n, r.toast.goal)}/${r.toast.goal})` : ''; - this.sink.showToast(r.toast.text + progress); - } else { - this.sink.showToast(r.text); - } - } -} \ No newline at end of file diff --git a/apps/game/src/systems/Lighting.ts b/apps/game/src/systems/Lighting.ts deleted file mode 100644 index 3068406..0000000 --- a/apps/game/src/systems/Lighting.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * Игровая обвязка освещения: статика из LightingDef области + лампа героя, - * проекция в экранные px каждый тик (camera.toScreen), условные источники - * по флагам и времени суток. Runtime API (setAmbient) — база day/night. - */ - -import { - Camera, - Lighting, - dayNightAmbient, - dayNightFactor, - tileToWorld, - unitsToPx, - type PulseSpec, - type Vec2 -} from '@rpg/engine'; -import type { AreaDef, LightSourceDef } from '../data/locations'; - -/** Параметры лампы героя (следует за героем в тёмных областях). */ -export interface HeroLampDef { - color: number; - radius: number; // юниты - intensity: number; - flicker: number; -} - -/** Кап активных источников — защита от опечатки в данных (tiles одного id). */ -export const MAX_LIGHTS = 24; - -export interface GameLightingDeps { - area: AreaDef; - /** Движковый адаптер (уже добавлен в renderer.lightRoot). */ - lighting: Lighting; - /** Позиции тайлов по id (один проход по карте, см. LocationScene). */ - layerTilePos: Map; - camera: Camera; - /** Позиция героя в мировых юнитах. */ - heroPos: () => Vec2; - hasFlag: (flag: string) => boolean; - /** Текущее время суток в часах (GameClock.hours) — вход кривой дня. */ - timeHours: () => number; - lamp?: HeroLampDef | null; -} - -export interface LightingSnapshot { - ambient: number; - /** Целевая интенсивность виньетки 0..1 (текущая — пост-лерп движка). */ - vignette: number; - sources: { id: string; x: number; y: number; color: number; intensity: number }[]; -} - -/** Порог ночи для ночных источников и лампы на светлой улице (≈ 19:00–07:00). */ -const NIGHT_SOURCE_FACTOR = 0.5; - -/** Тёмная аура зоны наката: радиус в юнитах и сила затемнения. */ -const HAZARD_SPOT_RADIUS = 1.6; -const HAZARD_SPOT_ALPHA = 0.22; - -/** - * Подъём центра лампы героя от ног к телу, в юнитах по обеим мировым осям - * (в изометрии это «вверх по экрану»: 0.5+0.5 юнита ≈ 8 px при tileH 16). - */ -const LAMP_LIFT = 0.5; - -/** Кап тёмных пятен — под пул движка (maxDarkSpots, по умолчанию 16). */ -const MAX_DARK_SPOTS = 16; - -/** Считается ли область тёмной для лампы (тот же порог, что darkAreaLamp сцены). */ -function isDarkAmbient(ambient: number | undefined): boolean { - if (ambient === undefined) return false; - const r = (ambient >> 16) & 0xff; - const g = (ambient >> 8) & 0xff; - const b = ambient & 0xff; - return (r * 0.3 + g * 0.6 + b * 0.1) / 255 < 0.75; -} - -/** Детерминированная фаза мерцания по id: снапшот воспроизводим между запусками. */ -function seedOf(id: string): number { - let h = 2166136261; - for (let i = 0; i < id.length; i++) { - h ^= id.charCodeAt(i); - h = Math.imul(h, 16777619); - } - return ((h >>> 0) % 1000) / 1000; -} - -export class GameLighting { - private readonly deps: GameLightingDeps; - /** Активные id на прошлом тике — чтобы снимать погасшие (условия по флагам). */ - private activeIds = new Set(); - /** Активные id тёмных пятен на прошлом тике (ауры хазардов). */ - private spotIds = new Set(); - /** - * Дифф-буферы (каждый тик, без аллокаций): пара Set'ов на слой, своп после - * тика — clear() не должен задевать активный набор. - */ - private idsB = new Set(); - private spotsB = new Set(); - /** Переиспользуемые списки активных def/позиций (каждый тик). */ - private readonly defsBuf: LightSourceDef[] = []; - private readonly posBuf: { x: number; y: number }[] = []; - /** Мутируемый def лампы героя (пушится в defsBuf без пересоздания). */ - private readonly lampAt = { x: 0, y: 0 }; - private readonly lampDef: LightSourceDef = { id: 'lamp', at: this.lampAt }; - /** Последняя заданная цель ambient — guard от повторного setAmbient. */ - private lastAmbientTarget: number | null = null; - private destroyed = false; - - constructor(deps: GameLightingDeps) { - this.deps = deps; - if (deps.area.lighting?.ambient !== undefined) { - deps.lighting.setAmbient(deps.area.lighting.ambient); - this.lastAmbientTarget = deps.area.lighting.ambient; - this.updateNightAmbient(); // ночной вход в область — без мелькания дня - } - } - - // --- runtime поверх статики области (фундамент day/night и эффектов) --- - - setAmbient(color: number, fadeSec = 0): void { - this.deps.lighting.setAmbient(color, fadeSec); - this.lastAmbientTarget = color; - } - - /** Виньетка 0..1 (низкий hp, опасная зона); guard по изменению — у сцены. */ - setVignette(intensity: number, fadeSec = 0): void { - this.deps.lighting.setVignette(intensity, fadeSec); - } - - /** Текущая целевая виньетка. */ - get vignetteLevel(): number { - return this.deps.lighting.vignetteLevel; - } - - /** Импульс света в точке (позиция уже в экранных px, радиус — в px). */ - pulseLight(args: { x: number; y: number; color: number; intensity?: number; radius?: number; spec: PulseSpec }): void { - this.deps.lighting.pulseLight(args); - } - - /** Аддитивная вспышка на весь экран (color, peak 0..1, огибающая). */ - pulseAmbient(args: { color: number; peak: number; spec: PulseSpec }): void { - this.deps.lighting.pulseAmbient(args); - } - - /** Тик: ночная цель ambient, источники, тёмные пятна хазардов. */ - update(): void { - if (this.destroyed) return; - this.updateNightAmbient(); - const next = this.idsB; - next.clear(); - for (const def of this.activeDefs()) { - for (const pos of this.sourcePositions(def)) { - if (next.size >= MAX_LIGHTS) break; - const id = def.tiles ? `${def.id}@${pos.x},${pos.y}` : def.id; - next.add(id); - const world = tileToWorld(pos.x + 0.5, pos.y + 0.5); - const s = this.deps.camera.toScreen(world.x, world.y); - // Свет живёт в lightRoot (вне worldRoot): радиус масштабируем зумом. - this.deps.lighting.upsertLight({ - id, - x: s.x, - y: s.y, - color: def.color ?? 0xf2b45a, - intensity: def.intensity ?? 0.9, - radius: unitsToPx(def.radius ?? 3) * this.deps.camera.zoomLevel, - flicker: def.flicker ?? 0, - seed: seedOf(id) - }); - } - } - for (const id of this.activeIds) { - if (!next.has(id)) this.deps.lighting.removeLight(id); - } - this.idsB = this.activeIds; // старый набор — на следующей итерации буфер - this.activeIds = next; - this.updateHazardSpots(); - } - - /** Текущий фактор ночи 0..1 по часам (округлён — для снапшота и фильтров). */ - get nightFactor(): number { - return Math.round(dayNightFactor(this.deps.timeHours()) * 1000) / 1000; - } - - /** Цель ambient: микс дневного и ночного по кривой суток (плавный закат — сама кривая). */ - private updateNightAmbient(): void { - const light = this.deps.area.lighting; - const night = light?.nightAmbient; - const base = light?.ambient; - if (night === undefined || base === undefined) return; - // Золотой час: день → закатный тон → ночь (рассвет тем же тёплым цветом). - const target = dayNightAmbient(base, night, this.nightFactor, light?.duskColor); - if (target !== this.lastAmbientTarget) this.setAmbient(target, 0); - } - - /** Тёмные ауры зон наката: по тайлу HazardDef, снятие погасших через diff. */ - private updateHazardSpots(): void { - const next = this.spotsB; - next.clear(); - for (const hazard of this.deps.area.hazards ?? []) { - for (const t of hazard.tiles) { - if (next.size >= MAX_DARK_SPOTS) break; - const id = `hazard@${t.x},${t.y}`; - next.add(id); - const world = tileToWorld(t.x + 0.5, t.y + 0.5); - const s = this.deps.camera.toScreen(world.x, world.y); - this.deps.lighting.upsertDarkSpot({ - id, - x: s.x, - y: s.y, - radius: unitsToPx(HAZARD_SPOT_RADIUS) * this.deps.camera.zoomLevel, - alpha: HAZARD_SPOT_ALPHA - }); - } - } - for (const id of this.spotIds) { - if (!next.has(id)) this.deps.lighting.removeDarkSpot(id); - } - this.spotsB = this.spotIds; - this.spotIds = next; - } - - /** Состояние для агентного снапшота (интенсивность — пост-мерцание). */ - snapshot(): LightingSnapshot { - const frames = this.deps.lighting.frames(); - return { - ambient: this.deps.lighting.ambientColor, - vignette: Math.round(this.deps.lighting.vignetteLevel * 1000) / 1000, - sources: frames.map((f) => ({ - id: f.id, - x: Math.round(f.x * 10) / 10, - y: Math.round(f.y * 10) / 10, - color: f.tint, - intensity: Math.round(f.alpha * 1000) / 1000 - })) - }; - } - - destroy(): void { - for (const id of this.activeIds) this.deps.lighting.removeLight(id); - this.activeIds.clear(); - for (const id of this.spotIds) this.deps.lighting.removeDarkSpot(id); - this.spotIds.clear(); - this.destroyed = true; - } - - // --- внутреннее --- - - /** Активные def области + лампа героя (в defsBuf, переиспользуется каждый тик). */ - private activeDefs(): LightSourceDef[] { - const out = this.defsBuf; - out.length = 0; - const all = this.deps.area.lighting?.sources ?? []; - const night = this.nightFactor; - for (const def of all) { - if (def.whenFlag && !this.deps.hasFlag(def.whenFlag)) continue; - if (def.notFlag && this.deps.hasFlag(def.notFlag)) continue; - if (def.whenNight && night < NIGHT_SOURCE_FACTOR) continue; - out.push(def); - } - const lamp = this.deps.lamp; - // Лампа в тёмной области или ночью (вечерняя улица тоже темна). - if (lamp && (isDarkAmbient(this.deps.area.lighting?.ambient) || night >= NIGHT_SOURCE_FACTOR)) { - // Позиция героя уже в мировых юнитах: центры тайлов = tileToWorld(x+0.5), - // поэтому отнимаем 0.5 — источник встаёт на якорь героя (ноги). Спрайт - // героя рисуется выше якоря (16×24, низ = якорь), поэтому поднимаем - // центр лампы к телу: в изометрии «вверх по экрану» = минус по обеим - // мировым осям поровну (экранный Y = (x+y)·tileH/2). - const hero = this.deps.heroPos(); - this.lampAt.x = hero.x - 0.5 - LAMP_LIFT; - this.lampAt.y = hero.y - 0.5 - LAMP_LIFT; - const d = this.lampDef; - d.radius = lamp.radius; - d.color = lamp.color; - d.intensity = lamp.intensity; - d.flicker = lamp.flicker; - out.push(d); - } - return out; - } - - /** Позиции источника в тайлах (в posBuf): точка at или все тайлы заданных id. */ - private sourcePositions(def: LightSourceDef): { x: number; y: number }[] { - const out = this.posBuf; - out.length = 0; - if (def.at) { - out.push(def.at); - return out; - } - for (const id of def.tiles ?? []) { - const positions = this.deps.layerTilePos.get(id); - if (positions) for (const pos of positions) out.push(pos); - } - return out; - } -} \ No newline at end of file diff --git a/apps/game/src/systems/LocationAudio.ts b/apps/game/src/systems/LocationAudio.ts deleted file mode 100644 index ef907d8..0000000 --- a/apps/game/src/systems/LocationAudio.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { RATE, renderMusicStereo, renderSpec, type AudioManager, type MusicHandle, type Vec2 } from '@rpg/engine'; -import { AudioSystem, type AudioLayerDef } from './AudioSystem'; -import { ROOM_TONES } from '../data/sfxSpecs'; -import { TENSION_STEM } from '../data/music'; -import type { AmbienceLayerDef, AreaDef } from '../data/locations'; - -/** - * Аудио-подсистема локации: позиционные слои (громкость от дистанции до - * источника), room tone интерьеров и динамическая музыка угрозы - * (tension-стем под темой области). Сцена кормит её тиком - * и уровнем угрозы боевого мира; свои лупы гасит сама в exit(). - */ - -export interface LocationAudioDeps { - audio: AudioManager; - /** Позиционный звук мира (шаги, звуки боя) — тикается сценой отдельно. */ - worldAudio: AudioSystem; - area: AreaDef; - /** Позиции тайлов-источников амбиент-слоёв по id (собирает сцена). */ - layerTilePos: Map; - /** Слушатель — герой (мировые юниты). */ - heroPos: () => Vec2; - /** Тема области/боя (Game.playTheme: кроссфейд, '' — тишина). */ - playTheme: (key: string) => void; - /** Амбиент области (Game.playAmbience: кроссфейд 1.5 с, тот же ключ не перезапускает). */ - playAmbience: (key: string) => void; - /** Фактор ночи 0..1 (кривая суток, как у света). */ - nightFactor: () => number; - /** Уровень угрозы боевого мира 0..1 (погоня — 1, wary — 0.4). */ - threat: () => number; -} - -export class LocationAudio { - /** Tension-стем (враги wary): буфер + хендл + сглаженная громкость. */ - private stemHandle: MusicHandle | null = null; - private stemBuf: AudioBuffer | null = null; - private stemLevel = 0; - /** Стем заводится (промис не вернулся) — не заводить второй. */ - private stemPending = false; - /** Room tone интерьера (тихий гул на шине ambience). */ - private roomtoneHandle: MusicHandle | null = null; - /** exit() вызван: гасим хвосты асинхронных заводок (roomtone/стем). */ - private exited = false; - /** Текущий выбор амбиента день/ночь (для гистерезиса порога). */ - private nightAmbient = false; - - constructor(private deps: LocationAudioDeps) {} - - /** Room tone интерьера (см. ROOM_TONES): тихий луп на шине ambience. */ - startRoomTone(): void { - const spec = ROOM_TONES[this.deps.area.id]; - if (!spec) return; - const buf = this.deps.audio.createBuffer(renderSpec(spec)); - if (!buf) return; // до unlock контекста нет — тон появится при следующем входе - const key = this.deps.area.id; - void this.deps.audio - .playLoopBuffer(`roomtone/${key}`, buf, { bus: 'ambience', fade: 2, volume: 0.6 }) - .then((h) => { - // Сцена уже вышла, пока луп заводился, — гасим без хвостов. - if (this.exited) { - h?.stop(1); - return; - } - this.roomtoneHandle = h; - }); - } - - /** Погасить свои лупы (exit сцены) — без хвостов поверх следующей сцены. */ - exit(): void { - this.exited = true; - this.roomtoneHandle?.stop(1); - this.roomtoneHandle = null; - this.stemHandle?.stop(1); - this.stemHandle = null; - this.stemBuf = null; - this.stemPending = false; - this.stemLevel = 0; - } - - /** - * Тик амбиент-слоёв: громкость каждого — от дистанции героя до ближайшего - * источника (тайлы или точка); глобальный слой (без источников) — константа. - * Пустой набор — setLayers сам снимет лишние. - */ - updateLayers(): void { - const p = this.deps.heroPos(); - const defs: AudioLayerDef[] = []; - for (const layer of this.deps.area.ambienceLayers ?? []) { - const d = this.layerDistance(layer, p); - const k = layer.radius === undefined ? 1 : Math.max(0, 1 - d / layer.radius); - const volume = (layer.volume ?? 0.5) * k; - if (volume > 0.01) defs.push({ key: layer.key, volume, fade: 0.5 }); - } - this.deps.worldAudio.setLayers(defs); - } - - /** - * Дистанция до ближайшего источника слоя (юниты; тайл — центр ромба). - * Слой без at, positions и tiles — глобальный (ветер): источников нет. - */ - private layerDistance(layer: AmbienceLayerDef, p: Vec2): number { - if (!layer.at && !(layer.positions && layer.positions.length > 0) && !(layer.tiles && layer.tiles.length > 0)) - return 0; - if (layer.at) return Math.hypot(p.x - (layer.at.x + 0.5), p.y - (layer.at.y + 0.5)); - let best = Infinity; - for (const tile of layer.positions ?? []) { - const d = Math.hypot(p.x - (tile.x + 0.5), p.y - (tile.y + 0.5)); - if (d < best) best = d; - } - for (const id of layer.tiles ?? []) { - for (const tile of this.deps.layerTilePos.get(id) ?? []) { - const d = Math.hypot(p.x - (tile.x + 0.5), p.y - (tile.y + 0.5)); - if (d < best) best = d; - } - } - return best; - } - - /** - * Амбиент дня/ночи: за порогом кривой суток (с гистерезисом ±0.05, чтобы - * не трещало на закате) играем ночной луп области — кроссфейд даёт сам - * Game.playAmbience (1.5 с). Ночной ключ — суффикс `_night` у дневного. - */ - updateDayNightAmbient(): void { - const base = this.deps.area.ambience; - if (!base) return; // интерьеры без амбиента — нечего переключать - const night = this.deps.nightFactor() >= (this.nightAmbient ? 0.45 : 0.55); - if (night === this.nightAmbient) return; - this.nightAmbient = night; - this.deps.playAmbience(night ? `${base}_night` : base); - } - - /** Начальный амбиент (enter сцены): день или ночь по текущим часам. */ - startAmbient(): void { - this.nightAmbient = this.deps.nightFactor() >= 0.5; - const base = this.deps.area.ambience; - if (base) this.deps.playAmbience(this.nightAmbient ? `${base}_night` : base); - } - - /** - * Динамическая музыка по уровню угрозы: погоня — боевая тема вместо темы - * области; настороженность — тихий tension-стем под темой (громкость - * по угрозе, сглаженная); спокойствие — тема области. Сцена зовёт после - * combat.update — угроза свежая. - */ - updateMusic(dt: number): void { - const threat = this.deps.threat(); - // Боевая тема вытесняет тему области; обратно — как только погоня кончилась. - this.deps.playTheme(threat >= 1 ? 'combat' : (this.deps.area.theme ?? '')); - - // Стем: тихо в бою (тема боя сама громкая), громче при wary, ноль в тишине. - const target = threat >= 1 ? 0.15 : threat > 0 ? 0.6 * threat : 0; - if (!this.stemHandle && !this.stemPending && target > 0) { - this.stemBuf ??= this.deps.audio.createBuffer(renderMusicStereo(TENSION_STEM), RATE, 2); - if (this.stemBuf) { - this.stemPending = true; - void this.deps.audio - .playLoopBuffer('music/tension', this.stemBuf, { fade: 1, volume: 0 }) - .then((h) => { - this.stemPending = false; - if (!h) return; - // Сцена сменилась, пока луп заводился, — гасим без хвостов. - if (!this.stemBuf) h.stop(1); - else this.stemHandle = h; - }); - } - } - // Громкость ведём плавно (dt-подход), stop в exit. - this.stemLevel += (target - this.stemLevel) * Math.min(1, dt * 2); - this.stemHandle?.setVolume(this.stemLevel * 0.8); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/LocationHud.ts b/apps/game/src/systems/LocationHud.ts deleted file mode 100644 index c092c36..0000000 --- a/apps/game/src/systems/LocationHud.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Container, PixelText } from '@rpg/engine'; - -/** - * HUD локации: название области (правый верх), подсказка управления (левый - * верх), счётчик мотов (под названием), трекер цели квеста (под подсказкой). - * Одна вьюха в uiRoot — сцена только добавляет её, тикает setMotes/setQuest - * и гасит в exit. - */ -export class LocationHud { - readonly view = new Container(); - private motesLabel: PixelText; - private questLabel: PixelText; - private lastMotes = -1; - private lastQuest: string | null = null; - - constructor(areaName: string) { - const label = new PixelText({ text: areaName, size: 11, color: 0x999988 }); - label.anchor.set(1, 0); - label.position.set(474, 4); - this.view.addChild(label); - this.motesLabel = new PixelText({ text: '', size: 10, color: 0xd8c79a }); - this.motesLabel.anchor.set(1, 0); - this.motesLabel.position.set(474, 17); // под названием локации - this.view.addChild(this.motesLabel); - const hint = new PixelText({ - text: 'WASD — идти · E — действие · Space — удар (удержать — резонанс) · I — сумка · Esc — меню', - size: 10, - color: 0x999988 - }); - // Под сердечками (HealthBar занимает y≈13..18). - hint.position.set(6, 23); - this.view.addChild(hint); - this.questLabel = new PixelText({ text: '', size: 10, color: 0xd8c79a }); - this.questLabel.position.set(6, 35); // под подсказкой управления - this.view.addChild(this.questLabel); - } - - /** Счётчик мотов: текст пишем только при изменении (сбор — редкое событие). */ - setMotes(count: number): void { - if (count === this.lastMotes) return; - this.lastMotes = count; - this.motesLabel.text = count > 0 ? `Пепельные моты: ${count}` : ''; - } - - /** Трекер цели: «Цель: <текст стадии>»; null — активных квестов нет. */ - setQuest(text: string | null): void { - if (text === this.lastQuest) return; - this.lastQuest = text; - this.questLabel.text = text === null ? '' : `Цель: ${text}`; - } - - destroy(): void { - this.view.destroy({ children: true }); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts deleted file mode 100644 index abb62fe..0000000 --- a/apps/game/src/systems/PlayerController.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { - IsometricTileMap, - worldToScreen, - screenToWorld, - worldNorm, - worldToTile, - tileToWorld, - moveTowardsW, - moveCircle, - circleFits, - separateCircles, - SpriteAnimator, - Container, - Sprite, - Texture, - type SceneRegistry, - type Vec2 -} from '@rpg/engine'; -import { PLAYER_COMBAT } from './combat/stats'; - -/** Максимальный радиус тел, из которых выталкивается герой (NPC — 0.35, запас). */ -const BODY_PROBE = 0.5; - -/** Порог прибытия в waypoint (юниты): меньше шага любого кадра — на глаз незаметно. */ -const ARRIVE_EPS = 1e-4; - -/** Кадры героя по направлениям. side смотрит вправо; влево — флип. */ -export interface HeroTextures { - down: [Texture, Texture]; - up: [Texture, Texture]; - side: [Texture, Texture]; -} - -type Facing = 'down' | 'up' | 'left' | 'right'; - -/** - * Управление героем: путь по клику (A*), плавное движение по маршруту, - * покадровая анимация ходьбы по направлению движения. - * Позиция — в мировых юнитах (1 юнит = 1 тайл, ноги героя). - */ -export class PlayerController { - readonly view: Container; - /** Именованные клипы: walk_* на ходу, idle_* — стоп-кадр направления. */ - readonly animator: SpriteAnimator; - - /** Спрайт тела (публичный: хит-флэши урона через FxLayer.flash). */ - readonly sprite: Sprite; - private facing: Facing = 'down'; - /** Кэш имени клипа ходьбы (меняется только с facing — setFacing). */ - private facingClip = 'walk_down'; - - /** Позиция в мировых юнитах (источник истины, ноги героя). */ - private pos: Vec2; - private path: { x: number; y: number }[] = []; - private waypoint: Vec2 | null = null; - /** Скорость в мировых юнитах/сек. */ - private speed = 1.75; - /** Множитель скорости (зоны наката без маски — 0.5). Меняется сценой каждый кадр. */ - speedMul = 1; - /** Реестр объектов сцены (тела NPC) — задаётся сценой после создания. */ - private bodies: SceneRegistry | null = null; - - constructor( - private map: IsometricTileMap, - textures: HeroTextures, - startTile: { x: number; y: number }, - private onStep?: () => void - ) { - this.view = new Container(); - this.sprite = new Sprite(textures.down[0]); // публичный: флэши урона (FxLayer.flash) - this.sprite.anchor.set(0.5, 1); // ноги в точке позиции - this.view.addChild(this.sprite); - this.animator = new SpriteAnimator(this.sprite, { - walk_down: { frames: textures.down, fps: 6 }, - walk_up: { frames: textures.up, fps: 6 }, - walk_side: { frames: textures.side, fps: 6 }, - idle_down: { frames: [textures.down[0]], loop: 'once' }, - idle_up: { frames: [textures.up[0]], loop: 'once' }, - idle_side: { frames: [textures.side[0]], loop: 'once' } - }, 'idle_down'); - - this.pos = tileToWorld(startTile.x, startTile.y); - this.syncView(); - } - - /** Текущее направление взгляда (для боя: куда бьёт конус). */ - get dir(): 'down' | 'up' | 'left' | 'right' { - return this.facing; - } - - /** Подключить реестр тел — герой не проходит сквозь NPC. */ - setBodies(registry: SceneRegistry | null): void { - this.bodies = registry; - } - - /** Вытолкнуть себя из тел (каждый актор двигает только себя). */ - private separateBodies(): void { - if (!this.bodies) return; - for (const hit of this.bodies.near(this.pos, PLAYER_COMBAT.radius + BODY_PROBE, 'npc')) { - separateCircles(this.pos, PLAYER_COMBAT.radius, hit.pos, hit.radius); - } - } - - /** Вектор направления взгляда в мировых юнитах (экранные оси — мировые диагонали). */ - get dirVector(): Vec2 { - switch (this.facing) { - case 'up': - return { x: -1, y: -1 }; - case 'left': - return { x: -1, y: 1 }; - case 'right': - return { x: 1, y: -1 }; - default: - return { x: 1, y: 1 }; - } - } - - /** Пойти по готовому пути тайлов (мост: scene:walk/approach, кат-сцены). */ - followPath(path: { x: number; y: number }[]): void { - if (path.length === 0) return; - this.path = path; - this.advanceWaypoint(); - } - - update(dt: number): void { - // Тела: даже стоячий герой выталкивается (враг мог втолкнуть его в NPC). - this.separateBodies(); - if (!this.waypoint) { - this.playIdle(); - return; - } - this.pos = moveTowardsW(this.pos, this.waypoint, this.speed * this.speedMul * dt); - if (this.arrivedAt(this.waypoint)) { - this.pos = { x: this.waypoint.x, y: this.waypoint.y }; // снап: вью и тайлы без дрейфа - this.onStep?.(); // ступили на новый тайл - this.advanceWaypoint(); - this.syncView(); // вью на точном прибытии (иначе кадр отстаёт) - return; - } - this.faceMovement(); - this.animator.resume(); - this.animator.play(this.walkClip()); - this.animator.update(dt); - this.syncView(); - } - - get moving(): boolean { - return this.waypoint !== null; - } - - /** Текущий тайл героя. */ - currentTile(): { x: number; y: number } { - return ( - worldToTile(this.pos.x, this.pos.y, this.map.data.width, this.map.data.height) ?? { x: 0, y: 0 } - ); - } - - /** Прямое движение (тач-джойстик/стик): экранный вектор -1..1, движение с проверкой стен. */ - moveFree(dir: Vec2, dt: number): void { - const len = Math.hypot(dir.x, dir.y); - if (len < 0.01) { - this.stop(); - return; - } - // Экранная ось джойстика -> мировое направление (экранные оси — мировые диагонали). - const w = screenToWorld(dir.x, dir.y); - const n = worldNorm(w.x, w.y); - const step = this.speed * this.speedMul * dt; - // Круг тела скользит вдоль стен (оси раздельно). - const moved = moveCircle(this.map, this.pos, { x: n.x * step, y: n.y * step }, PLAYER_COMBAT.radius); - this.separateBodies(); - if (moved) { - // Поворот — по экранным компонентам смещения (кадры down/up/side). - const proj = worldToScreen(n.x, n.y); - this.setFacing( - Math.abs(proj.x) >= Math.abs(proj.y) ? (proj.x >= 0 ? 'right' : 'left') : proj.y >= 0 ? 'down' : 'up' - ); - this.animator.resume(); - this.animator.play(this.walkClip()); - this.animator.update(dt); - this.syncView(); - } else { - this.stop(); - } - } - - /** Позиция героя (ноги) в мировых юнитах. */ - get position(): Vec2 { - return { x: this.pos.x, y: this.pos.y }; - } - - /** Текущий кадр спрайта (для дебага анимации). */ - get currentTexture(): Texture { - return this.sprite.texture; - } - - /** Прервать текущий путь (например, при уроне). */ - stop(): void { - this.path = []; - this.waypoint = null; - } - - /** Имя клипа ходьбы по текущему направлению — кэш из setFacing. */ - private walkClip(): string { - return this.facingClip; - } - - /** Стоп-кадр текущего направления (без «застыла на произвольном кадре ходьбы»). */ - private playIdle(): void { - this.animator.play(`idle_${this.facing === 'left' || this.facing === 'right' ? 'side' : this.facing}`); - } - - /** Мгновенно переместить героя в тайл (респаун, переходы между локациями). */ - teleportTo(tile: { x: number; y: number }): void { - this.stop(); - this.pos = tileToWorld(tile.x, tile.y); - this.syncView(); - } - - /** Отброс: сдвиг позиции, только если тело помещается (для боя), юниты. */ - applyKnockback(dir: Vec2, dist: number): void { - const nx = this.pos.x + dir.x * dist; - const ny = this.pos.y + dir.y * dist; - if (circleFits(this.map, { x: nx, y: ny }, PLAYER_COMBAT.radius)) { - this.pos.x = nx; - this.pos.y = ny; - this.syncView(); - } - this.stop(); - } - - /** Направление — по доминирующей экранной оси движения к следующей точке пути. */ - private faceMovement(): void { - if (!this.waypoint) return; - const dx = this.waypoint.x - this.pos.x; - const dy = this.waypoint.y - this.pos.y; - const proj = worldToScreen(dx, dy); - this.setFacing( - Math.abs(proj.x) >= Math.abs(proj.y) ? (proj.x >= 0 ? 'right' : 'left') : proj.y >= 0 ? 'down' : 'up' - ); - } - - /** Прибытие с eps: moveTowardsW обещает точность, но расталкивание тел сдвигает. */ - private arrivedAt(t: Vec2): boolean { - return Math.abs(this.pos.x - t.x) < ARRIVE_EPS && Math.abs(this.pos.y - t.y) < ARRIVE_EPS; - } - - /** Сменить направление взгляда (кадры + флип спрайта + кэш имени клипа). */ - private setFacing(facing: Facing): void { - if (facing === this.facing) return; - this.facing = facing; - this.sprite.scale.x = facing === 'left' ? -1 : 1; - // Имя клипа ходьбы зависит только от facing: кэш вместо строки каждый тик. - this.facingClip = `walk_${facing === 'left' || facing === 'right' ? 'side' : facing}`; - } - - private advanceWaypoint(): void { - const next = this.path.shift(); - this.waypoint = next ? tileToWorld(next.x, next.y) : null; - } - - /** Вью-позиция: единственная точка перевода мир→экран (округление до px). */ - private syncView(): void { - const s = worldToScreen(this.pos.x, this.pos.y); - this.view.position.set(Math.round(s.x), Math.round(s.y)); - } -} - diff --git a/apps/game/src/systems/SceneObjects.ts b/apps/game/src/systems/SceneObjects.ts deleted file mode 100644 index 707e079..0000000 --- a/apps/game/src/systems/SceneObjects.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { SceneRegistry, tileToWorld, type Grid, type IsometricTileMap } from '@rpg/engine'; -import type { AreaDef } from '../data/locations'; -import type { NpcDef } from '../data/npcs'; -import type { InteractableDef } from '../data/interactables'; - -/** - * Объекты сцены на движковом SceneRegistry: единый список «кто на карте» - * (NPC, интерактивы, пропы, фауна, враги) с индексом по тайлам. - * Клик-роутинг, подписи и телесные коллизии спрашивают здесь, а не перебирают - * параллельные списки. Роды задаёт игра: 'npc' | 'interactable' | 'prop' | - * 'fauna' | 'enemy'. - */ - -/** Радиус тела NPC (юниты) — для будущих телесных коллизий. */ -const NPC_RADIUS = 0.35; - -export class SceneObjects { - readonly registry = new SceneRegistry(); - - constructor(area: AreaDef, private map: IsometricTileMap) { - for (const def of area.npcs) { - this.registry.add({ - id: `npc:${def.id}`, - kind: 'npc', - pos: tileToWorld(def.tile.x, def.tile.y), - radius: NPC_RADIUS, - ref: def - }); - } - for (const def of area.interactables ?? []) { - this.registry.add({ - id: `int:${def.id}`, - kind: 'interactable', - pos: tileToWorld(def.tile.x, def.tile.y), - ref: def - }); - } - // Пропы карты: footprint-объекты, занимают все свои тайлы. - for (const prop of map.props) { - this.registry.add({ - id: `prop:${prop.x},${prop.y}`, - kind: 'prop', - pos: { x: prop.x + prop.w / 2, y: prop.y + prop.h / 2 }, - footprint: { w: prop.w, h: prop.h }, - ref: prop - }); - } - } - - /** NPC на тайле (клик-роутинг). */ - npcAt(x: number, y: number): NpcDef | null { - return (this.registry.at(x, y, 'npc')?.ref as NpcDef | undefined) ?? null; - } - - /** Все NPC сцены (кандидаты кнопки действия): статичные + размещения по флагам. */ - npcs(): NpcDef[] { - const out: NpcDef[] = []; - for (const obj of this.registry.byKind('npc')) { - const def = obj.ref as NpcDef | undefined; - if (def) out.push(def); - } - return out; - } - - /** - * Дополнительный NPC после создания сцены — размещения по флагам (акт 2): - * попадает и в клик-роутинг, и в кэш занятых тайлов (если тот уже построен). - */ - addNpc(def: NpcDef): void { - this.registry.add({ - id: `npc:${def.id}`, - kind: 'npc', - pos: tileToWorld(def.tile.x, def.tile.y), - radius: NPC_RADIUS, - ref: def - }); - this.npcTiles?.add(def.tile.y * 4096 + def.tile.x); - } - - /** Интерактивный объект на тайле (клик-роутинг). */ - interactableAt(x: number, y: number): InteractableDef | null { - return (this.registry.at(x, y, 'interactable')?.ref as InteractableDef | undefined) ?? null; - } - - /** Ключ тайла (как в реестре; NPC не двигаются — кэш навсегда). */ - private npcTiles: Set | null = null; - - /** - * Grid для построения путей: тайлы NPC считаются занятыми — A* не ведёт - * героя сквозь тело, где его остановит расталкивание. Остальная карта — - * живая (IsometricTileMap учитывает стены и пропы). - */ - walkGrid(): Grid { - if (!this.npcTiles) { - this.npcTiles = new Set(); - for (const npc of this.registry.byKind('npc')) { - this.npcTiles.add(npc.tile.y * 4096 + npc.tile.x); - } - } - const npcTiles = this.npcTiles; - return { - width: this.map.width, - height: this.map.height, - isWalkable: (x, y) => this.map.isWalkable(x, y) && !npcTiles.has(y * 4096 + x) - }; - } -} \ No newline at end of file diff --git a/apps/game/src/systems/Shadows.ts b/apps/game/src/systems/Shadows.ts deleted file mode 100644 index 348b6e5..0000000 --- a/apps/game/src/systems/Shadows.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Тени мира: блобы под актёрами (герой, NPC, враги) и направленные тени - * tall-объектов карты. Положение считает чистая математика sunShadow/ - * blobPlacement по часам суток (сцена передаёт SunShadow каждый тик — - * тени ползут за солнцем плавно). Актёрский блоб — первый ребёнок вьюхи - * (ноги в (0,0)), статические тени — отдельный ground-слой под актёрами. - */ -import { Container, Sprite, Texture, blobPlacement, applyBlobShadow, makeShadowTexture, type Renderer, type SunShadow } from '@rpg/engine'; - -/** Актёрский блоб: вьюха + «толщина» объекта в px виртуального экрана. */ -interface ActorShadow { - view: Container; - radiusPx: number; - heightPx: number; - sprite: Sprite; -} - -/** Статическая тень (tall-тайл): база уже в координатах мира-экрана. */ -interface StaticShadow { - sprite: Sprite; - base: { x: number; y: number }; - radiusPx: number; - heightPx: number; -} - -/** Узкий интерфейс для боевых вьюх — без связности с полной системой. */ -export interface ShadowAttach { - attachActor(view: Container, radiusPx: number, heightPx: number): void; -} - -export class GameShadows implements ShadowAttach { - private readonly tex: Texture; - /** Ground-слой: добавить в world между картой и актёрами. */ - readonly ground = new Container(); - private readonly actorShadows: ActorShadow[] = []; - private readonly statics: StaticShadow[] = []; - - constructor(renderer: Renderer) { - this.tex = makeShadowTexture(renderer); - this.ground.eventMode = 'none'; - } - - /** Блоб под актёром: первым ребёнком — спрайты рисуются поверх тени. */ - attachActor(view: Container, radiusPx: number, heightPx: number): void { - const sprite = new Sprite(this.tex); - sprite.anchor.set(0.5, 0.5); - view.addChildAt(sprite, 0); - this.actorShadows.push({ view, radiusPx, heightPx, sprite }); - } - - /** Направленная тень tall-объекта: база — центр основания (центр ромба). */ - addStatic(base: { x: number; y: number }, radiusPx: number, heightPx: number): void { - const sprite = new Sprite(this.tex); - sprite.anchor.set(0.5, 0.5); - this.ground.addChild(sprite); - this.statics.push({ sprite, base, radiusPx, heightPx }); - } - - /** Пересчёт всех теней по солнцу (null — ночь: погасить). */ - update(sun: SunShadow | null): void { - // Вьюхи погибших врагов уничтожаются CombatViews — блобы вычищаем сами. - for (let i = this.actorShadows.length - 1; i >= 0; i--) { - if (this.actorShadows[i]!.sprite.destroyed) { - this.actorShadows.splice(i, 1); - continue; - } - const rec = this.actorShadows[i]!; - this.place(rec.sprite, { x: 0, y: 0 }, rec, sun); - } - for (const rec of this.statics) this.place(rec.sprite, rec.base, rec, sun); - } - - private place( - sprite: Sprite, - base: { x: number; y: number }, - rec: { radiusPx: number; heightPx: number }, - sun: SunShadow | null - ): void { - if (!sun) { - sprite.alpha = 0; - return; - } - applyBlobShadow(sprite, base, blobPlacement(rec.radiusPx, rec.heightPx, sun)); - } - - destroy(): void { - this.ground.destroy({ children: true }); - this.tex.destroy(); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/StepVariants.ts b/apps/game/src/systems/StepVariants.ts deleted file mode 100644 index 911dc18..0000000 --- a/apps/game/src/systems/StepVariants.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { renderSpec } from '@rpg/engine'; -import type { AudioManager } from '@rpg/engine'; -import { SPEC_SFX, playSfx } from '../data/sfxSpecs'; - -/** - * Вариативные шаги: 3 варианта на поверхность, компилируются в рантайме - * из спеков sfxSpecs.ts (сид варианта = базовый + i·10 — звучат похоже, - * но не идентично). Выбор варианта — по кругу. До unlock буферы не создать - * (createBuffer → null) — играем базовый спек, варианты синтезируются - * заново при следующих шагах, пока не получится (обычно вторая попытка, - * после первого play). - */ - -export type StepSurface = 'grass' | 'ash' | 'water' | 'floor'; - -const VARIANTS = 3; - -export class StepVariants { - /** Буферы по поверхности (синтезируются при первом успешном шаге). */ - private cache = new Map(); - private seq = 0; - - constructor(private audio: AudioManager) {} - - /** Играть очередной вариант шага (до готовности буферов — базовый спек). */ - play(surface: StepSurface, volume: number): void { - const key = `sfx/step_${surface}`; - const list = this.ensure(surface); - if (list.length > 0) { - const i = this.seq++ % list.length; - void this.audio.playBuffer(`${key}#${i}`, list[i]!, { volume }); - } else { - playSfx(this.audio, key, { volume }); - } - } - - /** Синтезировать варианты поверхности (если контекст ещё не готов — пустой список). */ - private ensure(surface: StepSurface): AudioBuffer[] { - const existing = this.cache.get(surface); - if (existing) return existing; - const base = SPEC_SFX[`sfx/step_${surface}`]; - if (!base) return []; - const list: AudioBuffer[] = []; - for (let i = 0; i < VARIANTS; i++) { - const buf = this.audio.createBuffer(renderSpec({ ...base, seed: (base.seed ?? 1) + i * 10 }), 22050); - if (!buf) return []; // контекста нет — попробуем на следующем шаге - list.push(buf); - } - this.cache.set(surface, list); - return list; - } -} \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/AudioSystem.test.ts b/apps/game/src/systems/__tests__/AudioSystem.test.ts deleted file mode 100644 index abc902c..0000000 --- a/apps/game/src/systems/__tests__/AudioSystem.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { EventBus, type AudioManager, type MusicHandle } from '@rpg/engine'; -import { AudioSystem, type AudioSystemDeps } from '../AudioSystem'; - -/** Фейковый AudioManager: записывает вызовы play/playBuffer/playLoop. */ -function fakeAudio() { - const played: { key: string; opts?: { volume?: number; rate?: number; pan?: number } }[] = []; - const loops: { key: string; opts?: { bus?: string; volume?: number; fade?: number } }[] = []; - const audio = { - play: vi.fn(async (key: string, opts?: { volume?: number; rate?: number; pan?: number }) => { - played.push({ key, opts }); - }), - playBuffer: vi.fn(async (key: string, _buf: AudioBuffer, opts?: { volume?: number; rate?: number; pan?: number }) => { - played.push({ key, opts }); - }), - playSpec: vi.fn(async (key: string, _spec: unknown, opts?: { volume?: number; rate?: number; pan?: number }) => { - played.push({ key, opts }); - }), - // По умолчанию контекст «не разблокирован» — буферы не создать. - createBuffer: vi.fn((): AudioBuffer | null => null), - playLoop: vi.fn(async (key: string, opts?: { bus?: string; volume?: number; fade?: number }) => { - loops.push({ key, opts }); - return { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle; - }) - } as unknown as AudioManager; - return { audio, played, loops }; -} - -function makeDeps(over: Partial = {}) { - const calls = fakeAudio(); - const events = new EventBus(); - const deps: AudioSystemDeps = { - audio: calls.audio, - events, - getListener: () => ({ x: 10, y: 10 }), - ...over - }; - return { deps, events, calls }; -} - -describe('AudioSystem.playAt — затухание и панорама', () => { - it('рядом с героем — полная громкость, без pan (герой в центре)', () => { - const { deps, calls } = makeDeps(); - const sys = new AudioSystem(deps); - - sys.playAt('sfx/bell_hit', { x: 10, y: 10 }, 0.8); - - expect(calls.played).toHaveLength(1); - expect(calls.played[0]!.key).toBe('sfx/bell_hit'); - expect(calls.played[0]!.opts!.volume).toBeCloseTo(0.8); - expect(calls.played[0]!.opts!.pan).toBeUndefined(); // |pan| < 0.01 — без узла - }); - - it('за радиусом — тишина (вызова нет)', () => { - const { deps, calls } = makeDeps(); - const sys = new AudioSystem(deps); - - sys.playAt('sfx/spit', { x: 30, y: 30 }, 0.6, 12); - - expect(calls.played).toHaveLength(0); - }); - - it('сбоку от слушателя — панорама со знаком, клэмп ±0.8', () => { - const { deps, calls } = makeDeps(); - const sys = new AudioSystem(deps); - - sys.playAt('sfx/bell_low', { x: 12, y: 10 }, 0.9); - - const pan = calls.played[0]!.opts!.pan!; - expect(pan).toBeGreaterThan(0); - expect(pan).toBeLessThanOrEqual(0.8); - }); - - it('далеко сбоку — панорама клэмпится к 0.8', () => { - const { deps, calls } = makeDeps(); - const sys = new AudioSystem(deps); - - sys.playAt('sfx/bell_low', { x: 18, y: 2 }, 2); // далеко и сбоку, громкий - - expect(calls.played[0]!.opts!.pan).toBe(0.8); - }); -}); - -describe('AudioSystem.playStep — поверхности и варианты', () => { - it('без буферов (контекст не разблокирован) — файловый шаг по поверхности', () => { - const { deps, calls } = makeDeps(); - const sys = new AudioSystem(deps); - - sys.playStep('grass', { x: 10, y: 10 }, 0.35); - - expect(calls.played).toHaveLength(1); - expect(calls.played[0]!.key).toBe('sfx/step_grass'); - expect(calls.played[0]!.opts!.volume).toBeCloseTo(0.35); - }); - - it('буферы готовы — playBuffer вариантов по кругу', () => { - const { deps, calls } = makeDeps(); - vi.mocked(calls.audio.createBuffer).mockImplementation(() => ({ duration: 0.12 }) as AudioBuffer); - const sys = new AudioSystem(deps); - - sys.playStep('ash', { x: 10, y: 10 }, 0.35); - sys.update(0.1); - sys.playStep('ash', { x: 10, y: 10 }, 0.35); - sys.update(0.1); - sys.playStep('ash', { x: 10, y: 10 }, 0.35); - - expect(calls.played.map((p) => p.key)).toEqual(['sfx/step_ash#0', 'sfx/step_ash#1', 'sfx/step_ash#2']); - }); - - it('дедуп: одна поверхность чаще 0.08 с не звучит', () => { - const { deps, calls } = makeDeps(); - const sys = new AudioSystem(deps); - - sys.playStep('grass', { x: 10, y: 10 }, 0.35); - sys.playStep('grass', { x: 10, y: 10 }, 0.35); // слишком рано — глушится - sys.update(0.05); - sys.playStep('grass', { x: 10, y: 10 }, 0.35); // всё ещё рано - sys.update(0.05); - sys.playStep('water', { x: 10, y: 10 }, 0.35); // другая поверхность — ок - - expect(calls.played.map((p) => p.key)).toEqual(['sfx/step_grass', 'sfx/step_water']); - }); - - it('далеко от слушателя — тишина', () => { - const { deps, calls } = makeDeps(); - const sys = new AudioSystem(deps); - - sys.playStep('floor', { x: 20, y: 20 }, 0.35); - - expect(calls.played).toHaveLength(0); - }); -}); - -describe('AudioSystem.attach — звук мира подпиской', () => { - it('combat:attack cone → bell_hit, resonance → bell_low; spit/awake/kill/playerHit', () => { - const { deps, events, calls } = makeDeps(); - const sys = new AudioSystem(deps); - sys.attach(); - - events.emit('combat:attack', { mode: 'cone', origin: { x: 10, y: 10 } }); - events.emit('combat:attack', { mode: 'resonance', origin: { x: 10, y: 10 } }); - events.emit('combat:spit', { pos: { x: 10, y: 10 } }); - events.emit('combat:awake', { kind: 'crawler', pos: { x: 10, y: 10 } }); - events.emit('combat:kill', { kind: 'crawler', tile: { x: 10, y: 10 } }); - events.emit('combat:playerHit', { hp: 5 }); - - expect(calls.played.map((p) => p.key)).toEqual([ - 'sfx/bell_hit', - 'sfx/bell_low', - 'sfx/spit', - 'sfx/ash_hiss', - 'sfx/ash_die', - 'sfx/hurt' - ]); - // playerHit — урон герою, без панорамы - expect(calls.played[5]!.opts!.pan).toBeUndefined(); - }); - - it('off() снимает подписки', () => { - const { deps, events, calls } = makeDeps(); - const sys = new AudioSystem(deps); - const off = sys.attach(); - off(); - - events.emit('combat:spit', { pos: { x: 10, y: 10 } }); - expect(calls.played).toHaveLength(0); - }); -}); - -describe('AudioSystem.setLayers — локальные амбиенты', () => { - it('слой — playLoop в шине ambience; повтор с тем же ключом не перезапускает', () => { - const { deps, calls } = makeDeps(); - const sys = new AudioSystem(deps); - - sys.setLayers([{ key: 'ambience/ponds_water' }]); - sys.setLayers([{ key: 'ambience/ponds_water' }]); // тот же ключ — no-op - - expect(calls.loops).toHaveLength(1); - expect(calls.loops[0]!.key).toBe('ambience/ponds_water'); - expect(calls.loops[0]!.opts!.bus).toBe('ambience'); - }); - - it('два слоя — два независимых лупа', () => { - const { deps, calls } = makeDeps(); - const sys = new AudioSystem(deps); - - sys.setLayers([{ key: 'ambience/tower_hum' }, { key: 'ambience/houses' }]); - - expect(calls.loops).toHaveLength(2); - expect(calls.loops[0]!.key).toBe('ambience/tower_hum'); - expect(calls.loops[1]!.key).toBe('ambience/houses'); - }); - - it('пустой набор останавливает слои; повторный старт — новый луп', () => { - const { deps, calls } = makeDeps(); - const sys = new AudioSystem(deps); - const handles: MusicHandle[] = []; - (deps.audio.playLoop as ReturnType).mockImplementation( - async (key: string, opts?: { bus?: string; volume?: number; fade?: number }) => { - calls.loops.push({ key, opts }); - const h = { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle; - handles.push(h); - return h; - } - ); - - sys.setLayers([{ key: 'ambience/tower_hum' }]); - sys.setLayers([]); - sys.setLayers([{ key: 'ambience/tower_hum' }]); - - expect(handles).toHaveLength(2); // второй старт после снятия - expect(calls.loops).toHaveLength(2); - }); - - it('update доводит громкость слоя к цели', async () => { - const { deps } = makeDeps(); - const sys = new AudioSystem(deps); - let handle: MusicHandle | null = null; - (deps.audio.playLoop as ReturnType).mockImplementation(async () => { - handle = { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle; - return handle; - }); - - sys.setLayers([{ key: 'ambience/tower_hum', volume: 0.5, fade: 1 }]); - await Promise.resolve(); // луп стартовал - sys.update(0.5); - expect(handle!.setVolume).toHaveBeenCalledWith(expect.closeTo(0.25)); - sys.update(0.5); - expect(handle!.setVolume).toHaveBeenCalledWith(0.5); - }); -}); \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/InteractRouting.test.ts b/apps/game/src/systems/__tests__/InteractRouting.test.ts deleted file mode 100644 index a0947e4..0000000 --- a/apps/game/src/systems/__tests__/InteractRouting.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { inInteractRangePos, pickInteract, type InteractCandidate } from '../InteractRouting'; - -describe('inInteractRangePos — истинная евклидова линейка (юнит = тайл)', () => { - // Центр тайла (5,5) = мир (5.5, 5.5): NPC/объект стоят в центре своего тайла. - - it('соседний тайл по оси — в радиусе (дистанция 1.0)', () => { - expect(inInteractRangePos({ x: 6.5, y: 5.5 }, 5, 5)).toBe(true); - }); - - it('диагональный сосед — в радиусе (дистанция 1.414 < 1.5)', () => { - // findPathToNeighbor может привести героя в диагонального соседа цели. - expect(inInteractRangePos({ x: 6.5, y: 6.5 }, 5, 5)).toBe(true); - }); - - it('два тайла — вне радиуса (дистанция 2.0): дальний клик не срабатывает', () => { - // Регресс бага: проекционная линейка давала «радиус» в 2-3 тайла. - expect(inInteractRangePos({ x: 7.5, y: 5.5 }, 5, 5)).toBe(false); - }); - - it('2 тайла вдоль мировой диагонали (проекция была бы «ближе») — вне радиуса', () => { - expect(inInteractRangePos({ x: 6.5, y: 6.5 }, 4, 4)).toBe(false); - }); -}); - -describe('pickInteract — выбор цели кнопки действия', () => { - /** Кандидат-заглушка: тайл задаёт дистанцию (герой в (0,0)). */ - function cand( - kind: InteractCandidate['kind'], - tile: { x: number; y: number }, - label = 'цель' - ): InteractCandidate { - return { kind, tile, dist: Math.hypot(tile.x, tile.y), label }; - } - - it('ближайший кандидат в радиусе — он', () => { - const c = pickInteract([cand('npc', { x: 2, y: 0 }), cand('interact', { x: 1, y: 0 })]); - expect(c?.label).toBe('цель'); - expect(c?.kind).toBe('interact'); - expect(c?.tile).toEqual({ x: 1, y: 0 }); - }); - - it('вне радиуса (два тайла и дальше) — не выбирается', () => { - expect(pickInteract([cand('npc', { x: 2, y: 0 })])).toBeNull(); - expect(pickInteract([])).toBeNull(); - }); - - it('ничья по дистанции — приоритет NPC выше интерактива', () => { - const c = pickInteract([cand('interact', { x: 1, y: 0 }), cand('npc', { x: 0, y: 1 })]); - expect(c?.kind).toBe('npc'); - }); - - it('почти-ничья (в эпсилоне) — приоритет: переход выше цветка', () => { - const c = pickInteract([cand('flower', { x: 1, y: 1 }), cand('transition', { x: 1.04, y: 0 })]); - expect(c?.kind).toBe('transition'); - }); - - it('заметно ближе — дистанция побеждает приоритет', () => { - const c = pickInteract([cand('npc', { x: 1, y: 0 }), cand('flower', { x: 0.5, y: 0 })]); - expect(c?.kind).toBe('flower'); - }); - - it('заперто — кандидат locked с текстом', () => { - const c: InteractCandidate = { - kind: 'locked', - tile: { x: 1, y: 0 }, - dist: 1, - label: 'дверь', - text: 'Заперто.' - }; - expect(pickInteract([c])?.text).toBe('Заперто.'); - }); -}); \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/Interactables.test.ts b/apps/game/src/systems/__tests__/Interactables.test.ts deleted file mode 100644 index b043c56..0000000 --- a/apps/game/src/systems/__tests__/Interactables.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Interactables, type InteractSink } from '../Interactables'; -import { fakeState } from '../../testing/gameState'; -import type { InteractableDef } from '../../data/interactables'; - -const def = (over: Partial = {}): InteractableDef => ({ - id: 'chest', - pos: { x: 3.5, y: 3.5 }, - tile: { x: 3, y: 3 }, - kind: 'container', - once: true, - responses: [{ text: 'в сундуке', gives: 'cloth' }], - ...over -}); - -function make(defs: InteractableDef[], sinkOver: Partial = {}) { - const calls: { toast: string[]; gave: [string, number][]; sound: string[] } = { - toast: [], - gave: [], - sound: [] - }; - const sink: InteractSink = { - hasItem: () => false, - give: (id: string, n: number) => calls.gave.push([id, n]), - playSound: (k: string) => calls.sound.push(k), - showToast: (t: string) => calls.toast.push(t), - ...sinkOver - }; - const st = fakeState(); - return { world: new Interactables(defs, st, sink), calls, st }; -} - -describe('Interactables', () => { - it('defAt — поиск по тайлу', () => { - const { world } = make([def()]); - expect(world.defAt(3, 3)?.id).toBe('chest'); - expect(world.defAt(0, 0)).toBeNull(); - }); - - it('once: реакция + used:; повтор — пусто', () => { - const { world, calls } = make([def()]); - expect(world.isUsed('chest')).toBe(false); - const pick = world.tryInteract(world.defAt(3, 3)!); - expect(pick?.ok).toBe(true); - expect(calls.toast).toEqual(['в сундуке']); - expect(calls.gave).toEqual([['cloth', 1]]); - expect(world.isUsed('chest')).toBe(true); - // Повтор: реакции нет, тост не дублируется. - expect(world.tryInteract(world.defAt(3, 3)!)).toBeNull(); - expect(calls.toast).toHaveLength(1); - }); - - it('не-once объект реагирует повторно', () => { - const { world, calls } = make([ - def({ id: 'hearth', once: false, responses: [{ text: 'тепло' }] }) - ]); - world.tryInteract(world.defAt(3, 3)!); - world.tryInteract(world.defAt(3, 3)!); - expect(calls.toast).toEqual(['тепло', 'тепло']); - }); - - it('эффекты: флаги/вар/count/звук', () => { - const { world, calls, st } = make([ - def({ - once: false, - responses: [ - { - text: 'рычаг', - setFlags: ['a'], - clearFlags: ['b'], - setVar: { id: 'motes', value: 7 }, - gives: 'salt', - count: 2, - sound: 'sfx/bell_hit' - } - ] - }) - ]); - st.setFlag('b'); - world.tryInteract(world.defAt(3, 3)!); - expect(st.hasFlag('a')).toBe(true); - expect(st.hasFlag('b')).toBe(false); - expect(st.getNumber('motes')).toBe(7); - expect(calls.gave).toEqual([['salt', 2]]); - expect(calls.sound).toEqual(['sfx/bell_hit']); - }); - - it('addVar: подбор мотов копит вар, а не затирает', () => { - const { world, st, calls } = make([ - def({ - id: 'mote', - kind: 'pickup', - once: true, - responses: [{ text: 'мот', gives: 'mote', addVar: { id: 'motes', by: 1 } }] - }) - ]); - world.tryInteract(world.defAt(3, 3)!); - expect(st.getNumber('motes')).toBe(1); - // Второй такой же объект (другой тайл): вар растёт от прежнего значения. - const { world: w2, st: st2 } = make([ - def({ - id: 'mote_2', - kind: 'pickup', - once: true, - responses: [{ text: 'мот', gives: 'mote', addVar: { id: 'motes', by: 1 } }] - }) - ]); - st2.setVar('motes', 2); - w2.tryInteract(w2.defAt(3, 3)!); - expect(st2.getNumber('motes')).toBe(3); - expect(calls.toast).toEqual(['мот']); - }); - - it('toast с varKey: тост прогресса вместо text, после addVar', () => { - const { world, calls, st } = make([ - def({ - id: 'tone_tree_1', - kind: 'examine', - once: false, - responses: [ - { - text: 'Дерево отдаёт свой тон.', - toast: { text: 'Тон', varKey: 'tones', goal: 3 }, - addVar: { id: 'tones', by: 1 } - } - ] - }) - ]); - world.tryInteract(world.defAt(3, 3)!); - expect(st.getNumber('tones')).toBe(1); - expect(calls.toast).toEqual(['Тон (1/3)']); - }); - - it('when от GameState: реакция меняется с флагом', () => { - const { world, calls, st } = make([ - def({ - once: false, - responses: [ - { when: { flag: 'quest_bells_done' }, text: 'после' }, - { text: 'до' } - ] - }) - ]); - world.tryInteract(world.defAt(3, 3)!); - expect(calls.toast[0]).toBe('до'); - st.setFlag('quest_bells_done'); - world.tryInteract(world.defAt(3, 3)!); - expect(calls.toast[1]).toBe('после'); - }); -}); \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/clock.test.ts b/apps/game/src/systems/__tests__/clock.test.ts deleted file mode 100644 index 92a53f4..0000000 --- a/apps/game/src/systems/__tests__/clock.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { DAY_MINUTES, GameClock, START_MINUTES } from '../Clock'; - -describe('GameClock', () => { - it('старт: 8:00, night-вход hours = 8', () => { - const c = new GameClock(); - expect(c.minutes).toBe(480); - expect(c.hours).toBe(8); - expect(c.label).toBe('8:00'); - }); - - it('advance: dtSec секунды = столько же игровых минут', () => { - const c = new GameClock(); - c.advance(60); // 1 реальная минута = 1 игровой час - expect(c.minutes).toBe(540); - expect(c.label).toBe('9:00'); - }); - - it('advance: переход через полночь', () => { - const c = new GameClock(); - c.set(23 * 60 + 50); - c.advance(20); - expect(c.minutes).toBe(10); - expect(c.label).toBe('0:10'); - }); - - it('label: минуты с ведущим нулём, часы без', () => { - const c = new GameClock(); - c.set(13 * 60 + 5); - expect(c.label).toBe('13:05'); - c.set(45); - expect(c.label).toBe('0:45'); - }); - - it('set: mod 1440, отрицательные в диапазон', () => { - const c = new GameClock(); - c.set(1500); - expect(c.minutes).toBe(60); - c.set(-60); - expect(c.minutes).toBe(23 * 60); - }); - - it('reset возвращает к 8:00', () => { - const c = new GameClock(); - c.set(0); - c.reset(); - expect(c.minutes).toBe(START_MINUTES); - expect(START_MINUTES).toBe(480); - expect(DAY_MINUTES).toBe(1440); - }); - - it('hours — float для кривой света', () => { - const c = new GameClock(); - c.set(19 * 60 + 30); - expect(c.hours).toBeCloseTo(19.5, 10); - }); -}); \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/lighting.test.ts b/apps/game/src/systems/__tests__/lighting.test.ts deleted file mode 100644 index dba6e46..0000000 --- a/apps/game/src/systems/__tests__/lighting.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Camera, DUSK_PEAK, Lighting, tileToWorld } from '@rpg/engine'; -import { GameLighting, type HeroLampDef } from '../Lighting'; -import type { AreaDef } from '../../data/locations'; - -const LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.5, intensity: 0.5, flicker: 0.12 }; - -function fakeArea(sources?: AreaDef['lighting'], hazards?: AreaDef['hazards']): AreaDef { - return { - id: 'meadows', // AreaId — юнион; значение не важно, свет приходит из fakeArea-аргумента - name: 'Тест', - spawn: { x: 1, y: 1 }, - npcs: [], - enemies: [], - transitions: [], - atmosphere: 'none', - lighting: sources, - hazards - }; -} - -function makeGameLighting( - area: AreaDef, - opts?: { flags?: Set; hero?: { x: number; y: number }; lamp?: HeroLampDef | null; time?: number } -) { - const engineLighting = new Lighting({ width: 480, height: 270 }); - const camera = new Camera(480, 270); - camera.snap(10, 10); - const flags = opts?.flags ?? new Set(); - let hero = opts?.hero ?? { x: 5, y: 5 }; - const clock = { hours: opts?.time ?? 12 }; - const gl = new GameLighting({ - area, - lighting: engineLighting, - layerTilePos: new Map([[11, [{ x: 2, y: 3 }, { x: 8, y: 3 }]]]), // TILES.HOUSE = 11 - camera, - heroPos: () => hero, - hasFlag: (f) => flags.has(f), - timeHours: () => clock.hours, - lamp: opts?.lamp === undefined ? null : opts.lamp - }); - return { gl, engineLighting, camera, setHero: (p: { x: number; y: number }) => (hero = p), setTime: (h: number) => (clock.hours = h) }; -} - -describe('GameLighting: статика области', () => { - it('ambient из данных применяется к движковому слою', () => { - const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0x54586a })); - expect(engineLighting.ambientColor).toBe(0x54586a); - gl.destroy(); - }); - - it('деф с at — один источник в центре ромба', () => { - const { gl, engineLighting, camera } = makeGameLighting( - fakeArea({ sources: [{ id: 'hearth', at: { x: 8, y: 0 }, intensity: 1, flicker: 0 }] }) - ); - gl.update(); - const frames = engineLighting.frames(); - expect(frames.length).toBe(1); - expect(frames[0].id).toBe('hearth'); - // Позиция — ровно проекция центра ромба тайла (8.5, 0.5) - const w = tileToWorld(8.5, 0.5); - const s = camera.toScreen(w.x, w.y); - expect(frames[0].x).toBeCloseTo(s.x, 3); - expect(frames[0].y).toBeCloseTo(s.y, 3); - gl.destroy(); - }); - - it('деф с tiles — источник на каждый тайл id', () => { - const { gl, engineLighting } = makeGameLighting( - fakeArea({ sources: [{ id: 'window', tiles: [11], intensity: 0.4, flicker: 0 }] }) - ); - gl.update(); - const ids = engineLighting.frames().map((f) => f.id).sort(); - expect(ids).toEqual(['window@2,3', 'window@8,3']); - gl.destroy(); - }); - - it('whenFlag/notFlag управляют активностью', () => { - const { gl, engineLighting } = makeGameLighting( - fakeArea({ - sources: [ - { id: 'off', at: { x: 1, y: 1 }, whenFlag: 'quest_bells_done', flicker: 0 }, - { id: 'on', at: { x: 1, y: 1 }, notFlag: 'quest_bells_done', flicker: 0 } - ] - }) - ); - gl.update(); - expect(engineLighting.frames().map((f) => f.id)).toEqual(['on']); - gl.destroy(); - }); - - it('погасшие источники снимаются с движкового слоя (флаг-условие)', () => { - const flags = new Set(); - const { gl, engineLighting } = makeGameLighting( - fakeArea({ sources: [{ id: 'a', at: { x: 1, y: 1 }, flicker: 0, notFlag: 'lit' }] }), - { flags } - ); - gl.update(); - expect(engineLighting.hasLight('a')).toBe(true); - flags.add('lit'); - gl.update(); - expect(engineLighting.hasLight('a')).toBe(false); - gl.destroy(); - }); -}); - -describe('GameLighting: лампа героя', () => { - it('следует за героем, id стабилен (ночь на светлой улице)', () => { - const { gl, engineLighting, setHero } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { - time: 21, - lamp: LAMP - }); - gl.update(); - const first = engineLighting.frames()[0]; - setHero({ x: 7.5, y: 9.5 }); - gl.update(); - const second = engineLighting.frames()[0]; - expect(first.id).toBe('lamp'); - expect(second.id).toBe('lamp'); - expect(second.x).not.toBe(first.x); // лампа переехала вместе с героем - gl.destroy(); - }); - - it('радиус в юнитах переводится в px', () => { - const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0x54586a }), { lamp: LAMP }); - gl.update(); - // unitsToPx(2.5) = 2.5 * 32 = 80; scale = 80 / GLOW_BASE_PX(32) = 2.5 - expect(engineLighting.frames()[0].scale).toBeCloseTo(2.5, 6); - gl.destroy(); - }); - - it('на светлой улице днём лампы нет, ночью появляется', () => { - const { gl, engineLighting, setTime } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { - time: 12, - lamp: LAMP - }); - gl.update(); - expect(engineLighting.hasLight('lamp')).toBe(false); - setTime(21); - gl.update(); - expect(engineLighting.hasLight('lamp')).toBe(true); - gl.destroy(); - }); -}); - -describe('GameLighting: день/ночь по времени', () => { - it('цель ambient — микс день/ночь по кривой суток', () => { - const { gl, engineLighting, setTime } = makeGameLighting( - fakeArea({ ambient: 0xd8dade, nightAmbient: 0x6a7288 }), - { time: 12 } - ); - gl.update(); - expect(engineLighting.ambientColor).toBe(0xd8dade); // день - setTime(19); // k = 0.5 — середина заката - gl.update(); - expect(engineLighting.ambientColor).not.toBe(0xd8dade); // микс, не день - expect(engineLighting.ambientColor).not.toBe(0x6a7288); // и не ночь - setTime(21); - gl.update(); - expect(engineLighting.ambientColor).toBe(0x6a7288); // ночь - setTime(10); - gl.update(); - expect(engineLighting.ambientColor).toBe(0xd8dade); // и обратно к дню - gl.destroy(); - }); - - it('без nightAmbient в данных время не трогает ambient', () => { - const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { time: 21 }); - gl.update(); - expect(engineLighting.ambientColor).toBe(0xd8dade); - gl.destroy(); - }); - - it('золотой час: на пике заката ambient — закатный тон (свой в данных)', () => { - const { gl, engineLighting, setTime } = makeGameLighting( - fakeArea({ ambient: 0xf7f4ea, nightAmbient: 0x3e4658, duskColor: 0xe8a060 }), - { time: 18 + 2 * DUSK_PEAK } // k = DUSK_PEAK - ); - gl.update(); - expect(engineLighting.ambientColor).toBe(0xe8a060); - setTime(12); // день — обратно из заката - gl.update(); - expect(engineLighting.ambientColor).toBe(0xf7f4ea); - gl.destroy(); - }); - - it('конструктор сразу применяет ночной микс (без мелькания дня)', () => { - const { gl, engineLighting } = makeGameLighting( - fakeArea({ ambient: 0xd8dade, nightAmbient: 0x6a7288 }), - { time: 23 } - ); - expect(engineLighting.ambientColor).toBe(0x6a7288); - gl.destroy(); - }); - - it('whenNight: ночные источники горят ночью, гаснут днём', () => { - const { gl, engineLighting, setTime } = makeGameLighting( - fakeArea({ sources: [{ id: 'win', at: { x: 1, y: 1 }, flicker: 0, whenNight: true }] }), - { time: 21 } - ); - gl.update(); - expect(engineLighting.hasLight('win')).toBe(true); - setTime(12); - gl.update(); - expect(engineLighting.hasLight('win')).toBe(false); - gl.destroy(); - }); -}); - -describe('GameLighting: тёмные ауры хазардов', () => { - it('пятно на каждый тайл зоны, снятие при исчезновении зоны', () => { - const hazards = [{ name: 'накат', tiles: [{ x: 3, y: 4 }, { x: 4, y: 4 }], requiresItem: 'cloth' as const }]; - const { gl, engineLighting } = makeGameLighting(fakeArea(undefined, hazards)); - gl.update(); - expect(engineLighting.hasDarkSpot('hazard@3,4')).toBe(true); - expect(engineLighting.hasDarkSpot('hazard@4,4')).toBe(true); - gl.destroy(); - // Пятна сняты destroy - expect(engineLighting.hasDarkSpot('hazard@3,4')).toBe(false); - }); -}); - -describe('GameLighting: runtime API и снапшот', () => { - it('setAmbient пробрасывается в движковый слой с лерпом', () => { - const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xffffff })); - gl.setAmbient(0x000000, 1); - engineLighting.update(0.5); - expect(engineLighting.ambientColor).toBe(0x000000); - expect(engineLighting.children[0].tint).not.toBe(0xffffff); - expect(engineLighting.children[0].tint).not.toBe(0x000000); - gl.destroy(); - }); - - it('snapshot: ambient + виньетка + источники с пост-мерцанием', () => { - const { gl } = makeGameLighting( - fakeArea({ ambient: 0x334455, sources: [{ id: 'h', at: { x: 1, y: 1 }, intensity: 0.8, flicker: 0 }] }) - ); - gl.update(); - gl.setVignette(0.35); - const s = gl.snapshot(); - expect(s.ambient).toBe(0x334455); - expect(s.vignette).toBe(0.35); - expect(s.sources.length).toBe(1); - expect(s.sources[0].id).toBe('h'); - expect(s.sources[0].intensity).toBeCloseTo(0.8, 3); - expect(Number.isFinite(s.sources[0].x)).toBe(true); - gl.destroy(); - }); - - it('destroy снимает все источники', () => { - const { gl, engineLighting } = makeGameLighting(fakeArea(), { lamp: LAMP }); - gl.update(); - gl.destroy(); - expect(engineLighting.frames().length).toBe(0); - }); -}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/CombatFlow.ts b/apps/game/src/systems/combat/CombatFlow.ts deleted file mode 100644 index 6ba9780..0000000 --- a/apps/game/src/systems/combat/CombatFlow.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { - Graphics, - worldNorm, - worldToScreen, - unitsToPx, - conePath, - type Camera, - type Entity, - type FxLayer, - type PostFx, - type Vec2 -} from '@rpg/engine'; -import type { Game } from '../../Game'; -import type { VarId } from '../../data/ids'; -import { VARS } from '../../data/ids'; -import { GameLighting } from '../Lighting'; -import type { PlayerController } from '../PlayerController'; -import { CombatWorld } from './CombatWorld'; -import { CombatViews } from './CombatViews'; -import { HealthBar } from './HealthBar'; -import { PlayerCombat } from './PlayerCombat'; -import { PLAYER_COMBAT } from './stats'; - -/** Боевые счётчики GameState (инкрементируются здесь). */ -const BUMP: Record<'kills' | 'motes' | 'deaths' | 'resonances', VarId> = { - kills: VARS.kills, - motes: VARS.motes, - deaths: VARS.deaths, - resonances: VARS.resonances -}; - -/** - * Боевая обвязка героя поверх ECS-мира CombatWorld: ввод атаки (тап — короткий - * удар, удержание — резонанс), реакции на урон (вспышка, шейк, отброс, - * смерть-респаун), hp-бар, индикатор заряда, уровень угрозы для музыки. - * Сцена только собирает систему, тикает и передаёт ей ввод. - */ -export interface CombatFlowDeps { - game: Game; - combat: CombatWorld; - combatViews: CombatViews; - player: PlayerController; - lighting: GameLighting; - camera: Camera; - /** Пост-эффекты: блюр-волна резонанса, аберрация урона. */ - postfx: PostFx; - /** Слой мировых эффектов (флэш урона на герое). */ - fx: FxLayer; - /** Стартовый тайл локации (респаун после смерти). */ - spawn: Vec2; - /** Зона пепельного наката (резонанс вспыхивает пепел). */ - inHazard(): boolean; - /** Респаун/телепорт: камера мгновенно на героя. */ - snapCamera(): void; -} - -export class CombatFlow { - /** Боевое состояние героя (читают агентный мост и мигание неуязвимости). */ - readonly playerCombat: PlayerCombat; - /** Полоска сердец (сцена добавляет в uiRoot). */ - readonly healthBar: HealthBar; - private ring: Graphics; - /** Кольцо сейчас нарисовано: clear/перерисовка только на переходах. */ - private ringVisible = false; - /** Сектор замаха (удержание атаки): полигон по conePath, двигается без перерисовки. */ - private swing: Graphics; - /** Ключ нарисованного сектора (направление + заряжен) — null сектор скрыт. */ - private swingKey: string | null = null; - private offs: (() => void)[] = []; - - constructor(private deps: CombatFlowDeps, savedHp: number) { - this.playerCombat = new PlayerCombat(savedHp); - this.healthBar = new HealthBar(); - this.healthBar.setHp(this.playerCombat.hp); - this.ring = new Graphics(); - deps.game.renderer.worldRoot.addChild(this.ring); - this.swing = new Graphics(); - deps.game.renderer.worldRoot.addChild(this.swing); - // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира. - this.offs.push( - deps.game.engine.events.on<{ entity: Entity }>('combat:hurt', ({ entity }) => { - const en = deps.combat.enemies.get(entity); - if (!en) return; - deps.combatViews.flashEnemy(entity); - const s = worldToScreen(en.pos.x, en.pos.y); - if (en.brain.dead) deps.combatViews.deathBurst(s); - else deps.combatViews.hitBurst(s); - // Световой импульс в точке попадания (lightRoot — экранное пространство). - const ls = deps.camera.toScreen(en.pos.x, en.pos.y); - deps.lighting.pulseLight({ - x: ls.x, - y: ls.y, - color: 0xf2b45a, - intensity: 0.45, - radius: unitsToPx(1.5) * deps.camera.zoomLevel, - spec: { attack: 0.02, decay: 0.2 } - }); - }) - ); - } - - /** Нажатие атаки: начало зарядки резонанса. */ - onAttackPressed(): void { - this.playerCombat.startCharge(); - } - - /** Отпускание атаки: резонанс (долгое) или короткий удар. */ - onAttackReleased(): void { - if (!this.playerCombat.isCharging()) return; - const action = this.playerCombat.release(); - const from = this.deps.player.position; - if (action === 'resonance') { - this.doResonance(from); - } else if (action === 'attack') { - this.deps.combat.playerConeAttack(from, this.deps.player.dirVector); - this.swingFlash(from, this.deps.player.dirVector); - } - } - - /** Уровень угрозы 0..1 по состояниям врагов (музыка: бой/настороженность). */ - threat(): number { - let threat = 0; - for (const en of this.deps.combat.enemies.values()) { - const st = en.brain.state; - if (st === 'chase' || st === 'windup' || st === 'attack' || st === 'hurt') threat = 1; - else if (st === 'wary') threat = Math.max(threat, 0.4); - } - return threat; - } - - /** Тик: таймеры боя, hp-бар, кольцо заряда под героем. */ - update(dt: number): void { - this.playerCombat.update(dt); - this.updateRing(); - this.updateSwing(); - this.healthBar.setHp(this.playerCombat.hp); - } - - /** Урон герою: неуязвимость внутри PlayerCombat; здесь отброс, шейк, звук, смерть. */ - onPlayerDamaged(damage: number, from: Vec2): void { - const d = this.deps; - const applied = this.playerCombat.takeDamage(damage); - if (applied <= 0) return; - d.game.engine.events.emit('combat:playerHit', { hp: this.playerCombat.hp }); - d.game.camera.addShake(2.5, 0.3); - // Красная вспышка на весь экран — экранная реакция на боль. - d.lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18, spec: { attack: 0.02, decay: 0.35 } }); - // Хроматическая аберрация: экран «ведёт» от боли (импульс с огибающей). - d.postfx.pulseAberration(3, { attack: 0.02, hold: 0.06, decay: 0.35 }); - // Симметричный врагам флэш урона на герое. - d.fx.flash(d.player.sprite, 0xd05a5a, 0.18); - // Отброс от источника урона (направление — по метрике проекции) - const dx = d.player.position.x - from.x; - const dy = d.player.position.y - from.y; - d.player.applyKnockback(worldNorm(dx, dy), PLAYER_COMBAT.knockback); - - if (this.playerCombat.dead) { - this.bump('deaths', 1); - this.playerCombat.revive(); - d.player.teleportTo(d.spawn); - d.snapCamera(); - } - } - - exit(): void { - for (const off of this.offs) off(); - this.offs = []; - this.ring.destroy(); // в worldRoot — не вычищается с this.world - this.swing.destroy(); - } - - /** Резонанс: волна сна, кольцо, звон света, реакция наката. */ - private doResonance(from: Vec2): void { - const d = this.deps; - const slept = d.combat.resonancePulse(from); - const s = worldToScreen(from.x, from.y); - d.combatViews.resonanceRing(s); - d.game.camera.addShake(1.5, 0.25); - // Звон вспыхивает: тёплый импульс у героя и лёгкий подъём ambient. - const ls = d.camera.toScreen(from.x, from.y); - d.lighting.pulseLight({ - x: ls.x, - y: ls.y, - color: 0xd99a32, - intensity: 0.6, - radius: unitsToPx(2.5) * d.camera.zoomLevel, - spec: { attack: 0.05, decay: 0.45 } - }); - d.lighting.pulseAmbient({ color: 0xd99a32, peak: 0.12, spec: { attack: 0.05, decay: 0.5 } }); - // Блюр-волна вокруг героя: воздух плавится от резонанса (точка + радиус). - d.postfx.pulseBlur( - { strength: 5, center: { x: ls.x, y: ls.y }, radius: unitsToPx(3) * d.camera.zoomLevel }, - { attack: 0.06, hold: 0.08, decay: 0.5 } - ); - this.bump('resonances', slept > 0 ? 1 : 0); - // Звон как «проверка воздуха»: в накате волна на миг подсвечивает пепел. - if (d.inHazard()) { - d.combatViews.hitBurst({ x: s.x - 14, y: s.y + 6 }); - d.combatViews.hitBurst({ x: s.x + 14, y: s.y + 6 }); - void d.game.audio.play('sfx/ash_hiss', 0.7); - } - } - - /** Индикатор заряда резонанса под героем (Graphics трогаем только на переходах). */ - private updateRing(): void { - const charging = this.playerCombat.isCharging(); - if (!charging) { - if (this.ringVisible) { - this.ringVisible = false; - this.ring.clear(); - } - return; - } - const u = this.deps.player.position; - const p = worldToScreen(u.x, u.y); - const k = Math.min(1, this.playerCombat.chargeTime / PLAYER_COMBAT.resonanceCharge); - const color = k >= 1 ? 0xd99a32 : 0x888899; - if (!this.ringVisible) { - this.ringVisible = true; - this.ring - .circle(p.x, p.y - 2, 6 + 4 * k) - .stroke({ color, width: 1, alpha: 0.9 }); - } - } - - /** - * Замах при удержании атаки: полупрозрачный сектор конуса по направлению - * взгляда (заряжен резонанс — круг волны). Полигон перерисовывается только - * на смене направления/состояния заряда; позиция следует за героем каждый - * тик (сдвиг вьюхи, без перестройки полигона). - */ - private updateSwing(): void { - const charging = this.playerCombat.isCharging(); - if (!charging) { - if (this.swingKey) { - this.swingKey = null; - this.swing.clear(); - } - return; - } - const ready = this.playerCombat.chargeTime >= PLAYER_COMBAT.resonanceCharge; - const dir = this.deps.player.dirVector; - const key = `${dir.x},${dir.y}|${ready ? 'r' : 'c'}`; - if (key !== this.swingKey) { - this.swingKey = key; - this.swing.clear(); - const d = worldToScreen(dir.x, dir.y); // угол сектора — в экранных осях (как inConeW) - if (ready) { - this.swing - .circle(0, 0, unitsToPx(PLAYER_COMBAT.resonanceRange) - 2) - .fill({ color: 0xd99a32, alpha: 0.12 }); - } else { - const pts = conePath({ x: 0, y: 0 }, d, unitsToPx(PLAYER_COMBAT.coneRange), PLAYER_COMBAT.coneHalfAngle); - this.swing.poly(pts.flatMap((p) => [p.x, p.y])).fill({ color: 0xf2b45a, alpha: 0.15 }); - } - } - const p = worldToScreen(this.deps.player.position.x, this.deps.player.position.y); - this.swing.position.set(p.x, p.y); - } - - /** След удара: яркая вспышка-сектор по конусу, тает за ~0.18 с (FxLayer.destroy). */ - private swingFlash(from: Vec2, dir: Vec2): void { - const p = worldToScreen(from.x, from.y); - const d = worldToScreen(dir.x, dir.y); - const pts = conePath({ x: 0, y: 0 }, d, unitsToPx(PLAYER_COMBAT.coneRange), PLAYER_COMBAT.coneHalfAngle); - const flash = new Graphics(); - flash.poly(pts.flatMap((q) => [q.x, q.y])).fill({ color: 0xf2cd68, alpha: 0.4 }); - flash.position.set(p.x, p.y); - this.deps.game.renderer.worldRoot.addChild(flash); - this.deps.fx.fade(flash, { duration: 0.18 }); - } - - /** Инкремент боевого счётчика в vars GameState. */ - private bump(name: keyof typeof BUMP, by: number): void { - const key = BUMP[name]; - this.deps.game.state.setVar(key, this.deps.game.state.getNumber(key) + by); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/combat/CombatViews.ts b/apps/game/src/systems/combat/CombatViews.ts deleted file mode 100644 index 570b7d5..0000000 --- a/apps/game/src/systems/combat/CombatViews.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { - Container, - IsoDepthLayer, - Sprite, - SpriteAnimator, - Texture, - worldToTile, - worldToScreen, - type AssetLoader, - type Entity, - type FxLayer, - type Vec2 -} from '@rpg/engine'; -import type { CombatWorld } from './CombatWorld'; -import type { EnemyKindId } from '../../data/enemies'; -import type { ShadowAttach } from '../Shadows'; - -/** - * Пиксельные вьюхи боя: спрайты сгустков, снаряды, вспышки урона, растворение - * трупов, частицы ударов. Данные живут в CombatWorld (ECS) — здесь только отображение. - */ - -interface EnemyView { - root: Container; - sprite: Sprite; - animator: SpriteAnimator; - /** Растворение запущено (труп тает через FxFade, не вручную). */ - dissolving?: boolean; -} - -export class CombatViews { - private views = new Map(); - private projectileViews = new Map(); - - constructor( - private combat: CombatWorld, - private actors: IsoDepthLayer, - private enemyTextures: Map, - private fxRoot: Container, - /** Слой эффектов (FxLayer сцены): тик и зачистка — сами. */ - private fx: FxLayer, - /** Ассеты: спрайт снаряда плевка (tiles/spit). */ - private assets: AssetLoader, - /** Тени: блоб-тень под каждым сгустком (attach на спавне). */ - private shadows: ShadowAttach - ) {} - - /** Спавн-вспышка пепла (взрыв частиц). */ - hitBurst(pos: Vec2): void { - this.fx.burst(10, { x: pos.x, y: pos.y - 6 }, { - color: 0x52525c, - rate: 0, - lifetime: [0.2, 0.45], - radialSpeed: [30, 70], - size: 2, - drag: 2.5, - scaleOverLife: 'shrink', - seed: (pos.x * 31 + pos.y) | 0 - }, this.fxRoot); - } - - /** Смерть: заметный выброс пепла. */ - deathBurst(pos: Vec2): void { - this.fx.burst(16, { x: pos.x, y: pos.y - 8 }, { - colors: [0x6a6a74, 0x8a8a96], - rate: 0, - lifetime: [0.3, 0.6], - radialSpeed: [40, 90], - size: 2, - drag: 2, - spin: [-4, 4], - scaleOverLife: 'shrink', - seed: (pos.x * 17 + pos.y) | 0 - }, this.fxRoot); - } - - /** Кольцо резонанса — расходящаяся окружность (пресет, тикается слоем). */ - resonanceRing(pos: Vec2): void { - this.fx.ring( - { color: 0xd99a32, from: 8, to: 96, duration: 0.4, width: 1 }, - { x: pos.x, y: pos.y - 4 }, - { parent: this.fxRoot } - ); - } - - /** Синхронизация вьюх с ECS-миром. Вызывать каждый тик после combat.update. */ - sync(dt: number): void { - const map = this.combat.deps.map; - const tileOf = (p: Vec2): { x: number; y: number } => - worldToTile(p.x, p.y, map.data.width, map.data.height) ?? { x: 0, y: 0 }; - - // Спавн новых - for (const [e, en] of this.combat.enemies) { - if (!this.views.has(e)) { - const frames = this.enemyTextures.get(en.kind.id)!; - const root = new Container(); - const sprite = new Sprite(frames[0]); - sprite.anchor.set(0.5, 1); - root.addChild(sprite); - this.shadows.attachActor(root, 8, 10); - const sp = worldToScreen(en.pos.x, en.pos.y); - root.position.set(sp.x, sp.y); - const t = tileOf(en.pos); - this.actors.add(root, t.x, t.y); - this.views.set(e, { - root, - sprite, - animator: new SpriteAnimator(sprite, { walk: { frames, fps: 4 } }, 'walk') - }); - } - } - - // Обновление/смерть - for (const [e, en] of this.combat.enemies) { - const view = this.views.get(e)!; - const sp = worldToScreen(en.pos.x, en.pos.y); - view.root.position.set(sp.x, sp.y); - const t = tileOf(en.pos); - this.actors.setDepth(view.root, t.x, t.y); - - if (en.brain.dead) { - // Растворение трупа: один fade на переход alive→dead (keep — - // вьюху уничтожает сцена по !isAlive, без двойного destroy). - if (!view.dissolving) { - view.dissolving = true; - this.fx.fade(view.root, { duration: 0.5, keep: true }); - } - view.sprite.tint = 0x555560; - continue; - } - - // Мигание неактивных: спящий сгусток почти невидим в пепле - view.root.alpha = en.brain.asleep ? 0.45 : 1; - - // Покадровая анимация (4 Гц) — только когда активен; спящим pause. - if (en.brain.asleep) view.animator.pause(); - else { - view.animator.resume(); - view.animator.play('walk'); - } - view.animator.update(dt); - } - - // Удаление уничтоженных сущностей - for (const [e, view] of this.views) { - if (!this.combat.world.isAlive(e)) { - this.actors.removeChild(view.root); - view.root.destroy({ children: true }); - this.views.delete(e); - } - } - - // Снаряды: спрайт пепельного плевка (tiles/spit) - for (const [e, pr] of this.combat.projectiles) { - let pv = this.projectileViews.get(e); - if (!pv) { - pv = new Sprite(this.assets.texture('tiles/spit')); - pv.anchor.set(0.5); - this.fxRoot.addChild(pv); - this.projectileViews.set(e, pv); - } - const sp = worldToScreen(pr.pos.x, pr.pos.y); - pv.position.set(sp.x, sp.y); - } - for (const [e, pv] of this.projectileViews) { - if (!this.combat.world.isAlive(e)) { - pv.destroy(); - this.projectileViews.delete(e); - } - } - } - - /** Вспышка конкретного врага (вызывает CombatWorld при попадании). */ - flashEnemy(e: Entity): void { - const view = this.views.get(e); - if (view) this.fx.flash(view.sprite, 0xf2b45a, 0.15); - } - - exit(): void { - for (const [, view] of this.views) { - this.actors.removeChild(view.root); - view.root.destroy({ children: true }); - } - this.views.clear(); - for (const [, pv] of this.projectileViews) pv.destroy(); - this.projectileViews.clear(); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/combat/CombatWorld.ts b/apps/game/src/systems/combat/CombatWorld.ts deleted file mode 100644 index 9bd9362..0000000 --- a/apps/game/src/systems/combat/CombatWorld.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { - EventBus, - IsometricTileMap, - World, - worldToTile, - worldDist, - worldNorm, - inCircleW, - inConeW, - tileToWorld, - moveCircle, - circleFits, - separateCircles, - type Entity, - type SceneRegistry, - type System, - type Vec2, - type Invariant -} from '@rpg/engine'; -import { - ENEMY_KINDS, - withDefaults, - type EnemyKindDef, - type EnemyKindId, - type EnemyPatrolDef -} from '../../data/enemies'; -import { EnemyBrain, ENEMY_STATES, NOISE_HEAR_LEVEL, NOISE_WAKE_LEVEL, type EnemyIntent } from './EnemyBrain'; -import { hasLineOfSight, tileOpaque } from './los'; -import { PLAYER_COMBAT } from './stats'; - -/** - * Боевой мир: сущности (враги, снаряды) в движковом ECS, поведение — в системах. - * Пиксельные вьюхи держатся отдельно (CombatViews) — в ECS только данные. - */ - -export interface EnemyComp { - kind: EnemyKindDef; - brain: EnemyBrain; - pos: Vec2; - hp: number; - /** Точка спавна — «дом» для возврата после погони. */ - home: Vec2; - /** Точки патруля, мировые юниты (пусто — враг спит в пепле). */ - patrolPoints: Vec2[]; - /** Секунды до исчезновения трупа (после смерти). */ - corpseTimer: number; - /** Кэш прямой видимости героя: true/false и сек до пересчёта. */ - los: boolean; - losTimer: number; -} - -export interface ProjectileComp { - pos: Vec2; - vel: Vec2; - damage: number; - life: number; -} - -export interface CombatDeps { - map: IsometricTileMap; - events: EventBus; - /** Позиция героя (ноги), мировые юниты (1 юнит = 1 тайл). */ - getPlayerPos: () => Vec2; - /** Герой получил урон (сцена отбросит/шейкнет/проиграет звук). */ - damagePlayer: (damage: number, from: Vec2) => void; - /** - * Реестр объектов сцены (опция): враги регистрируются в нём для кликов - * и телесных коллизий — боевая система остаётся на ECS. - */ - registry?: SceneRegistry; -} - -/** Окно слышимости шума: событие живёт столько секунд для бодрых ушей. */ -const NOISE_WINDOW = 0.5; -/** Пересчёт LOS раз в 3 тика (60 Гц) — видимость меняется медленнее. */ -const LOS_INTERVAL = 0.05; -/** Бег (отступление) быстрее шага. */ -const RUN_MULT = 1.3; - -/** Движение врага: круг тела скользит вдоль стен (движковые коллизии). */ -function stepWithCollision( - map: IsometricTileMap, - pos: Vec2, - dir: Vec2, - speed: number, - radius: number, - dt: number -): void { - moveCircle(map, pos, { x: dir.x * speed * dt, y: dir.y * speed * dt }, radius); -} - -/** Максимальный радиус тел в реестре (NPC 0.35, враги до 0.3 — с запасом). */ -const BODY_PROBE = 0.5; - -// ---------- системы ECS ---------- - -/** ИИ и движение сгустков. */ -class EnemySystem implements System { - /** Колбэк непрозрачности тайлов (карта не меняется за жизнь мира). */ - private opaque: (tx: number, ty: number) => boolean; - - constructor(private combat: CombatWorld) { - // Дома (пропы) тоже перекрывают взгляд — сгустки не видят сквозь них. - this.opaque = tileOpaque(combat.deps.map); - } - - update(_world: World, dt: number): void { - const playerPos = this.combat.deps.getPlayerPos(); - for (const [e, en] of this.combat.enemies) { - const world = this.combat.world; - if (en.brain.dead) { - en.corpseTimer -= dt; - if (en.corpseTimer <= 0) { - world.destroyEntity(e); - this.combat.enemies.delete(e); - this.combat.deps.registry?.remove(`enemy:${e}`); - } - continue; - } - - // LOS с кэшем: пересчёт раз в LOS_INTERVAL. - en.losTimer -= dt; - if (en.losTimer <= 0) { - en.los = hasLineOfSight(en.pos, playerPos, this.opaque); - en.losTimer = LOS_INTERVAL; - } - - const dx = playerPos.x - en.pos.x; - const dy = playerPos.y - en.pos.y; - const dist = worldDist(en.pos, playerPos) || 1e-4; - const noise = this.combat.noiseFor(en); - const intent: EnemyIntent = en.brain.update(dt, { - dist, - dirToPlayer: worldNorm(dx, dy), - pos: en.pos, - canSee: en.los, - noise, - patrolPoints: en.patrolPoints, - home: en.home, - hpFraction: en.hp / en.kind.hp - }); - - // Позиция до шага и толкучки: если сепарация втолкнет тело в стену - // (separateCircles стены не проверяет), откатываемся сюда. - const beforeCrowd = { ...en.pos }; - - switch (intent.type) { - case 'move': - stepWithCollision( - this.combat.deps.map, - en.pos, - intent.dir, - en.kind.speed * (intent.run ? RUN_MULT : 1), - en.kind.radius, - dt - ); - this.combat.deps.registry?.move(`enemy:${e}`, en.pos); - break; - case 'strike': - // Удар доходит, если герой в радиусе тела + замах - if (dist <= en.kind.attackRange + 0.25) { - this.combat.deps.damagePlayer(en.kind.attackDamage, en.pos); - } - break; - case 'shoot': - this.combat.spawnProjectile(en.pos, intent.dir, en.kind.attackDamage); - // Плевок слышно (звук — подпиской на combat:spit), но спящих не будит (< 0.7) - this.combat.deps.events.emit('combat:spit', { pos: { ...en.pos } }); - this.combat.noise(en.pos, 0.6); - break; - default: - break; - } - - // Тела: каждый враг выталкивает себя из героя, NPC и других врагов - // (сепарация после шага — толкучка не отменяет намерение ИИ). - separateCircles(en.pos, en.kind.radius, playerPos, PLAYER_COMBAT.radius); - const reg = this.combat.deps.registry; - if (reg) { - for (const hit of reg.near(en.pos, en.kind.radius + BODY_PROBE)) { - if (hit.id === `enemy:${e}` || (hit.kind !== 'enemy' && hit.kind !== 'npc')) continue; - separateCircles(en.pos, en.kind.radius, hit.pos, hit.radius); - } - } - // Стена сильнее толкучки: втолкнули в непроходимый тайл — откат. - if (!circleFits(this.combat.deps.map, en.pos, en.kind.radius)) { - en.pos.x = beforeCrowd.x; - en.pos.y = beforeCrowd.y; - } - } - } -} - -/** Снаряды: полёт, попадание в героя, срок жизни. */ -class ProjectileSystem implements System { - constructor(private combat: CombatWorld) {} - - update(_world: World, dt: number): void { - const playerPos = this.combat.deps.getPlayerPos(); - for (const [e, pr] of this.combat.projectiles) { - pr.life -= dt; - pr.pos.x += pr.vel.x * dt; - pr.pos.y += pr.vel.y * dt; - - // Столкновение с героем - if (inCircleW(playerPos, 0.2, pr.pos)) { - this.combat.deps.damagePlayer(pr.damage, pr.pos); - this.combat.killProjectile(e); - continue; - } - // Вне карты, срок истёк или плевок в стену/дом (вода прозрачна — - // летит над прудом; правило игры, не движка). - const tile = worldToTile(pr.pos.x, pr.pos.y, this.combat.deps.map.data.width, this.combat.deps.map.data.height); - if (pr.life <= 0 || !tile || this.combat.opaque(tile.x, tile.y)) { - this.combat.killProjectile(e); - } - } - } -} - -// ---------- фасад ---------- - -export class CombatWorld { - readonly world = new World(); - readonly enemies = new Map(); - readonly projectiles = new Map(); - /** Последний шум мира (шаги, удары, плевки); живёт NOISE_WINDOW секунд. */ - private lastNoise: { origin: Vec2; level: number; age: number } | null = null; - /** Переиспользуемый view шума для noiseFor — без копии origin на врага/тик. */ - private readonly noiseView: { origin: Vec2; level: number } = { origin: { x: 0, y: 0 }, level: 0 }; - /** Непрозрачность тайлов (стены/дома) — для конуса удара и снарядов. */ - readonly opaque: (tx: number, ty: number) => boolean; - - constructor( - readonly deps: CombatDeps, - private onEnemyKilled?: (kind: EnemyKindDef) => void - ) { - this.opaque = tileOpaque(deps.map); - this.world.addSystem(new EnemySystem(this)); - this.world.addSystem(new ProjectileSystem(this)); - } - - /** - * Заспавнить сгусток. Без patrol спит в пепле (dormant), пока не разбудит - * гулкий звук; с патрулём сразу бродит по маршруту. - */ - spawnEnemy(kindId: EnemyKindId, pos: Vec2, opts?: { patrol?: EnemyPatrolDef }): Entity { - const kind = withDefaults(ENEMY_KINDS[kindId]); - const e = this.world.createEntity(); - this.enemies.set(e, { - kind, - brain: new EnemyBrain( - kind, - opts?.patrol - ? { points: opts.patrol.points.map((p) => tileToWorld(p.x, p.y)), pauseSec: opts.patrol.pauseSec } - : undefined - ), - pos: { ...pos }, - hp: kind.hp, - home: { ...pos }, - patrolPoints: opts?.patrol ? opts.patrol.points.map((p) => tileToWorld(p.x, p.y)) : [], - corpseTimer: 0.5, - los: false, - losTimer: 0 - }); - // В реестре сцены — та же ссылка pos: move() после шага переиндексирует. - this.deps.registry?.add({ - id: `enemy:${e}`, - kind: 'enemy', - pos: this.enemies.get(e)!.pos, - radius: kind.radius, - ref: e - }); - return e; - } - - spawnProjectile(from: Vec2, dir: Vec2, damage: number): Entity { - const e = this.world.createEntity(); - this.projectiles.set(e, { - pos: { x: from.x + dir.x * 0.3, y: from.y + dir.y * 0.3 }, - vel: { x: dir.x * 2.2, y: dir.y * 2.2 }, - damage, - life: 1.5 - }); - return e; - } - - killProjectile(e: Entity): void { - if (!this.world.isAlive(e)) return; - this.world.destroyEntity(e); - this.projectiles.delete(e); - } - - /** - * Короткий удар конусом. Возвращает число поражённых сгустков. - * Побочный эффект: гулкий звон (уровень 1.0) будит спящих в wakeRadius. - */ - playerConeAttack(from: Vec2, dir: Vec2): number { - this.noise(from, 1.0); - this.deps.events.emit('combat:attack', { mode: 'cone', origin: { ...from } }); - - let hits = 0; - for (const [e, en] of this.enemies) { - if (en.brain.dead) continue; - if (!inConeW(from, dir, PLAYER_COMBAT.coneRange, PLAYER_COMBAT.coneHalfAngle, en.pos)) continue; - // Звон не огибает дома: цель должна быть в прямой видимости. - if (!hasLineOfSight(from, en.pos, this.opaque)) continue; - this.damageEnemy(e, en, PLAYER_COMBAT.coneDamage, from); - hits++; - } - return hits; - } - - /** Низкий резонанс: усыпляет сгустков в радиусе. Возвращает число усыпленных. */ - resonancePulse(from: Vec2): number { - this.deps.events.emit('combat:attack', { mode: 'resonance', origin: { ...from } }); - - let slept = 0; - for (const [, en] of this.enemies) { - if (en.brain.dead) continue; - if (inCircleW(from, PLAYER_COMBAT.resonanceRange, en.pos)) { - en.brain.putToSleep(PLAYER_COMBAT.resonanceSleep); - slept++; - } - } - return slept; - } - - /** - * Шум в точке мира. level 0.35 — слышат бодрые в hearRadius (патруль - * идёт проверять); 0.7+ — гулкий удар, будит и спящих в wakeRadius; - * 0.6 — плевок/смерть сгустка (слышат, но не просыпаются). - */ - noise(pos: Vec2, level: number): void { - this.lastNoise = { origin: { ...pos }, level, age: 0 }; - if (level >= NOISE_WAKE_LEVEL) { - for (const [, en] of this.enemies) { - if (inCircleW(pos, PLAYER_COMBAT.wakeRadius, en.pos) && en.brain.asleep) { - en.brain.hearLoud(); - this.deps.events.emit('combat:awake', { kind: en.kind.id, pos: { ...en.pos } }); - } - } - } - } - - /** Шум, слышимый конкретному врагу (фильтр по уровню, радиусу слуха и окну). */ - noiseFor(en: EnemyComp): { origin: Vec2; level: number } | null { - const n = this.lastNoise; - if (!n || n.age > NOISE_WINDOW || n.level < NOISE_HEAR_LEVEL) return null; - if (en.brain.asleep) return null; // спящего будит только noise() от 0.7 - if (worldDist(en.pos, n.origin) > (en.kind.hearRadius ?? 5)) return null; - // Общий view (потребитель копирует origin перед хранением — см. EnemyBrain). - this.noiseView.origin = n.origin; - this.noiseView.level = n.level; - return this.noiseView; - } - - /** Урон сгустку; смерть — дроп, событие и приглушённый шум. */ - damageEnemy(e: Entity, en: EnemyComp, damage: number, from: Vec2): void { - en.hp -= damage; - en.brain.hurt(); - this.deps.events.emit('combat:hurt', { entity: e, died: en.hp <= 0 }); - if (en.hp <= 0) { - if (en.brain.die()) { - this.noise(en.pos, 0.6); - this.deps.events.emit('combat:kill', { kind: en.kind.id, tile: { ...en.pos } }); - this.onEnemyKilled?.(en.kind); - // Отброс трупа от героя — лёгкий визуальный эффект делает CombatViews. - en.pos.x += Math.sign(en.pos.x - from.x) * 0.0625; - en.pos.y += Math.sign(en.pos.y - from.y) * 0.0625; - } - } - } - - /** Инварианты боевого мира: легальность состояний и позиций врагов. */ - agentInvariants(): Invariant[] { - const where = 'systems/combat'; - const { width, height } = this.deps.map.data; - const out: Invariant[] = []; - for (const [, en] of this.enemies) { - if (!ENEMY_STATES.includes(en.brain.state as (typeof ENEMY_STATES)[number])) { - out.push({ - id: 'enemy-state-legal', - severity: 'error', - message: `${en.kind.id}: неизвестное состояние "${en.brain.state}"`, - where - }); - } - const tx = Math.floor(en.pos.x); - const ty = Math.floor(en.pos.y); - if (tx < 0 || ty < 0 || tx >= width || ty >= height) { - out.push({ - id: 'enemy-in-bounds', - severity: 'error', - message: `${en.kind.id} вне карты (${en.pos.x.toFixed(2)},${en.pos.y.toFixed(2)})`, - where - }); - } - } - return out; - } - - update(dt: number): void { - if (this.lastNoise) { - this.lastNoise.age += dt; - if (this.lastNoise.age > NOISE_WINDOW) this.lastNoise = null; - } - this.world.update(dt); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/combat/EnemyBrain.ts b/apps/game/src/systems/combat/EnemyBrain.ts deleted file mode 100644 index 154c0b6..0000000 --- a/apps/game/src/systems/combat/EnemyBrain.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { StateMachine, Cooldown, type Vec2 } from '@rpg/engine'; -import type { EnemyKindDef } from '../../data/enemies'; - -/** - * Чистый ИИ «пепельного сгустка» поверх движкового StateMachine. - * Сенсоры входят (дистанция/направление до героя, видимость, шум, патруль), - * намерения выходят (куда идти, когда бить/плевать). Без Pixi — тестируется - * в Vitest. - * - * Состояния: спящий враг (dormant, старт без патруля) -> rise -> chase -> - * windup -> attack -> recover -> chase; патрульный враг: patrol <-> wary -> - * chase; отступление: chase -> flee (низкое hp) / -> return (потерял героя) -> - * patrol | dormant; sleeping (от низкого резонанса), dead. - * - * Шум слышен бодрому врагу от уровня NOISE_HEAR_LEVEL (в радиусе hearRadius — - * фильтрует CombatWorld); спящих будит только уровень >= 0.7 (гулкий удар). - */ -export type EnemyIntent = - | { type: 'idle' } - | { type: 'move'; dir: Vec2; run?: boolean } - | { type: 'strike' } - | { type: 'shoot'; dir: Vec2 }; - -/** Варианты намерений с направлением — для типизации переиспользуемых констант. */ -type MoveIntent = Extract; -type ShootIntent = Extract; - -export interface EnemySenses { - /** Дистанция до героя, мировых юнитов (1 юнит = 1 тайл). */ - dist: number; - /** Нормализованное направление к герою. */ - dirToPlayer: Vec2; - /** Позиция врага (для хождения по патрульным точкам и к дому). */ - pos?: Vec2; - /** Герой в прямой видимости (LOS; по умолчанию true — старые сенсоры). */ - canSee?: boolean; - /** Последний шум в радиусе слуха (уже отфильтрован по hearRadius). */ - noise?: { origin: Vec2; level: number } | null; - /** Точки патруля, мировые юниты (пусто — враг без патруля). */ - patrolPoints?: Vec2[]; - /** Точка спавна — «дом», к которому враг отступает, юниты. */ - home?: Vec2; - /** Доля hp (0..1; по умолчанию 1). */ - hpFraction?: number; -} - -/** Шум от этого уровня слышит бодрый враг (спящих будит CombatWorld от 0.7). */ -export const NOISE_HEAR_LEVEL = 0.35; - -/** Шум от этого уровня будит спящих (гулкий удар; см. CombatWorld.noise). */ -export const NOISE_WAKE_LEVEL = 0.7; - -/** Легальные состояния автомата (инвариант enemy-state-legal). */ -export const ENEMY_STATES = [ - 'dormant', - 'rise', - 'patrol', - 'wary', - 'chase', - 'windup', - 'attack', - 'recover', - 'hurt', - 'flee', - 'return', - 'sleeping', - 'dead' -] as const; - -/** Дальность, на которой считается «дошёл до точки» (юниты). */ -const ARRIVE_DIST = 0.35; - -/** - * Переиспользуемые намерения и вектор направления: update() вызывается на - * каждый тик каждого врага — новые объекты там не создаются. Потребитель - * (CombatWorld) читает intent сразу, между тиками не хранит. - */ -const INTENT_IDLE: EnemyIntent = { type: 'idle' }; -const INTENT_STRIKE: EnemyIntent = { type: 'strike' }; -const INTENT_MOVE: MoveIntent = { type: 'move', dir: { x: 0, y: 0 } }; -const INTENT_MOVE_RUN: MoveIntent = { type: 'move', dir: { x: 0, y: 0 }, run: true }; -const INTENT_SHOOT: ShootIntent = { type: 'shoot', dir: { x: 0, y: 0 } }; -const MOVE_DIR: Vec2 = { x: 0, y: 0 }; - -export class EnemyBrain { - readonly sm = new StateMachine(); - private attackCd: Cooldown; - private sleepTimer = 0; - private hurtTimer = 0; - /** Секунд без прямой видимости в погоне. */ - private lostTimer = 0; - /** Таймер настороженности и точка, к которой идём проверять. */ - private waryTimer = 0; - private waryTarget: Vec2 | null = null; - /** Патруль: индекс точки (ping-pong) и пауза на точке, сек. */ - private patrolIndex = 0; - private patrolDir = 1; - private patrolPause = 0; - - constructor( - readonly kind: EnemyKindDef, - private patrol?: { points: Vec2[]; pauseSec: number } - ) { - this.attackCd = new Cooldown(kind.attackCooldown); - - for (const s of ENEMY_STATES) this.sm.add(s, {}); - - // Обычные переходы - this.sm.transition('dormant', 'wake', 'rise'); - this.sm.transition('rise', 'done', 'chase'); - // Патруль: увидел — погоня, услышал шум — насторожился. - this.sm.transition('patrol', 'see', 'chase'); - this.sm.transition('patrol', 'noise', 'wary'); - this.sm.transition('wary', 'see', 'chase'); - this.sm.transition('wary', 'lost', 'return'); - this.sm.transition('chase', 'inRange', 'windup'); - this.sm.transition('chase', 'exhausted', 'sleeping'); - this.sm.transition('chase', 'lost', 'return'); - this.sm.transition('chase', 'flee', 'flee'); - this.sm.transition('flee', 'calm', 'wary'); - this.sm.transition('return', 'home', this.patrol ? 'patrol' : 'dormant'); - this.sm.transition('return', 'see', 'chase'); - this.sm.transition('windup', 'strike', 'attack'); - this.sm.transition('attack', 'done', 'recover'); - this.sm.transition('recover', 'done', 'chase'); - this.sm.transition('hurt', 'done', 'chase'); - this.sm.transition('sleeping', 'wake', 'rise'); - - // Из любого состояния: урон и смерть (кроме dead — повторный вход игнорируется) - this.sm.transitionAny('hurt', 'hurt'); - this.sm.transitionAny('die', 'dead'); - - this.sm.change(this.patrol ? 'patrol' : 'dormant'); - } - - /** Гулкий звук рядом — проснуться (только для спящих; уровень фильтрует CombatWorld). */ - hearLoud(): void { - if (this.sm.current === 'dormant' || this.sm.current === 'sleeping') { - this.sm.handleEvent('wake'); - } - } - - /** Низкий резонанс: усыпить на seconds (мёртвых не трогает). */ - putToSleep(seconds: number): void { - if (this.sm.current === 'dead') return; - this.sm.change('sleeping'); - this.sleepTimer = seconds; - } - - /** Получил урон: короткий стан (не выбивает из dead). */ - hurt(): void { - if (this.sm.current === 'dead') return; - this.hurtTimer = 0.25; - this.sm.handleEvent('hurt'); - } - - /** Умер. Возвращает true, если умер именно сейчас. */ - die(): boolean { - if (this.sm.current === 'dead') return false; - this.sm.handleEvent('die'); - return true; - } - - get state(): string | null { - return this.sm.current; - } - - get asleep(): boolean { - return this.sm.current === 'sleeping' || this.sm.current === 'dormant'; - } - - get dead(): boolean { - return this.sm.current === 'dead'; - } - - /** Тик ИИ: сенсоры внутрь, намерение наружу. */ - update(dt: number, senses: EnemySenses): EnemyIntent { - this.sm.update(dt); - this.attackCd.update(dt); - - const canSee = senses.canSee ?? true; - const aggro = this.kind.aggroRange ?? 4; - - // Таймеры поверх автомата - if (this.hurtTimer > 0) { - this.hurtTimer -= dt; - if (this.hurtTimer <= 0) this.sm.handleEvent('done'); - return INTENT_IDLE; - } - if (this.sm.current === 'sleeping') { - this.sleepTimer -= dt; - if (this.sleepTimer <= 0) this.sm.handleEvent('wake'); - return INTENT_IDLE; - } - if (this.sm.current === 'rise') { - // Подъём из пепла: 0.6 с, потом в погоню - if (this.sm.time >= 0.6) this.sm.handleEvent('done'); - return INTENT_IDLE; - } - - switch (this.sm.current) { - case 'patrol': - return this.updatePatrol(dt, senses, canSee, aggro); - case 'wary': - return this.updateWary(dt, senses, canSee, aggro); - case 'chase': - return this.updateChase(dt, senses, canSee, aggro); - case 'flee': - return this.updateFlee(senses); - case 'return': - return this.updateReturn(senses, canSee, aggro); - case 'windup': { - if (this.sm.time >= this.kind.windup) { - this.sm.handleEvent('strike'); - this.attackCd.triggerForce(); - if (this.kind.keepDistance !== undefined) { - INTENT_SHOOT.dir.x = senses.dirToPlayer.x; - INTENT_SHOOT.dir.y = senses.dirToPlayer.y; - return INTENT_SHOOT; - } - return INTENT_STRIKE; - } - return INTENT_IDLE; - } - case 'attack': { - if (this.sm.time >= Math.max(this.kind.recover, 0.3)) { - this.sm.handleEvent('done'); - } - return INTENT_IDLE; - } - case 'recover': { - if (this.sm.time >= this.kind.recover) this.sm.handleEvent('done'); - return INTENT_IDLE; - } - default: - return INTENT_IDLE; - } - } - - /** Патруль: ход по точкам туда-обратно; герой/шум вырывают из круга. */ - private updatePatrol(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { - if (this.checkSee(senses, canSee, aggro)) return INTENT_IDLE; - if (this.checkNoise(senses)) return INTENT_IDLE; - - const points = this.patrol?.points ?? senses.patrolPoints ?? []; - if (points.length === 0) return INTENT_IDLE; - if (this.patrolPause > 0) { - this.patrolPause -= dt; - return INTENT_IDLE; - } - if (!this.dirInto(MOVE_DIR, senses.pos, points[this.patrolIndex]!)) { - // Точка достигнута: пауза и разворот на следующую (ping-pong). - this.patrolPause = this.patrol?.pauseSec ?? 0; - this.advancePatrolIndex(points.length); - return INTENT_IDLE; - } - return this.moveIntentFrom(MOVE_DIR, false); - } - - /** Настороженность: идём к источнику шума, осматриваемся warySec — и домой. */ - private updateWary(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { - if (this.checkSee(senses, canSee, aggro)) return INTENT_IDLE; - if (this.checkNoise(senses)) return INTENT_IDLE; // свежий шум обновляет цель и таймер - - this.waryTimer -= dt; - if (this.waryTimer <= 0) { - this.sm.handleEvent('lost'); - return INTENT_IDLE; - } - if (!this.waryTarget || !this.dirInto(MOVE_DIR, senses.pos, this.waryTarget)) return INTENT_IDLE; - return this.moveIntentFrom(MOVE_DIR, false); - } - - /** Погоня: потерял героя дольше deaggroSec — домой; мало hp — прочь. */ - private updateChase(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { - if (canSee) { - this.lostTimer = 0; - } else { - this.lostTimer += dt; - if (this.lostTimer >= (this.kind.deaggroSec ?? 2.5)) { - this.sm.handleEvent('lost'); - return INTENT_IDLE; - } - } - const fleeAt = this.kind.fleeBelowHpFraction ?? 0; - if (fleeAt > 0 && (senses.hpFraction ?? 1) <= fleeAt) { - this.sm.handleEvent('flee'); - return INTENT_IDLE; - } - if (senses.dist > aggro * 1.5) { - // Убежал слишком далеко даже с LOS — не гоняемся по всей карте. - this.sm.handleEvent('lost'); - return INTENT_IDLE; - } - - const inRange = senses.dist <= this.kind.attackRange; - if (inRange && this.kind.keepDistance === undefined && this.attackCd.ready) { - // контактный (ползун): без замаха - if (this.kind.windup === 0) { - this.attackCd.triggerForce(); - return INTENT_STRIKE; - } - this.sm.handleEvent('inRange'); - return INTENT_IDLE; - } - // Плевун держит дистанцию - if (this.kind.keepDistance !== undefined) { - if (senses.dist < this.kind.keepDistance - 0.4) { - return this.moveIntentFrom(negate(MOVE_DIR, senses.dirToPlayer), false); - } - if (senses.dist <= this.kind.attackRange && this.attackCd.ready) { - this.sm.handleEvent('inRange'); - return INTENT_IDLE; - } - if (senses.dist > this.kind.keepDistance + 0.4) { - return this.moveIntentFrom(senses.dirToPlayer, false); - } - return INTENT_IDLE; - } - return this.moveIntentFrom(senses.dirToPlayer, false); - } - - /** Отступление: прочь от героя бегом, пока он не отстал на fleeRange. */ - private updateFlee(senses: EnemySenses): EnemyIntent { - if (senses.dist >= (this.kind.fleeRange ?? 4)) { - // Оторвался: стоит и осматривается warySec, потом домой. - this.waryTarget = null; - this.waryTimer = this.kind.warySec ?? 2.5; - this.sm.handleEvent('calm'); - return INTENT_IDLE; - } - return this.moveIntentFrom(negate(MOVE_DIR, senses.dirToPlayer), true); - } - - /** Возврат к дому; на месте — снова в патруль или обратно в пепел. */ - private updateReturn(senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { - if (this.checkSee(senses, canSee, aggro)) return INTENT_IDLE; - if (this.checkNoise(senses)) return INTENT_IDLE; - if (senses.home) { - if (!this.dirInto(MOVE_DIR, senses.pos, senses.home)) { - this.lostTimer = 0; - this.sm.handleEvent('home'); - } else { - return this.moveIntentFrom(MOVE_DIR, false); - } - } - return INTENT_IDLE; - } - - /** Переход «увидел героя» (LOS + aggroRange); true — событие отправлено. */ - private checkSee(senses: EnemySenses, canSee: boolean, aggro: number): boolean { - if (canSee && senses.dist <= aggro) { - this.lostTimer = 0; - this.sm.handleEvent('see'); - return true; - } - return false; - } - - /** Переход «услышал шум» (уровень >= порога); true — событие отправлено. */ - private checkNoise(senses: EnemySenses): boolean { - const noise = senses.noise; - if (noise && noise.level >= NOISE_HEAR_LEVEL) { - this.waryTarget = { ...noise.origin }; - this.waryTimer = this.kind.warySec ?? 2.5; - this.sm.handleEvent('noise'); - return true; - } - return false; - } - - /** Нормализованное направление к точке в out; false — точка достигнута (ARRIVE_DIST). */ - private dirInto(out: Vec2, from: Vec2 | undefined, target: Vec2): boolean { - if (!from) return false; - const dx = target.x - from.x; - const dy = target.y - from.y; - const d = Math.hypot(dx, dy); - if (d < ARRIVE_DIST) return false; - out.x = dx / d; - out.y = dy / d; - return true; - } - - /** move-намерение с копией направления из dir (общий intent.dir мутируется). */ - private moveIntentFrom(dir: Vec2, run: boolean): EnemyIntent { - const intent = run ? INTENT_MOVE_RUN : INTENT_MOVE; - intent.dir.x = dir.x; - intent.dir.y = dir.y; - return intent; - } - - private advancePatrolIndex(count: number): void { - if (count < 2) return; - if (this.patrolIndex + this.patrolDir >= count || this.patrolIndex + this.patrolDir < 0) { - this.patrolDir = -this.patrolDir; - } - this.patrolIndex += this.patrolDir; - } -} - -/** Обратный вектор в out (без аллокации — вызывается в тике). */ -function negate(out: Vec2, v: Vec2): Vec2 { - out.x = -v.x; - out.y = -v.y; - return out; -} \ No newline at end of file diff --git a/apps/game/src/systems/combat/HealthBar.ts b/apps/game/src/systems/combat/HealthBar.ts deleted file mode 100644 index 58bf33e..0000000 --- a/apps/game/src/systems/combat/HealthBar.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Container, Graphics } from '@rpg/engine'; -import { PLAYER_COMBAT } from './stats'; - -/** - * Полоса здоровья героя: сердечки в левом верхнем углу (uiRoot). - */ -export class HealthBar extends Container { - private hearts: Graphics[] = []; - /** Прошлое значение hp — не перерисовываем при том же (тик идёт каждый кадр). */ - private lastHp: number = PLAYER_COMBAT.maxHp; - - constructor() { - super(); - for (let i = 0; i < PLAYER_COMBAT.maxHp; i++) { - const h = new Graphics(); - h.position.set(8 + i * 11, 16); - this.addChild(h); - this.hearts.push(h); - } - this.draw(PLAYER_COMBAT.maxHp); - } - - /** Обновить по текущему HP. */ - setHp(hp: number): void { - if (hp === this.lastHp) return; - this.lastHp = hp; - this.draw(hp); - } - - private draw(hp: number): void { - this.hearts.forEach((h, i) => { - h.clear(); - // Пиксельное сердечко 7x6 из прямоугольников - const color = i < hp ? 0xb0453f : 0x2a2a33; - const border = i < hp ? 0x7a2f2b : 0x1c1c24; - const px = (x: number, y: number, w: number, hh: number, c: number): void => { - h.rect(x, y, w, hh).fill(c); - }; - px(-3, -2, 6, 4, border); // тело - px(-2, 2, 4, 1, border); - px(-3, -2, 2, 2, color); - px(1, -2, 2, 2, color); - px(-2, -1, 4, 2, color); - px(-2, 1, 2, 1, color); - px(0, 1, 1, 1, color); - px(-3, -3, 2, 1, color); // горбики - px(1, -3, 2, 1, color); - }); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/combat/PlayerCombat.ts b/apps/game/src/systems/combat/PlayerCombat.ts deleted file mode 100644 index 741bf79..0000000 --- a/apps/game/src/systems/combat/PlayerCombat.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Cooldown } from '@rpg/engine'; -import { PLAYER_COMBAT } from './stats'; - -/** - * Боевое состояние героя: HP, неуязвимость, кулдауны, заряд резонанса. - * Чистая логика (без Pixi) — тестируется в Vitest. - */ -export class PlayerCombat { - hp: number; - /** Секунды удержания атаки (заряд низкого резонанса). */ - chargeTime = 0; - private invulnTimer = 0; - /** Кулдаун короткого удара. */ - readonly attackCd = new Cooldown(PLAYER_COMBAT.coneCooldown); - /** Кулдаун низкого резонанса. */ - readonly resonanceCd = new Cooldown(PLAYER_COMBAT.resonanceCooldown); - - constructor(hp: number = PLAYER_COMBAT.maxHp) { - this.hp = Math.min(hp, PLAYER_COMBAT.maxHp); - } - - update(dt: number): void { - if (this.invulnTimer > 0) this.invulnTimer = Math.max(0, this.invulnTimer - dt); - this.attackCd.update(dt); - this.resonanceCd.update(dt); - if (this.isCharging()) this.chargeTime += dt; - } - - /** Атака удерживается (Space зажат) — копим заряд резонанса. */ - startCharge(): void { - if (this.chargeTime === 0) this.chargeTime = 0.0001; // старт отсчёта - } - - isCharging(): boolean { - return this.chargeTime > 0; - } - - /** - * Отпустил: если зарядился до резонанса и кулдаун готов — вернуть 'resonance', - * иначе — обычный удар (если готов). Кулдауны триггерятся здесь. - */ - release(): 'resonance' | 'attack' | null { - const charged = this.chargeTime >= PLAYER_COMBAT.resonanceCharge; - this.chargeTime = 0; - if (charged) { - if (this.resonanceCd.trigger()) return 'resonance'; - return null; // резонанс ещё перезаряжается — молча - } - return this.attackCd.trigger() ? 'attack' : null; - } - - /** - * Получить урон. Возвращает фактически применённый урон (0 — неуязвим). - */ - takeDamage(damage: number): number { - if (this.invulnTimer > 0 || this.hp <= 0) return 0; - const applied = Math.min(damage, this.hp); - this.hp -= applied; - this.invulnTimer = PLAYER_COMBAT.invulnTime; - return applied; - } - - /** Неуязвим (мигание спрайта в этот период). */ - get invuln(): boolean { - return this.invulnTimer > 0; - } - - get dead(): boolean { - return this.hp <= 0; - } - - /** Восстановление после смерти (респаун). */ - revive(): void { - this.hp = PLAYER_COMBAT.maxHp; - this.invulnTimer = PLAYER_COMBAT.invulnTime; - this.chargeTime = 0; - this.attackCd.reset(); - this.resonanceCd.reset(); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts b/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts deleted file mode 100644 index d0aa8c5..0000000 --- a/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ENEMY_KINDS, withDefaults } from '../../../data/enemies'; -import { EnemyBrain, NOISE_HEAR_LEVEL, NOISE_WAKE_LEVEL, type EnemySenses } from '../EnemyBrain'; -import { far, fakeWorld, run } from '../../../testing/combatWorld'; - -/** Патрульные точки в юнитах. */ -const points = [ - { x: 10.5, y: 7.5 }, - { x: 12.5, y: 9.5 } -]; - -describe('EnemyBrain: патруль', () => { - it('с патрулём стартует бодрым в patrol, без — спит в пепле', () => { - const patrolled = new EnemyBrain(withDefaults(ENEMY_KINDS.crawler), { points, pauseSec: 1 }); - expect(patrolled.state).toBe('patrol'); - expect(patrolled.asleep).toBe(false); - const sleeper = new EnemyBrain(ENEMY_KINDS.crawler); - expect(sleeper.state).toBe('dormant'); - expect(sleeper.asleep).toBe(true); - }); - - it('дошёл до точки — пауза, потом идёт к следующей (ping-pong)', () => { - const b = new EnemyBrain(ENEMY_KINDS.heavy, { points, pauseSec: 1 }); - // Стоим на точке 0 — сразу пауза - expect(b.update(1 / 60, far(points[0]!)).type).toBe('idle'); - run(b, 1.1, far(points[0]!)); - // Пауза истекла — движение к точке 1 (вниз-вправо, диагональ изометрии) - const move = run(b, 0.05, far(points[0]!)); - expect(move.type).toBe('move'); - if (move.type === 'move') { - expect(move.dir.x).toBeCloseTo(0.707, 1); - expect(move.dir.y).toBeCloseTo(0.707, 1); - expect(move.run).toBeUndefined(); // патруль шагом, не бегом - } - // Дошли до точки 1 — снова пауза и разворот назад - run(b, 3.2, far(points[1]!)); - const back = run(b, 0.05, far(points[1]!)); - expect(back.type).toBe('move'); - if (back.type === 'move') expect(back.dir.x).toBeLessThan(0); - }); - - it('шум уровня 0.35 вырывает из патруля в настороженность', () => { - const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 }); - const pos = { x: 10.5, y: 7.5 }; - b.update(1 / 60, { ...far(pos), noise: { origin: { x: 11.5, y: 8.5 }, level: NOISE_HEAR_LEVEL } }); - expect(b.state).toBe('wary'); - // Идёт к источнику шума - const move = b.update(1 / 60, { ...far(pos), noise: null }); - expect(move.type).toBe('move'); - }); - - it('тихий шум (ниже порога) не настораживает', () => { - const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 }); - b.update(1 / 60, { ...far(points[0]!), noise: { origin: { x: 11, y: 8 }, level: 0.2 } }); - expect(b.state).toBe('patrol'); - }); -}); - -describe('EnemyBrain: настороженность', () => { - it('увидел героя в aggroRange — погоня', () => { - const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 }); - const pos = { x: 10.5, y: 7.5 }; - b.update(1 / 60, { ...far(pos), noise: { origin: pos, level: 0.5 } }); - expect(b.state).toBe('wary'); - b.update(1 / 60, { dist: 3, dirToPlayer: { x: 1, y: 0 }, pos, canSee: true }); - expect(b.state).toBe('chase'); - }); - - it('герой не показался за warySec — возврат к дому', () => { - const b = new EnemyBrain(ENEMY_KINDS.heavy, { points, pauseSec: 1 }); // warySec 3 - const pos = { x: 10.5, y: 7.5 }; - b.update(1 / 60, { ...far(pos), noise: { origin: { x: 11, y: 8 }, level: 0.5 } }); - run(b, 1, { ...far({ x: 11, y: 8 }), noise: null }); - expect(b.state).toBe('wary'); - run(b, 2.2, { ...far({ x: 11, y: 8 }), noise: null }); - expect(b.state).toBe('return'); - // Идёт к дому и на месте — снова в патруль - run(b, 3, far({ x: 10.7, y: 7.7 })); - expect(b.state).toBe('patrol'); - }); -}); - -describe('EnemyBrain: погоня и отступление', () => { - it('потерял героя из виду дольше deaggroSec — возврат', () => { - const b = new EnemyBrain(ENEMY_KINDS.crawler); - b.hearLoud(); - run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, y: 0 } }); - expect(b.state).toBe('chase'); - // Герой скрылся: 2.5 с без LOS — и враг уходит домой (позицию двигаем руками) - const pos = { x: 10, y: 10 }; - for (let i = 0; i < Math.round(2.6 * 60); i++) { - b.update(1 / 60, { dist: 3, dirToPlayer: { x: 1, y: 0 }, canSee: false, pos, home: { x: 5, y: 5 } }); - pos.x -= 0.007; - } - expect(b.state).toBe('return'); - }); - - it('герой дальше 1.5 aggro — не гоняется по всей карте', () => { - const b = new EnemyBrain(ENEMY_KINDS.crawler); - b.hearLoud(); - run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, y: 0 } }); - run(b, 0.1, { dist: 7, dirToPlayer: { x: 1, y: 0 } }); // 1.5 * aggro 4 = 6 - expect(b.state).toBe('return'); - }); - - it('мало hp — бежит прочь бегом, оторвался — успокоился', () => { - const b = new EnemyBrain(withDefaults(ENEMY_KINDS.heavy)); // fleeBelowHpFraction 0.35 - const chase: EnemySenses = { dist: 1, dirToPlayer: { x: 1, y: 0 } }; - b.hearLoud(); - run(b, 0.7, chase); - run(b, 0.1, { ...chase, hpFraction: 0.3 }); - expect(b.state).toBe('flee'); - const flee = b.update(1 / 60, { ...chase, hpFraction: 0.3 }); - expect(flee.type).toBe('move'); - if (flee.type === 'move') { - expect(flee.dir.x).toBe(-1); // прочь от героя - expect(flee.run).toBe(true); - } - // Герой отстал на fleeRange (4.5) — отбой в настороженность - run(b, 0.1, { dist: 4.6, dirToPlayer: { x: 1, y: 0 }, hpFraction: 0.3 }); - expect(b.state).toBe('wary'); - }); - - it('возврат без патруля — дошёл до дома и снова спит', () => { - const b = new EnemyBrain(ENEMY_KINDS.crawler); - b.hearLoud(); - run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, y: 0 } }); - run(b, 2.6, { dist: 3, dirToPlayer: { x: 1, y: 0 }, canSee: false, home: { x: 5, y: 5 } }); - run(b, 5, { dist: 30, dirToPlayer: { x: 1, y: 0 }, canSee: false, pos: { x: 4.8, y: 4.8 }, home: { x: 5, y: 5 } }); - expect(b.state).toBe('dormant'); - expect(b.asleep).toBe(true); - }); -}); - -// ---------- CombatWorld: пороги шума ---------- - -describe('CombatWorld: шум будит только от гулкого', () => { - it('шаги героя (0.35) не будят спящего, гулкий звон (1.0) будит', () => { - const hero = { x: 20, y: 20 }; - const w = fakeWorld(hero); - const e = w.spawnEnemy('crawler', { x: 10.5, y: 10.5 }); - const en = w.enemies.get(e)!; - expect(en.brain.asleep).toBe(true); - - w.noise({ x: 10.5, y: 10.5 }, 0.35); // шаги прямо рядом - w.update(1 / 60); - expect(en.brain.asleep).toBe(true); - - w.noise({ x: 10.5, y: 10.5 }, NOISE_WAKE_LEVEL); // гулкий удар - expect(en.brain.asleep).toBe(false); - expect(en.brain.state).toBe('rise'); - }); - - it('бодрый патрульный слышит тихий шум в hearRadius и настораживается', () => { - const hero = { x: 20, y: 20 }; - const w = fakeWorld(hero); - const e = w.spawnEnemy('heavy', { x: 10.5, y: 10.5 }, { - patrol: { points: [{ x: 10, y: 10 }, { x: 12, y: 12 }], pauseSec: 1 } - }); - const en = w.enemies.get(e)!; - expect(en.brain.state).toBe('patrol'); - // Шаги героя в 1 юните — в hearRadius 4 - w.noise({ x: 11.5, y: 10.5 }, 0.35); - w.update(1 / 60); - expect(en.brain.state).toBe('wary'); - }); - - it('шум вне радиуса слуха не слышен', () => { - const hero = { x: 20, y: 20 }; - const w = fakeWorld(hero); - const e = w.spawnEnemy('crawler', { x: 10.5, y: 10.5 }, { - patrol: { points: [{ x: 10, y: 10 }, { x: 12, y: 12 }], pauseSec: 1 } - }); - const en = w.enemies.get(e)!; - // hearRadius ползуна 3.5, шум в 20 юнитах - w.noise({ x: 18.5, y: 10.5 }, 0.5); - w.update(1 / 60); - expect(en.brain.state).toBe('patrol'); - }); - - it('инварианты боевого мира пусты на честном ИИ', () => { - const hero = { x: 20, y: 20 }; - const w = fakeWorld(hero); - w.spawnEnemy('crawler', { x: 10.5, y: 10.5 }, { - patrol: { points: [{ x: 10, y: 10 }, { x: 12, y: 12 }], pauseSec: 1 } - }); - for (let i = 0; i < 600; i++) w.update(1 / 60); - expect(w.agentInvariants()).toEqual([]); - }); -}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts b/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts deleted file mode 100644 index 8d448e1..0000000 --- a/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ENEMY_KINDS } from '../../../data/enemies'; -import { EnemyBrain, type EnemySenses } from '../EnemyBrain'; - -/** Тик ИИ на 1/60 с; возвращает последнее намерение. */ -const run = (b: EnemyBrain, seconds: number, senses: EnemySenses): ReturnType => { - let intent: ReturnType = { type: 'idle' }; - for (let i = 0; i < Math.round(seconds * 60); i++) intent = b.update(1 / 60, senses); - return intent; -}; - -/** Прогон с ловлей конкретного намерения (strike/shoot выдаются одним тиком). */ -const sawIntent = (b: EnemyBrain, seconds: number, senses: EnemySenses, type: string): boolean => { - for (let i = 0; i < Math.round(seconds * 60); i++) { - if (b.update(1 / 60, senses).type === type) return true; - } - return false; -}; - -const away: EnemySenses = { dist: 3.1, dirToPlayer: { x: 0, y: 1 } }; -const close: EnemySenses = { dist: 0.25, dirToPlayer: { x: 1, y: 0 } }; - -/** Разбудить и дождаться погони (спит -> rise 0.6 с -> chase). */ -const wakeUp = (b: EnemyBrain, senses: EnemySenses): void => { - b.hearLoud(); - run(b, 0.7, senses); -}; - -describe('EnemyBrain (ползун)', () => { - it('стартует спящим в пепле и не двигается', () => { - const b = new EnemyBrain(ENEMY_KINDS.crawler); - expect(b.asleep).toBe(true); - expect(b.update(1 / 60, close).type).toBe('idle'); - }); - - it('гулкий звук поднимает: rise -> chase', () => { - const b = new EnemyBrain(ENEMY_KINDS.crawler); - b.hearLoud(); - expect(b.state).toBe('rise'); - run(b, 0.7, away); - expect(b.state).toBe('chase'); - // Вдали от героя — движение к нему - expect(run(b, 0.05, away)).toEqual({ type: 'move', dir: { x: 0, y: 1 } }); - }); - - it('в радиусе атаки бьёт контактом по кулдауну', () => { - const b = new EnemyBrain(ENEMY_KINDS.crawler); - wakeUp(b, away); - expect(sawIntent(b, 0.1, close, 'strike')).toBe(true); - // Кулдаун: в следующее окно удара нет, после — снова есть - expect(sawIntent(b, 0.1, close, 'strike')).toBe(false); - run(b, ENEMY_KINDS.crawler.attackCooldown, away); - expect(sawIntent(b, 0.1, close, 'strike')).toBe(true); - }); - - it('урон станит и возвращает в погоню', () => { - const b = new EnemyBrain(ENEMY_KINDS.crawler); - wakeUp(b, away); - b.hurt(); - expect(b.state).toBe('hurt'); - expect(b.update(1 / 60, away).type).toBe('idle'); - run(b, 0.3, away); - expect(b.state).toBe('chase'); - }); - - it('резонанс усыпляет на заданное время, потом просыпается', () => { - const b = new EnemyBrain(ENEMY_KINDS.crawler); - wakeUp(b, away); - b.putToSleep(4); - expect(b.asleep).toBe(true); - expect(run(b, 2, away).type).toBe('idle'); - expect(b.asleep).toBe(true); - run(b, 2.2, away); - expect(b.state).toBe('rise'); // проснулся — сначала подъём - run(b, 0.7, away); - expect(b.state).toBe('chase'); - expect(b.asleep).toBe(false); - }); - - it('смерть окончательна: повторный die возвращает false', () => { - const b = new EnemyBrain(ENEMY_KINDS.crawler); - expect(b.die()).toBe(true); - expect(b.dead).toBe(true); - expect(b.die()).toBe(false); - // Урон и сон мёртвого не трогают - b.hurt(); - b.putToSleep(4); - expect(b.dead).toBe(true); - }); -}); - -describe('EnemyBrain (наст)', () => { - it('перед ударом есть замах 0.6 с', () => { - const b = new EnemyBrain(ENEMY_KINDS.heavy); - wakeUp(b, away); - // Сразу — только замах, урона нет - run(b, 0.05, close); - expect(b.state).toBe('windup'); - run(b, 0.3, close); - expect(b.state).toBe('windup'); - // Через 0.6 с замаха — удар и фаза атаки - run(b, 0.35, close); - expect(b.state).toBe('attack'); - expect(sawIntent(b, 0.05, close, 'strike')).toBe(false); // уже прошёл - }); -}); - -describe('EnemyBrain (плевун)', () => { - it('сближение — отходит, издали — идёт ближе', () => { - const b = new EnemyBrain(ENEMY_KINDS.spitter); - wakeUp(b, { dist: 3.1, dirToPlayer: { x: 1, y: 0 } }); - expect(b.state).toBe('chase'); - // Слишком близко к keepDistance — пятится - const back = run(b, 0.05, { dist: 1.3, dirToPlayer: { x: 1, y: 0 } }); - expect(back.type).toBe('move'); - if (back.type === 'move') expect(back.dir.x).toBeLessThan(0); - // Слишком далеко — подходит - const toward = run(b, 0.05, { dist: 3.8, dirToPlayer: { x: 0, y: 1 } }); - expect(toward.type).toBe('move'); - if (toward.type === 'move') expect(toward.dir.y).toBeGreaterThan(0); - }); - - it('на дистанции атаки — замах и плевок', () => { - const b = new EnemyBrain(ENEMY_KINDS.spitter); - wakeUp(b, { dist: 100, dirToPlayer: { x: 1, y: 0 } }); - const mid: EnemySenses = { dist: 2.5, dirToPlayer: { x: 1, y: 0 } }; - // Входим в радиус атаки — замах - run(b, 0.05, mid); - expect(b.state).toBe('windup'); - // Через windup — плевок (ловим намерение в тик срабатывания) - expect(sawIntent(b, 0.45, mid, 'shoot')).toBe(true); - }); -}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/__tests__/PlayerCombat.test.ts b/apps/game/src/systems/combat/__tests__/PlayerCombat.test.ts deleted file mode 100644 index bdf693b..0000000 --- a/apps/game/src/systems/combat/__tests__/PlayerCombat.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { PLAYER_COMBAT } from '../stats'; -import { PlayerCombat } from '../PlayerCombat'; - -/** Тик на 1/60 с — как в игровом цикле. */ -const step = (c: PlayerCombat, seconds: number): void => { - for (let i = 0; i < Math.round(seconds * 60); i++) c.update(1 / 60); -}; - -describe('PlayerCombat', () => { - it('стартует с полным здоровьем', () => { - const c = new PlayerCombat(); - expect(c.hp).toBe(PLAYER_COMBAT.maxHp); - expect(c.dead).toBe(false); - expect(c.invuln).toBe(false); - }); - - it('урон не превышает остаток HP, после него неуязвимость', () => { - const c = new PlayerCombat(); - expect(c.takeDamage(3)).toBe(3); - expect(c.hp).toBe(2); - expect(c.invuln).toBe(true); - // В окно неуязвимости урон не проходит - expect(c.takeDamage(1)).toBe(0); - expect(c.hp).toBe(2); - }); - - it('смерть при HP 0; revive возвращает полные сердечки', () => { - const c = new PlayerCombat(); - c.takeDamage(PLAYER_COMBAT.maxHp); - expect(c.dead).toBe(true); - c.revive(); - expect(c.hp).toBe(PLAYER_COMBAT.maxHp); - expect(c.dead).toBe(false); - expect(c.invuln).toBe(true); - }); - - it('короткий тап — удар по кулдауну', () => { - const c = new PlayerCombat(); - c.startCharge(); - step(c, 0.1); - expect(c.isCharging()).toBe(true); - expect(c.release()).toBe('attack'); - // Кулдаун ещё не истёк — второй удар не проходит - c.startCharge(); - step(c, 0.1); - expect(c.release()).toBeNull(); - // После кулдауна снова готов - step(c, PLAYER_COMBAT.coneCooldown); - c.startCharge(); - step(c, 0.1); - expect(c.release()).toBe('attack'); - }); - - it('удержание дольше resonanceCharge — резонанс по своему кулдауну', () => { - const c = new PlayerCombat(); - c.startCharge(); - step(c, PLAYER_COMBAT.resonanceCharge + 0.1); - expect(c.release()).toBe('resonance'); - // Резонанс перезаряжается — повторный заряд молча отклоняется - c.startCharge(); - step(c, PLAYER_COMBAT.resonanceCharge + 0.1); - expect(c.release()).toBeNull(); - step(c, PLAYER_COMBAT.resonanceCooldown); - c.startCharge(); - step(c, PLAYER_COMBAT.resonanceCharge + 0.1); - expect(c.release()).toBe('resonance'); - }); - - it('неуязвимость спадает со временем', () => { - const c = new PlayerCombat(); - c.takeDamage(1); - step(c, PLAYER_COMBAT.invulnTime + 0.05); - expect(c.invuln).toBe(false); - expect(c.takeDamage(1)).toBe(1); - }); -}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/__tests__/los.test.ts b/apps/game/src/systems/combat/__tests__/los.test.ts deleted file mode 100644 index 0502d96..0000000 --- a/apps/game/src/systems/combat/__tests__/los.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { hasLineOfSight, tileOpaque } from '../los'; -import { buildMeadowsMap } from '../../../data/map'; -import { mapWithWall } from '../../../testing/maps'; - -describe('hasLineOfSight', () => { - const opaque = tileOpaque({ data: mapWithWall() }); - - it('свободная линия видна', () => { - expect(hasLineOfSight({ x: 0.5, y: 0.5 }, { x: 4.5, y: 0.5 }, opaque)).toBe(true); - }); - - it('стена в середине перекрывает обзор', () => { - expect(hasLineOfSight({ x: 0.5, y: 5.5 }, { x: 9.5, y: 5.5 }, opaque)).toBe(false); - }); - - it('линия мимо стены видит', () => { - expect(hasLineOfSight({ x: 0.5, y: 0.5 }, { x: 9.5, y: 4.5 }, opaque)).toBe(true); - }); - - it('короткие отрезки и нулевая длина видимы', () => { - expect(hasLineOfSight({ x: 3, y: 3 }, { x: 3.2, y: 3 }, opaque)).toBe(true); - expect(hasLineOfSight({ x: 3, y: 3 }, { x: 3, y: 3 }, opaque)).toBe(true); - }); - - it('вне карты — непрозрачно', () => { - expect(hasLineOfSight({ x: 9.5, y: 5.5 }, { x: 15.5, y: 5.5 }, opaque)).toBe(false); - }); -}); - -describe('tileOpaque на реальной карте', () => { - it('дерево/дом непрозрачны, трава/тропа/вода прозрачны', () => { - const data = buildMeadowsMap(); - const map = { data }; - const is = tileOpaque(map); - // Дерево и воду ищем на карте: раскладка seeded, но не привязываем тест к координате. - let tree: { x: number; y: number } | null = null; - let water: { x: number; y: number } | null = null; - for (let y = 0; y < data.height; y++) { - for (let x = 0; x < data.width; x++) { - const t = data.tiles[y * data.width + x]; - if (t === 3 && !tree) tree = { x, y }; - if (t === 2 && !water) water = { x, y }; - } - } - expect(tree).not.toBeNull(); - expect(water).not.toBeNull(); - expect(is(tree!.x, tree!.y)).toBe(true); // дерево - expect(is(16, 28)).toBe(false); // трава у спавна - expect(is(water!.x, water!.y)).toBe(false); // вода прозрачна - }); -}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/__tests__/stats.test.ts b/apps/game/src/systems/combat/__tests__/stats.test.ts deleted file mode 100644 index fa4c4b3..0000000 --- a/apps/game/src/systems/combat/__tests__/stats.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { PLAYER_COMBAT } from '../stats'; -import { ENEMY_KINDS } from '../../../data/enemies'; - -/** - * Таблица баланса боевой модели демо-среза (согласована с планом и docs/world.md): - * удар — «гулкий звон» (будит пепел), резонанс — «низкий звон» (усыпляет). - */ -describe('PLAYER_COMBAT — таблица баланса', () => { - it('герой: 5 сердечек, окно неуязвимости 0.8 с', () => { - expect(PLAYER_COMBAT.maxHp).toBe(5); - expect(PLAYER_COMBAT.invulnTime).toBeCloseTo(0.8); - expect(PLAYER_COMBAT.knockback).toBe(0.75); - }); - - it('короткий удар: конус 1.0 юнита, 55°, урон 2, кулдаун 0.45 с', () => { - expect(PLAYER_COMBAT.coneRange).toBe(1.0); - expect((PLAYER_COMBAT.coneHalfAngle * 180) / Math.PI).toBeCloseTo(55); - expect(PLAYER_COMBAT.coneDamage).toBe(2); - expect(PLAYER_COMBAT.coneCooldown).toBeCloseTo(0.45); - }); - - it('резонанс: заряд 0.6 с, радиус 3.0, сон 4 с, кулдаун 3 с', () => { - expect(PLAYER_COMBAT.resonanceCharge).toBeCloseTo(0.6); - expect(PLAYER_COMBAT.resonanceRange).toBe(3.0); - expect(PLAYER_COMBAT.resonanceSleep).toBe(4); - expect(PLAYER_COMBAT.resonanceCooldown).toBe(3); - }); - - it('гулкий удар будит спящих в радиусе 4.5 юнитов', () => { - expect(PLAYER_COMBAT.wakeRadius).toBe(4.5); - }); - - it('ползун: слабый, но быстрый, контактный', () => { - const c = ENEMY_KINDS.crawler; - expect(c.hp).toBe(3); - expect(c.attackDamage).toBe(1); - expect(c.windup).toBe(0); - // Убивается одним коротким ударом (урон 2) не должен: нужно 2 удара - expect(Math.ceil(c.hp / PLAYER_COMBAT.coneDamage)).toBe(2); - }); - - it('плевун: хрупкий стрелок, держит дистанцию', () => { - const s = ENEMY_KINDS.spitter; - expect(s.hp).toBe(2); - expect(s.keepDistance).toBeGreaterThan(PLAYER_COMBAT.coneRange); - expect(s.attackRange).toBeGreaterThan(s.keepDistance ?? 0); - expect(s.windup).toBeGreaterThan(0); - }); - - it('наст: танк с тяжёлым замахом и уроном 2', () => { - const h = ENEMY_KINDS.heavy; - expect(h.hp).toBe(8); - expect(h.attackDamage).toBe(2); - expect(h.windup).toBeGreaterThanOrEqual(0.6); - // Четыре коротких удара (урон 2) убивают - expect(Math.ceil(h.hp / PLAYER_COMBAT.coneDamage)).toBe(4); - }); -}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/los.ts b/apps/game/src/systems/combat/los.ts deleted file mode 100644 index cf7b99a..0000000 --- a/apps/game/src/systems/combat/los.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Vec2 } from '@rpg/engine'; - -/** - * Линия видимости по сетке (чистая математика, без Pixi). - * Отрезок дискретизируется шагом 0.25 юнита; тайл с непрозрачным объектом - * (стена, дом, башня, дерево) перекрывает обзор. Не в движке: правила - * прозрачности зависят от игры, второй пользователь не виден. - */ - -/** Шаг дискретизации отрезка, юнитов. */ -const STEP = 0.25; - -/** Виден ли `to` из `from` с учётом непрозрачных тайлов. */ -export function hasLineOfSight( - from: Vec2, - to: Vec2, - isOpaque: (tx: number, ty: number) => boolean -): boolean { - const dx = to.x - from.x; - const dy = to.y - from.y; - const len = Math.hypot(dx, dy); - if (len < 1e-6) return true; - const n = Math.ceil(len / STEP); - const ux = dx / n; - const uy = dy / n; - // Концевые точки не проверяем: сами враг/герой стоят в своих тайлах. - for (let i = 1; i < n; i++) { - const x = from.x + ux * i; - const y = from.y + uy * i; - if (isOpaque(Math.floor(x), Math.floor(y))) return false; - } - return true; -} - -/** - * Непрозрачен ли тайл карты: высокий объект (TallSpec) или footprint пропа - * (дом) перекрывает взгляд. Вода и ровный пепел прозрачны — сгустки видят - * через низину. extra — дополнительные непрозрачные тайлы (правила игры). - */ -export function tileOpaque( - map: { - isPropBlocked?: (tx: number, ty: number) => boolean; - data: { width: number; height: number; tiles: number[]; tall?: Record }; - }, - extra?: (tx: number, ty: number) => boolean -): (tx: number, ty: number) => boolean { - return (tx: number, ty: number): boolean => { - const { width, height, tiles, tall } = map.data; - if (tx < 0 || ty < 0 || tx >= width || ty >= height) return true; - if (map.isPropBlocked?.(tx, ty)) return true; - if (extra?.(tx, ty)) return true; - return tall?.[tiles[ty * width + tx]] !== undefined; - }; -} \ No newline at end of file diff --git a/apps/game/src/systems/combat/stats.ts b/apps/game/src/systems/combat/stats.ts deleted file mode 100644 index 0a1b63f..0000000 --- a/apps/game/src/systems/combat/stats.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Числовые константы боя героя — чистые данные без Pixi (тестируются). - * Позиции, дистанции и скорости — в мировых юнитах (1 юнит = 1 тайл; - * на экране 1 юнит = tileW px по линейке проекции). - * Согласованы с боевой моделью демо-среза и сеттингом: - * удар — «гулкий звон» (будит пепел), резонанс — «низкий звон» (усыпляет). - */ - -export const PLAYER_COMBAT = { - /** Здоровье героя (сердечки). */ - maxHp: 5, - /** Радиус тела героя (юниты; < 0.5 — путь A* по центрам остаётся проходим). */ - radius: 0.35, - /** Конус удара: дальность, полуугол (рад), урон, кулдаун. */ - coneRange: 1.0, - coneHalfAngle: (55 * Math.PI) / 180, - coneDamage: 2, - coneCooldown: 0.45, - /** В зоне удара стоим ближе, чем coneRange (гистерезис авто-подхода). */ - attackStop: 0.85, - /** Низкий резонанс: сколько держать заряд, радиус, сон врага, кулдаун. */ - resonanceCharge: 0.6, - resonanceRange: 3.0, - resonanceSleep: 4, - resonanceCooldown: 3, - /** Неуязвимость после урона, сек; отброс, юнитов. */ - invulnTime: 0.8, - knockback: 0.75, - /** Гулкий удар излучает звук: будит сгустков в этом радиусе. */ - wakeRadius: 4.5 -} as const; diff --git a/apps/game/src/systems/cutscene/CutsceneActors.ts b/apps/game/src/systems/cutscene/CutsceneActors.ts deleted file mode 100644 index e6b51e1..0000000 --- a/apps/game/src/systems/cutscene/CutsceneActors.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { - findPath, - tileToWorld, - worldToScreen, - worldToTile, - type Container, - type CutsceneStep, - type IsometricTileMap, - type Vec2 -} from '@rpg/engine'; - -/** - * Актёры кат-сцен: ходок по A*-пути для вьюхи NPC и фабрики шагов. - * Мир на время сцены на паузе — ходок без боя/расталкивания, тикается - * сценой независимо от раннера (исполнитель шага until). - */ - -/** Скорость ходьбы актёра по умолчанию (юниты/сек) — чуть медленнее героя. */ -const WALKER_SPEED = 1.4; - -/** Таймаут ожидания ходьбы по умолчанию (сек) — страховка скипа/зависания. */ -const WALK_TIMEOUT = 30; - -/** - * Ходок: ведёт вьюху по пути A* в мировых юнитах, синхронизирует вью - * (округление до px — единственная точка мир→экран, как у героя). - */ -export class ActorWalker { - private pos: Vec2; - private waypoints: Vec2[] = []; - - constructor( - private map: IsometricTileMap, - private view: Container, - startTile: { x: number; y: number }, - /** Смена тайла (для depth-слоя сцены). */ - private onTile?: (tile: Vec2) => void, - private speed = WALKER_SPEED - ) { - this.pos = tileToWorld(startTile.x, startTile.y); - } - - /** Идёт ли сейчас (шаг until должен ждать). */ - get done(): boolean { - return this.waypoints.length === 0; - } - - get position(): Vec2 { - return { x: this.pos.x, y: this.pos.y }; - } - - /** Построить путь к тайлу и пойти. Непроходимый тайл — остаётся на месте. */ - walkTo(tile: { x: number; y: number }): void { - const from = worldToTileClamped(this.pos, this.map); - const path = findPath(this.map, from, tile, false); - this.waypoints = path ? path.map((t) => tileToWorld(t.x, t.y)) : []; - } - - update(dt: number): void { - if (this.waypoints.length === 0) return; - const target = this.waypoints[0]!; - const dx = target.x - this.pos.x; - const dy = target.y - this.pos.y; - const dist = Math.hypot(dx, dy); - const step = this.speed * dt; - let crossedTile = false; - if (dist <= step) { - this.pos = { x: target.x, y: target.y }; - this.waypoints.shift(); - crossedTile = true; - } else { - this.pos = { x: this.pos.x + (dx / dist) * step, y: this.pos.y + (dy / dist) * step }; - } - this.syncView(); - if (crossedTile) this.onTile?.(worldToTileClamped(this.pos, this.map)); - } - - /** Вью-позиция: округление до px (вьюха лежит в worldRoot — мировые px). */ - private syncView(): void { - const s = worldToScreen(this.pos.x, this.pos.y); - this.view.position.set(Math.round(s.x), Math.round(s.y)); - } -} - -/** Тайл под позицией (вне карты — ближайший краевой, как у героя). */ -function worldToTileClamped(pos: Vec2, map: IsometricTileMap): Vec2 { - const w = map.data.width; - const h = map.data.height; - const t = worldToTile(pos.x, pos.y, w, h); - return ( - t ?? { - x: Math.min(w - 1, Math.max(0, Math.floor(pos.x))), - y: Math.min(h - 1, Math.max(0, Math.floor(pos.y))) - } - ); -} - -/** Шаги «актёр идёт к тайлу»: запуск + ожидание прихода (со страховкой). */ -export function npcWalkSteps(walker: ActorWalker, tile: Vec2, timeout = WALK_TIMEOUT): CutsceneStep[] { - return [ - { kind: 'call', fn: () => walker.walkTo(tile) }, - { kind: 'until', predicate: () => walker.done, label: `актёр к ${tile.x},${tile.y}`, timeout } - ]; -} - -/** Шаг «вьюха плавно уходит в мировую точку» (глайд без пути — по прямой). */ -export function npcMoveView(view: Container, toWorld: Vec2, seconds: number): CutsceneStep { - const p = worldToScreen(toWorld.x, toWorld.y); - return { kind: 'moveView', view, x: p.x, y: p.y, seconds }; -} \ No newline at end of file diff --git a/apps/game/src/systems/fauna/FaunaSystem.ts b/apps/game/src/systems/fauna/FaunaSystem.ts deleted file mode 100644 index bfe0a3c..0000000 --- a/apps/game/src/systems/fauna/FaunaSystem.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { - Container, - Sprite, - SpriteAnimator, - StateMachine, - Texture, - moveTowardsW, - worldDist, - worldToScreen, - gridOf, - type EventBus, - type Grid, - type IsoDepthLayer, - type SceneRegistry, - type TileMapData, - type Vec2 -} from '@rpg/engine'; - -/** - * Пейзажная фауна (витрина StateMachine вне боя): безгласный олень. - * Стоит, медленно бродит по соседним тайлам; на громкий звон рядом - * подходит к источнику и стоит у колокольчиков. Не боевой. - */ - -/** Радиус слуха на звон (мировые юниты). */ -const HEAR_RADIUS = 7; -/** Скорость брожения / подхода (юниты/сек). */ -const WANDER_SPEED = 0.35; -const APPROACH_SPEED = 0.7; -/** Радиус тела оленя (юниты) — для телесных коллизий. */ -const FAUNA_RADIUS = 0.4; - -interface FaunaEntity { - /** Позиция (ноги) в мировых юнитах. */ - pos: Vec2; - machine: StateMachine; - /** Куда идём (если идём). */ - dest: Vec2 | null; - view: Container; - sprite: Sprite; - animator: SpriteAnimator; - /** Пауза перед следующим шагом (idle). */ - pause: number; -} - -export class FaunaSystem { - private entities: FaunaEntity[] = []; - private rng: () => number; - private off: () => void; - /** Проходимость карты (blocked-иды + пропы) — один адаптер на систему. */ - private grid: Grid; - - constructor( - private actors: IsoDepthLayer, - events: EventBus, - data: TileMapData, - spawns: Vec2[], - /** Реестр сцены (опция): олени регистрируются для «кто рядом». */ - private registry?: SceneRegistry, - seed = 20260908 - ) { - this.grid = gridOf(data); - // Детерминированный ГПСЧ — брожение одинаково между запусками. - let s = seed; - this.rng = () => { - s = (s + 0x6d2b79f5) | 0; - let t = Math.imul(s ^ (s >>> 15), 1 | s); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; - for (const p of spawns) this.spawn(p); - // Громкий звон рядом — олень подходит к источнику и стоит у колокольчиков. - this.off = events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => { - for (const e of this.entities) { - if (worldDist(e.pos, origin) <= HEAR_RADIUS) { - e.dest = { ...origin }; - e.machine.change('approach'); - // Олень, услышав звон, вскрикивает — звук вешает AudioSystem. - events.emit('fauna:startle', { pos: { ...e.pos } }); - } - } - }); - } - - /** Привязать кадры (вызывается сценой после конструктора). */ - setFrames(frames: [Texture, Texture]): void { - for (const e of this.entities) { - e.animator.setClipFrames('walk', frames); - e.animator.showStep(0); - } - } - - /** Заспавнить оленя в точке (юниты). */ - private spawn(pos: Vec2): void { - const view = new Container(); - const sprite = new Sprite(); - sprite.anchor.set(0.5, 1); - view.addChild(sprite); - const sp = worldToScreen(pos.x, pos.y); - view.position.set(sp.x, sp.y); - this.actors.add(view, Math.floor(pos.x), Math.floor(pos.y)); - - const e: FaunaEntity = { - pos: { ...pos }, - machine: new StateMachine(), - dest: null, - view, - sprite, - animator: new SpriteAnimator(sprite, { walk: { frames: [sprite.texture, sprite.texture], fps: 3 } }), - pause: 0 - }; - - // Стоит: через паузу делает шаг к соседнему тайлу. - e.machine.add('idle', { - update: () => { - if (e.machine.time >= e.pause) this.startWander(e); - } - }); - // Бродит: медленно к случайной соседней точке. - e.machine.add('wander', { - update: (dt) => this.stepTo(e, dt, WANDER_SPEED, 'idle') - }); - // Подходит на звон: быстрее, к источнику звука. - e.machine.add('approach', { - update: (dt) => this.stepTo(e, dt, APPROACH_SPEED, 'idle') - }); - e.machine.change('idle'); - e.pause = 1.5 + this.rng() * 3; - this.entities.push(e); - this.registry?.add({ - id: `fauna:${this.entities.length - 1}`, - kind: 'fauna', - pos: e.pos, - radius: FAUNA_RADIUS - }); - } - - /** Начать шаг к случайной соседней проходимой точке. */ - private startWander(e: FaunaEntity): void { - const dirs = [ - [1, 0], - [-1, 0], - [0, 1], - [0, -1] - ]; - const options = dirs - .map(([dx, dy]) => ({ x: e.pos.x + dx, y: e.pos.y + dy })) - .filter((p) => this.walkable(p)); - if (options.length === 0) { - e.pause = 2; - return; - } - e.dest = options[Math.floor(this.rng() * options.length)]!; - e.pause = 1.5 + this.rng() * 3; - e.machine.change('wander'); - } - - /** Движение к dest; по приходе — переход в idle. */ - private stepTo(e: FaunaEntity, dt: number, speed: number, back: string): void { - if (!e.dest) { - e.machine.change(back); - return; - } - e.pos = moveTowardsW(e.pos, e.dest, speed * dt); - // Прибытие с eps: расталкивание тел могло сместить существо. - if (Math.abs(e.pos.x - e.dest.x) < 1e-4 && Math.abs(e.pos.y - e.dest.y) < 1e-4) { - e.pos = { x: e.dest.x, y: e.dest.y }; // снап — тайл без дрейфа - e.dest = null; - e.machine.change(back); - } - } - - private walkable(p: Vec2): boolean { - const tx = Math.floor(p.x); - const ty = Math.floor(p.y); - return this.grid.isWalkable(tx, ty); - } - - update(dt: number): void { - for (const [i, e] of this.entities.entries()) { - e.machine.update(dt); - // Покадровая анимация: на ходу play, в покое — стоп-кадр 0. - if (e.dest) { - e.animator.resume(); - e.animator.play('walk'); - } else { - e.animator.showStep(0); - } - e.animator.update(dt); - this.registry?.move(`fauna:${i}`, e.pos); - const sp = worldToScreen(e.pos.x, e.pos.y); - e.view.position.set(sp.x, sp.y); - this.actors.setDepth(e.view, Math.floor(e.pos.x), Math.floor(e.pos.y)); - } - } - - exit(): void { - this.off?.(); - for (const e of this.entities) { - this.actors.removeChild(e.view); - e.view.destroy({ children: true }); - } - this.entities = []; - } -} \ No newline at end of file diff --git a/apps/game/src/systems/quest/FlowerQuest.ts b/apps/game/src/systems/quest/FlowerQuest.ts deleted file mode 100644 index 19b0666..0000000 --- a/apps/game/src/systems/quest/FlowerQuest.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { CutsceneRunner, IsometricTileMap, tileToWorld, worldToScreen, type Vec2 } from '@rpg/engine'; -import { Game } from '../../Game'; -import { VARS } from '../../data/ids'; -import { TILES } from '../../data/map'; -import { QUEST_FLOWERS, QUEST_PLANT_TILES, QUEST_TOWER_GREEN, QUEST_TOWER_TILE } from '../../data/quests'; -import { playSfx } from '../../data/sfxSpecs'; - -/** - * Квест «Три цветка» (docs/world.md, акт 1): сбор лунных колокольчиков, - * посадка поляны у тропы и кат-сцена сдачи (колокол башни, зелень). - * Изменения тайлов идут через map.setTile с поддержанием индекса - * layerTilePos — свет по тайлам-источникам остаётся живым. - */ -export class FlowerQuest { - constructor( - private game: Game, - private map: IsometricTileMap, - /** Позиции тайлов-источников по id (свет/слои): меняем при setTile. */ - private layerTilePos: Map, - /** Экранный центр ромба тайла (с Origin карты). */ - private tileCenter: (tx: number, ty: number) => { x: number; y: number }, - /** Всплеск пеплинок в точке (CombatViews.hitBurst). */ - private burst: (s: { x: number; y: number }) => void, - private showToast: (text: string) => void, - private cutscene: CutsceneRunner - ) {} - - /** id тайла карты. */ - tileId(x: number, y: number): number { - return this.map.data.tiles[y * this.map.data.width + x]; - } - - /** Смена тайла с поддержанием индекса layerTilePos (свет по тайлам живой). */ - setTileTracked(x: number, y: number, id: number): void { - const prev = this.tileId(x, y); - this.map.setTile(x, y, id); - const from = this.layerTilePos.get(prev); - if (from) { - const i = from.findIndex((p) => p.x === x && p.y === y); - if (i >= 0) from.splice(i, 1); - if (from.length === 0) this.layerTilePos.delete(prev); - } - const list = this.layerTilePos.get(id) ?? []; - list.push({ x, y }); - this.layerTilePos.set(id, list); - } - - /** Сбор лунного колокольчика: тайл зеленеет, цветок — в сумку, прогресс — в vars. */ - collectFlower(x: number, y: number): void { - this.setTileTracked(x, y, TILES.GRASS); - this.game.inventory.add('bellflower'); - const n = this.game.state.getNumber(VARS.flowers) + 1; - this.game.state.setVar(VARS.flowers, n); - this.game.engine.events.emit('quest:flower', { n }); - playSfx(this.game.audio, 'sfx/bell_hit', 0.6); - this.burst(this.tileCenter(x, y)); - this.showToast(`Лунный колокольчик (${Math.min(n, QUEST_FLOWERS)}/${QUEST_FLOWERS})`); - } - - /** Посадка цветов у тропы: поляна гудит колокольчиками и разрастается. */ - plantFlowers(): void { - const left = this.game.state.getNumber(VARS.flowers) - QUEST_FLOWERS; - this.game.state.setVar(VARS.flowers, Math.max(0, left)); - for (const [tx, ty] of QUEST_PLANT_TILES) { - this.setTileTracked(tx, ty, TILES.BELLFLOWER); - this.burst(this.tileCenter(tx, ty)); - } - // Пересев: поляна расползается на соседние тайлы (якорь «надежда растёт»). - for (const [tx, ty] of QUEST_PLANT_TILES) { - for (const [dx, dy] of [ - [1, 0], - [-1, 0], - [0, 1], - [0, -1] - ]) { - const id = this.tileId(tx + dx, ty + dy); - if (id === TILES.GRASS || id === TILES.ASH) this.setTileTracked(tx + dx, ty + dy, TILES.BELLFLOWER); - } - } - void this.game.audio.play('sfx/bell_low', 0.8); - this.showToast('Цветы в земле. Поляна гудит.'); - } - - /** Кат-сцена сдачи: посадка, камера к башне, удар колокола, зелень — финал акта 1. */ - startHandInCutscene(): void { - // Башня Звенца: тайл в данных, камере нужны юниты. - const tower = tileToWorld(QUEST_TOWER_TILE.x, QUEST_TOWER_TILE.y); - const s = worldToScreen(tower.x, tower.y); - this.cutscene.play([ - // Пересев: поляна гудит, герой стоит. - { kind: 'call', fn: () => this.plantFlowers(), seconds: 0.8 }, - // Камера уходит к башне. - { kind: 'cameraMove', x: tower.x, y: tower.y, seconds: 1.2, camera: this.game.camera }, - // Удар колокола: низкий звон, тряска, кольцо резонанса. - { - kind: 'burst', - fn: () => { - void this.game.audio.play('sfx/bell_low', 1); - this.game.camera.addShake(2.5, 0.4); - this.burst(s); - }, - seconds: 1 - }, - // Зелень поднимается у подножия башни (визуальный финал акта). - { - kind: 'burst', - fn: () => { - for (const [tx, ty] of QUEST_TOWER_GREEN) { - const id = this.tileId(tx, ty); - if (id === TILES.GRASS || id === TILES.ASH) { - this.setTileTracked(tx, ty, TILES.BELLFLOWER); - this.burst(this.tileCenter(tx, ty)); - } - } - }, - seconds: 1.2 - }, - // Тост-финал акта 1. - { kind: 'call', fn: () => this.showToast('Пепел отступил у тропы. Машина дышит тише... но дышит.') } - ]); - } -} \ No newline at end of file diff --git a/apps/game/src/systems/quest/ResonatorQuest.ts b/apps/game/src/systems/quest/ResonatorQuest.ts deleted file mode 100644 index 82f2477..0000000 --- a/apps/game/src/systems/quest/ResonatorQuest.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { CutsceneRunner, tileToWorld, worldToScreen } from '@rpg/engine'; -import { Game } from '../../Game'; -import { FLAGS, type FlagId } from '../../data/ids'; -import { QUEST_TONE_TILES } from '../../data/quests'; - -/** - * Квест «Голос для резонатора» (docs/world.md, акт 2): звон у деревьев- - * механизмов рощи (интерактивы, тон — варом через реакции) и кат-сцена - * настройки — резонатор ловит собранные тоны, роща отвечает. - */ -export class ResonatorQuest { - constructor( - private game: Game, - /** Экранный центр ромба тайла (с Origin карты). */ - private tileCenter: (tx: number, ty: number) => { x: number; y: number }, - /** Всплеск пеплинок в точке (CombatViews.hitBurst). */ - private burst: (s: { x: number; y: number }) => void, - private showToast: (text: string) => void, - private cutscene: CutsceneRunner - ) {} - - /** Кат-сцена настройки: камера к резонатору Клинта, звон, ответ рощи. */ - startTuneCutscene(): void { - // Резонатор у поляны Клинта (интерактив clint_resonator). - const at = tileToWorld(12, 8); - const s = worldToScreen(at.x, at.y); - this.cutscene.play([ - // Камера подходит и наезжает: настройка — крупный план. - { kind: 'cameraMove', x: at.x, y: at.y, seconds: 1, camera: this.game.camera }, - { kind: 'cameraZoom', camera: this.game.camera, z: 2, seconds: 0.8 }, - // Резонатор ловит тон: звон, тряска, всплеск. - { - kind: 'burst', - fn: () => { - void this.game.audio.play('sfx/bell_low', 1); - this.game.camera.addShake(2, 0.4); - this.burst(s); - }, - seconds: 1 - }, - // Роща отвечает: прозвеневшие деревья вспыхивают в такт. - { - kind: 'burst', - fn: () => { - QUEST_TONE_TILES.forEach(([tx, ty], i) => { - if (!this.game.state.hasFlag(FLAGS[`tone_tree_${i + 1}` as FlagId])) return; - this.burst(this.tileCenter(tx, ty)); - }); - }, - seconds: 1.2 - }, - // Отъезд и тост-финал квеста. - { kind: 'cameraZoom', camera: this.game.camera, z: 1, seconds: 0.6 }, - { kind: 'call', fn: () => this.showToast('Резонатор держит тон. Роща слышит.') } - ]); - } -} \ No newline at end of file diff --git a/apps/game/src/testing/combatWorld.ts b/apps/game/src/testing/combatWorld.ts deleted file mode 100644 index d5ec8d1..0000000 --- a/apps/game/src/testing/combatWorld.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { EventBus, IsometricTileMap } from '@rpg/engine'; -import { CombatWorld } from '../systems/combat/CombatWorld'; -import { type EnemyBrain, type EnemySenses } from '../systems/combat/EnemyBrain'; - -/** - * Тестовые фикстуры боевого мира (без Pixi и браузера). Импортировать только - * из *.test.ts — в прод-бандл эти файлы не попадают. - */ - -/** Фейковая карта w×h травы, проходима всюду (по умолчанию 20×20). */ -export function fakeMap(w = 20, h = 20): IsometricTileMap { - const data = { - width: w, - height: h, - tiles: new Array(w * h).fill(0), - blocked: [], - tall: undefined - }; - // width/height наверху — контракт Grid для движковых коллизий. - return { width: w, height: h, data, isWalkable: () => true } as unknown as IsometricTileMap; -} - -/** Боевой мир над фейковой картой: события глушатся, герой в заданной точке. */ -export function fakeWorld(playerPos: { x: number; y: number }): CombatWorld { - return new CombatWorld({ - map: fakeMap(), - events: { emit: () => {} } as unknown as EventBus, - getPlayerPos: () => playerPos, - damagePlayer: () => {} - }); -} - -/** Тик ИИ на 1/60 с; возвращает последнее намерение. */ -export function run( - b: EnemyBrain, - seconds: number, - senses: EnemySenses -): ReturnType { - let intent: ReturnType = { type: 'idle' }; - for (let i = 0; i < Math.round(seconds * 60); i++) intent = b.update(1 / 60, senses); - return intent; -} - -/** Сенсоры «герой далеко и не виден» (для патрульных сценариев). */ -export function far(pos: { x: number; y: number }): EnemySenses { - return { - dist: 30, - dirToPlayer: { x: 1, y: 0 }, - pos, - canSee: false, - home: { x: 10.5, y: 7.5 }, - hpFraction: 1 - }; -} \ No newline at end of file diff --git a/apps/game/src/testing/gameState.ts b/apps/game/src/testing/gameState.ts deleted file mode 100644 index e548941..0000000 --- a/apps/game/src/testing/gameState.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { GameState } from '@rpg/engine'; - -/** - * Фейковый GameState: флаги и вар-числа в памяти (без DOM и сейвов). - * Импортировать только из *.test.ts. - */ -export function fakeState(): GameState { - const flags = new Set(); - const vars = new Map(); - return { - setFlag: (f: string) => void flags.add(f), - hasFlag: (f: string) => flags.has(f), - clearFlag: (f: string) => void flags.delete(f), - setVar: (k: string, v: number) => void vars.set(k, Number(v)), - getNumber: (k: string) => vars.get(k) ?? 0 - } as unknown as GameState; -} \ No newline at end of file diff --git a/apps/game/src/testing/maps.ts b/apps/game/src/testing/maps.ts deleted file mode 100644 index 1adb0f9..0000000 --- a/apps/game/src/testing/maps.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { TILES } from '../data/map'; - -/** Карта 10×10 травы с одной стеной (tall-объект) в центре — для LOS. */ -export function mapWithWall(): { - width: number; - height: number; - tiles: number[]; - tall: Record; -} { - const tiles = new Array(100).fill(TILES.GRASS); - tiles[5 * 10 + 5] = TILES.HOUSE; - return { width: 10, height: 10, tiles, tall: { [TILES.HOUSE]: { height: 1, ground: 0 } } }; -} \ No newline at end of file diff --git a/apps/game/tools/agent.mjs b/apps/game/tools/agent.mjs deleted file mode 100644 index 34f28ca..0000000 --- a/apps/game/tools/agent.mjs +++ /dev/null @@ -1,164 +0,0 @@ -/** - * CLI-диспетчер агентных инструментов. Вывод по умолчанию — JSON - * (машинночитаемый), --pretty — для человека. - * - * node apps/game/tools/agent.mjs check [--only a,b] [--skip a,b] [--pretty] - * node apps/game/tools/agent.mjs run [--pretty] - * node apps/game/tools/agent.mjs snapshot [--new-game] [--out файл] [--pretty] - * node apps/game/tools/agent.mjs screenshot [--out файл] [--steps N] [--pretty] - * node apps/game/tools/agent.mjs dev [--port N] - */ -import { spawnSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { parseArgs, startDevServer, openGame } from './lib.mjs'; - -/** Корень репозитория (тулзы живут в apps/game/tools, скрипты npm — в корне). */ -const ROOT = new URL('../../..', import.meta.url); - -const args = parseArgs(); -const cmd = args._[0] ?? 'check'; - -/** Один «серверный» шаг проверки: результат + ms + детали (fn может быть async). */ -async function stepResult(name, fn) { - const t0 = Date.now(); - try { - const details = await fn(); - return { name, ok: true, ms: Date.now() - t0, details: details ?? null }; - } catch (err) { - return { name, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) }; - } -} - -/** Существующие .map равны генераторам (gen.test.ts сравнивает сиды с файлами). */ -const mapsFresh = () => stepResult('maps:fresh', () => { - const gen = spawnSync('npx', ['vitest', 'run', 'apps/game/tools/maps/gen.test.ts'], { encoding: 'utf8', cwd: ROOT.pathname }); - if (gen.status !== 0) throw new Error(gen.stdout.split('\n').filter((l) => l.includes('FAIL')).join('; ') || 'gen.test.ts не зелёный'); - return 'файлы карт равны генераторам'; -}); - -/** Браузерная проверка invariants — сценарий checks/agent-invariants.mjs (единый источник). */ -const agentInvariants = () => runScenario('agent:invariants', 'apps/game/tools/checks/agent-invariants.mjs'); - -/** Сценарий из apps/game/tools/checks/ как шаг check: exit code -> результат. */ -async function runScenario(name, file) { - return stepResult(name, async () => { - const mod = await import(pathToFileURL(resolve(ROOT.pathname, file))); - const code = await mod.default({ args, pretty: args.pretty }); - if (code !== 0) throw new Error(`сценарий ${file} не зелёный (exit ${code})`); - return null; - }); -} - -const commands = { - /** Полный прогон проверок. */ - async check() { - const all = [ - { name: 'typecheck', run: () => stepResult('typecheck', () => { - const r = spawnSync('npm', ['run', 'typecheck'], { encoding: 'utf8', cwd: ROOT.pathname }); - if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('error TS')).join('\n')); - return null; - }) }, - { name: 'guard:boundary', run: () => stepResult('guard:boundary', () => { - const r = spawnSync('npm', ['run', 'guard'], { encoding: 'utf8', cwd: ROOT.pathname }); - if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('"rule"') || l.includes('"ok": false')).join('\n') || 'граница движок/игра нарушена'); - return null; - }) }, - { name: 'tests', run: () => stepResult('tests', () => { - const r = spawnSync('npm', ['test'], { encoding: 'utf8', cwd: ROOT.pathname }); - if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('FAIL')).join('; ') || 'тесты не зелёные'); - return null; - }) }, - { name: 'maps:fresh', run: mapsFresh }, - { name: 'agent:invariants', run: agentInvariants }, - { name: 'transitions', run: () => runScenario('transitions', 'apps/game/tools/checks/transitions.mjs') }, - { name: 'interact', run: () => runScenario('interact', 'apps/game/tools/checks/interact.mjs') }, - { name: 'interact-world', run: () => runScenario('interact-world', 'apps/game/tools/checks/interact-world.mjs') }, - { name: 'quest-bells', run: () => runScenario('quest-bells', 'apps/game/tools/checks/quest-bells.mjs') }, - { name: 'lighting', run: () => runScenario('lighting', 'apps/game/tools/checks/lighting.mjs') }, - { name: 'time', run: () => runScenario('time', 'apps/game/tools/checks/time.mjs') }, - { name: 'collision', run: () => runScenario('collision', 'apps/game/tools/checks/collision.mjs') }, - { name: 'ai', run: () => runScenario('ai', 'apps/game/tools/checks/ai.mjs') }, - { name: 'audio', run: () => runScenario('audio', 'apps/game/tools/checks/audio.mjs') }, - { name: 'synth', run: () => runScenario('synth', 'apps/game/tools/checks/synth.mjs') }, - { name: 'music', run: () => runScenario('music', 'apps/game/tools/checks/music.mjs') } - ]; - const only = args.only ?? all.map((p) => p.name); - const skip = args.skip ?? []; - const report = { name: 'check', ok: true, ms: 0, results: [] }; - const t0 = Date.now(); - for (const probe of all) { - if (!only.includes(probe.name) || skip.includes(probe.name)) continue; - let res; - try { - res = await probe.run(); - } catch (err) { - res = { name: probe.name, ok: false, ms: 0, details: String(err?.message ?? err) }; - } - report.results.push(res); - report.ok = report.ok && res.ok; - } - report.ms = Date.now() - t0; - console.log(args.pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report)); - return report.ok ? 0 : 1; - }, - - /** Один сценарий из apps/game/tools/checks/. */ - async run() { - const file = args._[1]; - if (!file) throw new Error('укажи путь к сценарию: node apps/game/tools/agent.mjs run apps/game/tools/checks/xxx.mjs'); - const mod = await import(pathToFileURL(file).href); - if (typeof mod.default !== 'function') throw new Error(`${file}: нет export default (async ({args}) => exitCode)`); - return mod.default({ args, pretty: args.pretty }); - }, - - /** Бенчмарк производительности (не входит в check — замер, а не гейт). */ - async perf() { - await runScenario('perf', 'apps/game/tools/checks/perf.mjs'); - return 0; - }, - - /** Снимок снапшота в файл/stdout. */ - async snapshot() { - const server = await startDevServer({ port: args.port ?? 5199 }); - try { - const ctx = await openGame({ url: server.url, newGame: args.newGame ?? false }); - const snap = await ctx.agent.snapshot(); - if (args.steps) await ctx.agent.step(args.steps); - if (args.out) writeFileSync(args.out, JSON.stringify(snap, null, 2)); - console.log(args.out ? `снапшот: ${args.out}` : JSON.stringify(snap, null, 2)); - await ctx.browser.close(); - return 0; - } finally { server.stop(); } - }, - - /** Скриншот + консоль (для визуальных проверок, когда снапшота мало). */ - async screenshot() { - const server = await startDevServer({ port: args.port ?? 5199 }); - try { - const ctx = await openGame({ url: server.url, newGame: args.newGame ?? true }); - if (args.steps) await ctx.agent.step(args.steps); - const out = args.out ?? '/tmp/rpg_agent.png'; - await ctx.agent.screenshot(out); - console.log(JSON.stringify({ screenshot: out, logs: ctx.consoleLogs.slice(-10) })); - await ctx.browser.close(); - return 0; - } finally { server.stop(); } - }, - - /** Dev-сервер + URL для ручной работы (не гасит сервер до Ctrl+C). */ - async dev() { - const server = await startDevServer({ port: args.port ?? 5199 }); - console.log(`dev: ${server.url}`); - process.on('SIGINT', () => { server.stop(); process.exit(0); }); - await new Promise(() => {}); - } -}; - -const fn = commands[cmd]; -if (!fn) { - console.error(`Неизвестная команда «${cmd}». Доступны: ${Object.keys(commands).join(', ')}`); - process.exit(2); -} -process.exit(await fn()); \ No newline at end of file diff --git a/apps/game/tools/aiart/__tests__/recolor.test.mjs b/apps/game/tools/aiart/__tests__/recolor.test.mjs deleted file mode 100644 index d67eb52..0000000 --- a/apps/game/tools/aiart/__tests__/recolor.test.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { remap, parseColor } from '../recolor.mjs'; - -/** Спрайт 2×2: два пикселя цвета from (alpha 255), один прозрачный, один другой. */ -const sprite = (from) => { - const rgba = new Uint8Array(2 * 2 * 4); - rgba.set([...from, 255], 0); - rgba.set([1, 2, 3, 255], 8); - return rgba; -}; - -describe('remap', () => { - it('точная замена RGB, alpha и прочие цвета не тронуты', () => { - const out = remap(sprite([58, 40, 34]), [[[58, 40, 34], [58, 67, 86]]]); - expect([...out.slice(0, 4)]).toEqual([58, 67, 86, 255]); - expect([...out.slice(8, 12)]).toEqual([1, 2, 3, 255]); // чужой цвет не тронут - expect(out[4 + 3]).toBe(0); // прозрачный остался прозрачным - }); -}); - -describe('parseColor', () => { - it('ключ палитры и #hex дают один и тот же RGB', () => { - expect(parseColor('C0')).toEqual([0x3a, 0x43, 0x56]); - expect(parseColor('#3a4356')).toEqual([0x3a, 0x43, 0x56]); - }); - it('неизвестный ключ — ошибка', () => { - expect(() => parseColor('Z9')).toThrow(); - }); -}); \ No newline at end of file diff --git a/apps/game/tools/aiart/atlas-manifest.json b/apps/game/tools/aiart/atlas-manifest.json deleted file mode 100644 index 6fff155..0000000 --- a/apps/game/tools/aiart/atlas-manifest.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "characters": [ - { - "id": "bellringer", - "prompt": "a bell ringer wearing a dark blue cloak and a wide brimmed hat, holding a small golden hand bell, pixel art character sprite, plain white background", - "seed": 42, - "views": ["FSS", "BSS", "LSS"], - "frameSets": { - "down": { "view": "FSS", "poses": [0, 1], "bob": true }, - "up": { "view": "BSS", "poses": [0, 1], "bob": true }, - "side": { "view": "LSS", "poses": [0, 1], "bob": true } - }, - "frameSize": [16, 24], - "palette": "game", - "remap": { - "R0": "C0", - "R1": "C0", - "R2": "C1", - "G1": "C0", - "G2": "C1", - "G2H": "C1", - "F0": "B0" - }, - "crop": { - "FSS": [[8, 150, 122, 230], [136, 150, 122, 230], [264, 150, 122, 230], [392, 150, 122, 230]], - "BSS": [[8, 150, 122, 230], [136, 150, 122, 230], [264, 150, 122, 230], [392, 150, 122, 230]], - "LSS": [[8, 150, 122, 230], [136, 150, 122, 230], [264, 150, 122, 230], [392, 150, 122, 230]] - }, - "target": "chars/hero_sheet.png", - "status": "draft" - } - ] -} \ No newline at end of file diff --git a/apps/game/tools/aiart/atlas.mjs b/apps/game/tools/aiart/atlas.mjs deleted file mode 100644 index 61e67f4..0000000 --- a/apps/game/tools/aiart/atlas.mjs +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Пайплайн генерации атласов персонажей из AI-листов (работа B, см. docs/plan.md): - * gen — AI-листы ракурсов (sd_sheet.py, возобновляемо, ~6.6 мин/вид) - * build — листы → кадры 16×24 → атлас + обзорный лист (build//) - * promote — копия атласа в assets/ (только при status: approved) - * - * Всё манифест-драйвенно: atlas-manifest.json (промпт, seed, ракурсы, палитра). - * AI-вывод в assets/ напрямую не попадает — только через promote. - */ -import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'; -import { join, dirname, basename } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { spawnSync } from 'node:child_process'; -import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; -import { findFigures, resizeRect, quantize } from '@rpg/engine/tools/imaging.mjs'; -import { buildAtlas } from '@rpg/engine/tools/atlas.mjs'; -import { squashFrame } from '@rpg/engine/tools/frames.mjs'; -import { remap, parseColor } from './recolor.mjs'; -import { PALETTE } from '../pixelart/palette.mjs'; - -const HERE = fileURLToPath(new URL('.', import.meta.url)); -const SHEETS = join(HERE, 'sheets'); -const FRAMES = join(HERE, 'frames'); -const BUILD = join(HERE, 'build'); -const MANIFEST = join(HERE, 'atlas-manifest.json'); - -// ---------- контекст персонажа ---------- - -function loadChar(id) { - const { characters } = JSON.parse(readFileSync(MANIFEST, 'utf8')); - const entry = characters.find((c) => c.id === id); - if (!entry) throw new Error(`персонаж «${id}» не найден в atlas-manifest.json`); - return entry; -} - -/** Палитра из манифеста: "game" — 32 цвета игры; иначе palettes/.json (hex). */ -function loadPalette(entry) { - const hexes = entry.palette === 'game' - ? Object.values(PALETTE) - : JSON.parse(readFileSync(join(HERE, 'palettes', `${entry.palette}.json`), 'utf8')); - return hexes.map((hex) => { - const n = parseInt(hex.slice(1), 16); - return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; - }); -} - -// ---------- gen: AI-листы ---------- - -function cmdGen(char, { force = false } = {}) { - const py = process.env.RPG_SD_PYTHON ?? `${process.env.HOME}/.cache/rpg-ai/venv-pxgpt/bin/python`; - const dir = join(SHEETS, char.id); - mkdirSync(dir, { recursive: true }); - for (const view of char.views) { - const out = join(dir, `${view}_${char.seed}.png`); - if (existsSync(out) && !force) { - console.log(`[skip] ${out} уже есть (—force для перегенерации)`); - continue; - } - console.log(`[gen] ${view} (seed ${char.seed}) — это долго, ~6.6 мин`); - const r = spawnSync(py, [join(HERE, 'sd_sheet.py'), view, String(char.seed), dir, char.prompt], { stdio: 'inherit' }); - if (r.status !== 0) throw new Error(`sd_sheet.py упал на ${view} (код ${r.status})`); - } -} - -// ---------- build: кадры + атлас ---------- - -/** Лист ракурса: фигуры — crop-оверрайды из манифеста (rect[]) или автодетект. */ -function sheetFigures(char, view) { - const sheet = decodePng(readFileSync(join(SHEETS, char.id, `${view}_${char.seed}.png`))); - const override = char.crop?.[view]; - if (override) { - return { sheet, figures: override.map(([x, y, w, h]) => ({ x, y, w, h })) }; - } - return { sheet, figures: findFigures(sheet, { maxCount: 4 }) }; -} - -/** Обзорный лист: кадры ×scale в сетке (для апрува человеком). */ -function frameSheet(frames, fw, fh, scale = 4) { - const pad = 6, cellW = fw * scale, cellH = fh * scale; - const cols = 4, rows = Math.ceil(frames.length / cols); - const W = cols * (cellW + pad) + pad, H = rows * (cellH + pad) + pad; - const out = new Uint8Array(W * H * 4); - for (let p = 0; p < out.length; p += 4) { out[p] = 30; out[p + 1] = 32; out[p + 2] = 38; out[p + 3] = 255; } - frames.forEach((rgba, i) => { - const ox = pad + (i % cols) * (cellW + pad), oy = pad + ((i / cols) | 0) * (cellH + pad); - for (let y = 0; y < cellH; y++) for (let x = 0; x < cellW; x++) { - const si = (((y / scale) | 0) * fw + ((x / scale) | 0)) * 4; - if (rgba[si + 3] === 0) continue; - out.set(rgba.subarray(si, si + 4), ((oy + y) * W + ox + x) * 4); - } - }); - return encodePng(W, H, Buffer.from(out)); -} - -function cmdBuild(char) { - const pal = loadPalette(char); - const [fw, fh] = char.frameSize; - // Ремап «поплавших» цветов генерации — после квантизации (точные цвета палитры). - const pairs = Object.entries(char.remap ?? {}).map(([from, to]) => [parseColor(from), parseColor(to)]); - const frames = [], names = [], animations = {}; - mkdirSync(join(FRAMES, char.id), { recursive: true }); - for (const [dirName, set] of Object.entries(char.frameSets)) { - if (!existsSync(join(SHEETS, char.id, `${set.view}_${char.seed}.png`))) { - console.log(`[skip] ${set.view}: лист ещё не сгенерирован (atlas.mjs gen ${char.id})`); - continue; - } - const { sheet, figures } = sheetFigures(char, set.view); - if (Math.max(...set.poses) >= figures.length) { - throw new Error(`${set.view}: найдено фигур ${figures.length}, поза ${Math.max(...set.poses)} не выбирается (см. crop в манифесте)`); - } - // Приседание: 2-й кадр — производный от первого (сдвиг вниз на 1px, - // верх прозрачный) — различимая ходьба из одинаковых стоячих поз; - // вторая поза листа тогда не используется. - const usedPoses = set.bob ? [set.poses[0]] : set.poses; - const setFirst = frames.length; - for (const pose of usedPoses) { - let rgba = quantize(resizeRect(sheet, figures[pose], fw, fh), pal); - if (pairs.length) rgba = remap(rgba, pairs); - const n = frames.length + 1; - frames.push(rgba); - names.push(`${char.id}_${dirName}_${n}`); - writeFileSync(join(FRAMES, char.id, `${names[names.length - 1]}.png`), encodePng(fw, fh, Buffer.from(rgba))); - if (set.bob) { - const bob = squashFrame(rgba, fw, fh); - frames.push(bob); - names.push(`${char.id}_${dirName}_${frames.length}`); - writeFileSync(join(FRAMES, char.id, `${names[names.length - 1]}.png`), encodePng(fw, fh, Buffer.from(bob))); - } - } - animations[`${char.id}_${dirName}`] = frames - .slice(setFirst) - .map((_, i) => setFirst + i); - } - if (!frames.length) throw new Error('ни одного ракурса нет — сначала atlas.mjs gen'); - const outDir = join(BUILD, char.id); - mkdirSync(dirname(join(outDir, char.target)), { recursive: true }); - // meta.image — имя PNG рядом с JSON (Pixi резолвит его относительно URL - // атласа; путь с каталогом даст /chars/chars/… → 404 в игре). - const { png, json } = buildAtlas(frames, names, { - frameW: fw, frameH: fh, image: basename(char.target), animations - }); - writeFileSync(join(outDir, char.target), png); - writeFileSync(join(outDir, char.target.replace(/\.png$/, '.json')), JSON.stringify(json, null, 2)); - writeFileSync(join(outDir, '_review.png'), frameSheet(frames, fw, fh)); - console.log(`[build] ${frames.length} кадров -> ${outDir} (обзорный лист _review.png, статус: ${char.status})`); -} - -// ---------- promote: в assets после апрува ---------- - -function cmdPromote(char) { - if (char.status !== 'approved') { - throw new Error(`статус «${char.status}» — promote только при approved (правь atlas-manifest.json после апрува обзорного листа)`); - } - const outDir = join(BUILD, char.id); - const dst = join(HERE, '..', '..', 'assets'); - const jsonTarget = char.target.replace(/\.png$/, '.json'); - copyFileSync(join(outDir, char.target), join(dst, char.target)); - copyFileSync(join(outDir, jsonTarget), join(dst, jsonTarget)); - console.log(`[promote] ${char.target} (+ JSON) -> assets. Внимание: npm run art перегенерирует кодовый атлас поверх.`); -} - -// ---------- CLI ---------- - -const [, , cmd, id, ...rest] = process.argv; -if (!cmd || !id) { - console.log('Использование: node atlas.mjs [--force]'); - process.exit(1); -} -const char = loadChar(id); -const handlers = { gen: cmdGen, build: cmdBuild, promote: cmdPromote }; -if (!handlers[cmd]) throw new Error(`неизвестная команда «${cmd}»`); -handlers[cmd](char, { force: rest.includes('--force') }); \ No newline at end of file diff --git a/apps/game/tools/aiart/build/bellringer/_review.png b/apps/game/tools/aiart/build/bellringer/_review.png deleted file mode 100644 index 85e5f43..0000000 --- a/apps/game/tools/aiart/build/bellringer/_review.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.json b/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.json deleted file mode 100644 index 0c1cad6..0000000 --- a/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "frames": { - "bellringer_down_1": { - "frame": { - "x": 0, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "bellringer_down_2": { - "frame": { - "x": 16, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "bellringer_up_3": { - "frame": { - "x": 32, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "bellringer_up_4": { - "frame": { - "x": 48, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "bellringer_side_5": { - "frame": { - "x": 64, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - }, - "bellringer_side_6": { - "frame": { - "x": 80, - "y": 0, - "w": 16, - "h": 24 - }, - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 16, - "h": 24 - } - } - }, - "meta": { - "image": "hero_sheet.png", - "size": { - "w": 96, - "h": 24 - }, - "scale": 1 - } -} \ No newline at end of file diff --git a/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.png b/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.png deleted file mode 100644 index 7d4ddb3..0000000 --- a/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_down_1.png b/apps/game/tools/aiart/frames/bellringer/bellringer_down_1.png deleted file mode 100644 index 31a1ebe..0000000 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_down_1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_down_2.png b/apps/game/tools/aiart/frames/bellringer/bellringer_down_2.png deleted file mode 100644 index 44320ac..0000000 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_down_2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_side_1.png b/apps/game/tools/aiart/frames/bellringer/bellringer_side_1.png deleted file mode 100644 index a353c32..0000000 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_side_1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_side_2.png b/apps/game/tools/aiart/frames/bellringer/bellringer_side_2.png deleted file mode 100644 index 7473f83..0000000 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_side_2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_side_5.png b/apps/game/tools/aiart/frames/bellringer/bellringer_side_5.png deleted file mode 100644 index a353c32..0000000 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_side_5.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_side_6.png b/apps/game/tools/aiart/frames/bellringer/bellringer_side_6.png deleted file mode 100644 index 300f2fe..0000000 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_side_6.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_up_1.png b/apps/game/tools/aiart/frames/bellringer/bellringer_up_1.png deleted file mode 100644 index 14af22e..0000000 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_up_1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_up_2.png b/apps/game/tools/aiart/frames/bellringer/bellringer_up_2.png deleted file mode 100644 index afb427d..0000000 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_up_2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_up_3.png b/apps/game/tools/aiart/frames/bellringer/bellringer_up_3.png deleted file mode 100644 index 14af22e..0000000 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_up_3.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_up_4.png b/apps/game/tools/aiart/frames/bellringer/bellringer_up_4.png deleted file mode 100644 index 9426cda..0000000 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_up_4.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/gen_b4.py b/apps/game/tools/aiart/gen_b4.py deleted file mode 100644 index ce17662..0000000 --- a/apps/game/tools/aiart/gen_b4.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python3 -"""B4: батч AI-генерации кандидатов-плейсхолдеров (SD1.5, GPU fp16). - -Модель: Retro Diffusion (LostMedia/RetroDiffusion, Public Domain Mark 1.0) — -SD1.5-файнтюн пиксель-арта, слушает промпты одиночных объектов (проба 2026-09-09: -очаг/часы — отличные, мот/свиток читаются). Листовая модель B3 -(SD_PixelArt_SpriteSheet_Generator) рисует только 4 фигурки персонажей — -для объектов непригодна. RTX 3090 fp16: ~1.3 с/вид, семена бесплатны. - -Список объектов — LIST (id, промпт): интерактивы-плейсхолдеры (InteractableViews), -снаряд плевка и иконки сумки 8×8 (арт-библия §4). Слоты назначает post_b4.mjs. - -Запуск (интерпретатор — venv-aiaudio, там torch cu130): - ~/.cache/rpg-ai/venv-aiaudio/bin/python gen_b4.py # все объекты, SEEDS семян - ... gen_b4.py signpost hearth:7 # конкретные id[:seed] -Вывод: aiart/review-b4/raw/_.png (512×512 сырьё). -""" -import sys -import time -from pathlib import Path - -import torch -from diffusers import StableDiffusionPipeline - -MODEL_ID = 'LostMedia/RetroDiffusion' -STEPS = 20 -CFG = 7.0 -SEEDS = (1, 2, 3, 4) -NEG = ('blurry, photo, realistic, smooth gradients, text, watermark, ' - 'multiple objects, cropped, character, person, ' - 'warm background, brown background, orange background, sunset lighting') - -LIST = { - # --- интерактивы-плейсхолдеры (InteractableViews) --- - 'signpost': 'pixel art of a weathered wooden road signpost with an arrow board, dark wood', - 'mote': 'pixel art of a small mound of gray ash with a glowing warm ember inside', - 'tone_tree': 'pixel art of a dead tree trunk wrapped in black cables with small copper bells, on a plain pale gray background', - 'resonator': 'pixel art of a copper bell resonator machine on a rusty steel frame, on a plain pale gray background', - 'bell_rope': 'pixel art of a hanging bell pull rope with a wooden handle grip', - 'hearth': 'pixel art of a stone hearth fireplace with a warm fire inside', - 'note': 'pixel art of a small crumpled paper note lying flat on the ground', - 'counter': 'pixel art of a wooden market stall counter with crates and goods on top, side view', - 'spit': 'pixel art of a small blob of gray ash, a projectile', - # --- иконки сумки 8×8 (items.ts) --- - 'icon_clock': 'pixel art icon of a small copper pocket watch', - 'icon_cloth': 'pixel art icon of a folded waxed cloth mask', - 'icon_bellflower': 'pixel art icon of a small blue bellflower', - 'icon_salt': 'pixel art icon of a small pouch of salt', - 'icon_flask': 'pixel art icon of a small copper water flask', - 'icon_mote': 'pixel art icon of a small warm glowing ash mote', - 'icon_map': 'pixel art icon of a rolled paper map scroll', -} - - -def main() -> None: - args = sys.argv[1:] - wanted = {a.split(':')[0] for a in args} if args else set(LIST) - seeds_of = {} - for a in args: - if ':' in a: - k, s = a.split(':', 1) - seeds_of.setdefault(k, []).extend(int(x) for x in s.split(':')) - out_dir = Path(__file__).resolve().parent / 'review-b4' / 'raw' - out_dir.mkdir(parents=True, exist_ok=True) - - t0 = time.time() - pipe = StableDiffusionPipeline.from_pretrained( - MODEL_ID, torch_dtype=torch.float16, safety_checker=None, - ).to('cuda') - pipe.set_progress_bar_config(disable=True) - print(f'[load] {time.time() - t0:.1f}s', flush=True) - - for oid in [k for k in LIST if k in wanted]: - prompt = LIST[oid] - for seed in seeds_of.get(oid, list(SEEDS)): - gen = torch.Generator('cuda').manual_seed(seed) - img = pipe( - prompt, negative_prompt=NEG, num_inference_steps=STEPS, - guidance_scale=CFG, width=512, height=512, generator=gen, - ).images[0] - out = out_dir / f'{oid}_{seed}.png' - img.convert('RGBA').save(out) - print(f'[ok] {out.name}', flush=True) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/apps/game/tools/aiart/gen_pxgpt.py b/apps/game/tools/aiart/gen_pxgpt.py deleted file mode 100644 index 237babb..0000000 --- a/apps/game/tools/aiart/gen_pxgpt.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -"""AI-шаг пробы B1: доменные интерактивы через PixelGPT-24×24 (CPU). - -Модель: unstonio/pixelgpt-24x24 (68M декодер, ~22 с/спрайт). Репо модели — -вне репо игры: ~/.cache/rpg-ai/pixelgpt-24x24 (env RPG_PXGPT_REPO). Веса — -models/pixelar_fp16.pt (LFS) + models/minilm/; лицензии на модель нет — -результаты только PoC/review, в игру не идут. - -Палитра обусловливания — 5 ключей из apps/game/tools/pixelart/palette.mjs -(первый — самый тёмный, токен 0 = прозрачный). Токены 1–4 маппятся на -палитру по индексу (как во фронтенде модели: fillStyle = palette[role]). - -Запуск: $RPG_SD_PYTHON gen_pxgpt.py - строки промптов: seed|имя|промпт|5 ключей палитры через запятую - (формат apps/game/tools/aiart/prompts-b1.txt); строки с # — комментарии. -""" -import os -import re -import sys -import time -from pathlib import Path - -import numpy as np -import torch -from PIL import Image - -REPO = Path(os.environ.get('RPG_PXGPT_REPO', f'{Path.home()}/.cache/rpg-ai/pixelgpt-24x24')) -GAME_ROOT = Path(__file__).resolve().parents[4] -CFG = 3.0 -TOP_P = 0.95 -TEMP = 1.0 - - -def load_game_palette(path: Path) -> dict[str, tuple[int, int, int]]: - """Ключи палитры игры → RGB (парсинг palette.mjs).""" - out = {} - for m in re.finditer(r"(?:^|\s)([A-Z]\w*):\s*'(#[0-9a-fA-F]{6})'", path.read_text()): - h = m.group(2) - out[m.group(1)] = tuple(int(h[i:i + 2], 16) for i in (1, 3, 5)) - return out - - -def render(tokens: list[int], palette: list[tuple[int, int, int]], out: Path) -> None: - """24×24 RGBA: токен 0 — прозрачный, 1–5 — палитра со сдвигом (t-1). - - Сдвиг восстановлен сверкой с review-b1: токен 1 = первый ключ палитры - (самый тёмный), токен 5 — последний (в пробы B1 не попадал). - """ - im = Image.new('RGBA', (24, 24), (0, 0, 0, 0)) - px = im.load() - for i, t in enumerate(tokens): - if t: - px[i % 24, i // 24] = (*palette[t - 1], 255) - out.parent.mkdir(parents=True, exist_ok=True) - im.save(out) - - -def main() -> None: - prompts_file, out_dir = Path(sys.argv[1]), Path(sys.argv[2]) - keys = load_game_palette(GAME_ROOT / 'apps/game/tools/pixelart/palette.mjs') - - sys.path.insert(0, str(REPO)) - torch.set_num_threads(4) - from model_runtime import SEQ_LEN, SOS_TOKEN, load_model, sample_next, seed_everything, sequence_to_tokens - from sentence_transformers import SentenceTransformer - - device = torch.device('cpu') - t0 = time.time() - model, config, flat = load_model(str(REPO / 'models/pixelar_fp16.pt'), device) - encoder = SentenceTransformer(str(REPO / 'models/minilm'), device='cpu', local_files_only=True) - print(f'[load] {time.time() - t0:.0f}s', flush=True) - - for line in prompts_file.read_text().splitlines(): - line = line.strip() - if not line or line.startswith('#'): - continue - seed, name, prompt, palette_keys = line.split('|') - palette = [keys[k.strip()] for k in palette_keys.split(',')] - seed = int(seed) - seed_everything(seed) - dtype = next(model.parameters()).dtype - embedding = torch.from_numpy(encoder.encode( - [prompt], normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=False, - )).to(device=device, dtype=dtype) - pal = torch.from_numpy(np.asarray(palette, dtype=np.uint8)[None]).to(device=device, dtype=dtype) / 255.0 - captions = torch.cat([embedding, model.null_caption.unsqueeze(0).to(embedding.dtype)], dim=0) - model_palettes = pal.repeat(2, 1, 1) - with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=False): - condition = model.cond_vector(captions, model_palettes) - caches = model.allocate_caches(2, device, condition.dtype) - current = torch.full((2, 1), SOS_TOKEN, dtype=torch.long, device=device) - sequence = torch.zeros(1, SEQ_LEN, dtype=torch.long, device=device) - for position in range(SEQ_LEN): - logits = model(current, model_palettes, condition, caches=caches, start_pos=position)[:, -1].float() - conditional, unconditional = logits[:1], logits[1:] - token = sample_next(unconditional + CFG * (conditional - unconditional), TEMP, TOP_P) - sequence[:, position] = token - current = torch.cat([token, token], dim=0).unsqueeze(1) - tokens = sequence_to_tokens(sequence, flat).clamp(0, 4)[0].cpu().to(torch.uint8).reshape(-1).tolist() - render(tokens, palette, out_dir / f'{name}.png') - print(f'[ok] {name} (seed {seed}, {time.time() - t0:.0f}s)', flush=True) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/apps/game/tools/aiart/lint-art.mjs b/apps/game/tools/aiart/lint-art.mjs deleted file mode 100644 index b835554..0000000 --- a/apps/game/tools/aiart/lint-art.mjs +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Линтер арта — авто-чеклист арт-библии (docs/art-style.md) перед попаданием - * в assets/: все непрозрачные пиксели из палитры, размер в известном слоте, - * детект «мыла» (чужая плотность пикселя — градиентные переходы). - * - * node lint-art.mjs [--palette game|<файл.json>] [--size 16x24,...] - * - * Выход: построчные вердикты, код 1 при нарушениях. Гейт апрува — человек, - * линтер ловит только механику. - */ -import { readFileSync } from 'node:fs'; -import { decodePng } from '@rpg/engine/tools/png.mjs'; -import { PALETTE } from '../pixelart/palette.mjs'; -import { SLOTS } from '../pixelart/sizes.mjs'; - -/** Палитра по имени: "game" — 32 цвета игры; иначе JSON-файл с массивом hex. */ -function loadPalette(name) { - if (name === 'game') { - return Object.values(PALETTE).map((hex) => hexToRgb(hex)); - } - return JSON.parse(readFileSync(name, 'utf8')).map(hexToRgb); -} - -function hexToRgb(hex) { - const n = parseInt(hex.slice(1), 16); - return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; -} - -/** Все непрозрачные пиксели — из палитры? Возвращает список нарушений. */ -function checkPalette(img, pal) { - const set = new Set(pal.map((c) => c.join(','))); - const bad = new Map(); - for (let p = 0; p < img.rgba.length; p += 4) { - if (img.rgba[p + 3] === 0) continue; - const key = `${img.rgba[p]},${img.rgba[p + 1]},${img.rgba[p + 2]}`; - if (!set.has(key) && !bad.has(key)) bad.set(key, 1); - } - return [...bad.keys()]; -} - -/** - * Детект «мыла»: доля горизонтальных пар соседних непрозрачных пикселей с - * маленьким (1..10 на канал), но ненулевым расхождением — градиент внутри - * «квадрата». У честного пиксель-арта соседи либо совпадают, либо различны. - */ -function checkSoap(img) { - let pairs = 0, gradient = 0; - for (let y = 0; y < img.height; y++) { - for (let x = 0; x < img.width - 1; x++) { - const a = (y * img.width + x) * 4, b = a + 4; - if (img.rgba[a + 3] === 0 || img.rgba[b + 3] === 0) continue; - pairs++; - const d = [0, 1, 2].map((c) => Math.abs(img.rgba[a + c] - img.rgba[b + c])); - if (d.some((v) => v >= 1 && v <= 10) && d.every((v) => v <= 10)) gradient++; - } - } - return pairs ? gradient / pairs : 0; -} - -// ---------- CLI ---------- - -const args = process.argv.slice(2); -const files = args.filter((a) => !a.startsWith('--')); -const opt = (name, def) => { - const i = args.indexOf(`--${name}`); - return i >= 0 ? args[i + 1] : def; -}; -if (!files.length) { - console.log('Использование: node lint-art.mjs [--palette game|<файл.json>] [--size 16x24,16x16]'); - process.exit(1); -} -const pal = loadPalette(opt('palette', 'game')); -const sizes = opt('size', null); // переопределение слотов (через запятую) -const slots = sizes ? new Set(sizes.split(',')) : SLOTS; - -let failed = 0; -for (const file of files) { - const img = decodePng(readFileSync(file)); - const problems = []; - const dim = `${img.width}x${img.height}`; - const atlasRow = [12, 14, 16, 20, 24, 32, 48].includes(img.height) && img.width > img.height - && (img.width % 16 === 0 || img.width % img.height === 0); - if (!slots.has(dim) && !atlasRow) problems.push(`размер ${dim} вне слотов`); - const badColors = checkPalette(img, pal); - if (badColors.length) problems.push(`вне палитры: ${badColors.length} цв. (${badColors.slice(0, 3).join(' ')})`); - const soap = checkSoap(img); - if (soap > 0.1) problems.push(`мыло: ${(soap * 100).toFixed(0)}% градиентных пар`); - if (problems.length) failed++; - console.log(`${problems.length ? 'FAIL' : ' ok '} ${file}${problems.length ? ' — ' + problems.join('; ') : ''}`); -} -process.exit(failed ? 1 : 0); \ No newline at end of file diff --git a/apps/game/tools/aiart/manifest-b1.json b/apps/game/tools/aiart/manifest-b1.json deleted file mode 100644 index 19cb54e..0000000 --- a/apps/game/tools/aiart/manifest-b1.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "run": "b1-domain", - "date": "2026-09-06", - "model": "PixelGPT-24×24 (github.com/unstonio/pixelgpt-24x24), 68M декодер, CPU ~22 с/спрайт", - "license": "кода и весов НЕТ (в репо не указана) — в игру нельзя; датасет 20K — CC BY 4.0", - "pipeline": "prompts-b1.txt (seed|имя|промпт|5 ключей палитры) -> gen_pxgpt.py (обусловливание палитрой игры) -> review-b1/*.png (24x24) -> post.mjs (кроп, nearest 16x16, квантизация palette.mjs) -> processed-b1/", - "criterion": ">= 30% годных на доменных интерактивах, где PXG-Tiny отказывала", - "notes": [ - "Отказов нет вообще: модель пробуется рисовать любой конкретный объект (у PXG-Tiny домен давал gate=clarify).", - "Кастомная палитра из 5 цветов игры на запрос работает — стиль совпал на 100%.", - "Главный фактор брака — даунскейл 24→16: колодец/рычаг/прилавок на 24×24 читаются, на 16 разваливаются.", - "Фон часто залит непрозрачным цветом (сундуки) — кроп по альфе не спасает." - ], - "items": [ - { "file": "b1_bell_s1.png", "prompt": "a small bronze bell with a handle", "seed": 227, "match": "good", "note": "колокол читается чётко, бронза" }, - { "file": "b1_bell_s2.png", "prompt": "a small bronze bell with a handle", "seed": 228, "match": "good", "note": "тоже читаемый колокол" }, - { "file": "b1_chest_s1.png", "prompt": "a wooden chest with bronze trim", "seed": 237, "match": "partial", "note": "форма ок, но фон залит жёлтым" }, - { "file": "b1_chest_s2.png", "prompt": "a wooden chest with bronze trim", "seed": 238, "match": "partial", "note": "фон залит; у PXG-Tiny сундук был чище" }, - { "file": "b1_counter_s1.png", "prompt": "a wooden shop counter with goods", "seed": 231, "match": "partial", "note": "на 24×24 прилавок читается, на 16 разваливается" }, - { "file": "b1_counter_s2.png", "prompt": "a wooden shop counter with goods", "seed": 232, "match": "bad", "note": "тёмная каша" }, - { "file": "b1_door_s1.png", "prompt": "an old wooden door with iron hinges", "seed": 233, "match": "good", "note": "дверь с панелями и ручкой" }, - { "file": "b1_door_s2.png", "prompt": "an old wooden door with iron hinges", "seed": 234, "match": "good", "note": "дверь с перекладинами" }, - { "file": "b1_hearth_s1.png", "prompt": "a stone fireplace with burning fire", "seed": 229, "match": "good", "note": "камин с огнём, тёплый F0 уместен" }, - { "file": "b1_hearth_s2.png", "prompt": "a stone fireplace with burning fire", "seed": 230, "match": "partial", "note": "силуэт есть, огонь слабый" }, - { "file": "b1_lever_s1.png", "prompt": "a rusty metal lever on a stone base", "seed": 225, "match": "partial", "note": "на 24×24 рычаг на подставке, на 16 — палка" }, - { "file": "b1_lever_s2.png", "prompt": "a rusty metal lever on a stone base", "seed": 226, "match": "bad", "note": "похоже на лимон" }, - { "file": "b1_mote_s1.png", "prompt": "a glowing wisp of light", "seed": 235, "match": "good", "note": "светящийся ореол с ядром — как моты" }, - { "file": "b1_mote_s2.png", "prompt": "a glowing wisp of light", "seed": 236, "match": "partial", "note": "ядро тёмное, свечение по краю" }, - { "file": "b1_signpost_s1.png", "prompt": "a wooden signpost with a small sign board", "seed": 223, "match": "good", "note": "столб с доской-указателем" }, - { "file": "b1_signpost_s2.png", "prompt": "a wooden signpost with a small sign board", "seed": 224, "match": "good", "note": "столб с двумя досками" }, - { "file": "b1_well_s1.png", "prompt": "a stone well with a wooden roof", "seed": 221, "match": "partial", "note": "на 24×24 good (кладка, проём) — убил даунскейл 24→16" }, - { "file": "b1_well_s2.png", "prompt": "a stone well with a wooden roof", "seed": 222, "match": "bad", "note": "серая клякса" } - ], - "verdict": { - "good": 8, - "partial": 7, - "bad": 3, - "total": 18, - "goodShare": "44%", - "conclusion": "Критерий 30% выполнен; главное — отказов нет, доменные интерактивы модель рисует (PXG-Tiny отказывала). Но: качество неровное, даунскейл 24→16 губит деталь (колодец good на 24×24), фон часто залит, ровно 5 цветов на спрайт, ~22 с/спрайт на CPU. Решающее ограничение — лицензии на код/веса нет, в игру нельзя. Итог: локальные GPT-пиксель-модели домен тянут частично (PXG-Tiny — инвентарь/декор, PixelGPT — простые объекты на 24×24); для критичных интерактивов — RD API (платный) или ручной арт. Дообучение под игру решением не считается (отвергнуто)." - } -} \ No newline at end of file diff --git a/apps/game/tools/aiart/manifest.json b/apps/game/tools/aiart/manifest.json deleted file mode 100644 index 89aba36..0000000 --- a/apps/game/tools/aiart/manifest.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "run": "b0-poc", - "date": "2026-09-06", - "model": "PXG-Tiny (Tarul/pxg-tiny), NumPy CPU, 16x16, код+веса MIT/CC0", - "pipeline": "cli.py -> review/*.png -> post.mjs (кроп альфы, nearest, квантизация palette.mjs) -> processed/", - "criterion": ">= 30% спрайтов годных без ручной правки", - "items": [ - { "file": "a_chest_s1.png", "prompt": "a chest", "seed": 1, "gate": "accept", "match": "good", "note": "золотой сундук, годен" }, - { "file": "a_chest_s2.png", "prompt": "a chest", "seed": 2, "gate": "accept", "match": "good", "note": "серебряный сундук, годен" }, - { "file": "a_rock_s1.png", "prompt": "a rock", "seed": 1, "gate": "accept", "match": "good", "note": "валун, годен" }, - { "file": "a_rock_s2.png", "prompt": "a rock", "seed": 2, "gate": "accept", "match": "good", "note": "валун, годен" }, - { "file": "a_table_s1.png", "prompt": "a table", "seed": 1, "gate": "accept", "match": "good", "note": "стол, годен" }, - { "file": "a_table_s2.png", "prompt": "a table", "seed": 2, "gate": "accept", "match": "good", "note": "стол, годен" }, - { "file": "102_a_golden_key.png", "prompt": "a golden key", "seed": 102, "gate": "accept", "match": "good", "note": "ключ, годен" }, - { "file": "111_an_iron_sword.png", "prompt": "an iron sword", "seed": 111, "gate": "accept", "match": "good", "note": "меч, годен" }, - { "file": "109_a_healing_potion_in_a_round_.png", "prompt": "a healing potion in a round bottle", "seed": 109, "gate": "accept", "match": "partial", "note": "форма ок, цвет ушёл в W3 при квантизации (зелёного в палитре нет)" }, - { "file": "107_a_wooden_chair.png", "prompt": "a wooden chair", "seed": 107, "gate": "accept", "match": "partial", "note": "похоже на табурет, читаемо" }, - { "file": "101_an_iron_chest_with_bronze_tr.png", "prompt": "an iron chest with bronze trim", "seed": 101, "gate": "accept", "match": "bad", "note": "сгенерировал рыцаря вместо сундука" }, - { "file": "a_coin_s1.png", "prompt": "a coin", "seed": 1, "gate": "accept", "match": "bad", "note": "табурет вместо монеты" }, - { "file": "a_coin_s2.png", "prompt": "a coin", "seed": 2, "gate": "accept", "match": "bad", "note": "табурет вместо монеты" }, - { "file": "an_oak_s1.png", "prompt": "an oak", "seed": 1, "gate": "accept", "match": "bad", "note": "нечитаемая каша" }, - { "file": "an_oak_s2.png", "prompt": "an oak", "seed": 2, "gate": "accept", "match": "bad", "note": "котелок вместо дерева" }, - { "file": "105_an_old_oak_tree.png", "prompt": "an old oak tree", "seed": 105, "gate": "accept", "match": "bad", "note": "рыжая каша" }, - { "file": "106_a_wooden_table.png", "prompt": "a wooden table", "seed": 106, "gate": "accept", "match": "partial", "note": "стол-табурет" }, - { "file": "108_a_small_grey_stone_house.png", "prompt": "a small grey stone house", "seed": 108, "gate": "accept", "match": "bad", "note": "наковальня-кувшин" }, - { "file": "110_a_wooden_staff.png", "prompt": "a wooden staff", "seed": 110, "gate": "accept", "match": "bad", "note": "доска вместо посоха" }, - { "file": "112_a_bronze_coin.png", "prompt": "a bronze coin", "seed": 112, "gate": "accept", "match": "bad", "note": "кирпич" } - ], - "refused": [ - { "prompt": "a grey crystal shard", "gate": "clarify", "note": "shard вне лексикона" }, - { "prompt": "a grey stone", "gate": "clarify", "note": "stone вне лексикона (rock — есть)" }, - { "prompt": "a bronze bell", "gate": "clarify", "note": "колокол нет в классах — ожидаемо" }, - { "prompt": "a yellow flower", "gate": "clarify", "note": "цветов нет в классах — критично для игры" } - ], - "verdict": { - "good": 8, - "partial": 3, - "bad": 9, - "total": 20, - "goodShare": "40%", - "conclusion": "Критерий 30% выполнен, стиль совпадает с игрой на 100% (чанки-пиксель, палитра). Но домен модели узкий: критичные для игры ассеты (колодец, столб, рычаг, колокольчик, моты, очаг, прилавок) вне 38 классов. Вывод: PXG-Tiny годна для инвентаря/декора из её классов; для доменных ассетов нужен дообученный генератор, платный RD API или ручной арт." - } -} \ No newline at end of file diff --git a/apps/game/tools/aiart/post.mjs b/apps/game/tools/aiart/post.mjs deleted file mode 100644 index 60086f7..0000000 --- a/apps/game/tools/aiart/post.mjs +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Пост-обработка AI-спрайтов «Пепельных лугов» — сценарий над жанронезависимым - * @rpg/engine/tools/imaging.mjs (декодер/кроп/квантизация — там). - * Запуск: node apps/game/tools/aiart/post.mjs <каталог с PNG> [каталог вывода=processed] - * → <вывод>/*.png (кроп по альфе, квантизация в палитру) - * → <вывод>/_sheet.png (обзорный лист для апрува) - * - * AI-вывод — только сырьё: в игру попадает после ручного апрува (манифест). - */ -import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; -import { normalizeToSize, quantize, reviewSheet } from '@rpg/engine/tools/imaging.mjs'; -import { PALETTE } from '../pixelart/palette.mjs'; - -const REVIEW = process.argv[2] ?? 'review'; -const OUT_NAME = process.argv[3] ?? 'processed'; -const HERE = fileURLToPath(new URL('.', import.meta.url)); -const OUT = join(HERE, OUT_NAME); -const SIZE = 16; // целевой размер спрайта (арт-библия) - -/** Палитра игры как массив [r,g,b] для квантизации. */ -const PAL = Object.entries(PALETTE).map(([, hex]) => { - const n = parseInt(hex.slice(1), 16); - return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; -}); - -const names = readdirSync(join(HERE, REVIEW)).filter((f) => f.endsWith('.png')); -mkdirSync(OUT, { recursive: true }); -const processed = names.map((name) => { - const img = decodePng(readFileSync(join(HERE, REVIEW, name))); - const rgba = quantize(normalizeToSize(img, SIZE), PAL); - writeFileSync(join(OUT, name), encodePng(SIZE, SIZE, Buffer.from(rgba))); - return { name, rgba }; -}); -reviewSheet(processed, join(OUT, '_sheet.png'), { size: SIZE }); -console.log(`обработано ${processed.length} -> ${OUT}`); \ No newline at end of file diff --git a/apps/game/tools/aiart/post_b4.mjs b/apps/game/tools/aiart/post_b4.mjs deleted file mode 100644 index 534111b..0000000 --- a/apps/game/tools/aiart/post_b4.mjs +++ /dev/null @@ -1,191 +0,0 @@ -/** - * B4: пост-обработка кандидатов gen_b4.py в слоты арт-библии. - * Запуск: node apps/game/tools/aiart/post_b4.mjs - * raw/_.png → кроп фигуры (findFigures) → nearest в слот B4_SLOTS - * → квантизация в палитру игры → processed-b4/_.png - * → обзорный лист processed-b4/_sheet.png (кандидаты по объектам, для апрува) - * - * AI-вывод — только сырьё: в игру (assets/) попадает после ручного апрува. - */ -import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; -import { findFigures, quantize } from '@rpg/engine/tools/imaging.mjs'; -import { PALETTE } from '../pixelart/palette.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const RAW = join(HERE, 'review-b4', 'raw'); -const OUT = join(HERE, 'review-b4', 'processed'); - -/** Слоты кандидатов (id → w×h): интерактивы — по родам плейсхолдеров, иконки — 8×8. */ -const SLOTS = { - signpost: [16, 24], - mote: [16, 16], - tone_tree: [32, 48], - resonator: [32, 40], - bell_rope: [16, 24], - hearth: [32, 32], - note: [16, 16], - counter: [32, 16], - spit: [8, 8], - icon_clock: [8, 8], - icon_cloth: [8, 8], - icon_bellflower: [8, 8], - icon_salt: [8, 8], - icon_flask: [8, 8], - icon_mote: [8, 8], - icon_map: [8, 8] -}; - -/** Палитра игры как массив [r,g,b]. */ -const PAL = Object.entries(PALETTE).map(([, hex]) => { - const n = parseInt(hex.slice(1), 16); - return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; -}); - -/** Фигура листа: bbox крупнейшей связной компоненты (фон — угловой пиксель). */ -function figureRect(img) { - const boxes = findFigures(img, { maxCount: 8, minArea: 2000 }); - if (boxes.length) { - return boxes.reduce((a, b) => (a.w * a.h >= b.w * b.h ? a : b)); - } - return { x: 0, y: 0, w: img.width, h: img.height }; -} - -/** - * Маска фона наращиванием от краёв (BFS): фон RD-вывода — гладкий градиент, - * угловой пиксель его не описывает. Шаг с малым допуском (фон плавный), у - * границы объекта скачок — рост останавливается. - */ -function bgMask(img) { - const W = img.width; - const H = img.height; - const mask = new Uint8Array(W * H); - const queue = []; - const seed = (x, y) => { - const p = y * W + x; - if (!mask[p] && img.rgba[p * 4 + 3] >= 128) { - mask[p] = 1; - queue.push(x, y); - } - }; - for (let x = 0; x < W; x++) { - seed(x, 0); - seed(x, H - 1); - } - for (let y = 0; y < H; y++) { - seed(0, y); - seed(W - 1, y); - } - const close = (a, b) => - Math.abs(img.rgba[a] - img.rgba[b]) + Math.abs(img.rgba[a + 1] - img.rgba[b + 1]) + - Math.abs(img.rgba[a + 2] - img.rgba[b + 2]) < 40; - for (let q = 0; q < queue.length; q += 2) { - const x = queue[q]; - const y = queue[q + 1]; - const p = y * W + x; - const nb = [ - [x - 1, y], - [x + 1, y], - [x, y - 1], - [x, y + 1] - ]; - for (const [nx, ny] of nb) { - if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; - const n = ny * W + nx; - if (!mask[n] && close(p, n)) { - mask[n] = 1; - queue.push(nx, ny); - } - } - } - return mask; -} - -/** - * Даунскейл прямоугольника усреднением ячеек (RD-вывод гладкий — nearest - * сэмплирует шум, усреднение даёт ровные цветовые области). Пиксели фоновой - * маски в среднее не входят; ячейка без фигуры — прозрачная. - */ -function meanRect(img, rect, w, h) { - const bg = bgMask(img); - const out = new Uint8Array(w * h * 4); - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - const sx0 = rect.x + Math.floor((x * rect.w) / w); - const sx1 = rect.x + Math.max(sx0 + 1, Math.floor(((x + 1) * rect.w) / w)); - const sy0 = rect.y + Math.floor((y * rect.h) / h); - const sy1 = rect.y + Math.max(sy0 + 1, Math.floor(((y + 1) * rect.h) / h)); - let r = 0, g = 0, b = 0, n = 0; - for (let sy = sy0; sy < sy1 && sy < img.height; sy++) { - for (let sx = sx0; sx < sx1 && sx < img.width; sx++) { - const i = (sy * img.width + sx) * 4; - if (bg[sy * img.width + sx] || img.rgba[i + 3] < 128) continue; - r += img.rgba[i]; g += img.rgba[i + 1]; b += img.rgba[i + 2]; n++; - } - } - if (n > 0) { - const i = (y * w + x) * 4; - out[i] = r / n; out[i + 1] = g / n; out[i + 2] = b / n; out[i + 3] = 255; - } - } - } - return out; -} - -/** Обзорный лист: кандидаты группами по объекту (имя — подпись группы). */ -function groupSheet(groups, path) { - const scale = 3; - const pad = 8; - const cellW = 40 * scale; - const cellH = 52 * scale; - const cols = Math.max(...groups.map((g) => g.items.length)); - const W = cols * (cellW + pad) + pad; - const H = groups.length * (cellH + pad + 14) + pad; - const bg = Buffer.alloc(W * H * 4); - for (let p = 0; p < bg.length; p += 4) { - bg[p] = 30; bg[p + 1] = 32; bg[p + 2] = 38; bg[p + 3] = 255; - } - groups.forEach((g, gy) => { - const y0 = pad + gy * (cellH + pad + 14); - g.items.forEach(({ rgba, w, h }, i) => { - const x0 = pad + i * (cellW + pad); - for (let y = 0; y < h * scale; y++) { - for (let x = 0; x < w * scale; x++) { - const sp = ((y / scale) | 0) * w + ((x / scale) | 0); - if (rgba[sp * 4 + 3] === 0) continue; - bg.set( - rgba.subarray(sp * 4, sp * 4 + 4), - ((y0 + y) * W + x0 + x) * 4 - ); - } - } - }); - }); - writeFileSync(path, encodePng(W, H, Buffer.from(bg))); - return { W, H }; -} - -const files = readdirSync(RAW).filter((f) => f.endsWith('.png')); -mkdirSync(OUT, { recursive: true }); -const byId = new Map(); -for (const name of files) { - const id = name.replace(/_\d+\.png$/, ''); - const [w, h] = SLOTS[id]; - if (!w) throw new Error(`нет слота для ${id}`); - const img = decodePng(readFileSync(join(RAW, name))); - const rgba = quantize(meanRect(img, figureRect(img), w, h), PAL); - writeFileSync(join(OUT, name), encodePng(w, h, Buffer.from(rgba))); - if (!byId.has(id)) byId.set(id, []); - byId.get(id).push({ name, rgba, w, h }); -} - -const groups = [...byId.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([id, items]) => ({ id, items })); -const info = groupSheet(groups, join(OUT, '_sheet.png')); -console.log( - `обработано ${files.length} (${groups.length} объектов) -> ${OUT}\n` + - `лист: ${info.W}x${info.H}` -); \ No newline at end of file diff --git a/apps/game/tools/aiart/probe-b3/frame_16x24.png b/apps/game/tools/aiart/probe-b3/frame_16x24.png deleted file mode 100644 index 9b14c45..0000000 --- a/apps/game/tools/aiart/probe-b3/frame_16x24.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/probe-b3/frame_16x24_x8.png b/apps/game/tools/aiart/probe-b3/frame_16x24_x8.png deleted file mode 100644 index 91ced5a..0000000 --- a/apps/game/tools/aiart/probe-b3/frame_16x24_x8.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/probe-b3/sd_fss_42_15steps.png b/apps/game/tools/aiart/probe-b3/sd_fss_42_15steps.png deleted file mode 100644 index 82663d6..0000000 --- a/apps/game/tools/aiart/probe-b3/sd_fss_42_15steps.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/probe-b3/sd_fss_42_raw.png b/apps/game/tools/aiart/probe-b3/sd_fss_42_raw.png deleted file mode 100644 index 630c7a0..0000000 --- a/apps/game/tools/aiart/probe-b3/sd_fss_42_raw.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/probe-b3/sd_q8_fss_42_broken.png b/apps/game/tools/aiart/probe-b3/sd_q8_fss_42_broken.png deleted file mode 100644 index 2364feb..0000000 --- a/apps/game/tools/aiart/probe-b3/sd_q8_fss_42_broken.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/probe-c2.png b/apps/game/tools/aiart/probe-c2.png deleted file mode 100644 index fce1a3d..0000000 --- a/apps/game/tools/aiart/probe-c2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/_sheet.png b/apps/game/tools/aiart/processed-b1/_sheet.png deleted file mode 100644 index 0b93f7e..0000000 --- a/apps/game/tools/aiart/processed-b1/_sheet.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_bell_s1.png b/apps/game/tools/aiart/processed-b1/b1_bell_s1.png deleted file mode 100644 index 53994eb..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_bell_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_bell_s2.png b/apps/game/tools/aiart/processed-b1/b1_bell_s2.png deleted file mode 100644 index d10130a..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_bell_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_chest_s1.png b/apps/game/tools/aiart/processed-b1/b1_chest_s1.png deleted file mode 100644 index b91e10c..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_chest_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_chest_s2.png b/apps/game/tools/aiart/processed-b1/b1_chest_s2.png deleted file mode 100644 index dba9c40..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_chest_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_counter_s1.png b/apps/game/tools/aiart/processed-b1/b1_counter_s1.png deleted file mode 100644 index dee7604..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_counter_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_counter_s2.png b/apps/game/tools/aiart/processed-b1/b1_counter_s2.png deleted file mode 100644 index 2f51d9e..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_counter_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_door_s1.png b/apps/game/tools/aiart/processed-b1/b1_door_s1.png deleted file mode 100644 index 3fe9340..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_door_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_door_s2.png b/apps/game/tools/aiart/processed-b1/b1_door_s2.png deleted file mode 100644 index 1befd14..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_door_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_hearth_s1.png b/apps/game/tools/aiart/processed-b1/b1_hearth_s1.png deleted file mode 100644 index 69c3284..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_hearth_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_hearth_s2.png b/apps/game/tools/aiart/processed-b1/b1_hearth_s2.png deleted file mode 100644 index b9ac7e3..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_hearth_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_lever_s1.png b/apps/game/tools/aiart/processed-b1/b1_lever_s1.png deleted file mode 100644 index 30b49c5..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_lever_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_lever_s2.png b/apps/game/tools/aiart/processed-b1/b1_lever_s2.png deleted file mode 100644 index b61b9d7..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_lever_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_mote_s1.png b/apps/game/tools/aiart/processed-b1/b1_mote_s1.png deleted file mode 100644 index 9f42c2a..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_mote_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_mote_s2.png b/apps/game/tools/aiart/processed-b1/b1_mote_s2.png deleted file mode 100644 index 6250da8..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_mote_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_signpost_s1.png b/apps/game/tools/aiart/processed-b1/b1_signpost_s1.png deleted file mode 100644 index efb564c..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_signpost_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_signpost_s2.png b/apps/game/tools/aiart/processed-b1/b1_signpost_s2.png deleted file mode 100644 index 7de5fbf..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_signpost_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_well_s1.png b/apps/game/tools/aiart/processed-b1/b1_well_s1.png deleted file mode 100644 index 8126474..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_well_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed-b1/b1_well_s2.png b/apps/game/tools/aiart/processed-b1/b1_well_s2.png deleted file mode 100644 index 822baf3..0000000 --- a/apps/game/tools/aiart/processed-b1/b1_well_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/101_an_iron_chest_with_bronze_tr.png b/apps/game/tools/aiart/processed/101_an_iron_chest_with_bronze_tr.png deleted file mode 100644 index 1631a09..0000000 --- a/apps/game/tools/aiart/processed/101_an_iron_chest_with_bronze_tr.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/102_a_golden_key.png b/apps/game/tools/aiart/processed/102_a_golden_key.png deleted file mode 100644 index b21b1b1..0000000 --- a/apps/game/tools/aiart/processed/102_a_golden_key.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/105_an_old_oak_tree.png b/apps/game/tools/aiart/processed/105_an_old_oak_tree.png deleted file mode 100644 index 89b38fe..0000000 --- a/apps/game/tools/aiart/processed/105_an_old_oak_tree.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/106_a_wooden_table.png b/apps/game/tools/aiart/processed/106_a_wooden_table.png deleted file mode 100644 index 1ecb360..0000000 --- a/apps/game/tools/aiart/processed/106_a_wooden_table.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/107_a_wooden_chair.png b/apps/game/tools/aiart/processed/107_a_wooden_chair.png deleted file mode 100644 index 72a854f..0000000 --- a/apps/game/tools/aiart/processed/107_a_wooden_chair.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/108_a_small_grey_stone_house.png b/apps/game/tools/aiart/processed/108_a_small_grey_stone_house.png deleted file mode 100644 index 133ecfb..0000000 --- a/apps/game/tools/aiart/processed/108_a_small_grey_stone_house.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/109_a_healing_potion_in_a_round_.png b/apps/game/tools/aiart/processed/109_a_healing_potion_in_a_round_.png deleted file mode 100644 index 148c00b..0000000 --- a/apps/game/tools/aiart/processed/109_a_healing_potion_in_a_round_.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/110_a_wooden_staff.png b/apps/game/tools/aiart/processed/110_a_wooden_staff.png deleted file mode 100644 index 7cb38f2..0000000 --- a/apps/game/tools/aiart/processed/110_a_wooden_staff.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/111_an_iron_sword.png b/apps/game/tools/aiart/processed/111_an_iron_sword.png deleted file mode 100644 index f1fe824..0000000 --- a/apps/game/tools/aiart/processed/111_an_iron_sword.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/112_a_bronze_coin.png b/apps/game/tools/aiart/processed/112_a_bronze_coin.png deleted file mode 100644 index 65531fb..0000000 --- a/apps/game/tools/aiart/processed/112_a_bronze_coin.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/_sheet.png b/apps/game/tools/aiart/processed/_sheet.png deleted file mode 100644 index 9f0ecf4..0000000 --- a/apps/game/tools/aiart/processed/_sheet.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/a_chest_s1.png b/apps/game/tools/aiart/processed/a_chest_s1.png deleted file mode 100644 index c5205ef..0000000 --- a/apps/game/tools/aiart/processed/a_chest_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/a_chest_s2.png b/apps/game/tools/aiart/processed/a_chest_s2.png deleted file mode 100644 index 4dd6669..0000000 --- a/apps/game/tools/aiart/processed/a_chest_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/a_coin_s1.png b/apps/game/tools/aiart/processed/a_coin_s1.png deleted file mode 100644 index a22534f..0000000 --- a/apps/game/tools/aiart/processed/a_coin_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/a_coin_s2.png b/apps/game/tools/aiart/processed/a_coin_s2.png deleted file mode 100644 index a22534f..0000000 --- a/apps/game/tools/aiart/processed/a_coin_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/a_rock_s1.png b/apps/game/tools/aiart/processed/a_rock_s1.png deleted file mode 100644 index 68e7e8a..0000000 --- a/apps/game/tools/aiart/processed/a_rock_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/a_rock_s2.png b/apps/game/tools/aiart/processed/a_rock_s2.png deleted file mode 100644 index 4e219f6..0000000 --- a/apps/game/tools/aiart/processed/a_rock_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/a_table_s1.png b/apps/game/tools/aiart/processed/a_table_s1.png deleted file mode 100644 index 924a5e4..0000000 --- a/apps/game/tools/aiart/processed/a_table_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/a_table_s2.png b/apps/game/tools/aiart/processed/a_table_s2.png deleted file mode 100644 index 924a5e4..0000000 --- a/apps/game/tools/aiart/processed/a_table_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/an_oak_s1.png b/apps/game/tools/aiart/processed/an_oak_s1.png deleted file mode 100644 index e707411..0000000 --- a/apps/game/tools/aiart/processed/an_oak_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/processed/an_oak_s2.png b/apps/game/tools/aiart/processed/an_oak_s2.png deleted file mode 100644 index b984d7b..0000000 --- a/apps/game/tools/aiart/processed/an_oak_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/prompts-b0.txt b/apps/game/tools/aiart/prompts-b0.txt deleted file mode 100644 index d039504..0000000 --- a/apps/game/tools/aiart/prompts-b0.txt +++ /dev/null @@ -1,17 +0,0 @@ -# B0 PoC: промпты под домен «Пепельных лугов» (классы PXG-Tiny, 16×16). -# Формат: seed|промпт. Последние два — намеренно вне домена классов -# (колокол, цветок) для проверки поведения отказа. -101|an iron chest with bronze trim -102|a golden key -103|a grey crystal shard -104|a grey stone -105|an old oak tree -106|a wooden table -107|a wooden chair -108|a small grey stone house -109|a healing potion in a round bottle -110|a wooden staff -111|an iron sword -112|a bronze coin -113|a bronze bell -114|a yellow flower \ No newline at end of file diff --git a/apps/game/tools/aiart/prompts-b1.txt b/apps/game/tools/aiart/prompts-b1.txt deleted file mode 100644 index a984fd7..0000000 --- a/apps/game/tools/aiart/prompts-b1.txt +++ /dev/null @@ -1,22 +0,0 @@ -# B1: доменные интерактивы «Пепельных лугов» через PixelGPT-24×24 (5 цветов из палитры). -# Формат: seed|имя|промпт|5 ключей палитры (первый — самый тёмный). -# С 220 по 227 — доменные объекты (вне 38 классов PXG-Tiny, отказ в B0); -# 228-229 — контрольные (PXG-Tiny их уже умела — для сравнения качества). -221|b1_well_s1|a stone well with a wooden roof|P2,P4,P6,P5H,R1 -222|b1_well_s2|a stone well with a wooden roof|P2,P4,P6,P5H,R1 -223|b1_signpost_s1|a wooden signpost with a small sign board|R1,R2,P3,P5,P5H -224|b1_signpost_s2|a wooden signpost with a small sign board|R1,R2,P3,P5,P5H -225|b1_lever_s1|a rusty metal lever on a stone base|R0,R1,R2,B0,P3 -226|b1_lever_s2|a rusty metal lever on a stone base|R0,R1,R2,B0,P3 -227|b1_bell_s1|a small bronze bell with a handle|B0,B1,B2,R1,P2 -228|b1_bell_s2|a small bronze bell with a handle|B0,B1,B2,R1,P2 -229|b1_hearth_s1|a stone fireplace with burning fire|P2,P3,R0,R1,F0 -230|b1_hearth_s2|a stone fireplace with burning fire|P2,P3,R0,R1,F0 -231|b1_counter_s1|a wooden shop counter with goods|R1,R2,P2,P4,B0 -232|b1_counter_s2|a wooden shop counter with goods|R1,R2,P2,P4,B0 -233|b1_door_s1|an old wooden door with iron hinges|R0,R1,R2,P3,B0 -234|b1_door_s2|an old wooden door with iron hinges|R0,R1,R2,P3,B0 -235|b1_mote_s1|a glowing wisp of light|P6,P5H,B1,B2,W2 -236|b1_mote_s2|a glowing wisp of light|P6,P5H,B1,B2,W2 -237|b1_chest_s1|a wooden chest with bronze trim|R1,B0,B1,B2,P3 -238|b1_chest_s2|a wooden chest with bronze trim|R1,B0,B1,B2,P3 \ No newline at end of file diff --git a/apps/game/tools/aiart/recolor.mjs b/apps/game/tools/aiart/recolor.mjs deleted file mode 100644 index 955577a..0000000 --- a/apps/game/tools/aiart/recolor.mjs +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Палитровый ремапер: перекраска квантованного спрайта маппингом цветов. - * Две потребности (docs/plan.md, работа B): привести «поплавшие» промпт-цвета - * AI-генерации к каноническим и дешёвые цветовые вариации NPC из одного спрайта. - * - * CLI: node recolor.mjs <из=в ...> - * цвета — ключи палитры (C1) или #hex; вход должен быть квантован - * в палитру (иначе «из» не найдётся — замена по точному совпадению). - */ -import { readFileSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; -import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; -import { PALETTE } from '../pixelart/palette.mjs'; - -/** Цвет из аргумента: ключ палитры (C1) или #hex → [r,g,b]. */ -export function parseColor(token) { - if (!token) throw new Error('пустой цвет'); - if (token.startsWith('#')) { - const n = parseInt(token.slice(1), 16); - return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; - } - if (!(token in PALETTE)) throw new Error(`«${token}» — нет такого ключа палитры`); - return hexToRgb(PALETTE[token]); -} - -function hexToRgb(hex) { - const n = parseInt(hex.slice(1), 16); - return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; -} - -/** - * Замена цветов по точному совпадению RGB (alpha не трогаем). - * @param pairs массив [[fromRGB, toRGB], ...] - */ -export function remap(rgba, pairs) { - const lookup = new Map(pairs.map(([from, to]) => [from.join(','), to])); - const out = new Uint8Array(rgba); - for (let p = 0; p < out.length; p += 4) { - const to = lookup.get(`${out[p]},${out[p + 1]},${out[p + 2]}`); - if (to) { - out[p] = to[0]; out[p + 1] = to[1]; out[p + 2] = to[2]; - } - } - return out; -} - -// ---------- CLI (только при прямом запуске) ---------- - -const invoked = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); -if (invoked) { - const [, , src, dst, ...tokens] = process.argv; - if (!src || !dst || !tokens.length) { - console.log('Использование: node recolor.mjs <из=в ...> (цвета: ключ палитры или #hex)'); - process.exit(1); - } - const pairs = tokens.map((t) => { - const [from, to] = t.split('='); - return [parseColor(from), parseColor(to)]; - }); - const img = decodePng(readFileSync(src)); - writeFileSync(dst, encodePng(img.width, img.height, Buffer.from(remap(img.rgba, pairs)))); - console.log(`[recolor] ${src} -> ${dst} (замены: ${pairs.map(([f, t]) => `#${f.map((v) => v.toString(16).padStart(2, '0')).join('')}→#${t.map((v) => v.toString(16).padStart(2, '0')).join('')}`).join(', ')})`); -} \ No newline at end of file diff --git a/apps/game/tools/aiart/review-b1/b1_bell_s1.png b/apps/game/tools/aiart/review-b1/b1_bell_s1.png deleted file mode 100644 index 4fc73ae..0000000 --- a/apps/game/tools/aiart/review-b1/b1_bell_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_bell_s2.png b/apps/game/tools/aiart/review-b1/b1_bell_s2.png deleted file mode 100644 index 7d8fd4f..0000000 --- a/apps/game/tools/aiart/review-b1/b1_bell_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_chest_s1.png b/apps/game/tools/aiart/review-b1/b1_chest_s1.png deleted file mode 100644 index 236eedc..0000000 --- a/apps/game/tools/aiart/review-b1/b1_chest_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_chest_s2.png b/apps/game/tools/aiart/review-b1/b1_chest_s2.png deleted file mode 100644 index cb69c03..0000000 --- a/apps/game/tools/aiart/review-b1/b1_chest_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_counter_s1.png b/apps/game/tools/aiart/review-b1/b1_counter_s1.png deleted file mode 100644 index 480011f..0000000 --- a/apps/game/tools/aiart/review-b1/b1_counter_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_counter_s2.png b/apps/game/tools/aiart/review-b1/b1_counter_s2.png deleted file mode 100644 index 49e68ff..0000000 --- a/apps/game/tools/aiart/review-b1/b1_counter_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_door_s1.png b/apps/game/tools/aiart/review-b1/b1_door_s1.png deleted file mode 100644 index 01718c0..0000000 --- a/apps/game/tools/aiart/review-b1/b1_door_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_door_s2.png b/apps/game/tools/aiart/review-b1/b1_door_s2.png deleted file mode 100644 index 5d00fbd..0000000 --- a/apps/game/tools/aiart/review-b1/b1_door_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_hearth_s1.png b/apps/game/tools/aiart/review-b1/b1_hearth_s1.png deleted file mode 100644 index 8ebeeeb..0000000 --- a/apps/game/tools/aiart/review-b1/b1_hearth_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_hearth_s2.png b/apps/game/tools/aiart/review-b1/b1_hearth_s2.png deleted file mode 100644 index 160376b..0000000 --- a/apps/game/tools/aiart/review-b1/b1_hearth_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_lever_s1.png b/apps/game/tools/aiart/review-b1/b1_lever_s1.png deleted file mode 100644 index c50bd15..0000000 --- a/apps/game/tools/aiart/review-b1/b1_lever_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_lever_s2.png b/apps/game/tools/aiart/review-b1/b1_lever_s2.png deleted file mode 100644 index 8a34473..0000000 --- a/apps/game/tools/aiart/review-b1/b1_lever_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_mote_s1.png b/apps/game/tools/aiart/review-b1/b1_mote_s1.png deleted file mode 100644 index 805d925..0000000 --- a/apps/game/tools/aiart/review-b1/b1_mote_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_mote_s2.png b/apps/game/tools/aiart/review-b1/b1_mote_s2.png deleted file mode 100644 index 3aeb83c..0000000 --- a/apps/game/tools/aiart/review-b1/b1_mote_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_signpost_s1.png b/apps/game/tools/aiart/review-b1/b1_signpost_s1.png deleted file mode 100644 index 1dab36e..0000000 --- a/apps/game/tools/aiart/review-b1/b1_signpost_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_signpost_s2.png b/apps/game/tools/aiart/review-b1/b1_signpost_s2.png deleted file mode 100644 index 75cbaa1..0000000 --- a/apps/game/tools/aiart/review-b1/b1_signpost_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_well_s1.png b/apps/game/tools/aiart/review-b1/b1_well_s1.png deleted file mode 100644 index 9e5d5d0..0000000 --- a/apps/game/tools/aiart/review-b1/b1_well_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review-b1/b1_well_s2.png b/apps/game/tools/aiart/review-b1/b1_well_s2.png deleted file mode 100644 index 2244504..0000000 --- a/apps/game/tools/aiart/review-b1/b1_well_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/101_an_iron_chest_with_bronze_tr.png b/apps/game/tools/aiart/review/101_an_iron_chest_with_bronze_tr.png deleted file mode 100644 index 8203cdc..0000000 --- a/apps/game/tools/aiart/review/101_an_iron_chest_with_bronze_tr.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/102_a_golden_key.png b/apps/game/tools/aiart/review/102_a_golden_key.png deleted file mode 100644 index 4d3c489..0000000 --- a/apps/game/tools/aiart/review/102_a_golden_key.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/105_an_old_oak_tree.png b/apps/game/tools/aiart/review/105_an_old_oak_tree.png deleted file mode 100644 index 8304d58..0000000 --- a/apps/game/tools/aiart/review/105_an_old_oak_tree.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/106_a_wooden_table.png b/apps/game/tools/aiart/review/106_a_wooden_table.png deleted file mode 100644 index db6e07b..0000000 --- a/apps/game/tools/aiart/review/106_a_wooden_table.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/107_a_wooden_chair.png b/apps/game/tools/aiart/review/107_a_wooden_chair.png deleted file mode 100644 index bdb1333..0000000 --- a/apps/game/tools/aiart/review/107_a_wooden_chair.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/108_a_small_grey_stone_house.png b/apps/game/tools/aiart/review/108_a_small_grey_stone_house.png deleted file mode 100644 index f2a2377..0000000 --- a/apps/game/tools/aiart/review/108_a_small_grey_stone_house.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/109_a_healing_potion_in_a_round_.png b/apps/game/tools/aiart/review/109_a_healing_potion_in_a_round_.png deleted file mode 100644 index 0209e11..0000000 --- a/apps/game/tools/aiart/review/109_a_healing_potion_in_a_round_.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/110_a_wooden_staff.png b/apps/game/tools/aiart/review/110_a_wooden_staff.png deleted file mode 100644 index ddf7d04..0000000 --- a/apps/game/tools/aiart/review/110_a_wooden_staff.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/111_an_iron_sword.png b/apps/game/tools/aiart/review/111_an_iron_sword.png deleted file mode 100644 index a92391d..0000000 --- a/apps/game/tools/aiart/review/111_an_iron_sword.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/112_a_bronze_coin.png b/apps/game/tools/aiart/review/112_a_bronze_coin.png deleted file mode 100644 index a6106a4..0000000 --- a/apps/game/tools/aiart/review/112_a_bronze_coin.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/a_chest_s1.png b/apps/game/tools/aiart/review/a_chest_s1.png deleted file mode 100644 index 4657c47..0000000 --- a/apps/game/tools/aiart/review/a_chest_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/a_chest_s2.png b/apps/game/tools/aiart/review/a_chest_s2.png deleted file mode 100644 index 0061bbb..0000000 --- a/apps/game/tools/aiart/review/a_chest_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/a_coin_s1.png b/apps/game/tools/aiart/review/a_coin_s1.png deleted file mode 100644 index 2561e82..0000000 --- a/apps/game/tools/aiart/review/a_coin_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/a_coin_s2.png b/apps/game/tools/aiart/review/a_coin_s2.png deleted file mode 100644 index 2561e82..0000000 --- a/apps/game/tools/aiart/review/a_coin_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/a_rock_s1.png b/apps/game/tools/aiart/review/a_rock_s1.png deleted file mode 100644 index 6adfdeb..0000000 --- a/apps/game/tools/aiart/review/a_rock_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/a_rock_s2.png b/apps/game/tools/aiart/review/a_rock_s2.png deleted file mode 100644 index 99dd8f8..0000000 --- a/apps/game/tools/aiart/review/a_rock_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/a_table_s1.png b/apps/game/tools/aiart/review/a_table_s1.png deleted file mode 100644 index a0e22df..0000000 --- a/apps/game/tools/aiart/review/a_table_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/a_table_s2.png b/apps/game/tools/aiart/review/a_table_s2.png deleted file mode 100644 index a0e22df..0000000 --- a/apps/game/tools/aiart/review/a_table_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/an_oak_s1.png b/apps/game/tools/aiart/review/an_oak_s1.png deleted file mode 100644 index 59c2a00..0000000 --- a/apps/game/tools/aiart/review/an_oak_s1.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/review/an_oak_s2.png b/apps/game/tools/aiart/review/an_oak_s2.png deleted file mode 100644 index c372350..0000000 --- a/apps/game/tools/aiart/review/an_oak_s2.png +++ /dev/null Binary files differ diff --git a/apps/game/tools/aiart/sd_sheet.py b/apps/game/tools/aiart/sd_sheet.py deleted file mode 100644 index 4654c86..0000000 --- a/apps/game/tools/aiart/sd_sheet.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -"""AI-шаг пайплайна атласов: генерация листа одного ракурса (SD1.5, CPU). - -Модель: SD_PixelArt_SpriteSheet_Generator (Onodofthenorth, Apache-2.0). -Токены ракурсов: PixelartFSS (фронт), PixelartRSS (право), PixelartBSS (зад), -PixelartLSS (лево). 15 шагов / cfg 7.0 — рабочий режим пробы B3 (~6.6 мин/вид -на 4 потоках fp32). Промпт персонажа приходит из манифеста пайплайна. - -Запуск (интерпретатор — env RPG_SD_PYTHON, дефолт venv-pxgpt): - $RPG_SD_PYTHON apps/game/tools/aiart/sd_sheet.py -""" -import sys -import time -from pathlib import Path - -import torch -from diffusers import StableDiffusionPipeline -from huggingface_hub import snapshot_download - -MODEL_ID = 'Onodofthenorth/SD_PixelArt_SpriteSheet_Generator' -STEPS = 15 -CFG = 7.0 -NEG = ('blurry, photo, realistic, 3d render, smooth gradients, text, watermark, ' - 'multiple characters, cropped') - - -def main() -> None: - view, seed, out_dir = sys.argv[1], int(sys.argv[2]), Path(sys.argv[3]) - prompt = sys.argv[4] - out_dir.mkdir(parents=True, exist_ok=True) - - torch.set_num_threads(4) - base = snapshot_download(MODEL_ID, local_files_only=True) - t0 = time.time() - pipe = StableDiffusionPipeline.from_pretrained(str(base), safety_checker=None) - pipe.set_progress_bar_config(disable=True) - print(f'[load] {time.time() - t0:.0f}s', flush=True) - - t0 = time.time() - img = pipe( - f'Pixelart{view} of {prompt}', - negative_prompt=NEG, - num_inference_steps=STEPS, - guidance_scale=CFG, - generator=torch.Generator('cpu').manual_seed(seed), - ).images[0] - print(f'[gen] {time.time() - t0:.0f}s', flush=True) - - out = out_dir / f'{view}_{seed}.png' - img.convert('RGBA').save(out) - print(f'[ok] {out}', flush=True) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/apps/game/tools/aiart/tryon.mjs b/apps/game/tools/aiart/tryon.mjs deleted file mode 100644 index 728ddab..0000000 --- a/apps/game/tools/aiart/tryon.mjs +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Примерка AI-атласа в игре ДО promote: временно подменяет целевой атлас - * сборкой из build//, снимает скриншот героя в локации, возвращает файлы. - * Апрув по обзорному листу — пиксели на клетке; примерка показывает якорь, - * масштаб и палитру в контексте мира. - * - * node tryon.mjs [файл-скриншота=/tmp/rpg_tryon.png] - * - * Файлы assets восстанавливаются в finally (даже при падении). - */ -import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { startDevServer, openGame } from '../lib.mjs'; - -const HERE = fileURLToPath(new URL('.', import.meta.url)); -const id = process.argv[2] ?? 'bellringer'; -const shot = process.argv[3] ?? '/tmp/rpg_tryon.png'; - -// Цель берём из манифеста (target), как её видит promote. -const manifest = JSON.parse(readFileSync(join(HERE, 'atlas-manifest.json'), 'utf8')); -const char = manifest.characters.find((c) => c.id === id); -if (!char) throw new Error(`персонаж «${id}» не найден в atlas-manifest.json`); -const buildPng = join(HERE, 'build', id, char.target); -if (!existsSync(buildPng)) throw new Error(`нет сборки ${buildPng} — сначала atlas.mjs build ${id}`); - -const dst = join(HERE, '..', '..', 'assets'); -const dstPng = join(dst, char.target); -const dstJson = dstPng.replace(/\.png$/, '.json'); -const backup = { - png: existsSync(dstPng) ? readFileSync(dstPng) : null, - json: existsSync(dstJson) ? readFileSync(dstJson) : null -}; - -let server = null, browser = null; -try { - copyFileSync(buildPng, dstPng); - // Игра ждёт кадры с префиксом hero_ (LocationScene: hero_down/up/side) — - // переписываем имена кадров сборки в JSON-копии. - const atlasJson = JSON.parse(readFileSync(join(HERE, 'build', id, char.target.replace(/\.png$/, '.json')), 'utf8')); - atlasJson.frames = Object.fromEntries( - Object.entries(atlasJson.frames).map(([k, v]) => [k.replace(new RegExp(`^${id}_`), 'hero_'), v]) - ); - writeFileSync(dstJson, JSON.stringify(atlasJson)); - console.log(`[tryon] ${char.target} подменён сборкой «${id}» (status: ${char.status}, кадры → hero_*)`); - - server = await startDevServer({ port: 5199 }); - const { browser: b, page, agent } = await openGame({ url: server.url, newGame: true }); - browser = b; - await agent.step(60); // пара шагов, чтобы герой попал в кадр ходьбы - const snap = await agent.snapshot(); - console.log('снапшот:', JSON.stringify({ area: snap.area, hero: snap.hero?.tile })); - await page.screenshot({ path: shot }); - console.log(`Скриншот: ${shot}`); -} finally { - if (backup.png) writeFileSync(dstPng, backup.png); - if (backup.json) writeFileSync(dstJson, backup.json); - if (backup.png === null) console.log('[tryon] внимание: исходного атласа не было — проверь assets вручную'); - if (browser) await browser.close(); - if (server) server.stop(); -} \ No newline at end of file diff --git a/apps/game/tools/aiaudio/analyze.py b/apps/game/tools/aiaudio/analyze.py deleted file mode 100644 index d35e3bc..0000000 --- a/apps/game/tools/aiaudio/analyze.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Численный отбор AI-клипов (работа A1): метрики вместо слушания. - -Для review/__loop.wav считает: RMS, диапазон огибающей (дыхание -лупа — std скользящего RMS), долю высокочастотной энергии (резкость/жёсткость), -центроид спектра, число клиппингов. Кандидаты против эталона сравниваются -по этим цифрам; финальный отбор — таблицей в консоль. - -Запуск: ~/.cache/rpg-ai/venv-aiaudio/bin/python tools/aiaudio/analyze.py -""" -import sys -import wave -from pathlib import Path - -import numpy as np - -HERE = Path(__file__).resolve().parent - - -def read_wav(path: Path) -> np.ndarray: - with wave.open(str(path), "rb") as w: - data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) - return data.astype(np.float64) / 32768.0 - - -def metrics(s: np.ndarray, rate: int) -> dict: - win = rate // 10 # окно огибающей 100 мс - n = len(s) // win - env = np.sqrt((s[: n * win].reshape(n, win) ** 2).mean(axis=1)) - spec = np.abs(np.fft.rfft(s * np.hanning(len(s)))) - freqs = np.fft.rfftfreq(len(s), 1 / rate) - total = spec.sum() + 1e-12 - hi = spec[freqs > 4000].sum() / total # резкость: доля >4 кГц - centroid = (spec * freqs).sum() / total - clips = int((np.abs(s) > 0.99).sum()) - return { - "rms": float(np.sqrt((s**2).mean())), - "dyn": float(env.std() / (env.mean() + 1e-12)), # дыхание: неровность огибающей - "hi": float(hi), - "cent": float(centroid), - "clip": clips, - } - - -def main() -> None: - import glob - - files: list[Path] = [] - for pat in sys.argv[1:]: - # Шаблон относительный: сначала от cwd, при пустом результате — от tools/aiaudio. - for base in (Path.cwd(), HERE): - found = sorted(glob.glob(str(base / pat))) - if found: - files.extend(Path(p) for p in found) - break - if not files: - print("нет файлов под шаблонами") - sys.exit(1) - print(f"{'файл':44} {'rms':>6} {'dyn':>6} {'hi':>6} {'cent':>6} {'clip':>5}") - for f in files: - m = metrics(*((lambda r, rate: (r, rate))(read_wav(f), 22050))) - print( - f"{f.name:44} {m['rms']:6.3f} {m['dyn']:6.3f} {m['hi']:6.3f} " - f"{m['cent']:6.0f} {m['clip']:5d}" - ) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/apps/game/tools/aiaudio/gen_audioldm2.py b/apps/game/tools/aiaudio/gen_audioldm2.py deleted file mode 100644 index 90dd70e..0000000 --- a/apps/game/tools/aiaudio/gen_audioldm2.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Генерация AI-аудио (работа A, этап A1): text-to-audio через AudioLDM2 по манифесту. - -Манифест — apps/game/tools/aiaudio/manifest.json: промпты, сиды, длительности, -gate — единственный источник истины пайплайна (аналог atlas-manifest.json в -aiart). Модель — AudioLDM2 (cvssp/audioldm2, CC-BY-NC-SA; решение A1 2026-09-09: -игра некоммерческая. Смена модели = новый gen-скрипт с тем же CLI). - -Запуск (обычно через runner: npm run aiaudio gen [id[:seed,seed]...]): - ~/.cache/rpg-ai/venv-aiaudio/bin/python gen_audioldm2.py [id[:seed]...] -Без аргументов — все сиды всех позиций манифеста. Выход — review/_.wav -(16000 Гц, моно); луп/нормализация — post.py (runner запускает сам). - -Детерминизм: seed + шаги фиксированы, промпт в манифесте; детерминизм -по-устройству (fp16 GPU ≠ fp32 CPU). GPU (RTX 3090) при наличии: fp16. -""" -import json -import sys -from pathlib import Path - -import numpy as np -import torch - -HERE = Path(__file__).resolve().parent -REVIEW = HERE / "review" -MANIFEST = HERE / "manifest.json" - -NEG_NOISE = ( - "music, melody, rhythm, drums, speech, voices, sudden loud noises, " - "harsh noise, distortion" -) - -DEFAULT_SEED = 1 - - -def load_items() -> dict[str, dict]: - """Позиции манифеста по id (промпт/длительность/сиды — источник истины).""" - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - return {it["id"]: it for it in data["items"]} - - -def load_pipeline(): - from diffusers import AudioLDM2Pipeline - - use_gpu = torch.cuda.is_available() - dtype = torch.float16 if use_gpu else torch.float32 - pipe = AudioLDM2Pipeline.from_pretrained("cvssp/audioldm2", torch_dtype=dtype) - pipe.to("cuda" if use_gpu else "cpu") - if not use_gpu: - torch.set_num_threads(12) - return pipe, use_gpu - - -def main() -> None: - items = load_items() - - # Аргументы — id или id:seed (один сид на аргумент, runner разворачивает сам). - jobs: list[tuple[str, int]] = [] - for arg in sys.argv[1:]: - ident, _, seed = arg.partition(":") - if ident not in items: - print(f"неизвестный id {ident!r}; доступно: {', '.join(items)}") - sys.exit(1) - jobs.append((ident, int(seed) if seed else DEFAULT_SEED)) - if not jobs: - for ident, it in items.items(): - jobs.extend((ident, s) for s in it.get("seeds", [DEFAULT_SEED])) - - import diffusers # noqa: F401 (после разбора аргументов — чтобы --help был быстрым) - - pipe, use_gpu = load_pipeline() - dev = "cuda fp16" if use_gpu else "cpu fp32" - REVIEW.mkdir(parents=True, exist_ok=True) - - for ident, seed in jobs: - spec = items[ident] - torch.manual_seed(seed) - np.random.seed(seed) - print(f"генерация {ident} (seed={seed}, {spec['duration']} с, {dev})...") - audio = pipe( - spec["positive"], - negative_prompt=spec.get("negative", NEG_NOISE), - num_inference_steps=100, - guidance_scale=3.0, - audio_length_in_s=spec["duration"], - ).audios[0] - audio = np.clip(audio, -1.0, 1.0) - pcm = (audio * 32767.0).astype(np.int16) - import wave - - out = REVIEW / f"{ident}_{seed}.wav" - with wave.open(str(out), "wb") as w: - w.setnchannels(1) - w.setsampwidth(2) - w.setframerate(16000) - w.writeframes(pcm.tobytes()) - print(f"готово: {out.relative_to(HERE)} ({pcm.shape[0] / 16000:.1f} с)") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/apps/game/tools/aiaudio/index.mjs b/apps/game/tools/aiaudio/index.mjs deleted file mode 100644 index 4934dc5..0000000 --- a/apps/game/tools/aiaudio/index.mjs +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Пайплайн AI-аудио (работа A1) — манифест-driven, аналог `npm run aiart`. - * - * Команды: - * npm run aiaudio list — таблица позиций манифеста (gate/сиды/promote) - * npm run aiaudio gen [id[:seed,seed]...] — генерация AudioLDM2 + пост-обработка - * (без аргументов — все сиды манифеста; sfx — без лупа, остальное — луп + --check) - * npm run aiaudio post [...] — только пост-обработка (pass-through post.py) - * npm run aiaudio promote — апрув: review/__loop.wav → - * assets/audio/.wav + gate=approved в манифесте (sfx не promote-ится — - * SFX_KEYS пуст, спек-синтез; нужен файловый SFX-путь в движке) - * npm run aiaudio view [seed...] — спектро-лист кандидата в /tmp - * (движковая тулза packages/engine/tools/spectro.mjs: взгляд агента на звук) - * - * Модель/окружение — см. шапку manifest.json; python — venv ~/.cache/rpg-ai/venv-aiaudio. - */ -import { spawnSync } from 'node:child_process'; -import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import path from 'node:path'; - -const require = createRequire(import.meta.url); -const HERE = path.dirname(require.resolve('./manifest.json')); -const ASSETS = path.resolve(HERE, '../../assets'); // apps/game/assets -const PY = path.join(process.env.HOME ?? '', '.cache/rpg-ai/venv-aiaudio/bin/python'); -const USAGE = 'npm run aiaudio ...|promote >'; - -function fail(msg) { - console.error(msg); - process.exit(1); -} - -function readManifest() { - return JSON.parse(readFileSync(path.join(HERE, 'manifest.json'), 'utf8')); -} - -/** Цели генерации: без аргументов — все сиды манифеста, иначе id[:seed,seed]. */ -function parseTargets(args, manifest) { - if (args.length === 0) { - return manifest.items.flatMap((it) => (it.seeds ?? [1]).map((seed) => ({ it, seed }))); - } - const out = []; - for (const arg of args) { - const [id, seeds] = arg.split(':'); - const it = manifest.items.find((x) => x.id === id); - if (!it) fail(`неизвестный id ${id}; доступно: ${manifest.items.map((x) => x.id).join(', ')}`); - for (const seed of (seeds ? seeds.split(',') : it.seeds ?? [1]).map(Number)) { - out.push({ it, seed }); - } - } - return out; -} - -function run(cmd, pyArgs) { - if (!existsSync(PY)) fail(`нет venv-питона: ${PY} (см. шапку manifest.json)`); - const r = spawnSync(PY, [cmd, ...pyArgs], { cwd: HERE, stdio: 'inherit' }); - if (r.status !== 0) fail(`${cmd} завершился с ошибкой`); -} - -function cmdGen(args) { - const manifest = readManifest(); - const targets = parseTargets(args, manifest); - console.log(`целей: ${targets.length} (${targets.map((t) => `${t.it.id}_${t.seed}`).join(', ')})`); - run('gen_audioldm2.py', targets.map((t) => `${t.it.id}:${t.seed}`)); - // Пост-обработка сразу: sfx — без лупа (_fx), остальное — луп + проверка шва. - for (const t of targets) { - const flags = t.it.kind === 'sfx' ? ['--noloop'] : ['--check']; - if (t.it.peak) flags.push('--peak', String(t.it.peak)); - run('post.py', [`review/${t.it.id}_${t.seed}.wav`, ...flags]); - } -} - -function cmdPromote(args) { - const [id, seedStr] = args; - if (!id || !seedStr) fail(USAGE); - const manifest = readManifest(); - const it = manifest.items.find((x) => x.id === id); - if (!it) fail(`неизвестный id ${id}`); - if (!it.promote) fail(`${id}: позиция без promote (SFX_KEYS пуст — файловых sfx пока нет)`); - const src = path.join(HERE, 'review', `${id}_${seedStr}_loop.wav`); - if (!existsSync(src)) fail(`нет файла ${src} (сначала npm run aiaudio gen ${id}:${seedStr})`); - // Промотируем только ключ, известный реестрам игры (audio.ts). - const audioTs = readFileSync(path.resolve(HERE, '../../src/data/audio.ts'), 'utf8'); - if (!audioTs.includes(`'${it.promote}'`)) fail(`ключ '${it.promote}' не объявлен в data/audio.ts — добавь сначала`); - const dst = path.join(ASSETS, 'audio', `${it.promote}.wav`); - copyFileSync(src, dst); - it.gate = 'approved'; - it.approved_seed = Number(seedStr); - it.date = new Date().toISOString().slice(0, 10); - writeFileSync(path.join(HERE, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); - console.log(`промотировано: ${it.promote}.wav (seed ${seedStr}, gate=approved)`); -} - -/** Лист спектрограмм кандидата (взгляд ИИ-агента): view [seed...] → /tmp/*.png. */ -function cmdView(args) { - const [id, ...seeds] = args; - if (!id) fail('использование: npm run aiaudio view [seed,seed...]'); - const manifest = readManifest(); - const it = manifest.items.find((x) => x.id === id); - if (!it) fail(`неизвестный id ${id}`); - const files = (seeds.length ? seeds.map(Number) : it.seeds ?? [1]).map((s) => { - const loop = it.kind !== 'sfx'; - const suffix = loop ? '_loop.wav' : '_fx.wav'; - const p = path.join(HERE, 'review', `${id}_${s}${suffix}`); - if (!existsSync(p)) fail(`нет файла ${p} (сначала npm run aiaudio gen ${id}:${s})`); - return `${p}${loop ? ':loop' : ''}`; - }); - const script = path.resolve(HERE, '../../../../packages/engine/tools/spectro.mjs'); - const out = `/tmp/aiaudio_${id}_spec.png`; - const r = spawnSync('node', [script, out, ...files], { stdio: 'inherit' }); - if (r.status !== 0) fail('spectro завершился с ошибкой'); -} - -function cmdList() { - const manifest = readManifest(); - for (const it of manifest.items) { - const gate = it.gate === 'approved' ? `approved(seed ${it.approved_seed})` : it.gate ?? '—'; - console.log(`${it.id.padEnd(20)} ${it.kind.padEnd(8)} сиды ${(it.seeds ?? [1]).join(',').padEnd(8)} ${gate.padEnd(20)} ${it.promote ?? ''}`); - } -} - -const [cmd, ...args] = process.argv.slice(2); -if (cmd === 'list') cmdList(); -else if (cmd === 'gen') cmdGen(args); -else if (cmd === 'post') run('post.py', args); -else if (cmd === 'promote') cmdPromote(args); -else if (cmd === 'view') cmdView(args); -else fail(`использование: ${USAGE}`); \ No newline at end of file diff --git a/apps/game/tools/aiaudio/manifest.json b/apps/game/tools/aiaudio/manifest.json deleted file mode 100644 index a4e25c4..0000000 --- a/apps/game/tools/aiaudio/manifest.json +++ /dev/null @@ -1,289 +0,0 @@ -{ - "run": "a1-pipeline", - "date": "2026-09-09", - "model": "AudioLDM2 (cvssp/audioldm2), RTX 3090 fp16 (torch 2.14.0+cu130), diffusers 0.35.2 + transformers 4.49.0", - "env": "~/.cache/rpg-ai/venv-aiaudio (pinned; diffusers 0.40 несовместим с transformers 4.x — _update_model_kwargs_for_generation удалён в 5.x)", - "license_note": "веса CC-BY-NC-SA 4.0 — решение A1 (2026-09-09, пользователь): строим пайплайн на AudioLDM2, игра некоммерческая; главный путь плана (Stable Audio Open Small) остаётся опцией — пайплайн манифест-driven, смена модели = новый gen-скрипт с тем же CLI", - "pipeline": "npm run aiaudio gen [id[:seed]...] -> post.py (ресэмпл 22050, нормализация 0.85, loopify 1 с) -> npm run aiaudio promote -> assets/audio/.wav; sfx — без лупа (_fx) и без promote (SFX_KEYS пуст, спек-синтез E4)", - "timing": "10 с аудио, 100 шагов: GPU ~15 с; CPU fp32 12 ядер ~3.5 мин", - "loop_check": "post.py --check: скачок на шве против максимума межсэмплового diff в середине записи", - "criterion": "ambience/layer — лучше процедурного эталона (tools/audio/gen.mjs) на слух; sfx — лучше спек-синтеза (data/sfxSpecs.ts); апрув — пользователь на слух", - "items": [ - { - "id": "amb_ponds", - "kind": "ambience", - "promote": "ambience/ponds", - "seeds": [ - 1, - 2 - ], - "duration": 10, - "gate": "approved", - "approved_seed": 1, - "date": "2026-09-09", - "positive": "field recording, calm misty pond at dusk, gentle water lapping, soft wind through reeds, occasional distant croaking, eerie quiet melancholic atmosphere, seamless ambience", - "note": "эталон ambiencePonds; луга молчат по дизайну, луп прудов апрувнут A0" - }, - { - "id": "amb_zvenets", - "kind": "ambience", - "promote": "ambience/zvenets", - "seeds": [ - 1, - 2 - ], - "duration": 10, - "gate": "approved", - "approved_seed": 1, - "date": "2026-09-09", - "positive": "field recording, quiet rural settlement at dusk, low distant metallic bell toll, soft wind, faint rustling, somber melancholic atmosphere, seamless ambience", - "note": "эталон ambienceZvenets; апрувнут A0" - }, - { - "id": "amb_meadows", - "kind": "ambience", - "seeds": [ - 1, - 2 - ], - "duration": 10, - "gate": "hold", - "date": "2026-09-09", - "positive": "field recording, desolate burned field, cold dry wind blowing over dead grass, occasional faint debris rattling, bleak post-fire emptiness, seamless ambience", - "note": "в игре луга без амбиента (тишина пепла) — клипы держим как запас" - }, - { - "id": "amb_ponds_night", - "kind": "ambience", - "promote": "ambience/ponds_night", - "seeds": [ - 1, - 2 - ], - "duration": 10, - "gate": "approved", - "approved_seed": 1, - "date": "2026-09-09", - "positive": "field recording, pond at night, deeper water lapping, night insects, distant eerie echoes, dark oppressive quiet, seamless ambience", - "note": "эталон ambiencePondsNight; апрувнут A0" - }, - { - "id": "amb_tower_hum", - "kind": "layer", - "promote": "ambience/tower_hum", - "seeds": [ - 1 - ], - "duration": 10, - "gate": "approved", - "approved_seed": 1, - "date": "2026-09-09", - "positive": "deep metallic resonance hum, giant bell tower vibrating, low sustained drone with slow shimmer, ominous and ancient", - "note": "слой layerTowerHum; апрувнут A0" - }, - { - "id": "amb_rust_grove", - "kind": "ambience", - "promote": "ambience/rust_grove", - "seeds": [ - 1, - 2, - 3 - ], - "duration": 10, - "gate": "approved", - "positive": "field recording, dead forest of rusted metal trees wrapped in black cables, wind through cable strands causing faint creaks and rare distant metallic chimes, low rustle of ash, somber mysterious atmosphere, seamless ambience", - "note": "новое A1: роще свой луп (seed 2 — тихий, редкие яркие скрипы); заменил подстановку прудов", - "approved_seed": 2, - "date": "2026-09-09" - }, - { - "id": "amb_old_siding", - "kind": "ambience", - "promote": "ambience/old_siding", - "seeds": [ - 1, - 2, - 3 - ], - "duration": 10, - "gate": "approved", - "positive": "field recording, abandoned railway siding in ash wastes at dusk, faint metallic squeak of old rails, cold wind over ash ground, very distant low machinery pulse, desolate quiet, seamless ambience", - "note": "новое A1: разъезду свой луп (seed 3 — событийная огибающая, скрипы рельсов); заменил подстановку прудов", - "approved_seed": 3, - "date": "2026-09-09" - }, - { - "id": "amb_zvenets_night", - "kind": "ambience", - "promote": "ambience/zvenets_night", - "seeds": [ - 1, - 2, - 3 - ], - "duration": 10, - "gate": "hold", - "positive": "field recording, quiet rural settlement at night, very low distant bell resonance, cold night wind, sparse faint insect chirps, deep somber quiet, seamless ambience", - "note": "оставляем процедурный эталон: у него огибающая живее (dyn 0.46 против 0.09–0.18) — отбор по метрикам, пользователь делегировал решение" - }, - { - "id": "layer_houses", - "kind": "layer", - "promote": "ambience/houses", - "seeds": [ - 1, - 2 - ], - "duration": 10, - "gate": "hold", - "positive": "quiet interior murmur of old wooden settlement houses, faint wood creaks, muffled domestic sounds behind walls, hearth crackling far away, close perspective, seamless ambience", - "note": "оставляем процедурный эталон: тихий с событиями треска (dyn 2.28, rms 0.03) против ровных AI-клипов" - }, - { - "id": "layer_wind", - "kind": "layer", - "promote": "ambience/wind", - "seeds": [ - 1, - 2, - 3, - 4 - ], - "duration": 10, - "peak": 0.6, - "gate": "approved", - "positive": "field recording, cold wind howling across a vast burned field, long slow gusts that swell and fade away, faint low mournful whistle, occasional light debris rattling far away, quiet distant perspective, seamless ambience", - "note": "новое A1 (фидбек: завывание, периодическое затухание, тихий): пик 0.6 поверх игрового гейна слоя 0.25–0.4", - "approved_seed": 3, - "date": "2026-09-09" - }, - { - "id": "layer_ponds_water", - "kind": "layer", - "promote": "ambience/ponds_water", - "seeds": [ - 1, - 2 - ], - "duration": 10, - "gate": "hold", - "positive": "gentle pond water lapping softly against a shore, calm small ripples, quiet close-up foley, seamless ambience", - "note": "оставляем процедурный эталон: dyn 0.67 против 0.15–0.26 у AI" - }, - { - "id": "sfx_step", - "kind": "sfx", - "seeds": [ - 1 - ], - "duration": 2.5, - "gate": "candidate", - "positive": "single soft footstep on dry crumbly ash and dead grass, short subtle crunch, close up, dry foley", - "note": "SFX-кандидаты A0/A1: в игре спек-синтез E4; promote после сравнения на слух (нужен файловый SFX-путь)" - }, - { - "id": "sfx_click", - "kind": "sfx", - "seeds": [ - 1 - ], - "duration": 1.5, - "gate": "candidate", - "positive": "single short UI click, soft wooden tap, clean and dry, interface sound", - "note": "SFX-кандидат" - }, - { - "id": "sfx_bell", - "kind": "sfx", - "seeds": [ - 1, - 2 - ], - "duration": 5, - "gate": "candidate", - "positive": "single bronze bell strike, resonant metallic ring with long natural decay, solemn, temple bell, one hit only", - "note": "SFX-кандидат; эталон bellHit" - }, - { - "id": "sfx_whoosh", - "kind": "sfx", - "seeds": [ - 1 - ], - "duration": 2, - "gate": "candidate", - "positive": "single quick air whoosh, fast swing passing by, short swoosh transition", - "note": "SFX-кандидат; эталон whoosh" - }, - { - "id": "sfx_hit", - "kind": "sfx", - "seeds": [ - 1 - ], - "duration": 2, - "gate": "candidate", - "positive": "single dull impact hit, heavy blunt thud with brief echo, combat punch sound, one hit only", - "note": "SFX-кандидат" - }, - { - "id": "sfx_pickup", - "kind": "sfx", - "seeds": [ - 1 - ], - "duration": 2, - "gate": "candidate", - "positive": "single gentle collect chime, soft bright pling, short delicate pickup sound, one note only", - "note": "SFX-кандидат; эталон motepick" - }, - { - "id": "sfx_drop", - "kind": "sfx", - "seeds": [ - 1 - ], - "duration": 2, - "gate": "candidate", - "positive": "single water drop into calm pond, soft plop with tiny ripple, close up, one drop only", - "note": "SFX-кандидат; эталон капли ambiencePonds" - }, - { - "id": "music_meadows", - "kind": "music", - "seeds": [ - 1, - 2 - ], - "duration": 10.0, - "gate": "hold", - "positive": "slow melancholic ambient music loop, sparse plucked melody over a low warm drone pad, minor pentatonic, desolate ash-gray atmosphere, no drums, no vocals, seamless loop", - "note": "новое A2: кандидат против спек-темы MEADOW_THEME (70 bpm, пэд+плак); promote пока некуда — музыка играет из MusicSpec в рантайме, нужен файловый путь на шине music; решение A2 (2026-09-09, делегировано): спек-темы остаются — AI-музыка не слушалась, AudioLDM2 музыкально слаб; лупы держим до Small-Music/слушания" - }, - { - "id": "music_combat", - "kind": "music", - "seeds": [ - 1, - 2 - ], - "duration": 10.0, - "gate": "hold", - "positive": "tense driving music loop, persistent low bass riff over a dark pulsing drone, urgent but muffled, minor key, no drums, no vocals, seamless loop", - "note": "новое A2: кандидат против COMBAT_THEME (112 bpm рифф); решение A2 (2026-09-09, делегировано): спек-темы остаются — AI-музыка не слушалась, AudioLDM2 музыкально слаб; лупы держим до Small-Music/слушания" - }, - { - "id": "music_tension", - "kind": "music", - "seeds": [ - 1, - 2 - ], - "duration": 10.0, - "gate": "hold", - "positive": "ominous quiet suspense music loop, deep sustained drone with rare distant high bell tolls, sparse and eerie, no drums, no vocals, seamless loop", - "note": "новое A2: кандидат против TENSION_STEM (дрон + редкие колокола); решение A2 (2026-09-09, делегировано): спек-темы остаются — AI-музыка не слушалась, AudioLDM2 музыкально слаб; лупы держим до Small-Music/слушания" - } - ] -} diff --git a/apps/game/tools/aiaudio/post.py b/apps/game/tools/aiaudio/post.py deleted file mode 100644 index e1de11d..0000000 --- a/apps/game/tools/aiaudio/post.py +++ /dev/null @@ -1,98 +0,0 @@ -"""PoC (работа A, этап A0): пост-обработка сгенерированного WAV под формат игры. - -Вход: review/_.wav — AudioLDM2, 16000 Гц, моно, 16-бит. -Выход: review/__loop.wav — 22050 Гц, моно 16-бит, нормализованный, - бесшовный луп (хвост кроссфейдом вплетается в начало — стык без щелчка). - -Запуск: ~/.cache/rpg-ai/venv-aiaudio/bin/python tools/aiaudio/post.py review/amb_ponds_1.wav -Флаги: --check — сверить стык с фоном записи; --noloop — SFX без лупа - (ресэмпл + нормализация, хвост не вплетается). -""" -import sys -import wave -from pathlib import Path - -import numpy as np -from scipy.signal import resample_poly - -HERE = Path(__file__).resolve().parent -TARGET_RATE = 22050 # формат игры: моно, 22050 Гц, 16-бит (см. audio/gen.mjs) -FADE = 1.0 # длительность кроссфейда лупа, с -PEAK = 0.85 # целевой пик нормализации - - -def read_wav(path: Path) -> tuple[np.ndarray, int]: - with wave.open(str(path), "rb") as w: - rate = w.getframerate() - data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) - return data.astype(np.float64) / 32768.0, rate - - -def write_wav(path: Path, samples: np.ndarray) -> None: - pcm = np.clip(samples * 32767.0, -32768, 32767).astype(np.int16) - with wave.open(str(path), "wb") as w: - w.setnchannels(1) - w.setsampwidth(2) - w.setframerate(TARGET_RATE) - w.writeframes(pcm.tobytes()) - - -def loopify(s: np.ndarray, rate: int, fade: float) -> np.ndarray: - """Хвост (последние fade с) кроссфейдом вплетается в начало — луп без шва. - - B[0..fade] = tail·(1-w) + head·w (хвост затухает, голова нарастает), - дальше — средина записи; шов (конец -> начало) соседние сэмплы A. - Длина выхода = len - fade с. - """ - n = int(fade * rate) - w = np.linspace(0.0, 1.0, n) - tail, head, body = s[-n:], s[:n], s[n:-n] - return np.concatenate([tail * (1 - w) + head * w, body]) - - -def main() -> None: - # Разбор аргументов вручную: --peak <значение> — цель нормализации - # (манифест может задавать тише для слоёв; по умолчанию PEAK). - peak_target = PEAK - args: list[str] = [] - raw = sys.argv[1:] - i = 0 - while i < len(raw): - if raw[i] == "--peak": - peak_target = float(raw[i + 1]) - i += 2 - elif not raw[i].startswith("--"): - args.append(raw[i]) - i += 1 - else: - i += 1 - check = "--check" in raw - noloop = "--noloop" in raw - if len(args) != 1: - print("использование: post.py [--check] [--noloop] [--peak 0.85]") - sys.exit(1) - src = Path(args[0]).resolve() - s, rate = read_wav(src) - s = resample_poly(s, TARGET_RATE, rate) # 16000 -> 22050 (целые множители: 441/320) - s = s if noloop else loopify(s, TARGET_RATE, FADE) - # Нормализация ПОСЛЕ loopify: пик модели часто в фейдах головы/хвоста, - # которые луп вплетает/выбрасывает — иначе фактический пик ниже цели. - peak = np.abs(s).max() - if peak > 1e-9: - s = s * (peak_target / peak) - out = src.with_name(src.stem + ("_fx.wav" if noloop else "_loop.wav")) - write_wav(out, s) - dur = s.shape[0] / TARGET_RATE - print(f"готово: {out.relative_to(HERE)} ({dur:.1f} с, {TARGET_RATE} Гц, пик {peak_target})") - - if check: - # Стык без щелчка: максимальный межсэмпловый скачок на шве против - # такого же скачка в середине записи — шов не должен выделяться. - mid = np.abs(np.diff(s[len(s) // 4 : len(s) // 4 + TARGET_RATE])).max() - seam = np.abs(s[-1] - s[0]) - print(f"стык: скачок на шве {seam:.4f}, максимум в середине {mid:.4f}") - assert seam <= max(mid, 0.01), "шов лупа подозрительно громкий" - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/apps/game/tools/audio/gen.d.mts b/apps/game/tools/audio/gen.d.mts deleted file mode 100644 index 4315d44..0000000 --- a/apps/game/tools/audio/gen.d.mts +++ /dev/null @@ -1,15 +0,0 @@ -/** Типы для парити-теста sfxSpecs: экспортируемые формулы gen.mjs. */ -export declare function bellHit(): Float32Array; -export declare function bellLow(): Float32Array; -export declare function ashHiss(): Float32Array; -export declare function ashDie(): Float32Array; -export declare function spit(): Float32Array; -export declare function hurt(): Float32Array; -export declare function motePickup(): Float32Array; -export declare function pickup(): Float32Array; -export declare function chestOpen(): Float32Array; -export declare function note(): Float32Array; -export declare function whoosh(): Float32Array; -export declare function deerStartle(): Float32Array; -export declare function uiClick(): Float32Array; -export declare function chime(): Float32Array; diff --git a/apps/game/tools/audio/gen.mjs b/apps/game/tools/audio/gen.mjs deleted file mode 100644 index fe6a4a3..0000000 --- a/apps/game/tools/audio/gen.mjs +++ /dev/null @@ -1,371 +0,0 @@ -/** - * Генератор амбиента «Пепельных лугов» — WAV из кода, без зависимостей. - * Запуск: node tools/audio/gen.mjs - * Выход: apps/game/assets/audio/ambience/*.wav - * - * Все sfx переехали на спек-синтез (data/sfxSpecs.ts, компилирует движок) — - * здесь остались только многослойные амбиент-лупы. Формулы бывших sfx - * экспортированы ниже: их сверяет с рендером спеков парити-тест - * data/__tests__/sfxSpecs.test.ts (звучание мигрированных звуков не менялось). - * - * Формат: моно, 22050 Гц, 16-бит PCM. Меланхоличный пепельный саунд. - * WAV-энкодер и примитивы синтеза — жанронезависимые, в @rpg/engine/tools/wav.mjs. - */ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { encodeWav, makeRng, synth, decay, bellPartial, bandNoise, normalize, loopify } from '@rpg/engine/tools/wav.mjs'; - -const OUT = fileURLToPath(new URL('../../assets/audio/', import.meta.url)); - -// ---------- звуки ---------- - -/** Гулкий удар колокольчиком: 4 негармоничных партиала + шумовой клик. - * Экспортируется для парити-теста: renderSpec(SPEC_SFX['sfx/bell_hit']) бит-в-бит. */ -export function bellHit() { - const dur = 0.35; - const s = synth(dur, (t) => { - const body = - bellPartial(520, 1.0, dur, 5)(t) * 0.5 + - bellPartial(520, 1.52, dur, 6)(t) * 0.3 + - bellPartial(520, 2.38, dur * 0.6, 7)(t) * 0.2 + - bellPartial(520, 3.91, dur * 0.4, 8)(t) * 0.12; - const click = t < 0.008 ? (makeRng(1)() * 2 - 1) * (1 - t / 0.008) * 0.4 : 0; - return body * 0.8 + click; - }); - return normalize(s, 0.7); -} - -/** Низкий резонанс: медленный гул с тремоло — «усыпляет пепел». */ -export function bellLow() { - const dur = 0.9; - const s = synth(dur, (t) => { - const trem = 1 + 0.3 * Math.sin(2 * Math.PI * 6 * t); - const body = - Math.sin(2 * Math.PI * 110 * t) * 0.5 + - Math.sin(2 * Math.PI * 165 * t) * 0.25 + - Math.sin(2 * Math.PI * 220.5 * t) * 0.12; - return body * decay(t, dur, 2) * trem; - }); - return normalize(s, 0.6); -} - -/** Шипение: сгусток просыпается. */ -export function ashHiss() { - const dur = 0.4; - const noise = bandNoise(11, 900, 4200, dur); - const s = synth(dur, (t) => noise() * Math.min(1, t / 0.08) * decay(t, dur, 2)); - return normalize(s, 0.55); -} - -/** Смерть сгустка: шум с падающим «выдохом». */ -export function ashDie() { - const dur = 0.6; - const noise = bandNoise(23, 300, 2400, dur); - const s = synth(dur, (t) => { - const pitch = 1 - 0.7 * (t / dur); // темнеет к концу - return noise() * decay(t, dur, 3) * (0.4 + pitch); - }); - return normalize(s, 0.6); -} - -/** Плевок: синус с падающей высотой + шипящий хвост. */ -export function spit() { - const dur = 0.2; - const noise = bandNoise(37, 2000, 6000, dur); - const s = synth(dur, (t) => { - const f = 700 - 350 * (t / dur); - return (Math.sin(2 * Math.PI * f * t) * 0.6 + noise() * 0.4) * decay(t, dur, 3); - }); - return normalize(s, 0.5); -} - -/** Урон герою: глухой низкий удар. */ -export function hurt() { - const dur = 0.25; - const noise = bandNoise(41, 80, 400, dur); - const s = synth(dur, (t) => { - const thump = Math.sin(2 * Math.PI * 90 * t) * decay(t, dur, 3); - return thump * 0.8 + noise() * 0.3 * decay(t, dur * 0.5, 2); - }); - return normalize(s, 0.65); -} - -/** Мот подобран: мерцающий двойной звон (тёплая искра в серой земле). */ -export function motePickup() { - const dur = 0.45; - const s = synth(dur, (t) => { - const body = - Math.sin(2 * Math.PI * 1240 * t) * 0.4 + - Math.sin(2 * Math.PI * 1860 * t) * 0.2 * decay(t, dur, 4); - return body * decay(t, dur, 2.5); - }); - return normalize(s, 0.4); -} - -/** Предмет в сумку: мягкий «пок». */ -export function pickup() { - const dur = 0.14; - const s = synth(dur, (t) => { - const f = 520 + 220 * (t / dur); // чуть взлетает - return Math.sin(2 * Math.PI * f * t) * decay(t, dur, 2.5); - }); - return normalize(s, 0.4); -} - -/** Сундук открылся: низкий удар крышки + скрип петель. */ -export function chestOpen() { - const dur = 0.55; - const noise = bandNoise(157, 300, 1400, dur); - const s = synth(dur, (t) => { - const thump = Math.sin(2 * Math.PI * 110 * t) * decay(t, 0.12, 2) * 0.7; - const creak = noise() * Math.min(1, t / 0.1) * decay(t, dur, 1.5) * 0.35; - return thump + creak; - }); - return normalize(s, 0.45); -} - -/** Записка: шелест бумаги. */ -export function note() { - const dur = 0.35; - const noise = bandNoise(159, 1800, 6000, dur); - const s = synth(dur, (t) => noise() * (0.4 + 0.6 * Math.sin(2 * Math.PI * 3.2 * t + 1)) * decay(t, dur, 1.5)); - return normalize(s, 0.3); -} - -/** Переход локаций: воздух уносит — шум с подъёмом и спадом. - * Экспортируется для парити-теста (спек sfx/whoosh). */ -export function whoosh() { - const dur = 0.7; - const noise = bandNoise(160, 200, 2400, dur); - const s = synth(dur, (t) => { - const swell = Math.sin(Math.PI * Math.min(1, t / dur)); // 0 → 1 → 0 - return noise() * swell; - }); - return normalize(s, 0.4); -} - -/** Олень вспугнут: глухой выдох + стук копыт. */ -export function deerStartle() { - const dur = 0.4; - const noise = bandNoise(161, 150, 900, dur); - const s = synth(dur, (t) => { - const breath = noise() * Math.min(1, t / 0.05) * decay(t, dur, 2); - const hoof = t > 0.2 && t < 0.28 ? Math.sin(2 * Math.PI * 90 * (t - 0.2)) * decay(t - 0.2, 0.08, 2) * 0.6 : 0; - return breath * 0.7 + hoof; - }); - return normalize(s, 0.4); -} - -/** Клик UI. */ -export function uiClick() { - const dur = 0.06; - const s = synth(dur, (t) => Math.sin(2 * Math.PI * 880 * t) * decay(t, dur, 3) * 0.5); - return normalize(s, 0.4); -} - -/** Колокольчик Милы: высокий, почти хрустальный. */ -export function chime() { - const dur = 0.5; - const s = synth(dur, (t) => { - const body = - Math.sin(2 * Math.PI * 1560 * t) * 0.5 + - Math.sin(2 * Math.PI * 1560 * 1.61 * t) * 0.25; - return body * decay(t, dur, 3); - }); - return normalize(s, 0.45); -} - -function ambiencePonds() { - const dur = 8; - const wind = bandNoise(71, 60, 400, dur); - const r = makeRng(13); - const drops = []; - for (let i = 0; i < 4; i++) drops.push({ at: r() * dur, f: 900 + r() * 600 }); - const s = synth(dur, (t) => { - let v = wind() * 0.3 * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur + 3)); - for (const d of drops) { - const dt = t - d.at + (t < d.at ? dur : 0); - if (dt >= 0 && dt < 0.15) v += Math.sin(2 * Math.PI * d.f * dt * (1 - dt * 3)) * 0.08 * decay(dt, 0.15, 2); - } - return v; - }); - return loopify(normalize(s, 0.5)); -} - -/** - * Амбиент Звенца: низкий колокольный тон (два удара за цикл) над тихим - * ветром. Колокол башни отлит из рельсов — голос ниже лугового гула. - */ -function ambienceZvenets() { - const dur = 8; - const wind = bandNoise(81, 80, 500, dur); - const strikes = [ - { at: 1.0, f: 148 }, - { at: 5.0, f: 111 } - ]; - const s = synth(dur, (t) => { - let v = wind() * 0.2 * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur + 0.5)); - for (const s of strikes) { - const dt = t - s.at + (t < s.at ? dur : 0); // зациклить удары - if (dt >= 0 && dt < 2.5) { - v += - (Math.sin(2 * Math.PI * s.f * dt) * 0.22 + - Math.sin(2 * Math.PI * s.f * 2.76 * dt) * 0.08 + - Math.sin(2 * Math.PI * s.f * 5.4 * dt) * 0.04) * - decay(dt, 2.5, 3); - } - } - return v; - }); - return loopify(normalize(s, 0.55)); -} - -/** - * Ночь на лугах: та же фактура ветра, что днём, но глубже, тише и с меньшими - * порывами; вдалеке гудит машина (Ржавая роща не спит) и изредка доходит - * колокол Звенца. - */ -function ambiencePondsNight() { - const dur = 8; - const wind = bandNoise(83, 50, 250, dur); - const lap = bandNoise(84, 200, 900, dur); - const r = makeRng(19); - const drops = []; - for (let i = 0; i < 7; i++) drops.push({ at: r() * dur, f: 700 + r() * 500 }); - const s = synth(dur, (t) => { - let v = wind() * 0.22 * (0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur + 1)); - // Ночью вода «дышит» ровнее и ближе. - v += lap() * 0.14 * (0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur + 3)); - for (const d of drops) { - const dt = t - d.at + (t < d.at ? dur : 0); - if (dt >= 0 && dt < 0.15) v += Math.sin(2 * Math.PI * d.f * dt * (1 - dt * 3)) * 0.09 * decay(dt, 0.15, 2); - } - return v; - }); - return loopify(normalize(s, 0.5)); -} - -/** - * Ночь в Звенце: ветер ниже, колокол бьёт реже и глуше, под ним — низкий - * гул башни, не смолкающий и ночью. - */ -function ambienceZvenetsNight() { - const dur = 8; - const wind = bandNoise(93, 60, 350, dur); - const strikes = [ - { at: 2.0, f: 111 }, - { at: 6.0, f: 98 } - ]; - const s = synth(dur, (t) => { - let v = wind() * 0.24 * (0.65 + 0.35 * Math.sin(2 * Math.PI * t / dur + 2.5)); - // Башня не спит: тихий двойной гул под всем прочим. - v += (Math.sin(2 * Math.PI * 55 * t) * 0.08 + Math.sin(2 * Math.PI * 82.5 * t) * 0.05) * (0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur * 2)); - for (const s of strikes) { - const dt = t - s.at + (t < s.at ? dur : 0); - if (dt >= 0 && dt < 2.5) - v += - (Math.sin(2 * Math.PI * s.f * dt) * 0.2 + - Math.sin(2 * Math.PI * s.f * 2.76 * dt) * 0.07 + - Math.sin(2 * Math.PI * s.f * 5.4 * dt) * 0.03) * - decay(dt, 2.5, 3); - } - return v; - }); - return loopify(normalize(s, 0.55)); -} - -// ---------- локальные амбиент-слои ---------- - -function layerPondsWater() { - const dur = 8; - const lap = bandNoise(172, 300, 1500, dur); - const r = makeRng(27); - const drops = []; - for (let i = 0; i < 6; i++) drops.push({ at: r() * dur, f: 800 + r() * 700 }); - const s = synth(dur, (t) => { - // Плебск «дышит» медленной волной. - let v = lap() * 0.12 * (0.5 + 0.5 * Math.sin(2 * Math.PI * t / dur + 2)); - for (const d of drops) { - const dt = t - d.at + (t < d.at ? dur : 0); // зациклить капли - if (dt >= 0 && dt < 0.12) v += Math.sin(2 * Math.PI * d.f * dt) * 0.1 * decay(dt, 0.12, 2); - } - return v; - }); - return loopify(normalize(s, 0.5)); -} - -/** Слой «башня Звенца»: низкий гул колокола (55+82.5 Гц) с биением. */ -function layerTowerHum() { - const dur = 8; - const s = synth(dur, (t) => { - const breath = 0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur * 2 + 0.5); - const body = - Math.sin(2 * Math.PI * 55 * t) * 0.5 + - Math.sin(2 * Math.PI * 82.5 * t) * 0.3; // биение 27.5 Гц - return body * breath * 0.35; - }); - return loopify(normalize(s, 0.5)); -} - -/** Слой «дома Звенца»: редкие скрипы вагонных щитов. */ -function layerHouses() { - const dur = 8; - const noise = bandNoise(174, 200, 1000, dur); - const r = makeRng(41); - const creaks = []; - for (let i = 0; i < 4; i++) creaks.push({ at: r() * dur, len: 0.5 + r() * 0.6 }); - const s = synth(dur, (t) => { - let v = 0; - for (const c of creaks) { - const dt = t - c.at + (t < c.at ? dur : 0); // зациклить скрипы - if (dt >= 0 && dt < c.len) { - const wob = 1 + 0.5 * Math.sin(2 * Math.PI * 5 * dt); - v += noise() * wob * decay(dt, c.len, 1.5) * 0.25; - } - } - return v; - }); - return loopify(normalize(s, 0.5)); -} - -/** Слой «ветер» (глобальный): свистящая высокая полоса с редкими порывами. */ -function layerWind() { - const dur = 8; - const air = bandNoise(175, 300, 1400, dur); - const r = makeRng(47); - const gusts = []; - for (let i = 0; i < 2; i++) gusts.push({ at: r() * dur, len: 2 + r() }); - const s = synth(dur, (t) => { - let v = air() * 0.18 * (0.55 + 0.45 * Math.sin(2 * Math.PI * t / dur + 1.5)); - for (const g of gusts) { - const dt = t - g.at + (t < g.at ? dur : 0); // зациклить порывы - if (dt >= 0 && dt < g.len) v += air() * 0.22 * decay(dt, g.len, 1.5); - } - return v; - }); - return loopify(normalize(s, 0.5)); -} - -// ---------- запись ---------- - -const AMBIENCE = { - 'ambience/ponds': ambiencePonds, - 'ambience/zvenets': ambienceZvenets, - // Ночные варианты: ниже, плотнее, с дальними механизмами (кроссфейд по часам). - 'ambience/ponds_night': ambiencePondsNight, - 'ambience/zvenets_night': ambienceZvenetsNight, - 'ambience/ponds_water': layerPondsWater, - 'ambience/tower_hum': layerTowerHum, - 'ambience/houses': layerHouses, - 'ambience/wind': layerWind -}; -// Записываем WAV только при запуске как CLI, а не при импорте (парити-тест -// импортирует формулы bellHit/whoosh, чтобы сверить их со спек-синтезом). -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - mkdirSync(`${OUT}ambience`, { recursive: true }); - for (const [name, gen] of Object.entries(AMBIENCE)) { - const buf = encodeWav(gen()); - writeFileSync(`${OUT}${name}.wav`, buf); - console.log(`${name}.wav ${(buf.length / 1024).toFixed(1)} КБ`); - } -} \ No newline at end of file diff --git a/apps/game/tools/checks/agent-invariants.mjs b/apps/game/tools/checks/agent-invariants.mjs deleted file mode 100644 index cf8451f..0000000 --- a/apps/game/tools/checks/agent-invariants.mjs +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Сценарий agent:invariants — мост жив, мир стабилен. - * Новая игра -> 300 фиксированных шагов -> движковые/сценические/контентные - * инварианты не содержат ошибок. - * Запуск: node tools/agent.mjs run tools/checks/agent-invariants.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function ({ pretty }) { - return withChecks( - 'agent-invariants', - async (t) => { - await t.c.run('новая игра через мост', async () => { - await t.boot(); - const s = await t.ctx.agent.snapshot(); - t.c.expect(s.scene === 'location', 'сцена не location', { scene: s.scene, err: s.error }); - return null; - }); - await t.c.run('300 шагов без ошибок инвариантов', async () => { - await t.ctx.agent.step(300); - await t.expectInvariantsClean('после 300 шагов'); - return null; - }); - await t.c.run('снапшот содержит героя и флаги', async () => { - const s = await t.ctx.agent.snapshot(); - t.c.expect(!!s.hero?.tile, 'нет s.hero.tile', s); - t.c.expect(Array.isArray(s.flags), 'нет s.flags'); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/ai.mjs b/apps/game/tools/checks/ai.mjs deleted file mode 100644 index 622c545..0000000 --- a/apps/game/tools/checks/ai.mjs +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Сценарий ai — ИИ врагов (батч 4). - * 1) тяжеловес с патрулём стартует бодрым в patrol и реально ходит; - * 2) тихий шум (0.35) при далёком герое — настороженность (wary); - * 3) не увидел героя — wary гаснет, возврат к дому и снова patrol; - * 4) герой рядом с LOS — погоня (chase); - * 5) герой далеко — потеря погони (return -> patrol); - * 6) мало hp — отступление бегом (flee), дистанция растёт; - * 7) инварианты (enemy-state-legal, enemy-in-bounds) чисты. - * Запуск: node tools/agent.mjs run tools/checks/ai.mjs - */ -import { withChecks } from '../lib.mjs'; - -const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); -/** Тяжеловес из снапшота. */ -const heavy = (s) => (s.enemies ?? []).find((e) => e.kind === 'heavy'); - -export default async function ({ pretty }) { - return withChecks( - 'ai', - async (t) => { - const { c } = t; - await c.run('патруль: тяжеловес бодр и ходит по маршруту', async () => { - await t.boot(); - const s0 = await t.ctx.agent.snapshot(); - const h0 = heavy(s0); - c.expect(h0?.state === 'patrol', 'тяжеловес не в patrol на старте', h0); - // Спавн (12,44), патруль к (8,40): позиция меняется без участия - // героя; путь диагональный — с запасом по времени. - const w = await t.ctx.agent.waitFor( - '(s.enemies.find((e) => e.kind === "heavy")?.pos.x ?? 9) < 10', - { timeoutTicks: 1800 } - ); - c.expect(w.ok, 'тяжеловес не сдвинулся с места в патруле', w.snapshot.enemies); - return null; - }); - - await c.run('тихий шум настораживает патрульного', async () => { - // Герой далеко (спавн 16,28), шум у маршрута тяжеловеса. - await t.ctx.agent.command('scene:noise', { x: 10, y: 42, level: 0.35 }); - const w = await t.ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "wary"', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'тяжеловес не насторожился от шума', w.snapshot.enemies); - return null; - }); - - await c.run('настороженность гаснет: wary -> return -> patrol', async () => { - const w = await t.ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', - { timeoutTicks: 900 } - ); - c.expect(w.ok, 'тяжеловес не вернулся к патрулю', w.snapshot.enemies); - return null; - }); - - await c.run('герой рядом — погоня', async () => { - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); - await t.ctx.agent.command('scene:teleport', { x: 11, y: 44 }); - const w = await t.ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "chase"', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'тяжеловес не погнался за героем', w.snapshot.enemies); - return null; - }); - - await c.run('герой далеко — погоня гаснет и враг уходит домой', async () => { - await t.ctx.agent.command('scene:teleport', { x: 16, y: 28 }); - const w = await t.ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', - { timeoutTicks: 900 } - ); - c.expect(w.ok, 'тяжеловес не отступил к дому', w.snapshot.enemies); - return null; - }); - - await c.run('мало hp — отступление бегом', async () => { - await t.ctx.agent.command('scene:teleport', { x: 11, y: 43 }); - const dmg = await t.ctx.agent.command('scene:damageEnemy', { id: 'heavy', value: 6 }); - c.expect(dmg === true, 'scene:damageEnemy не нашла тяжеловеса'); - const w = await t.ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "flee"', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'тяжеловес не отступает при низком hp', w.snapshot.enemies); - // Держится подальше: дистанция до неподвижного героя растёт. - const s1 = await t.ctx.agent.snapshot(); - const d1 = dist(heavy(s1).pos, s1.hero.pos); - await t.ctx.agent.waitFor('false', { timeoutTicks: 60 }); // 1 сек наблюдения - const s2 = await t.ctx.agent.snapshot(); - const d2 = dist(heavy(s2).pos, s2.hero.pos); - c.expect(d2 > d1, 'отступающий не отдаляется', { d1, d2 }); - return null; - }); - - await c.run('инварианты ИИ чисты', async () => { - await t.expectInvariantsClean('в ИИ-сценарии'); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/art_b4.mjs b/apps/game/tools/checks/art_b4.mjs deleted file mode 100644 index 42a3718..0000000 --- a/apps/game/tools/checks/art_b4.mjs +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Сценарий art_b4 — визуальная проверка AI-арта B4 (задача 5): - * столбы, моты, верёвка колокола, очаг, записка, прилавок, резонатор, - * деревья-механизмы, Старая Машина (view.tex) и иконки сумки 8×8. - * Скриншоты — /tmp/rpg_b4_*.png; численных assert'ов минимум: смена области, - * открытая сумка, инварианты. Запуск: node tools/agent.mjs run tools/checks/art_b4.mjs - */ -import { withChecks } from '../lib.mjs'; - -const shot = async (t, name) => { - await t.ctx.agent.step(2, { render: true }); - await t.ctx.page.screenshot({ path: `/tmp/rpg_b4_${name}.png` }); -}; - -export default async function ({ pretty }) { - return withChecks( - 'art_b4', - async (t) => { - const { c } = t; - let a; // агентный мост появляется после boot - await c.run('луга: столб и мот', async () => { - await t.boot(); - a = t.ctx.agent; - await t.sleepEnemies(); - await a.command('scene:teleport', { x: 47, y: 27 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); - await shot(t, 'sign_meadows'); - await a.command('scene:teleport', { x: 15, y: 28 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); - await shot(t, 'mote'); - return null; - }); - await c.run('сумка: иконки предметов', async () => { - for (const id of ['clock', 'cloth', 'bellflower', 'salt', 'water_flask', 'mote', 'map_scroll']) { - await a.command('scene:give', { id }); - } - await a.press('inventory'); - // Открытие — push с fade: сцена сменится, потом fade отыграть. - await a.waitFor('s.scene !== "location"', { timeoutTicks: 300 }); - await a.step(14, { render: true }); - await t.ctx.page.screenshot({ path: '/tmp/rpg_b4_inventory.png' }); - await a.press('menu'); - await a.waitFor('s.scene === "location"', { timeoutTicks: 300 }); - return null; - }); - await c.run('Звенец: столб и верёвка колокола', async () => { - await t.goToArea('zvenets', { x: 54, y: 28 }); - await a.command('scene:teleport', { x: 5, y: 19 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); - await shot(t, 'sign_zvenets'); - await a.command('scene:teleport', { x: 27, y: 15 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); - await shot(t, 'bell_rope'); - return null; - }); - await c.run('дом Ирвина: очаг и записка', async () => { - c.expect(await t.interactAt(13, 12), 'дверь дома Ирвина не открылась'); - await a.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await a.command('scene:teleport', { x: 8, y: 2 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); - await shot(t, 'hearth_note'); - // Шаг в проём (5,8) — назад в Звенец. - c.expect(await a.walkTo(5, 8, { timeoutTicks: 3000 }), 'не вышли из дома'); - await a.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 600 }); - return null; - }); - await c.run('лавка Милы: прилавок', async () => { - c.expect(await t.interactAt(43, 19), 'дверь лавки не открылась'); - await a.waitFor('s.area === "shop"', { timeoutTicks: 1200 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await a.command('scene:teleport', { x: 5, y: 5 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); - await shot(t, 'counter_shop'); - c.expect(await a.walkTo(5, 8, { timeoutTicks: 3000 }), 'не вышли из лавки'); - await a.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 600 }); - return null; - }); - await c.run('роща: дерево-механизм и резонатор', async () => { - await t.goToArea('rust_grove', { x: 54, y: 20 }); - const s = await a.snapshot(); - const tree = (s.interactables ?? []).find((i) => i.id === 'tone_tree_1'); - c.expect(tree, 'tone_tree_1 не найден', s.interactables?.map((i) => i.id)); - await a.command('scene:teleport', { x: tree.tile.x, y: tree.tile.y + 2 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); - await shot(t, 'tone_tree'); - await a.command('scene:teleport', { x: 24, y: 18 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); - await shot(t, 'resonator'); - return null; - }); - await c.run('зал Машины: Старая Машина', async () => { - await t.goToArea('old_siding', { x: 46, y: 20 }); - await t.goToArea('machine_hall', { x: 28, y: 31 }); - await a.command('scene:teleport', { x: 13, y: 4 }); - await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); - await shot(t, 'machine_hall'); - return null; - }); - const inv = await a.invariants(); - const errs = inv.filter((i) => i.severity === 'error'); - c.expect(errs.length === 0, 'инварианты не чисты', errs); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/audio.mjs b/apps/game/tools/checks/audio.mjs deleted file mode 100644 index f8696bd..0000000 --- a/apps/game/tools/checks/audio.mjs +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Сценарий audio — звук мира через DEV-шпион window.__gameAudioLog - * (на слух в headless не проверить: читаем фактические запуски sfx/амбиента). - * 1) новая игра: на лугах тишина — амбиент области не запускается; - * 2) шаги: step_* в логе, боевых звуков нет; - * 3) атака: bell_hit; - * 4) guard амбиента: повторный playAmbience того же ключа не перезапускает; - * 5) лог ограничен 24 записями. - * Запуск: node tools/agent.mjs run tools/checks/audio.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function ({ pretty }) { - return withChecks( - 'audio', - async (t) => { - const { c } = t; - await c.run('новая игра: на лугах тишина — амбиент не запускается', async () => { - await t.boot(); - const log = await t.readAudioLog(); - c.expect(Array.isArray(log), 'DEV-лог аудио недоступен (прод-сборка?)', log); - const amb = log.filter((e) => e.key.startsWith('ambience/')); - c.expect(amb.length === 0, 'на лугах не должно быть амбиента (тишина)', amb); - return null; - }); - await c.run('шаги: step_* в логе, боевых звуков нет', async () => { - await t.sleepEnemies(); - const before = (await t.readAudioLog()).filter((e) => e.key.startsWith('sfx/step_')).length; - await t.ctx.agent.walkTo(18, 28, { timeoutTicks: 1500 }); - const log = await t.waitAudioLog( - (l) => l.filter((e) => e.key.startsWith('sfx/step_')).length > before - ); - const steps = log.filter((e) => e.key.startsWith('sfx/step_')); - c.expect(steps.length > before, 'шагов в логе нет', { steps: steps.length }); - const combat = log.filter((e) => ['sfx/hurt', 'sfx/ash_die', 'sfx/spit'].includes(e.key)); - c.expect(combat.length === 0, 'боевые звуки без боя', combat); - return null; - }); - await c.run('атака: bell_hit в логе', async () => { - await t.ctx.agent.press('attack'); - const log = await t.waitAudioLog((l) => l.some((e) => e.key === 'sfx/bell_hit')); - c.expect(log.some((e) => e.key === 'sfx/bell_hit'), 'атака не сыграла bell_hit', log); - return null; - }); - await c.run('guard амбиента: тот же ключ не перезапускается', async () => { - // Луга без амбиента — гвард проверяем ключом прудов. - const count = async () => - (await t.readAudioLog()).filter((e) => e.key === 'ambience/ponds').length; - await t.ctx.page.evaluate(() => window.__game.playAmbience('ambience/ponds')); - // Лог пишется при фактическом старте (после декода) — ждём его. - await t.waitAudioLog((l) => l.some((e) => e.key === 'ambience/ponds')); - const before = await count(); - await t.ctx.page.evaluate(() => window.__game.playAmbience('ambience/ponds')); - await t.ctx.agent.step(60); - const after = await count(); - c.expect(after === before, 'повторный playAmbience перезапустил трек', { - before, - after - }); - return null; - }); - await c.run('лог ограничен 24 записями', async () => { - // Прогулка туда-обратно — шагов больше, чем вмещает кольцевой буфер. - for (const [x, y] of [[18, 28], [14, 28], [18, 28], [14, 28], [18, 28], [14, 28]]) { - await t.ctx.agent.walkTo(x, y, { timeoutTicks: 1500 }); - } - const log = await t.readAudioLog(); - c.expect(log.length <= 24, 'лог не обрезается до 24', log.length); - c.expect(log.length > 0, 'лог пуст после прогулки'); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/big_house.mjs b/apps/game/tools/checks/big_house.mjs deleted file mode 100644 index 19c0b33..0000000 --- a/apps/game/tools/checks/big_house.mjs +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Сценарий big_house — большой дом 2×2 в Звенце (первый проп с footprint): - * 1) коллизия: footprint непроходим, соседний тайл проходим; - * 2) scene:walk в дом — false, approach — к соседу; - * 3) глубина: герой с юга (перед домом) и с севера (за домом) — скриншоты. - * Запуск: node tools/agent.mjs run tools/checks/big_house.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function () { - return withChecks( - 'big_house', - async (t) => { - const { c } = t; - await c.run('снапшот: проп дома в collision-слое', async () => { - await t.boot(); - await t.sleepEnemies(); - await t.goToArea('zvenets', { x: 54, y: 28 }); - const s = await t.ctx.agent.snapshot(); - // collision-слой снапшота отдаёт пропы без id — ищем по footprint. - const prop = (s.collision.props ?? []).find((p) => p.x === 46 && p.y === 24); - c.expect(!!prop, 'проп (46,24) 2×2 не в collision-слое', s.collision.props); - c.expect(prop && prop.w === 2 && prop.h === 2, 'footprint пропа не 2×2', prop); - const idx = (x, y) => y * s.collision.width + x; - c.expect( - s.collision.blocked[idx(46, 24)] === 1 && s.collision.blocked[idx(47, 25)] === 1, - 'клетки footprint пропа не заблокированы' - ); - return null; - }); - await c.run('scene:walkable: дом непроходим, сосед свободен', async () => { - const walk = (x, y) => t.ctx.agent.command('scene:walkable', { x, y }); - c.expect((await walk(46, 24)) === false, 'северо-запад дома проходим'); - c.expect((await walk(47, 25)) === false, 'юго-восток дома проходим'); - c.expect((await walk(48, 24)) === true, 'тайл восточнее дома заблокирован'); - return null; - }); - await c.run('scene:walk в дом — false, approach — к соседу', async () => { - await t.ctx.agent.command('scene:teleport', { x: 48, y: 24 }); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); - const walked = await t.ctx.agent.command('scene:walk', { x: 46, y: 24 }); - c.expect(walked === false, 'scene:walk провёл героя в дом', walked); - const dest = await t.ctx.agent.approach(46, 24); - c.expect(dest !== null, 'approach к дому не нашёл соседа'); - return null; - }); - await c.run('глубина: герой с юга и с севера дома', async () => { - const shot = async (name) => { - await t.ctx.agent.step(2, { render: true }); - await t.ctx.page.screenshot({ path: `/tmp/rpg_big_house_${name}.png` }); - }; - const teleport = async (x, y) => { - await t.ctx.agent.command('scene:teleport', { x, y }); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); - const s = await t.ctx.agent.snapshot(); - c.expect( - s.hero.tile.x === x && s.hero.tile.y === y, - `телепорт в (${x},${y}) не удался`, - s.hero.tile - ); - }; - // Юг: герой вплотную перед домом — перекрывает его низ. - await teleport(46, 26); - await shot('south'); - // Север: дом ближе к камере — перекрывает героя целиком. - await teleport(46, 23); - await shot('north'); - return null; - }); - const inv = await t.ctx.agent.invariants(); - const errs = inv.filter((i) => i.severity === 'error'); - c.expect(errs.length === 0, 'инварианты с пропом не чисты', errs); - } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/collision.mjs b/apps/game/tools/checks/collision.mjs deleted file mode 100644 index 73152de..0000000 --- a/apps/game/tools/checks/collision.mjs +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Сценарий collision — коллизии и карта коллизий агенту. - * 1) слой collision в снапшоте: стены/вода/пропы, тайл героя свободен. - * 2) scene:walkable: трава проходима, вода и дерево — нет. - * 3) scene:raycast: дерево перекрывает луч, вода прозрачна (плевок летит над прудом). - * 4) scene:walk в непроходимый — false; approach подводит к соседу. - * 5) расталкивание тел: враг выталкивает себя из героя. - * Запуск: node tools/agent.mjs run tools/checks/collision.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function ({ pretty }) { - return withChecks( - 'collision', - async (t) => { - const { c } = t; - await c.run('снапшот: слой collision валиден', async () => { - await t.boot(); - const s = await t.ctx.agent.snapshot(); - const col = s.collision; - c.expect(col && col.width > 0 && col.height > 0, 'нет карты коллизий', col); - c.expect( - col.blocked.length === col.width * col.height, - 'blocked не накрывает карту', - { len: col.blocked.length, w: col.width, h: col.height } - ); - c.expect( - col.blocked.every((v) => v === 0 || v === 1), - 'blocked не бинарный', - col.blocked.filter((v) => v !== 0 && v !== 1) - ); - c.expect( - col.props.every((p) => p.w >= 1 && p.h >= 1), - 'проп без footprint', - col.props - ); - const idx = s.hero.tile.y * col.width + s.hero.tile.x; - c.expect(col.blocked[idx] === 0, 'герой заспавнился в блоке', s.hero.tile); - return null; - }); - await c.run('scene:walkable: вода и дерево непроходимы', async () => { - const walk = (x, y) => t.ctx.agent.command('scene:walkable', { x, y }); - const s = await t.ctx.agent.snapshot(); - const heroWalk = await walk(s.hero.tile.x, s.hero.tile.y); - c.expect(heroWalk === true, 'тайл героя непроходим', s.hero.tile); - c.expect((await walk(40, 16)) === false, 'вода проходима?'); - c.expect((await walk(7, 1)) === false, 'дерево проходимо?'); - return null; - }); - await c.run('scene:raycast: дерево перекрывает, вода прозрачна', async () => { - const ray = (from, to) => t.ctx.agent.command('scene:raycast', { from, to }); - // Вертикальный луч (7,0)->(7,2) задевает дерево (7,1). - c.expect((await ray({ x: 7, y: 0 }, { x: 7, y: 2 })) === false, 'дерево не перекрыло луч'); - // Берег-берег через пруд: вода прозрачна, луч чист. - c.expect((await ray({ x: 37, y: 16 }, { x: 45, y: 16 })) === true, 'вода перекрыла луч'); - return null; - }); - await c.run('scene:walk в непроходимый — false, approach — к соседу', async () => { - await t.sleepEnemies(); - // Ближе к дереву: маршрут от (2,3) не задевает спящих ползунов у тропы - // (сам (2,2) — тайл перехода на пруды, туда телепортировать нельзя). - await t.ctx.agent.command('scene:teleport', { x: 2, y: 3 }); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); - // Путь в дерево (7,1) не строится: scene:walk — false. - const walked = await t.ctx.agent.command('scene:walk', { x: 7, y: 1 }); - c.expect(walked === false, 'scene:walk провёл героя в дерево', walked); - // approach подводит к проходимому соседу дерева. - const dest = await t.ctx.agent.approach(7, 1); - c.expect(dest !== null, 'approach к дереву не нашёл соседа'); - const s = await t.ctx.agent.snapshot(); - c.expect( - s.hero.tile.x === dest.x && s.hero.tile.y === dest.y, - 'герой не на тайле прибытия approach', - { dest, after: s.hero.tile } - ); - const col = s.collision; - const idx = s.hero.tile.y * col.width + s.hero.tile.x; - c.expect(col.blocked[idx] === 0, 'герой стоит в блоке', s.hero.tile); - return null; - }); - await c.run('расталкивание: враг выталкивает себя из героя', async () => { - await t.sleepEnemies(); - // Ползун спит в (20.5,14.5); телепортируемся внутрь его тела. - await t.ctx.agent.command('scene:teleport', { x: 20, y: 14 }); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); - const s = await t.ctx.agent.snapshot(); - const en = (s.enemies ?? []).find( - (e) => !e.dead && Math.hypot(e.pos.x - 20.5, e.pos.y - 14.5) < 1.2 - ); - c.expect(!!en, 'ползун у тропы не найден', s.enemies); - const gap = Math.hypot(en.pos.x - s.hero.pos.x, en.pos.y - s.hero.pos.y); - c.expect(gap >= 0.4, 'враг остался внутри тела героя', { gap, en: en.pos, hero: s.hero.pos }); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/final.mjs b/apps/game/tools/checks/final.mjs deleted file mode 100644 index 49ce0ff..0000000 --- a/apps/game/tools/checks/final.mjs +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Сценарий final — финал акта 2: правильный звон Старой Машины. - * 1) путь акта 2: Звенец -> роща -> разъезд -> люк в зал Машины; - * 2) без настроенного резонатора Машина отказывает (locked-текст); - * 3) чит-настройка (quest_resonator_done): действие E у Машины -> реакция ставит - * machine_lulled и запускает катсцену (тост «дышит тише», конец); - * 4) повторное действие: Машина спит; - * 5) инварианты чисты после финала. - * Запуск: node tools/agent.mjs run tools/checks/final.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function ({ pretty }) { - return withChecks( - 'final', - async (t) => { - const { c } = t; - await c.run('путь акта 2: спуск через люк в зал Машины', async () => { - await t.boot(); - await t.sleepEnemies(); - await t.goToArea('zvenets', { x: 54, y: 28 }); - await t.sleepEnemies(); - await t.ctx.agent.command('scene:teleport', { x: 53, y: 20 }); - await t.goToArea('rust_grove', { x: 54, y: 20 }); - await t.sleepEnemies(); - await t.goToArea('old_siding', { x: 46, y: 20 }); - await t.sleepEnemies(); - await t.goToArea('machine_hall', { x: 28, y: 31 }); - const s = await t.ctx.agent.snapshot(); - c.expect(s.area === 'machine_hall', 'герой не в зале Машины', s.area); - return null; - }); - await c.run('без резонатора Машина отказывает', async () => { - const used = await t.useInteractable('old_machine'); - c.expect(used, 'не подошли к Машине'); - const w = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("не настроен")', { - timeoutTicks: 1200 - }); - c.expect(w.ok, 'Машина не отказала без резонатора', w.snapshot.lastToast); - c.expect(!w.snapshot.flags.includes('machine_lulled'), 'флаг machine_lulled без настройки'); - return null; - }); - await c.run('правильный звон: катсцена, machine_lulled', async () => { - await t.ctx.agent.command('scene:setFlag', { flag: 'quest_resonator_done' }); - const used = await t.useInteractable('old_machine'); - c.expect(used, 'не подошли к Машине'); - const f = await t.ctx.agent.waitFor('s.flags.includes("machine_lulled")', { - timeoutTicks: 1200 - }); - c.expect(f.ok, 'реакция не подняла machine_lulled', f.snapshot.flags); - const toast = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("дышит тише")', { - timeoutTicks: 1200 - }); - c.expect(toast.ok, 'нет тоста финала', toast.snapshot.lastToast); - await t.ctx.agent.waitFor('!s.cutscene?.active', { timeoutTicks: 1200 }); - return null; - }); - await c.run('повторное действие: Машина спит', async () => { - const used = await t.useInteractable('old_machine'); - c.expect(used, 'не подошли к Машине'); - const w = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("спит")', { - timeoutTicks: 1200 - }); - c.expect(w.ok, 'повторная реакция не про «сон»', w.snapshot.lastToast); - return null; - }); - await c.run('инварианты чисты после финала', async () => { - await t.expectInvariantsClean('после финала акта 2'); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/hero_depth.mjs b/apps/game/tools/checks/hero_depth.mjs deleted file mode 100644 index ad20780..0000000 --- a/apps/game/tools/checks/hero_depth.mjs +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Сценарий hero_depth — герой за tall-объектами (задача 19): - * деревья/дома-тайлы/валуны живут в одном depth-слое с героем (глубина tx+ty), - * поэтому с юга герой поверх дерева, с севера — дерево поверх героя. - * Дерево ищется по коллизии: blocked-клетка с проходимым севером и югом, - * не покрытая footprint'ом пропов. - * Запуск: node tools/agent.mjs run tools/checks/hero_depth.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function () { - return withChecks( - 'hero_depth', - async (t) => { - const { c } = t; - await c.run('снапшот: ищем дерево (blocked с проходимым севером/югом)', async () => { - await t.boot(); - await t.sleepEnemies(); - // Новая игра стартует в лугах — переход не нужен. - const s = await t.ctx.agent.snapshot(); - const w = s.collision.width; - const blocked = (x, y) => s.collision.blocked[y * w + x] === 1; - const covered = (x, y) => - (s.collision.props ?? []).some( - (p) => x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h - ); - const cand = []; - for (let y = 1; y < s.collision.height - 1; y++) { - for (let x = 1; x < w - 1; x++) { - if (blocked(x, y) && !covered(x, y) && !blocked(x, y - 1) && !blocked(x, y + 1)) { - cand.push({ x, y }); - } - } - } - c.expect(cand.length > 0, 'в лугах не нашлось дерева с проходимым севером/югом'); - t.tree = cand[0]; - return null; - }); - await c.run('глубина: с юга герой поверх, с севера — за деревом', async () => { - const { x, y } = t.tree; - const teleport = async (tx, ty, name) => { - await t.ctx.agent.command('scene:teleport', { x: tx, y: ty }); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); - const s = await t.ctx.agent.snapshot(); - c.expect( - s.hero.tile.x === tx && s.hero.tile.y === ty, - `телепорт в (${tx},${ty}) не удался`, - s.hero.tile - ); - await t.ctx.agent.step(2, { render: true }); - await t.ctx.page.screenshot({ path: `/tmp/rpg_hero_depth_${name}.png` }); - // Численно: zIndex дерева (tx+ty) против героя — кто «ближе» - // к камере, тот и рисуется позже (sortableChildren). - const z = await t.ctx.page.evaluate(() => { - const scene = window.__game.scenes.current; - return { - heroZ: scene.player.view.zIndex, - // zIndex всех прочих детей actors: среди них — дерево. - treeZs: scene.actors.children - .filter((ch) => ch !== scene.player.view) - .map((ch) => ch.zIndex) - }; - }); - c.expect( - z.treeZs.includes(x + y), - `tall-вьюха дерева (z=${x + y}) не в actors-слое`, - z.treeZs?.slice(0, 12) - ); - return z; - }; - const south = await teleport(x, y + 1, 'south'); // герой перед деревом - const north = await teleport(x, y - 1, 'north'); // герой за деревом - c.expect( - south.heroZ > x + y && north.heroZ < x + y, - 'глубина героя относительно дерева не меняется при телепорте', - { south: south.heroZ, north: north.heroZ, tree: x + y } - ); - return null; - }); - const inv = await t.ctx.agent.invariants(); - const errs = inv.filter((i) => i.severity === 'error'); - c.expect(errs.length === 0, 'инварианты не чисты', errs); - } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/interact-world.mjs b/apps/game/tools/checks/interact-world.mjs deleted file mode 100644 index 0230a77..0000000 --- a/apps/game/tools/checks/interact-world.mjs +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Сценарий interact-world — взаимодействие с миром шире (батч 3). - * 1) столб у тропы: тост с текстом знака; - * 2) подбор мота: предмет в сумке, вар motes растёт, used:; - * 3) второй и третий моты: вар копится (addVar, порядок не важен); - * 4) знак наката у прудов; - * 5) верёвка колокола: флаг rang_tower, повтор — другой текст; - * 6) Мила: знакомство -> повторный диалог с веткой по мотам; - * 7) инвариант interact-used-consistent чист. - * Запуск: node tools/agent.mjs run tools/checks/interact-world.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function ({ pretty }) { - return withChecks( - 'interact-world', - async (t) => { - const { c } = t; - await c.run('столб у тропы: тост с текстом знака', async () => { - await t.boot(); - await t.sleepEnemies(); - // Столб у тропы в Звенец (луга, восток) — подходим и жмём E. - const opened = await t.useInteractable('sign_meadows_east'); - c.expect(opened, 'не подошли к столбу'); - const w = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("Звенец")', { - timeoutTicks: 1500 - }); - c.expect(w.ok, 'знак не показал текст', w.snapshot.lastToast); - return null; - }); - await c.run('мот 1: предмет + вар + used', async () => { - const opened = await t.useInteractable('mote_1'); - c.expect(opened, 'не подошли к моту'); - const w = await t.ctx.agent.waitFor( - '(s.inventory.find((i) => i.id === "mote")?.count ?? 0) === 1 && s.vars.motes === 1', - { timeoutTicks: 1500 } - ); - c.expect(w.ok, 'подбор мота не дал предмет и вар', { - inventory: w.snapshot.inventory, - vars: w.snapshot.vars - }); - const s = await t.ctx.agent.snapshot(); - const mote = (s.interactables ?? []).find((o) => o.id === 'mote_1'); - c.expect(mote?.used === true, 'мот не помечен used', mote); - return null; - }); - await c.run('моты 2 и 3: вар копится (addVar)', async () => { - let opened = await t.useInteractable('mote_2'); - c.expect(opened, 'не подошли ко второму моту'); - const w2 = await t.ctx.agent.waitFor('s.vars.motes === 2', { timeoutTicks: 1500 }); - c.expect(w2.ok, 'второй мот не поднял вар до 2', w2.snapshot.vars); - opened = await t.useInteractable('mote_3'); - c.expect(opened, 'не подошли к третьему моту'); - const w3 = await t.ctx.agent.waitFor('s.vars.motes === 3', { timeoutTicks: 1500 }); - c.expect(w3.ok, 'третий мот не поднял вар до 3', w3.snapshot.vars); - return null; - }); - await c.run('знак наката у прудов', async () => { - // Тропа на пруды (2,2): step-переход; у берега — столб. - await t.goToArea('ponds', { x: 2, y: 2 }); - const opened = await t.useInteractable('sign_ponds'); - c.expect(opened, 'не подошли к знаку наката'); - const ws = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("накатом")', { - timeoutTicks: 900 - }); - c.expect(ws.ok, 'знак наката не показал текст', ws.snapshot.lastToast); - // Обратно на луга (2,2) прудов. - await t.goToArea('meadows', { x: 2, y: 2 }); - return null; - }); - await c.run('верёвка колокола: флаг + повтор с другим текстом', async () => { - // Звенец через тропу (54,28); верёвка у башни — подходим и звоним. - await t.goToArea('zvenets', { x: 54, y: 28 }); - const opened = await t.useInteractable('bell_rope'); - c.expect(opened, 'не подошли к верёвке колокола'); - const wr = await t.ctx.agent.waitFor('s.flags.includes("rang_tower")', { - timeoutTicks: 1500 - }); - c.expect(wr.ok, 'звон не поднял rang_tower', wr.snapshot.flags); - const s1 = await t.ctx.agent.snapshot(); - const text1 = s1.lastToast?.text ?? ''; - c.expect(text1.includes('ушёл в землю'), 'первый звон — не тот текст', text1); - await t.useInteractable('bell_rope'); - await t.ctx.agent.step(150); - const s2 = await t.ctx.agent.snapshot(); - const text2 = s2.lastToast?.text ?? ''; - c.expect( - text2.includes('снова') && text2 !== text1, - 'повторный звон не сменил текст', - text2 - ); - return null; - }); - await c.run('Мила: знакомство -> повтор с веткой по мотам', async () => { - // Первое знакомство (trader_first): полотно. - const talked = await t.talkTo('trader'); - c.expect(talked, 'первый диалог с Милой не открылся'); - const d1 = await t.ctx.agent.runDialogue(900); - c.expect(d1, 'первый диалог с Милой не завершился'); - // Повтор (trader_repeat): при моты >= 1 после первой реплики идёт - // ветка о мота́х — листаем вручную и ловим её текст. - const talked2 = await t.talkTo('trader'); - c.expect(talked2, 'повторный диалог не открылся'); - await t.ctx.agent.press('advance'); // догнать печать (typewriter) - await t.ctx.agent.press('advance'); // следующая реплика - const w = await t.ctx.agent.waitFor( - 's.dialogue != null && (s.dialogue.text ?? "").includes("Моты")', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'в повторном диалоге нет ветки о мота́х', w.snapshot.dialogue); - const d2 = await t.ctx.agent.runDialogue(600); - c.expect(d2, 'повторный диалог не завершился'); - return null; - }); - await c.run('инвариант: used-флаги и снапшот согласованы', async () => { - await t.expectInvariantsClean('после обхода мира'); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/interact.mjs b/apps/game/tools/checks/interact.mjs deleted file mode 100644 index 9d680de..0000000 --- a/apps/game/tools/checks/interact.mjs +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Сценарий interact — интерьеры и интерактивные объекты. - * 1) дверь: действие E у дома Ирвина -> интерьер house_elder; - * 2) сундук (once): предмет в сумке, used:, повтор молчит; - * 3) очаг: реакция зависит от флага quest_bells_done; - * 4) записка (once): флаг read_note; - * 5) выход через проём: step kind 'return' -> Звенец, герой на тайле входа; - * 6) лавка Милы: прилавок меняет реакцию с флагом; - * 7) размещение NPC по флагу: act2_hook -> Клинт появляется в Ржавой роще, - * действие E у него открывает диалог. - * Запуск: node tools/agent.mjs run tools/checks/interact.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function ({ pretty }) { - // Тайл, с которого герой нажал E у двери (цель kind 'return'). - let entryTile = null; - return withChecks( - 'interact', - async (t) => { - const { c } = t; - await c.run('дверь: дом Ирвина -> интерьер', async () => { - await t.boot(); - await t.sleepEnemies(); - // Добираемся до Звенца пешком (луга -> тропа (54,28)). - await t.goToArea('zvenets', { x: 54, y: 28 }); - // Подходим к дому (дверь (13,12)) и жмём действие. - const opened = await t.interactAt(13, 12); - c.expect(opened, 'не подошли к дому Ирвина'); - entryTile = (await t.ctx.agent.snapshot()).hero.tile; - const w = await t.ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'действие у двери не открыло интерьер', { area: w.snapshot.area }); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await t.ctx.agent.snapshot(); - c.expect(s.hero.tile.x === 5 && s.hero.tile.y === 7, 'герой не у проёма (5,7)', s.hero.tile); - return null; - }); - await c.run('сундук: предмет + used, повтор молчит', async () => { - const before = await t.ctx.agent.snapshot(); - const had = before.inventory.find((i) => i.id === 'cloth')?.count ?? 0; - // Подход к сундуку и действие — герой открывает. - const opened = await t.useInteractable('chest_elder'); - c.expect(opened, 'не подошли к сундуку'); - const w = await t.ctx.agent.waitFor( - `(s.inventory.find((i) => i.id === 'cloth')?.count ?? 0) === ${had + 1}`, - { timeoutTicks: 1200 } - ); - c.expect(w.ok, 'сундук не дал полотно', { inventory: w.snapshot.inventory }); - const s = await t.ctx.agent.snapshot(); - const chest = (s.interactables ?? []).find((o) => o.id === 'chest_elder'); - c.expect(chest?.used === true, 'сундук не помечен used', chest); - c.expect((s.flags ?? []).includes('used:chest_elder'), 'нет флага used:chest_elder'); - // Повтор: тик последнего тоста не меняется. - const t0 = s.lastToast?.tick ?? -1; - await t.useInteractable('chest_elder'); - await t.ctx.agent.step(120); - const s2 = await t.ctx.agent.snapshot(); - c.expect((s2.lastToast?.tick ?? -1) === t0, 'повторное взаимодействие не молчит', s2.lastToast); - return null; - }); - await c.run('очаг: реакция меняется с флагом', async () => { - await t.useInteractable('hearth_elder'); - await t.ctx.agent.step(120); - const s1 = await t.ctx.agent.snapshot(); - const text1 = s1.lastToast?.text ?? ''; - c.expect(text1.includes('щепки'), 'реакция очага до флага не та', text1); - await t.ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); - await t.useInteractable('hearth_elder'); - await t.ctx.agent.step(120); - const s2 = await t.ctx.agent.snapshot(); - c.expect( - (s2.lastToast?.text ?? '').includes('ровнее'), - 'реакция очага после флага не та', - s2.lastToast - ); - return null; - }); - await c.run('записка: флаг read_note', async () => { - await t.useInteractable('note_elder'); - await t.ctx.agent.step(120); - const s = await t.ctx.agent.snapshot(); - c.expect((s.flags ?? []).includes('read_note'), 'записка не подняла read_note', s.flags); - return null; - }); - await c.run('выход через проём: возврат в Звенец', async () => { - // Пешком в проём (5,8) — step-переход kind 'return' срабатывает на шаге. - await t.ctx.agent.walkTo(5, 8, { timeoutTicks: 300 }); - const w = await t.ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'проём не вернул в Звенец', { area: w.snapshot.area }); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await t.ctx.agent.snapshot(); - c.expect( - s.hero.tile.x === entryTile.x && s.hero.tile.y === entryTile.y, - 'герой не на тайле, откуда нажал E у двери', - { entry: entryTile, after: s.hero.tile } - ); - return null; - }); - await c.run('лавка Милы: прилавок меняет реакцию', async () => { - // Действие у двери лавки (43,19) — интерьер; прилавок внутри. - const opened = await t.interactAt(43, 19); - c.expect(opened, 'не подошли к двери лавки'); - const w = await t.ctx.agent.waitFor('s.area === "shop"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'лавка не открылась', { area: w.snapshot.area }); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await t.useInteractable('counter_shop'); - await t.ctx.agent.step(120); - const s1 = await t.ctx.agent.snapshot(); - const text1 = s1.lastToast?.text ?? ''; - c.expect(text1.includes('соль'), 'реакция прилавка до флага не та', text1); - await t.useInteractable('counter_shop'); - await t.ctx.agent.step(120); - const s2 = await t.ctx.agent.snapshot(); - c.expect( - (s2.lastToast?.text ?? '').includes('цветы'), - 'прилавок не изменил реакцию после флага', - s2.lastToast - ); - return null; - }); - await c.run('снапшот: интерактивные объекты области', async () => { - const s = await t.ctx.agent.snapshot(); - const list = s.interactables ?? []; - c.expect(list.some((o) => o.id === 'counter_shop'), 'прилавка нет в снапшоте', list); - c.expect(list.every((o) => typeof o.used === 'boolean'), 'объект без поля used', list); - return null; - }); - await c.run('размещение NPC по флагу: Клинт в роще', async () => { - // Выходим из лавки обратно в Звенец (проём (5,8)). - await t.ctx.agent.walkTo(5, 8, { timeoutTicks: 300 }); - const back = await t.ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); - c.expect(back.ok, 'не вернулись из лавки в Звенец', { area: back.snapshot.area }); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - // Флаг-крючок акта 2: после него Клинт стоит в Ржавой роще. - await t.ctx.agent.command('scene:setFlag', { flag: 'act2_hook' }); - // Роща: Звенец (54,20) -> роща (2,20). - await t.goToArea('rust_grove', { x: 54, y: 20 }); - await t.sleepEnemies(); - const s = await t.ctx.agent.snapshot(); - const clint = (s.npcs ?? []).find((n) => n.id === 'clint'); - c.expect(clint !== undefined, 'Клинта нет в роще после act2_hook', s.npcs); - if (!clint) return null; - // Действие у Клинта — сцена ждёт подхода героя (E работает только - // в радиусе); путь через рощу небыстрый — таймаут с запасом. - const talked = await t.talkTo('clint'); - c.expect(talked, 'диалог Клинта не открылся'); - const d = await t.ctx.agent.runDialogue(2400); - c.expect(d, 'диалог Клинта не завершился'); - const s2 = await t.ctx.agent.snapshot(); - c.expect((s2.flags ?? []).includes('met_clint'), 'диалог не поднял met_clint', s2.flags); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/lighting.mjs b/apps/game/tools/checks/lighting.mjs deleted file mode 100644 index 685a183..0000000 --- a/apps/game/tools/checks/lighting.mjs +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Сценарий lighting — освещение сцены через мост: ambient областей, день/ночь - * по внутриигровому времени (scene:setTime), окна Звенца, очаг дома Ирвина - * (флаг квеста), лампа героя, мерцание, виньетка при низком hp. - * Перед каждым ambient-ассертом время пинуется — реальный темп (1 с = 1 мин) - * не должен влиять на результат. - * 1) Луга, полдень: светлый дневной ambient, источников нет; - * 2) 21:00: ночная цель ambient лугов, лампа героя на светлой днём улице; - * 3) Звенец ночью: ночная цель деревни, три окна горят, башня молчит; - * 4) Дом Ирвина: тёмный интерьер, hearth_low, лампа; мерцание очага гуляет; - * 5) телепорт внутри дома — лампа следует за героем (разность векторов); - * 6) quest_bells_done: hearth_high вместо hearth_low и строго ярче; - * 7) урон герою до hp 2 — виньетка темнеет (полный боевой путь); - * 8) инварианты чисты. - * Запуск: node tools/agent.mjs run tools/checks/lighting.mjs - */ -import { withChecks } from '../lib.mjs'; - -/** Ambient областей (те же константы, что в data/locations.ts). */ -const AMB = { - meadows: 0xf7f4ea, - meadowsNight: 0x3e4658, - zvenets: 0xf5f2ea, - zvenetsNight: 0x454c62, - house_elder: 0x54586a -}; - -/** Экранное смещение источника b относительно a (px). */ -function lightVec(s, a, b) { - const x = s.lighting.sources.find((l) => l.id === a); - const y = s.lighting.sources.find((l) => l.id === b); - return x && y ? { x: y.x - x.x, y: y.y - x.y } : null; -} - -export default async function ({ pretty }) { - return withChecks( - 'lighting', - async (t) => { - const { c } = t; - await c.run('луга в полдень: дневной ambient, источников нет', async () => { - await t.boot(); - await t.ctx.agent.command('scene:setTime', { hours: 12 }); - const w = await t.ctx.agent.waitFor( - `s.lighting.ambient === ${AMB.meadows} && s.lighting.sources.length === 0`, - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'днём луга не светлы или есть источники', w.snapshot?.lighting); - c.expect(w.snapshot.time.night === 0, 'в полдень фактор ночи не нулевой', w.snapshot.time); - return null; - }); - await c.run('ночь на лугах (21:00): ночная цель ambient, лампа героя', async () => { - await t.ctx.agent.command('scene:setTime', { hours: 21 }); - const w = await t.ctx.agent.waitFor( - `s.lighting.ambient === ${AMB.meadowsNight} && s.lighting.sources.some((l) => l.id === "lamp")`, - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'ночью луга не потемнели или лампа не зажглась', w.snapshot?.lighting); - c.expect(w.snapshot.time.night === 1, 'в 21:00 фактор ночи не единичный', w.snapshot.time); - return null; - }); - await c.run('Звенец ночью: ночная цель, три окна, башня молчит', async () => { - await t.goToArea('zvenets', { x: 54, y: 28 }); - await t.ctx.agent.command('scene:setTime', { hours: 21 }); // пин: ходьба ест время - const s = await t.ctx.agent.snapshot(); - c.expect(s.lighting.ambient === AMB.zvenetsNight, 'ambient Звенца не ночной', s.lighting); - const windows = s.lighting.sources.filter((l) => l.id.startsWith('window_')); - c.expect(windows.length === 3, 'ночью должно гореть три окна домов', s.lighting.sources); - c.expect(!s.lighting.sources.some((l) => l.id === 'tower_resonance'), 'башня светит без звонка', s.lighting.sources); - c.expect(s.lighting.sources.some((l) => l.id === 'lamp'), 'ночью на улице нет лампы героя', s.lighting.sources); - return null; - }); - await c.run('дом Ирвина: тёмный интерьер, hearth_low, лампа героя', async () => { - const opened = await t.interactAt(13, 12); // дверь дома — действие E - c.expect(opened, 'не подошли к двери дома Ирвина'); - const w = await t.ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'не вошли в дом Ирвина'); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await t.ctx.agent.snapshot(); - c.expect(s.lighting.ambient === AMB.house_elder, 'ambient интерьера не тёмный', s.lighting); - c.expect(s.lighting.sources.some((l) => l.id === 'hearth_low'), 'нет тлеющего очага', s.lighting.sources); - c.expect(!s.lighting.sources.some((l) => l.id === 'hearth_high'), 'очаг горит ровно до сдачи квеста', s.lighting.sources); - c.expect(s.lighting.sources.some((l) => l.id === 'lamp'), 'в тёмном доме нет лампы героя', s.lighting.sources); - // Мерцание: серия снапшотов — интенсивность очага гуляет (с допуском). - const seen = new Set(); - for (let i = 0; i < 8; i++) { - const shot = await t.ctx.agent.snapshot(); - const h = shot.lighting.sources.find((l) => l.id === 'hearth_low'); - if (h) seen.add(Math.round(h.intensity * 100)); - await t.ctx.agent.step(6); - } - c.expect(seen.size >= 3, `мерцание очага не видно (вариантов ${seen.size})`, [...seen]); - return null; - }); - await c.run('лампа героя: следует за телепортом', async () => { - const v1 = lightVec(await t.ctx.agent.snapshot(), 'hearth_low', 'lamp'); - await t.ctx.agent.command('scene:teleport', { x: 2, y: 2 }); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); - const v2 = lightVec(await t.ctx.agent.snapshot(), 'hearth_low', 'lamp'); - c.expect(v1 !== null && v2 !== null, 'нет лампы или очага в снапшоте', { v1, v2 }); - // Очаг статичен: v2 - v1 — чистое смещение лампы (камера двигает оба поровну). - const shift = Math.hypot(v2.x - v1.x, v2.y - v1.y); - c.expect(shift > 60, `лампа не переехала за героем (смещение ${shift.toFixed(0)} px)`, { v1, v2 }); - return null; - }); - await c.run('сдача квеста: hearth_high вместо hearth_low и ярче', async () => { - await t.ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); - const w = await t.ctx.agent.waitFor('s.lighting.sources.some((l) => l.id === "hearth_high")', { - timeoutTicks: 300 - }); - c.expect(w.ok, 'после сдачи квеста очаг не разгорелся', w.snapshot.lighting.sources); - const s = await t.ctx.agent.snapshot(); - c.expect(!s.lighting.sources.some((l) => l.id === 'hearth_low'), 'тлеющий очаг не погас', s.lighting.sources); - const high = s.lighting.sources.find((l) => l.id === 'hearth_high'); - // Минимум hearth_high (1.05·0.8) выше максимума hearth_low (0.75). - c.expect(high.intensity > 0.84 - 0.02, 'hearth_high не ярче тлеющего', high); - return null; - }); - await c.run('виньетка: урон до hp 2 — края экрана темнеют', async () => { - await t.ctx.agent.command('scene:damagePlayer', { value: 3 }); - const w = await t.ctx.agent.waitFor('s.lighting.vignette > 0.29', { timeoutTicks: 300 }); - c.expect(w.ok, 'виньетка не загорелась при hp 2', w.snapshot?.lighting); - return null; - }); - await c.run('инварианты чисты при работающем свете', async () => { - await t.expectInvariantsClean('при работающем свете'); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/music.mjs b/apps/game/tools/checks/music.mjs deleted file mode 100644 index 2e1f86c..0000000 --- a/apps/game/tools/checks/music.mjs +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Сценарий music — музыкальный слой: тема лугов генерируется правилами - * (data/music.ts, сид) и компилируется движковым секвенсором в рантайме - * (playTheme → playLoopBuffer), факт запуска — в window.__gameAudioLog. - * 1) новая игра в лугах: music/meadows в логе (тема играет) + амбиент; - * 2) тема не перезапускается при возврате в ту же область (guard); - * 3) уход в Звенец (темы нет): слот темы снят (themeKey '') и тема лугов - * не перезапускается. Динамическая музыка разрешена: стем (настороженность) - * и тема боя (погоня тяжеловеса у тропы — его аггро-радиус на пути). - * Запуск: node tools/agent.mjs run tools/checks/music.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function ({ pretty }) { - return withChecks( - 'music', - async (t) => { - const { c } = t; - /** Сколько раз ключ встречается в логе (null-лог → 0). */ - const countKey = async (key) => - ((await t.readAudioLog()) ?? []).filter((e) => e.key === key).length; - /** Слот темы страницы (window.__game.themeKey) или null. */ - const themeKey = () => - t.ctx.page.evaluate(() => { - const g = window.__game; - return g ? g.themeKey : null; - }); - await c.run('новая игра в лугах: тема скомпилирована и играет', async () => { - await t.boot(); - // Луга без амбиента (тишина по фидбеку) — тема играет одна. - const log = await t.waitAudioLog((l) => l.some((e) => e.key === 'music/meadows')); - c.expect( - log.some((e) => e.key === 'music/meadows'), - 'темы лугов нет в аудио-логе (компиляция не удалась?)', - log - ); - c.expect( - !log.some((e) => e.key.startsWith('ambience/')), - 'на лугах не должно быть амбиента (тишина)', - log.filter((e) => e.key.startsWith('ambience/')) - ); - return null; - }); - await c.run('возврат в ту же область: тема не перезапускается (guard)', async () => { - const before = await countKey('music/meadows'); - c.expect(before > 0, 'тема лугов не играла перед проверкой guard', before); - // Шагнуть туда-обратно мимо переходов, сцена не меняется — guard ключа. - await t.ctx.agent.walkTo(15, 28, { timeoutTicks: 1500 }); - await t.ctx.agent.walkTo(17, 28, { timeoutTicks: 1500 }); - c.expect((await countKey('music/meadows')) === before, 'тема перезапустилась без смены ключа', { - before, - after: await countKey('music/meadows') - }); - return null; - }); - await c.run('уход в Звенец: слот темы снят, тема лугов не перезапускается', async () => { - // Лог аудио — кольцевой на 24 записи: долгая пешая прогулка на - // картах ×2 вытесняет старые записи шагами, поэтому перед - // переходом лог чистится, и дальше смотрим только новые записи. - await t.ctx.page.evaluate(() => { - (window.__gameAudioLog ?? []).splice(0); - }); - // Пешком к тропе в Звенец (54,28) — честный step-переход. - const walked = await t.ctx.agent.walkTo(54, 28, { timeoutTicks: 6000 }); - c.expect(walked, 'walkTo(54,28) не дошёл до перехода'); - // Слот темы освобождается (тема Звенца пуста; тема боя погони — - // временный ключ, ждём его снятия после деаггро). - for (let i = 0; i < 12 && (await themeKey()) !== ''; i++) { - await t.ctx.agent.step(30); - } - c.expect((await themeKey()) === '', 'слот темы не снят при уходе из лугов'); - c.expect( - (await countKey('music/meadows')) === 0, - 'тема лугов перезапустилась (guard ключа сломан)', - { after: await countKey('music/meadows') } - ); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/perf.mjs b/apps/game/tools/checks/perf.mjs deleted file mode 100644 index f44c73e..0000000 --- a/apps/game/tools/checks/perf.mjs +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Сценарий perf — базовый замер производительности (baseline оптимизаций). - * Не гейтовский чек: числа фиксируются в коммитах до/после, ассерты только - * на «всё живо» (fps > 0, путь найден, инварианты чисты). - * Важно: launchBrowser гоняет рендер на swiftshader (программный) — fps здесь - * уровень слабого железа, а не обычного десктопа; сравнивать можно только - * числа одинаковых прогонов (до/после), абсолютные значения — не про-player. - * 1) старт: страница+ассеты до маяка (включая фиксированные 1,5 с сна моста — - * вычитать при сравнении), затем newGame() → location; - * 2) путь: время scene:route (один A*) и walkTo через луга (один scene:walk); - * 3) бой: погоня + урон + эффекты — fps/heap после боя; - * 4) fps меряется в реальном времени (rAF), не в ручных шагах моста: - * окно ~0,9 с реального времени, затем снапшот + performance.memory. - * Запуск: node tools/agent.mjs run tools/checks/perf.mjs [--pretty] - */ -import { withChecks } from '../lib.mjs'; - -/** Пауза в реальном времени (rAF-цикл живёт, fpsMeter копит кадры). */ -const idle = (page, ms) => page.evaluate((m) => new Promise((r) => setTimeout(r, m)), ms); - -export default async function ({ pretty }) { - return withChecks( - 'perf', - async (t) => { - const { c } = t; - - await c.run('старт: страница+ассеты, затем newGame → location', async () => { - const t0 = Date.now(); - await t.open({ newGame: false }); // до маяка «ассеты загружены» + 1,5 c сна - const bootMs = Date.now() - t0; - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const t1 = Date.now(); - await t.ctx.agent.newGame(); // сам ждёт s.scene === 'location' - const newGameMs = Date.now() - t1; - const s = await t.ctx.agent.snapshot(); - c.expect(s.scene === 'location', 'после newGame не в локации', s.scene); - return { bootMs: bootMs - 1500, bootMsRaw: bootMs, newGameMs, fps: s.fps }; - }); - - await c.run('путь: один A* (scene:route) и walkTo через луга', async () => { - const t0 = Date.now(); - const route = await t.ctx.agent.command('scene:route', { x: 48, y: 28 }); - const routeMs = Date.now() - t0; - c.expect(Array.isArray(route) && route.length > 0, 'маршрут на восток лугов не найден', route); - const t1 = Date.now(); - const walked = await t.ctx.agent.walkTo(48, 28, { timeoutTicks: 6000 }); - const walkMs = Date.now() - t1; - c.expect(walked, 'walkTo(48,28) не дошёл', null); - return { routeMs, routeLen: route.length, walkMs }; - }); - - await c.run('fps/heap в покое после прогулки', async () => { - await idle(t.ctx.page, 900); - const s = await t.ctx.agent.snapshot(); - const heap = await t.ctx.page.evaluate( - () => (performance.memory ? Math.round(performance.memory.usedJSHeapSize / 1048576) : null) - ); - c.expect(s.fps > 0, 'fps-метр молчит (нет кадров?)', s.fps); - return { fps: s.fps, heapMB: heap }; - }); - - await c.run('бой: погоня, урон, эффекты — fps/heap под нагрузкой', async () => { - await t.ctx.agent.command('scene:sleepAll'); // чистый старт сценария боя - await t.ctx.agent.command('scene:teleport', { x: 15, y: 18 }); // у сталкера (16,18) - await t.ctx.agent.command('scene:noise', { x: 16, y: 18, level: 1 }); // будим - const w = await t.ctx.agent.waitFor( - 's.enemies.some((e) => e.state === "chase")', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'после шума никто не погнался', w.snapshot?.enemies); - // Полноценный бой: сгусток догоняет и бьёт, эффекты/частицы живут. - await t.ctx.agent.step(360); - for (let i = 0; i < 3; i++) { - await t.ctx.agent.command('scene:damageEnemy', { value: 3 }); - await t.ctx.agent.step(60); - } - await idle(t.ctx.page, 900); - const s = await t.ctx.agent.snapshot(); - const heap = await t.ctx.page.evaluate( - () => (performance.memory ? Math.round(performance.memory.usedJSHeapSize / 1048576) : null) - ); - return { fps: s.fps, heapMB: heap, heroHp: s.hero?.hp, enemies: s.enemies?.length }; - }); - - await c.run('инварианты чисты под нагрузкой', async () => { - await t.expectInvariantsClean('в perf-сценарии'); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/postfx.mjs b/apps/game/tools/checks/postfx.mjs deleted file mode 100644 index eb2b77f..0000000 --- a/apps/game/tools/checks/postfx.mjs +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Сценарий postfx — пост-эффекты мира (PostFx: блюр/аберрация/марево). - * На слух/глаз в headless не проверить силу эффекта — проверяем, что - * команды моста применяются (pulse/set/clear), и снимаем скриншоты в пике - * эффекта для визуальной сверки агентом. - * 1) новая игра: baseline без эффектов; - * 2) blur: точечный импульс вокруг героя — скриншот в пике; - * 3) aberration: импульс сдвига каналов — скриншот в пике; - * 4) shimmer: зона марева у героя — скриншот (держится до clear); - * 5) clear: после сброса и шага — команда отвечает, сцена жива. - * Запуск: node tools/agent.mjs run tools/checks/postfx.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function ({ pretty }) { - return withChecks( - 'postfx', - async (t) => { - const { c } = t; - await c.run('новая игра: baseline без эффектов', async () => { - await t.boot(); - await t.ctx.agent.screenshot('/tmp/rpg_postfx_baseline.png'); - return null; - }); - await c.run('blur: точечный импульс вокруг героя', async () => { - const s = await t.ctx.agent.snapshot(); - const ok = await t.ctx.agent.command('scene:postfx', { - effect: 'blur', - x: s.hero.tile.x, - y: s.hero.tile.y, - level: 3, - value: 10 - }); - c.expect(ok === true, 'scene:postfx blur не применился', ok); - await t.ctx.agent.step(12); // пик огибающей (attack 0.05 + hold 0.1) - await t.ctx.page.screenshot({ path: '/tmp/rpg_postfx_blur.png' }); - return null; - }); - await c.run('aberration: импульс сдвига каналов', async () => { - const ok = await t.ctx.agent.command('scene:postfx', { - effect: 'aberration', - value: 5 - }); - c.expect(ok === true, 'scene:postfx aberration не применился', ok); - await t.ctx.agent.step(10); - await t.ctx.page.screenshot({ path: '/tmp/rpg_postfx_aberration.png' }); - return null; - }); - await c.run('shimmer: зона марева у героя', async () => { - const s = await t.ctx.agent.snapshot(); - const ok = await t.ctx.agent.command('scene:postfx', { - effect: 'shimmer', - x: s.hero.tile.x, - y: s.hero.tile.y, - level: 3, - value: 2.5 - }); - c.expect(ok === true, 'scene:postfx shimmer не применился', ok); - await t.ctx.agent.step(6); - await t.ctx.page.screenshot({ path: '/tmp/rpg_postfx_shimmer.png' }); - return null; - }); - await c.run('clear: сброс эффектов, сцена жива', async () => { - const ok = await t.ctx.agent.command('scene:postfx', { effect: 'clear' }); - c.expect(ok === true, 'scene:postfx clear не сработал', ok); - await t.ctx.agent.step(30); - await t.ctx.page.screenshot({ path: '/tmp/rpg_postfx_clear.png' }); - // Сцена отвечает на мост после сброса. - const s = await t.ctx.agent.snapshot(); - c.expect(s.scene === 'location', 'сцена не жива после clear', s.scene); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/quest-bells.mjs b/apps/game/tools/checks/quest-bells.mjs deleted file mode 100644 index 56fe163..0000000 --- a/apps/game/tools/checks/quest-bells.mjs +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Сценарий quest-bells — полный цикл квеста «Три цветка» (акт 1) через мост. - * 1) Ирвин: elder_first до выбора, вариант по ТЕКСТУ (pickChoiceByText) -> - * флаги met_elder + quest_bells_taken; - * 2) чит-цветы (scene:setVar flowers=3) -> повторный разговор: elder_hand_in - * (квест-стадия ready), флаг quest_bells_done, кат-сцена посадки: - * vars.flowers -> 0, тост «Поляна гудит», поляна зеленеет; - * 3) Мила: trader_first (полотно в сумке, met_mila), затем trader_after - * (эпилог из квест-стадии); - * 4) инвариант interact-used-consistent и контент чисты. - * Запуск: node tools/agent.mjs run tools/checks/quest-bells.mjs - */ -import { withChecks } from '../lib.mjs'; - -/** Реплика героя в elder_first (выбор по тексту — не по индексу). */ -const RING_REPLY = 'Прозвоню дорогу до прудов и вернусь до темноты.'; - -export default async function ({ pretty }) { - return withChecks( - 'quest-bells', - async (t) => { - const { c } = t; - await c.run('Ирвин: elder_first, выбор по тексту, флаги квеста', async () => { - await t.boot(); - await t.sleepEnemies(); - // NPC в Звенце: тропа на востоке (54,28), затем Ирвин. - await t.goToArea('zvenets', { x: 54, y: 28 }); - const talked = await t.talkTo('elder'); // подойти и нажать E - c.expect(talked, 'не подошли к Ирвину'); - const o = await t.ctx.agent.waitFor('s.dialogue?.id === "elder_first"', { timeoutTicks: 900 }); - c.expect(o.ok, 'диалог elder_first не открылся', o.snapshot.dialogue); - // Листаем до выбора: advance при выборе активирует курсор — проверяем снапшот. - for (let i = 0; i < 12; i++) { - const s = await t.ctx.agent.snapshot(); - if (s.dialogue?.waitingForChoice) break; - if (!s.dialogue) break; - await t.ctx.agent.press('advance'); // догнать печать или следующая реплика - } - const picked = await t.ctx.agent.pickChoiceByText(RING_REPLY); - c.expect(picked, 'вариант по тексту не найден', (await t.ctx.agent.snapshot()).dialogue); - const d = await t.ctx.agent.runDialogue(900); - c.expect(d, 'диалог не завершился после выбора'); - const w = await t.ctx.agent.waitFor( - 's.flags.includes("met_elder") && s.flags.includes("quest_bells_taken")', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'граф не поднял флаги знакомства и взятия квеста', w.snapshot.flags); - return null; - }); - await c.run('сдача: elder_hand_in, кат-сцена посадки, поляна зеленеет', async () => { - // Чит-цветы: сам сбор (пруды) покрыт сценарием quest-bells/пруды, - // здесь — сдача. - await t.ctx.agent.command('scene:setVar', { id: 'flowers', value: 3 }); - const talked = await t.talkTo('elder'); - c.expect(talked, 'не подошли к Ирвину для сдачи'); - const o = await t.ctx.agent.waitFor('s.dialogue?.id === "elder_hand_in"', { timeoutTicks: 900 }); - c.expect(o.ok, 'квест-стадия не дала elder_hand_in', o.snapshot.dialogue); - const d = await t.ctx.agent.runDialogue(900); - c.expect(d, 'диалог сдачи не завершился'); - const w = await t.ctx.agent.waitFor( - 's.flags.includes("quest_bells_done") && s.vars.flowers === 0', - { timeoutTicks: 900 } - ); - c.expect(w.ok, 'нет флага сдачи или цветы не списаны', { - flags: w.snapshot.flags, - vars: w.snapshot.vars - }); - // Кат-сцена посадки: тост и конец (действие блокируется до её конца). - const toast = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("гудит")', { - timeoutTicks: 900 - }); - c.expect(toast.ok, 'нет тоста посадки', toast.snapshot.lastToast); - await t.ctx.agent.waitFor('!s.cutscene?.active', { timeoutTicks: 900 }); - return null; - }); - await c.run('Мила: trader_first (полотно) -> trader_after (эпилог)', async () => { - const talked = await t.talkTo('trader'); - c.expect(talked, 'не подошли к Миле'); - const o = await t.ctx.agent.waitFor('s.dialogue?.id === "trader_first"', { timeoutTicks: 900 }); - c.expect(o.ok, 'первый диалог Милы не открылся', o.snapshot.dialogue); - const d1 = await t.ctx.agent.runDialogue(900); - c.expect(d1, 'первый диалог Милы не завершился'); - const inv = await t.ctx.agent.waitFor( - '(s.inventory.find((i) => i.id === "cloth")?.count ?? 0) === 1', - { timeoutTicks: 300 } - ); - c.expect(inv.ok, 'полотно (do[] giveItem) не в сумке', inv.snapshot.inventory); - // Повторный разговор: квест-стадия-эпилог -> trader_after. - const talked2 = await t.talkTo('trader'); - c.expect(talked2, 'не подошли к Миле для эпилога'); - const o2 = await t.ctx.agent.waitFor('s.dialogue?.id === "trader_after"', { timeoutTicks: 900 }); - c.expect(o2.ok, 'эпилог trader_after не открылся', o2.snapshot.dialogue); - const d2 = await t.ctx.agent.runDialogue(600); - c.expect(d2, 'эпилог не завершился'); - return null; - }); - await c.run('инварианты чисты после полного квеста', async () => { - await t.expectInvariantsClean('после полного квеста'); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/resonator.mjs b/apps/game/tools/checks/resonator.mjs deleted file mode 100644 index 38621ac..0000000 --- a/apps/game/tools/checks/resonator.mjs +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Сценарий resonator — полный цикл квеста «Голос для резонатора» (акт 2). - * 1) крючок акта 2: флаг act2_hook -> Клинт в Ржавой роще; - * 2) Клинт: clint_first -> флаги met_clint + quest_resonator_taken; - * 3) звон у трёх деревьев-механизмов (интерактивы tone_tree_N): - * vars.tones растёт до 3, деревья помечены флагами; - * 4) сдача: clint_tune (квест-стадия ready) -> quest_resonator_done, - * кат-сцена настройки: тост «Резонатор держит тон», конец; - * 5) инварианты чисты после полного квеста. - * Запуск: node tools/agent.mjs run tools/checks/resonator.mjs - */ -import { withChecks } from '../lib.mjs'; - -/** Деревья-механизмы (data/quests.ts QUEST_TONE_TILES → интерактивы tone_tree_N). */ -const TREES = ['tone_tree_1', 'tone_tree_2', 'tone_tree_3']; - -export default async function ({ pretty }) { - return withChecks( - 'resonator', - async (t) => { - const { c } = t; - await c.run('крючок акта 2: Клинт появляется в роще', async () => { - await t.boot(); - await t.sleepEnemies(); - await t.goToArea('zvenets', { x: 54, y: 28 }); - await t.sleepEnemies(); - await t.ctx.agent.command('scene:setFlag', { flag: 'act2_hook' }); - // К восточному переходу — телепортом в Звенце: пешком из центра - // маршрут уводит через город и мост не доживает. - await t.ctx.agent.command('scene:teleport', { x: 53, y: 20 }); - await t.goToArea('rust_grove', { x: 54, y: 20 }); - await t.sleepEnemies(); - const s = await t.ctx.agent.snapshot(); - c.expect((s.npcs ?? []).some((n) => n.id === 'clint'), 'Клинта нет в роще после act2_hook', s.npcs); - return null; - }); - await c.run('Клинт: clint_first, флаги знакомства и взятия квеста', async () => { - const talked = await t.talkTo('clint'); // подойти и нажать E - c.expect(talked, 'не подошли к Клинту'); - const o = await t.ctx.agent.waitFor('s.dialogue?.id === "clint_first"', { timeoutTicks: 2400 }); - c.expect(o.ok, 'диалог clint_first не открылся', o.snapshot.dialogue); - const d = await t.ctx.agent.runDialogue(900); - c.expect(d, 'диалог clint_first не завершился'); - const w = await t.ctx.agent.waitFor( - 's.flags.includes("met_clint") && s.flags.includes("quest_resonator_taken")', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'граф не поднял флаги знакомства и взятия квеста', w.snapshot.flags); - return null; - }); - await c.run('звон у деревьев-механизмов: tones 0 -> 3', async () => { - for (const [i, id] of TREES.entries()) { - const used = await t.useInteractable(id); - c.expect(used, `не подошли к ${id}`); - const w = await t.ctx.agent.waitFor( - `s.vars.tones === ${i + 1} && s.flags.includes("tone_tree_${i + 1}")`, - { timeoutTicks: 2400 } - ); - c.expect(w.ok, `${id} не отдало тон`, { vars: w.snapshot.vars }); - } - return null; - }); - await c.run('сдача: clint_tune, кат-сцена настройки, флаг done', async () => { - // Звон у деревьев шумит: краулер у (12,10) просыпается и может - // убить героя по дороге к Клинту — усыпляем перед сдачей. - await t.sleepEnemies(); - const talked = await t.talkTo('clint'); - c.expect(talked, 'не подошли к Клинту для сдачи'); - const o = await t.ctx.agent.waitFor('s.dialogue?.id === "clint_tune"', { timeoutTicks: 2400 }); - c.expect(o.ok, 'квест-стадия не дала clint_tune', o.snapshot.dialogue); - const d = await t.ctx.agent.runDialogue(900); - c.expect(d, 'диалог clint_tune не завершился'); - const w = await t.ctx.agent.waitFor('s.flags.includes("quest_resonator_done")', { - timeoutTicks: 300 - }); - c.expect(w.ok, 'нет флага сдачи резонатора', w.snapshot.flags); - // Кат-сцена настройки: тост и конец (действие блокируется до её конца). - const toast = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("держит тон")', { - timeoutTicks: 900 - }); - c.expect(toast.ok, 'нет тоста настройки', toast.snapshot.lastToast); - await t.ctx.agent.waitFor('!s.cutscene?.active', { timeoutTicks: 900 }); - return null; - }); - await c.run('инварианты чисты после полного квеста', async () => { - await t.expectInvariantsClean('после квеста резонатора'); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/shadows.mjs b/apps/game/tools/checks/shadows.mjs deleted file mode 100644 index 047c619..0000000 --- a/apps/game/tools/checks/shadows.mjs +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Сценарий shadows — тени мира (блобы актёров + направленные тени tall-объектов). - * Математика sunShadow чистая и покрыта юнит-тестами; здесь проверяем, что - * тени в игре ползут за солнцем и гаснут ночью — визуально, по скриншотам - * в ключевые часы (утро/полдень/вечер/ночь). - * 1) новая игра (8:00): baseline — утренние тени влево; - * 2) setTime 13: полдень — короткие тени под объектами; - * 3) setTime 19: вечер — тени вправо; - * 4) setTime 23: ночь — теней нет; - * 5) setTime 8 обратно + шаг: сцена жива, время применилось. - * Запуск: node tools/agent.mjs run tools/checks/shadows.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function ({ pretty }) { - return withChecks( - 'shadows', - async (t) => { - const { c } = t; - const setTime = async (hours) => { - const ok = await t.ctx.agent.command('scene:setTime', { hours }); - c.expect(ok === true, `scene:setTime ${hours}h не применился`, ok); - return ok; - }; - await c.run('новая игра 8:00: baseline, утренние тени влево', async () => { - await t.boot(); - // пережидаем fade-переход меню→локация (скриншот в fade тёмен) - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - // ручные шаги без рендера не обновляют канвас: рисуем кадры явно - await t.ctx.agent.step(10, { render: true }); - const s = await t.ctx.agent.snapshot(); - c.expect(s.scene === 'location', 'не в локации после boot', s.scene); - await t.ctx.page.screenshot({ path: '/tmp/rpg_shadow_morning.png' }); - return null; - }); - await c.run('13:00: полдень, короткие тени', async () => { - await setTime(13); - await t.ctx.agent.step(2); - await t.ctx.page.screenshot({ path: '/tmp/rpg_shadow_noon.png' }); - return null; - }); - await c.run('19:00: вечер, тени вправо', async () => { - await setTime(19); - await t.ctx.agent.step(2); - await t.ctx.page.screenshot({ path: '/tmp/rpg_shadow_evening.png' }); - return null; - }); - await c.run('23:00: ночь, теней нет', async () => { - await setTime(23); - await t.ctx.agent.step(2); - await t.ctx.page.screenshot({ path: '/tmp/rpg_shadow_night.png' }); - return null; - }); - await c.run('возврат к 8:00: время применилось, сцена жива', async () => { - await setTime(8); - await t.ctx.agent.step(5); - const s = await t.ctx.agent.snapshot(); - c.expect(s.scene === 'location', 'сцена не жива после setTime', s.scene); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/synth.mjs b/apps/game/tools/checks/synth.mjs deleted file mode 100644 index 2d6192c..0000000 --- a/apps/game/tools/checks/synth.mjs +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Сценарий synth — спек-синтез: агент без слуха описывает звуки параметрами - * (scene:synthesize → playSpec), факты запуска читаем из window.__gameAudioLog. - * 1) удар (hit): запуск в логе с заданным ключом и громкостью; - * 2) звон (chime): другой ключ, кэш — компиляция не мешает повтору; - * 3) детерминизм: тот же спек дважды — оба запуска в логе; - * 4) негодный спек (нет dur / неизвестный kind) — команда возвращает null; - * 5) гул (hum) не глушит амбиент области (разные голоса). - * Запуск: node tools/agent.mjs run tools/checks/synth.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function ({ pretty }) { - return withChecks( - 'synth', - async (t) => { - const { c } = t; - /** - * Команда моста с ожиданием появления ключа в логе - * (синтез асинхронный): считаем запуски ключа до и после. - */ - const synthAndWait = async (spec, { key = 'agent/synth', volume, tries = 12 } = {}) => { - const count = async () => - (await t.readAudioLog()).filter((e) => e.key === key).length; - const before = await count(); - const res = await t.ctx.agent.command('scene:synthesize', { - spec, - key, - ...(volume !== undefined ? { volume } : {}) - }); - const log = await t.waitAudioLog((l) => l.filter((e) => e.key === key).length > before, tries); - return { res, log }; - }; - await c.run('новая игра: спек-синтез недоступен в прод-сборке с внятной ошибкой', async () => { - await t.boot(); - c.expect(Array.isArray(await t.readAudioLog()), 'DEV-лог аудио недоступен (прод-сборка?)'); - return null; - }); - await c.run('удар по спеку: запуск в логе с заданным ключом и громкостью', async () => { - const { res, log } = await synthAndWait( - { kind: 'hit', dur: 0.3, low: 600, high: 3000, seed: 7 }, - { key: 'agent/hit-test', volume: 0.5 } - ); - c.expect(res === true, 'scene:synthesize не принят мостом', res); - const hits = log.filter((e) => e.key === 'agent/hit-test'); - c.expect(hits.length > 0, 'удар по спеку не сыграл', log); - c.expect(Math.abs(hits.at(-1).volume - 0.5) < 0.01, 'громкость не дошла до голоса', hits.at(-1)); - return null; - }); - await c.run('звон по спеку: другой ключ играет параллельно', async () => { - const { res, log } = await synthAndWait( - { kind: 'chime', dur: 0.6, freq: 880 }, - { key: 'agent/chime-test' } - ); - c.expect(res === true, 'scene:synthesize не принят мостом', res); - c.expect(log.some((e) => e.key === 'agent/chime-test'), 'звон по спеку не сыграл', log); - c.expect( - log.some((e) => e.key === 'agent/hit-test'), - 'предыдущий голос пропал из лога (лог мал?)', - log - ); - return null; - }); - await c.run('детерминизм: тот же спек дважды — оба запуска в логе', async () => { - const spec = { kind: 'scrape', dur: 0.5, low: 80, high: 400, tone: 60, seed: 3 }; - const first = await synthAndWait(spec, { key: 'agent/scrape-test' }); - const second = await synthAndWait(spec, { key: 'agent/scrape-test' }); - c.expect(first.res === true && second.res === true, 'команда не принята', [first.res, second.res]); - const plays = second.log.filter((e) => e.key === 'agent/scrape-test'); - c.expect(plays.length >= 2, 'повторный синтез не сыграл', plays); - return null; - }); - await c.run('негодный спек: без dur и с неизвестным kind — null', async () => { - const noDur = await t.ctx.agent.command('scene:synthesize', { spec: { kind: 'hit' } }); - const badKind = await t.ctx.agent.command('scene:synthesize', { spec: { kind: 'thunder', dur: 1 } }); - const noSpec = await t.ctx.agent.command('scene:synthesize', {}); - c.expect(noDur === null, 'спек без dur должен вернуть null', noDur); - c.expect(badKind === null, 'неизвестный kind должен вернуть null', badKind); - c.expect(noSpec === null, 'вызов без спека должен вернуть null', noSpec); - return null; - }); - await c.run('гул (hum) не глушит музыку области', async () => { - const { res, log } = await synthAndWait( - { kind: 'hum', dur: 2, tone: 55, loop: true }, - { key: 'agent/hum-test' } - ); - c.expect(res === true, 'scene:synthesize не принят мостом', res); - c.expect(log.some((e) => e.key === 'agent/hum-test'), 'гул по спеку не сыграл', log); - // Луга без амбиента — проверяем, что гул не глушит тему лугов. - c.expect(log.some((e) => e.key === 'music/meadows'), 'тема лугов пропала', log); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/time.mjs b/apps/game/tools/checks/time.mjs deleted file mode 100644 index 3e957f5..0000000 --- a/apps/game/tools/checks/time.mjs +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Сценарий time — внутриигровое время суток через мост: старт, ход по тикам, - * кривая суток (закат), ночные источники/лампа, инварианты. - * Темп: 1 реальная секунда = 1 игровая минута, тик фиксированный 1/60 с — - * 600 шагов моста = ровно 10 игровых минут (детерминированно, без ожидания). - * 1) Новая игра: старт 8:00, фактор ночи 0; - * 2) перемотка 8:00 + 600 шагов → +10 минут (±1, float-хвост-floor); - * 3) 19:00 — середина заката: night ≈ 0.5, ambient — микс по фактическому - * фактору (время идёт и после пина, точное равенство неустойчиво); - * 4) 21:00 — ночь: night 1, лампа героя зажглась на светлой днём улице; - * 5) часы (scene:give) кладутся в сумку и видны в снапшоте; - * 6) сумка открыта — время заморожено (локация не тикается), закрыта — идёт; - * 7) инварианты чисты (time-bounded и прочие). - * Запуск: node tools/agent.mjs run tools/checks/time.mjs - */ -import { withChecks } from '../lib.mjs'; - -/** Ambient лугов и его ночной микс на середине заката (19:00). */ -const AMB = { meadows: 0xf7f4ea, night: 0x3e4658 }; -/** Золотой час (те же константы, что в движке lightSim). */ -const DUSK = { peak: 0.45, color: 0xe8c090 }; - -export default async function ({ pretty }) { - return withChecks( - 'time', - async (t) => { - const { c } = t; - await c.run('новая игра: старт 8:00, день', async () => { - await t.boot(); - const s = await t.ctx.agent.snapshot(); - c.expect(s.time.minutes >= 480 && s.time.minutes < 485, `старт не 8:00 (${s.time.label})`, s.time); - c.expect(s.time.night === 0, 'утром фактор ночи не нулевой', s.time); - c.expect(/^\d{1,2}:\d{2}$/.test(s.time.label), `label не часы (${s.time.label})`, s.time); - return null; - }); - await c.run('ход времени: 600 шагов = +10 минут (±1)', async () => { - await t.ctx.agent.command('scene:setTime', { hours: 8 }); // пин: точка отсчёта 480 - await t.ctx.agent.step(600); // 600 тиков × (1/60 с) × 1 мин/с = 10 мин - const s = await t.ctx.agent.snapshot(); - const dt = s.time.minutes - 480; - c.expect(dt >= 9 && dt <= 11, `после 600 шагов прошло ${dt} мин (не ~10)`, s.time); - return null; - }); - await c.run('закат 19:00: night ≈ 0.5, ambient — микс по фактору', async () => { - await t.ctx.agent.command('scene:setTime', { hours: 19 }); - const w = await t.ctx.agent.waitFor('Math.abs(s.time.night - 0.5) < 0.02', { timeoutTicks: 300 }); - c.expect(w.ok, 'на середине заката фактор ночи не ~0.5', w.snapshot?.time); - // Время продолжает идти (1 тик ≈ +1/60 мин) — сверяем ambient с миксом - // по фактическому фактору из того же снапшота (округление по каналам). - const s = await t.ctx.agent.snapshot(); - const mix = (a, b, k) => - [16, 8, 0].map((sh) => { - const ca = (a >> sh) & 255, cb = (b >> sh) & 255; - return Math.round(ca * (1 - k) + cb * k) << sh; - }).reduce((x, y) => x | y); - // Трёхопорный микс суток (день -> золотой час -> ночь), как dayNightAmbient. - const k = s.time.night; - const linear = k < DUSK.peak - ? mix(AMB.meadows, DUSK.color, k / DUSK.peak) - : mix(DUSK.color, AMB.night, (k - DUSK.peak) / (1 - DUSK.peak)); - const expected = k <= 0 ? AMB.meadows : k >= 1 ? AMB.night : linear; - c.expect(s.lighting.ambient === expected, `ambient ${s.lighting.ambient.toString(16)} не микс ${expected.toString(16)}`, { ambient: s.lighting.ambient, night: s.time.night }); - return null; - }); - await c.run('ночь 21:00: night 1, ночная цель и лампа героя', async () => { - await t.ctx.agent.command('scene:setTime', { hours: 21 }); - const w = await t.ctx.agent.waitFor( - `s.time.night === 1 && s.lighting.ambient === ${AMB.night} && s.lighting.sources.some((l) => l.id === "lamp")`, - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'в 21:00 луга не ночные или лампа не зажглась', w.snapshot?.lighting); - return null; - }); - await c.run('часы в сумке: scene:give кладёт предмет', async () => { - await t.ctx.agent.command('scene:give', { id: 'clock' }); - const s = await t.ctx.agent.snapshot(); - c.expect(s.inventory.some((i) => i.id === 'clock' && i.count === 1), 'часов нет в снапшоте сумки', s.inventory); - return null; - }); - await c.run('сумка: время заморожено, после закрытия — идёт', async () => { - await t.ctx.agent.command('scene:setTime', { hours: 10 }); // пин перед открытием - await t.ctx.agent.press('inventory'); // открыть сумку (атомарно) - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 120 }); - await t.ctx.agent.press('advance'); // применить часы (первый пункт) — строка в панели - await t.ctx.agent.step(120); // 2 с: в сумке локация не тикается - const s = await t.ctx.agent.snapshot(); - c.expect(s.time.minutes === 600, `в сумке время уехало (${s.time.label})`, s.time); - await t.ctx.agent.press('inventory'); // закрыть сумку - const w = await t.ctx.agent.waitFor('s.time.minutes > 600', { timeoutTicks: 300 }); - c.expect(w.ok, 'после закрытия сумки время не пошло', w.snapshot?.time); - return null; - }); - await c.run('инварианты чисты при идущем времени', async () => { - await t.expectInvariantsClean('при идущем времени'); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/transitions.mjs b/apps/game/tools/checks/transitions.mjs deleted file mode 100644 index a2dc12c..0000000 --- a/apps/game/tools/checks/transitions.mjs +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Сценарий transitions — единый механизм переходов. - * 1) step-переход: пешком в Звенец -> area==='zvenets'. - * 2) клик-переход: клик по колодцу у дома Ирвина -> телепорт на луга (entry (22,21)). - * 3) обратный колодец: клик по луговому колодцу -> возврат в Звенец. - * 4) акт 2: Звенец -> Ржавая роща -> Старый разъезд -> зал Машины (step). - * 5) снапшот перечисляет переходы области с триггерами. - * Запуск: node tools/agent.mjs run tools/checks/transitions.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function ({ pretty }) { - return withChecks( - 'transitions', - async (t) => { - const { c } = t; - await c.run('step-переход: луга -> Звенец пешком', async () => { - await t.boot(); - // Спим врагов, чтобы путь был безопасным; идём к тропе (54,28). - await t.sleepEnemies(); - await t.goToArea('zvenets', { x: 54, y: 28 }); - return null; - }); - await c.run('клик-переход: колодец Звенца -> луга', async () => { - // Колодец (16,13) у дома Ирвина; entry на лугах — (44,41). - // Подходим к колодцу и жмём действие E. - const used = await t.interactAt(16, 13); - c.expect(used, 'не подошли к колодцу Звенца'); - const w = await t.ctx.agent.waitFor('s.area === "meadows"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'колодец не телепортировал на луга', { area: w.snapshot.area }); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await t.ctx.agent.snapshot(); - c.expect(s.hero.tile.x === 44 && s.hero.tile.y === 41, 'герой не на entry (44,41)', s.hero.tile); - return null; - }); - await c.run('обратный колодец: луга -> Звенец', async () => { - // Луговой колодец (44,40) стоит рядом с entry — подходим и жмём E. - const used = await t.interactAt(44, 40); - c.expect(used, 'не подошли к луговому колодцу'); - const w = await t.ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'луговой колодец не вернул в Звенец', { area: w.snapshot.area }); - await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await t.ctx.agent.snapshot(); - c.expect(s.hero.tile.x === 16 && s.hero.tile.y === 14, 'герой не на entry (16,14) Звенца', s.hero.tile); - return null; - }); - await c.run('снапшот: переходы области с триггерами', async () => { - const s = await t.ctx.agent.snapshot(); - const wells = (s.transitions ?? []).filter((tr) => tr.trigger === 'click'); - c.expect(wells.length >= 1, 'нет click-переходов в снапшоте', s.transitions); - c.expect( - (s.transitions ?? []).every((tr) => typeof tr.to === 'string' && tr.to.length > 0), - 'переход без цели', - s.transitions - ); - return null; - }); - await c.run('акт 2: Звенец -> роща -> разъезд -> зал Машины', async () => { - // Цепочка step-переходов акта 2: восток Звенца -> роща -> разъезд, - // затем люк (28,31) вниз в зал Машины. Врагов спим на каждой - // области: бой сбивает маршрут (как в smoke-act1). - await t.sleepEnemies(); - await t.goToArea('rust_grove', { x: 54, y: 20 }); - await t.sleepEnemies(); - await t.goToArea('old_siding', { x: 46, y: 20 }); - await t.sleepEnemies(); - await t.goToArea('machine_hall', { x: 28, y: 31 }); - // Обратно наверх: проём в нижней стене зала. - await t.goToArea('old_siding', { x: 13, y: 21 }); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/checks/zoom.mjs b/apps/game/tools/checks/zoom.mjs deleted file mode 100644 index c81a83d..0000000 --- a/apps/game/tools/checks/zoom.mjs +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Сценарий zoom — зум камеры: действие, ходьба и диалог не ломаются при z=2. - * Проверки в Звенце (там NPC): 1) клавиша debugZoom: worldRoot.scale = 2; - * 2) действие E у NPC при зуме открывает диалог; - * 3) walkTo при зуме доходит (culling /z); - * 4) возврат на z=1: масштаб как до зума. - * Запуск: node tools/agent.mjs run tools/checks/zoom.mjs - */ -import { withChecks } from '../lib.mjs'; - -export default async function ({ pretty }) { - return withChecks( - 'zoom', - async (t) => { - const { c } = t; - const worldRoot = () => t.ctx.page.evaluate(() => window.__agent.worldRootView()); - await c.run('включение зума: scale=2', async () => { - await t.boot(); - await t.sleepEnemies(); - await t.goToArea('zvenets', { x: 54, y: 28 }); - const before = await worldRoot(); - c.expect(before.scale === 1, 'стартовый зум не 1', before.scale); - await t.ctx.agent.press('debugZoom'); - // scale ставится в Camera.apply на рендер-кадре — даём кадры. - await t.ctx.agent.step(3, { render: true }); - const after = await worldRoot(); - c.expect(after.scale === 2, 'после debugZoom scale не 2', after.scale); - return null; - }); - await c.run('действие E у NPC при зуме открывает диалог', async () => { - const s = await t.ctx.agent.snapshot(); - const npc = s.npcs?.[0]; - c.expect(!!npc, 'в области нет NPC', s.npcs); - // Герой подходит сам (interactAt) и открывает диалог. - const talked = await t.talkTo(npc.id); - c.expect(talked, 'не подошли к NPC при зуме'); - const d = await t.ctx.agent.runDialogue(900); - c.expect(d, 'диалог с NPC при зуме не открылся'); - return null; - }); - await c.run('walkTo при зуме доходит до цели', async () => { - const s = await t.ctx.agent.snapshot(); - const target = { x: s.hero.tile.x + 3, y: s.hero.tile.y + 3 }; - const walked = await t.ctx.agent.walkTo(target.x, target.y, { timeoutTicks: 1500 }); - c.expect(walked, `walkTo(${target.x},${target.y}) при зуме не дошёл`); - return null; - }); - await c.run('возврат на z=1: масштаб как до зума', async () => { - await t.ctx.agent.press('debugZoom'); - await t.ctx.agent.step(3, { render: true }); - const after = await worldRoot(); - c.expect(after.scale === 1, 'повторный debugZoom не вернул scale=1', after.scale); - return null; - }); - }, - { pretty } - ); -} \ No newline at end of file diff --git a/apps/game/tools/dialogue-editor/editor.css b/apps/game/tools/dialogue-editor/editor.css deleted file mode 100644 index 50498b9..0000000 --- a/apps/game/tools/dialogue-editor/editor.css +++ /dev/null @@ -1,137 +0,0 @@ -/* Редактор диалогов: тёмная тема, без внешних ресурсов. */ - -:root { - --bg: #14161c; - --panel: #1c1f27; - --panel2: #232733; - --border: #3a3f4f; - --text: #d6d9e0; - --muted: #8a8fa0; - --accent: #f0d878; - --edge: #6a7185; - --edge-choice: #d8b04a; - --danger: #d88a8a; - --ok: #8ad88f; -} - -* { box-sizing: border-box; } - -body { - margin: 0; - background: var(--bg); - color: var(--text); - font: 14px/1.45 system-ui, sans-serif; - height: 100vh; - display: flex; - flex-direction: column; -} - -.muted { color: var(--muted); } -.spacer { flex: 1; } -.accent { border-color: var(--accent); color: var(--accent); } -.danger { border-color: var(--danger); color: var(--danger); } - -button, select, input, textarea { - background: var(--panel2); - color: var(--text); - border: 1px solid var(--border); - border-radius: 4px; - padding: 4px 8px; - font: inherit; -} -button { cursor: pointer; } -button:hover { border-color: var(--accent); } -input, textarea { width: 100%; } - -#topbar { - display: flex; - gap: 10px; - align-items: center; - padding: 8px 12px; - background: var(--panel); - border-bottom: 1px solid var(--border); -} -#topbar h1 { font-size: 15px; margin: 0; } - -#layout { - flex: 1; - display: grid; - grid-template-columns: 170px 1fr 340px; - min-height: 0; -} - -#graph-list { - background: var(--panel); - border-right: 1px solid var(--border); - padding: 8px; - overflow-y: auto; -} -#graph-list button { - display: block; - width: 100%; - text-align: left; - margin-bottom: 4px; - border: 1px solid transparent; - background: none; -} -#graph-list button:hover { border-color: var(--border); } -#graph-list button.active { border-color: var(--accent); color: var(--accent); } - -#canvas-wrap { position: relative; overflow: auto; min-width: 0; } -#canvas { width: 100%; height: 100%; min-height: 400px; } - -/* Узел-карточка. */ -.node-card rect { - fill: var(--panel); - stroke: var(--border); - rx: 6; -} -.node-card.selected rect { stroke: var(--accent); stroke-width: 2; } -.node-card.ghost rect { stroke-dasharray: 4 3; stroke: var(--danger); } -.node-card text { fill: var(--text); font: 12px system-ui, sans-serif; } -.node-card .speaker { fill: var(--accent); font-weight: 600; } -.node-card .body { fill: var(--muted); } -.node-card .badge { fill: #4a5164; font: 10px monospace; } - -#run-output, #problems { - position: absolute; - left: 12px; - right: 12px; - bottom: 12px; - max-height: 40%; - overflow-y: auto; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 6px; - padding: 8px 12px; - white-space: pre-wrap; -} -#problems .err { color: var(--danger); } -#problems .warn { color: var(--accent); } - -#inspector { - background: var(--panel); - border-left: 1px solid var(--border); - padding: 10px; - overflow-y: auto; -} -#inspector .row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; } -#inspector label { display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: var(--muted); flex: 1; } -#inspector label.grow { flex: 2; } -#inspector details { margin-bottom: 8px; } -#inspector summary { cursor: pointer; color: var(--muted); margin-bottom: 6px; } -#inspector .buttons { justify-content: space-between; } -#inspector .buttons button { flex: 1; white-space: nowrap; } - -.choice-edit { - border: 1px solid var(--border); - border-radius: 4px; - padding: 6px; - margin-bottom: 6px; - background: var(--panel2); -} -.choice-edit .row { margin-bottom: 4px; } -.choice-edit button { margin-top: 4px; } - -#status.ok { color: var(--ok); } -#status.err { color: var(--danger); } \ No newline at end of file diff --git a/apps/game/tools/dialogue-editor/editor.js b/apps/game/tools/dialogue-editor/editor.js deleted file mode 100644 index 2a89708..0000000 --- a/apps/game/tools/dialogue-editor/editor.js +++ /dev/null @@ -1,578 +0,0 @@ -/** - * Клиент редактора диалогов (D8): список графов, SVG-канва с карточками и - * рёбрами, инспектор узла. Без фреймворков и CDN; авторитет валидации — - * сервер (probe.ts), клиент лишь мгновенно подсвечивает сирот и неизвестные id. - */ - -const $ = (id) => document.getElementById(id); -const SVGNS = 'http://www.w3.org/2000/svg'; -const CARD_W = 190; -const CARD_H = 74; -const GAP_X = 90; -const GAP_Y = 24; - -let graphIds = []; -let currentId = null; -let mtime = null; -let graph = null; -let selected = null; -let dirty = false; - -/** Позиции узлов последнего рендера (для рёбер). */ -let posMap = new Map(); - -async function jget(url) { - const r = await fetch(url); - const body = await r.json(); - if (!r.ok) throw new Error(body.message ?? `${r.status}`); - return body; -} - -async function jsend(url, method, body) { - const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - return { ok: r.ok, status: r.status, body: await r.json() }; -} - -function setStatus(text, cls = '') { - $('status').textContent = text; - $('status').className = `muted ${cls}`; -} - -function markDirty() { - dirty = true; - setStatus('не сохранено'); -} - -/** Все исходящие рёбра узла: [{to, kind}] — next и next выборов. */ -function edgesOf(id) { - const n = graph.nodes[id]; - if (!n) return []; - const out = []; - if (n.next) out.push({ to: n.next, kind: 'next' }); - (n.choices ?? []).forEach((c) => { - if (c.next) out.push({ to: c.next, kind: 'choice' }); - }); - return out; -} - -/** Слоёная раскладка: BFS от start (уровень → колонка), сироты — в последнюю. */ -function computeLayout() { - const pos = new Map(); - const depth = new Map(); - if (!graph.nodes[graph.start]) return pos; - depth.set(graph.start, 0); - const queue = [graph.start]; - while (queue.length > 0) { - const id = queue.shift(); - for (const e of edgesOf(id)) { - if (graph.nodes[e.to] && !depth.has(e.to)) { - depth.set(e.to, depth.get(id) + 1); - queue.push(e.to); - } - } - } - const rows = new Map(); - const lastCol = Math.max(-1, ...depth.values()) + 1; - for (const id of Object.keys(graph.nodes)) { - const d = depth.get(id) ?? lastCol; - const row = rows.get(d) ?? 0; - rows.set(d, row + 1); - pos.set(id, { x: d * (CARD_W + GAP_X) + 16, y: row * (CARD_H + GAP_Y) + 16, orphan: !depth.has(id) }); - } - return pos; -} - -/** Эллипсис для карточки. */ -function preview(s, n) { - const s2 = (s ?? '').replace(/\s+/g, ' ').trim(); - return s2.length > n ? `${s2.slice(0, n - 1)}…` : s2; -} - -function svgText(x, y, str, cls) { - const t = document.createElementNS(SVGNS, 'text'); - t.setAttribute('x', x); - t.setAttribute('y', y); - if (cls) t.setAttribute('class', cls); - t.textContent = str; - return t; -} - -/** Реплика ли узел: есть текст (inline или ключ). */ -function isReplica(n) { - return n.text !== undefined || n.textKey !== undefined; -} - -/** Краткие бейджи условий и эффектов (внизу карточки). */ -function badgesOf(n) { - const parts = []; - if (n.mood) parts.push(`mood:${n.mood}`); - if (n.when?.length) parts.push(`when:${n.when.join(',')}`); - if (n.whenNot?.length) parts.push(`!${n.whenNot.join(',')}`); - if (n.whenVar) parts.push(`${n.whenVar.key}${n.whenVar.op}${n.whenVar.value}`); - if (n.whenVars?.length) parts.push(`${n.whenVars.length}×var`); - if (n.hasItem?.length) parts.push(`item:${n.hasItem.join(',')}`); - if (n.setFlags?.length) parts.push(`+${n.setFlags.join(',')}`); - if (n.setVars && Object.keys(n.setVars).length) parts.push(`v=${Object.keys(n.setVars).join(',')}`); - (n.do ?? []).forEach((o) => parts.push(`${o.kind}${o.id ? ':' + o.id : ''}`)); - return parts.join(' '); -} - -/** Карточка узла (rect + 4 строки текста). */ -function drawNodeCard(id) { - const n = graph.nodes[id]; - const p = posMap.get(id); - const g = document.createElementNS(SVGNS, 'g'); - g.setAttribute('class', `node-card${selected === id ? ' selected' : ''}${p.orphan ? ' ghost' : ''}`); - g.setAttribute('transform', `translate(${p.x},${p.y})`); - const rect = document.createElementNS(SVGNS, 'rect'); - rect.setAttribute('width', CARD_W); - rect.setAttribute('height', CARD_H); - rect.setAttribute('rx', 6); - g.appendChild(rect); - g.appendChild(svgText(10, 16, n.speaker ?? (isReplica(n) ? '—' : '(действие)'), 'speaker')); - g.appendChild(svgText(10, 30, id, '')); - g.appendChild(svgText(10, 46, preview(n.text ?? n.textKey ?? '', 27), 'body')); - const badges = badgesOf(n); - if (badges) g.appendChild(svgText(10, 62, preview(badges, 30), 'badge')); - g.addEventListener('click', () => select(id)); - $('nodes').appendChild(g); -} - -/** Ребро между карточками: кубическая кривая от правой грани к левой. */ -function drawEdge(from, to, kind) { - const a = posMap.get(from); - const b = posMap.get(to); - if (!a || !b) return; - const x1 = a.x + CARD_W; - const y1 = a.y + CARD_H / 2; - const x2 = b.x; - const y2 = b.y + CARD_H / 2; - const mx = (x1 + x2) / 2; - const path = document.createElementNS(SVGNS, 'path'); - path.setAttribute('d', `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`); - path.setAttribute('fill', 'none'); - path.setAttribute('stroke', kind === 'choice' ? 'var(--edge-choice)' : 'var(--edge)'); - if (kind === 'choice') path.setAttribute('stroke-dasharray', '5 4'); - path.setAttribute('stroke-width', '1.5'); - $('edges').appendChild(path); -} - -function renderCanvas() { - posMap = computeLayout(); - $('edges').replaceChildren(); - $('nodes').replaceChildren(); - for (const id of Object.keys(graph.nodes)) { - for (const e of edgesOf(id)) { - if (graph.nodes[e.to]) drawEdge(id, e.to, e.kind); - } - } - for (const id of Object.keys(graph.nodes)) { - drawNodeCard(id); - } - const xs = [...posMap.values()].map((p) => p.x + CARD_W + GAP_X); - const ys = [...posMap.values()].map((p) => p.y + CARD_H + GAP_Y); - $('canvas').setAttribute('viewBox', `0 0 ${Math.max(480, ...xs)} ${Math.max(400, ...ys)}`); -} - -function select(id) { - selected = id; - renderCanvas(); - renderInspector(); -} - -/* ---------- инспектор ---------- */ - -function setField(el, value) { - el.value = value ?? ''; -} - -/** Собрать whenVar из трёх полей (пустой ключ — условия нет). */ -function whenVarFromInputs() { - const key = $('f-whenvar-key').value.trim(); - if (!key) return undefined; - return { key, op: $('f-whenvar-op').value, value: Number($('f-whenvar-value').value || 0) }; -} - -/** do[] из строки «kind:id:count, kind:id». */ -function doFromString(s) { - const ops = []; - for (const part of s.split(',')) { - const [kind, id, count] = part.trim().split(':').map((x) => x.trim()); - if (!kind) continue; - const op = { kind }; - if (id) op.id = id; - if (count) op.count = Number(count); - ops.push(op); - } - return ops.length > 0 ? ops : undefined; -} - -function doToString(ops) { - return (ops ?? []).map((o) => [o.kind, o.id, o.count].filter((x) => x !== undefined).join(':')).join(', '); -} - -/** setVars из строки «ключ=число, ...». */ -function setVarsFromString(s) { - const out = {}; - for (const part of s.split(',')) { - const [k, v] = part.split('=').map((x) => x.trim()); - if (k && v !== undefined && v !== '') out[k] = Number(v); - } - return Object.keys(out).length > 0 ? out : undefined; -} - -function setVarsToString(vars) { - return Object.entries(vars ?? {}).map(([k, v]) => `${k}=${v}`).join(', '); -} - -/** Заполнить поля инспектора из узла. */ -function fillInspector(id) { - const n = graph.nodes[id]; - setField($('f-id'), id); - setField($('f-speaker'), n.speaker); - setField($('f-mood'), n.mood); - setField($('f-text'), n.text); - setField($('f-textkey'), n.textKey); - setField($('f-tags'), (n.tags ?? []).join(', ')); - setField($('f-next'), n.next ?? ''); - setField($('f-when'), (n.when ?? []).join(', ')); - setField($('f-whennot'), (n.whenNot ?? []).join(', ')); - const wv = n.whenVar ?? n.whenVars?.[0]; - setField($('f-whenvar-key'), wv?.key); - setField($('f-whenvar-op'), wv?.op ?? 'ge'); - setField($('f-whenvar-value'), wv?.value); - setField($('f-setflags'), (n.setFlags ?? []).join(', ')); - setField($('f-setvars'), setVarsToString(n.setVars)); - setField($('f-do'), doToString(n.do)); - fillNextOptions(n.next ?? ''); - renderChoicesEditor(id); -} - -/** Варианты next: все узлы + пустой конец. */ -function fillNextOptions(current) { - const sel = $('f-next'); - sel.replaceChildren(new Option('— конец —', '')); - for (const id of Object.keys(graph.nodes)) sel.appendChild(new Option(id, id)); - sel.value = current; -} - -/** Обёртка label>control (строки инспектора). */ -function wrapLabel(text, control, grow) { - const label = document.createElement('label'); - if (grow) label.className = 'grow'; - label.append(text, control); - return label; -} - -/** Редактор одного выбора: text / next / when / удалить. */ -function choiceRow(id, index, ch) { - const div = document.createElement('div'); - div.className = 'choice-edit'; - const text = document.createElement('input'); - text.value = ch.text ?? ''; - text.placeholder = 'текст варианта'; - text.addEventListener('input', () => { - ch.text = text.value; - markDirty(); - renderCanvas(); - }); - const next = document.createElement('select'); - next.appendChild(new Option('— конец —', '')); - for (const nid of Object.keys(graph.nodes)) next.appendChild(new Option(nid, nid)); - next.value = ch.next ?? ''; - next.addEventListener('change', () => { - if (next.value) ch.next = next.value; - else delete ch.next; - markDirty(); - renderCanvas(); - }); - const when = document.createElement('input'); - when.value = (ch.when ?? []).join(', '); - when.placeholder = 'when (флаги, через запятую)'; - when.addEventListener('input', () => { - const flags = listOf(when.value); - if (flags.length > 0) ch.when = flags; - else delete ch.when; - markDirty(); - }); - const del = document.createElement('button'); - del.textContent = '× удалить'; - del.className = 'danger'; - del.addEventListener('click', () => { - graph.nodes[id].choices.splice(index, 1); - if (graph.nodes[id].choices.length === 0) delete graph.nodes[id].choices; - markDirty(); - renderAll(); - }); - const row1 = document.createElement('div'); - row1.className = 'row'; - row1.appendChild(wrapLabel('text', text, true)); - const row2 = document.createElement('div'); - row2.className = 'row'; - row2.appendChild(wrapLabel('next', next, true)); - const row3 = document.createElement('div'); - row3.className = 'row'; - row3.appendChild(wrapLabel('when', when, true)); - div.append(row1, row2, row3, del); - return div; -} - -function renderChoicesEditor(id) { - const n = graph.nodes[id]; - const wrap = $('choices'); - wrap.replaceChildren(); - (n.choices ?? []).forEach((ch, i) => { - wrap.appendChild(choiceRow(id, i, ch)); - }); -} - -/* ---------- запись полей обратно в узел ---------- */ - -/** Поля с comma-списками: пустое поле → пустой список. */ -function listOf(s) { - return s.split(',').map((x) => x.trim()).filter(Boolean); -} - -function bindField(elId, apply) { - $(elId).addEventListener('input', () => { - if (!selected) return; - apply(graph.nodes[selected]); - markDirty(); - renderCanvas(); - }); -} - -/** whenVar-поле: единая запись (редактируем одно условие; whenVars[1+] не трогаем). */ -function bindWhenVar() { - const apply = (n) => { - const wv = whenVarFromInputs(); - if (wv) { - n.whenVar = wv; - delete n.whenVars; - } else { - delete n.whenVar; - delete n.whenVars; - } - }; - for (const id of ['f-whenvar-key', 'f-whenvar-op', 'f-whenvar-value']) bindField(id, apply); -} - -function bindInspector() { - bindField('f-speaker', (n) => { - if ($('f-speaker').value) n.speaker = $('f-speaker').value; - else delete n.speaker; - }); - bindField('f-mood', (n) => { - if ($('f-mood').value) n.mood = $('f-mood').value; - else delete n.mood; - }); - bindField('f-text', (n) => { - if ($('f-text').value) n.text = $('f-text').value; - else delete n.text; - }); - bindField('f-textkey', (n) => { - if ($('f-textkey').value) n.textKey = $('f-textkey').value; - else delete n.textKey; - }); - bindField('f-tags', (n) => { - const tags = listOf($('f-tags').value); - if (tags.length > 0) n.tags = tags; - else delete n.tags; - }); - bindField('f-when', (n) => { - const flags = listOf($('f-when').value); - if (flags.length > 0) n.when = flags; - else delete n.when; - }); - bindField('f-whennot', (n) => { - const flags = listOf($('f-whennot').value); - if (flags.length > 0) n.whenNot = flags; - else delete n.whenNot; - }); - bindWhenVar(); - bindField('f-setflags', (n) => { - const flags = listOf($('f-setflags').value); - if (flags.length > 0) n.setFlags = flags; - else delete n.setFlags; - }); - bindField('f-setvars', (n) => { - const vars = setVarsFromString($('f-setvars').value); - if (vars) n.setVars = vars; - else delete n.setVars; - }); - bindField('f-do', (n) => { - const ops = doFromString($('f-do').value); - if (ops) n.do = ops; - else delete n.do; - }); - $('f-next').addEventListener('change', () => { - if (!selected) return; - if ($('f-next').value) graph.nodes[selected].next = $('f-next').value; - else delete graph.nodes[selected].next; - markDirty(); - renderCanvas(); - }); - $('btn-add-choice').addEventListener('click', () => { - if (!selected) return; - (graph.nodes[selected].choices ??= []).push({ text: 'Новый вариант' }); - markDirty(); - renderAll(); - }); - $('btn-add-node').addEventListener('click', addNode); - $('btn-del-node').addEventListener('click', deleteNode); -} - -/** Свободный id узла: node_N. */ -function freeNodeId() { - let i = 1; - while (graph.nodes[`node_${i}`]) i++; - return `node_${i}`; -} - -function addNode() { - const id = freeNodeId(); - graph.nodes[id] = { text: '' }; - markDirty(); - select(id); -} - -function deleteNode() { - if (!selected) return; - if (selected === graph.start) { - setStatus('start удалять нельзя — выбери другой узел', 'err'); - return; - } - delete graph.nodes[selected]; - selected = null; - markDirty(); - renderAll(); -} - -/* ---------- список графов, открытие, сохранение, dry-run ---------- */ - -async function loadGraphList() { - graphIds = await jget('/api/graphs'); - const nav = $('graph-list'); - nav.replaceChildren(); - for (const g of graphIds) { - const b = document.createElement('button'); - b.textContent = g.id; - b.className = g.id === currentId ? 'active' : ''; - b.addEventListener('click', () => switchGraph(g.id)); - nav.appendChild(b); - } -} - -async function switchGraph(id) { - if (dirty && !confirm('Есть несохранённые правки. Перейти и потерять их?')) return; - await openGraph(id); -} - -async function openGraph(id) { - const data = await jget(`/api/graph/${id}`); - currentId = id; - mtime = data.mtime; - graph = data.graph; - selected = null; - dirty = false; - $('graph-title').textContent = `— ${id}`; - $('run-output').hidden = true; - $('problems').hidden = true; - setStatus('загружено с диска'); - await loadGraphList(); - renderAll(); -} - -function renderInspector() { - if (selected && graph.nodes[selected]) { - $('inspector-empty').hidden = true; - $('inspector-form').hidden = false; - fillInspector(selected); - } else { - $('inspector-empty').hidden = false; - $('inspector-form').hidden = true; - } -} - -function renderAll() { - renderCanvas(); - renderInspector(); -} - -async function save() { - if (!graph) return; - setStatus('валидация…'); - const r = await jsend(`/api/graph/${currentId}`, 'PUT', { mtime, graph }); - if (r.status === 409) { - if (confirm(`${r.body.message}. Перечитать с диска и потерять локальные правки?`)) { - await openGraph(currentId); - } - return; - } - if (r.status === 422) { - showProblems(r.body); - setStatus('ошибки валидации — не сохранено', 'err'); - return; - } - if (!r.ok) { - setStatus(`ошибка: ${r.body.message ?? r.status}`, 'err'); - return; - } - mtime = r.body.mtime; - dirty = false; - $('problems').hidden = true; - setStatus('сохранено', 'ok'); -} - -/** Проблемы (валидация/анализ) в панель над канвой. */ -function showProblems(out) { - const div = $('problems'); - div.replaceChildren(); - const line = (cls, text) => { - const p = document.createElement('div'); - p.className = cls; - p.textContent = text; - div.appendChild(p); - }; - for (const inv of out.errors ?? []) line('err', `[ошибка] ${inv.message}`); - for (const inv of out.warnings ?? []) line('warn', `[предупреждение] ${inv.message}`); - for (const o of out.orphans ?? []) line('warn', `[сирота] узел «${o}» недостижим`); - for (const cy of out.cycles ?? []) line('err', `[цикл без текста] ${cy.join(' → ')}`); - for (const e of out.silentEnds ?? []) line('warn', `[тихий конец] «${e}»`); - div.hidden = div.childElementCount === 0; -} - -async function dryRun() { - if (!graph) return; - setStatus('прогон…'); - const r = await jsend('/api/dry-run', 'POST', { graph, preset: $('preset').value }); - if (!r.ok) { - setStatus(`ошибка: ${r.body.message ?? r.status}`, 'err'); - return; - } - const div = $('run-output'); - div.textContent = r.body.run ?? ''; - div.hidden = false; - showProblems(r.body); - setStatus('прогон готов', 'ok'); -} - -async function init() { - const list = await jget('/api/graphs'); - if (list.length === 0) { - setStatus('в data/dialogues нет графов', 'err'); - return; - } - bindInspector(); - $('btn-save').addEventListener('click', save); - $('btn-reload').addEventListener('click', async () => { - if (!dirty || confirm('Потерять локальные правки?')) await openGraph(currentId); - }); - $('btn-dry').addEventListener('click', dryRun); - window.addEventListener('beforeunload', (e) => { - if (dirty) e.preventDefault(); - }); - await openGraph(list[0].id); -} - -init().catch((e) => setStatus(String(e), 'err')); \ No newline at end of file diff --git a/apps/game/tools/dialogue-editor/index.html b/apps/game/tools/dialogue-editor/index.html deleted file mode 100644 index f2d3c19..0000000 --- a/apps/game/tools/dialogue-editor/index.html +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - Редактор диалогов — Пепельные луга - - - -
-

Редактор диалогов

- - - - - - - -
- -
- - -
- - - - - - -
- - -
- - - - \ No newline at end of file diff --git a/apps/game/tools/dialogue-editor/server.mjs b/apps/game/tools/dialogue-editor/server.mjs deleted file mode 100644 index 4963f37..0000000 --- a/apps/game/tools/dialogue-editor/server.mjs +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Сервер визуального редактора диалогов (D8): node http без зависимостей. - * Отдаёт статику (index.html/editor.js/editor.css), читает и пишет графы - * в data/dialogues/*.json (атомарно, с mtime-гардом от конкурентной правки), - * валидацию и dry-run делает спавном vite-node tools/dialogues/probe.ts — - * правила одни (dialogueRules.ts), дублирования нет. - * - * Запуск: npm run dialogues [-- --port 5299] - */ -import http from 'node:http'; -import { spawn } from 'node:child_process'; -import { readFile, writeFile, rename, readdir, stat } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -/** Корень монорепо (server.mjs — на 4 уровня ниже корня). */ -const ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); -const DIALOGUES_DIR = path.join(ROOT, 'apps/game/src/data/dialogues'); -const STATIC_DIR = path.join(ROOT, 'apps/game/tools/dialogue-editor'); -const VITE_NODE = path.join(ROOT, 'node_modules/.bin/vite-node'); -/** Порт редактора: --port N (иначе 5299; строка `??` после indexOf(-1) дала бы NaN). */ -const portIdx = process.argv.indexOf('--port'); -const PORT = - portIdx !== -1 && Number.isFinite(Number(process.argv[portIdx + 1])) - ? Number(process.argv[portIdx + 1]) - : 5299; - - -/** Спавн vite-node (TS-импорты из src): stdout — последняя строка (JSON). */ -function viteNode(args, input) { - return new Promise((resolve, reject) => { - const p = spawn(VITE_NODE, args, { cwd: ROOT }); - let out = ''; - let err = ''; - if (input !== undefined) { - p.stdin.write(input); - } - p.stdin.end(); - p.stdout.on('data', (c) => (out += c)); - p.stderr.on('data', (c) => (err += c)); - p.on('error', reject); - p.on('close', (code) => { - const line = out.trim().split('\n').filter(Boolean).pop() ?? ''; - try { - resolve(JSON.parse(line)); - } catch { - reject(new Error(`probe упал (code ${code}): ${err.slice(-500) || line.slice(-200)}`)); - } - }); - }); -} - -/** Валидация графа (probe.ts): Invariants + анализ достижимости. */ -function probe(graph) { - return viteNode(['apps/game/tools/dialogues/probe.ts'], JSON.stringify({ op: 'validate', graph })); -} - -/** Dry-run графа на пресете состояния. */ -function dryRun(graph, preset) { - return viteNode(['apps/game/tools/dialogues/probe.ts'], JSON.stringify({ op: 'dry-run', graph, preset })); -} - -/** Список графов: id + mtime (для гарандов конкурентной правки). */ -async function listGraphs() { - const files = (await readdir(DIALOGUES_DIR)).filter((f) => f.endsWith('.json')).sort(); - const out = []; - for (const f of files) { - const id = f.replace(/\.json$/, ''); - out.push({ id, mtime: (await stat(path.join(DIALOGUES_DIR, f))).mtimeMs }); - } - return out; -} - -/** Канонический формат файла: 4 пробела + \\n в конце. */ -function serialize(graph) { - return JSON.stringify(graph, null, 4) + '\n'; -} - -const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }; - -/** Ответ хелпером: JSON / текст / буфер статики / ошибка. */ -function send(res, code, body, type = 'application/json; charset=utf-8') { - res.writeHead(code, { 'Content-Type': type }); - if (Buffer.isBuffer(body) || typeof body === 'string') res.end(body); - else res.end(JSON.stringify(body)); -} - -/** Чтение тела запроса (JSON, лимит 2 МБ — графы маленькие). */ -function readBody(req) { - return new Promise((resolve, reject) => { - let data = ''; - req.on('data', (c) => { - data += c; - if (data.length > 2 * 1024 * 1024) req.destroy(); - }); - req.on('end', () => { - try { - resolve(JSON.parse(data || '{}')); - } catch (e) { - reject(new Error(`тело не JSON: ${e.message}`)); - } - }); - }); -} - -/** PUT графа: mtime-гард -> валидация -> атомарная запись. */ -async function putGraph(id, body) { - const file = path.join(DIALOGUES_DIR, `${id}.json`); - let current; - try { - current = (await stat(file)).mtimeMs; - } catch { - return { code: 404, body: { message: `граф «${id}» не найден` } }; - } - if (typeof body.mtime !== 'number' || body.mtime !== current) { - // Конкурентная правка: отдаём свежий mtime — клиент предложит перечитать. - return { code: 409, body: { message: 'файл изменён на диске', mtime: current } }; - } - const probeOut = await probe(body.graph); - if (probeOut.errors.length > 0) { - return { code: 422, body: probeOut }; - } - const tmp = `${file}.tmp`; - await writeFile(tmp, serialize(body.graph)); - await rename(tmp, file); - return { code: 200, body: { ok: true, mtime: (await stat(file)).mtimeMs } }; -} - -const server = http.createServer(async (req, res) => { - const url = new URL(req.url ?? '/', `http://localhost:${PORT}`); - try { - if (url.pathname === '/api/graphs' && req.method === 'GET') { - return send(res, 200, await listGraphs()); - } - const graphMatch = url.pathname.match(/^\/api\/graph\/(\w+)$/); - if (graphMatch) { - const id = graphMatch[1]; - const file = path.join(DIALOGUES_DIR, `${id}.json`); - if (req.method === 'GET') { - const data = await readFile(file, 'utf8'); - return send(res, 200, { id, mtime: (await stat(file)).mtimeMs, graph: JSON.parse(data) }); - } - if (req.method === 'PUT') { - const { code, body } = await putGraph(id, await readBody(req)); - return send(res, code, body); - } - } - if (url.pathname === '/api/dry-run' && req.method === 'POST') { - const { graph, preset } = await readBody(req); - return send(res, 200, await dryRun(graph, preset)); - } - if (url.pathname === '/api/registries' && req.method === 'GET') { - return send(res, 200, await viteNode(['apps/game/tools/dialogues/registries.ts'], '')); - } - // Статика редактора (нет файла — 404, не 500). - const name = url.pathname === '/' ? 'index.html' : url.pathname.slice(1); - if (/^[\w.-]+$/.test(name)) { - try { - const data = await readFile(path.join(STATIC_DIR, name)); - return send(res, 200, data, MIME[path.extname(name)] ?? 'application/octet-stream'); - } catch { - return send(res, 404, { message: `нет файла «${name}»` }); - } - } - send(res, 404, { message: 'нет такого пути' }); - } catch (e) { - send(res, 500, { message: String(e?.message ?? e) }); - } -}); - -server.listen(PORT, () => { - console.log(`редактор диалогов: http://localhost:${PORT}`); -}); \ No newline at end of file diff --git a/apps/game/tools/dialogues/dry-run.ts b/apps/game/tools/dialogues/dry-run.ts deleted file mode 100644 index 4e75f46..0000000 --- a/apps/game/tools/dialogues/dry-run.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * CLI dry-run диалогов: прогон графов через DialogueRunner на реальном - * GameState (без Pixi) + структурный анализ. Печатает дерево «узел → - * выборы → куда», сироты, циклы, тихие концы и битые ссылки. - * - * Запуск: npm run dialogues:dry [-- ] - * (vite-node — чтобы работал TS/JSON-импорт из src.) - */ -import { GameState, DialogueRunner } from '@rpg/engine'; -import { DIALOGUES } from '../../src/data/dialogues'; -import { analyzeGraph, checkGraph, type GraphRefs } from '../../src/data/dialogueRules'; -import { FLAGS, VARS } from '../../src/data/ids'; -import { ITEMS } from '../../src/data/items'; -import { DIALOGUE_CUSTOM } from '../../src/data/effects'; - -/** Реестры игры для checkGraph (те же множества, что в validate.ts). */ -function refs(): GraphRefs { - return { - flags: new Set(Object.keys(FLAGS)), - vars: new Set(Object.keys(VARS)), - items: new Set(Object.keys(ITEMS)), - customs: new Set(Object.keys(DIALOGUE_CUSTOM)), - strings: new Set() - }; -} - -/** Пресеты состояния: в каких условиях гоняем каждый граф. */ -const PRESETS: { name: string; setup: (s: GameState) => void }[] = [ - { name: 'fresh', setup: () => {} }, - { - name: 'quest-taken+flowers', - setup: (s) => { - s.setFlag('quest_bells_taken'); - s.setFlag('met_elder'); - s.setVar('flowers', 3); - } - }, - { - name: 'quest-done', - setup: (s) => { - s.setFlag('quest_bells_taken'); - s.setFlag('met_elder'); - s.setFlag('quest_bells_done'); - } - } -]; - -/** Дерево реплик: что покажет раннер и куда можно пойти — см. main(). */ - -const main = (): void => { - const only = process.argv[2]; - let errors = 0; - for (const [id, graph] of Object.entries(DIALOGUES)) { - if (only && id !== only) continue; - console.log(`\n=== ${id} (start: ${graph.start}) ===`); - const a = analyzeGraph(graph); - const bad = checkGraph(id, graph, refs()).filter((i) => i.severity === 'error'); - errors += bad.length; - for (const inv of bad) console.log(` [${inv.severity}] ${inv.id}: ${inv.message}`); - for (const o of a.orphans) console.log(` [warn] сирота: «${o}»`); - for (const cycle of a.textlessCycles) console.log(` [error] цикл без текста: ${cycle.join(' → ')}`); - for (const end of a.silentEnds) console.log(` [warn] тихий конец: «${end}»`); - - // Прогон на каждом пресете: какие реплики реально покажутся. - for (const preset of PRESETS) { - const state = new GameState(); - preset.setup(state); - const runner = new DialogueRunner(state); - const shown: string[] = []; - runner.setView({ - show: (n) => shown.push(`${n.speaker ? n.speaker + ': ' : ''}${n.text}` + - (n.choices.length > 0 ? ` [${n.choices.map((c) => c.text).join(' | ')}]` : '')), - hide: () => {} - }); - runner.start(graph); - while (runner.active && !runner.waitingForChoice) runner.advance(); - console.log(` [${preset.name}] ${shown.join(' → ') || '(молча)'}`); - } - } - if (errors > 0) process.exitCode = 1; -}; - -main(); \ No newline at end of file diff --git a/apps/game/tools/dialogues/probe.ts b/apps/game/tools/dialogues/probe.ts deleted file mode 100644 index 396eaf0..0000000 --- a/apps/game/tools/dialogues/probe.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Проверка произвольного графа (для сервера редактора, D8). Читает stdin: - * { "op": "validate", "graph": {...} } — Invariants + анализ - * { "op": "dry-run", "graph": {...}, "preset": "fresh" } — ещё и прогон раннера - * Печатает JSON: - * { errors, warnings, orphans, cycles, silentEnds, run? } - * Запуск: vite-node apps/game/tools/dialogues/probe.ts - */ -import { GameState, DialogueRunner } from '@rpg/engine'; -import type { DialogueGraph } from '@rpg/engine'; -import { analyzeGraph, checkGraph, type GraphRefs } from '../../src/data/dialogueRules'; -import { FLAGS, VARS } from '../../src/data/ids'; -import { ITEMS } from '../../src/data/items'; -import { DIALOGUE_CUSTOM } from '../../src/data/effects'; - -/** Реестры игры (те же множества, что в validate.ts и dry-run.ts). */ -function refs(): GraphRefs { - return { - flags: new Set(Object.keys(FLAGS)), - vars: new Set(Object.keys(VARS)), - items: new Set(Object.keys(ITEMS)), - customs: new Set(Object.keys(DIALOGUE_CUSTOM)), - strings: new Set() - }; -} - -/** Пресеты состояния — как в dry-run.ts (общая семантика с CLI). */ -const PRESETS: { name: string; setup: (s: GameState) => void }[] = [ - { name: 'fresh', setup: () => {} }, - { - name: 'quest-taken+flowers', - setup: (s) => { - s.setFlag('quest_bells_taken'); - s.setFlag('met_elder'); - s.setVar('flowers', 3); - } - }, - { - name: 'quest-done', - setup: (s) => { - s.setFlag('quest_bells_taken'); - s.setFlag('met_elder'); - s.setFlag('quest_bells_done'); - } - } -]; - -/** Один прогон раннера: реплики до первого выбора (или до конца). */ -function walk(graph: DialogueGraph, preset: string): string { - const state = new GameState(); - (PRESETS.find((p) => p.name === preset) ?? PRESETS[0]!).setup(state); - const runner = new DialogueRunner(state); - const shown: string[] = []; - runner.setView({ - show: (n) => - void shown.push( - `${n.speaker ? n.speaker + ': ' : ''}${n.text}` + - (n.choices.length > 0 ? ` [${n.choices.map((c) => c.text).join(' | ')}]` : '') - ), - hide: () => {} - }); - runner.start(graph); - while (runner.active && !runner.waitingForChoice) runner.advance(); - return shown.join(' → ') || '(молча)'; -} - -const main = (): void => { - const chunks: string[] = []; - process.stdin.on('data', (c) => chunks.push(c)); - process.stdin.on('end', () => { - const req = JSON.parse(chunks.join('')) as { - op?: string; - graph: DialogueGraph; - preset?: string; - }; - const graph = req.graph; - const a = analyzeGraph(graph); - const inv = checkGraph('editor', graph, refs()); - const out: Record = { - errors: inv.filter((i) => i.severity === 'error'), - warnings: inv.filter((i) => i.severity === 'warn'), - orphans: a.orphans, - cycles: a.textlessCycles, - silentEnds: a.silentEnds - }; - if (req.op === 'dry-run') { - out.run = walk(graph, req.preset ?? 'fresh'); - } - console.log(JSON.stringify(out)); - }); -}; - -main(); \ No newline at end of file diff --git a/apps/game/tools/dialogues/registries.ts b/apps/game/tools/dialogues/registries.ts deleted file mode 100644 index ba8f005..0000000 --- a/apps/game/tools/dialogues/registries.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Реестры игры для редактора диалогов (D8): печатает JSON с допустимыми - * id — сервер отдаёт их клиенту для выпадающих списков и подсказок. - * - * Запуск: vite-node apps/game/tools/dialogues/registries.ts - */ -import { FLAGS, VARS } from '../../src/data/ids'; -import { ITEMS } from '../../src/data/items'; -import { DIALOGUE_CUSTOM } from '../../src/data/effects'; -import { DIALOGUES } from '../../src/data/dialogues'; - -const main = (): void => { - const out = { - flags: Object.keys(FLAGS), - vars: Object.keys(VARS), - items: Object.keys(ITEMS), - customs: Object.keys(DIALOGUE_CUSTOM), - dialogues: Object.keys(DIALOGUES), - strings: [] as string[] - }; - console.log(JSON.stringify(out)); -}; - -main(); \ No newline at end of file diff --git a/apps/game/tools/final-smoke.mjs b/apps/game/tools/final-smoke.mjs deleted file mode 100644 index 8eaa968..0000000 --- a/apps/game/tools/final-smoke.mjs +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Финальный визуальный смоук (финал ревью): переходы луга<->Звенец x3, - * бой с врагом, тост знака — проверка отсутствия наслоения вьюх/звука - * после повторных переходов. Скриншоты в /tmp/rpg_final_*.png. - * Запуск: node apps/game/tools/final-smoke.mjs [url] - */ -import { fightNearest, openGame, startDevServer } from './lib.mjs'; - -const server = await startDevServer(); -let ctx; -try { - ctx = await openGame({ url: server.url, newGame: true }); - const { page, agent } = ctx; - const fail = []; - - /** Туда-обратно: пешком к тропе (54,28) -> Звенец -> колодец (16,13) -> луга. */ - const roundTrip = async (n) => { - await agent.command('scene:sleepAll'); - const w1 = await agent.walkTo(54, 28, { timeoutTicks: 6000 }); - if (!w1) fail.push(`поездка ${n}: не дошёл до тропы`); - const z = await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); - if (!z.ok) fail.push(`поездка ${n}: не в Звенце`); - await agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - if (await agent.approach(16, 13)) agent.press('interact'); // колодец у дома Ирвина - else fail.push(`поездка ${n}: не подошли к колодцу`); - const m = await agent.waitFor('s.area === "meadows"', { timeoutTicks: 1200 }); - if (!m.ok) fail.push(`поездка ${n}: колодец не вернул на луга`); - await agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await page.screenshot({ path: `/tmp/rpg_final_trip${n}.png` }); - }; - - for (let n = 1; n <= 3; n++) await roundTrip(n); - console.log('3 поездки туда-обратно: ок'); - - // Тост знака у тропы (48,27): подходим и жмём действие. - if (await agent.approach(48, 27)) agent.press('interact'); - else fail.push('не подошли к знаку у тропы'); - const t = await agent.waitFor('(s.lastToast?.text ?? "").includes("Звенец")', { timeoutTicks: 1500 }); - console.log('тост знака:', t.ok ? t.snapshot.lastToast?.text : 'НЕ ПОКАЗАН'); - if (!t.ok) fail.push('тост знака не показан'); - - // Бой: подойти к ближайшему живому врагу, доворот facing, конус - // (fightNearest — см. lib.mjs; позиция врага плавает, до 3 попыток). - const hit = await fightNearest(agent); - console.log('бой:', hit ? 'враг получил урон' : 'УРОНА НЕТ'); - if (!hit) fail.push('враг не получил урон'); - await page.screenshot({ path: '/tmp/rpg_final_combat.png' }); - - // Наслоение звука: после 3-го возврата амбиент не должен перезапуститься - // (guard ключа). Лог — кольцевой на 24 записи, старт с бута давно вытеснен, - // поэтому следим за хвостом лога после перехода. - let ambRestarted = false; - for (let i = 0; i < 4 && !ambRestarted; i++) { - await agent.step(30); - const tail = await page.evaluate(() => - (window.__gameAudioLog ?? []).slice(-8).map((e) => e.key) - ); - ambRestarted = tail.includes('ambience/meadows'); - } - console.log('перезапуск амбиента после 3-го возврата:', ambRestarted ? 'ЕСТЬ' : 'нет'); - if (ambRestarted) fail.push('амбиент перезапустился после возврата на луга'); - // Объекты в снапшоте по одному на id; враги — без id в снапшоте, поэтому - // проверяем, что их число не растёт от поездок (наслоения вьюх нет). - const snap = await agent.snapshot(); - const dupIds = (arr) => arr.length - new Set(arr.map((x) => x.id)).size; - const dup = dupIds(snap.interactables ?? []); - console.log('дубли id объектов:', dup); - if (dup > 0) fail.push(`дубли в снапшоте: ${dup}`); - - const errs = ((await agent.invariants()) ?? []).filter((i) => i.severity === 'error'); - if (errs.length) fail.push(`инварианты: ${JSON.stringify(errs)}`); - - console.log(fail.length ? 'ПРОВАЛЫ: ' + fail.join('; ') : 'ИТОГ: чисто'); - process.exitCode = fail.length ? 1 : 0; -} finally { - try { - await ctx?.browser?.close(); - } catch {} - server.stop(); -} \ No newline at end of file diff --git a/apps/game/tools/guards/boundary.mjs b/apps/game/tools/guards/boundary.mjs deleted file mode 100644 index ebaa21f..0000000 --- a/apps/game/tools/guards/boundary.mjs +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Гвард границы «движок / игра» — сценарий запуска (npm run guard). - * Правила и механизм проверки — жанронезависимые, в @rpg/engine/tools/boundary.mjs. - * Нарушения печатаются JSON-ом, exit 1 при наличии error-нарушений. - */ -import { checkBoundary, collectSources } from '@rpg/engine/tools/boundary.mjs'; - -const violations = checkBoundary(collectSources()); -console.log(JSON.stringify({ ok: violations.length === 0, violations }, null, 2)); -process.exit(violations.length ? 1 : 0); \ No newline at end of file diff --git a/apps/game/tools/lib.mjs b/apps/game/tools/lib.mjs deleted file mode 100644 index 8252230..0000000 --- a/apps/game/tools/lib.mjs +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Сценарный слой тулз «Пепельных лугов» поверх жанронезависимого каркаса - * @rpg/engine/tools/agent-lib.mjs: маяк загрузки игры и re-export общих - * примитивов. Смоуки и checks/*.mjs импортируют отсюда. - */ -import { fileURLToPath } from 'node:url'; -import { - Checks, - openGame as engineOpenGame, - startDevServer as engineStartDevServer -} from '@rpg/engine/tools/agent-lib.mjs'; - -export { CHROMIUM, launchBrowser, AgentClient, Checks, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; - -/** Каталог игры (index.html + vite-конфиг) — дефолтный cwd dev-сервера. */ -const GAME_ROOT = fileURLToPath(new URL('..', import.meta.url)); - -/** startDevServer по умолчанию поднимает vite из каталога игры, а не из корня монорепо. */ -export const startDevServer = (opts = {}) => engineStartDevServer({ cwd: GAME_ROOT, ...opts }); - -/** Консольный маяк BootScene: ассеты загружены, мост готов. */ -export const BEACON = '[boot] ассеты загружены'; - -/** openGame с игровым маяком загрузки. */ -export const openGame = (opts = {}) => engineOpenGame({ beacon: BEACON, ...opts }); - -/** - * Удар по ближайшему живому врагу через агентный мост. Возврат — урон прошёл. - * Подход задаёт facing только последним отрезком пути — он может прийти - * перпендикулярно врагу, поэтому после подхода герой делает короткий шаг в - * его сторону (клавиши движения — экранный вектор: facing совпадёт с - * направлением на врага) и бьёт конусом. Враг подвижен — до 3 попыток со - * свежей позицией. - */ -export const fightNearest = async (agent) => { - const snap = await agent.snapshot(); - const hero = snap.hero; - const foes = (snap.enemies ?? []).filter((e) => !e.dead && e.hp > 0); - if (foes.length === 0) return true; // бить некого — не провал - const d2 = (e) => (e.pos.x - hero.pos.x) ** 2 + (e.pos.y - hero.pos.y) ** 2; - const foe = foes.reduce((b, e) => (d2(e) < d2(b) ? e : b)); - const hurt = - `(s.enemies.filter((e) => !e.dead).length) < ${foes.length} || ` + - `s.enemies.some((e) => !e.dead && e.hp < ${Math.min(...foes.map((e) => e.hp))})`; - for (let i = 0; i < 3; i++) { - const cur = await agent.snapshot(); - // Свежая позиция цели: враг сам идёт на героя, пока мы подходили. - const live = (cur.enemies ?? []).find( - (e) => !e.dead && e.hp > 0 && Math.hypot(e.pos.x - foe.pos.x, e.pos.y - foe.pos.y) < 3 - ); - if (!live) return true; // добита (нашим прошлым тапом или сплэшем) - const dest = await agent.approach(Math.floor(live.pos.x), Math.floor(live.pos.y)); - if (dest) { - const dx = live.pos.x - cur.hero.pos.x; - const dy = live.pos.y - cur.hero.pos.y; - // Экранные оси — мировые диагонали: sx = dx − dy, sy = dx + dy. - const sx = dx - dy; - const sy = dx + dy; - const dir = Math.abs(sx) >= Math.abs(sy) ? (sx >= 0 ? 'right' : 'left') : sy >= 0 ? 'down' : 'up'; - agent.press(dir, 5); // шаг к врагу задаёт facing - agent.press('attack'); - } - const w = await agent.waitFor(hurt, { timeoutTicks: 120 }); - if (w.ok) return true; - } - return false; -}; - -/** - * Каркас чек-сценария: поднимает dev-сервер, даёт помощники (boot, goToArea, - * sleepEnemies, readAudioLog, waitAudioLog, expectInvariantsClean) и в любом - * исходе гасит браузер и сервер. Сценарий оставляет только шаги проверки. - * - * export default async function ({ pretty }) { - * return withChecks('имя', async (t) => { ... }, { pretty }); - * } - */ -export async function withChecks(name, fn, { pretty } = {}) { - const c = new Checks(name); - const server = await startDevServer(); - const h = { ctx: null }; - const t = { - c, - /** Контекст игры (заполняется boot). */ - get ctx() { - return h.ctx; - }, - /** Новая игра + выход из стартового fade-перехода. */ - boot: async () => { - h.ctx = await openGame({ url: server.url, newGame: true }); - await h.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - return h.ctx; - }, - /** - * Открыть игру с сырыми опциями openGame (boot = open({newGame: true}) + - * ожидание fade). Для сценариев, которым нужен свой тайминг старта. - */ - open: async (opts = {}) => { - h.ctx = await openGame({ url: server.url, ...opts }); - return h.ctx; - }, - /** Спим всех врагов (безопасная навигация по миру). */ - sleepEnemies: () => h.ctx.agent.command('scene:sleepAll'), - /** - * Пешком к тайлу-триггеру step-перехода (via) и дождаться области - * (двери-переходы — явным interactAt в сценарии). - */ - goToArea: async (area, via) => { - const walked = await h.ctx.agent.walkTo(via.x, via.y, { timeoutTicks: 6000 }); - c.expect(walked, `walkTo(${via.x},${via.y}) не дошёл до перехода`); - const w = await h.ctx.agent.waitFor(`s.area === "${area}"`, { timeoutTicks: 1200 }); - c.expect(w.ok, `после перехода не в «${area}»`, w.snapshot?.area); - await h.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - return w; - }, - /** - * Подойти к тайлу и нажать действие (E): walkTo на проходимый (цветок, - * мот), approach на непроходимый (стены, объекты, NPC-тайлы — путь в - * занятый тайл не строится). Таймаут с запасом: карты ×2, пешком - * бывает далеко. Возврат — дошли ли. - */ - interactAt: async (tx, ty) => { - const a = h.ctx.agent; - const ok = - (await a.walkTo(tx, ty, { timeoutTicks: 6000 })) || - (await a.approach(tx, ty, { timeoutTicks: 6000 })) !== null; - if (ok) a.press('interact'); - return ok; - }, - /** Подойти к NPC по id (координаты — из снапшота) и заговорить. */ - talkTo: async (npcId) => { - const snap = await h.ctx.agent.snapshot(); - const npc = (snap.npcs ?? []).find((n) => n.id === npcId); - c.expect(npc, `NPC «${npcId}» не найден в снапшоте`, null); - if (!(await t.interactAt(npc.tile.x, npc.tile.y))) return false; - const w = await h.ctx.agent.waitFor('s.dialogue != null', { timeoutTicks: 300 }); - return w.ok; - }, - /** Подойти к интерактивному объекту по id и нажать действие. */ - useInteractable: async (id) => { - const snap = await h.ctx.agent.snapshot(); - const it = (snap.interactables ?? []).find((o) => o.id === id); - c.expect(it, `интерактив «${id}» не найден в снапшоте`, null); - return t.interactAt(it.tile.x, it.tile.y); - }, - /** Удар по ближайшему живому врагу: см. fightNearest выше. */ - fightNearest: async () => { - const ok = await fightNearest(h.ctx.agent); - c.expect(ok, 'враг не получил урон', null); - return ok; - }, - /** DEV-лог аудио страницы (window.__gameAudioLog) или null вне DEV. */ - readAudioLog: () => h.ctx.page.evaluate(() => window.__gameAudioLog ?? null), - /** - * Ждать условие на аудио-логе: декод WAV и компиляция спеков в headless - * занимают заметное время — шагаем и опрашиваем, а не читаем сразу. - */ - waitAudioLog: async (pred, tries = 12) => { - let log = await t.readAudioLog(); - for (let i = 0; i < tries && !pred(log); i++) { - await h.ctx.agent.step(30); - log = await t.readAudioLog(); - } - return log; - }, - /** Ошибки инвариантов моста: пусто — чисто (warn допустим). */ - expectInvariantsClean: async (what) => { - const inv = await h.ctx.agent.invariants(); - const errs = (inv ?? []).filter((i) => i.severity === 'error'); - c.expect(errs.length === 0, `инварианты нарушены${what ? `: ${what}` : ''}`, errs); - return errs; - } - }; - try { - await fn(t); - } finally { - try { - await h.ctx?.browser?.close(); - } catch { - // браузер мог умереть раньше — сервер всё равно гасим - } - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; -} \ No newline at end of file diff --git a/apps/game/tools/maps/gen.test.ts b/apps/game/tools/maps/gen.test.ts deleted file mode 100644 index 450213c..0000000 --- a/apps/game/tools/maps/gen.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; -import { encodeMap, parseMap } from '@rpg/engine'; -import { - buildMeadowsMap, - buildPondsMap, - buildZvenetsMap, - buildRustGroveMap, - buildOldSidingMap, - buildMachineHallMap, - buildHouseElderMap, - buildShopMap -} from '../../src/data/map'; - -/** - * Карты-файлы: fresh-тест (диск == генератор) и round-trip RLE — read-only - * и идут в каждом `npm test`. Запись файлов — только при RUN_MAPS_GEN=1 - * (`npm run maps`): обычный прогон тестов не трогает рабочее дерево. - */ - -const here = dirname(fileURLToPath(import.meta.url)); -const outDir = resolve(here, '../../assets/maps'); -const gen = !!process.env.RUN_MAPS_GEN; -const d = gen ? describe : describe.skip; - -const MAPS: Record> = { - meadows: buildMeadowsMap(), - ponds: buildPondsMap(), - zvenets: buildZvenetsMap(), - rust_grove: buildRustGroveMap(), - old_siding: buildOldSidingMap(), - machine_hall: buildMachineHallMap(), - house_elder: buildHouseElderMap(), - shop: buildShopMap() -}; - -describe('карты-файлы на диске равны генераторам (fresh)', () => { - for (const [id, data] of Object.entries(MAPS)) { - it(`${id}.map не разъехался с генератором (npm run maps лечит)`, () => { - const path = resolve(outDir, `${id}.map`); - const onDisk = JSON.parse(readFileSync(path, 'utf8')); - expect(onDisk).toEqual(encodeMap(data)); - }); - } -}); - -describe('rpg-map, RLE (round-trip, read-only)', () => { - for (const [id, data] of Object.entries(MAPS)) { - it(`${id}: encode -> parse восстанавливает карту`, () => { - const back = parseMap(JSON.parse(JSON.stringify(encodeMap(data)))); - - expect(back.width).toBe(data.width); - expect(back.height).toBe(data.height); - expect(back.blocked).toEqual(data.blocked); - expect(back.tiles).toEqual(data.tiles); - expect(back.tall).toEqual(data.tall); - expect(back.props).toEqual(data.props); - }); - } - - it('RLE реально сжимает карты', () => { - for (const [id, data] of Object.entries(MAPS)) { - const file = encodeMap(data); - const pairs = file.tiles as [number, number][]; - // Серий меньше, чем тайлов: RLE что-то ужал, несмотря на пятнистость - expect(pairs.length).toBeLessThan(data.tiles.length); - expect(file.encoding).toBe('rle'); - expect(id.length).toBeGreaterThan(0); - } - }); -}); - -d('запись карт-файлов (RUN_MAPS_GEN=1 / npm run maps)', () => { - for (const [id, data] of Object.entries(MAPS)) { - it(`${id}: пишет assets/maps/${id}.map`, () => { - const file = encodeMap(data); - mkdirSync(outDir, { recursive: true }); - writeFileSync(resolve(outDir, `${id}.map`), JSON.stringify(file)); - expect(true).toBe(true); - }); - } -}); \ No newline at end of file diff --git a/apps/game/tools/pixelart/__tests__/sizes.test.ts b/apps/game/tools/pixelart/__tests__/sizes.test.ts deleted file mode 100644 index 2c180e7..0000000 --- a/apps/game/tools/pixelart/__tests__/sizes.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; -import { SIZES, SLOTS } from '../sizes.mjs'; - -/** - * Реестр размеров (sizes.mjs) — единственный источник размеров спрайтов - * для gen.mjs и lint-art.mjs. Здесь проверяется сам реестр и его - * согласованность с фактическими ассетами (манифест AI-атласов). - */ - -const here = dirname(fileURLToPath(import.meta.url)); -const dim = (s: { w: number } | number[]) => (Array.isArray(s) ? `${s[0]}x${s[1]}` : `${s.w}x${s.h}`); - -describe('реестр размеров спрайтов', () => { - it('все группы дают валидные слоты «ширина x высота»', () => { - for (const group of Object.values(SIZES)) { - const sizes = 'w' in group ? [group] : Object.values(group); - for (const s of sizes) { - expect(dim(s as never)).toMatch(/^\d+x\d+$/); - } - } - }); - - it('базовые слоты арт-библии на месте', () => { - for (const slot of ['32x16', '16x24', '20x20', '32x48', '32x56', '32x80', '64x80']) { - expect(SLOTS.has(slot), slot).toBe(true); - } - }); - - it('frameSize манифеста AI-атласов входит в слоты', () => { - const manifest = JSON.parse( - readFileSync(resolve(here, '../../aiart/atlas-manifest.json'), 'utf8') - ) as Record; - for (const [id, entry] of Object.entries(manifest)) { - const fs = entry.frameSize; - if (!fs) continue; - expect(SLOTS.has(`${fs[0]}x${fs[1]}`), id).toBe(true); - } - }); -}); \ No newline at end of file diff --git a/apps/game/tools/pixelart/gen.mjs b/apps/game/tools/pixelart/gen.mjs deleted file mode 100644 index ccc568a..0000000 --- a/apps/game/tools/pixelart/gen.mjs +++ /dev/null @@ -1,1667 +0,0 @@ -/** - * Генератор пиксель-арта «Пепельных лугов». - * Запуск: node tools/pixelart/gen.mjs - * Выход: apps/game/assets/tiles/*.png, apps/game/assets/chars/*.png - */ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { Canvas as EngineCanvas, fillDiamond, fromAscii as fromAsciiRaw, rng } from '@rpg/engine/tools/canvas.mjs'; -import { buildAtlas } from '@rpg/engine/tools/atlas.mjs'; -import { bobFrame } from '@rpg/engine/tools/frames.mjs'; -import { RGBA } from './palette.mjs'; -import { SIZES } from './sizes.mjs'; - -/** Канвас с палитрой игры (ключи цветов — из palette.mjs). */ -class Canvas extends EngineCanvas { - constructor(w, h) { super(w, h, RGBA); } -} - -/** ASCII-карта -> спрайт в палитре игры (extra — дополнительные символы). */ -const fromAscii = (rows, extra) => fromAsciiRaw(rows, RGBA, extra); - -const OUT = fileURLToPath(new URL('../../assets/', import.meta.url)); - -// ---------- тайлы 32x16 ---------- - -/** Ромб с зерном пепла/травы. Без обводки граней: смежные тайлы сливаются в поле. */ -function tileBase(base, light, dark, seed, specks) { - const c = new Canvas(SIZES.tile.w, SIZES.tile.h); - fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, base); - const r = rng(seed); - for (let i = 0; i < specks; i++) { - const x = Math.floor(r() * 32); - const y = Math.floor(r() * 16); - if (c.get(x, y)) { - c.set(x, y, r() < 0.5 ? dark : base); - } - } - return c; -} - -function grassTile(seed = 77) { - const c = tileBase('G1', 'G2', 'G0', 11, 26); - // Сухие стебли: 1-2 пикселя G2H. seed — вариант текстуры (микс по хэшу тайла). - const r = rng(seed); - for (let i = 0; i < 5; i++) { - const x = 3 + Math.floor(r() * 26); - const y = 3 + Math.floor(r() * 10); - if (c.get(x, y)) { - c.set(x, y, 'G2H'); - } - } - return c; -} - -function pathTile(seed = 31) { - const c = tileBase('P4', 'P5', 'P3', 23, 30); - // Утоптанный камешек и след колеи. seed — вариант текстуры. - const r = rng(seed); - for (let i = 0; i < 4; i++) { - const x = 2 + Math.floor(r() * 28); - const y = 2 + Math.floor(r() * 12); - if (c.get(x, y)) c.set(x, y, 'P2'); - } - return c; -} - -/** Вода: кадр 1 и кадр 2 (блики-штрихи W2 сдвинуты на 1px — живая рябь). */ -function waterTile(rippleShift = 0) { - const c = tileBase('W1', 'W2', 'W0', 31, 0); - // Рябь: короткие горизонтальные штрихи W2 и глубина W0. - const r = rng(5); - for (let i = 0; i < 5; i++) { - const x = 4 + Math.floor(r() * 22); - const y = 2 + Math.floor(r() * 12); - if (c.get(x + rippleShift, y)) c.set(x + rippleShift, y, 'W2'); - if (c.get(x + 1 + rippleShift, y)) c.set(x + 1 + rippleShift, y, 'W2'); - } - for (let i = 0; i < 3; i++) { - const x = 6 + Math.floor(r() * 20); - const y = 5 + Math.floor(r() * 9); - c.set(x, y, 'W0'); - c.set(x + 1, y, 'W0'); - } - return c; -} - -function ashTile(seed = 9, stems = 10) { - // Пепельный бугор: чуть светлее травы, тоже без обводки. - const c = new Canvas(SIZES.tile.w, SIZES.tile.h); - fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, 'P3'); - const r = rng(seed); - for (let i = 0; i < 6; i++) { - const x = 3 + Math.floor(r() * 26); - const y = 4 + Math.floor(r() * 9); - if (c.get(x, y)) c.set(x, y, 'P5'); - } - // Проглядывающая трава: стебли 2–3 px и пучки, жизнь заметно пробивается, - // но пепел остаётся пеплом (растительность ~треть площади ромба). - for (let i = 0; i < stems; i++) { - const x = 3 + Math.floor(r() * 26); - const y = 5 + Math.floor(r() * 8); - if (!c.get(x, y)) continue; - // Пучок: с шансом 1/3 из точки растут 2–3 стебля вразброс. - const extra = r() < 0.33 ? 2 : 1; - for (let j = 0; j < extra; j++) { - const sx = Math.min(28, Math.max(3, x + (extra > 1 ? Math.floor(r() * 5) - 2 : 0))); - const sy = Math.min(12, Math.max(3, y + (extra > 1 ? Math.floor(r() * 3) - 1 : 0))); - if (!c.get(sx, sy)) continue; - const tip = r() < 0.5 ? 'G2H' : 'G2'; // сухая верхушка - const h = 1 + Math.floor(r() * 2); // высота стебля 2–3 px - for (let k = 0; k <= h; k++) { - if (!c.get(sx, sy - k)) break; - c.set(sx, sy - k, k === h ? tip : k === 0 ? 'G0' : r() < 0.5 ? 'G1' : 'G2'); - } - } - } - return c; -} - -// ---------- декор 16x16 (прозрачное поле, рисунок в ромб 16x8) ---------- - -/** Микро-кустик сухой травы: подушка G2 с сухими стеблями G2H. */ -function decorTuft() { - const c = new Canvas(SIZES.decor.w, SIZES.decor.h); - fillDiamond(c, 8, 9, 14, 7, 'G2'); - fillDiamond(c, 8, 9, 10, 5, 'G1'); - const r = rng(151); - for (let i = 0; i < 6; i++) { - const x = 4 + Math.floor(r() * 9); - const y = 5 + Math.floor(r() * 6); - if (c.get(x, y)) c.set(x, y, 'G2H'); - } - // пара торчащих стеблей выше подушки - c.set(6, 4, 'G2H'); - c.set(10, 5, 'G2H'); - return c; -} - -/** Камешек: основание P3, верхняя грань P5. */ -function decorStone() { - const c = new Canvas(SIZES.decor.w, SIZES.decor.h); - fillDiamond(c, 8, 10, 8, 4, 'P3'); - // светлая грань — верхний склон - fillDiamond(c, 8, 9, 6, 2, 'P5'); - return c; -} - -/** Проплешина: тёмное пятно на траве (только тень, без рисунка сверху). */ -function decorBald() { - const c = new Canvas(SIZES.decor.w, SIZES.decor.h); - fillDiamond(c, 8, 9, 12, 6, 'G0'); - fillDiamond(c, 8, 9, 8, 4, 'G1'); - return c; -} - -function bellflowerTile() { - const c = grassTile(); - // Три цветка: золото 3px + хайлайт. Тёплый цвет — только жизнь. - const flowers = [ - [10, 7], - [21, 9], - [16, 5] - ]; - for (const [x, y] of flowers) { - c.set(x, y - 1, 'B2'); - c.set(x - 1, y, 'B1'); - c.set(x, y, 'B1'); - c.set(x + 1, y, 'B1'); - c.set(x, y + 1, 'G2H'); // стебель - } - return c; -} - -/** - * Дерево 32x48, три вида кроны + кадр качания. - * kind: 'wide' (три облака, кабель — Ржавая роща), 'sparse' (редкая высокая), - * 'young' (молодое с малой кроной). sway — сдвиг кроны по X (±1px, ветер): - * кадры одного вида отличаются только кроной, ствол неподвижен. - */ -function treeTile({ kind = 'wide', seed = 2026, cable = true, sway = 0 } = {}) { - const c = new Canvas(...SIZES.tall.tree); - const r = rng(seed); - - const ellipse = (cx, cy, rx, ry, color) => { - for (let y = -ry; y <= ry; y++) { - for (let x = -rx; x <= rx; x++) { - if ((x * x) / (rx * rx) + (y * y) / (ry * ry) <= 1) { - c.set(cx + x, cy + y, color); - } - } - } - }; - - // Крона: перекрывающиеся «облака» мёртвой листвы (сдвигаются ветром). - const clouds = - kind === 'wide' - ? [ - [16, 9, 10, 7], - [9, 14, 6, 5], - [23, 13, 7, 5] - ] - : kind === 'sparse' - ? [ - [16, 8, 8, 7], - [10, 13, 5, 4], - [22, 12, 5, 4] - ] - : [ - [16, 12, 7, 5], - [12, 16, 4, 3], - [21, 15, 4, 3] - ]; - for (const [cx, cy, rx, ry] of clouds) ellipse(cx + sway, cy, rx, ry, 'G0'); - // Внутренний тон. - for (const [cx, cy, rx, ry] of clouds) ellipse(cx + sway, cy + 1, rx - 3, ry - 2, 'G1'); - for (let i = 0; i < 26; i++) { - const x = 6 + Math.floor(r() * 20); - const y = 3 + Math.floor(r() * 12); - if (c.get(x + sway, y) && r() < 0.5) c.set(x + sway, y, 'G2'); - } - // Прорехи (мёртвое дерево): прозрачные проколы в кроне. - for (const [x, y] of kind === 'wide' - ? [ - [13, 6], - [19, 12], - [8, 13], - [22, 8] - ] - : kind === 'sparse' - ? [ - [15, 5], - [20, 10], - [12, 11], - [17, 8], - [9, 9] - ] - : [ - [14, 11], - [19, 13] - ]) { - c.set(x + sway, y, null); - c.set(x + 1 + sway, y, null); - } - - // Ствол: металл с ржавчиной внизу, лёгкий изгиб, корни в стороны. - for (let y = 18; y <= 47; y++) { - const bend = Math.round(Math.sin((y - 18) * 0.09) * 1.5); - const half = y > 40 ? 2 : 1; - for (let dx = -half; dx <= half; dx++) { - c.set(16 + bend + dx, y, y > 38 ? 'R1' : 'M1'); - } - c.set(16 + bend - half - 1, y, y > 38 ? 'R0' : 'M0'); - } - // Корни. - for (let i = 0; i < 4; i++) { - c.set(12 - i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); - c.set(20 + i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); - } - // Пепел на корнях. - c.set(13, 46, 'P5'); - c.set(19, 46, 'P5'); - - // Кабель через крону и вокруг ствола (только «роща»-вид). - if (cable) { - for (let x = 4; x <= 27; x++) { - const y = 16 - Math.round(Math.sin((x - 4) * 0.2) * 1); - c.set(x, y, 'M2'); - } - for (let y = 22; y <= 44; y += 7) { - c.set(14, y, 'M0'); - c.set(19, y, 'M0'); - } - } - - return c; -} - -/** Линия по Брезенхэму (для кромок и трещин). */ -function line(canvas, x0, y0, x1, y1, color) { - const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1); - for (let i = 0; i <= n; i++) { - canvas.set(Math.round(x0 + ((x1 - x0) * i) / n), Math.round(y0 + ((y1 - y0) * i) / n), color); - } -} - -/** Построчная заливка полигона: fn(y, x0, x1) рисует строку внутри контура. */ -function fillPolyRows(canvas, pts, fn) { - const ys = pts.map((p) => p[1]); - const y0 = Math.max(0, Math.ceil(Math.min(...ys))); - const y1 = Math.min(canvas.height - 1, Math.floor(Math.max(...ys))); - for (let y = y0; y <= y1; y++) { - const xs = []; - for (let i = 0; i < pts.length; i++) { - const [xa, ya] = pts[i]; - const [xb, yb] = pts[(i + 1) % pts.length]; - if ((ya <= y && yb > y) || (yb <= y && ya > y)) { - xs.push(xa + ((y - ya) / (yb - ya)) * (xb - xa)); - } - } - if (xs.length < 2) continue; - const lx = Math.max(0, Math.ceil(Math.min(...xs))); - const rx = Math.min(canvas.width - 1, Math.floor(Math.max(...xs))); - if (lx <= rx) fn(y, lx, rx); - } -} - -/** - * Объёмный изометрический дом: две видимые стены сходятся ближним углом - * к зрителю (юго-западная светлая, юго-восточная тёмная — свет сверху-слева), - * шатровая или двускатная крыша, шпальный фундамент. Геометрия — по - * изо-боксу плейсхолдеров движка: основание — ромб тайла, центр на 8 px - * выше низа канваса. kind: 'shack' (времянка, шатровая metal-крыша), - * 'cottage' (двускатная шпальная с трубой), 'shed' (низкий сарай, - * широкое окно), 'barn' (высокий амбар с воротами, 32x80), - * 'two' (двухэтажный, два ряда окон, 32x80). - */ -function isoHouseTile({ kind = 'cottage', seed = 777 } = {}) { - const tall = kind === 'barn' || kind === 'two'; - const c = new Canvas(...(tall ? SIZES.tall.houseTall : SIZES.tall.house)); - const r = rng(seed); - const H = c.height; - const by = H - 8; // центр основания-ромба (ближняя вершина у кромки) - const wh = { shack: 13, cottage: 17, shed: 11, barn: 28, two: 24 }[kind]; - const gh = { shack: 9, cottage: 7, shed: 4, barn: 11, two: 9 }[kind]; // высота крыши - const wt = by - wh; // верх стен по боковым вершинам ромба - const mat = kind === 'shack' || kind === 'two' ? 'metal' : 'ties'; - - const fillPoly = (pts, color) => - fillPolyRows(c, pts, (y, lx, rx) => { - for (let x = lx; x <= rx; x++) c.set(x, y, color); - }); - // Стена: доски base со швами seam каждые 4 px по экранному Y. - const wallFill = (pts, base, seam) => - fillPolyRows(c, pts, (y, lx, rx) => { - const seamRow = (y - (H - wh)) % 4 === 0; - for (let x = lx; x <= rx; x++) c.set(x, y, seamRow ? seam : r() < 0.85 ? base : seam); - }); - // Точка на стене: t — доля вдоль стены (0..1), h — высота от земли. - const wallPt = (side, t, h) => - side === 'sw' - ? [Math.round((15 * t)), Math.round(H - 8 + 8 * t - h)] - : [Math.round(16 + 15 * t), Math.round(H - 8 * t - h)]; - // Проём: рама M0, тёмный проём (дверь) или стекло P2 с переплётом. - const opening = (side, t0, t1, h0, hh, glass) => { - const quad = (a0, a1, b0, b1) => [wallPt(side, a0, b0), wallPt(side, a1, b0), wallPt(side, a1, b1), wallPt(side, a0, b1)]; - fillPoly(quad(t0 - 0.04, t1 + 0.04, h0 - 1, h0 + hh + 1), 'M0'); - fillPoly(quad(t0, t1, h0, h0 + hh), glass ? 'P2' : 'P1'); - if (glass) { - const hm = h0 + Math.floor(hh / 2); - fillPoly(quad(t0, t1, hm, hm + 1), 'M1'); // переплёт - } else { - c.set(...wallPt(side, t1 - 0.1, h0 + Math.floor(hh * 0.45)), 'B0'); // ручка - } - }; - // Труба на скате: двухпиксельная колонна с ржавым верхом. - const pipe = (px, py) => { - for (let d = 1; d <= 6; d++) { - c.set(px, py - d, 'M0'); - c.set(px + 1, py - d, 'M1'); - } - c.set(px, py - 7, 'R1'); - c.set(px + 1, py - 7, 'R1'); - }; - - // Стены: юго-запад (свет) и юго-восток (тень), сходятся ближним углом. - wallFill([[0, by], [16, H], [16, H - wh], [0, wt]], 'C1', 'C0'); - wallFill([[16, H], [31, by], [31, wt], [16, H - wh]], 'C0', 'M0'); - // Стойки по углам. - for (let y = wt; y < by; y++) { - c.set(0, y, 'M1'); - c.set(31, y, 'M1'); - } - for (let y = H - wh; y < H; y++) c.set(16, y, 'M1'); - - if (mat === 'metal') { - // Шатровая крыша из ржавого листа: два видимых ската от вершины. - const ay = wt - gh; - const face = (pts, base) => - fillPolyRows(c, pts, (y, lx, rx) => { - for (let x = lx; x <= rx; x++) { - c.set(x, y, y % 4 === 0 ? 'M0' : r() < 0.85 ? base : 'M0'); - } - }); - face([[0, wt], [16, H - wh], [16, ay]], 'M1'); - face([[16, H - wh], [31, wt], [16, ay]], 'M0'); - // Потёки ржавчины на теневом скате. - for (let i = 0; i < 5; i++) c.set(18 + Math.floor(r() * 11), ay + 2 + Math.floor(r() * (wh - 4)), 'R1'); - line(16, ay, 0, wt, 'M0'); - line(16, ay, 16, H - wh, 'M2'); // ближнее ребро ловит свет - line(16, ay, 31, wt, 'M0'); - } else { - // Двускатная: фронтон над юго-западной стеной, скат к юго-востоку. - const gx = 8; - const gy = H - wh - 4 - gh; // вершина фронтона - wallFill([[0, wt], [16, H - wh], [gx, gy]], 'C1', 'C0'); - fillPolyRows(c, [[gx, gy], [gx + 16, gy - 8], [31, wt], [16, H - wh]], (y, lx, rx) => { - for (let x = lx; x <= rx; x++) { - c.set(x, y, y % 4 === 0 ? 'T0' : r() < 0.85 ? 'T1' : 'T0'); - } - }); - line(c, gx, gy, 0, wt, 'T0'); // кромки фронтона - line(c, gx, gy, 16, H - wh, 'T0'); - line(c, 16, H - wh, 31, wt, 'T0'); // свес над теневой стеной - line(c, gx, gy, gx + 16, gy - 8, 'T1'); // конёк ловит свет - } - - // Шпальный фундамент вдоль основания обеих стен. - for (let i = -1; i <= 17; i++) { - const t = i / 16; - c.set(Math.round(15 * t), Math.round(H - 8 + 8 * t), i % 2 ? 'R0' : 'R1'); - c.set(Math.round(15 * t), Math.round(H - 8 + 8 * t) + 1, 'R0'); - c.set(16 + i, H - Math.round(8 * t), i % 2 ? 'R0' : 'R1'); - c.set(16 + i, H - Math.round(8 * t) + 1, 'R0'); - } - // Пепел у стен. - c.set(3, H - 3, 'P5'); - c.set(6, H - 2, 'P4'); - c.set(27, H - 4, 'P5'); - c.set(24, H - 2, 'P4'); - c.set(13, H - 1, 'P4'); - - if (kind === 'shack') { - opening('sw', 0.2, 0.55, 4, 4, true); - opening('se', 0.45, 0.8, 0, 8, false); - } else if (kind === 'cottage') { - opening('sw', 0.12, 0.42, 5, 5, true); - opening('se', 0.5, 0.85, 0, 10, false); - pipe(20, H - wh - 9); - } else if (kind === 'shed') { - opening('sw', 0.18, 0.75, 3, 5, true); // широкое окно во всю стену - opening('se', 0.75, 1.0, 0, 7, false); - } else if (kind === 'barn') { - opening('se', 0.35, 0.75, 0, 16, false); // ворота - fillPoly([wallPt('se', 0.38, 5), wallPt('se', 0.72, 5), wallPt('se', 0.72, 6), wallPt('se', 0.38, 6)], 'T0'); - fillPoly([wallPt('se', 0.38, 11), wallPt('se', 0.72, 11), wallPt('se', 0.72, 12), wallPt('se', 0.38, 12)], 'T0'); - opening('sw', 0.4, 0.6, wh - 7, 4, false); // слуховое окно сена - } else { - // Двухэтажный: два ряда окон на светлой стене, дверь на теневой. - opening('sw', 0.18, 0.42, 4, 5, true); - opening('sw', 0.58, 0.82, 4, 5, true); - opening('sw', 0.38, 0.62, wh - 8, 5, true); - opening('se', 0.5, 0.85, 0, 9, false); - pipe(18, wt - 2); - } - - return c; -} - -/** - * Валун 32x32: крупный камень в изо-объёме. Верхняя грань P4/P5, юго-западная - * светлая (P5 с бликами P5H), юго-восточная в тени (P2/P3), контур P0, - * трещины P1, пепел на верхней грани. Tall-объект ~1.05 юнита, непроходим. - */ -/** - * Валун, 3 вида: round — большой округлый (текущий), spike — высокий острый - * скол (32x40), flat — низкий плоский камень (32x24). Общий приём: тень- - * подошва накрывает ground-ромб, рисунок до пола слота — камень «стоит». - */ -function boulderTile({ kind = 'round', seed = 555 } = {}) { - const c = new Canvas(...SIZES.tall[kind === 'round' ? 'boulder' : kind === 'spike' ? 'boulder_2' : 'boulder_3']); - const r = rng(seed); - const h = c.height; - const ellipse = (cx, cy, rx2, ry2, color) => { - for (let y = -ry2; y <= ry2; y++) { - for (let x = -rx2; x <= rx2; x++) { - if ((x * x) / (rx2 * rx2) + (y * y) / (ry2 * ry2) <= 1) c.set(cx + x, cy + y, color); - } - } - }; - - // Тень-подошва: накрывает центр ground-ромба. - ellipse(16, h - 4, 14, 4, 'P1'); - for (let i = 0; i < 10; i++) { - const x = 4 + Math.floor(r() * 25); - const y = h - 7 + Math.floor(r() * 6); - if (c.get(x, y) && r() < 0.5) c.set(x, y, 'P2'); - } - - // Грани: юго-запад (свет), юго-восток (тень), верхняя грань. - const sw = kind === 'spike' - ? [[13, 4], [16, 3], [18, 6], [10, h - 2], [4, h - 8]] - : kind === 'flat' - ? [[4, 10], [20, 8], [28, 12], [26, h - 2], [8, h - 2]] - : [[4, 18], [8, 23], [12, h - 2], [2, h - 7]]; - const se = kind === 'spike' - ? [[16, 3], [18, 6], [26, h - 9], [24, h - 2], [10, h - 2]] - : kind === 'flat' - ? [[20, 8], [28, 12], [26, h - 2], [18, h - 3]] - : [[8, 23], [20, 24], [28, 17], [28, h - 9], [18, h - 2], [12, h - 2]]; - const top = kind === 'spike' - ? [[13, 4], [16, 3], [18, 6], [16, 10]] - : kind === 'flat' - ? [[4, 10], [20, 8], [28, 12], [24, 16], [10, 15]] - : [[4, 18], [10, 10], [22, 9], [28, 17], [20, 24], [8, 23]]; - fillPolyRows(c, sw, (y, lx, rx) => { - for (let x = lx; x <= rx; x++) c.set(x, y, r() < 0.8 ? 'P4' : 'P5'); - }); - fillPolyRows(c, se, (y, lx, rx) => { - for (let x = lx; x <= rx; x++) c.set(x, y, r() < 0.7 ? 'P3' : 'P2'); - }); - fillPolyRows(c, top, (y, lx, rx) => { - for (let x = lx; x <= rx; x++) c.set(x, y, r() < 0.75 ? 'P5' : 'P4'); - }); - - // Блик на верхне-левом склоне и пепел на верхней грани. - const peakY = kind === 'spike' ? 6 : kind === 'flat' ? 12 : 11; - c.set(13, peakY, 'P5H'); - c.set(14, peakY - 1, 'P5H'); - for (let i = 0; i < 7; i++) { - const x = 10 + Math.floor(r() * 13); - const y = peakY + 2 + Math.floor(r() * 6); - if (c.get(x, y)) c.set(x, y, r() < 0.5 ? 'P6' : 'P4'); - } - // Трещины: тёмные ломаные по верхней и теневой граням. - line(c, 17, peakY, 21, peakY + 5, 'P1'); - line(c, 21, peakY + 5, 19, peakY + 10, 'P1'); - if (kind !== 'flat') line(c, 9, h - 9, 13, h - 4, 'P1'); - - // Контур по нижнему силуэту и рёбрам граней. - if (kind === 'spike') { - line(c, 4, h - 8, 10, h - 2, 'P0'); - line(c, 10, h - 2, 24, h - 2, 'P0'); - line(c, 24, h - 2, 26, h - 9, 'P0'); - line(c, 13, 4, 4, h - 8, 'P0'); - line(c, 16, 3, 13, 4, 'P0'); - line(c, 16, 3, 18, 6, 'P0'); - line(c, 18, 6, 26, h - 9, 'P0'); - } else if (kind === 'flat') { - line(c, 4, 10, 20, 8, 'P0'); - line(c, 20, 8, 28, 12, 'P0'); - line(c, 28, 12, 26, h - 2, 'P0'); - line(c, 26, h - 2, 8, h - 2, 'P0'); - line(c, 8, h - 2, 4, 10, 'P0'); - } else { - line(c, 2, h - 7, 12, h - 2, 'P0'); - line(c, 12, h - 2, 18, h - 2, 'P0'); - line(c, 18, h - 2, 28, h - 9, 'P0'); - line(c, 4, 18, 8, 23, 'P0'); - line(c, 8, 23, 20, 24, 'P0'); - line(c, 20, 24, 28, 17, 'P0'); - } - return c; -} - -/** - * Большой мёртвый куст, 3 вида: cloud — облако на «ноге» (32x40, текущий), - * spread — низкий раскидистый (32x32), tall — высокий узкий конус (32x48). - * Общий приём: тень-подошва + тёмная нога ветвей от земли — куст «стоит». - */ -function bushTile({ kind = 'cloud', seed = 656 } = {}) { - const c = new Canvas(...SIZES.tall[kind === 'cloud' ? 'bush' : kind === 'spread' ? 'bush_2' : 'bush_3']); - const r = rng(seed); - const h = c.height; - const ellipse = (cx, cy, rx, ry, color) => { - for (let y = -ry; y <= ry; y++) { - for (let x = -rx; x <= rx; x++) { - if ((x * x) / (rx * rx) + (y * y) / (ry * ry) <= 1) c.set(cx + x, cy + y, color); - } - } - }; - - // Пропорции вида: центр кроны, её эллипсы (центр + бока), нога. - const crown = kind === 'spread' - ? { c: [16, 15, 12, 6], sides: [[7, 19, 7, 4], [25, 14, 7, 4]], inner: [16, 14, 9, 4], legTop: 21, legBottom: h - 3 } - : kind === 'tall' - ? { c: [16, 11, 8, 9], sides: [[10, 21, 5, 5], [22, 20, 5, 5]], inner: [16, 10, 5, 6], legTop: 24, legBottom: h - 3 } - : { c: [16, 18, 11, 8], sides: [[8, 23, 6, 5], [24, 22, 6, 5]], inner: [16, 17, 8, 5], legTop: 27, legBottom: h - 3 }; - - // Тень-подошва: накрывает центр ground-ромба — куст «стоит», а не лежит. - ellipse(16, h - 5, 12, 4, 'P1'); - for (let i = 0; i < 8; i++) { - const x = 5 + Math.floor(r() * 23); - const y = h - 8 + Math.floor(r() * 6); - if (c.get(x, y) && r() < 0.5) c.set(x, y, 'P2'); - } - - // Нога ветвей: сужается от земли к кроне. - for (let y = crown.legTop; y <= crown.legBottom; y++) { - const half = Math.max(1, Math.round((crown.legBottom - y) * 0.36)); - for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, r() < 0.75 ? 'G0' : 'G1'); - } - // Крона-облако: центр + боковые пучки, внутренний тон. - ellipse(crown.c[0], crown.c[1], crown.c[2], crown.c[3], 'G0'); - for (const [cx, cy, rx, ry] of crown.sides) ellipse(cx, cy, rx, ry, 'G0'); - ellipse(crown.inner[0], crown.inner[1], crown.inner[2], crown.inner[3], 'G1'); - for (let i = 0; i < 22; i++) { - const x = 6 + Math.floor(r() * 20); - const y = crown.c[1] - crown.c[3] + Math.floor(r() * (crown.c[3] * 2 + 6)); - if (c.get(x, y) && r() < 0.5) c.set(x, y, 'G2'); - } - // Прорехи (сухой куст) и стебли наружу. - const topY = crown.c[1] - crown.c[3] + 3; - for (const [x, y] of [[12, topY + 4], [21, topY + 8], [9, topY + 11]]) { - c.set(x, y, null); - c.set(x + 1, y, null); - } - for (const [x, y] of [[7, topY + 3], [13, topY - 2], [19, topY - 3], [25, topY + 3]]) { - c.set(x, y, 'G2H'); - c.set(x, y + 1, 'G2'); - } - // Пепел у корней по краям тени. - c.set(5, h - 3, 'P5'); - c.set(27, h - 3, 'P5'); - return c; -} - -/** - * Большой дом 64x80 (проба работы C): двухмодульный дом на footprint 2×2, - * композит из повторяемых частей — стены-щиты + стойки, крыша из шпал, - * фундамент из рельсов. Якорь — низ-центр footprint'а (ставит движок, prop). - */ -function bigHouseTile() { - const c = new Canvas(...SIZES.prop.houseBig); - const r = rng(4242); - const planks = (x0, x1, y0, y1) => { - for (let y = y0; y <= y1; y++) { - const seam = (y - y0) % 6 === 0; - for (let x = x0; x <= x1; x++) c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0'); - } - }; - const windowAt = (x0, y0) => { - for (let y = y0; y <= y0 + 7; y++) for (let x = x0; x <= x0 + 6; x++) c.set(x, y, 'P2'); - for (let x = x0 - 1; x <= x0 + 7; x++) { c.set(x, y0 - 1, 'M0'); c.set(x, y0 + 8, 'M0'); } - for (let y = y0 - 1; y <= y0 + 8; y++) { c.set(x0 - 1, y, 'M0'); c.set(x0 + 7, y, 'M0'); } - for (let x = x0; x <= x0 + 6; x++) c.set(x, y0 + 3, 'M1'); // переплёт - c.set(x0 + 3, y0, 'P5'); // пепел на раме - }; - - // Стены: два модуля щитов, разделены и обрамлены стойками. - planks(5, 58, 31, 74); - for (const x of [5, 31, 32, 58]) for (let y = 30; y <= 74; y++) c.set(x, y, 'M1'); - for (let x = 4; x <= 59; x++) c.set(x, 30, 'M0'); // верхний венец - - // Крыша: треугольник шпал, расширяется книзу, свес по краям. - for (let i = 0; i <= 27; i++) { - const y = 2 + i; - const half = Math.min(28, 2 + Math.floor(i * 0.95)); - for (let dx = -half; dx <= half; dx++) c.set(32 + dx, y, i % 4 === 0 ? 'T0' : 'T1'); - } - for (let x = 2; x <= 61; x++) c.set(x, 29, 'T0'); - - // Дверь правого модуля + козырёк. - for (let y = 56; y <= 74; y++) for (let x = 44; x <= 52; x++) c.set(x, y, 'P1'); - c.set(50, 65, 'B0'); - for (let i = 0; i <= 5; i++) for (let dx = -i; dx <= i + 7; dx++) c.set(48 + dx, 50 + i, i === 5 ? 'T0' : 'T1'); - - // Окна: по одному на модуль. - windowAt(10, 38); - windowAt(37, 38); - - // Фундамент из рельсов, пепел у стен. - for (let x = 3; x <= 60; x++) { c.set(x, 75, 'R1'); c.set(x, 76, 'R0'); } - c.set(2, 77, 'P5'); c.set(61, 77, 'P5'); c.set(8, 78, 'P4'); c.set(55, 78, 'P4'); - - return c; -} - -/** Башня Звенца 32x80: водонапорная башня с колоколом из рельсов. Tall-объект 2.5 юнита. */ -/** Пол интерьера: доски вагонных платформ вдоль изометрии, швы темнее. */ -function floorTile() { - const c = new Canvas(SIZES.tile.w, SIZES.tile.h); - fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, 'T1'); - const r = rng(808); - for (let y = 0; y < 16; y++) { - for (let x = 0; x < 32; x++) { - if (!c.get(x, y)) continue; - // Доски «полосами» по экранному Y + случайная щепка. - const seam = y % 5 === 0; - c.set(x, y, seam ? 'T0' : r() < 0.12 ? 'T0' : 'T1'); - } - } - return c; -} - -/** Каменная стена вагонного цеха: блоки C1 со швами C0, тень по низу. */ -function wallTile() { - const c = new Canvas(...SIZES.tall.wall); - const r = rng(909); - for (let y = 0; y < 32; y++) { - for (let x = 0; x < 32; x++) { - const seam = y % 8 === 0 || (x + Math.floor(y / 8) * 8) % 16 === 0; - c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0'); - } - } - // Нижний ряд темнее (тень у земли). - for (let x = 0; x < 32; x++) { - c.set(x, 30, 'C0'); - c.set(x, 31, 'C0'); - } - return c; -} - -/** Старый телеграфный колодец: каменное жерло, бронзовый обруч — звон = жизнь. */ -function wellTile() { - const c = new Canvas(...SIZES.tall.well); - const r = rng(1010); - - // Тёмное жерло (вода) — верхняя часть, видна внутри кольца. - for (let y = 20; y <= 26; y++) { - const half = Math.floor((26 - y) * 1.8) + 2; - for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'W0'); - } - // Каменная кладка вокруг жерла (эллипс). - for (let y = 16; y <= 28; y++) { - const half = Math.floor((24 - y) * 1.35) + 3; - for (let dx = -half; dx <= half; dx++) { - const rim = Math.abs(dx) > half - 2; - c.set(16 + dx, y, rim ? (r() < 0.8 ? 'C1' : 'C0') : null === c.get(16 + dx, y) ? 'C0' : c.get(16 + dx, y)); - } - } - // Бронзовый обруч по кромке — тёплый: колодец «звонный». - for (let x = 6; x <= 25; x++) { - c.set(x, 15, x % 4 === 0 ? 'B0' : 'B1'); - c.set(x, 16, 'B0'); - } - // Ворото с бронзовой рукоятью. - for (let y = 4; y <= 15; y++) { - c.set(7, y, 'T1'); - c.set(24, y, 'T1'); - } - for (let x = 7; x <= 24; x++) c.set(x, 5, 'T1'); - c.set(12, 4, 'B1'); - c.set(13, 4, 'B2'); // блик бронзы - return c; -} - -/** Сундук: вощёное дерево с бронзовой кромкой и замком — «звонное» железо. */ -function chestTile() { - const c = new Canvas(...SIZES.prop.chest); - const r = rng(1020); - - // Крышка: слегка выпуклая, доски из тёплого дерева. - for (let x = 4; x <= 19; x++) { - const lift = x >= 7 && x <= 16 ? 1 : 0; - for (let y = 6 - lift; y <= 9; y++) c.set(x, y, r() < 0.85 ? 'T1' : 'T0'); - } - // Корпус: темнее крышки. - for (let y = 11; y <= 15; y++) { - for (let x = 4; x <= 19; x++) c.set(x, y, r() < 0.85 ? 'T0' : 'T1'); - } - // Бронзовая полоса по стыку крышки и корпуса (как на ящике-плейсхолдере). - for (let x = 4; x <= 19; x++) c.set(x, 10, x % 5 === 0 ? 'B0' : 'B1'); - // Замок: вертикальная накладка с бликом. - for (let y = 10; y <= 15; y++) { - c.set(11, y, 'B1'); - c.set(12, y, 'B0'); - } - c.set(11, 12, 'B2'); - // Тень под днищем. - for (let x = 5; x <= 19; x++) c.set(x, 16, 'P1'); - return c; -} - -function towerTile() { - const c = new Canvas(...SIZES.tall.tower); - const r = rng(1414); - - // Бак: цилиндр из полос металла. - for (let y = 12; y <= 40; y++) { - for (let x = 7; x <= 24; x++) { - c.set(x, y, r() < 0.8 ? 'M1' : 'M0'); - } - // Боковые тени. - c.set(7, y, 'M0'); - c.set(8, y, 'M0'); - c.set(24, y, 'M0'); - } - // Пояса обруча. - for (const y of [14, 22, 30, 38]) { - for (let x = 7; x <= 24; x++) c.set(x, y, 'M2'); - } - // Коническая крыша бака. - for (let i = 0; i <= 6; i++) { - const half = 9 - i; - for (let dx = -half; dx <= half; dx++) c.set(16 + dx, 11 - i, 'M2'); - } - // Флюгер-шпиль. - for (let y = 2; y <= 4; y++) c.set(16, y, 'M2'); - c.set(18, 2, 'B1'); // бронзовый флажок — колокол помнит голос - - // Ноги-рельсы. - for (const [lx, rx] of [ - [10, 12], - [19, 21] - ]) { - for (let y = 41; y <= 77; y++) { - c.set(lx, y, 'M2'); - c.set(lx + 1, y, 'M0'); - c.set(rx, y, 'M2'); - c.set(rx - 1, y, 'M0'); - } - } - // Раскосы между ногами. - for (let i = 0; i < 4; i++) { - const y = 46 + i * 8; - for (let dx = 0; dx < 8; dx++) { - c.set(12 + dx, y + dx, i % 2 === 0 ? 'M1' : 'M2'); - c.set(19 - dx, y + dx, i % 2 === 0 ? 'M1' : 'M2'); - } - } - - // Колокол из рельсов под баком, между ног. - for (let y = 44; y <= 54; y++) { - const half = y < 50 ? 2 : 3; - for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'B0'); - } - c.set(16, 45, 'B2'); // блик - c.set(15, 48, 'B1'); - c.set(16, 55, 'M0'); // язык - - // Пепел у ног. - c.set(8, 79, 'P5'); - c.set(23, 79, 'P5'); - - return c; -} - -// ---------- тайлы акта 2: роща и разъезд ---------- - -/** Бухта кабеля: ржавые кольца труб/кабелей, лежат грудой (низкий tall-объект). */ -function wireTile() { - const c = new Canvas(...SIZES.tall.wire); - const r = rng(1515); - - // Груда: три кольца-сечения, нижнее шире (перспективный вал). - const rings = [ - { y: 24, half: 8, h: 5 }, - { y: 18, half: 6, h: 4 }, - { y: 13, half: 4, h: 4 } - ]; - for (const { y, half, h } of rings) { - for (let dy = 0; dy < h; dy++) { - for (let dx = -half; dx <= half; dx++) { - // Пустота внутри эллипса кольца (сечение трубы). - const inside = Math.abs(dx) < half - 2 && dy > 0 && dy < h - 1; - if (inside) continue; - // Ржавчина: внешняя дуга рыжая, тень снизу. - const rust = dy === 0 || dx === -half; - c.set(16 + dx, y + dy, rust ? (r() < 0.6 ? 'R2' : 'R1') : r() < 0.7 ? 'M1' : 'M0'); - } - } - // Сердцевина сечения — темный провал. - c.set(16, y + 1, 'P0'); - c.set(15, y + 2, 'P1'); - c.set(17, y + 2, 'P1'); - // Блик по верху кольца. - c.set(16 - half + 1, y, 'M2'); - c.set(16 + half - 1, y, 'M2'); - } - // Одиночный хвост кабеля в сторону. - for (let dx = 0; dx < 6; dx++) c.set(23 + dx, 28 + (dx % 2), dx < 4 ? 'M1' : 'M0'); - // Пепел у подножия. - c.set(6, 30, 'P5'); - c.set(26, 30, 'P5'); - return c; -} - -/** Рельсы: две нити со шпалами вдоль оси Y (экранная вертикаль ромба). */ -function railTile() { - const c = tileBase('P2', 'P3', 'P1', 19, 14); - const inDiamond = (x, y) => Math.abs(x - 15.5) / 16 + Math.abs(y - 7.5) / 8 <= 1; - // Шпалы: горизонтальные доски T0/T1 через равные промежутки. - for (const y of [3, 6, 9, 12]) { - for (let x = 2; x <= 29; x++) { - if (inDiamond(x, y)) c.set(x, y, x % 5 === 0 ? 'T0' : 'T1'); - } - } - // Нити рельсов: вертикальные полосы ржавого металла с бликом. - for (const [x1, x2] of [[11, 12], [19, 20]]) { - for (let y = 0; y <= 15; y++) { - for (const x of [x1, x2]) { - if (inDiamond(x, y)) c.set(x, y, x === x1 ? 'R1' : 'R2'); - } - } - } - // Блик верха рельса. - for (const x of [11, 19]) { - for (let y = 2; y <= 13; y++) if (inDiamond(x, y) && y % 4 === 1) c.set(x, y, 'R2'); - } - return c; -} - -/** Люк-ступени вниз: тёмный провал с тремя ступенями (ходовой тайл). */ -function stairsTile() { - const c = tileBase('P3', 'P4', 'P2', 23, 10); - // Проём: тёмный ромб в центре, уходящий вниз. - for (let y = 2; y <= 13; y++) { - const half = Math.floor((y - 2) * 0.8) + 1; - for (let dx = -half; dx <= half; dx++) { - const x = 15 + dx; - if (Math.abs(x - 15.5) / 16 + Math.abs(y - 7.5) / 8 <= 0.75) c.set(x, y, 'P0'); - } - } - // Ступени: горизонтальные полосы, светлеют кверху. - for (const [y, col] of [[11, 'P1'], [8, 'P2'], [5, 'P3']]) { - for (let x = 9; x <= 22; x++) { - if (Math.abs(x - 15.5) / 16 + Math.abs(y - 7.5) / 8 <= 0.7) c.set(x, y, col); - } - } - // Кромка люка — металл с ржавыми пятнами. - for (let y = 1; y <= 14; y++) { - for (let x = 4; x <= 27; x++) { - const d = Math.abs(x - 15.5) / 16 + Math.abs(y - 7.5) / 8; - if (d <= 1.0 && d > 0.78 && (x + y) % 3 === 0) c.set(x, y, 'R1'); - } - } - return c; -} - -/** - * Дерево-механизм Ржавой рощи: мёртвый металлический ствол в чёрных кабелях - * с медными колокольчиками (квест резонатора, interactables rust_grove). - */ -function toneTreeTile() { - const c = new Canvas(...SIZES.tall.tree); - - // Ствол: металл с изгибом, светлее с одной стороны, ржавчина у корней. - for (let y = 12; y <= 47; y++) { - const bend = Math.round(Math.sin((y - 12) * 0.11) * 2); - const half = y > 42 ? 3 : y > 18 ? 2 : 1; - for (let dx = -half; dx <= half; dx++) { - c.set(16 + bend + dx, y, y > 40 ? 'R1' : dx === half ? 'M2' : 'M1'); - } - c.set(16 + bend - half - 1, y, y > 40 ? 'R0' : 'M0'); - } - // Обрубки ветвей: слепые отростки в стороны. - for (const [x0, y0, x1, y1] of [ - [14, 14, 8, 10], - [19, 12, 25, 9], - [13, 20, 9, 18] - ]) { - line(c, x0, y0, x1, y1, 'M1'); - c.set(x1, y1, 'M2'); - } - - // Кабели: чёрные струны вдоль ствола + косые обмотки кольцами. - line(c, 12, 14, 15, 46, 'P0'); - line(c, 21, 13, 18, 46, 'P0'); - for (let y = 16; y <= 42; y += 5) line(c, 12, y, 21, y + 2, 'P0'); - - // Медные колокольчики на кабельных петлях (тепло — бронза, «жизнь»). - for (const [bx, by] of [ - [10, 12], - [24, 11], - [11, 22] - ]) { - c.set(bx, by - 1, 'P0'); // подвес - c.set(bx, by, 'B0'); - c.set(bx + 1, by, 'B1'); - c.set(bx, by + 1, 'B0'); - c.set(bx + 1, by + 1, 'B0'); - } - - // Корни в стороны и пепел у основания. - for (let i = 0; i < 4; i++) { - c.set(11 - i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); - c.set(22 + i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); - } - c.set(13, 47, 'P5'); - c.set(20, 47, 'P5'); - return c; -} - -/** - * Резонатор Клинта: медный колокол на ржавой стальной раме, с кабелем к чаше - * (interactables rust_grove, kind lever). - */ -function resonatorTile() { - const c = new Canvas(32, 40); - - // Ноги рамы: ржавый металл, расширяются к подошве. - for (let y = 18; y <= 37; y++) { - const w = y > 34 ? 3 : 2; - for (let dx = -w; dx <= w; dx++) { - c.set(8 + dx, y, dx === w ? 'R2' : 'R1'); - c.set(23 + dx, y, dx === -w ? 'R2' : 'R1'); - } - } - // Подошвы. - for (let x = 5; x <= 11; x++) c.set(x, 38, 'R0'); - for (let x = 20; x <= 26; x++) c.set(x, 38, 'R0'); - - // Верхняя балка и раскосы. - for (let x = 6; x <= 25; x++) { - c.set(x, 14, 'M2'); - c.set(x, 15, 'M1'); - } - line(c, 9, 16, 13, 24, 'M1'); - line(c, 22, 16, 18, 24, 'M1'); - - // Колокол: медный купол под балкой (тепло — бронза). - for (let y = 0; y < 9; y++) { - const half = Math.round(Math.sqrt(((y + 1) / 10) * 36)); - for (let dx = -half; dx <= half; dx++) { - c.set(15 + dx, 16 + y, dx === -half ? 'B1' : 'B0'); - } - } - // Кромка и язых. - for (let x = 12; x <= 18; x++) c.set(x, 25, 'B1'); - c.set(15, 27, 'M0'); - c.set(15, 28, 'M0'); - - // Кабель от языха к резонансной чаше на раме. - line(c, 15, 29, 15, 31, 'P0'); - for (let x = 11; x <= 20; x++) c.set(x, 32, 'M2'); - for (let x = 12; x <= 19; x++) c.set(x, 33, 'M1'); - // Блик на чаше. - c.set(13, 32, 'P5H'); - return c; -} - -/** Старая Машина: уснувшая печь-автоматон (высокий тайл, слот башни). */ -function machineTile() { - const c = new Canvas(...SIZES.tall.machine); - const r = rng(1616); - - // Корпус: массивный железный шкаф с боковыми тенями. - for (let y = 10; y <= 70; y++) { - for (let x = 6; x <= 25; x++) { - c.set(x, y, r() < 0.75 ? 'M1' : 'M0'); - } - c.set(6, y, 'M0'); - c.set(7, y, 'M0'); - c.set(24, y, 'M0'); - c.set(25, y, 'M0'); - // Потёки ржавчины: редкие вертикальные штрихи. - if (y % 7 === 3) { - c.set(9 + (y % 11), y, 'R1'); - c.set(21 - (y % 9), y, 'R2'); - } - } - // Сборные пояса и заклёпки. - for (const y of [14, 28, 42, 56]) { - for (let x = 6; x <= 25; x++) c.set(x, y, 'M2'); - } - for (const y of [18, 32, 46, 60]) { - for (const x of [8, 13, 18, 23]) c.set(x, y, 'P5'); - } - // Труба сверху с обручем. - for (let y = 2; y <= 9; y++) { - for (let x = 12; x <= 19; x++) c.set(x, y, r() < 0.8 ? 'M1' : 'M0'); - c.set(12, y, 'M0'); - c.set(19, y, 'M0'); - } - for (let x = 12; x <= 19; x++) c.set(x, 4, 'M2'); - - // Устье печи: тёмная арка, внутри — тлеющие угли (огонь = жизнь). - for (let y = 58; y <= 69; y++) { - const half = y < 62 ? 7 - (62 - y) : 7; - for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'P0'); - } - for (const [x, y, col] of [ - [13, 66, 'B0'], - [16, 67, 'F0'], - [18, 65, 'B0'], - [15, 64, 'F0'], - [20, 67, 'B0'] - ]) { - c.set(x, y, col); - } - // Обрамление арки: бронзовые заклёпки по углам — «звонное» железо. - c.set(9, 58, 'B0'); - c.set(22, 58, 'B0'); - // Ноги-опоры. - for (const [lx, rx] of [[7, 24]]) { - for (let y = 71; y <= 78; y++) { - c.set(lx, y, 'M0'); - c.set(rx, y, 'M0'); - } - } - // Пепел у подножия. - c.set(5, 79, 'P5'); - c.set(26, 79, 'P5'); - return c; -} - -// ---------- персонажи 16x24 ---------- - -// Общие цвета: o — контур/глаза (P0), c/C — плащ тень/свет, -// t/T — шляпа тень/свет, s/S — кожа тень/свет, b/B — бронза, p — сапоги (P1). -const HERO_MAP = { o: 'P0', c: 'C0', C: 'C1', t: 'T0', T: 'T1', s: 'S0', S: 'S1', b: 'B0', B: 'B1', p: 'P1', P: 'P2' }; - -const heroDown1 = fromAscii([ - '................', - '.....oooooo.....', - '....oTTTTTTo....', - '...oTttttttTo...', - '..oTttttttttTo..', - '..oooooooooooo..', - '....oSSSSSSo....', - '....oSoSSoSo....', - '....oSSSsSSo....', - '.....oSSSSo.....', - '....oCCCCCCo....', - '...oCCCCCCCCo...', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '..obbBBBBBBbbo..', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '...oCCo..oCCo...', - '....oPo..oPo....', - '....oPo..oPo....', - '...oPPo..oPPo...', - '...oPPo..oPPo...', - '....oo....oo....', - '................' -], HERO_MAP); - -// Второй кадр ходьбы: ноги сошлись (passing), туловище на пиксель ниже — боб походки. -const heroDown2 = fromAscii([ - '................', - '................', - '.....oooooo.....', - '....oTTTTTTo....', - '...oTttttttTo...', - '..oTttttttttTo..', - '..oooooooooooo..', - '....oSSSSSSo....', - '....oSoSSoSo....', - '....oSSSsSSo....', - '.....oSSSSo.....', - '....oCCCCCCo....', - '...oCCCCCCCCo...', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '..obbBBBBBBbbo..', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '....oCCoCCo.....', - '....oPo.oPo.....', - '....oPo.oPo.....', - '....oPPoPPo.....', - '.....oPPPo......', - '......ooo.......' -], HERO_MAP); - -// Спина: швы капюшона под шляпой, лампа на поясе (единственный тёплый пиксель). -const heroUpRows = [ - '................', - '.....oooooo.....', - '....oTTTTTTo....', - '...oTttttttTo...', - '..oTttttttttTo..', - '..oooooooooooo..', - '.....oTttTo.....', - '....oCCCCCCo....', - '...oCCCCCCCCo...', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '..oCcCCCCCCcCo..', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '..obbBBffBBbbo..', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '...oCCo..oCCo...', - '....oPo..oPo....', - '....oPo..oPo....', - '...oPPo..oPPo...', - '...oPPo..oPPo...', - '....oo....oo....', - '................' -]; - -const heroUp1 = fromAscii(heroUpRows, { ...HERO_MAP, f: 'F0' }); - -// Второй кадр спины: туловище на пиксель ниже — боб походки. -const heroUp2 = fromAscii(['................', ...heroUpRows.slice(0, -1)], { ...HERO_MAP, f: 'F0' }); - -const heroSide1 = fromAscii([ - '................', - '.....oooooo.....', - '....oTTTTTTo....', - '...oTttttttTo...', - '..oTttttttttTo..', - '..oooooooooooo..', - '.....oSSSSSSo...', - '.....oSSoSSo....', - '.....oSSSSSs....', - '......oSSSSo....', - '.....oCCCCCo....', - '....oCCCCCCCo...', - '...oCCCCCCCCCo..', - '...oCCCCCCCCCo..', - '...obbBBBBBBbo..', - '...oCCCCCCCCCo..', - '...oCCCCCCCCCo..', - '....oCCoCCo.....', - '....oPo.oPo.....', - '....oPo.oPo.....', - '...oPPo.oPPo....', - '...oPPo.oPPo....', - '....oo...oo.....', - '................' -], HERO_MAP); - -// Шаг в бок: ноги шире (stride), туловище на пиксель ниже — боб походки. -const heroSide2 = fromAscii([ - '................', - '................', - '.....oooooo.....', - '....oTTTTTTo....', - '...oTttttttTo...', - '..oTttttttttTo..', - '..oooooooooooo..', - '.....oSSSSSSo...', - '.....oSSoSSo....', - '.....oSSSSSs....', - '......oSSSSo....', - '.....oCCCCCo....', - '....oCCCCCCCo...', - '...oCCCCCCCCCo..', - '...oCCCCCCCCCo..', - '...obbBBBBBBbo..', - '...oCCCCCCCCCo..', - '...oCCCCCCCCCo..', - '....oCCoCCo.....', - '...oPo...oPo....', - '...oPo...oPo....', - '..oPPo...oPPo...', - '..oPPo...oPPo...', - '...oo.....oo....' -], HERO_MAP); - -const elderIrwin = fromAscii([ - '................', - '.....oooooo.....', - '....oTTTTTTo....', - '...oTttttttTo...', - '...otttttttto...', - '....ooSSSSoo....', - '....oSoPSoSo....', - '....oSsSsSso....', - '.....oSSSSo.....', - '.....oTTTTo.....', - '....oTssssTo....', - '...oTTsssSTo....', - '...oTtTssTtTo...', - '...oTttTTttTo...', - '...oTttttttTo...', - '...oTttttttTo...', - '...oTttttttTo...', - '...oTttttttTo...', - '....oTtTTtTo....', - '....oTtttTo.....', - '....oTttttTo....', - '....oTttttTo....', - '....oTttttTo....', - '....oooooooo....' -], HERO_MAP); - -const traderMila = fromAscii([ - '................', - '.....oooooo.....', - '....oWWWWWWo....', - '...oWwWWWwWo....', - '...oWWWWWWWo....', - '....oWSSSSo.....', - '....oSoPSoSo....', - '....oSsSsSso....', - '.....oSSSSo.....', - '.....oWWWWo.....', - '....oWwWWwWo....', - '...oWWWWWWWWo...', - '...oWWWWWWWWo...', - '...oWWWBWWWWo...', - '...oWWWWWWWWo...', - '....oWWWWWWo....', - '....oWooWoo.....', - '....oPo..oPo....', - '....oPo..oPo....', - '...oPPo..oPPo...', - '...oPPo..oPPo...', - '....oo....oo....', - '................', - '................' -], { o: 'P0', W: 'W3', w: 'S0', S: 'S1', s: 'S0', P: 'P0', B: 'B1' }); - -// Клинт, кузнец-починщик (акт 2): кожаный фартук T0/T1, ржавые пятна R1, -// руки мастера S0/S1 — ворчун с молотком. -const clint = fromAscii([ - '................', - '.....oooooo.....', - '....oPPPPPPo....', - '...oPpPPPpPo....', - '...oPPPPPPPo....', - '....oSSSSSSo....', - '....oSoSSoSo....', - '....oSsSsSso....', - '.....oSSSSo.....', - '.....oTTTTo.....', - '....oTtTTtTo....', - '...oTTTTTTTTo...', - '...oTtTTTTtTo...', - '...oTTTtTTTTo...', - '...oTTTTTTTToB..', - '...oTTTTTTTTo...', - '...oTtTTTTtTo...', - '....oTo..oTo....', - '....oPo..oPo....', - '....oPo..oPo....', - '...oPPo..oPPo...', - '...oPPo..oPPo...', - '....oo....oo....', - '................' -], { o: 'P0', P: 'P2', p: 'P1', S: 'S1', s: 'S0', T: 'T0', t: 'T1', B: 'B0' }); - -// ---------- вывод ---------- - -mkdirSync(OUT + 'tiles', { recursive: true }); -mkdirSync(OUT + 'chars', { recursive: true }); - -function save(canvas, rel) { - writeFileSync(OUT + rel, canvas.toPng()); - console.log(` ${rel}`); -} - -console.log('Генерация тайлов:'); -save(grassTile(), 'tiles/grass.png'); -save(grassTile(83), 'tiles/grass_2.png'); -save(grassTile(149), 'tiles/grass_3.png'); -save(pathTile(), 'tiles/path.png'); -save(pathTile(57), 'tiles/path_2.png'); -save(waterTile(0), 'tiles/water_1.png'); -save(waterTile(1), 'tiles/water_2.png'); -save(ashTile(), 'tiles/ash.png'); -save(ashTile(41, 5), 'tiles/ash_2.png'); // реже трава — редкий второй оттенок -save(decorTuft(), 'tiles/decor_tuft.png'); -save(decorStone(), 'tiles/decor_stone.png'); -save(decorBald(), 'tiles/decor_bald.png'); -save(bellflowerTile(), 'tiles/bellflower.png'); -// Деревья: три вида кроны, у каждого кадр качания (сдвиг кроны на 1px). -save(treeTile({ kind: 'wide', seed: 2026, cable: true }), 'tiles/tree.png'); -save(treeTile({ kind: 'wide', seed: 2026, cable: true, sway: 1 }), 'tiles/tree_sway.png'); -save(treeTile({ kind: 'sparse', seed: 404, cable: false }), 'tiles/tree_2.png'); -save(treeTile({ kind: 'sparse', seed: 404, cable: false, sway: 1 }), 'tiles/tree_2_sway.png'); -save(treeTile({ kind: 'young', seed: 909, cable: false }), 'tiles/tree_3.png'); -save(treeTile({ kind: 'young', seed: 909, cable: false, sway: 1 }), 'tiles/tree_3_sway.png'); -// Дома: три фасада (шпальная крыша / ржавый лист / длинное однооконное). -save(isoHouseTile({ kind: 'shack', seed: 777 }), 'tiles/house.png'); -save(isoHouseTile({ kind: 'cottage', seed: 1717 }), 'tiles/house_2.png'); -save(isoHouseTile({ kind: 'shed', seed: 2727 }), 'tiles/house_3.png'); -save(isoHouseTile({ kind: 'barn', seed: 3737 }), 'tiles/house_4.png'); -save(isoHouseTile({ kind: 'two', seed: 4747 }), 'tiles/house_5.png'); -save(boulderTile(), 'tiles/boulder.png'); -save(boulderTile({ kind: 'spike' }), 'tiles/boulder_2.png'); -save(boulderTile({ kind: 'flat' }), 'tiles/boulder_3.png'); -save(bushTile(), 'tiles/bush.png'); -save(bushTile({ kind: 'spread' }), 'tiles/bush_2.png'); -save(bushTile({ kind: 'tall' }), 'tiles/bush_3.png'); -save(bigHouseTile(), 'tiles/house_big.png'); -save(towerTile(), 'tiles/tower.png'); -save(floorTile(), 'tiles/floor.png'); -save(wallTile(), 'tiles/wall.png'); -save(wellTile(), 'tiles/well.png'); -save(chestTile(), 'tiles/chest.png'); -save(wireTile(), 'tiles/wire.png'); -save(railTile(), 'tiles/rail.png'); -save(stairsTile(), 'tiles/stairs.png'); -save(machineTile(), 'tiles/machine.png'); -// Интерактивы Ржавой рощи — рукодельные (AI-кандидаты не дали чистого фона). -save(toneTreeTile(), 'tiles/tone_tree.png'); -save(resonatorTile(), 'tiles/resonator.png'); - -console.log('Генерация персонажей:'); -save(heroDown1, 'chars/hero_down_1.png'); -save(heroDown2, 'chars/hero_down_2.png'); -save(heroUp1, 'chars/hero_up_1.png'); -save(heroUp2, 'chars/hero_up_2.png'); -save(heroSide1, 'chars/hero_side_1.png'); -save(heroSide2, 'chars/hero_side_2.png'); -save(elderIrwin, 'chars/elder_irwin.png'); -save(traderMila, 'chars/trader_mila.png'); -save(clint, 'chars/clint.png'); - -// ---------- фонарь героя (7x8, латунь B*, тёплое стекло F*) ---------- -// Вешается ребёнком на спрайт героя (пояс, правый бок): свет лампы — «от фонаря». -const heroLamp = fromAscii( - [ - '...B...', - '..BBB..', - '.B1F1B.', - '.BF2FB.', - '.BF2FB.', - '.B1F1B.', - '..B1B..', - '...B...' - ], - { B: 'B0', 1: 'B1', F: 'F1', 2: 'B2' } -); -save(heroLamp, 'chars/hero_lamp.png'); - -// ---------- безгласный олень (пейзажная фауна) ---------- -// Выцветший: серо-оливковые G*/M2, никаких тёплых пикселей — голос забран. - -const DEER_MAP = { o: 'P0', m: 'M2', G: 'G1', l: 'G2', d: 'P0' }; - -// Кадр 1: стоит (ноги вместе). -const deerStand = fromAscii([ - '................', - '................', - '...o.....o......', - '....o...o.......', - '.....o.o........', - '......omo.......', - '.....ommmo......', - '....oGGGGo......', - '...oGGdGGo......', - '....oGGGo.......', - '.....oGo........', - '..oGGGGGGGGo....', - '.oGGGGGGGGGGo...', - '.oGllGGGlllGo...', - '.oGllGGGlllGo...', - '..ooGGGGGGoo....', - '...oGo..oGo.....', - '...oGo..oGo.....', - '...oGo..oGo.....', - '...oGo..oGo.....', - '...ooo..ooo.....', - '................', - '................', - '................' -], DEER_MAP); - -// Кадр 2: шаг (ноги врозь). -const deerWalk = fromAscii([ - '................', - '................', - '...o.....o......', - '....o...o.......', - '.....o.o........', - '......omo.......', - '.....ommmo......', - '....oGGGGo......', - '...oGGdGGo......', - '....oGGGo.......', - '.....oGo........', - '..oGGGGGGGGo....', - '.oGGGGGGGGGGo...', - '.oGllGGGlllGo...', - '.oGllGGGlllGo...', - '..ooGGGGGGoo....', - '..oGo....oGo....', - '..oGo....oGo....', - '.oGo......oGo...', - '.oGo......oGo...', - '.ooo......ooo...', - '................', - '................', - '................' -], DEER_MAP); - -save(deerStand, 'chars/fauna_deer_1.png'); -save(deerWalk, 'chars/fauna_deer_2.png'); - -// атлас фауны (кадры — та же сетка, что у героя) -const FRAME_W_FAUNA = SIZES.fauna.w; -const FRAME_H_FAUNA = SIZES.fauna.h; -const faunaSheet = buildAtlas( - [deerStand, deerWalk].map((c) => new Uint8Array(c.data)), - ['fauna_deer_1', 'fauna_deer_2'], - { - frameW: FRAME_W_FAUNA, frameH: FRAME_H_FAUNA, image: 'fauna_sheet.png', - animations: { fauna_deer_walk: [0, 1] } - } -); -writeFileSync(OUT + 'chars/fauna_sheet.png', faunaSheet.png); -writeFileSync(OUT + 'chars/fauna_sheet.json', JSON.stringify(faunaSheet.json, null, 2)); -console.log(` chars/fauna_sheet.png (+ .json): ${FRAME_W_FAUNA * 2}x${FRAME_H_FAUNA}`); - -// ---------- пепельные сгустки (враги демо-боя) ---------- -// Серые P*/G* тела; тёплое ядро F* — «жизнь» загорается, когда сгусток просыпается. - -const CLUMP_MAP = { o: 'P0', d: 'P2', p: 'P3', q: 'P4', h: 'P5H', f: 'F0', F: 'F1' }; - -// Ползун 14x12: плоский слизень на коротких ножках. -const crawler1 = fromAscii([ - '..............', - '..............', - '...oooooo.....', - '..odppppdo....', - '.odpppppppdo..', - '.dpppffppppdo.', - '.dpppppppppdo.', - '.odpppppppdo..', - '..oddpppddo...', - '..o.o....o.o..', - '..o.o....o.o..', - '..............' -], CLUMP_MAP); - -const crawler2 = fromAscii([ - '..............', - '..............', - '...oooooo.....', - '..odppppdo....', - '.odpppppppdo..', - '.dpppffppppdo.', - '.dpppppppppdo.', - '.odpppppppdo..', - '..oddpppddo...', - '...o.o..o.o...', - '...o.o..o.o...', - '..............' -], CLUMP_MAP); - -// Плевун 14x14: столбик с тёмной пастью. -const spitter1 = fromAscii([ - '....oooooo....', - '...odppppdo...', - '..odppppppdo..', - '..dpppppppppd.', - '..dpppffppppd.', - '..dpppppppppd.', - '..dpoooopppd..', - '..dpppppppppd.', - '...dpppppppd..', - '...odpppppdo..', - '....odpppdo...', - '....o.o.o.o...', - '...o..o..o....', - '..............' -], CLUMP_MAP); - -const spitter2 = fromAscii([ - '....oooooo....', - '...odppppdo...', - '..odppppppdo..', - '..dpppppppppd.', - '..dpppffppppd.', - '..dpppppppppd.', - '..dppoooopppd.', - '..dpppppppppd.', - '...dpppppppd..', - '...odpppppdo..', - '....odpppdo...', - '...o..o..o....', - '....o.o.o.o...', - '..............' -], CLUMP_MAP); - -// Наст 20x20: тяжёлая корка с трещинами; медленно давит. -const heavy1 = fromAscii([ - '.......oooooo.......', - '.....oodppppdoo.....', - '....odppppppppdo....', - '...odppppppppppdo...', - '...dpppddppppppppd..', - '..dppppppddpppppppd.', - '..dppppppppddppffpd.', - '..dpppffppppppppfpd.', - '..dpppffppppppppppd.', - '..dpppppppppppppppd.', - '..dpppppppppppppppd.', - '..dpppppppppppppppd.', - '..odpppppppppppppdo.', - '..odqpppppppppppdo..', - '...odqqppppqqpdo....', - '....odqpppqqpdo.....', - '.....oddddddddo.....', - '...ooooddddooooo....', - '....................', - '....................' -], CLUMP_MAP); - -const heavy2 = fromAscii([ - '.......oooooo.......', - '.....oodppppdoo.....', - '....odppppppppdo....', - '...odppppppppppdo...', - '...dpppppppddppppd..', - '..dpppddpppppddpppd.', - '..dpppppddpppppffpd.', - '..dpppffpppddpppfpd.', - '..dpppffppppppppppd.', - '..dpppppppppppppppd.', - '..dpppppppppppppppd.', - '..dpppppppppppppppd.', - '..odpppppppppppppdo.', - '..odqpppppppppppdo..', - '...odqqppppqqpdo....', - '....odqpppqqpdo.....', - '.....oddddddddo.....', - '...ooooddddooooo....', - '....................', - '....................' -], CLUMP_MAP); - -save(crawler1, 'chars/clump_crawler_1.png'); -save(crawler2, 'chars/clump_crawler_2.png'); -save(spitter1, 'chars/clump_spitter_1.png'); -save(spitter2, 'chars/clump_spitter_2.png'); -save(heavy1, 'chars/clump_heavy_1.png'); -save(heavy2, 'chars/clump_heavy_2.png'); - -// атлас сгустков (кадры разной ширины — вписаны по низу-центру в ячейку) -const CLUMP_W = SIZES.clump.w; -const CLUMP_H = SIZES.clump.h; -/** Canvas → кадр w×h×4 с выравниванием по низу-центру. */ -const toClumpFrame = (c) => { - const out = new Uint8Array(CLUMP_W * CLUMP_H * 4); - const dx = Math.floor((CLUMP_W - c.width) / 2); - for (let y = 0; y < c.height; y++) { - for (let x = 0; x < c.width; x++) { - const rgba = c.get(x, y); - if (!rgba) continue; - out.set(rgba, ((CLUMP_H - c.height + y) * CLUMP_W + dx + x) * 4); - } - } - return out; -}; -const clumpSheet = buildAtlas( - [crawler1, crawler2, spitter1, spitter2, heavy1, heavy2].map(toClumpFrame), - ['clump_crawler_1', 'clump_crawler_2', 'clump_spitter_1', 'clump_spitter_2', 'clump_heavy_1', 'clump_heavy_2'], - { - frameW: CLUMP_W, frameH: CLUMP_H, image: 'clumps_sheet.png', - animations: { clump_crawler: [0, 1], clump_spitter: [2, 3], clump_heavy: [4, 5] } - } -); -writeFileSync(OUT + 'chars/clumps_sheet.png', clumpSheet.png); -writeFileSync(OUT + 'chars/clumps_sheet.json', JSON.stringify(clumpSheet.json, null, 2)); -console.log(` chars/clumps_sheet.png (+ .json): ${CLUMP_W * 6}x${CLUMP_H}`); - -// ---------- атлас героя (Spritesheet: один PNG + JSON + animations) ---------- - -const FRAME_W = SIZES.char.w; -const FRAME_H = SIZES.char.h; -const heroSheet = buildAtlas( - [heroDown1, heroDown2, heroUp1, heroUp2, heroSide1, heroSide2].map((c) => new Uint8Array(c.data)), - ['hero_down_1', 'hero_down_2', 'hero_up_1', 'hero_up_2', 'hero_side_1', 'hero_side_2'], - { - frameW: FRAME_W, frameH: FRAME_H, image: 'hero_sheet.png', - animations: { - hero_walk_down: [0, 1], - hero_walk_up: [2, 3], - hero_walk_side: [4, 5] - } - } -); -writeFileSync(OUT + 'chars/hero_sheet.png', heroSheet.png); -writeFileSync(OUT + 'chars/hero_sheet.json', JSON.stringify(heroSheet.json, null, 2)); -console.log(` chars/hero_sheet.png (+ .json): ${FRAME_W * 6}x${FRAME_H}`); - -// ---------- атлас NPC: idle-кадры из опорных (bobFrame) ---------- - -const npcSheet = buildAtlas( - [ - new Uint8Array(elderIrwin.data), - bobFrame(new Uint8Array(elderIrwin.data), FRAME_W, FRAME_H), - new Uint8Array(traderMila.data), - bobFrame(new Uint8Array(traderMila.data), FRAME_W, FRAME_H), - new Uint8Array(clint.data), - bobFrame(new Uint8Array(clint.data), FRAME_W, FRAME_H) - ], - ['elder_irwin_1', 'elder_irwin_2', 'trader_mila_1', 'trader_mila_2', 'clint_1', 'clint_2'], - { - frameW: FRAME_W, frameH: FRAME_H, image: 'npcs_sheet.png', - animations: { elder_irwin_idle: [0, 1], trader_mila_idle: [2, 3], clint_idle: [4, 5] } - } -); -writeFileSync(OUT + 'chars/npcs_sheet.png', npcSheet.png); -writeFileSync(OUT + 'chars/npcs_sheet.json', JSON.stringify(npcSheet.json, null, 2)); -console.log(` chars/npcs_sheet.png (+ .json): ${FRAME_W * 6}x${FRAME_H}`); - -console.log('Готово: ' + OUT); \ No newline at end of file diff --git a/apps/game/tools/pixelart/palette.mjs b/apps/game/tools/pixelart/palette.mjs deleted file mode 100644 index ed6c7b8..0000000 --- a/apps/game/tools/pixelart/palette.mjs +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Палитра из docs/art-style.md — единственный источник цветов арта. - */ - -export const PALETTE = { - // Пепел - P0: '#17171d', - P1: '#23232b', - P2: '#2f2f38', - P3: '#3e3e48', - P4: '#5b5b66', - P5: '#767681', - P6: '#94949e', - P5H: '#b2b2bb', - // Мёртвая трава - G0: '#343b2c', - G1: '#4a5340', - G2: '#626c4c', - G2H: '#7d8662', - // Ржавчина и металл - R0: '#3a2822', - R1: '#5c3b2e', - R2: '#82503a', - M0: '#26262e', - M1: '#44454e', - M2: '#61626c', - // Вода - W0: '#22343c', - W1: '#3b5e6b', - W2: '#568590', - // Тепло — только жизнь - B0: '#8a6a2e', - B1: '#d99a32', - B2: '#f2cd68', - F0: '#d97f2b', - F1: '#f2b45a', - // Люди и ткань - C0: '#3a4356', - C1: '#4d5a72', - W3: '#8a8f7d', - S0: '#8a6d55', - S1: '#c9a689', - T0: '#5a4a3c', - T1: '#77634f' -}; - -/** Прозрачный пиксель. */ -export const TRANSPARENT = [0, 0, 0, 0]; - -export function hexToRgba(hex) { - const n = parseInt(hex.slice(1), 16); - return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff, 255]; -} - -/** Ключи палитры -> RGBA-значения (кэш). */ -export const RGBA = Object.fromEntries( - Object.entries(PALETTE).map(([k, v]) => [k, hexToRgba(v)]) -); \ No newline at end of file diff --git a/apps/game/tools/pixelart/sizes.mjs b/apps/game/tools/pixelart/sizes.mjs deleted file mode 100644 index e0030b0..0000000 --- a/apps/game/tools/pixelart/sizes.mjs +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Реестр размеров спрайтов игры — единственный источник истины для - * генераторов (gen.mjs), линтера (aiart/lint-art.mjs) и aiart-тулз. - * Таблица размеров в docs/art-style.md ссылается сюда (§2). - * - * Рантайм размеры не отсюда: движок читает их из атласного JSON - * (`AssetLoader.frameSize`) — тулзовый реестр движок не знает. - */ -export const SIZES = { - /** Тайл-ромб (изометрия 2:1). */ - tile: { w: 32, h: 16 }, - /** Высокие тайлы: спрайт якорится низом в центр ромба. */ - tall: { - wall: [32, 32], - well: [32, 40], - tree: [32, 48], - house: [32, 56], - /** Высокие дома (амбар, двухэтажный) — ростом с башню. */ - houseTall: [32, 80], - /** Валун — крупный непроходимый камень в изо-объёме (3 вида). */ - boulder: [32, 32], - boulder_2: [32, 40], - boulder_3: [32, 24], - /** Большой мёртвый куст-облако (3 вида). */ - bush: [32, 40], - bush_2: [32, 32], - bush_3: [32, 48], - tower: [32, 80], - /** Бухта кабеля Ржавой рощи (низкая, в полтайла). */ - wire: [32, 32], - /** Старая Машина: печь-автоматон зала разъезда (слот башни). */ - machine: [32, 80] - }, - /** Крупные пропы. */ - prop: { - houseBig: [64, 80], - chest: [24, 18] - }, - /** Кадры героя и NPC (общая сетка атласов chars/*_sheet). */ - char: { w: 16, h: 24 }, - /** Кадры фауны (та же сетка, что у героя). */ - fauna: { w: 16, h: 24 }, - /** Кадры сгустков (враги демо-боя; рисунки вписаны по низу-центру). */ - clump: { w: 20, h: 20 }, - /** Спрайты декора поверх тайлов (микро-кусты, камни, проплешины). */ - decor: { w: 16, h: 16 }, - /** Одиночные спрайты-сателлиты (вешаются на персонажей). */ - satellite: { heroLamp: [7, 8] } -}; - -/** Слоты вне реестра: рукодельные кадры сгустков, крупные AI-ассеты, иконки 8×8. */ -const EXTRA_SLOTS = ['8x8', '14x12', '14x14', '48x48', '96x96', '128x112']; - -const dim = (s) => (Array.isArray(s) ? `${s[0]}x${s[1]}` : `${s.w}x${s.h}`); - -/** Все размеры группы: группа — либо один размер {w,h}, либо карта имён → размер. */ -const groupDims = (group) => - 'w' in group ? [group] : Object.values(group); - -/** Все допустимые слоты линтера: реестр + исключения. */ -export const SLOTS = new Set([ - ...Object.values(SIZES).flatMap((group) => groupDims(group).map(dim)), - ...EXTRA_SLOTS -]); \ No newline at end of file diff --git a/apps/game/tools/smoke-act1.mjs b/apps/game/tools/smoke-act1.mjs deleted file mode 100644 index 6b0ef4f..0000000 --- a/apps/game/tools/smoke-act1.mjs +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Полный прогон акта 1 через агентный мост: луга -> Звенец -> квест у Ирвина -> - * (сбор цветов эмулируется var'ом) -> сдача -> кат-сцена с колоколом. - * Запуск: node tools/smoke-act1.mjs [url] [скриншот] - */ -import { openGame } from './lib.mjs'; - -const url = process.argv[2] ?? 'http://localhost:5199/'; -const shot = process.argv[3] ?? '/tmp/rpg_act1.png'; - -const { browser, page, agent } = await openGame({ url, newGame: true }); -page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); - -// Усыпить сгустков: путь героя не должен прерываться уроном. -async function sleepAll() { - await page.evaluate(() => { - const g = window.__game; - for (const [, en] of g.scenes.current.combat?.enemies ?? []) en.brain.putToSleep(9999); - }); -} -await sleepAll(); - -// 1) Луга -> тропа в Звенец (54,28) — триггер срабатывает на самом тайле. -console.log('шаг 1: луга -> Звенец'); -if (!(await agent.walkTo(54, 28, { timeoutTicks: 6000 }))) console.log(' ! герой не дошёл до (54,28)'); -await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 300 }); -// Фаза «in» fade-перехода: сцена ещё игнорирует клики. -await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); -await sleepAll(); // переход мог вернуть героя в бой - -// 2-3) Подойти к Ирвину (12,14) и нажать действие E — диалог откроется в радиусе. -console.log('шаг 2: квест у Ирвина'); -if (await agent.approach(12, 14)) agent.press('interact'); -else console.log(' ! не подошли к Ирвину'); -if (!(await agent.runDialogue())) console.log(' ! диалог с Ирвином не открылся'); - -// 4) Сбор цветов — эмуляция var'ом (переход в пруды проверяется checks/transitions). -console.log('шаг 4: цветы собраны (эмуляция)'); -await agent.command('scene:setVar', { id: 'flowers', value: 3 }); - -// 5) Сдача Ирвину -> кат-сцена. -console.log('шаг 5: сдача -> кат-сцена'); -if (await agent.approach(12, 14)) agent.press('interact'); -else console.log(' ! не подошли к Ирвину для сдачи'); -if (!(await agent.runDialogue())) console.log(' ! диалог сдачи не открылся'); - -// 6) Кат-сцена: крутим шаги, пока активна (мост перематывает без реального ожидания). -await agent.waitFor('s.cutscene == null || !s.cutscene.active', { timeoutTicks: 1200 }); - -const snap = await agent.snapshot(); -console.log('итог:', JSON.stringify({ flags: snap.flags, vars: snap.vars })); -await page.screenshot({ path: shot }); -console.log(`Скриншот: ${shot}`); -if (!snap.flags.includes('quest_bells_done')) console.log(' ! квест не закрыт (quest_bells_done)'); -await browser.close(); \ No newline at end of file diff --git a/apps/game/tools/smoke.mjs b/apps/game/tools/smoke.mjs deleted file mode 100644 index 368b890..0000000 --- a/apps/game/tools/smoke.mjs +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Базовый смоук через агентный мост: новая игра, шаги, снапшот, скриншот. - * Запуск: node tools/smoke.mjs [url] [файл-скриншота] - */ -import { openGame } from './lib.mjs'; - -const url = process.argv[2] ?? 'http://localhost:5199/'; -const shot = process.argv[3] ?? '/tmp/rpg_smoke.png'; - -const { browser, page, agent } = await openGame({ url, newGame: true }); - -// 300 фиксированных шагов без ошибок инвариантов. -await agent.step(300); -const inv = await agent.invariants(); -const errs = inv.filter((i) => i.severity === 'error'); -if (errs.length) console.log(' ! инварианты:', JSON.stringify(errs)); - -const snap = await agent.snapshot(); -console.log('снапшот:', JSON.stringify({ area: snap.area, hero: snap.hero?.tile, hp: snap.hero?.hp })); -await page.screenshot({ path: shot }); -console.log(`Скриншот: ${shot}`); -await browser.close(); \ No newline at end of file diff --git a/apps/game/tsconfig.json b/apps/game/tsconfig.json deleted file mode 100644 index d16bc7c..0000000 --- a/apps/game/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["vite/client"], - "resolveJsonModule": true - }, - "include": ["src"] -} \ No newline at end of file diff --git a/apps/game/vite.config.ts b/apps/game/vite.config.ts deleted file mode 100644 index 315b6fe..0000000 --- a/apps/game/vite.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from 'vite'; -import { fileURLToPath } from 'node:url'; - -// Движок — TS-исходники workspace-пакета: алиас гарантирует, что Vite -// собирает их как исходный код, а не как предсобранный пакет. -// publicDir = apps/game/assets: PNG-ассеты раздаются с корня и копируются в dist. -export default defineConfig({ - publicDir: 'assets', - resolve: { - alias: [ - // Точный алиас: под-пути (@rpg/engine/assets/*) идут через exports пакета. - { - find: /^@rpg\/engine$/, - replacement: fileURLToPath(new URL('../../packages/engine/src/index.ts', import.meta.url)) - } - ] - } -}); \ No newline at end of file diff --git a/docs/art-style.md b/docs/art-style.md index bc7364b..46b7d86 100644 --- a/docs/art-style.md +++ b/docs/art-style.md @@ -6,7 +6,7 @@ ## 1. Палитра (32 цвета) -Движок и генератор (`apps/game/tools/pixelart/`) используют ровно эти цвета. Имя цвета — тоже +Движок и генератор (`v1/apps/game/tools/pixelart/`) используют ровно эти цвета. Имя цвета — тоже комментарий к настроению. ### Пепел (нейтраль) @@ -77,7 +77,7 @@ ## 2. Формат и размеры -Источник номиналов — реестр `apps/game/tools/pixelart/sizes.mjs` (его читают +Источник номиналов — реестр `v1/apps/game/tools/pixelart/sizes.mjs` (его читают генераторы и линтер; рантайм берёт размер кадра из атласа — `AssetLoader.frameSize`). - Тайл: ромб **32×16** (движок, `DEFAULT_ISO`). @@ -95,7 +95,7 @@ Плотность пикселя — та же, что у тайлов и персонажей (1 px спрайта = 1 px мира): апскейл-болванки из AI-генерации в эти слоты не попадают — только как плейсхолдеры. - AI-атласы персонажей: пайплайн `npm run aiart` (gen/build/promote, манифест - `apps/game/tools/aiart/atlas-manifest.json`); в `assets/` — только после + `v1/apps/game/tools/aiart/atlas-manifest.json`); в `assets/` — только после ручного апрува обзорного листа (`status: approved`). Палитра — параметр манифеста; по умолчанию эти 32 цвета. - Интерактивные объекты: `InteractableDef.view.tex` — спрайт из `tiles/`, @@ -126,9 +126,9 @@ ## 4. Ассеты и чеклист -Все PNG — из `apps/game/assets/`, генерируются `apps/game/tools/pixelart/gen.mjs`. +Все PNG — из `v1/apps/game/assets/`, генерируются `v1/apps/game/tools/pixelart/gen.mjs`. -### Тайлы (32×16, id совпадает с `apps/game/src/data/map.ts`) +### Тайлы (32×16, id совпадает с `v1/apps/game/src/data/map.ts`) | id | файл | содержание | |---|---|---| @@ -172,7 +172,7 @@ ## 5. Что где хранится -- `apps/game/tools/pixelart/gen.mjs` — генератор: пиксель-карты в ASCII + энкодер PNG (без зависимостей). -- `apps/game/assets/` — выход генератора, загружается `AssetLoader` по ключу = пути без расширения +- `v1/apps/game/tools/pixelart/gen.mjs` — генератор: пиксель-карты в ASCII + энкодер PNG (без зависимостей). +- `v1/apps/game/assets/` — выход генератора, загружается `AssetLoader` по ключу = пути без расширения (`tiles/grass` → `assets/tiles/grass.png`). - После замены сгенерированного арта на нарисованный руками имена файлов и размеры не меняются. \ No newline at end of file diff --git a/docs/demo.md b/docs/demo.md index b7f874f..cbf1f2e 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -44,7 +44,7 @@ | math/rng (mulberry32) | генерация карт и частиц, детерминированный shake | готово | | AgentHost/EngineAgent (agent/) | `window.__agent`: снапшот, ручной шаг, инъекция ввода, инварианты | готово | | data/validate.ts → Invariant[] | runtime-валидация контента (диалоги/NPC/локации/враги) | готово | -| apps/game/tools/agent.mjs + packages/engine/tools/agent-lib.mjs | CLI check/run/snapshot/screenshot/dev; смоуки на мосту (каркас — в движке) | готово | +| v1/apps/game/tools/agent.mjs + v1/packages/engine/tools/agent-lib.mjs | CLI check/run/snapshot/screenshot/dev; смоуки на мосту (каркас — в движке) | готово | ## Сюжетная рамка (акты 1–2, по docs/world.md) @@ -86,21 +86,21 @@ ## Архитектура среза -- `packages/engine` — жанронезависимый движок, публичный API только `src/index.ts`. -- `apps/game/src/`: +- `v1/packages/engine` — жанронезависимый движок, публичный API только `src/index.ts`. +- `v1/apps/game/src/`: - `data/` — контент: `locations.ts` (определения локаций), `map.ts` (генераторы), `dialogues.ts` (графы), `npcs.ts`, `enemies.ts`, `quests.ts` (журнал/сумка); - `scenes/` — BootScene → MenuScene (+ SettingsScene, SaveSlotsScene) → LocationScene (+ InventoryScene, MapScene); - `systems/` — PlayerController (путь + прямое движение), DialogueSystem, `combat/` (CombatWorld, EnemyBrain, PlayerCombat, CombatViews, HealthBar, stats). -- Инструменты: `apps/game/tools/pixelart` (PNG-атласы), `apps/game/tools/audio` (WAV-генератор), - `apps/game/tools/aiaudio` (AI-лупы AudioLDM2: манифест + gen/post/promote, апрув-гейт), - `apps/game/tools/maps` (карты-файлы), `apps/game/tools/agent.mjs` + `apps/game/tools/lib.mjs` + - `apps/game/tools/checks/` (агентный мост: check/snapshot/сценарии; браузерный каркас — - `packages/engine/tools/agent-lib.mjs`), `apps/game/tools/smoke*.mjs` +- Инструменты: `v1/apps/game/tools/pixelart` (PNG-атласы), `v1/apps/game/tools/audio` (WAV-генератор), + `v1/apps/game/tools/aiaudio` (AI-лупы AudioLDM2: манифест + gen/post/promote, апрув-гейт), + `v1/apps/game/tools/maps` (карты-файлы), `v1/apps/game/tools/agent.mjs` + `v1/apps/game/tools/lib.mjs` + + `v1/apps/game/tools/checks/` (агентный мост: check/snapshot/сценарии; браузерный каркас — + `v1/packages/engine/tools/agent-lib.mjs`), `v1/apps/game/tools/smoke*.mjs` (смоуки через мост, в реальном Chromium). Разграничение: тулзы, знающие контент - игры, — в `apps/game/tools`; жанронезависимая машинерия — `packages/engine/tools/` + игры, — в `v1/apps/game/tools`; жанронезависимая машинерия — `v1/packages/engine/tools/` (png/canvas/imaging/wav/agent-lib/boundary через `@rpg/engine/tools/*`). ## Новое в движке за срез @@ -144,37 +144,37 @@ **Текущий срез — движок для ИИ-агента + геймплей на нём (план утверждён):** - ✅ **Батч 0 — агентный набор инструментов** (готово): мост `window.__agent`, - валидация контента, `apps/game/tools/agent.mjs`, смоуки на мосту, `docs/llms.txt` + + валидация контента, `v1/apps/game/tools/agent.mjs`, смоуки на мосту, `docs/llms.txt` + `docs/engine/agent.md` + `docs/engine/practices.md`. - ✅ **Батч 1 — единый механизм переходов** (готово): `TransitionDef` (step/click, условия, lockedText), дисарм тайла входа, `returnTo`, сейвы v3, - телепорт-колодцы (Звенец ↔ луга), сценарий `apps/game/tools/checks/transitions.mjs`. + телепорт-колодцы (Звенец ↔ луга), сценарий `v1/apps/game/tools/checks/transitions.mjs`. - ✅ **Батч 2 — интерьеры + интерактивные объекты** (готово): карты интерьеров (`buildInteriorMap`: дом Ирвина, лавка Милы), двери-действия (E у тайла) внутрь и выход `kind: 'return'`, `InteractableDef`/`Interactables` (сундук, очаг, записка, прилавок; реакции по `when`, `once` → флаг `used:`, `gives`), снапшот - `interactables`, сценарий `apps/game/tools/checks/interact.mjs`. + `interactables`, сценарий `v1/apps/game/tools/checks/interact.mjs`. - ✅ **Батч 3 — взаимодействие с миром шире** (готово): столбы у троп (луга, пруды, Звенец), подбор пепельных мотов (`gives` + `addVar`, вар `motes` — зачаток экономики), верёвка колокола башни (флаг `rang_tower`), ветка повторного диалога Милы по вару (`whenVar motes`), инвариант `interact-used-consistent` в мосте, `walkTo` пропускает узлы-объекты, - сценарий `apps/game/tools/checks/interact-world.mjs`. + сценарий `v1/apps/game/tools/checks/interact-world.mjs`. - ✅ **Батч 4b — арт интерактивов (B4, задача 5)** (готово): плейсхолдеры Graphics заменены спрайтами через `InteractableDef.view.tex` (якорь низ-центр по тайлу): столбы, моты, верёвка колокола, прилавок, очаг, записка, резонатор, деревья-механизмы, Старая Машина, сундук; снаряд плевка — `tiles/spit`; иконки предметов 8×8 в сумке. Источники: AI (Retro Diffusion, GPU ~1.3 с/вид, `tools/aiart/gen_b4.py`+`post_b4.mjs`) и рукодельные (`toneTreeTile`/ - `resonatorTile` в `gen.mjs`); визуальный чек `apps/game/tools/checks/art_b4.mjs`. + `resonatorTile` в `gen.mjs`); визуальный чек `v1/apps/game/tools/checks/art_b4.mjs`. - ✅ **Батч 4 — ИИ врагов** (готово): LOS по сетке (`systems/combat/los.ts`, кэш раз в 3 тика), шум мира `CombatWorld.noise` (шаги героя 0.35, удар конусом 1.0, плевок/смерть 0.6; спящих будит только ≥ 0.7), состояния `patrol`/`wary`/`flee`/`return` в `EnemyBrain` (патруль — per-spawn в `AREAS[*].enemies`), бег при отступлении (`run` в `EnemyIntent.move`), инварианты `enemy-state-legal`/`enemy-in-bounds`, команды - `scene:noise`/`scene:damageEnemy`, сценарий `apps/game/tools/checks/ai.mjs`. -- Принцип среза: каждая геймплейная система имеет сценарий в `apps/game/tools/checks/`. + `scene:noise`/`scene:damageEnemy`, сценарий `v1/apps/game/tools/checks/ai.mjs`. +- Принцип среза: каждая геймплейная система имеет сценарий в `v1/apps/game/tools/checks/`. **Дальше (запрос игрока, после моста):** diff --git a/docs/engine/README.md b/docs/engine/README.md index a429c98..c9d5ab5 100644 --- a/docs/engine/README.md +++ b/docs/engine/README.md @@ -1,7 +1,7 @@ # Документация движка @rpg/engine Движок — переиспользуемая основа для 2D-пиксельных игр в браузере. Первая игра на нём -— «Пепельные луга» (`apps/game`), она же живой пример использования всех API. +— «Пепельные луга» (`v1/apps/game`), она же живой пример использования всех API. ## Карта документации @@ -19,7 +19,7 @@ | [assets-audio-save.md](assets-audio-save.md) | AssetLoader, атласы, AudioManager, сейвы | | [art-pipeline.md](art-pipeline.md) | Генератор пиксель-арта, палитра, замена на рисованный арт | | [recipes.md](recipes.md) | «Как сделать…»: готовые решения типовых задач | -| [agent.md](agent.md) | Агентный мост: EngineAgent, window.__agent, инварианты, apps/game/tools/agent.mjs | +| [agent.md](agent.md) | Агентный мост: EngineAgent, window.__agent, инварианты, v1/apps/game/tools/agent.mjs | | [practices.md](practices.md) | Живой документ практик агента «по ситуациям» — пополняется в каждом коммите | ## Принципы @@ -27,7 +27,7 @@ ### 1. Жёсткая граница API Игра импортирует движок **только** через `@rpg/engine` (весь API реэкспортирован из -`packages/engine/src/index.ts`). Прямые импорты `pixi.js` в игре — нарушение границы: +`v1/packages/engine/src/index.ts`). Прямые импорты `pixi.js` в игре — нарушение границы: нужные типы реэкспортируются движком (`Container`, `Graphics`, `Text`, `Sprite`, `Texture`). Это позволяет менять внутренности движка, не трогая игры. @@ -48,7 +48,7 @@ ### 4. Движок жанронезависим -В `packages/engine` нет ни одного упоминания контента конкретной игры: `GameState` +В `v1/packages/engine` нет ни одного упоминания контента конкретной игры: `GameState` — механика флагов и переменных, `DialogueRunner` — механика графов. Всё содержимое (тексты, флаги сюжета, карты) живёт в приложении. @@ -61,7 +61,7 @@ ## Структура пакетов ``` -packages/engine/src/ +v1/packages/engine/src/ core/ Engine, GameLoop, EventBus, Tween+easing, GameState, StateMachine, Settings scene/ SceneManager (стек сцен + fade-переходы) render/ Renderer, Camera, IsoDepthLayer, Particles, computeScale diff --git a/docs/engine/agent.md b/docs/engine/agent.md index def626b..8a1ef43 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -8,19 +8,19 @@ ## Слои ``` -packages/engine/src/agent/ — движковый каркас (жанронезависимый) +v1/packages/engine/src/agent/ — движковый каркас (жанронезависимый) types.ts JsonValue, SnapshotLayer, Invariant, SceneAgent, AgentHost invariants.ts чистые хелперы: checkFinite/checkRange/checkBounds/checkWalkable EngineAgent.ts снапшот, инварианты, ручной шаг, инъекция ввода, waitFor -apps/game/src/agent/ — контентный слой игры +v1/apps/game/src/agent/ — контентный слой игры snapshot.ts чистые сборщики слоёв (hero/enemies/npcs/dialogue/game) — Vitest GameAgent.ts регистрирует window.__agent (только DEV), high-level хелперы -apps/game/src/data/validate.ts — runtime-валидация контента → Invariant[] -packages/engine/tools/agent-lib.mjs — браузерный каркас (puppeteer, openGame, Checks) -apps/game/tools/lib.mjs — сценарный слой игры (маяк загрузки поверх каркаса) -apps/game/tools/agent.mjs — CLI: check / run / snapshot / screenshot / dev +v1/apps/game/src/data/validate.ts — runtime-валидация контента → Invariant[] +v1/packages/engine/tools/agent-lib.mjs — браузерный каркас (puppeteer, openGame, Checks) +v1/apps/game/tools/lib.mjs — сценарный слой игры (маяк загрузки поверх каркаса) +v1/apps/game/tools/agent.mjs — CLI: check / run / snapshot / screenshot / dev ``` Граница: движок не знает ничего об RPG-контенте. `EngineAgent` общается со сценой @@ -123,7 +123,7 @@ ``` - **pred — строка-выражение** над снапшотом (`'s.hero.hp < 3'`): из браузера нельзя - передать функцию. Компилируется `new Function('s', ...)` — источник доверенный (apps/game/tools/). + передать функцию. Компилируется `new Function('s', ...)` — источник доверенный (v1/apps/game/tools/). - **waitFor крутит шаги сам** и вызывает pred после каждого шага; сцены могут меняться в процессе — обращайся к полям через optional chaining. - Мост **никогда не бросает исключений**: ошибка внутри превращается в слой @@ -149,7 +149,7 @@ дальше мост только ждёт прибытия (тайл цели и `!s.hero.moving` — точный снап прихода; возврат true — герой стоит на целевом тайле). Взаимодействие — не по клику, а кнопкой действия: `approach(tx, ty)` ведёт к краю непроходимой цели и -возвращает тайл прибытия; после него `press('interact')` (в `apps/game/tools/lib.mjs` +возвращает тайл прибытия; после него `press('interact')` (в `v1/apps/game/tools/lib.mjs` это завёрнуто в `t.interactAt/t.talkTo/t.useInteractable/t.fightNearest`). Семантика «just pressed» не меняется: инъекция между тиками видна сцене ровно @@ -191,24 +191,24 @@ Вызывается из `GameAgent.invariants()` (работает и в меню) и из юнит-теста `data/__tests__/validate.test.ts` — битый контент падает сразу в тестах. -## apps/game/tools/agent.mjs (CLI, JSON по умолчанию) +## v1/apps/game/tools/agent.mjs (CLI, JSON по умолчанию) ```bash -node apps/game/tools/agent.mjs check [--only a,b] [--skip a,b] [--pretty] # полный прогон -node apps/game/tools/agent.mjs run apps/game/tools/checks/xxx.mjs # один сценарий -node apps/game/tools/agent.mjs snapshot [--new-game] [--out файл] [--pretty] -node apps/game/tools/agent.mjs screenshot [--out] [--steps N] # скриншот + хвост консоли -node apps/game/tools/agent.mjs dev [--port 5199] # dev-сервер для ручной работы +node v1/apps/game/tools/agent.mjs check [--only a,b] [--skip a,b] [--pretty] # полный прогон +node v1/apps/game/tools/agent.mjs run v1/apps/game/tools/checks/xxx.mjs # один сценарий +node v1/apps/game/tools/agent.mjs snapshot [--new-game] [--out файл] [--pretty] +node v1/apps/game/tools/agent.mjs screenshot [--out] [--steps N] # скриншот + хвост консоли +node v1/apps/game/tools/agent.mjs dev [--port 5199] # dev-сервер для ручной работы ``` `check` поднимает dev-сервер сам (и гасит в finally): `typecheck`, `tests`, `maps`, `maps:fresh` (файлы карт равны генераторам), `agent:invariants` (новая игра → 300 -шагов → инварианты чисты). Сценарии геймплея — в `apps/game/tools/checks/*.mjs`, каркас — -`Checks` из `packages/engine/tools/agent-lib.mjs` — импортируйте через сценарный слой -`apps/game/tools/lib.mjs` (там маяк загрузки игры) (проверка возвращает `false`/кидает исключение +шагов → инварианты чисты). Сценарии геймплея — в `v1/apps/game/tools/checks/*.mjs`, каркас — +`Checks` из `v1/packages/engine/tools/agent-lib.mjs` — импортируйте через сценарный слой +`v1/apps/game/tools/lib.mjs` (там маяк загрузки игры) (проверка возвращает `false`/кидает исключение или `c.expect(cond, msg, details)`). -**Новый сценарий — через `withChecks`** (`apps/game/tools/lib.mjs`): он поднимает и +**Новый сценарий — через `withChecks`** (`v1/apps/game/tools/lib.mjs`): он поднимает и гасит dev-сервер, а сценарий оставляет только шаги. Второй аргумент — `async (t) => {...}` с помощниками: `t.boot()` (новая игра + выход из fade), `t.c`/`t.ctx` (Checks и контекст), `t.sleepEnemies()`, `t.goToArea(area, via)` (пешком к step-переходу), `t.readAudioLog()`/ @@ -221,12 +221,12 @@ **startDevServer и cwd**: `startDevServer({ port, cwd })` поднимает vite из каталога `cwd` (по умолчанию — корень монорепо); приложение обязано передать свой каталог (index.html + vite-конфиг), иначе vite от корня отдаёт 404 на всё. Игровой -`lib.mjs` уже заворачивает это с `cwd: apps/game` — импортируйте `startDevServer` +`lib.mjs` уже заворачивает это с `cwd: v1/apps/game` — импортируйте `startDevServer` оттуда. Грабли: (а) `--strictPort` при занятом порте гасит наш vite, но fetch продолжил бы отвечать чужим сервером — каркас отслеживает выход процесса и падает; (б) npx-обёртка при смерти оставляет зомби-vite, держащий порт — spawn идёт с `detached: true`, `stop()` убивает всю группу (`process.kill(-pid)`). **Правило**: каждая геймплейная система обязана иметь сценарий проверки в -`apps/game/tools/checks/`. Предпочитай `snapshot`/`check` скриншотам: скриншот — для +`v1/apps/game/tools/checks/`. Предпочитай `snapshot`/`check` скриншотам: скриншот — для визуальных вопросов (арт, компоновка), состояние игры читается снапшотом. \ No newline at end of file diff --git a/docs/engine/art-pipeline.md b/docs/engine/art-pipeline.md index 27f87ab..f776860 100644 --- a/docs/engine/art-pipeline.md +++ b/docs/engine/art-pipeline.md @@ -1,6 +1,6 @@ # Арт-пайплайн: генерация пиксель-арта из кода -Арт игры генерируется кодом: сценарий `apps/game/tools/pixelart/gen.mjs` рисует спрайты +Арт игры генерируется кодом: сценарий `v1/apps/game/tools/pixelart/gen.mjs` рисует спрайты на канвасе из движка и кодирует PNG без сторонних зависимостей (`node:zlib` + свой CRC32). Позже генератор можно заменить на рисованный арт — движку всё равно, откуда текстуры. @@ -9,29 +9,29 @@ | Файл | Что делает | |---|---| -| `packages/engine/tools/png.mjs` | PNG-кодек без зависимостей: `encodePng`/`decodePng` (RGBA 8 бит) | -| `packages/engine/tools/canvas.mjs` | Canvas (таблица цветов — параметр): set/get пикселя, `fillDiamond` (изо-ромб), `fromAscii`, seeded rng | -| `packages/engine/tools/imaging.mjs` | Кроп по альфе, nearest-масштаб, квантизация в палитру, обзорный лист; `findFigures`/`resizeRect` — фигуры на однотонном фоне | -| `packages/engine/tools/atlas.mjs` | `buildAtlas` — кадры в строку + Pixi Spritesheet JSON (с `animations`) | -| `packages/engine/tools/frames.mjs` | Производные кадры из опорных: shift/bob/squash/swing/mirror, `deriveCycle` | -| `apps/game/tools/pixelart/palette.mjs` | Палитра игры из 32 цветов (hex + RGBA) — сценарный слой | -| `apps/game/tools/pixelart/gen.mjs` | Генерация всех спрайтов игры → `apps/game/assets/` (канвас с палитрой игры) | +| `v1/packages/engine/tools/png.mjs` | PNG-кодек без зависимостей: `encodePng`/`decodePng` (RGBA 8 бит) | +| `v1/packages/engine/tools/canvas.mjs` | Canvas (таблица цветов — параметр): set/get пикселя, `fillDiamond` (изо-ромб), `fromAscii`, seeded rng | +| `v1/packages/engine/tools/imaging.mjs` | Кроп по альфе, nearest-масштаб, квантизация в палитру, обзорный лист; `findFigures`/`resizeRect` — фигуры на однотонном фоне | +| `v1/packages/engine/tools/atlas.mjs` | `buildAtlas` — кадры в строку + Pixi Spritesheet JSON (с `animations`) | +| `v1/packages/engine/tools/frames.mjs` | Производные кадры из опорных: shift/bob/squash/swing/mirror, `deriveCycle` | +| `v1/apps/game/tools/pixelart/palette.mjs` | Палитра игры из 32 цветов (hex + RGBA) — сценарный слой | +| `v1/apps/game/tools/pixelart/gen.mjs` | Генерация всех спрайтов игры → `v1/apps/game/assets/` (канвас с палитрой игры) | ## Запуск ```bash -npm run art # node apps/game/tools/pixelart/gen.mjs — перегенерировать все PNG +npm run art # node v1/apps/game/tools/pixelart/gen.mjs — перегенерировать все PNG ``` -Выходные файлы кладутся в `apps/game/assets/` и грузятся в игре по ключам -(`tiles/grass`, `chars/hero_down_1`, ...). Список ключей — в `apps/game/src/Game.ts`. +Выходные файлы кладутся в `v1/apps/game/assets/` и грузятся в игре по ключам +(`tiles/grass`, `chars/hero_down_1`, ...). Список ключей — в `v1/apps/game/src/Game.ts`. ## Палитра и правила - 32 цвета, тёмная холодная база; **тёплые цвета — только живое** (герой, NPC, колокольчики, огонь). Это правило сеттинга «Пепельных лугов» — см. `docs/art-style.md`. - Размеры: тайл 32×16 (изометрия 2:1), персонаж ~16×24, высокое дерево 32×48. - Номиналы — в реестре `apps/game/tools/pixelart/sizes.mjs` (его импортируют + Номиналы — в реестре `v1/apps/game/tools/pixelart/sizes.mjs` (его импортируют `gen.mjs` и линтер `aiart/lint-art.mjs` — новые размеры добавляются в одном месте); рантайм размер кадра читает из атласного JSON (`AssetLoader.frameSize`). - Пиксель перо = 1 виртуальный пиксель; никаких сглаженных краёв — контуры ступенчатые. @@ -57,7 +57,7 @@ Варианты текстур (микс в игре по хэшу координат) — та же функция с другим seed: `grassTile(83)`, `ashTile(41)` и т.д. Палитра одна и та же, различие — рисунок шума, -а не цвета. Все варианты кладутся в `apps/game/assets/tiles/` и перечисляются в +а не цвета. Все варианты кладутся в `v1/apps/game/assets/tiles/` и перечисляются в `Game.ASSET_KEYS` + `LocationScene.tileTextures()`. ## Атласы @@ -115,14 +115,14 @@ Сценарии применения (манифест персонажей, gen/build/promote, примерка `tryon.mjs` — подмена целевого атласа сборкой и скриншот в игре до апрува, ремапер `recolor.mjs`, линтер `lint-art.mjs`) — в игре: -`apps/game/tools/aiart/` +`v1/apps/game/tools/aiart/` (см. `docs/plan.md`, работа B). ## Замена генератора на рисованный арт Движку не важно происхождение текстур. Чтобы перейти на рисованный арт: -1. Рисуете PNG того же размера (или атлас + JSON) в `apps/game/assets/`. +1. Рисуете PNG того же размера (или атлас + JSON) в `v1/apps/game/assets/`. 2. Ключи ассетов не меняются — код игры не трогается. 3. Палитру соблюдаете, чтобы арт не выбивался из сеттинга (`docs/art-style.md`). diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index cb48de8..519957b 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -43,7 +43,7 @@ Смещения над головой персонажа (маркер, полоска HP) считайте от `frameSize`, а не от захардкоженной высоты. Источник номиналов для генераторов — -`apps/game/tools/pixelart/sizes.mjs` (см. [art-pipeline.md](art-pipeline.md)). +`v1/apps/game/tools/pixelart/sizes.mjs` (см. [art-pipeline.md](art-pipeline.md)). ## AudioManager diff --git a/docs/engine/cutscene.md b/docs/engine/cutscene.md index df1c864..f8f3a1e 100644 --- a/docs/engine/cutscene.md +++ b/docs/engine/cutscene.md @@ -1,6 +1,6 @@ # Кат-сцены: CutsceneRunner -`packages/engine/src/cutscene/CutsceneRunner.ts` — view-агностичный раннер кат-сцен. +`v1/packages/engine/src/cutscene/CutsceneRunner.ts` — view-агностичный раннер кат-сцен. Сцена — это список шагов; раннер исполняет их последовательно и сообщает `active`, чтобы игра поставила геймплей на паузу. Никакой привязки к Pixi/вью — только эффекты и камера, передаваемые в шагах. diff --git a/docs/engine/getting-started.md b/docs/engine/getting-started.md index ec194c4..7ebf062 100644 --- a/docs/engine/getting-started.md +++ b/docs/engine/getting-started.md @@ -7,7 +7,7 @@ ``` apps/my-game/ index.html - vite.config.ts # alias @rpg/engine -> ../../packages/engine/src/index.ts + vite.config.ts # alias @rpg/engine -> ../../v1/packages/engine/src/index.ts src/ main.ts # bootstrap Game.ts # общий контекст игры (опционально, но удобно) @@ -26,7 +26,7 @@ alias: [ { find: /^@rpg\/engine$/, - replacement: fileURLToPath(new URL('../../packages/engine/src/index.ts', import.meta.url)) + replacement: fileURLToPath(new URL('../../v1/packages/engine/src/index.ts', import.meta.url)) } ] } diff --git a/docs/engine/inventory.md b/docs/engine/inventory.md index d341899..761c0bf 100644 --- a/docs/engine/inventory.md +++ b/docs/engine/inventory.md @@ -1,9 +1,9 @@ # Инвентарь (модель) -Модуль `packages/engine/src/inventory/Inventory.ts` — жанронезависимый +Модуль `v1/packages/engine/src/inventory/Inventory.ts` — жанронезависимый контейнер предметов. Движок знает только **модель**: стаки, лимиты, события. Реестр предметов (имена, описания) и UI-сцены — на стороне игры -(в этой игре: `apps/game/src/data/items.ts`, `InventoryScene`). +(в этой игре: `v1/apps/game/src/data/items.ts`, `InventoryScene`). ## Модель @@ -77,7 +77,7 @@ ## Юза́бельные предметы (игра) «Применение» предмета — чистый хелпер игры `useItemLine(id, {label}) → string | null` -(`apps/game/src/data/items.ts`): юза́бельные предметы дают строку-результат +(`v1/apps/game/src/data/items.ts`): юза́бельные предметы дают строку-результат (часы → «Время: 8:24»), остальные — null (сумка молчит). `InventoryScene` показывает сумку списком с курсором (`MenuList`), Enter вызывает хелпер и кладёт строку в `PixelText` внизу панели. Новые юза́бельные предметы @@ -85,6 +85,6 @@ ## Тесты -`packages/engine/src/inventory/__tests__/Inventory.test.ts` — add/count, +`v1/packages/engine/src/inventory/__tests__/Inventory.test.ts` — add/count, remove, лимиты (частичное добавление, отказ на новый слот, load-кламп), детали всех 4 kind события, round-trip serialize/load, clear, генерик. \ No newline at end of file diff --git a/docs/engine/practices.md b/docs/engine/practices.md index c37e1f6..c713670 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -9,23 +9,23 @@ 1. `npm run typecheck` — быстрый отсев. 2. `npx vitest run <затронутый тест>` — точечный прогон. -3. `node apps/game/tools/agent.mjs check --pretty` — полный прогон (typecheck + тесты + +3. `node v1/apps/game/tools/agent.mjs check --pretty` — полный прогон (typecheck + тесты + карты + карты-файлы равны генераторам + браузерная проверка инвариантов + - сценарий переходов `apps/game/tools/checks/transitions.mjs`). -4. Смоук акта 1 (`node apps/game/tools/smoke-act1.mjs`) — не сломал ли существующий геймплей. + сценарий переходов `v1/apps/game/tools/checks/transitions.mjs`). +4. Смоук акта 1 (`node v1/apps/game/tools/smoke-act1.mjs`) — не сломал ли существующий геймплей. ## Ситуация: добавляю локацию -1. Генератор в `apps/game/tools/maps/gen.mjs` (детерминированный сид) → `npm run maps`. +1. Генератор в `v1/apps/game/tools/maps/gen.mjs` (детерминированный сид) → `npm run maps`. 2. `AreaId` + `AreaDef` в `data/locations.ts`: spawn/npcs/enemies/transitions. 3. Экспорт через `AREAS`/`areaOf` и загрузка в `BootScene` по `Object.keys(AREAS)`. 4. Валидатор проверит спавн/врагов/переходы автоматически (`agent:invariants`): **инвариант `in-wall` у врага — почти всегда реальный баг данных** (враг поставлен на воду/дерево). Чинить данные, а не валидатор. -5. Сценарий в `apps/game/tools/checks/`: писать через каркас `withChecks` из - `apps/game/tools/lib.mjs` (boot/goToArea/sleepEnemies/expectInvariantsClean — +5. Сценарий в `v1/apps/game/tools/checks/`: писать через каркас `withChecks` из + `v1/apps/game/tools/lib.mjs` (boot/goToArea/sleepEnemies/expectInvariantsClean — готовые помощники; сервер гасится сам). Готовый образец — - `apps/game/tools/checks/transitions.mjs`. Контекст игры — только `t.ctx`: + `v1/apps/game/tools/checks/transitions.mjs`. Контекст игры — только `t.ctx`: деструктуризация до `boot` снимет геттер и даст `null`. ## Ситуация: меняю генераторы арта или карт @@ -48,7 +48,7 @@ 3. `npm run maps` при провале «fresh»-теста чинит файлы, **но сначала прочти провал**: расхождение «тайлов N, ожидалось W×H» = индекс за пределами карты в генераторе (разреженный массив дорос, см. граблю ниже). -4. Проверка: `apps/game/tools/checks/transitions.mjs` — шаг до перехода, клик-переход +4. Проверка: `v1/apps/game/tools/checks/transitions.mjs` — шаг до перехода, клик-переход туда и обратно, снапшот `s.transitions` (триггеры и цели). ## Ситуация: добавляю интерьер / интерактивный объект @@ -64,7 +64,7 @@ реакции по `when` (первая подходящая), `once` поднимает флаг `used:`, эффекты — `setFlags`/`setVar`/`gives`, текст виден в `s.lastToast`. 4. Валидатор сам проверит объекты в границах и переходы; полная проверка — - `apps/game/tools/checks/interact.mjs` (образец: вход → сундук → used → выход → + `v1/apps/game/tools/checks/interact.mjs` (образец: вход → сундук → used → выход → возврат на тайл клика). 5. `waitFor` моста видит **полный** снапшот игры (`s.flags`, `s.inventory`, `s.interactables`) — предикаты над прогрессом пиши против него, а не @@ -135,7 +135,7 @@ перед `press('attack')`. Направление — по экранным компонентам смещения герой→враг (`sx = dx − dy`, `sy = dx + dy`); клавиши движения — экранный вектор (`moveFree` делает `screenToWorld`), так что одиночное направление - даёт верный facing. Хелпер — `fightNearest` в `apps/game/tools/lib.mjs`. + даёт верный facing. Хелпер — `fightNearest` в `v1/apps/game/tools/lib.mjs`. 6. `s.error` в снапшоте — мост поймал исключение, читай текст. ## Ситуация: добавляю флаг / вар / предмет @@ -187,8 +187,8 @@ (typecheck + guard + юнит-тесты, ~30 с). Провал = коммит отменён; пропустить разово — `git commit --no-verify` (осознанно: хук не гоняет браузерные пробы). -2. Полная сетка перед пушем — `node apps/game/tools/agent.mjs check` + - смоук акта (`node apps/game/tools/smoke-act1.mjs`). +2. Полная сетка перед пушем — `node v1/apps/game/tools/agent.mjs check` + + смоук акта (`node v1/apps/game/tools/smoke-act1.mjs`). 3. Если коммит состоит из изменений движка — доки `docs/engine/` в том же коммите (см. «меняю API движка»). @@ -196,7 +196,7 @@ 1. Обнови соответствующий `docs/engine/*.md` и этот файл (если появился новый приём) **в том же коммите**. -2. Экспорт только через `packages/engine/src/index.ts` — под-пути движка +2. Экспорт только через `v1/packages/engine/src/index.ts` — под-пути движка импортировать нельзя. 3. `npm run typecheck` ловит разрывы в обоих пакетах; тесты движка — в Vitest без браузера (Pixi-зависимости — через стабы, см. `GameLoop.timing.test.ts`). @@ -204,20 +204,20 @@ ## Ситуация: меняю границу движок / игра 1. Правила границы проверяет гвард: `npm run guard` - (`apps/game/tools/guards/boundary.mjs`), он же проба `guard:boundary` в + (`v1/apps/game/tools/guards/boundary.mjs`), он же проба `guard:boundary` в `agent check`. Провал = импорт мимо публичного API движка (`@rpg/engine` / `@rpg/engine/assets/*` / `@rpg/engine/tools/*`), относительный импорт, пересекающий границу пакета, или импорт игры из движка. Чинить импорт, а не гвард. 2. Движок не знает ничего об RPG-контенте; если движку нужна точка расширения — интерфейс с инъекцией (образцы: `StorageLike`, `SceneAgent`), а не - импорт из `apps/game`. + импорт из `v1/apps/game`. 3. Чистое ядро гварда (`importSpecs`/`checkBoundary`) живёт в движке - (`packages/engine/tools/boundary.mjs`, тесты рядом в `__tests__/`) — новый + (`v1/packages/engine/tools/boundary.mjs`, тесты рядом в `__tests__/`) — новый класс нарушений добавляй как правило + тест; корни пакетов и публичное API — опции, сценарий запуска конкретного репо — у приложения. -## Ситуация: пишу новый сценарий apps/game/tools/checks/ +## Ситуация: пишу новый сценарий v1/apps/game/tools/checks/ 1. Каркас: `import { startDevServer, openGame, Checks } from '../lib.mjs'` (сценарный слой игры поверх жанронезависимого `@rpg/engine/tools/agent-lib.mjs` — @@ -241,8 +241,8 @@ таймауту — возвращает `{ ok: false }`; в пробах проверяй `ok` явно, иначе сценарий тихо поедет дальше. 7. Новый метод моста добавляется **в трёх местах**: `AgentApi`/`GameAgent` + - `registerAgent` (apps/game/src/agent/GameAgent.ts) и прокси `AgentClient` - (packages/engine/tools/agent-lib.mjs) — у клиента белый список методов, + `registerAgent` (v1/apps/game/src/agent/GameAgent.ts) и прокси `AgentClient` + (v1/packages/engine/tools/agent-lib.mjs) — у клиента белый список методов, без строки там сценарий получит «not a function». Полный цикл сюжета — образец `checks/quest-bells.mjs` (квест «Три цветка» от взятия до эпилога). @@ -261,17 +261,17 @@ или eps 1e-5): генератор при импорте WAV не пишет (CLI-гвард `import.meta.url === pathToFileURL(process.argv[1]).href`). 2. **Файловые лупы (амбиент)**: функция-синтез в - `apps/game/tools/audio/gen.mjs` (примитивы движкового + `v1/apps/game/tools/audio/gen.mjs` (примитивы движкового `@rpg/engine/tools/synth.mjs` — bandNoise, bellPartial, synth, decay, loopify; сиды фиксированы — звук детерминирован) + запись в словарь `AMBIENCE`; `npm run audio` перегенерирует WAV в - `apps/game/assets/audio/ambience/`. AI-лупы (AudioLDM2) — через + `v1/apps/game/assets/audio/ambience/`. AI-лупы (AudioLDM2) — через `npm run aiaudio` (манифест `tools/aiaudio/manifest.json`: gen → post → promote по апруву). Отбор кандидата без слушания — спектро-лист - `packages/engine/tools/spectro.mjs` (`npm run aiaudio view ` → PNG в + `v1/packages/engine/tools/spectro.mjs` (`npm run aiaudio view ` → PNG в /tmp: спектрограмма log-частоты, огибающая RMS, маркер шва лупа) — агент «смотрит» на порывы/события/артефакты, числа `analyze.py` дополняют картину. -3. Ключ лупа — в реестр `apps/game/src/data/audio.ts` (`AMBIENCE_KEYS` / +3. Ключ лупа — в реестр `v1/apps/game/src/data/audio.ts` (`AMBIENCE_KEYS` / `AMBIENCE_LAYER_KEYS`): BootScene префетчит оттуда (спек-ключи не префетчатся — их компилирует движок на месте). 4. Как играть: разовый sfx — `playSfx(audio, key, {volume, rate, pan})` @@ -312,7 +312,7 @@ `ROOM_TONES` ⊆ `AREAS`. 5. На слух в headless не проверить — смотреть фактические запуски: DEV-шпион `window.__gameAudioLog` (кольцо на 24, `t`/`volume`/`pan`) читается из - страницы; образец — `apps/game/tools/checks/audio.mjs`. После действия + страницы; образец — `v1/apps/game/tools/checks/audio.mjs`. После действия опрашивай лог с паузами (`waitAudio`): компиляция спека/декод WAV занимает заметное время, шаг(60) может не хватить. 6. Грабли: `ctx.resume()` без флага `--autoplay-policy=no-user-gesture-required` @@ -332,7 +332,7 @@ от игровых троп, иначе сломаются смоуки акта. 3. Проверка без боёв: `scene:noise`/`scene:damageEnemy`/`scene:teleport` (см. `docs/engine/agent.md`) + состояния из `s.enemies[i].state`. - Пример — `apps/game/tools/checks/ai.mjs`. + Пример — `v1/apps/game/tools/checks/ai.mjs`. 4. Позицию врага в юнит-тестах двигай руками: мозг чистый, сенсор `pos` — это то, что подал тест (см. `EnemyAI.test.ts`). @@ -345,7 +345,7 @@ 2. Карта коллизий агенту — слой `s.collision {width, height, blocked, props}`; точечные вопросы — `scene:walkable {x, y}`, прямая видимость — `scene:raycast {from, to}` (см. `docs/engine/agent.md`). -3. Проба-эталон — `apps/game/tools/checks/collision.mjs`: слой валиден, +3. Проба-эталон — `v1/apps/game/tools/checks/collision.mjs`: слой валиден, walkable/raycast на конкретных тайлах, scene:walk в непроходимый — false и approach к его соседу, телепорт в тело спящего врага (расталкивание). Расширяя коллизии — добавляй шаг туда. @@ -450,8 +450,8 @@ ## Грабли среды (кратко, подробности в CLAUDE.md) - **Раскладка тулз по правилу «знает ли контент игры»**: знает (палитра, - тайлы, локации, сценарии акта) — `apps/game/tools/` (сценарии применения); - нет — `packages/engine/tools/` (png/canvas/imaging/wav/agent-lib/boundary, + тайлы, локации, сценарии акта) — `v1/apps/game/tools/` (сценарии применения); + нет — `v1/packages/engine/tools/` (png/canvas/imaging/wav/agent-lib/boundary, импорт из игры через `@rpg/engine/tools/*` из `exports` движка). Обе — 3 уровня ниже корня: скриптам, спавнящим процессы с путями от корня репо, нужен `ROOT = new URL('../../..', import.meta.url)` (не `'../..'`). diff --git a/docs/engine/recipes.md b/docs/engine/recipes.md index 3b82816..29a35a8 100644 --- a/docs/engine/recipes.md +++ b/docs/engine/recipes.md @@ -1,10 +1,10 @@ # Recipes: «как сделать…» -Готовые решения типовых задач. Примеры взяты из живой игры (`apps/game`). +Готовые решения типовых задач. Примеры взяты из живой игры (`v1/apps/game`). ## Локация с картой и камерой -Смотри `apps/game/src/scenes/LocationScene.ts`. Скелет: +Смотри `v1/apps/game/src/scenes/LocationScene.ts`. Скелет: ```ts export class LocationScene implements Scene { @@ -53,7 +53,7 @@ } ``` -Полная версия с анимацией ходьбы и поворотами — `apps/game/src/systems/PlayerController.ts`. +Полная версия с анимацией ходьбы и поворотами — `v1/apps/game/src/systems/PlayerController.ts`. ## NPC с диалогом и флагами @@ -299,7 +299,7 @@ ## Меню с клавиатурой и мышью -Смотри `apps/game/src/scenes/MenuScene.ts`: MenuList + `advance`/`up`/`down` действия. +Смотри `v1/apps/game/src/scenes/MenuScene.ts`: MenuList + `advance`/`up`/`down` действия. Кнопки обрабатывают мышь сами; клавиатуру двигает сцена. Наведение мыши и фокус клавиатуры совмещены: `Button.focused` подсвечивает как фокус, так и hover. diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index 431c1c2..3cc9f5d 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -2,7 +2,7 @@ ## PixelText (пиксельный шрифт) -Движок поставляет VT323 (OFL, есть кириллица) в `packages/engine/assets/fonts/`. +Движок поставляет VT323 (OFL, есть кириллица) в `v1/packages/engine/assets/fonts/`. Загрузка — через FontFace API, до создания текста: ```ts diff --git a/docs/llms.txt b/docs/llms.txt index b937663..c1c47f8 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,13 +1,13 @@ # Пепельные луга — изометрическая пиксельная RPG -Собственный жанронезависимый движок (`packages/engine`, TypeScript, PixiJS 8 -только как рендер-бэкенд) и игра на нём (`apps/game`). Движок и инструментарий +Собственный жанронезависимый движок (`v1/packages/engine`, TypeScript, PixiJS 8 +только как рендер-бэкенд) и игра на нём (`v1/apps/game`). Движок и инструментарий делаются для работы ИИ-агента: детерминированный шаг, агентный мост (`window.__agent`), runtime-валидация контента, CLI-проверки. Начни с `CLAUDE.md` в корне (команды, архитектура, грабли) и `docs/engine/practices.md` (приёмы по ситуациям). Проверка любого изменения: -`node apps/game/tools/agent.mjs check --pretty`. +`node v1/apps/game/tools/agent.mjs check --pretty`. ## Движок @@ -25,16 +25,16 @@ - [Ассеты, аудио, сейвы](engine/assets-audio-save.md): AssetLoader, атласы, шины аудио, SaveManager. - [Пайплайн арта](engine/art-pipeline.md): генератор пиксель-арта, палитра, атласы. - [Рецепты](engine/recipes.md): короткие «как сделать X». -- [Агентный мост](engine/agent.md): EngineAgent, window.__agent, waitFor, валидация контента, apps/game/tools/agent.mjs. +- [Агентный мост](engine/agent.md): EngineAgent, window.__agent, waitFor, валидация контента, v1/apps/game/tools/agent.mjs. ## Инструменты агента - [Практики](engine/practices.md): живой документ приёмов по ситуациям агента; пополняется в каждом коммите. -- `apps/game/tools/agent.mjs`: CLI check/run/snapshot/screenshot/dev (JSON по умолчанию). -- `packages/engine/tools/`: жанронезависимые тулзы (png/canvas/imaging/wav/spectro, agent-lib, boundary) — импорт через `@rpg/engine/tools/*`; тулзы, знающие контент игры, остаются в `apps/game/tools/` как сценарии применения. spectro.mjs — спектро-лист PNG («взгляд» агента на WAV, гейты AI-аудио). -- `apps/game/tools/checks/*.mjs`: сценарии проверки геймплейных систем через мост. -- `apps/game/tools/dialogues/`: dry-run CLI (`npm run dialogues:dry`) и probe/registries для редактора; визуальный редактор графов — `npm run dialogues` (порт 5299). -- `apps/game/tools/guards/boundary.mjs` (`npm run guard`): сценарий запуска гварда; правила — `packages/engine/tools/boundary.mjs`. +- `v1/apps/game/tools/agent.mjs`: CLI check/run/snapshot/screenshot/dev (JSON по умолчанию). +- `v1/packages/engine/tools/`: жанронезависимые тулзы (png/canvas/imaging/wav/spectro, agent-lib, boundary) — импорт через `@rpg/engine/tools/*`; тулзы, знающие контент игры, остаются в `v1/apps/game/tools/` как сценарии применения. spectro.mjs — спектро-лист PNG («взгляд» агента на WAV, гейты AI-аудио). +- `v1/apps/game/tools/checks/*.mjs`: сценарии проверки геймплейных систем через мост. +- `v1/apps/game/tools/dialogues/`: dry-run CLI (`npm run dialogues:dry`) и probe/registries для редактора; визуальный редактор графов — `npm run dialogues` (порт 5299). +- `v1/apps/game/tools/guards/boundary.mjs` (`npm run guard`): сценарий запуска гварда; правила — `v1/packages/engine/tools/boundary.mjs`. ## Мир и арт diff --git a/docs/plan.md b/docs/plan.md index fae0f1c..c3b814b 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -7,7 +7,7 @@ Дата ревизии: 2026-09-09. **Статус проекта (2026-09-06): техно-демка движка.** Основные силы — движок -(`packages/engine`); игра — витрина и полигон проверки API. Приоритет решений: +(`v1/packages/engine`); игра — витрина и полигон проверки API. Приоритет решений: что двигает движок (API, тулзы, доки, границы), то и делаем; игрового контента ровно столько, сколько нужно демонстрации. @@ -113,7 +113,7 @@ tools/aiart/gen.mjs # вызов модели (seed, промпт из манифеста) → tools/aiart/post.mjs # даунскейл nearest → квантизация palette.mjs → дедуп → tools/aiart/manifest.json # список ассетов: промпт, seed, размер, статус - → apps/game/assets/… # только после ручного апрува (review-каталог) + → v1/apps/game/assets/… # только после ручного апрува (review-каталог) ``` Принципы: манифест-драйвен (как `maps/`); апрув-гейт (человек смотрит обзорный @@ -175,7 +175,7 @@ VAE в q8 даёт шум — `probe-b3/sd_q8_fss_42_broken.png`. Вердикт: путь рабочий для разовых атласов (4 вида ≈ 30 мин/персонажа), но кадры-позы придётся дорабатывать руками; полный прогон — при потребности акта 2. - Пробы: `apps/game/tools/aiart/probe-b3/`. Фолбэк — RD API или ручной арт. + Пробы: `v1/apps/game/tools/aiart/probe-b3/`. Фолбэк — RD API или ручной арт. - **B3-доп (ускорители, 2026-09-06)**: альтернативные пути разгона проверены и **отвергнуты**, остаёмся на fp32-CPU (diffusers, 4 потока): - **iGPU HD 620 (Vulkan/ggml)** — не готовился к прогону осознанно: стек @@ -246,7 +246,7 @@ - **C2 (проба) ✅**: большой дом 2×2 кодом в `gen.mjs` (`tiles/house_big.png`, 64×80, композит: модули щитов + крыша шпал + козырёк + окна) против апскейл-болванок 24→48 из B1 - (`apps/game/tools/aiart/probe-c2.png`). Вывод: **для крупных объектов — + (`v1/apps/game/tools/aiart/probe-c2.png`). Вывод: **для крупных объектов — код-композит в `gen.mjs`**; апскейл даёт мыло с чужой плотностью пикселя и залитым фоном (только черновая болванка), нативно больших размеров локальные модели не умеют. RD API остаётся опцией, если код-композита не хватит. @@ -291,7 +291,7 @@ Стек: `~/.cache/rpg-ai/venv-aiaudio`, torch 2.14.0+cu130, **diffusers 0.35.2 + transformers 4.49.0** (diffusers 0.40 требует transformers 5.x и падает на AudioLDM2: `_update_model_kwargs_for_generation` удалён в 5.x). -- Пайплайн: `apps/game/tools/aiaudio/gen_audioldm2.py` → `post.py` +- Пайплайн: `v1/apps/game/tools/aiaudio/gen_audioldm2.py` → `post.py` (ресэмпл 22050 моно 16-bit, нормализация 0.85, loopify — хвост кроссфейдом в голову за 1 с, проверка стыка `--check`). Сырые клипы — `review/` (в gitignore, перегенерируются по seed). Seed-детерминизм — по-устройству @@ -307,14 +307,14 @@ # gate=approved в манифесте ``` -Манифест — `apps/game/tools/aiaudio/manifest.json` (промпты, сиды, duration, +Манифест — `v1/apps/game/tools/aiaudio/manifest.json` (промпты, сиды, duration, `peak` — цель нормализации, gate, заметки). Принципы aiart: манифест, апрув-гейт (ключ promote проверяется на объявление в `data/audio.ts`), seed-детерминизм, формат игры не меняется (шины master/music/sfx/ambience готовы). Лупы проверяются скриптом (стык начала/конца без щелчка) и метриками `analyze.py` (огибающая/ спектр — отбор при делегированном апруве). SFX остаются на спек-синтезе — промотировать некуда (SFX_KEYS пуст), файлового SFX-пути в движке нет. -«Взгляд» агента на звук: движковая тулза `packages/engine/tools/spectro.mjs` +«Взгляд» агента на звук: движковая тулза `v1/packages/engine/tools/spectro.mjs` (спектрограмма log-частоты + огибающая RMS + маркер шва → PNG; `npm run aiaudio view `) — замена числового отбора визуальной оценкой на гейтах A2/A3. diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index dd26360..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * ESLint (flat config): исходники движка и игры. Логика качества — tsc (типы) - * и гвард границы (imports); ESLint ловит остальное: неиспользуемое, промахи - * промисов, равенства. Тулзы .mjs не линтятся (node-скрипты без типов). - */ -import js from '@eslint/js'; -import tseslint from 'typescript-eslint'; - -export default tseslint.config( - { - ignores: ['**/node_modules/**', '**/dist/**', '**/*.d.ts'] - }, - js.configs.recommended, - ...tseslint.configs.recommended, - { - files: ['packages/engine/src/**/*.ts', 'apps/game/src/**/*.ts', 'apps/game/tools/**/*.ts'], - rules: { - // no-undef в TS ложится на глобали DOM/Vite — типы проверяет tsc. - 'no-undef': 'off', - // Неиспользуемое — ошибка, кроме параметров с ведущим _ (хуки движка). - '@typescript-eslint/no-unused-vars': [ - 'error', - { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' } - ], - 'no-console': 'off', - 'prefer-const': 'error' - } - }, - { - // Node-тулзы (.mjs): глобали Node без типов (tsc их не видит). - files: ['apps/game/tools/**/*.mjs', 'apps/game/tools/**/*.ts'], - languageOptions: { - globals: { - console: 'readonly', - process: 'readonly', - URL: 'readonly', - Buffer: 'readonly', - fetch: 'readonly', - setTimeout: 'readonly', - clearTimeout: 'readonly', - // Страница игры в puppeteer-сценариях (page.evaluate). - window: 'readonly', - performance: 'readonly' - } - } - }, - { - // Сценарии-тулзы: консоль — их интерфейс; пустой catch — «браузер мог умереть». - files: ['apps/game/tools/**/*.ts', 'apps/game/tools/**/*.mjs'], - rules: { - 'no-console': 'off', - 'no-empty': ['error', { allowEmptyCatch: true }] - } - }, - { - // Браузерный скрипт редактора диалогов. - files: ['apps/game/tools/dialogue-editor/**/*.js'], - languageOptions: { - globals: { - console: 'readonly', - document: 'readonly', - window: 'readonly', - fetch: 'readonly', - setTimeout: 'readonly', - setInterval: 'readonly', - clearInterval: 'readonly', - requestAnimationFrame: 'readonly', - localStorage: 'readonly', - history: 'readonly', - Option: 'readonly', - confirm: 'readonly' - } - } - } -); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index e1b7592..0000000 --- a/package-lock.json +++ /dev/null @@ -1,3803 +0,0 @@ -{ - "name": "rpg", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "rpg", - "version": "0.1.0", - "workspaces": [ - "packages/*", - "apps/*" - ], - "devDependencies": { - "@eslint/js": "^10.0.1", - "eslint": "^10.10.0", - "typescript": "^5.6.0", - "typescript-eslint": "^8.69.0", - "vitest": "^2.1.0" - } - }, - "apps/game": { - "name": "@rpg/game", - "version": "0.1.0", - "dependencies": { - "@rpg/engine": "*", - "pixi.js": "^8.19.0" - }, - "devDependencies": { - "puppeteer-core": "^25.10.0", - "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/@cacheable/memory": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", - "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cacheable/utils": "^2.5.0", - "@keyv/bigmap": "^1.3.1", - "hookified": "^1.15.1", - "keyv": "^5.6.0" - } - }, - "node_modules/@cacheable/utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", - "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hashery": "^1.5.1", - "keyv": "^5.6.0" - } - }, - "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/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", - "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "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/@keyv/bigmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", - "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "hashery": "^1.4.0", - "hookified": "^1.15.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "keyv": "^5.6.0" - } - }, - "node_modules/@keyv/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "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/@puppeteer/browsers": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz", - "integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "modern-tar": "^0.8.4", - "yargs": "^18.0.0" - }, - "bin": { - "browsers": "lib/main-cli.js" - }, - "engines": { - "node": ">=22.12.0" - }, - "peerDependencies": { - "proxy-agent": ">=8.0.1", - "yauzl": "^2.10.0 || ^3.4.0" - }, - "peerDependenciesMeta": { - "proxy-agent": { - "optional": true - }, - "yauzl": { - "optional": true - } - } - }, - "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/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "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/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", - "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/type-utils": "8.69.0", - "@typescript-eslint/utils": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.69.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", - "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", - "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", - "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.69.0", - "@typescript-eslint/types": "^8.69.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", - "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", - "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", - "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/utils": "8.69.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", - "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", - "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.69.0", - "@typescript-eslint/tsconfig-utils": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", - "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", - "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.69.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "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/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "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/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "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/cacheable": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", - "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cacheable/memory": "^2.2.0", - "@cacheable/utils": "^2.5.0", - "hookified": "^1.15.0", - "keyv": "^5.6.0", - "qified": "^0.10.1" - } - }, - "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/chromium-bidi": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", - "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "engines": { - "node": ">=20.19.0 <22.0.0 || >=22.12.0" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "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/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/devtools-protocol": { - "version": "0.0.1666840", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", - "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "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/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "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/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", - "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", - "dev": true, - "license": "MIT", - "workspaces": [ - "packages/*" - ], - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.7.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.3", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "11.1.5 || >11.1.6 <12", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "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/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.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/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "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/file-entry-cache": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", - "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^6.1.23" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "6.1.23", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", - "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cacheable": "^2.5.0", - "flatted": "^3.4.2", - "hookified": "^1.15.0" - } - }, - "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", - "dev": true, - "license": "ISC" - }, - "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/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/hashery": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", - "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "hookified": "^1.15.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/hookified": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", - "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "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/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, - "license": "MIT" - }, - "node_modules/modern-tar": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.4.tgz", - "integrity": "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "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/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "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/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/puppeteer-core": { - "version": "25.10.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz", - "integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "3.2.2", - "chromium-bidi": "17.0.2", - "devtools-protocol": "0.0.1666840", - "typed-query-selector": "^2.12.2", - "webdriver-bidi-protocol": "0.4.3", - "ws": "^8.21.3" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/qified": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", - "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hookified": "^2.1.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/qified/node_modules/hookified": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", - "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", - "dev": true, - "license": "MIT" - }, - "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/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "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/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "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/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typed-query-selector": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", - "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", - "dev": true, - "license": "MIT" - }, - "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/typescript-eslint": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", - "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.69.0", - "@typescript-eslint/parser": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/utils": "8.69.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "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/webdriver-bidi-protocol": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", - "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "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" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", - "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^8.2.1", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "packages/engine": { - "name": "@rpg/engine", - "version": "0.1.0", - "dependencies": { - "pixi.js": "^8.19.0" - } - } - } -} diff --git a/package.json b/package.json index d9901db..587bc32 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,28 @@ { - "name": "rpg", + "name": "rpg-monorepo", "version": "0.1.0", "private": true, - "type": "module", - "workspaces": [ - "packages/*", - "apps/*" - ], + "description": "Диспетчер версий движка: v1 — черновик (Pixi, изометрия), v2 — воксельный движок (three.js). Команды делегируются в v1.", "scripts": { - "dev": "npm run dev --workspace @rpg/game", - "build": "npm run build --workspace @rpg/game", - "art": "node apps/game/tools/pixelart/gen.mjs", - "aiart": "node apps/game/tools/aiart/atlas.mjs", - "art:lint": "node apps/game/tools/aiart/lint-art.mjs $(find apps/game/assets -name '*.png')", - "audio": "node apps/game/tools/audio/gen.mjs", - "aiaudio": "node apps/game/tools/aiaudio/index.mjs", - "maps": "RUN_MAPS_GEN=1 vitest run apps/game/tools/maps/gen.test.ts", - "dialogues:dry": "vite-node apps/game/tools/dialogues/dry-run.ts", - "dialogues": "node apps/game/tools/dialogue-editor/server.mjs", - "guard": "node apps/game/tools/guards/boundary.mjs", - "test": "vitest run", - "test:watch": "vitest", - "typecheck": "tsc --noEmit -p packages/engine && tsc --noEmit -p apps/game", - "lint": "eslint packages/engine/src apps/game/src apps/game/tools", - "check:fast": "npm run typecheck && npm run lint && npm run guard && npm test", - "agent": "node apps/game/tools/agent.mjs dev", - "agent:check": "node apps/game/tools/agent.mjs check", - "agent:snapshot": "node apps/game/tools/agent.mjs snapshot --new-game" + "dev": "npm run dev --prefix v1", + "build": "npm run build --prefix v1", + "art": "npm run art --prefix v1", + "aiart": "npm run aiart --prefix v1", + "art:lint": "npm run art:lint --prefix v1", + "audio": "npm run audio --prefix v1", + "aiaudio": "npm run aiaudio --prefix v1", + "maps": "npm run maps --prefix v1", + "dialogues:dry": "npm run dialogues:dry --prefix v1", + "dialogues": "npm run dialogues --prefix v1", + "guard": "npm run guard --prefix v1", + "test": "npm run test --prefix v1", + "test:watch": "npm run test:watch --prefix v1", + "typecheck": "npm run typecheck --prefix v1", + "lint": "npm run lint --prefix v1", + "check:fast": "npm run check:fast --prefix v1", + "agent": "npm run agent --prefix v1", + "agent:check": "npm run agent:check --prefix v1", + "agent:snapshot": "npm run agent:snapshot --prefix v1" }, - "prepare": "git config core.hooksPath .githooks", - "devDependencies": { - "@eslint/js": "^10.0.1", - "eslint": "^10.10.0", - "typescript": "^5.6.0", - "typescript-eslint": "^8.69.0", - "vitest": "^2.1.0" - } -} + "prepare": "git config core.hooksPath .githooks" +} \ No newline at end of file diff --git a/packages/engine/assets/fonts/OFL.txt b/packages/engine/assets/fonts/OFL.txt deleted file mode 100644 index bcb092f..0000000 --- a/packages/engine/assets/fonts/OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/engine/assets/fonts/VT323-Regular.ttf b/packages/engine/assets/fonts/VT323-Regular.ttf deleted file mode 100644 index afa6909..0000000 --- a/packages/engine/assets/fonts/VT323-Regular.ttf +++ /dev/null Binary files differ diff --git a/packages/engine/package.json b/packages/engine/package.json deleted file mode 100644 index 1959fc0..0000000 --- a/packages/engine/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@rpg/engine", - "version": "0.1.0", - "private": true, - "type": "module", - "main": "src/index.ts", - "exports": { - ".": "./src/index.ts", - "./assets/*": "./assets/*", - "./tools/*": "./tools/*" - }, - "dependencies": { - "pixi.js": "^8.19.0" - } -} \ No newline at end of file diff --git a/packages/engine/src/agent/EngineAgent.ts b/packages/engine/src/agent/EngineAgent.ts deleted file mode 100644 index 1fb0776..0000000 --- a/packages/engine/src/agent/EngineAgent.ts +++ /dev/null @@ -1,221 +0,0 @@ -import type { - AgentHost, - EngineSnapshot, - Invariant, - SceneAgent, - SnapshotLayer, - WaitForOptions -} from './types'; -import { mergeInvariants } from './invariants'; - -/** - * Top-level ключи движкового слоя снапшота (см. snapshot()). Игра расширяет - * его своими слоями (AGENT_SNAPSHOT_KEYS) — валидатор предикатов waitFor - * сверяет обращения `s.<ключ>` с объединённым списком. - */ -export const ENGINE_SNAPSHOT_KEYS = [ - 'tick', - 'fps', - 'fixedStep', - 'scenes', - 'transitioning', - 'camera', - 'pointer' -] as const; - -/** - * Движковый агентный мост: снапшоты, детерминированные шаги, инъекция ввода. - * Никогда не бросает: каждый доступ — через safe() с try/catch, ошибка - * становится слоем снапшота `{ error }`. - */ -export class EngineAgent { - constructor( - private host: AgentHost, - /** Фабрика сцены-агента: сцена может отсутствовать (меню, переход). */ - private sceneAgent: () => SceneAgent | null - ) {} - - /** Полный снапшот: движковый слой + контентный слой текущей сцены. */ - snapshot(): SnapshotLayer { - const base = this.safe('snapshot', (): EngineSnapshot => { - const h = this.host; - return { - tick: h.tickCount, - fps: h.fps, - fixedStep: h.fixedStep, - scenes: [h.scenes.current?.constructor.name ?? null].filter( - (s): s is string => s !== null - ), - transitioning: h.scenes.transitioning, - camera: { - x: num(h.camera.x), - y: num(h.camera.y), - shaking: h.camera.shaking - }, - pointer: this.pointerLayer() - }; - }) as SnapshotLayer; - const scene = this.sceneAgent(); - if (scene) { - const layer = this.safe('scene.snapshot', () => scene.agentSnapshot()); - if (layer) Object.assign(base, layer); - } - return base; - } - - /** Нарушенные инварианты: движковые + сценические. */ - invariants(): Invariant[] { - const engine: Invariant[] = this.safe('invariants', () => { - const cam = this.host.camera; - return mergeInvariants( - checkNum('camera.x', cam.x, 'engine/camera'), - checkNum('camera.y', cam.y, 'engine/camera'), - this.pointerInvariants() - ); - }) ?? []; - const scene = this.sceneAgent(); - const sceneInvs = scene - ? (this.safe('scene.invariants', () => scene.agentInvariants()) ?? []) - : []; - return [...engine, ...(Array.isArray(sceneInvs) ? sceneInvs : [])]; - } - - /** n фиксированных шагов без реального ожидания (детерминированно). */ - step(n = 1, opts?: { render?: boolean }): { tick: number } { - const render = opts?.render ?? false; - // Серия целиком в ручном режиме: реальный rAF-цикл между шагами не - // тикает и не очищает «just pressed» инъекции середины серии. - try { - this.host.stepTicks(n, render); - } catch { - return { tick: this.host.tickCount }; - } - return { tick: this.host.tickCount }; - } - - /** - * Крутить шаги, пока предикат не станет истинным (или лимит шагов). - * pred вызывается после каждого шага на полном снапшоте. - */ - async waitFor( - pred: (s: SnapshotLayer) => boolean, - opts?: WaitForOptions - ): Promise<{ ok: boolean; snapshot: SnapshotLayer; ticks: number }> { - const limit = opts?.timeoutTicks ?? 600; - const render = opts?.render ?? false; - for (let i = 0; i < limit; i++) { - this.step(1, { render }); - let s: SnapshotLayer; - try { - s = this.snapshot(); - } catch { - continue; - } - let ok = false; - try { - ok = pred(s); - } catch { - // предикат упал — трактуем как «не совпало» - } - if (ok) return { ok: true, snapshot: s, ticks: i + 1 }; - } - return { ok: false, snapshot: this.snapshot(), ticks: limit }; - } - - /** Ввод в виртуальных пикселях: эквивалент pointerdown/up на канвасе. */ - /** - * Клик в виртуальных пикселях. Инъекция и один шаг логики — атомарно - * (один JS-тик): между evaluate rAF-кадр успел бы очистить «just pressed». - */ - tapVirtual(vx: number, vy: number): void { - this.safe('tapVirtual', () => { - this.host.input.injectPointerDown(vx, vy); - this.host.input.injectPointerUp(vx, vy); - this.host.stepTick(); - }); - } - - /** Действие через маппинг действий (isActionJustPressed внутри этого же тика). */ - press(action: string, holdTicks = 1): void { - this.safe('press', () => { - this.host.input.injectAction(action); - this.host.stepTick(); // действие видно сцене в этом же тике - for (let i = 1; i < holdTicks; i++) this.host.stepTick(); - this.host.input.injectActionRelease(action); - }); - } - - /** Сырой код клавиши (e.code) — атомарно с одним шагом логики. */ - key(code: string): void { - this.safe('key', () => { - this.host.input.injectKeyCode(code); - this.host.stepTick(); - this.host.input.injectKeyCodeUp(code); - }); - } - - /** - * Реальный DOM-клик по канвасу (виртуальные px → CSS): нужен для Pixi-кнопок, - * если сцена слушает DOM напрямую. В геймплее предпочитай действия (press). - */ - uiTap(vx: number, vy: number): void { - this.safe('uiTap', () => { - const canvas = document.querySelector('canvas'); - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const sx = rect.left + (vx / this.host.virtualWidth) * rect.width; - const sy = rect.top + (vy / this.host.virtualHeight) * rect.height; - canvas.dispatchEvent(new MouseEvent('pointerdown', { - clientX: sx, clientY: sy, bubbles: true - })); - canvas.dispatchEvent(new MouseEvent('pointerup', { - clientX: sx, clientY: sy, bubbles: true - })); - }); - } - - /** Команда сцены (whitelist в реализации SceneAgent). */ - command(name: string, args?: unknown): unknown { - const scene = this.sceneAgent(); - if (!scene?.agentCommand) return null; - return this.safe('command', () => scene.agentCommand!(name, args as never)) ?? null; - } - - // --- внутреннее --- - - private pointerLayer(): SnapshotLayer { - const p = this.host.input.getPointer(); - return { - x: num(p.x), y: num(p.y), down: p.down, - justPressed: p.justPressed, downTicks: p.downTicks - }; - } - - private pointerInvariants(): Invariant[] { - const p = this.host.input.getPointer(); - return mergeInvariants( - checkNum('pointer.x', p.x, 'engine/pointer'), - checkNum('pointer.y', p.y, 'engine/pointer') - ); - } - - /** Выполнить fn, поймав исключение: ошибка -> слой { error }, а не бросок. */ - private safe(label: string, fn: () => T): T | null { - try { - return fn(); - } catch (e) { - return { error: `${label}: ${e instanceof Error ? e.message : String(e)}` } as unknown as T; - } - } -} - -function num(v: number): number { - return Number.isFinite(v) ? Math.round(v * 1000) / 1000 : String(v) as unknown as number; -} - -function checkNum(name: string, v: number, where: string): Invariant | null { - if (!Number.isFinite(v)) { - return { id: 'pos-nan', severity: 'error', message: `${name} = ${v}`, where }; - } - return null; -} \ No newline at end of file diff --git a/packages/engine/src/agent/__tests__/EngineAgent.test.ts b/packages/engine/src/agent/__tests__/EngineAgent.test.ts deleted file mode 100644 index 589b706..0000000 --- a/packages/engine/src/agent/__tests__/EngineAgent.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Camera } from '../../render/Camera'; -import { EventBus } from '../../core/EventBus'; -import { EngineAgent } from '../EngineAgent'; -import type { AgentHost, SceneAgent, SnapshotLayer } from '../types'; - -/** - * Фейковый хост: без Pixi/канваса. stepTick двигает «время» и вызывает - * sceneAgent; renderFrame только помечается. - */ -function fakeHost(): AgentHost & { renders: number } { - let ticks = 0; - const events = new EventBus(); - return { - events, - scenes: { current: undefined, transitioning: false } as never, - input: { - getPointer: () => ({ x: 0, y: 0, down: false, justPressed: false, downTicks: 0 }) - } as never, - camera: new Camera(480, 270), - virtualWidth: 480, - virtualHeight: 270, - fixedStep: 1 / 60, - fps: 0, - get tickCount() { - return ticks; - }, - stepTick() { - return ++ticks; - }, - stepTicks(n = 1) { - for (let i = 0; i < n; i++) this.stepTick(); - return ticks; - }, - renderFrame() { - this.renders++; - }, - renders: 0 - }; -} - -function fakeScene(): SceneAgent { - return { - agentSnapshot: () => ({ area: 'test', hp: 5 }), - agentInvariants: () => [{ id: 'warn-x', severity: 'warn', message: 'подозрительно' }], - agentCommand: (name) => (name === 'ping' ? 'pong' : null) - }; -} - -describe('EngineAgent — снапшот и шаги', () => { - it('снапшот содержит движковый слой + слой сцены', () => { - const host = fakeHost(); - const agent = new EngineAgent(host, () => fakeScene()); - const s = agent.snapshot() as SnapshotLayer; - expect(s.tick).toBe(0); - expect(s.fixedStep).toBeCloseTo(1 / 60, 6); - expect(s.transitioning).toBe(false); - expect(s.camera).toBeTruthy(); - expect(s.pointer).toBeTruthy(); - expect(s.area).toBe('test'); - expect(s.hp).toBe(5); - }); - - it('без сцены снапшот всё равно валиден', () => { - const agent = new EngineAgent(fakeHost(), () => null); - const s = agent.snapshot(); - expect(s.tick).toBe(0); - expect(s.area).toBeUndefined(); - }); -}); - -describe('EngineAgent — инварианты', () => { - it('мержит движковые и сценические инварианты', () => { - const host = fakeHost(); - host.camera.x = NaN; // движковый инвариант pos-nan - const agent = new EngineAgent(host, () => fakeScene()); - const invs = agent.invariants(); - expect(invs.map((i) => i.id)).toEqual(['pos-nan', 'warn-x']); - }); -}); - -describe('EngineAgent — шаги и команды', () => { - it('step крутит ровно n тиков', () => { - const host = fakeHost(); - const agent = new EngineAgent(host, () => fakeScene()); - expect(agent.step(10).tick).toBe(10); - expect(host.tickCount).toBe(10); - }); - - it('waitFor завершается по предикату и по лимиту', async () => { - const host = fakeHost(); - const agent = new EngineAgent(host, () => fakeScene()); - const ok = await agent.waitFor((s) => (s.tick as number) >= 5, { timeoutTicks: 100 }); - expect(ok.ok).toBe(true); - expect(ok.ticks).toBe(5); - const fail = await agent.waitFor(() => false, { timeoutTicks: 3 }); - expect(fail.ok).toBe(false); - expect(fail.ticks).toBe(3); - }); - - it('command проходит в сцену, неизвестная — null', () => { - const agent = new EngineAgent(fakeHost(), () => fakeScene()); - expect(agent.command('ping')).toBe('pong'); - expect(agent.command('unknown')).toBeNull(); - }); - - it('ошибка в сцене не вылетает, а попадает в слой { error }', () => { - const bad: SceneAgent = { - agentSnapshot: () => { - throw new Error('бум'); - }, - agentInvariants: () => { - throw new Error('бум'); - } - }; - const agent = new EngineAgent(fakeHost(), () => bad); - const s = agent.snapshot() as SnapshotLayer; - expect(String(s.error)).toContain('бум'); - expect(agent.invariants()).toEqual([]); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/agent/__tests__/invariants.test.ts b/packages/engine/src/agent/__tests__/invariants.test.ts deleted file mode 100644 index 12fa1d4..0000000 --- a/packages/engine/src/agent/__tests__/invariants.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - checkBounds, - checkFinite, - checkRange, - checkWalkable, - mergeInvariants -} from '../invariants'; - -const where = 'test'; - -describe('инварианты — хелперы', () => { - it('checkFinite находит NaN и Infinity', () => { - const invs = checkFinite({ x: 1.5, y: NaN, z: Infinity }, where); - expect(invs).toHaveLength(2); - expect(invs.every((i) => i.id === 'pos-nan' && i.severity === 'error')).toBe(true); - expect(checkFinite({ ok: 0 }, where)).toHaveLength(0); - }); - - it('checkRange ловит выход за диапазон и NaN', () => { - expect(checkRange('hp', 3, 0, 5, where)).toBeNull(); - expect(checkRange('hp', -1, 0, 5, where)?.id).toBe('range'); - expect(checkRange('hp', NaN, 0, 5, where)?.id).toBe('pos-nan'); - }); - - it('checkBounds и checkWalkable работают по тайлу', () => { - const map = { isWalkable: (x: number, y: number) => x + y > 0 }; - expect(checkBounds('герой', { x: 2, y: 3 }, 10, 10, where)).toBeNull(); - expect(checkBounds('герой', { x: -1, y: 3 }, 10, 10, where)?.id).toBe('out-of-bounds'); - expect(checkWalkable('герой', { x: 1, y: 1 }, map, where)).toBeNull(); - expect(checkWalkable('герой', { x: 0, y: 0 }, map, where)?.id).toBe('in-wall'); - }); - - it('mergeInvariants разворачивает группы и отбрасывает пустое', () => { - const a = { id: 'a', severity: 'warn', message: 'a' } as const; - const b = { id: 'b', severity: 'error', message: 'b' } as const; - expect(mergeInvariants(null, false, undefined, a, [b as never], null)).toEqual([a, b]); - expect(mergeInvariants()).toEqual([]); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/agent/invariants.ts b/packages/engine/src/agent/invariants.ts deleted file mode 100644 index c463ccf..0000000 --- a/packages/engine/src/agent/invariants.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { Invariant } from './types'; - -/** - * Чистые хелперы инвариантов: сцены и системы собирают нарушения через них, - * мост мержит и отдаёт списком. Без зависимостей — тестируется в Vitest. - */ - -function inv(id: string, severity: 'error' | 'warn', message: string, where?: string): Invariant { - return where ? { id, severity, message, where } : { id, severity, message }; -} - -/** NaN/Infinity в числовых значениях (позиции, hp и т.п.) — почти всегда сломанный мир. */ -export function checkFinite(values: Record, where: string): Invariant[] { - const out: Invariant[] = []; - for (const [name, v] of Object.entries(values)) { - if (!Number.isFinite(v)) { - out.push(inv('pos-nan', 'error', `${name} = ${v} (не конечное число)`, where)); - } - } - return out; -} - -/** Число вне диапазона (hp < 0, скорость < 0, ...). */ -export function checkRange( - name: string, - v: number, - min: number, - max: number, - where: string -): Invariant | null { - if (!Number.isFinite(v)) { - return inv('pos-nan', 'error', `${name} = ${v} (не конечное число)`, where); - } - if (v < min || v > max) { - return inv('range', 'error', `${name} = ${v} вне [${min}, ${max}]`, where); - } - return null; -} - -/** Тайл в границах карты. */ -export function checkBounds( - name: string, - tile: { x: number; y: number }, - w: number, - h: number, - where: string -): Invariant | null { - if (!Number.isInteger(tile.x) || !Number.isInteger(tile.y) || tile.x < 0 || tile.y < 0 - || tile.x >= w || tile.y >= h) { - return inv('out-of-bounds', 'error', `${name}: тайл (${tile.x},${tile.y}) вне карты ${w}×${h}`, where); - } - return null; -} - -/** Тайл проходим (герой/NPC не должны стоять в стене). */ -export function checkWalkable( - name: string, - tile: { x: number; y: number }, - map: { isWalkable(x: number, y: number): boolean }, - where: string -): Invariant | null { - if (!map.isWalkable(tile.x, tile.y)) { - return inv('in-wall', 'error', `${name} стоит в непроходимом тайле (${tile.x},${tile.y})`, where); - } - return null; -} - -/** Мерж групп: null/false/undefined отбрасываются, группы — разворачиваются. */ -export function mergeInvariants( - ...groups: (Invariant | null | undefined | false | Invariant[])[] -): Invariant[] { - const out: Invariant[] = []; - for (const g of groups) { - if (!g) continue; - if (Array.isArray(g)) out.push(...g); - else out.push(g); - } - return out; -} \ No newline at end of file diff --git a/packages/engine/src/agent/types.ts b/packages/engine/src/agent/types.ts deleted file mode 100644 index 1834f2d..0000000 --- a/packages/engine/src/agent/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Camera } from '../render/Camera'; -import type { EventBus } from '../core/EventBus'; -import type { InputManager } from '../input/InputManager'; -import type { SceneManager } from '../scene/SceneManager'; - -/** - * Агентный мост (движковый каркас): машиночитаемый доступ к игре — - * снапшоты состояния, детерминированные шаги, инъекция ввода, инварианты. - * Игра строит контентный слой поверх каркаса (см. docs/engine/agent.md). - * Всё, что делает мост, — никогда не бросает исключений: ошибка превращается - * в слой снапшота `{ error: string }`, а не в потерю телеметрии. - */ - -export type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue }; -export type SnapshotLayer = { [k: string]: JsonValue }; - -/** Нарушенный инвариант: error — «мир сломан», warn — «подозрительно». */ -export interface Invariant { - /** Стабильный id: 'pos-nan', 'hero-in-wall', ... */ - id: string; - severity: 'error' | 'warn'; - message: string; - /** Где обнаружено: 'scene/LocationScene', 'engine/camera', ... */ - where?: string; -} - -/** - * Что сцена отдаёт мосту. Реализуется игровыми сценами; движок вызывает - * методы через фабрику `() => SceneAgent | null` (сцена может быть не готова). - */ -export interface SceneAgent { - /** Контентный слой снапшота этой сцены. */ - agentSnapshot(): SnapshotLayer; - /** Инварианты сцены (мост мержит со своими и движковыми). */ - agentInvariants(): Invariant[]; - /** Произвольные команды (перемотки/читы для проверок). null — команда неизвестна. */ - agentCommand?(name: string, args?: JsonValue): JsonValue; -} - -/** Минимальный хост, который нужен мосту (удовлетворяется Engine). */ -export interface AgentHost { - readonly scenes: SceneManager; - readonly input: InputManager; - readonly camera: Camera; - readonly events: EventBus; - /** Виртуальное разрешение (для пересчёта в CSS-пиксели в uiTap). */ - readonly virtualWidth: number; - readonly virtualHeight: number; - /** Один фиксированный шаг (без рендера); возвращает счётчик тиков. */ - stepTick(): number; - /** Серия шагов в ручном режиме (setManual — без «догоняния» rAF). */ - stepTicks(n?: number, render?: boolean): number; - renderFrame(): void; - readonly tickCount: number; - readonly fixedStep: number; - /** Реальный FPS отрисовки (окно ~0,5 с). */ - readonly fps: number; -} - -/** Движковый слой снапшота. */ -export interface EngineSnapshot extends SnapshotLayer { - tick: number; - fps: number; - fixedStep: number; - scenes: string[]; - transitioning: boolean; - camera: SnapshotLayer; - pointer: SnapshotLayer; -} - -export interface WaitForOptions { - /** Лимит шагов (по умолчанию 600 — 10 с игрового времени). */ - timeoutTicks?: number; - /** Рендерить ли каждый шаг (нужно только перед скриншотом). */ - render?: boolean; -} \ No newline at end of file diff --git a/packages/engine/src/anim/SpriteAnimator.ts b/packages/engine/src/anim/SpriteAnimator.ts deleted file mode 100644 index 41e85c2..0000000 --- a/packages/engine/src/anim/SpriteAnimator.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Кадровый аниматор спрайта с именованными клипами: play/pause, режимы - * loop/once/pingpong, события onFrame/onFinish, рассинхрон толпы через - * randomPhase (seed). Тонкий Pixi-адаптер: математика — в anim/clip.ts, - * текстура пишется только при смене шага. Тикается вручную либо через - * engine.fx (реестр Updatables). - */ -import type { Sprite, Texture } from 'pixi.js'; -import type { Updatable } from '../core/Updatables'; -import { createRng } from '../math/rng'; -import { stepClip, type ClipLoop } from './clip'; - -export interface SpriteClip { - frames: readonly Texture[]; - /** Кадров в секунду; без значения — дефолт аниматора. */ - fps?: number; - loop?: ClipLoop; - speedScale?: number; -} - -export interface SpriteAnimatorOptions { - /** Дефолт fps для клипов без своего (8, как у FrameAnimation). */ - fps?: number; - /** Дефолтный режим для клипов без своего. */ - loop?: ClipLoop; - speedScale?: number; - /** Seed: детерминированная случайная фаза старта (рассинхрон толпы). */ - randomPhase?: number; - /** Шаг, который показывать до первого play(). */ - startStep?: number; -} - -export class SpriteAnimator implements Updatable { - readonly sprite: Sprite; - /** Вызывается при смене шага. */ - onFrame?: (step: number) => void; - /** Вызывается один раз при finished у once/pingpong-прохода. */ - onFinish?: (clip: string) => void; - - /** Самозачистка из engine.fx: спрайт разрушен — реестр отпустит запись. */ - get destroyed(): boolean { - return this.sprite.destroyed; - } - - private clips: Record; - private defaults: Required>; - private speedScale_: number; - private currentName: string | null = null; - private time = 0; - private step_ = 0; - private paused_ = false; - private finishedNotified = false; - /** Случайная фаза старта в долях шага [0,1) — из seed (без Math.random). */ - private phase = 0; - - constructor( - sprite: Sprite, - clips: Record, - current?: string, - opts: SpriteAnimatorOptions = {} - ) { - this.sprite = sprite; - this.clips = clips; - this.defaults = { fps: opts.fps ?? 8, loop: opts.loop ?? 'loop' }; - this.speedScale_ = opts.speedScale ?? 1; - if (opts.randomPhase !== undefined) this.applyRandomPhase(opts.randomPhase); - if (current !== undefined) { - this.play(current, { restart: true }); - } else { - // Стартовый шаг первого клипа — просто отрисовать, не запуская время. - const first = Object.keys(clips)[0]; - if (first !== undefined) { - this.currentName = first; - this.showStep(opts.startStep ?? 0); - } - } - } - - /** Включить клип; тот же клип без restart продолжается с текущего шага. */ - play(name: string, opts: { restart?: boolean } = {}): void { - const clip = this.clips[name]; - if (!clip) throw new Error(`клип «${name}» не найден`); - if (name !== this.currentName || opts.restart) { - this.currentName = name; - this.time = this.phase; - this.finishedNotified = false; - this.setStep(this.currentName, 0); - } - } - - pause(): void { - this.paused_ = true; - } - - resume(): void { - this.paused_ = false; - } - - get paused(): boolean { - return this.paused_; - } - - get clip(): string | null { - return this.currentName; - } - - get step(): number { - return this.step_; - } - - get speedScale(): number { - return this.speedScale_; - } - - set speedScale(v: number) { - this.speedScale_ = v; - } - - /** Заморозить на конкретном шаге текущего клипа (idle-поза без клипа). */ - showStep(step: number): void { - this.paused_ = true; - this.setStep(this.currentName, step); - } - - /** Заменить кадры клипа на лету (пересборка атласа, вариации окраса). */ - setClipFrames(name: string, frames: readonly Texture[]): void { - const clip = this.clips[name]; - if (!clip) throw new Error(`клип «${name}» не найден`); - clip.frames = frames; - if (name === this.currentName) this.setStep(name, Math.min(this.step_, Math.max(0, frames.length - 1))); - } - - update(dt: number): void { - if (this.paused_ || this.currentName === null) return; - const name = this.currentName; - const clip = this.clips[name]; - if (clip.frames.length === 0) return; - const def = { fps: clip.fps ?? this.defaults.fps, loop: clip.loop ?? this.defaults.loop, speedScale: clip.speedScale }; - const { time, tick } = stepClip(clip.frames.length, def, this.time, dt * this.speedScale_); - this.time = time; - if (tick.step !== this.step_) this.setStep(name, tick.step); - if (tick.finished && !this.finishedNotified) { - this.finishedNotified = true; - this.onFinish?.(name); - } - // pingpong/once с конечным временем: после конца время не двигается. - if (tick.finished && def.loop === 'once') this.paused_ = true; - } - - /** Случайная фаза из seed: доля периода клипа, детерминированная. */ - private applyRandomPhase(seed: number): void { - // Единый ГПСЧ движка (mulberry32): [0,1) из целого seed без Math.random. - this.phase = createRng(seed | 0 || 0x9e3779b9).next(); - } - - private setStep(name: string | null, step: number): void { - if (name === null) return; - const frames = this.clips[name].frames; - this.step_ = step; - const tex = frames[step]; - if (tex && this.sprite.texture !== tex) this.sprite.texture = tex; - this.onFrame?.(step); - } -} \ No newline at end of file diff --git a/packages/engine/src/anim/SpriteMotion.ts b/packages/engine/src/anim/SpriteMotion.ts deleted file mode 100644 index eaa5b8d..0000000 --- a/packages/engine/src/anim/SpriteMotion.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Pixi-адаптер оживителей: применяет выборку sampleMotion к view (position - * относительно базовой при bob, rotation при sway, alpha/scale при - * pulse/blink). Базы снимаются в конструкторе; позиция/поворот - * перезаписываются только когда есть bob/sway — view с чистым blink/pulse - * можно двигать извне. Тикается через engine.fx. - */ -import type { Container } from 'pixi.js'; -import type { Updatable } from '../core/Updatables'; -import { createRng } from '../math/rng'; -import { sampleMotion, type MotionParams } from './motion'; - -export interface SpriteMotionOptions { - /** Seed — детерминированная случайная фаза (рассинхрон кустов/искр). */ - seed?: number; -} - -export class SpriteMotion implements Updatable { - /** Живые параметры: меняйте на лету (колокол качается только при звоне). */ - params: MotionParams; - - private readonly view: Container; - private readonly baseX: number; - private readonly baseY: number; - private readonly baseAlpha: number; - private readonly baseScaleX: number; - private readonly baseScaleY: number; - private t = 0; - - constructor(view: Container, params: MotionParams, opts: SpriteMotionOptions = {}) { - this.view = view; - this.params = params; - this.baseX = view.x; - this.baseY = view.y; - this.baseAlpha = view.alpha; - this.baseScaleX = view.scale.x; - this.baseScaleY = view.scale.y; - if (opts.seed !== undefined) { - // Фаза из seed: доля периода, детерминированная (без Math.random). - const rng = createRng(opts.seed); - const phase = (rng.int(0, 1000) / 1000); - this.t = phase * (params.bob?.period ?? params.sway?.period ?? params.pulse?.period ?? params.blink?.period ?? 1); - } - } - - update(dt: number): void { - this.t += dt; - const s = sampleMotion(this.params, this.t); - // Позицию/поворот трогаем только если они «живые» (bob/sway): view с - // чистым blink/pulse имеет право двигаться извне (герой, враги). - if (this.params.bob) { - this.view.x = this.baseX; - this.view.y = this.baseY + s.y; - } - if (this.params.sway) this.view.rotation = s.angle; - if (this.params.blink || this.params.pulse) this.view.alpha = this.baseAlpha * s.alpha; - if (this.params.pulse) this.view.scale.set(this.baseScaleX * s.scale, this.baseScaleY * s.scale); - } - - /** Вернуть исходные position/rotation/alpha/scale (при выходе из сцены). */ - detach(): void { - if (this.params.bob) { - this.view.x = this.baseX; - this.view.y = this.baseY; - } - if (this.params.sway) this.view.rotation = 0; - if (this.params.blink || this.params.pulse) this.view.alpha = this.baseAlpha; - if (this.params.pulse) this.view.scale.set(this.baseScaleX, this.baseScaleY); - } - - /** Самозачистка из engine.fx: view разрушен — реестр отпустит запись. */ - get destroyed(): boolean { - return this.view.destroyed; - } -} \ No newline at end of file diff --git a/packages/engine/src/anim/__tests__/SpriteAnimator.test.ts b/packages/engine/src/anim/__tests__/SpriteAnimator.test.ts deleted file mode 100644 index 066658b..0000000 --- a/packages/engine/src/anim/__tests__/SpriteAnimator.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -// SpriteAnimator пишет texture в Sprite — минимальный стаб. -function makeSprite() { - return { texture: null as unknown, destroyed: false } as unknown as import('pixi.js').Sprite; -} - -function frames(n: number): import('pixi.js').Texture[] { - return Array.from({ length: n }, (_, i) => ({ idx: i }) as unknown as import('pixi.js').Texture); -} - -import { SpriteAnimator } from '../SpriteAnimator'; - -describe('SpriteAnimator', () => { - it('конструктор без клипа: показывает стартовый шаг, время стоит', () => { - const sprite = makeSprite(); - const a = new SpriteAnimator(sprite, { idle: { frames: frames(4) } }, undefined, { startStep: 2 }); - expect(a.clip).toBe('idle'); - expect(a.step).toBe(2); - expect(a.paused).toBe(true); - a.update(1); - expect(a.step).toBe(2); // заморожен - }); - - it('loop: шаги идут по fps, по кругу', () => { - const sprite = makeSprite(); - const a = new SpriteAnimator(sprite, { walk: { frames: frames(3), fps: 10 } }, 'walk'); - expect(a.step).toBe(0); - a.update(0.1); // 1 шаг при 10 fps - expect(a.step).toBe(1); - a.update(0.2); // ещё 2 — на конец - expect(a.step).toBe(0); // 3 % 3 - }); - - it('тот же клип без restart продолжается; restart начинает с нуля', () => { - const sprite = makeSprite(); - const a = new SpriteAnimator(sprite, { walk: { frames: frames(4), fps: 10 } }, 'walk'); - a.update(0.15); - expect(a.step).toBe(1); - a.play('walk'); - expect(a.step).toBe(1); // продолжение - a.play('walk', { restart: true }); - expect(a.step).toBe(0); - }); - - it('once: доигрывает до последнего шага, paused, onFinish один раз', () => { - const sprite = makeSprite(); - const finished: string[] = []; - const a = new SpriteAnimator(sprite, { hit: { frames: frames(3), fps: 10, loop: 'once' } }, 'hit'); - a.onFinish = (c) => finished.push(c); - for (let i = 0; i < 10; i++) a.update(0.1); // 1 с — клип 0.3 с - expect(a.step).toBe(2); - expect(a.paused).toBe(true); - expect(finished).toEqual(['hit']); - }); - - it('большой dt проматывает промежуточные шаги (сообщается финальный)', () => { - const sprite = makeSprite(); - const seen: number[] = []; - const a = new SpriteAnimator(sprite, { walk: { frames: frames(8), fps: 10 } }, 'walk'); - a.onFrame = (s) => seen.push(s); - a.update(1); // 10 шагов за тик - expect(a.step).toBe(2); // 10 % 8 - expect(seen).toEqual([2]); // промежуточные не долетели - }); - - it('randomPhase: одинаковый seed — одинаковая фаза (шаги совпадают)', () => { - const mk = (seed: number) => - new SpriteAnimator(makeSprite(), { walk: { frames: frames(3) } }, undefined, { randomPhase: seed }); - const a = mk(1234); - const b = mk(1234); - // Фаза приватна, но детерминизм виден через play + апдейты: - // time = phase, поэтому при одинаковых апдейтах шаги совпадают. - a.play('walk'); - b.play('walk'); - a.update(0.05); - b.update(0.05); - expect(a.step).toBe(b.step); - a.update(0.3); - b.update(0.3); - expect(a.step).toBe(b.step); - }); - - it('setClipFrames: кадры меняются на лету, шаг клэмпится', () => { - const sprite = makeSprite(); - const a = new SpriteAnimator(sprite, { walk: { frames: frames(4), fps: 10 } }, 'walk'); - a.update(0.3); // шаг 3 - expect(a.step).toBe(3); - a.setClipFrames('walk', frames(2)); - expect(a.step).toBe(1); // клэмп к 2-1 - }); - - it('несуществующий клип — ошибка', () => { - const a = new SpriteAnimator(makeSprite(), { walk: { frames: frames(2) } }); - expect(() => a.play('fly')).toThrow('fly'); - }); - - it('destroyed спрайта пробрасывается (самозачистка из engine.fx)', () => { - const sprite = makeSprite(); - const a = new SpriteAnimator(sprite, { walk: { frames: frames(2) } }); - expect(a.destroyed).toBe(false); - sprite.destroyed = true; - expect(a.destroyed).toBe(true); - }); - - it('speedScale замедляет/ускоряет клип', () => { - const a = new SpriteAnimator(makeSprite(), { walk: { frames: frames(10), fps: 10 } }, 'walk', { speedScale: 0.5 }); - a.update(0.1); - expect(a.step).toBe(0); // полшага — не дошёл - a.update(0.1); - expect(a.step).toBe(1); - }); - - it('пустой набор клипов допустим (без падений)', () => { - const a = new SpriteAnimator(makeSprite(), {}); - expect(a.clip).toBeNull(); - expect(() => a.update(0.1)).not.toThrow(); - }); - - it('onFrame получает номер шага', () => { - const onFrame = vi.fn(); - const a = new SpriteAnimator(makeSprite(), { walk: { frames: frames(2), fps: 10 } }, 'walk'); - a.onFrame = onFrame; - a.update(0.1); - expect(onFrame).toHaveBeenCalledWith(1); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/anim/__tests__/SpriteMotion.test.ts b/packages/engine/src/anim/__tests__/SpriteMotion.test.ts deleted file mode 100644 index 20a89ba..0000000 --- a/packages/engine/src/anim/__tests__/SpriteMotion.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { SpriteMotion } from '../SpriteMotion'; - -/** Минимальный стенд под интерфейс Container, который трогает SpriteMotion. */ -function fakeView(x = 10, y = 20) { - const v = { - x, - y, - rotation: 0, - alpha: 1, - scale: { _sx: 1, _sy: 1, set(sx: number, sy: number) { this._sx = sx; this._sy = sy; }, - get x() { return this._sx; }, get y() { return this._sy; } }, - destroyed: false - }; - return v; -} - -describe('SpriteMotion', () => { - it('blink не трогает позицию: движущийся view сохраняет внешнее перемещение', () => { - const v = fakeView(5, 7); - const m = new SpriteMotion(v as never, { blink: { period: 0.25, duty: 1 } }); - v.x = 100; // извне (синк вью героя) - v.y = 50; - m.update(0.1); - m.update(0.1); - expect(v.x).toBe(100); - expect(v.y).toBe(50); - }); - - it('bob смещает Y относительно базы, X держит; detach возвращает базу', () => { - const v = fakeView(10, 20); - const m = new SpriteMotion(v as never, { bob: { amplitude: 4, period: 1 } }); - m.update(0.25); // четверть периода — максимальное смещение - expect(v.x).toBe(10); - expect(v.y).not.toBe(20); - m.detach(); - expect(v.x).toBe(10); - expect(v.y).toBe(20); - }); - - it('pulse меняет alpha и scale, но не позицию', () => { - const v = fakeView(); - const m = new SpriteMotion(v as never, { pulse: { min: 0.5, max: 1, period: 1 } }); - m.update(0.25); - expect(v.alpha).not.toBe(1); - expect(v.x).toBe(10); - expect(v.y).toBe(20); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/anim/__tests__/clip.test.ts b/packages/engine/src/anim/__tests__/clip.test.ts deleted file mode 100644 index 4183987..0000000 --- a/packages/engine/src/anim/__tests__/clip.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { stepClip, type ClipDef } from '../clip'; - -const loop: ClipDef = { fps: 10, loop: 'loop' }; -const once: ClipDef = { fps: 10, loop: 'once' }; -const ping: ClipDef = { fps: 10, loop: 'pingpong' }; - -describe('stepClip (время в шагах: fps=10 → 1 сек = 10 шагов)', () => { - it('loop: шаги идут по кругу, time не ограничено', () => { - let time = 0; - const steps: number[] = []; - for (let i = 0; i < 25; i++) { - const r = stepClip(4, loop, time, 0.1); - time = r.time; - steps.push(r.tick.step); - } - expect(steps).toEqual([1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1]); - expect(time).toBeCloseTo(25, 6); // время в шагах: 25 × (0.1 с × 10 fps) - }); - - it('loop: отрицательный dt ходит назад без ухода в минус', () => { - const r = stepClip(4, loop, 0, -0.1); - expect(r.tick.step).toBe(3); - }); - - it('once: стоп на последнем кадре, finished один раз', () => { - let time = 0; - const finished: boolean[] = []; - for (let i = 0; i < 5; i++) { - const r = stepClip(3, once, time, 0.1); - time = r.time; - finished.push(r.tick.finished); - } - // 3 кадра → конец после 2-го продвижения; дальше шаг держится на 2. - expect(finished).toEqual([false, true, false, false, false]); - let last: number | null = null; - for (let i = 0; i < 3; i++) last = stepClip(3, once, time, 0.1).tick.step; - expect(last).toBe(2); - }); - - it('pingpong: туда-обратно без дубля крайних кадров', () => { - let time = 0; - const steps: number[] = []; - for (let i = 0; i < 8; i++) { - const r = stepClip(3, ping, time, 0.1); - time = r.time; - steps.push(r.tick.step); - } - // Период 2*(3-1)=4: 0,1,2,1 | 0,1,2,1 - expect(steps).toEqual([1, 2, 1, 0, 1, 2, 1, 0]); - }); - - it('speedScale замедляет и ускоряет клип', () => { - // 0.1 сек при fps 10 = 1 шаг; с speedScale 0.5 — полшага. - expect(stepClip(4, { ...loop, speedScale: 0.5 }, 0, 0.1).tick.step).toBe(0); - expect(stepClip(4, { ...loop, speedScale: 2 }, 0, 0.1).tick.step).toBe(2); - }); - - it('dt больше периода: сообщается только финальный шаг', () => { - const r = stepClip(4, loop, 0, 10); // 10 сек = 100 шагов вперёд - expect(r.tick.step).toBe(0); // 100 % 4 = 0 - expect(r.tick.prevStep).toBe(0); - }); - - it('prevStep отражает шаг до продвижения', () => { - const r = stepClip(4, loop, 1.05, 0.1); - expect(r.tick.prevStep).toBe(1); - expect(r.tick.step).toBe(2); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/anim/__tests__/flash.test.ts b/packages/engine/src/anim/__tests__/flash.test.ts deleted file mode 100644 index 0fe5f2d..0000000 --- a/packages/engine/src/anim/__tests__/flash.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { TintFlash, lerpColor } from '../flash'; - -describe('TintFlash', () => { - it('не активен до start', () => { - const f = new TintFlash(0xffffff, 0xff0000, 0.2); - expect(f.active).toBe(false); - expect(f.update(0.1)).toBeNull(); - }); - - it('в начале — исходный цвет, в середине — ближе к вспышке', () => { - const f = new TintFlash(0xffffff, 0xff0000, 0.2); - f.start(); - expect(f.update(0)).toBe(0xffffff); - const mid = f.update(0.1); // середина: максимум вспышки - expect(mid).toBe(0xff0000); - }); - - it('к концу возвращается к исходному, затем null', () => { - const f = new TintFlash(0xffffff, 0xff0000, 0.2); - f.start(); - f.update(0.19); - const end = f.update(0.05); // пересекли границу - expect(end).toBe(0xffffff); - expect(f.active).toBe(false); - expect(f.update(0.05)).toBeNull(); - }); - - it('повторный запуск работает', () => { - const f = new TintFlash(0xffffff, 0x00ff00, 0.1); - f.start(); - f.update(0.2); // завершён - f.start(); - expect(f.active).toBe(true); - expect(f.update(0)).toBe(0xffffff); - }); -}); - -describe('lerpColor', () => { - it('края и середина', () => { - expect(lerpColor(0x000000, 0xffffff, 0)).toBe(0x000000); - expect(lerpColor(0x000000, 0xffffff, 1)).toBe(0xffffff); - expect(lerpColor(0x000000, 0xffffff, 0.5)).toBe(0x808080); - }); - - it('по каналам, не по всему числу', () => { - // от красного к синему: середина — фиолетовый (0x800080), а не «серый мусор» - expect(lerpColor(0xff0000, 0x0000ff, 0.5)).toBe(0x800080); - expect(lerpColor(0x102030, 0x102030, 0.7)).toBe(0x102030); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/anim/__tests__/motion.test.ts b/packages/engine/src/anim/__tests__/motion.test.ts deleted file mode 100644 index c757e8f..0000000 --- a/packages/engine/src/anim/__tests__/motion.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { sampleMotion } from '../motion'; - -describe('sampleMotion', () => { - it('bob: синус по Y, округлён до целого px', () => { - const p = { bob: { amplitude: 2, period: 1 } }; - expect(sampleMotion(p, 0).y).toBe(0); // 0 - expect(sampleMotion(p, 0.25).y).toBe(2); // пик - expect(sampleMotion(p, 0.75).y).toBe(-2); // низ - // Между узлами — целые: субпиксельного дрожания нет - const mid = sampleMotion(p, 0.05).y; - expect(Number.isInteger(mid)).toBe(true); - }); - - it('sway: угол в рад, амплитуда соблюдена', () => { - const p = { sway: { amplitude: 0.1, period: 2 } }; - expect(sampleMotion(p, 0.5).angle).toBeCloseTo(0.1, 6); // четверть периода = пик - expect(sampleMotion(p, 1.5).angle).toBeCloseTo(-0.1, 6); - expect(sampleMotion(p, 0).angle).toBeCloseTo(0, 6); - }); - - it('pulse: границы min/max, треугольная волна', () => { - const p = { pulse: { min: 0.4, max: 1, period: 2 } }; - expect(sampleMotion(p, 0).alpha).toBeCloseTo(0.4); // старт с минимума - expect(sampleMotion(p, 1).alpha).toBeCloseTo(1); // середина = max - expect(sampleMotion(p, 2).alpha).toBeCloseTo(0.4); // период замкнулся - expect(sampleMotion(p, 1).scale).toBeCloseTo(1); - }); - - it('blink: строб 0/1 с duty', () => { - const p = { blink: { period: 1, duty: 0.25 } }; - expect(sampleMotion(p, 0).alpha).toBe(1); - expect(sampleMotion(p, 0.2).alpha).toBe(1); // внутри duty - expect(sampleMotion(p, 0.5).alpha).toBe(0); // вне duty - expect(sampleMotion(p, 0.95).alpha).toBe(0); - }); - - it('композиция: bob+sway+pulse+blink независимы', () => { - const p = { - bob: { amplitude: 2, period: 1 }, - sway: { amplitude: 0.1, period: 1 }, - pulse: { min: 0.5, max: 1, period: 1 }, - blink: { period: 1, duty: 1 } // всегда включён - }; - const s = sampleMotion(p, 0.25); - expect(s.y).toBe(2); - expect(s.angle).toBeCloseTo(0.1, 6); - expect(s.alpha).toBeCloseTo(0.75, 6); // pulse: tri(0.25)=0.5 → k=0.75, blink=1 - }); - - it('phase сдвигает волну (доля периода)', () => { - const p = { bob: { amplitude: 2, period: 1, phase: 0.25 } }; - expect(sampleMotion(p, 0).y).toBe(2); // при t=0 уже пик - }); - - it('пустые параметры — нейтральная выборка', () => { - const s = sampleMotion({}, 5); - expect(s).toEqual({ y: 0, angle: 0, alpha: 1, scale: 1 }); - }); - - it('волна замкнута на границе периода (без скачка)', () => { - const p = { bob: { amplitude: 3, period: 2 } }; - expect(sampleMotion(p, 0).y).toBe(sampleMotion(p, 2).y); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/anim/clip.ts b/packages/engine/src/anim/clip.ts deleted file mode 100644 index dba30c9..0000000 --- a/packages/engine/src/anim/clip.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Чистая математика покадровых клипов (без Pixi): время в «шагах», - * режимы loop/once/pingpong, события смены шага и завершения. - * Тикается от фиксированного шага движка; dt больше периода осознанно - * «проматывает» промежуточные шаги (сообщается только финальный) — - * по аналогии с пересечением границы delay в TweenManager. - */ - -/** Режим клипа: loop — цикл; once — один раз и стоп на последнем кадре; - * pingpong — цикл туда-обратно (крайние кадры без дубля). */ -export type ClipLoop = 'loop' | 'once' | 'pingpong'; - -export interface ClipDef { - /** Кадров в секунду. */ - fps: number; - loop?: ClipLoop; - /** Множитель скорости клипа (перемножается со speedScale аниматора). */ - speedScale?: number; -} - -export interface ClipTick { - /** Индекс шага после продвижения (для once не превышает len-1). */ - step: number; - /** Индекс шага до продвижения. */ - prevStep: number; - /** Клип дошёл до конца (once) — ровно один раз при пересечении конца. */ - finished: boolean; -} - -/** - * Продвинуть время клипа (сек) на dt и посчитать новый шаг. - * Время накапливается в шагах: time += dt * fps * speedScale. - */ -export function stepClip( - len: number, - def: ClipDef, - prevTime: number, - dt: number -): { time: number; tick: ClipTick } { - const scale = def.speedScale ?? 1; - const time = prevTime + dt * def.fps * scale; - const prevStep = stepAt(len, def.loop ?? 'loop', prevTime); - const step = stepAt(len, def.loop ?? 'loop', time); - // finished — только для once и только в тик, когда конец был пересечён. - const finished = def.loop !== 'loop' && time >= len - 1 && prevTime < len - 1; - return { time, tick: { step, prevStep, finished } }; -} - -/** Индекс шага в момент time (в шагах) для заданного режима. */ -function stepAt(len: number, loop: ClipLoop, time: number): number { - if (loop === 'once') return Math.min(Math.max(0, Math.floor(time)), len - 1); - if (loop === 'pingpong') { - // Период 2*(len-1): туда 0..len-1, обратно len-1..0; крайние кадры без дубля. - const period = Math.max(1, 2 * (len - 1)); - const m = ((Math.floor(time) % period) + period) % period; - return m < len ? m : period - m; - } - const m = ((Math.floor(time) % len) + len) % len; - return m; -} \ No newline at end of file diff --git a/packages/engine/src/anim/flash.ts b/packages/engine/src/anim/flash.ts deleted file mode 100644 index ac67052..0000000 --- a/packages/engine/src/anim/flash.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Хит-флэш: лерп цвета tint от исходного к «вспышке» и обратно. - * Чистая логика (без Pixi) — результат применимо к любому числовому свойству. - */ - -export class TintFlash { - private elapsed: number | null = null; - /** tint на момент запуска (возвращается к нему по окончании). */ - private base: number; - - /** - * @param from исходный tint (например 0xffffff) - * @param to цвет вспышки - * @param seconds полная длительность (туда-обратно) - */ - constructor( - from: number, - private readonly to: number, - private readonly seconds: number - ) { - this.base = from; - } - - /** Запустить (перезапускается с нуля). */ - start(): void { - this.elapsed = 0; - } - - /** - * Обновить; возвращает текущий tint или null, если флэш не активен. - * При окончании возвращает исходный цвет. - */ - update(dt: number): number | null { - if (this.elapsed === null) return null; - this.elapsed += dt; - if (this.elapsed >= this.seconds) { - this.elapsed = null; - return this.base; - } - // треугольная волна: 0..1..0 - const t = 1 - Math.abs(this.elapsed / this.seconds - 0.5) * 2; - return lerpColor(this.base, this.to, t); - } - - get active(): boolean { - return this.elapsed !== null; - } - - /** Немедленно завершить; возвращает базовый цвет (вызывающий применяет сам). */ - finish(): number { - this.elapsed = null; - return this.base; - } -} - -/** Лерп двух цветов 0xRRGGBB по каналам. */ -export function lerpColor(a: number, b: number, t: number): number { - const ar = (a >> 16) & 0xff; - const ag = (a >> 8) & 0xff; - const ab = a & 0xff; - const br = (b >> 16) & 0xff; - const bg = (b >> 8) & 0xff; - const bb = b & 0xff; - const r = Math.round(ar + (br - ar) * t); - const g = Math.round(ag + (bg - ag) * t); - const bl = Math.round(ab + (bb - ab) * t); - return (r << 16) | (g << 8) | bl; -} \ No newline at end of file diff --git a/packages/engine/src/anim/motion.ts b/packages/engine/src/anim/motion.ts deleted file mode 100644 index 085e6ad..0000000 --- a/packages/engine/src/anim/motion.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Процедурные «оживители» статичных объектов — чистая математика без Pixi: - * синусные волны, композиция до четырёх одновременно. Фазы — в долях - * периода; случайная фаза берётся из seed снаружи (детерминизм). - */ - -export interface BobParams { - /** Амплитуда, px (округляется до целого — пиксель-арт не дрожит). */ - amplitude: number; - /** Полный период, сек. */ - period: number; - /** Фаза, доли периода (0..1). */ - phase?: number; -} - -export interface SwayParams { - /** Амплитуда, рад (вокруг основания view). */ - amplitude: number; - period: number; - phase?: number; -} - -export interface PulseParams { - /** Нижняя и верхняя граница пульса (alpha или scale). */ - min: number; - max: number; - period: number; - phase?: number; -} - -export interface BlinkParams { - period: number; - /** Доля периода в «включённом» состоянии (0..1), по умолчанию 0.5. */ - duty?: number; - phase?: number; -} - -export interface MotionParams { - bob?: BobParams; - sway?: SwayParams; - pulse?: PulseParams; - blink?: BlinkParams; -} - -/** Выборка оживителя в момент t (сек) относительно базового состояния. */ -export interface MotionSample { - /** Смещение по Y, px (целое). */ - y: number; - /** Поворот, рад. */ - angle: number; - /** Множитель альфы (произведение пульса и строба). */ - alpha: number; - /** Множитель масштаба. */ - scale: number; -} - -const TAU = Math.PI * 2; - -/** Фаза волны 0..1 в момент t для периода period и стартовой фазы phase. */ -function wave(t: number, period: number, phase = 0): number { - return ((t / period + phase) % 1 + 1) % 1; -} - -/** Выборка всех оживителей в момент t; параметры независимы, эффекты перемножаются. */ -export function sampleMotion(p: MotionParams, t: number): MotionSample { - let y = 0; - let angle = 0; - let alpha = 1; - let scale = 1; - if (p.bob) { - const w = wave(t, p.bob.period, p.bob.phase); - // Синус фазы 0..1 → -1..1; округление до целого px. - y = Math.round(Math.sin(w * TAU) * p.bob.amplitude); - } - if (p.sway) { - const w = wave(t, p.sway.period, p.sway.phase); - angle = Math.sin(w * TAU) * p.sway.amplitude; - } - if (p.pulse) { - const w = wave(t, p.pulse.period, p.pulse.phase); - // Треугольная волна 0..1..0 — пульс без резких скачков фазы. - const tri = 1 - Math.abs(w * 2 - 1); - const k = p.pulse.min + (p.pulse.max - p.pulse.min) * tri; - alpha *= k; - scale *= k; - } - if (p.blink) { - const w = wave(t, p.blink.period, p.blink.phase); - alpha *= w < (p.blink.duty ?? 0.5) ? 1 : 0; - } - return { y, angle, alpha, scale }; -} \ No newline at end of file diff --git a/packages/engine/src/assets/AssetLoader.ts b/packages/engine/src/assets/AssetLoader.ts deleted file mode 100644 index cad6f93..0000000 --- a/packages/engine/src/assets/AssetLoader.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Assets, Spritesheet, Texture } from 'pixi.js'; - -/** - * Загрузчик ассетов поверх Assets из PixiJS: группы, прогресс, готовые Texture. - * URL ассетов передаётся функцией-резолвером (приложение решает, где лежат файлы). - * Атласы (Spritesheet JSON + PNG) загружаются тем же механизмом: loadAtlas(). - */ -export class AssetLoader { - private textures = new Map(); - private atlases = new Map(); - private initPromise: Promise | null = null; - - constructor(private resolveUrl: (key: string) => string) {} - - /** Assets требует однократной инициализации до первой загрузки. */ - private ensureInit(): Promise { - if (!this.initPromise) { - this.initPromise = Assets.init({}); - } - return this.initPromise; - } - - /** Загрузить группу ключей параллельно; onProgress получает долю 0..1. */ - async load(keys: string[], onProgress?: (p: number) => void): Promise { - await this.ensureInit(); - const urls = keys.map((key) => this.resolveUrl(key)); - let loaded: Record; - try { - loaded = await Assets.load(urls, onProgress); - } catch (err) { - throw new Error(`Не удалось загрузить ассеты (${urls.join(', ')}): ${String(err)}`, { cause: err }); - } - keys.forEach((key, i) => { - const tex = loaded[urls[i]!]; - if (!tex) throw new Error(`Не удалось загрузить ${urls[i]}: нет результата по URL`); - // Пиксель-арт мира: nearest — чёткие квадратные пиксели на любом масштабе. - tex.source.scaleMode = 'nearest'; - this.textures.set(key, tex); - }); - } - - /** - * Загрузить атлас (Spritesheet JSON). JSON должен ссылаться на PNG - * относительным путём — Pixi подгрузит картинку сам. - * Ключ резолвится как есть: приложение должно дать URL на .json - * (например, ключ 'chars/hero_sheet.json'). - */ - async loadAtlas(key: string): Promise { - await this.ensureInit(); - const existing = this.atlases.get(key); - if (existing) return existing; - const sheet = await Assets.load(this.resolveUrl(key)); - if (!(sheet instanceof Spritesheet) || !sheet.textures) { - throw new Error(`Атлас ${key} не распознан как Spritesheet (проверьте URL и формат JSON)`); - } - // Кадры атласа — тоже nearest (общий источник, но пройдёмся по всем на всякий случай). - for (const tex of Object.values(sheet.textures)) { - tex.source.scaleMode = 'nearest'; - } - this.atlases.set(key, sheet); - return sheet; - } - - /** - * Кадры анимации из загруженного атласа: все кадры, чьё имя начинается - * с prefix, отсортированные по числу в конце имени (hero_walk_1, hero_walk_2, ...). - */ - frames(key: string, prefix: string): Texture[] { - const sheet = this.atlases.get(key); - if (!sheet) throw new Error(`Атлас не загружен: ${key}`); - const entries = Object.keys(sheet.textures) - .filter((name) => name.startsWith(prefix)) - .sort((a, b) => { - const na = Number(a.match(/(\d+)$/)?.[1] ?? -1); - const nb = Number(b.match(/(\d+)$/)?.[1] ?? -1); - return na - nb; - }); - if (entries.length === 0) { - throw new Error(`В атласе ${key} нет кадров с префиксом ${prefix}`); - } - return entries.map((name) => sheet.textures[name]!); - } - - /** Готовая анимация из sheet.animations (если генератор заполняет animations). */ - animation(key: string, name: string): Texture[] { - const sheet = this.atlases.get(key); - if (!sheet) throw new Error(`Атлас не загружен: ${key}`); - const frames = sheet.animations[name]; - if (!frames) throw new Error(`В атласе ${key} нет анимации ${name}`); - return frames; - } - - /** - * Размер кадра атласа из JSON (sourceSize: номинал ячейки до обрезки). - * Незнание имени кадра — размер первого кадра атласа. - */ - frameSize(key: string, frameName?: string): { w: number; h: number } { - const sheet = this.atlases.get(key); - if (!sheet) throw new Error(`Атлас не загружен: ${key}`); - const name = frameName ?? Object.keys(sheet.data.frames)[0]; - const frame = sheet.data.frames[name]; - if (!frame?.sourceSize) throw new Error(`В атласе ${key} нет кадра ${name} (или без sourceSize)`); - return { w: frame.sourceSize.w, h: frame.sourceSize.h }; - } - - 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 deleted file mode 100644 index 031e585..0000000 --- a/packages/engine/src/audio/AudioManager.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * WebAudio с четырьмя шинами (master/music/sfx/ambience): публичный фасад. - * Полифония sfx — Voices, лупы/кроссфейд — Loops, кэш буферов — BufferCache. - * Игры подключают Settings.onChange и вызывают setBusVolume. - */ -import { BufferCache } from './decode'; -import { LoopSlots } from './Loops'; -import { renderSpec, RATE, type SoundSpec } from './SoundSpec'; -import { SfxVoices } from './Voices'; -import { - DEFAULT_FADE, - type AudioBuses, - type BusName, - type LoopOptions, - type MusicHandle, - type PlayOptions, - type SfxHandle -} from './types'; - -export { type AudioBuses, type BusName, type MusicHandle, type PlayOptions, type SfxHandle } from './types'; - -export class AudioManager { - private ctx: AudioContext | null = null; - private buses: AudioBuses | null = null; - /** Буферы спек-синтеза (playSpec): компиляция один раз на ключ. */ - private specBuffers = new Map(); - private cache = new BufferCache(); - private voices: SfxVoices; - private loops = new LoopSlots(); - /** Слушатель visibility (снимается в dispose). */ - private onVisibility: (() => void) | null = null; - - /** - * Шпионский хук: вызывается при каждом фактическом запуске звука — - * и разового sfx, и лупов (музыка/амбиент/слои; rate 1, pan 0). - * Для тестов и DEV-лога игры. - */ - onPlayed?: (key: string, opts: PlayOptions) => void; - - /** Звук доиграл до конца (stop/вор/новый restart — не считаются). */ - onEnded?: (key: string) => void; - - constructor( - private resolveUrl: (key: string) => string, - private ctxFactory: () => AudioContext = () => new AudioContext(), - { maxVoices = 24, pauseOnHide = true }: { maxVoices?: number; pauseOnHide?: boolean } = {} - ) { - this.voices = new SfxVoices(maxVoices); - this.voices.onEnded = (key) => this.onEnded?.(key); - // Свернул таб — тишина, вернулся — звук сам (сверху не нужен жест, мы - // уже разблокированы); движку без DOM (тесты) listener не ставится. - if (pauseOnHide && typeof document !== 'undefined') { - this.onVisibility = () => { - if (document.hidden) { - void this.ctx?.suspend(); - } else if (this.ctx?.state === 'suspended') { - void this.ctx.resume(); - } - }; - document.addEventListener('visibilitychange', this.onVisibility); - } - } - - /** Создать контекст и шины синхронно (без resume) — для prepare(). */ - private createCtx(): void { - if (this.ctx) return; - this.ctx = this.ctxFactory(); - const master = this.ctx.createGain(); - const music = this.ctx.createGain(); - const sfx = this.ctx.createGain(); - const ambience = this.ctx.createGain(); - music.connect(master); - sfx.connect(master); - ambience.connect(master); - master.connect(this.ctx.destination); - this.buses = { master, music, sfx, ambience }; - } - - /** - * Синхронно завести контекст без resume: после него createBuffer работает, - * а первый play доведёт разблокировку до конца (resume — асинхронный). - * Нужно генеративным лупам (темы), которые компилируются вне async-цепочки. - */ - prepare(): void { - this.createCtx(); - } - - /** Расблокировать аудио — вызвать из обработчика пользовательского ввода. */ - async unlock(): Promise { - this.createCtx(); - if (this.ctx?.state === 'suspended') { - await this.ctx.resume(); - } - } - - /** Громкость шины 0..1 (до unlock шин ещё нет — вызов игнорируется). */ - setBusVolume(bus: BusName, volume: number): void { - if (this.buses) { - this.buses[bus].gain.value = Math.max(0, Math.min(1, volume)); - } - } - - /** Зарегистрировать URL ключа (файл читается при play/preload). */ - register(key: string): void { - this.cache.register(key, this.resolveUrl(key)); - } - - /** - * Декодировать заранее (после unlock, например в сцене загрузки), - * чтобы первый play не тратил время на fetch+decode. - */ - async preload(key: string): Promise { - const ctx = await this.ensureContext(); - if (!ctx) return; - await this.cache.decode(key, ctx); - } - - /** - * Проиграть sfx и вернуть хендл играющего голоса (null — не декодировался - * или контекст не готов; хендл «мёртв» после stop и естественного конца). - * opts — число (громкость, обратная совместимость) или PlayOptions. - */ - async play(key: string, opts?: number | PlayOptions): Promise { - const ctx = await this.ensureContext(); - if (!ctx || !this.buses) return null; - const buf = await this.cache.decode(key, ctx); - if (!buf) return null; - return this.startSfx(ctx, key, buf, opts); - } - - /** - * Проиграть буфер из памяти как sfx (процедурные звуки в рантайме). Ключ — - * логическое имя голоса (для restart, полифонии и шпионов), не файл. - */ - async playBuffer(key: string, buffer: AudioBuffer, opts?: number | PlayOptions): Promise { - const ctx = await this.ensureContext(); - if (!ctx || !this.buses) return null; - return this.startSfx(ctx, key, buffer, opts); - } - - /** - * Буфер из FLOAT-сэмплов (для процедурных звуков в рантайме): моно - * (channels 1) или interleaved L,R (channels 2 — стерео-рендеры движка). - * До unlock контекста нет — null; создать буфер по ключу не получится, - * вызывать после первого play/unlock. - */ - createBuffer(data: Float32Array, sampleRate = 22050, channels: 1 | 2 = 1): AudioBuffer | null { - if (!this.ctx) return null; - const frames = Math.floor(data.length / channels); - const buf = this.ctx.createBuffer(channels, frames, sampleRate); - // DOM-тип требует Float32Array — сэмплы из генераторов не обязаны. - if (channels === 1) { - buf.copyToChannel(data as Float32Array, 0); - } else { - const l = new Float32Array(frames); - const r = new Float32Array(frames); - for (let i = 0; i < frames; i++) { - l[i] = data[i * 2]!; - r[i] = data[i * 2 + 1]!; - } - buf.copyToChannel(l as Float32Array, 0); - buf.copyToChannel(r as Float32Array, 1); - } - return buf; - } - - /** - * Синтез по спеку (см. SoundSpec): детерминированно компилирует описание - * звука в буфер (кэш по ключу) и играет как sfx. Для ИИ-агента: описывает - * звук параметрами, без файлов; факт запуска виден в шпионе onPlayed. - */ - async playSpec(key: string, spec: SoundSpec, opts?: number | PlayOptions): Promise { - const ctx = await this.ensureContext(); - if (!ctx || !this.buses) return null; - let buf = this.specBuffers.get(key); - if (!buf) { - const created = this.createBuffer(renderSpec(spec), RATE); - if (!created) return null; - buf = created; - this.specBuffers.set(key, buf); - } - return this.startSfx(ctx, key, buf, opts); - } - - /** - * Длительность буфера в секундах (после preload/декода); не декодирован — - * null. Для катсцен и генераторов, синхронизирующихся со звуком. - */ - duration(key: string): number | null { - return this.cache.cached(key)?.duration ?? null; - } - - /** - * Играть луп в заданной шине; НЕ занимает ни слот музыки, ни слот амбиента — - * база для локальных амбиент-слоёв (несколько параллельных лупов). - */ - async playLoop(key: string, opts: LoopOptions = {}): Promise { - return this.playLoopFrom(key, opts); - } - - /** - * Луп из буфера в памяти (процедурная музыка: renderMusic → буфер → стем - * на шине). Ключ — логическое имя слота (для шпионов), не файл. - */ - async playLoopBuffer(key: string, buffer: AudioBuffer, opts: LoopOptions = {}): Promise { - const ctx = await this.ensureContext(); - if (!ctx || !this.buses) return null; - const full = this.loopOpts(opts); - const slot = this.loops.startSlot(key, ctx, this.buses, buffer, full, this.played()); - return this.loops.makeHandle(slot, full.fade); - } - - /** - * Играть музыку с кроссфейдом: текущий трек затухает, новый нарастает - * за fadeSeconds. Слот один — повторный вызов вытесняет прежний трек. - */ - async playMusic(key: string, opts: Omit = {}): Promise { - const ctx = await this.ensureContext(); - if (!ctx || !this.buses) return null; - const buf = await this.cache.decode(key, ctx); - if (!buf) return null; // негодный ключ не должен заглушить текущий трек - const full = this.loopOpts(opts); - const slot = this.loops.swapMusic(key, ctx, this.buses, buf, full, this.played()); - return this.loops.makeHandle(slot, full.fade); - } - - /** Остановить музыку (с затуханием). */ - stopMusic(fadeSeconds = 0.5): void { - this.loops.stopMusic(fadeSeconds); - } - - /** - * Играть амбиент с кроссфейдом — слот отдельный от музыки: - * playAmbience и playMusic не вытесняют друг друга. - */ - async playAmbience(key: string, { fade = DEFAULT_FADE, volume = 1 }: { fade?: number; volume?: number } = {}): Promise { - const ctx = await this.ensureContext(); - if (!ctx || !this.buses) return null; - const buf = await this.cache.decode(key, ctx); - if (!buf) return null; // негодный ключ не должен заглушить текущий амбиент - const slot = this.loops.swapAmbience(key, ctx, this.buses, buf, { loop: true, fade, volume, bus: 'ambience' }, this.played()); - return this.loops.makeHandle(slot, fade); - } - - /** Остановить амбиент (с затуханием). */ - stopAmbience(fadeSeconds = 0.5): void { - this.loops.stopAmbience(fadeSeconds); - } - - /** Погасить всё и снять слушатели (пересоздание аудио/HMR — без утечек). */ - dispose(): void { - if (this.onVisibility) { - document.removeEventListener('visibilitychange', this.onVisibility); - this.onVisibility = null; - } - this.voices.killAll(); - this.loops.stopMusic(0.05); - this.loops.stopAmbience(0.05); - } - - /** Общий запуск голоса sfx (нормализация opts-числа — здесь). */ - private startSfx(ctx: AudioContext, key: string, buf: AudioBuffer, opts?: number | PlayOptions): SfxHandle | null { - const o: PlayOptions = typeof opts === 'number' ? { volume: opts } : (opts ?? {}); - return this.voices.start(ctx, this.buses!, key, buf, o, (k, n) => this.onPlayed?.(k, n)); - } - - /** Нормализованные опции лупа. */ - private loopOpts(opts: LoopOptions): Required { - return { loop: opts.loop ?? true, fade: opts.fade ?? DEFAULT_FADE, volume: opts.volume ?? 1, bus: opts.bus ?? 'music' }; - } - - /** Шпионский колбэк для Loops (startSlot/swap* зовут на каждый запуск). */ - private played(): (key: string, opts: PlayOptions) => void { - return (key, opts) => this.onPlayed?.(key, opts); - } - - /** Файловый луп без слота (playLoop): декод + независимый запуск. */ - private async playLoopFrom(key: string, opts: LoopOptions): Promise { - const ctx = await this.ensureContext(); - if (!ctx || !this.buses) return null; - const buf = await this.cache.decode(key, ctx); - if (!buf) return null; - const full = this.loopOpts(opts); - const slot = this.loops.startSlot(key, ctx, this.buses, buf, full, this.played()); - return this.loops.makeHandle(slot, full.fade); - } - - private async ensureContext(): Promise { - await this.unlock(); - return this.ctx; - } -} \ No newline at end of file diff --git a/packages/engine/src/audio/Loops.ts b/packages/engine/src/audio/Loops.ts deleted file mode 100644 index 5377e20..0000000 --- a/packages/engine/src/audio/Loops.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Лупы (музыка/амбиент/локальные слои): запуск из буфера, кроссфейд, слоты - * музыки и амбиента (взаимно не вытесняют). Выбор трека — AudioManager. - */ -import { MIN_FADE_VOLUME, type AudioBuses, type LoopOptions, type MusicHandle, type PlayOptions } from './types'; - -/** Запущенный луп: контекст + источник + огибающая громкости. */ -export interface Slot { - ctx: AudioContext; - source: AudioBufferSourceNode; - gain: GainNode; -} - -export class LoopSlots { - private currentMusic: Slot | null = null; - private currentAmbience: Slot | null = null; - - /** - * Запуск лупа из готового буфера: источник + линейное нарастание громкости. - * Не занимает ни слот музыки, ни слот амбиента — база локальных слоёв. - */ - startSlot( - key: string, - ctx: AudioContext, - buses: AudioBuses, - buf: AudioBuffer, - { loop, fade, volume, bus }: Required, - onPlayed: (key: string, opts: PlayOptions) => void - ): Slot { - const source = ctx.createBufferSource(); - source.buffer = buf; - source.loop = loop; - const gain = ctx.createGain(); - gain.gain.setValueAtTime(MIN_FADE_VOLUME, ctx.currentTime); - gain.gain.linearRampToValueAtTime(volume, ctx.currentTime + fade); - source.connect(gain).connect(buses[bus]); - source.start(); - onPlayed(key, { volume, rate: 1, pan: 0 }); - return { ctx, source, gain }; - } - - /** Handle над слотом: затухание + мгновенная громкость (без борьбы с ramp). */ - makeHandle(slot: Slot, fade: number): MusicHandle { - return { - stop: (fadeSeconds = fade) => this.fadeOut(slot, fadeSeconds), - setVolume: (volume: number) => { - const t = slot.ctx.currentTime; - slot.gain.gain.cancelScheduledValues(t); - slot.gain.gain.setValueAtTime(Math.max(0, volume), t); - } - }; - } - - /** - * Слот музыки: новый трек вытесняет прежний (кроссфейд). Негодный ключ не - * должен заглушить текущий трек — проверка decode до вызова. - */ - swapMusic( - key: string, - ctx: AudioContext, - buses: AudioBuses, - buf: AudioBuffer, - opts: Required, - onPlayed: (key: string, opts: PlayOptions) => void - ): Slot { - this.stopMusic(opts.fade); - const slot = this.startSlot(key, ctx, buses, buf, opts, onPlayed); - this.currentMusic = slot; - return slot; - } - - /** Слот амбиента — отдельный от музыки: playAmbience и playMusic не конфликтуют. */ - swapAmbience( - key: string, - ctx: AudioContext, - buses: AudioBuses, - buf: AudioBuffer, - opts: Required, - onPlayed: (key: string, opts: PlayOptions) => void - ): Slot { - this.stopAmbience(opts.fade); - const slot = this.startSlot(key, ctx, buses, buf, opts, onPlayed); - this.currentAmbience = slot; - return slot; - } - - /** Остановить музыку (с затуханием). */ - stopMusic(fadeSeconds: number): void { - const current = this.currentMusic; - if (!current) return; - this.currentMusic = null; - this.fadeOut(current, fadeSeconds); - } - - /** Остановить амбиент (с затуханием). */ - stopAmbience(fadeSeconds: number): void { - const current = this.currentAmbience; - if (!current) return; - this.currentAmbience = null; - this.fadeOut(current, fadeSeconds); - } - - /** Затухание слота (экспонента — слуху линейный спад слышен ступенькой). */ - fadeOut(slot: Slot, fadeSeconds: number): void { - const t = slot.ctx.currentTime; - slot.gain.gain.cancelScheduledValues(t); - // Экспоненциальный ramp не определён от нуля — поднимаем до минимума. - const from = Math.max(slot.gain.gain.value, MIN_FADE_VOLUME); - slot.gain.gain.setValueAtTime(from, t); - slot.gain.gain.exponentialRampToValueAtTime(MIN_FADE_VOLUME, t + fadeSeconds); - setTimeout(() => { - try { - slot.source.stop(); - } catch { - // уже остановлен - } - }, fadeSeconds * 1000 + 50); - } -} \ No newline at end of file diff --git a/packages/engine/src/audio/Music.ts b/packages/engine/src/audio/Music.ts deleted file mode 100644 index 56d509d..0000000 --- a/packages/engine/src/audio/Music.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Музыкальный секвенсор движка: партитура (ноты + инструменты) → сэмплы-луп. - * Адресат — ИИ-агент: описывает партию нотами (MIDI), движок детерминированно - * компилирует её в буфер (AudioManager.playLoopBuffer — стем на шине music). - * Инструменты — осциллятор + огибающая поверх примитивов synth.mjs; сам - * рендер чистый (без ГПСЧ) — детерминизм мелодии обеспечивают сиды - * генератора нот на стороне игры/агента, а не рендера. - */ -import { RATE, decay, loopify, normalize } from '@rpg/engine/tools/synth.mjs'; - -/** Инструменты: осциллятор + огибающая (набор минимальный, расширять по мере надобности). */ -export type InstrumentKind = 'pluck' | 'bell' | 'pad'; - -/** Нота: старт и длина в долях (beats), высота в MIDI (69 = A4 = 440 Гц). */ -export interface NoteEvent { - beat: number; - dur: number; - midi: number; - /** Громкость ноты 0..1 (по умолчанию 0.8). */ - vel?: number; -} - -/** Партия = трек = стем: инструмент + ноты + громкость трека. */ -export interface TrackSpec { - instrument: InstrumentKind; - notes: NoteEvent[]; - /** Громкость трека (по умолчанию 1). */ - gain?: number; - /** Панорама -1..1 (только в стерео-рендере; по умолчанию 0 — центр). */ - pan?: number; -} - -/** Партитура лупа: темп, длина в долях, партии. */ -export interface MusicSpec { - /** Темп, долей в минуту. */ - bpm: number; - /** Длина лупа в долях (ноты, вылезшие за край, обрежаются). */ - beats: number; - tracks: TrackSpec[]; -} - -const midiHz = (midi: number): number => 440 * Math.pow(2, (midi - 69) / 12); - -/** Партиалы [множитель частоты, доля амплитуды] для мелодических инструментов. */ -const PLUCK_PARTIALS: [number, number][] = [ - [1, 0.6], - [2, 0.25], - [3, 0.15] -]; -const BELL_PARTIALS: [number, number][] = [ - [1, 0.5], - [1.52, 0.3], - [2.38, 0.2] -]; - -/** Сумма партиалов с экспоненциальным затуханием — pluck/bell. */ -function partialVoice(partials: [number, number][], f: number, ndur: number, power: number): (t: number) => number { - return (t) => { - let v = 0; - for (const [k, amp] of partials) { - v += Math.sin(2 * Math.PI * f * k * t) * amp * decay(t, ndur, power); - } - return v; - }; -} - -/** Пэд: sine + слегка расстроенная копия, мягкая атака/релиз вместо щелчка. */ -function padVoice(f: number, ndur: number): (t: number) => number { - const attack = Math.min(0.3, ndur * 0.25); - const release = Math.min(0.5, ndur * 0.35); - return (t) => { - const env = Math.min(1, t / attack) * Math.min(1, Math.max(0, (ndur - t) / release)); - return (Math.sin(2 * Math.PI * f * t) + Math.sin(2 * Math.PI * f * 1.004 * t)) * 0.5 * env; - }; -} - -/** Голос ноты: (t сек от начала ноты) → значение волны. */ -function voice(kind: InstrumentKind, f: number, ndur: number): (t: number) => number { - switch (kind) { - case 'pluck': - return partialVoice(PLUCK_PARTIALS, f, ndur, 3); - case 'bell': - return partialVoice(BELL_PARTIALS, f, ndur, 4); - case 'pad': - return padVoice(f, ndur); - } -} - -/** - * Суммирование нот всех партий в два канала. Моно-рендер кладёт всё в левый - * канал с весами 1 (pan не слышен); стерео — раскладывает по equal-power - * панораме трека. - */ -function mixTracks( - spec: MusicSpec, - total: number, - stereo: boolean -): [Float32Array, Float32Array] { - const spb = 60 / Math.max(1, spec.bpm); // сек на долю - const left = new Float32Array(total); - const right = new Float32Array(total); - for (const track of spec.tracks) { - const gain = track.gain ?? 1; - // Equal-power панорама: 0 → центр (обе 0.707), -1 → лево, 1 → право. - const a = ((Math.max(-1, Math.min(1, track.pan ?? 0)) + 1) * Math.PI) / 4; - const gl = stereo ? Math.cos(a) : 1; - const gr = stereo ? Math.sin(a) : 1; - for (const n of track.notes) { - const start = Math.round(n.beat * spb * RATE); - const len = Math.max(1, Math.round(n.dur * spb * RATE)); - const wave = voice(track.instrument, midiHz(n.midi), len / RATE); - const vel = (n.vel ?? 0.8) * gain; - for (let i = 0; i < len && start + i < total; i++) { - const v = wave(i / RATE) * vel; - left[start + i] += v * gl; - right[start + i] += v * gr; - } - } - } - return [left, right]; -} - -/** Нормализация к пику 0.8 по общему пику каналов + кроссфейд краёв лупа. */ -function finalizeLoop(left: Float32Array, right: Float32Array): void { - let peakMax = 0; - for (let i = 0; i < left.length; i++) - peakMax = Math.max(peakMax, Math.abs(left[i]!), Math.abs(right[i]!)); - const k = peakMax > 0 ? 0.8 / peakMax : 0; - if (k !== 1) { - for (let i = 0; i < left.length; i++) { - left[i] = left[i]! * k; - right[i] = right[i]! * k; - } - } - const fade = Math.min(0.5, (left.length / RATE) * 0.25); - loopify(left, fade); - loopify(right, fade); -} - -/** - * Партитура → моно-сэмплы-луп (RATE): ноты всех партий суммируются, луп - * сшивается кроссфейдом и нормализуется к пику 0.8. Одинаковые спеки дают - * одинаковые сэмплы (рендер чистый); pan треков не слышен. - */ -export function renderMusic(spec: MusicSpec): Float32Array { - const total = Math.max(1, Math.round(spec.beats * (60 / Math.max(1, spec.bpm)) * RATE)); - const [left] = mixTracks(spec, total, false); - return loopify(normalize(left, 0.8), Math.min(0.5, (total / RATE) * 0.25)); -} - -/** - * Партитура → стерео-сэмплы-луп (RATE, interleaved L,R): треки раскладываются - * по панораме `pan` (equal-power), нормализация к пику 0.8 — по общему пику - * каналов (баланс не искажается), кроссфейд краёв синхронный. Проигрывается - * как обычный AudioBuffer: `createBuffer(data, RATE, 2)`. - */ -export function renderMusicStereo(spec: MusicSpec): Float32Array { - const total = Math.max(1, Math.round(spec.beats * (60 / Math.max(1, spec.bpm)) * RATE)); - const [left, right] = mixTracks(spec, total, true); - finalizeLoop(left, right); - const out = new Float32Array(total * 2); - for (let i = 0; i < total; i++) { - out[i * 2] = left[i]!; - out[i * 2 + 1] = right[i]!; - } - return out; -} \ No newline at end of file diff --git a/packages/engine/src/audio/SoundSpec.ts b/packages/engine/src/audio/SoundSpec.ts deleted file mode 100644 index bcd5141..0000000 --- a/packages/engine/src/audio/SoundSpec.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { - RATE, - bandNoise, - bellPartial, - decay, - loopify, - makeRng, - normalize, - synth -} from '@rpg/engine/tools/synth.mjs'; - -/** - * Спек-синтез: детерминированная компиляция описания звука в сэмплы. - * Адресат — ИИ-агент (движок агент-первый): описывает звук параметрами, - * без слуха и без библиотек сэмплов; движок компилирует спек в буфер - * (AudioManager.playSpec), факт запуска наблюдаем через шпион onPlayed. - * Формулы — те же примитивы, что у файлового генератора игры - * (tools/synth.mjs), поэтому спек-звук и WAV-звук звучат в одной палитре. - */ - -export type SoundKind = 'hit' | 'chime' | 'scrape' | 'hum' | 'tone'; - -/** - * Дополнительный голос смеси: шум (low/high) или тон (freq) со своей - * огибающей. Параметры не заданы — наследуются от спека. - */ -export interface SpecVoice { - /** Тон голоса, Гц (чистый синус; вместе с delay — «блип» в конце). */ - freq?: number; - /** Полоса шума, Гц (шумовой голос). */ - low?: number; - high?: number; - /** Вес в смеси (дефолт 1). */ - w?: number; - /** Своя крутизна затухания (по умолчанию — power спека). */ - power?: number; - /** Своя длительность, сек (по умолчанию — dur спека). */ - dur?: number; - /** Нарастание, сек (min(1, t/attack) от старта голоса). */ - attack?: number; - /** Задержка старта, сек — до неё голос молчит. */ - delay?: number; - /** Свой сид шума (по умолчанию — seed спека). */ - seed?: number; -} - -export interface SoundSpec { - kind: SoundKind; - /** Длительность, сек (0.05..4; hum — до 8). */ - dur: number; - /** Полоса шума, Гц (hit/scrape; дефолты по kind). */ - low?: number; - high?: number; - /** Низкий тон, Гц: подложка удара/шороха или основной тон гула. */ - tone?: number; - /** Частота тона/звона, Гц (tone/chime; дефолты по kind). */ - freq?: number; - /** Конечная частота линейного свипа (tone): f = freq + (freqTo−freq)·t/dur. */ - freqTo?: number; - /** Вес основного тела в смеси (дефолт 1). */ - w?: number; - /** Крутизна затухания (дефолты по kind). */ - power?: number; - /** Нарастание основного тела, сек (min(1, t/attack)) — шипение, скрип. */ - attack?: number; - /** Линейный спад амплитуды к этому множителю к концу (1 → fadeTo). */ - fadeTo?: number; - /** Тремоло, Гц: множитель wobbleBase + wobbleDepth·sin(2π·wobbleHz·t + фаза). */ - wobbleHz?: number; - wobbleDepth?: number; - /** База тремоло (дефолт 1 — амплитуда гуляет вокруг единицы). */ - wobbleBase?: number; - /** Фаза тремоло, рад (дефолт 0). */ - wobblePhase?: number; - /** Дополнительные голоса поверх основного тела (см. SpecVoice). */ - layers?: SpecVoice[]; - /** Сид шума/клика — детерминизм (дефолт 1). */ - seed?: number; - /** Пик нормализации 0..1 (дефолт 0.5). */ - peak?: number; - /** Огибающая «0 → 1 → 0» вместо спада с самого громкого старта (воздух, свуш). */ - swell?: boolean; - /** Зациклить (кроссфейд краёв; для hum — по умолчанию). */ - loop?: boolean; -} - -/** Клэмп длительности: снизу — против нулевых буферов, сверху — по kind. */ -const clampDur = (dur: number, max: number): number => Math.max(0.05, Math.min(max, dur)); - -/** Огибающая: спад с самого громкого старта или «0 → 1 → 0» (swell). */ -function envelope(spec: SoundSpec, dur: number, power: number): (t: number) => number { - return spec.swell - ? (t) => Math.sin(Math.PI * Math.min(1, t / dur)) - : (t) => decay(t, dur, power); -} - -/** Нарастание: линейное от старта до полной громкости за attack секунд. */ -const attackEnv = (t: number, attack: number | undefined): number => - attack ? Math.min(1, t / attack) : 1; - -/** - * Микс: основное тело + доп. голоса (layers), поверх — тремоло и линейный - * спад. Без модификаторов совпадает с normalize(synth(dur, base), peak) - * бит-в-бит (множители не применяются вовсе). - */ -function renderMix(spec: SoundSpec, dur: number, base: (t: number) => number): Float32Array { - const voices = (spec.layers ?? []).map((l) => makeVoice(l, spec, dur)); - const wobbleHz = spec.wobbleHz; - const wobbleAmp = spec.wobbleDepth ?? 0; - const wobbleBase = spec.wobbleBase ?? 1; - const wobblePhase = spec.wobblePhase ?? 0; - const fadeTo = spec.fadeTo; - return normalize( - synth(dur, (t) => { - let v = base(t); - for (const voice of voices) v += voice(t); - if (wobbleHz !== undefined) - v *= wobbleBase + wobbleAmp * Math.sin(2 * Math.PI * wobbleHz * t + wobblePhase); - if (fadeTo !== undefined) v *= 1 + (fadeTo - 1) * (t / dur); - return v; - }), - spec.peak ?? 0.5 - ); -} - -/** Голос слоя: шум (low/high) или тон (freq), своя огибающая и задержка. */ -function makeVoice(l: SpecVoice, spec: SoundSpec, dur: number): (t: number) => number { - const vd = clampDur(l.dur ?? dur, 8); - const delay = l.delay ?? 0; - const w = l.w ?? 1; - const power = l.power ?? spec.power ?? 2; - if (l.low !== undefined || l.high !== undefined) { - const noise = bandNoise(l.seed ?? spec.seed ?? 1, l.low ?? 100, l.high ?? 900, vd); - return (t) => { - const lt = t - delay; - if (lt < 0) return 0; - return noise() * w * attackEnv(lt, l.attack) * decay(lt, vd, power); - }; - } - const f = l.freq ?? 440; - return (t) => { - const lt = t - delay; - if (lt < 0) return 0; - return Math.sin(2 * Math.PI * f * lt) * w * attackEnv(lt, l.attack) * decay(lt, vd, power); - }; -} - -/** Шумовой удар: полоса шума с резким спадом; tone — низкая подложка. */ -function hitBase(spec: SoundSpec, dur: number, env: (t: number) => number): (t: number) => number { - const noise = bandNoise(spec.seed ?? 1, spec.low ?? 600, spec.high ?? 3000, dur); - const tone = spec.tone ?? 0; - const w = spec.w ?? 1; - return (t) => { - let v = noise() * env(t) * w; - if (tone > 0) v += Math.sin(2 * Math.PI * tone * t) * decay(t, dur, (spec.power ?? 2) * 0.8) * 0.4; - return v; - }; -} - -/** Скрип/шорох: низкая полоса, медленный спад; swell — воздух; attack — нарастание. */ -function scrapeBase(spec: SoundSpec, dur: number, env: (t: number) => number): (t: number) => number { - const noise = bandNoise(spec.seed ?? 1, spec.low ?? 100, spec.high ?? 900, dur); - const w = spec.w ?? 1; - const attack = spec.attack; - return (t) => noise() * w * attackEnv(t, attack) * env(t); -} - -/** Чистый тон: синус freq (со свипом к freqTo), огибающая decay/swell. */ -function toneBase(spec: SoundSpec, dur: number, env: (t: number) => number): (t: number) => number { - const f0 = spec.freq ?? 440; - const f1 = spec.freqTo; - const w = spec.w ?? 1; - const attack = spec.attack; - return (t) => { - const f = f1 === undefined ? f0 : f0 + (f1 - f0) * (t / dur); - return Math.sin(2 * Math.PI * f * t) * w * attackEnv(t, attack) * env(t); - }; -} - -/** Звон: негармоничные партиалы одной частоты + короткий клик. */ -function chimeBase(spec: SoundSpec, dur: number): (t: number) => number { - const f = spec.freq ?? 520; - const power = spec.power ?? 5; - // Клик — спад с одной случайной амплитудой (как у файлового генератора: - // формулы совпадают дословно — мигрированные звуки звучат один в один). - const clickAmp = makeRng(spec.seed ?? 1)() * 2 - 1; - return (t) => { - const body = - bellPartial(f, 1.0, dur, power)(t) * 0.5 + - bellPartial(f, 1.52, dur, power + 1)(t) * 0.3 + - bellPartial(f, 2.38, dur * 0.6, power + 2)(t) * 0.2 + - bellPartial(f, 3.91, dur * 0.4, power + 3)(t) * 0.12; - const click = t < 0.008 ? clickAmp * (1 - t / 0.008) * 0.4 : 0; - return body * 0.8 + click; - }; -} - -/** Гул: два тона с биением (tone и tone×1.5) на «дыхании», луп по умолчанию. */ -function humBase(spec: SoundSpec, dur: number): (t: number) => number { - const tone = spec.tone ?? 55; - const breath = spec.power ?? 2; // число периодов «дыхания» на луп - return (t) => { - const swell = 0.6 + 0.4 * Math.sin(2 * Math.PI * (t / dur) * breath + 0.5); - const body = - Math.sin(2 * Math.PI * tone * t) * 0.5 + - Math.sin(2 * Math.PI * tone * 1.5 * t) * 0.3; - return body * swell * 0.35; - }; -} - -/** - * Спек → сэмплы (моно, RATE). Одинаковые спеки дают одинаковые сэмплы - * (детерминизм: сиды фикс, ГПСЧ детерминирован). - */ -export function renderSpec(spec: SoundSpec): Float32Array { - const dur = clampDur(spec.dur, spec.kind === 'hum' ? 8 : 4); - const env = envelope(spec, dur, spec.power ?? 2); - const base = - spec.kind === 'hit' - ? hitBase(spec, dur, env) - : spec.kind === 'chime' - ? chimeBase(spec, dur) - : spec.kind === 'scrape' - ? scrapeBase(spec, dur, env) - : spec.kind === 'tone' - ? toneBase(spec, dur, env) - : humBase(spec, dur); - const samples = renderMix(spec, dur, base); - if (spec.loop ?? spec.kind === 'hum') { - return loopify(samples, Math.min(0.5, dur / 4)); - } - return samples; -} - -export { RATE }; \ No newline at end of file diff --git a/packages/engine/src/audio/Voices.ts b/packages/engine/src/audio/Voices.ts deleted file mode 100644 index bded2ea..0000000 --- a/packages/engine/src/audio/Voices.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Полифония разовых sfx: учёт играющих голосов, лимит с воровством самого - * тихого, restart по ключу, панорама в полёте. Шины и контекст даёт AudioManager. - */ -import { clamp, MIN_FADE_VOLUME, type AudioBuses, type BusName, type PlayOptions, type SfxHandle } from './types'; - -/** Голос разового sfx: узлы + ключ (учёт для лимита полифонии и restart). */ -interface SfxVoice { - key: string; - /** Контекст и шины запуска (фейки тестов не дают source.context). */ - ctx: AudioContext; - buses: AudioBuses; - source: AudioBufferSourceNode; - gain: GainNode; - panner: StereoPannerNode | null; - /** Шина голоса (pan в полёте переподключается в ней же, не в sfx). */ - bus: BusName; - /** Заглушен принудительно (stop/вор/новый restart) — onEnded не зовётся. */ - killed: boolean; -} - -export class SfxVoices { - private voices: SfxVoice[] = []; - /** Звук доиграл до конца (stop/вор/новый restart — не считаются). */ - onEnded?: (key: string) => void; - - /** @param maxVoices лимит одновременных голосов (0 — без лимита). */ - constructor(private maxVoices: number) {} - - /** - * Общий запуск голоса (play — после декода, playBuffer — как есть). - * onPlayed зовётся после фактического source.start с нормализованными опциями. - */ - start( - ctx: AudioContext, - buses: AudioBuses, - key: string, - buf: AudioBuffer, - o: PlayOptions, - onPlayed: (key: string, opts: PlayOptions) => void - ): SfxHandle | null { - const volume = o.volume ?? 1; - const rate = clamp(o.rate ?? 1, 0.5, 2); - const pan = clamp(o.pan ?? 0, -1, 1); - const bus = o.bus ?? 'sfx'; - if (o.restart) this.stopKey(key); - this.stealIfNeeded(); - - const source = ctx.createBufferSource(); - source.buffer = buf; - source.playbackRate.value = rate; - const gain = ctx.createGain(); - gain.gain.value = volume; - source.connect(gain); - let panner: StereoPannerNode | null = null; - if (Math.abs(pan) >= 0.01 && typeof ctx.createStereoPanner === 'function') { - panner = ctx.createStereoPanner(); - panner.pan.value = pan; - gain.connect(panner).connect(buses[bus]); - } else { - gain.connect(buses[bus]); - } - source.start(); - const voice: SfxVoice = { key, ctx, buses, source, gain, panner, bus, killed: false }; - source.onended = () => { - this.release(voice); - if (!voice.killed) this.onEnded?.(key); - }; - this.voices.push(voice); - onPlayed(key, { volume, rate, pan }); - return this.makeHandle(voice); - } - - /** Остановить все играющие голоса ключа (короткий фейд). */ - stopKey(key: string, fadeSeconds = 0.05): void { - for (const voice of [...this.voices]) { - if (voice.key === key) this.kill(voice, fadeSeconds); - } - } - - /** Погасить все голоса (dispose менеджера). */ - killAll(): void { - for (const voice of [...this.voices]) this.kill(voice, 0.05); - } - - /** Хендл над голосом: после снятия с учёта (stop/конец) — no-op. */ - private makeHandle(voice: SfxVoice): SfxHandle { - const alive = () => this.voices.includes(voice); - return { - stop: (fadeSeconds = 0.05) => { - if (alive()) this.kill(voice, fadeSeconds); - }, - setVolume: (v: number) => { - if (alive()) voice.gain.gain.value = Math.max(0, v); - }, - setPan: (p: number) => { - if (alive()) this.setPan(voice, clamp(p, -1, 1)); - }, - setRate: (r: number) => { - if (alive()) voice.source.playbackRate.value = clamp(r, 0.5, 2); - } - }; - } - - /** Панорама голоса: узел создаётся при первом ненулевом значении — в шине голоса. */ - private setPan(voice: SfxVoice, pan: number): void { - if (voice.panner) { - voice.panner.pan.value = pan; - return; - } - if (Math.abs(pan) < 0.01 || typeof voice.ctx.createStereoPanner !== 'function') return; - const panner = voice.ctx.createStereoPanner(); - panner.pan.value = pan; - voice.gain.disconnect(); - voice.gain.connect(panner).connect(voice.buses[voice.bus]); - voice.panner = panner; - } - - /** Лимит голосов: при переполнении воруется самый тихий (ничья — самый старый). */ - private stealIfNeeded(): void { - while (this.maxVoices > 0 && this.voices.length >= this.maxVoices) { - let victim = this.voices[0]!; - for (const v of this.voices) { - if (v.gain.gain.value < victim.gain.gain.value) victim = v; - } - this.kill(victim, 0.05); - } - } - - /** Затухание голоса + снятие с учёта (handle сразу «мёртв»). */ - private kill(voice: SfxVoice, fadeSeconds: number): void { - voice.killed = true; - const t = voice.ctx.currentTime; - voice.gain.gain.cancelScheduledValues(t); - const from = Math.max(voice.gain.gain.value, MIN_FADE_VOLUME); - voice.gain.gain.setValueAtTime(from, t); - voice.gain.gain.exponentialRampToValueAtTime(MIN_FADE_VOLUME, t + fadeSeconds); - setTimeout(() => { - try { - voice.source.stop(); - } catch { - // уже остановлен - } - }, fadeSeconds * 1000 + 50); - this.release(voice); - } - - /** Снять голос с учёта (idempotent — зовётся и из onended, и из kill). */ - private release(voice: SfxVoice): void { - const i = this.voices.indexOf(voice); - if (i >= 0) this.voices.splice(i, 1); - } -} \ No newline at end of file diff --git a/packages/engine/src/audio/__tests__/AudioManager.test.ts b/packages/engine/src/audio/__tests__/AudioManager.test.ts deleted file mode 100644 index 9ea1207..0000000 --- a/packages/engine/src/audio/__tests__/AudioManager.test.ts +++ /dev/null @@ -1,603 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { AudioManager, type PlayOptions } from '../AudioManager'; - -/** Параметр WebAudio: значение + планировщик огибающей (мгновенно применяет). */ -function makeParam(value = 1) { - const p = { - value, - setValueAtTime: vi.fn((v: number) => { - p.value = v; - }), - linearRampToValueAtTime: vi.fn((v: number) => { - p.value = v; - }), - exponentialRampToValueAtTime: vi.fn((v: number) => { - p.value = v; - }), - cancelScheduledValues: vi.fn() - }; - return p; -} - -/** Минимальный граф WebAudio: считает узлы, запоминает соединения. */ -function makeCtx() { - const graph = { - gains: [] as { gain: ReturnType; dests: unknown[] }[], - sources: [] as { buffer: unknown; loop: boolean; playbackRate: ReturnType; start: ReturnType; stop: ReturnType; dests: unknown[] }[], - panners: [] as { pan: ReturnType; dests: unknown[] }[], - destination: { destination: true } - }; - const connect = (dests: unknown[]) => (dest: unknown) => { - dests.push(dest); - return dest; - }; - const ctx = { - currentTime: 0, - state: 'running', - destination: graph.destination, - resume: vi.fn(async () => undefined), - suspend: vi.fn(async () => { - ctx.state = 'suspended'; - }), - createGain: () => { - const node: { - gain: ReturnType; - dests: unknown[]; - connect: (d: unknown) => unknown; - disconnect: () => void; - } = { - gain: makeParam(1), - dests: [], - connect: () => undefined, - disconnect: () => undefined - }; - node.connect = connect(node.dests); - graph.gains.push(node); - return node; - }, - createBufferSource: () => { - const node: { buffer: unknown; loop: boolean; playbackRate: ReturnType; start: ReturnType; stop: ReturnType; dests: unknown[]; connect: (d: unknown) => unknown } = { - buffer: null, - loop: false, - playbackRate: makeParam(1), - start: vi.fn(), - stop: vi.fn(), - dests: [], - connect: () => undefined - }; - node.connect = connect(node.dests); - graph.sources.push(node); - return node; - }, - createStereoPanner: () => { - const node: { pan: ReturnType; dests: unknown[]; connect: (d: unknown) => unknown } = { - pan: makeParam(0), - dests: [], - connect: () => undefined - }; - node.connect = connect(node.dests); - graph.panners.push(node); - return node; - }, - decodeAudioData: vi.fn(async () => ({ decoded: true, duration: 1.25 })), - createBuffer: vi.fn((channels: number, length: number, rate: number) => { - const buf: { channels: number; length: number; sampleRate: number; data: Float32Array | null; copyToChannel: ReturnType } = { - channels, - length, - sampleRate: rate, - data: null, - copyToChannel: vi.fn((data: Float32Array) => { - buf.data = data; - }) - }; - return buf; - }) - }; - return { ctx, graph }; -} - -function makeManager(opts: { maxVoices?: number; pauseOnHide?: boolean } = {}) { - const { ctx, graph } = makeCtx(); - const audio = new AudioManager((key) => `url:${key}`, () => ctx as unknown as AudioContext, opts); - return { - audio, - graph, - ctx: ctx as unknown as { - createBuffer: ReturnType; - suspend: ReturnType; - resume: ReturnType; - state: string; - } - }; -} - -describe('AudioManager', () => { - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) }))); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it('unlock строит 4 шины: music/sfx/ambience ведут в master, master — в выход', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - - expect(graph.gains).toHaveLength(4); - const [master, music, sfx, ambience] = graph.gains; - expect(master!.dests).toContain(graph.destination); - for (const bus of [music, sfx, ambience]) { - expect(bus!.dests).toContain(master); - } - }); - - it('setBusVolume: до unlock — без броска, после unlock — меняет громкость шины', async () => { - const { audio, graph } = makeManager(); - expect(() => audio.setBusVolume('sfx', 0.3)).not.toThrow(); - expect(graph.gains).toHaveLength(0); - - await audio.unlock(); - audio.setBusVolume('sfx', 0.3); - audio.setBusVolume('ambience', 0.4); - expect(graph.gains[2]!.gain.value).toBe(0.3); - expect(graph.gains[3]!.gain.value).toBe(0.4); - }); - - it('play(key, 0.5) ≡ play(key, {volume: 0.5}) — источник в sfx через gain', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('bell'); - - await audio.play('bell', 0.5); - await audio.play('bell', { volume: 0.5 }); - - const sfxBus = graph.gains[2]!; - for (const src of graph.sources) { - const gainNode = src.dests[0] as { gain: ReturnType; dests: unknown[] }; - expect(gainNode.gain.value).toBe(0.5); - expect(gainNode.dests).toContain(sfxBus); - } - }); - - it('rate и pan доезжают до узлов; pan ≈ 0 — StereoPanner не создаётся', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('bell'); - - await audio.play('bell', { rate: 1.5, pan: 0.7 }); - expect(graph.sources[0]!.playbackRate.value).toBe(1.5); - expect(graph.panners).toHaveLength(1); - expect(graph.panners[0]!.pan.value).toBe(0.7); - - await audio.play('bell'); - expect(graph.panners).toHaveLength(1); // второй sfx без панорамы - expect(graph.sources[1]!.playbackRate.value).toBe(1); - }); - - it('незагруженный ключ — тихо ничего (null), onPlayed не зовётся', async () => { - const { audio } = makeManager(); - const played: { key: string; opts: PlayOptions }[] = []; - audio.onPlayed = (key, opts) => played.push({ key, opts }); - - await expect(audio.play('ghost')).resolves.toBeNull(); - expect(played).toHaveLength(0); - }); - - it('onPlayed зовётся после фактического запуска с нормализованными опциями', async () => { - const { audio, graph } = makeManager(); - const played: { key: string; opts: PlayOptions }[] = []; - audio.onPlayed = (key, opts) => played.push({ key, opts }); - await audio.unlock(); - await audio.register('bell'); - - await audio.play('bell', { volume: 0.6, rate: 1.2, pan: -0.4 }); - await audio.play('bell', 0.5); - - expect(played).toEqual([ - { key: 'bell', opts: { volume: 0.6, rate: 1.2, pan: -0.4 } }, - { key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } } - ]); - expect(graph.sources).toHaveLength(2); - }); - - it('play возвращает хендл: setVolume/setRate/setPan меняют узлы, stop глушит', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('bell'); - - const h = await audio.play('bell', { volume: 0.5, pan: 0.3 }); - expect(h).not.toBeNull(); - - const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number } }; - h!.setVolume(0.8); - expect(gainNode.gain.value).toBe(0.8); - - h!.setRate(1.4); - expect(graph.sources[0]!.playbackRate.value).toBe(1.4); - - h!.setPan(-0.5); - expect(graph.panners[0]!.pan.value).toBe(-0.5); - - h!.stop(0); - vi.advanceTimersByTime(100); - expect(graph.sources[0]!.stop).toHaveBeenCalled(); - }); - - it('setPan создаёт panner, если его не было', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('bell'); - - const h = await audio.play('bell', 0.5); - expect(graph.panners).toHaveLength(0); - - h!.setPan(0.4); - expect(graph.panners).toHaveLength(1); - expect(graph.panners[0]!.pan.value).toBeCloseTo(0.4); - // Пересоединение: gain теперь ведёт и в panner - const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] }; - expect(gainNode.dests).toContain(graph.panners[0]); - }); - - it('после stop хендл мёртв: setVolume/setRate — no-op', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('bell'); - - const h = await audio.play('bell', 0.5); - h!.stop(0); - h!.setVolume(0.9); - h!.setRate(1.5); - - // stop затушил громкость; setVolume мёртвого хендла её не вернул - const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number } }; - expect(gainNode.gain.value).toBeCloseTo(0.0001); - expect(graph.sources[0]!.playbackRate.value).toBe(1); - vi.advanceTimersByTime(100); - expect(graph.sources[0]!.stop).toHaveBeenCalledTimes(1); - }); - - it('restart: новый экземпляр ключа глушит прежний; без restart — играют оба', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('bell'); - - await audio.play('bell', 0.5); - await audio.play('bell', { volume: 0.5, restart: true }); - vi.advanceTimersByTime(100); - expect(graph.sources[0]!.stop).toHaveBeenCalled(); - expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); - - await audio.play('bell', 0.5); - vi.advanceTimersByTime(100); - expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // без restart живут оба - expect(graph.sources[2]!.stop).not.toHaveBeenCalled(); - }); - - it('maxVoices: при переполнении воруется самый тихий голос', async () => { - const { audio, graph } = makeManager({ maxVoices: 2 }); - await audio.unlock(); - await audio.register('bell'); - - await audio.play('bell', 0.9); - await audio.play('bell', 0.1); - await audio.play('bell', 0.5); - vi.advanceTimersByTime(100); - - expect(graph.sources[1]!.stop).toHaveBeenCalled(); // самый тихий (0.1) - expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); - expect(graph.sources[2]!.stop).not.toHaveBeenCalled(); - }); - - it('onPlayed зовётся и для лупов (музыка/амбиент), и для sfx', async () => { - const { audio } = makeManager(); - const played: { key: string; opts: PlayOptions }[] = []; - audio.onPlayed = (key, opts) => played.push({ key, opts }); - await audio.unlock(); - await audio.register('m'); - await audio.register('a'); - await audio.register('bell'); - - await audio.playMusic('m', { volume: 0.6, fade: 0 }); - await audio.playAmbience('a', { fade: 0 }); - await audio.play('bell', 0.5); - - expect(played).toEqual([ - { key: 'm', opts: { volume: 0.6, rate: 1, pan: 0 } }, - { key: 'a', opts: { volume: 1, rate: 1, pan: 0 } }, - { key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } } - ]); - }); - - it('onEnded зовётся на естественном конце звука', async () => { - const { audio, graph } = makeManager(); - const ended: string[] = []; - audio.onEnded = (key) => ended.push(key); - await audio.unlock(); - await audio.register('bell'); - - await audio.play('bell'); - expect(ended).toHaveLength(0); - - // Фейковый источник не завершается сам — дёргаем onended руками. - (graph.sources[0] as unknown as { onended?: () => void }).onended?.(); - expect(ended).toEqual(['bell']); - }); - - it('заглушенный голос (stop) onEnded не зовёт', async () => { - const { audio, graph } = makeManager(); - const ended: string[] = []; - audio.onEnded = (key) => ended.push(key); - await audio.unlock(); - await audio.register('bell'); - - const h = await audio.play('bell'); - h!.stop(0); - // Источник «остановился» после затухания — голос убит, событие не для него. - (graph.sources[0] as unknown as { onended?: () => void }).onended?.(); - expect(ended).toEqual([]); - }); - - it('duration: после load+preload — секунды буфера, иначе null', async () => { - const { audio } = makeManager(); - await audio.unlock(); - - expect(audio.duration('m')).toBeNull(); - await audio.register('m'); - await audio.preload('m'); - expect(audio.duration('m')).toBeCloseTo(1.25); - }); - - it('createBuffer: до unlock — null, после — буфер с сэмплами', async () => { - const { audio, ctx } = makeManager(); - const data = new Float32Array([0, 0.5, -0.5]); - expect(audio.createBuffer(data)).toBeNull(); // контекста ещё нет - - await audio.unlock(); - const buf = audio.createBuffer(data, 44100) as unknown as { data: Float32Array | null; copyToChannel: (d: Float32Array, ch: number) => void }; - expect(ctx.createBuffer).toHaveBeenCalledWith(1, 3, 44100); - expect(buf.data).toBe(data); - expect(buf!.copyToChannel).toHaveBeenCalledWith(data, 0); - }); - - it('createBuffer: channels 2 — interleaved L,R деинтерливится в два канала', async () => { - const { audio, ctx } = makeManager(); - await audio.unlock(); - const copied: { ch: number; data: Float32Array }[] = []; - const data = new Float32Array([0.1, 0.9, -0.2, -0.8]); - const buf = audio.createBuffer(data, 22050, 2) as unknown as { - copyToChannel: (d: Float32Array, ch: number) => void; - }; - // Собираем вызовы по каналам: фейк хранит только последний, читаем аргументы. - (ctx.createBuffer as ReturnType).mock.results.at(-1)!.value.copyToChannel.mock.calls.forEach( - ([d, ch]: [Float32Array, number]) => copied.push({ ch, data: d }) - ); - expect(ctx.createBuffer).toHaveBeenCalledWith(2, 2, 22050); - expect(copied).toEqual([ - { ch: 0, data: new Float32Array([0.1, -0.2]) }, - { ch: 1, data: new Float32Array([0.9, -0.8]) } - ]); - void buf; - }); - - it('playBuffer играет буфер в sfx с заданным ключом и возвращает хендл', async () => { - const { audio, graph } = makeManager(); - const played: { key: string; opts: PlayOptions }[] = []; - audio.onPlayed = (key, opts) => played.push({ key, opts }); - await audio.unlock(); - - const buf = audio.createBuffer(new Float32Array(100).fill(0.3))!; - const h = await audio.playBuffer('sfx/step_grass#0', buf, { volume: 0.4 }); - - expect(h).not.toBeNull(); - const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] }; - expect(gainNode.gain.value).toBe(0.4); - expect(gainNode.dests).toContain(graph.gains[2]); // шина sfx - expect(played).toEqual([{ key: 'sfx/step_grass#0', opts: { volume: 0.4, rate: 1, pan: 0 } }]); - - h!.stop(0); - vi.advanceTimersByTime(100); - expect(graph.sources[0]!.stop).toHaveBeenCalled(); - }); - - it('playSpec: спек компилируется в буфер и кэшируется по ключу', async () => { - const { audio, graph, ctx } = makeManager(); - const played: { key: string; opts: PlayOptions }[] = []; - audio.onPlayed = (key, opts) => played.push({ key, opts }); - await audio.unlock(); - - const spec = { kind: 'chime', dur: 0.1, freq: 660 } as const; - const h1 = await audio.playSpec('agent/door', spec, 0.7); - await audio.playSpec('agent/door', spec, 0.7); // из кэша - - expect(graph.sources).toHaveLength(2); - expect(ctx.createBuffer).toHaveBeenCalledTimes(1); // компиляция один раз - const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] }; - expect(gainNode.gain.value).toBe(0.7); - expect(gainNode.dests).toContain(graph.gains[2]); // шина sfx - expect(played.map((p) => p.key)).toEqual(['agent/door', 'agent/door']); - expect(h1).not.toBeNull(); - - // Другой ключ — своя компиляция - await audio.playSpec('agent/bush', spec, 0.5); - expect(ctx.createBuffer).toHaveBeenCalledTimes(2); - }); - - it('playSpec: спек рендерится в буфер с длиной dur×RATE (моно, 22050)', async () => { - const { audio, ctx } = makeManager(); - await audio.playSpec('x', { kind: 'hit', dur: 0.1 }); - expect(ctx.createBuffer).toHaveBeenCalledWith(1, 2205, 22050); - }); - - it('setVolume хендла сразу задаёт громкость слота', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('m'); - - const handle = await audio.playLoop('m', { fade: 0, volume: 1 }); - handle!.setVolume(0.3); - const slotGain = (graph.sources[0]!.dests[0] as { gain: { value: number } }).gain; - expect(slotGain.value).toBeCloseTo(0.3); - }); - - it('playMusic и playAmbience независимы: остановка музыки не глушит амбиент', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('m'); - await audio.register('a'); - - const music = await audio.playMusic('m', { fade: 0 }); - const amb = await audio.playAmbience('a', { fade: 0 }); - expect(music).not.toBeNull(); - expect(amb).not.toBeNull(); - expect(graph.sources).toHaveLength(2); - - music!.stop(0); - vi.advanceTimersByTime(100); - expect(graph.sources[0]!.stop).toHaveBeenCalled(); - expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); - }); - - it('playAmbience дважды — прежний слот остановлен', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('a'); - - const first = await audio.playAmbience('a', { fade: 0 }); - await audio.playAmbience('a', { fade: 0 }); - vi.advanceTimersByTime(100); - - expect(first).not.toBeNull(); - expect(graph.sources[0]!.stop).toHaveBeenCalled(); - expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // новый играет - }); - - it('playMusic с негодным ключом не глушит текущий трек', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('m'); - - await audio.playMusic('m', { fade: 0 }); - const bad = await audio.playMusic('ghost', { fade: 0 }); - expect(bad).toBeNull(); - expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); - }); - - it('playLoop играет в заданную шину и не занимает слот музыки', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('m'); - - const layer = await audio.playLoop('m', { bus: 'sfx', fade: 0 }); - expect(layer).not.toBeNull(); - const sfxBus = graph.gains[2]!; - expect(sfxBus.dests).not.toContain(graph.destination); - // gain лупа ведёт в sfx - const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] }; - expect(gainNode.dests).toContain(sfxBus); - - // музыка стартует отдельно, луп не заглушается - await audio.playMusic('m', { fade: 0 }); - expect(graph.sources).toHaveLength(2); - audio.stopMusic(0); - vi.advanceTimersByTime(100); - expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); - expect(graph.sources[1]!.stop).toHaveBeenCalled(); - }); - - it('bus в PlayOptions: разовый голос уходит в заданную шину', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('bell'); - - // graph.gains: master, music, sfx, ambience - await audio.play('bell', { volume: 0.5, bus: 'music' }); - const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] }; - expect(gainNode.dests).toContain(graph.gains[1]); // шина music - expect(gainNode.dests).not.toContain(graph.gains[2]); - - await audio.play('bell'); // без bus — по-прежнему sfx - const plain = graph.sources[1]!.dests[0] as { dests: unknown[] }; - expect(plain.dests).toContain(graph.gains[2]); - }); - - it('fadeOut: экспоненциальный спад от нуля поднимается до минимума', async () => { - const { audio, graph } = makeManager(); - await audio.unlock(); - await audio.register('m'); - - const layer = await audio.playLoop('m', { fade: 0, volume: 0 }); - layer!.stop(0.5); - const slotGain = graph.sources[0]!.dests[0] as { gain: ReturnType }; - // from = max(0, MIN) → setValueAtTime(MIN), затем exponential- ramp - expect(slotGain.gain.setValueAtTime).toHaveBeenCalledWith(0.0001, 0); - expect(slotGain.gain.exponentialRampToValueAtTime).toHaveBeenCalledWith(0.0001, 0.5); - }); - - it('visibilitychange: скрыли таб — suspend, вернули — resume', async () => { - const listeners: Record void)[]> = {}; - const doc = { - hidden: false, - addEventListener: (type: string, fn: () => void) => { - (listeners[type] ??= []).push(fn); - } - }; - vi.stubGlobal('document', doc); - try { - const { audio, ctx } = makeManager(); - await audio.unlock(); - expect(ctx.suspend).not.toHaveBeenCalled(); - - doc.hidden = true; - listeners.visibilitychange![0]!(); - expect(ctx.suspend).toHaveBeenCalledTimes(1); - - doc.hidden = false; - ctx.state = 'suspended'; - listeners.visibilitychange![0]!(); - expect(ctx.resume).toHaveBeenCalledTimes(1); - } finally { - vi.unstubAllGlobals(); - } - }); - - it('playLoopBuffer: луп из памяти на заданной шине, хендл стопит', async () => { - const { audio, graph } = makeManager(); - const played: { key: string; opts: PlayOptions }[] = []; - audio.onPlayed = (key, opts) => played.push({ key, opts }); - await audio.unlock(); - await audio.register('m'); // createBuffer фейка требует unlock — берём его буфер - - const buf = audio.createBuffer(new Float32Array(22050).fill(0.2))!; - const handle = await audio.playLoopBuffer('music/meadows', buf, { fade: 0, volume: 0.7 }); - expect(handle).not.toBeNull(); - const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] }; - expect(gainNode.gain.value).toBe(0.7); - expect(gainNode.dests).toContain(graph.gains[1]); // шина music по умолчанию - expect(played).toEqual([{ key: 'music/meadows', opts: { volume: 0.7, rate: 1, pan: 0 } }]); - - handle!.stop(0); - vi.advanceTimersByTime(100); - expect(graph.sources[0]!.stop).toHaveBeenCalled(); - }); - - it('pauseOnHide: false — visibilitychange игнорируется', async () => { - const listeners: Record void)[]> = {}; - vi.stubGlobal('document', { - hidden: true, - addEventListener: (type: string, fn: () => void) => { - (listeners[type] ??= []).push(fn); - } - }); - try { - const { ctx } = makeManager({ pauseOnHide: false }); - expect(listeners.visibilitychange).toBeUndefined(); // listener не ставился - expect(ctx.suspend).not.toHaveBeenCalled(); - } finally { - vi.unstubAllGlobals(); - } - }); -}); \ No newline at end of file diff --git a/packages/engine/src/audio/__tests__/Music.test.ts b/packages/engine/src/audio/__tests__/Music.test.ts deleted file mode 100644 index 221f11e..0000000 --- a/packages/engine/src/audio/__tests__/Music.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { renderMusic, renderMusicStereo, type MusicSpec } from '../Music'; - -const RATE = 22050; -const spb = (bpm: number): number => 60 / bpm; - -const same = (a: Float32Array, b: Float32Array): boolean => - a.length === b.length && a.every((v, i) => v === b[i]); - -const peak = (samples: Float32Array): number => Math.max(...[...samples].map(Math.abs)); - -/** Одна нота на долю — простейшая партитура. */ -const one = (over: Partial = {}): MusicSpec => ({ - bpm: 120, - beats: 4, - tracks: [{ instrument: 'pluck', notes: [{ beat: 0, dur: 1, midi: 69 }] }], - ...over -}); - -describe('renderMusic', () => { - it('детерминизм: одинаковые спеки → одинаковые сэмплы', () => { - expect(same(renderMusic(one()), renderMusic(one()))).toBe(true); - }); - - it('длина лупа — сырая минус кроссфейд (фейд = min(0.5с, 25% длины))', () => { - const t1 = 4 * spb(120) * RATE; // 2 с - expect(renderMusic(one({ bpm: 120, beats: 4 })).length).toBe(t1 - 0.5 * RATE); - const t2 = 8 * spb(60) * RATE; // 8 с — фейд упёрся в 0.5 с - expect(renderMusic(one({ bpm: 60, beats: 8 })).length).toBe(t2 - 0.5 * RATE); - }); - - it('высота ноты доезжает: другой midi — другие сэмплы', () => { - const a = renderMusic(one()); - const b = renderMusic(one({ tracks: [{ instrument: 'pluck', notes: [{ beat: 0, dur: 1, midi: 71 }] }] })); - expect(same(a, b)).toBe(false); - }); - - it('vel и gain меняют микс (пик после нормализации — 0.8 вне кроссфейд-зоны)', () => { - const beat1 = one({ tracks: [{ instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }] }] }); - expect(peak(renderMusic(beat1))).toBeCloseTo(0.8, 2); // нормализация - - // gain одной партии при опорной второй стереть нормализацией нельзя: - const mix = (g: number): MusicSpec => ({ - bpm: 120, - beats: 4, - tracks: [ - { instrument: 'pad', notes: [{ beat: 0, dur: 4, midi: 57 }] }, - { instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }], gain: g } - ] - }); - expect(same(renderMusic(mix(1)), renderMusic(mix(0.3)))).toBe(false); - }); - - it('ноты за краем лупа обрезаются, пустой трек — тишина нормализована в 0', () => { - const empty = renderMusic(one({ tracks: [{ instrument: 'pad', notes: [] }] })); - expect(peak(empty)).toBe(0); - - const clipped = renderMusic(one({ beats: 1, tracks: [{ instrument: 'pluck', notes: [{ beat: 0.5, dur: 2, midi: 69 }] }] })); - expect(clipped.length).toBeLessThanOrEqual(1 * spb(120) * RATE); - expect(peak(clipped)).toBeGreaterThan(0); - }); - - it('стерео: interleaved L,R; pan 0 — каналы равны, pan -1 — громче слева', () => { - const frames = 4 * spb(120) * RATE; - const spec = one({ tracks: [{ instrument: 'pluck', notes: [{ beat: 0, dur: 1, midi: 69 }], pan: -1 }] }); - const s = renderMusicStereo(spec); - expect(s.length).toBe(frames * 2); - - // Пики каналов (вне кроссфейд-зоны начала). - let lPeak = 0; - let rPeak = 0; - for (let i = 0; i < frames; i++) { - lPeak = Math.max(lPeak, Math.abs(s[i * 2]!)); - rPeak = Math.max(rPeak, Math.abs(s[i * 2 + 1]!)); - } - expect(lPeak).toBeGreaterThan(rPeak * 2); // панорама влево - - const centered = renderMusicStereo(one()); - let equal = true; - for (let i = 0; i < centered.length; i += 2) { - if (centered[i] !== centered[i + 1]) equal = false; - } - expect(equal).toBe(true); // pan 0 — моно-копия в обоих каналах - }); - - it('стерео: нормализация по общему пику каналов — баланс pan не искажается', () => { - // Тихий левый + громкий правый: пик пары равен 0.8 (нота вне кроссфейд-зоны). - const spec = one({ - tracks: [ - { instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }], pan: -1 }, - { instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }], pan: 1, gain: 2 } - ] - }); - const s = renderMusicStereo(spec); - expect(peak(s)).toBeCloseTo(0.8, 2); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/audio/__tests__/SoundSpec.test.ts b/packages/engine/src/audio/__tests__/SoundSpec.test.ts deleted file mode 100644 index 91299d9..0000000 --- a/packages/engine/src/audio/__tests__/SoundSpec.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { renderSpec, type SoundSpec } from '../SoundSpec'; - -const same = (a: Float32Array, b: Float32Array): boolean => - a.length === b.length && a.every((v, i) => v === b[i]); - -const peak = (samples: Float32Array): number => Math.max(...[...samples].map(Math.abs)); - -describe('renderSpec — детерминизм', () => { - it('одинаковые спеки → одинаковые сэмплы', () => { - const spec: SoundSpec = { kind: 'hit', dur: 0.2, seed: 42 }; - expect(same(renderSpec(spec), renderSpec(spec))).toBe(true); - }); - - it('другой сид → другой шум (но та же форма затухания)', () => { - const a = renderSpec({ kind: 'hit', dur: 0.2, seed: 1 }); - const b = renderSpec({ kind: 'hit', dur: 0.2, seed: 2 }); - expect(same(a, b)).toBe(false); - expect(peak(a)).toBeCloseTo(peak(b), 5); // нормализация по одному peak - }); -}); - -describe('renderSpec — kinds', () => { - it('hit/chime/scrape нормализуются к peak, немые буферы исключены', () => { - for (const kind of ['hit', 'chime', 'scrape'] as const) { - const samples = renderSpec({ kind, dur: 0.3, peak: 0.6 }); - expect(peak(samples)).toBeCloseTo(0.6, 2); - expect(peak(samples)).toBeGreaterThan(0); - } - }); - - it('длительности: без лупа — ровно dur×RATE; луп сшит (короче на фейд)', () => { - const hit = renderSpec({ kind: 'hit', dur: 0.4 }); - expect(hit.length).toBe(8820); // 0.4 с × 22050 - - const hum = renderSpec({ kind: 'hum', dur: 2 }); // луп по умолчанию - expect(hum.length).toBeLessThan(44100); // срезан кроссфейд-хвост - expect(hum.length).toBeGreaterThanOrEqual(44100 - 11025); // фейд ≤ 0.5 с - }); - - it('hum: заданный loop=false не сшивает; hit с loop=true сшивает', () => { - const humRaw = renderSpec({ kind: 'hum', dur: 1, loop: false }); - expect(humRaw.length).toBe(22050); - const hitLoop = renderSpec({ kind: 'hit', dur: 1, loop: true }); - expect(hitLoop.length).toBeLessThan(22050); - }); - - it('клэмп длительности: 0.01 → мин 0.05 с, hum 100 → макс 8 с', () => { - expect(renderSpec({ kind: 'hit', dur: 0.01 }).length).toBe(1102); // 0.05 с - expect(renderSpec({ kind: 'hum', dur: 100, loop: false }).length).toBe(176400); // 8 с - }); - - it('параметры доезжают: freq меняет тембр звона, tone — подложку удара', () => { - const low = renderSpec({ kind: 'chime', dur: 0.3, freq: 220, loop: false }); - const high = renderSpec({ kind: 'chime', dur: 0.3, freq: 880, loop: false }); - expect(same(low, high)).toBe(false); - - const plain = renderSpec({ kind: 'hit', dur: 0.3, seed: 5, loop: false }); - const toned = renderSpec({ kind: 'hit', dur: 0.3, seed: 5, tone: 120, loop: false }); - expect(same(plain, toned)).toBe(false); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/audio/decode.ts b/packages/engine/src/audio/decode.ts deleted file mode 100644 index d1df125..0000000 --- a/packages/engine/src/audio/decode.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Кэш декодированных аудио-буферов: реестр URL + decodeAudioData. - * Параллельные вызовы decode по одному ключу делят один in-flight промис — - * двойной fetch+decode при старте сцены исключён. - */ -export class BufferCache { - private urls = new Map(); - private buffers = new Map(); - private pending = new Map>(); - - /** Зарегистрировать URL ключа (без загрузки — файл читается в decode). */ - register(key: string, url: string): void { - this.urls.set(key, url); - } - - /** Уже декодированный буфер (без загрузки); не декодирован — null. */ - cached(key: string): AudioBuffer | null { - return this.buffers.get(key) ?? null; - } - - /** Декодировать (fetch + decodeAudioData); сбой — null с warn в консоль. */ - async decode(key: string, ctx: AudioContext): Promise { - const cached = this.buffers.get(key); - if (cached) return cached; - let flight = this.pending.get(key); - if (!flight) { - const url = this.urls.get(key); - if (!url) return null; - flight = this.fetchDecode(key, url, ctx).finally(() => this.pending.delete(key)); - this.pending.set(key, flight); - } - return flight; - } - - private async fetchDecode(key: string, url: string, ctx: AudioContext): Promise { - try { - const res = await fetch(url); - const buf = await ctx.decodeAudioData(await res.arrayBuffer()); - this.buffers.set(key, buf); - return buf; - } catch (err) { - console.warn(`[audio] не удалось декодировать ${key}:`, err); - return null; - } - } -} \ No newline at end of file diff --git a/packages/engine/src/audio/types.ts b/packages/engine/src/audio/types.ts deleted file mode 100644 index 056c911..0000000 --- a/packages/engine/src/audio/types.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Общие типы и константы аудио-подсистемы (шины, хендлы, опции). - * Реализации — в AudioManager (фасад), Voices (полифония sfx), Loops (лупы). - */ - -export interface AudioBuses { - master: GainNode; - music: GainNode; - sfx: GainNode; - ambience: GainNode; -} - -export type BusName = 'master' | 'music' | 'sfx' | 'ambience'; - -export interface MusicHandle { - stop(fadeSeconds?: number): void; - /** Задать громкость сразу (для медленных тик-фейдов поверх — локальные слои). */ - setVolume(volume: number): void; -} - -/** Хендл играющего разового sfx: параметры в полёте + остановка. */ -export interface SfxHandle { - stop(fadeSeconds?: number): void; - setVolume(volume: number): void; - setPan(pan: number): void; - setRate(rate: number): void; -} - -/** Опции разового sfx. */ -export interface PlayOptions { - /** Относительная громкость в шине (см. bus), 0..1+. */ - volume?: number; - /** Скорость воспроизведения 0.5..2 (джиттер шагов, вариации тона). */ - rate?: number; - /** Панорама -1..1; |pan| < 0.01 — узел StereoPanner не создаётся. */ - pan?: number; - /** Заглушить прежние играющие экземпляры этого же ключа (короткий фейд). */ - restart?: boolean; - /** Шина голоса (по умолчанию sfx) — например UI-звук в master. */ - bus?: BusName; -} - -export interface LoopOptions { - loop?: boolean; - fade?: number; - volume?: number; - bus?: BusName; -} - -export const DEFAULT_FADE = 1; -export const MIN_FADE_VOLUME = 0.0001; - -export const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); \ No newline at end of file diff --git a/packages/engine/src/core/Cooldown.ts b/packages/engine/src/core/Cooldown.ts deleted file mode 100644 index c1624fa..0000000 --- a/packages/engine/src/core/Cooldown.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Кулдаун на фиксированном шаге: триггер -> отсчёт -> готовность. - * Чистая логика без Pixi — тестируется в Vitest. - */ -export class Cooldown { - private remaining = 0; - - constructor(private readonly seconds: number) {} - - /** Запустить отсчёт заново (только если готов — см. triggerForce для перебива). */ - trigger(): boolean { - if (!this.ready) return false; - this.remaining = this.seconds; - return true; - } - - /** Перезапустить отсчёт даже если не готов. */ - triggerForce(): void { - this.remaining = this.seconds; - } - - update(dt: number): void { - if (this.remaining > 0) { - this.remaining = Math.max(0, this.remaining - dt); - } - } - - get ready(): boolean { - return this.remaining <= 0; - } - - /** Секунд до готовности (0 — уже готов). */ - get timeRemaining(): number { - return this.remaining; - } - - /** 1 — только запущен, 0 — готов. */ - get progress(): number { - return this.seconds > 0 ? this.remaining / this.seconds : 0; - } - - reset(): void { - this.remaining = 0; - } -} \ No newline at end of file diff --git a/packages/engine/src/core/Engine.ts b/packages/engine/src/core/Engine.ts deleted file mode 100644 index 29e443a..0000000 --- a/packages/engine/src/core/Engine.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { Application, Graphics } from 'pixi.js'; -import { Renderer, RendererOptions } from '../render/Renderer'; -import { Camera } from '../render/Camera'; -import { computeScale } from '../render/scale'; -import { GameLoop } from './GameLoop'; -import { FpsMeter } from './FpsMeter'; -import { EventBus } from './EventBus'; -import { TweenManager } from './Tween'; -import { Updatables } from './Updatables'; -import { InputManager } from '../input/InputManager'; -import { SceneManager } from '../scene/SceneManager'; - -/** - * Собирает подсистемы движка. Приложение создаёт Engine, добавляет стартовую - * сцену через scenes.replace() и вызывает start(). - */ -export interface EngineOptions extends RendererOptions { - /** FPS фиксированного шага. */ - fixedFps: number; - /** Лимит отрисовки, кадров/сек (0 — без лимита; шаги логики копятся). */ - maxFps?: number; - /** Пересчитывать масштаб при ресайзе окна (по умолчанию true). */ - autoResize?: boolean; -} - -export class Engine { - readonly renderer: Renderer; - readonly camera: Camera; - readonly events = new EventBus(); - readonly scenes = new SceneManager(); - /** Твины и таймеры: обновляются каждым фиксированным шагом. */ - readonly tweens = new TweenManager(); - /** Эффекты/аниматоры/флэши, тикаемые движком сами (фиксированный шаг). */ - readonly fx = new Updatables(); - input!: InputManager; - /** Дебаг-счётчик FPS (F3); создаётся в init(). */ - fpsMeter!: FpsMeter; - private loop: GameLoop; - private started = false; - private ticks = 0; - private readonly autoResize: boolean; - private onWindowResize = (): void => { - this.renderer.resize( - computeScale( - this.renderer.virtualWidth, - this.renderer.virtualHeight, - window.innerWidth, - window.innerHeight - ) - ); - }; - - constructor(options: EngineOptions) { - this.renderer = new Renderer(options); - this.camera = new Camera(options.virtualWidth, options.virtualHeight); - this.autoResize = options.autoResize ?? true; - this.loop = new GameLoop( - options.fixedFps, - { - update: (dt) => this.tick(dt), - render: () => this.render() - }, - { maxFps: options.maxFps } - ); - } - - /** - * Инициализация 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 - ); - - // Оверлей затемнения для переходов между сценами — верхний слой UI. - const fade = new Graphics(); - this.renderer.uiRoot.addChild(fade); - this.scenes.setOverlay(fade, this.renderer.virtualWidth, this.renderer.virtualHeight); - - // Дебаг-счётчик FPS (F3) — поверх мира, под фейдом переходов. - this.fpsMeter = new FpsMeter(); - this.renderer.uiRoot.addChild(this.fpsMeter.view); - - if (this.autoResize) { - window.addEventListener('resize', this.onWindowResize); - } - return this.renderer.app; - } - - async start(): Promise { - if (this.started) return; - this.started = true; - this.loop.start(); - } - - stop(): void { - this.loop.stop(); - } - - /** - * Полная зачистка (HMR/выгрузка страницы): цикл, текущая сцена (exit — - * там сцены снимают свои подписки и вьюхи), эффекты, твины, ввод, - * FPS-метр, слушатели окна и Pixi-приложение. После destroy движок не годится. - */ - destroy(): void { - this.stop(); - void this.scenes.current?.exit(); - this.fx.clear(); - this.tweens.clear(); - this.events.clear(); - this.input?.dispose(); - this.fpsMeter?.dispose(); - window.removeEventListener('resize', this.onWindowResize); - this.renderer.app.destroy(true, { children: true }); - } - - /** Счётчик фиксированных шагов с запуска (для агента и отладки). */ - get tickCount(): number { - return this.ticks; - } - - /** Длительность фиксированного шага, сек. */ - get fixedStep(): number { - return this.loop.step; - } - - /** Реальный FPS отрисовки (окно ~0,5 с) — для моста и отладки. */ - get fps(): number { - return this.fpsMeter.fps; - } - - /** Виртуальное разрешение (для пересчёта виртуальных px в CSS). */ - get virtualWidth(): number { - return this.renderer.virtualWidth; - } - - get virtualHeight(): number { - return this.renderer.virtualHeight; - } - - /** Один фиксированный шаг (без рендера) — мост/тесты крутят шаги сами. */ - stepTick(): number { - this.tick(this.loop.step); - this.ticks++; - this.events.emit('engine:tick', { tick: this.ticks }); - return this.ticks; - } - - /** Один кадр рендера без шага логики. */ - renderFrame(): void { - this.render(); - } - - /** - * n фиксированных шагов детерминированно: цикл на время переводится в ручной - * режим, после — тайминги сбрасываются, чтобы rAF не «догонял» пропущенное. - * finally: исключение в шаге не оставляет цикл в ручном режиме навсегда. - */ - stepTicks(n = 1, render = false): number { - this.loop.setManual(true); - try { - for (let i = 0; i < n; i++) { - this.stepTick(); - if (render) this.render(); - } - } finally { - this.loop.setManual(false); - this.loop.resetTiming(); - } - return this.ticks; - } - - /** Один фиксированный шаг. */ - private tick(dt: number): void { - this.input?.update(); // опрос геймпадов до логики - this.camera.update(dt); // тряска и прочее «живое» поведение камеры - this.tweens.update(dt); - this.fx.update(dt); // эффекты/аниматоры поверх твин-движения, до логики сцены - this.scenes.update(dt); - this.input?.endTick(); - } - - private render(): void { - this.fpsMeter?.frame(); - 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 deleted file mode 100644 index 941696c..0000000 --- a/packages/engine/src/core/EventBus.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Простая шина событий с типизированными темами. - * Подписка: 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/FpsMeter.ts b/packages/engine/src/core/FpsMeter.ts deleted file mode 100644 index 9495cd2..0000000 --- a/packages/engine/src/core/FpsMeter.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Container, Graphics } from 'pixi.js'; -import { PixelText } from '../ui/PixelText'; - -/** - * Дебаг-счётчик FPS движка: плашка в левом верхнем углу, среднее за окно - * ~полсекунды (текст перерисовывается не чаще — растеризация дорогая). - * Скрыт по умолчанию; переключение — F3: движок слушает клавишу сам, - * независимо от привязок ввода приложения (preventDefault — F3 в Firefox - * открывает поиск). - */ -export class FpsMeter { - readonly view = new Container(); - private bg = new Graphics(); - private label = new PixelText({ text: 'FPS --', size: 10, color: 0xd7e4ec }); - private frames = 0; - private windowStart = 0; - private lastFps = 0; - private shown = false; - private onKey = (e: KeyboardEvent): void => { - if (e.code !== 'F3') return; - e.preventDefault(); - this.toggle(); - }; - - constructor() { - this.view.addChild(this.bg, this.label); - this.label.position.set(4, 2); - this.view.position.set(2, 2); - this.view.visible = false; - window.addEventListener('keydown', this.onKey); - } - - /** Учёт кадра отрисовки (Engine зовёт из render). */ - frame(): void { - this.frames++; - const now = performance.now(); - const elapsed = now - this.windowStart; - if (elapsed < 500) return; - this.lastFps = Math.round((this.frames * 1000) / elapsed); - this.frames = 0; - this.windowStart = now; - if (!this.shown) return; - this.label.text = `FPS ${this.lastFps}`; - this.bg - .clear() - .rect(0, 0, this.label.width + 8, this.label.height + 4) - .fill({ color: 0x101018, alpha: 0.8 }) - .stroke({ color: 0x8899aa, width: 1 }); - } - - /** Средний FPS за последнее окно (~0,5 с); считается всегда, даже скрытым. */ - get fps(): number { - return this.lastFps; - } - - /** Показать/скрыть; после показа окно начинается с нуля. */ - toggle(): void { - this.shown = !this.shown; - this.view.visible = this.shown; - if (this.shown) { - this.frames = 0; - this.windowStart = performance.now(); - } - } - - dispose(): void { - window.removeEventListener('keydown', this.onKey); - } -} \ No newline at end of file diff --git a/packages/engine/src/core/GameLoop.ts b/packages/engine/src/core/GameLoop.ts deleted file mode 100644 index e5ba056..0000000 --- a/packages/engine/src/core/GameLoop.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Игровой цикл: фиксированный шаг обновления (60 Гц по умолчанию) + отрисовка - * после каждого шага (рендер с последним состоянием, без интерполяции — - * на 2D-пиксель-арте это стандартный компромисс). Лок кадров (maxFps) режет - * частоту отрисовки: шаги логики копятся и выполняются пачкой. - */ -export interface LoopCallbacks { - /** dt в секундах, всегда фиксированный. */ - update: (dt: number) => void; - render: () => void; -} - -export interface LoopOptions { - /** Лимит отрисовки, кадров/сек (0 — без лимита, по умолчанию). */ - maxFps?: number; -} - -export class GameLoop { - /** Длительность фиксированного шага, сек. */ - readonly step: number; - /** Максимум шагов за кадр (защита от «догоняния» после лагов). */ - maxStepsPerFrame = 5; - - private callbacks: LoopCallbacks; - private rafId = 0; - private lastTime = 0; - private accumulator = 0; - private running = false; - /** Ручной режим: rAF не планирует шаги (их крутит мост/тесты через stepTick). */ - private manual = false; - /** Минимальный интервал между отрисованными кадрами, мс (0 — без лока). */ - private minFrameMs: number; - /** Время последнего отрисованного кадра, мс. */ - private lastFrame = 0; - - constructor(fps: number, callbacks: LoopCallbacks, { maxFps = 0 }: LoopOptions = {}) { - this.step = 1 / fps; - this.callbacks = callbacks; - this.minFrameMs = maxFps > 0 ? 1000 / maxFps : 0; - } - - start(): void { - if (this.running) return; - this.running = true; - this.lastTime = performance.now(); - this.lastFrame = this.lastTime; - this.rafId = requestAnimationFrame(this.tick); - } - - stop(): void { - this.running = false; - cancelAnimationFrame(this.rafId); - } - - /** Ручной режим: кадры rAF не делают ни шагов, ни рендера. */ - setManual(on: boolean): void { - this.manual = on; - if (on) this.resetTiming(); - } - - /** - * Сброс таймингов: аккумулятор и lastTime обнуляются, чтобы следующий - * реальный кадр не «догонял» пропущенное время (до 5 лишних тиков). - */ - resetTiming(): void { - this.accumulator = 0; - this.lastTime = performance.now(); - this.lastFrame = this.lastTime; - } - - private tick = (now: number): void => { - if (!this.running) return; - if (this.manual) { - // Ручной режим: кадр пустой, но rAF и тайминги живут — иначе при - // выходе из ручного режима цикл «догонит» пропущенное время. - this.rafId = requestAnimationFrame(this.tick); - this.accumulator = 0; - this.lastTime = now; - return; - } - this.rafId = requestAnimationFrame(this.tick); - - // Лок кадров: кадр режется, но время не теряется — накопится в - // аккумулятор к следующему пропущенному кадру (шаги догонят пачкой). - if (now - this.lastFrame < this.minFrameMs - 1) return; - this.lastFrame = now; - - 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/core/GameState.ts b/packages/engine/src/core/GameState.ts deleted file mode 100644 index b87f693..0000000 --- a/packages/engine/src/core/GameState.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Состояние игры: флаги сюжета и именованные переменные. - * Жанронезависимо: движок знает только механику, контент (какие флаги есть) - * определяется игрой. Сериализуется в/из сейва вместе с позицией и прочим. - */ - -export interface GameStateData { - version: number; - flags: string[]; - vars: Record; -} - -export type GameStateMigration = (data: GameStateData) => GameStateData; - -export class GameState { - private flags = new Set(); - private vars = new Map(); - private migrations: GameStateMigration[] = []; - - /** Текущая версия формата данных (растёт при изменениях структуры сейва). */ - dataVersion = 1; - - /** - * Зарегистрировать миграцию: применяется к сейвам с dataVersion меньше - * текущей. Миграции регистрируются по порядку от старых версий к новым. - */ - addMigration(m: GameStateMigration): void { - this.migrations.push(m); - } - - // --- флаги --- - - setFlag(flag: string): void { - this.flags.add(flag); - } - - hasFlag(flag: string): boolean { - return this.flags.has(flag); - } - - clearFlag(flag: string): void { - this.flags.delete(flag); - } - - /** Все флаги (для отладки). */ - get allFlags(): string[] { - return [...this.flags]; - } - - // --- переменные --- - - setVar(key: string, value: number | string | boolean): void { - this.vars.set(key, value); - } - - getVar(key: string): number | string | boolean | undefined { - return this.vars.get(key); - } - - getNumber(key: string): number { - const v = this.vars.get(key); - return typeof v === 'number' ? v : 0; - } - - getString(key: string): string { - const v = this.vars.get(key); - return typeof v === 'string' ? v : ''; - } - - getBool(key: string): boolean { - const v = this.vars.get(key); - return v === true || typeof v === 'number' && v !== 0; - } - - // --- сериализация --- - - serialize(): GameStateData { - return { - version: this.dataVersion, - flags: [...this.flags], - vars: Object.fromEntries(this.vars) - }; - } - - /** Загрузить данные (с применением миграций). Восстанавливает состояние поверх текущего. */ - load(data: GameStateData): void { - let d = data; - // Контракт: миграция сама повышает version. Если она забыла — - // выходим, как только данные догнали текущую версию, чтобы не - // применить следующую миграцию к уже повышенным данным дважды. - for (const m of this.migrations) { - if (d.version >= this.dataVersion) break; - d = m(d); - } - this.dataVersion = d.version; - this.flags = new Set(d.flags); - this.vars = new Map(Object.entries(d.vars)); - } - - /** Сбросить всё (новая игра). */ - reset(): void { - this.flags.clear(); - this.vars.clear(); - } -} \ No newline at end of file diff --git a/packages/engine/src/core/Settings.ts b/packages/engine/src/core/Settings.ts deleted file mode 100644 index d9344ec..0000000 --- a/packages/engine/src/core/Settings.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Настройки игрока: громкости, язык, прочие предпочтения. - * Отдельный стор поверх SaveManager (слот «settings»), не попадает в сейвы игры. - */ - -import type { StorageLike } from '../save/SaveManager'; - -export interface SettingsData { - /** Громкости 0..1. */ - master: number; - music: number; - sfx: number; - /** Громкость амбиента мира (фоновые лупы областей и локальные слои). */ - ambience: number; - /** Язык локализации (BCP-47, например 'ru'). */ - lang: string; - /** Прочие настройки игры (произвольные пары). */ - extra: Record; -} - -export const DEFAULT_SETTINGS: SettingsData = { - master: 1, - music: 1, - sfx: 1, - ambience: 1, - lang: 'ru', - extra: {} -}; - -/** Интерфейс подписки на изменения (например, AudioManager применяет громкости). */ -export type SettingsListener = (s: SettingsData) => void; - -export class Settings { - private current: SettingsData = { ...DEFAULT_SETTINGS, extra: {} }; - private listeners = new Set(); - - constructor(private storage: StorageLike, private slot = 'settings') {} - - get data(): Readonly { - return this.current; - } - - /** Загрузить из хранилища; отсутствующий/битый слот — настройки по умолчанию. */ - load(): void { - try { - const raw = this.storage.getItem(this.slot); - if (raw) { - const parsed = JSON.parse(raw) as Partial; - this.current = { - ...DEFAULT_SETTINGS, - ...parsed, - extra: { ...(parsed.extra ?? {}) } - }; - } - } catch { - this.current = { ...DEFAULT_SETTINGS, extra: {} }; - } - } - - /** Изменить часть настроек, сохранить и уведомить слушателей. */ - update(patch: Partial> & { extra?: Record }): void { - this.current = { - ...this.current, - ...patch, - extra: { ...this.current.extra, ...(patch.extra ?? {}) } - }; - this.save(); - this.emit(); - } - - /** Вернуть настройку из extra (или undefined). */ - getExtra(key: string): number | string | boolean | undefined { - return this.data.extra[key]; - } - - onChange(listener: SettingsListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private save(): void { - try { - this.storage.setItem(this.slot, JSON.stringify(this.data)); - } catch { - // хранилище может быть недоступно (приватный режим) — работаем в памяти - } - } - - private emit(): void { - for (const l of this.listeners) l(this.data); - } -} \ No newline at end of file diff --git a/packages/engine/src/core/StateMachine.ts b/packages/engine/src/core/StateMachine.ts deleted file mode 100644 index b802273..0000000 --- a/packages/engine/src/core/StateMachine.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Конечный автомат: состояния с enter/exit/update и переходы по событиям. - * Используется для AI врагов, состояний игрока (idle/run/attack), экранов меню и т.п. - */ - -export interface StateDef { - /** Вызывается при входе в состояние. */ - enter?: () => void; - exit?: () => void; - /** dt в секундах, фиксированный шаг. */ - update?: (dt: number) => void; -} - -export class StateMachine { - private states = new Map(); - /** (состояние, событие) -> состояние-цель. */ - private transitions = new Map(); - /** Переходы «из любого состояния» (кроме self-loop): событие -> цель. */ - private anyTransitions = new Map(); - private currentName: string | null = null; - private timeInState = 0; - - constructor( - /** Неизвестное событие в текущем состоянии (по умолчанию — просто игнор). */ - private onUnhandled?: (state: string, event: string) => void - ) {} - - add(name: string, def: StateDef): this { - this.states.set(name, def); - return this; - } - - /** Переход из состояния `from` по событию `event` в состояние `to`. */ - transition(from: string, event: string, to: string): this { - if (!this.states.has(to)) { - throw new Error(`Переход в неизвестное состояние: ${to}`); - } - this.transitions.set(`${from}::${event}`, to); - return this; - } - - /** Перейти в состояние (exit текущего -> enter нового). Повторный вход в то же состояние игнорируется. */ - change(name: string): void { - if (!this.states.has(name)) { - throw new Error(`Неизвестное состояние: ${name}`); - } - if (this.currentName === name) return; - if (this.currentName) this.states.get(this.currentName)!.exit?.(); - this.currentName = name; - this.timeInState = 0; - this.states.get(name)!.enter?.(); - } - - /** - * Переход по событию из ЛЮБОГО состояния (кроме повторного входа в цель). - * Удобно для hurt/die в бою: не регистрировать переход из каждого состояния. - * Обычные переходы имеют приоритет над transitionAny. - */ - transitionAny(event: string, to: string): this { - if (!this.states.has(to)) { - throw new Error(`Переход в неизвестное состояние: ${to}`); - } - this.anyTransitions.set(event, to); - return this; - } - - /** Обработать событие: обычный переход текущего состояния, затем «из любого». */ - handleEvent(event: string): void { - if (!this.currentName) return; - const to = this.transitions.get(`${this.currentName}::${event}`) ?? this.anyTransitions.get(event); - if (to) { - this.change(to); - } else { - this.onUnhandled?.(this.currentName, event); - } - } - - get current(): string | null { - return this.currentName; - } - - /** Секунд с момента входа в текущее состояние. */ - get time(): number { - return this.timeInState; - } - - update(dt: number): void { - if (!this.currentName) return; - this.timeInState += dt; - this.states.get(this.currentName)!.update?.(dt); - } - - reset(): void { - this.currentName = null; - this.timeInState = 0; - } -} \ No newline at end of file diff --git a/packages/engine/src/core/Tween.ts b/packages/engine/src/core/Tween.ts deleted file mode 100644 index b747294..0000000 --- a/packages/engine/src/core/Tween.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { EaseFn, linear } from './easing'; - -/** - * Твины и таймеры: плавное изменение числовых свойств объекта и отложенные вызовы. - * Менеджер обновляется игровым циклом (движок делает это сам в Engine.tick). - */ - -export interface TweenOptions { - /** Длительность в секундах. */ - duration: number; - /** Функция сглаживания (по умолчанию linear). */ - ease?: EaseFn; - /** Задержка до старта в секундах. */ - delay?: number; - /** Вызывается по завершении. */ - onDone?: () => void; -} - -/** Управление конкретным твином/таймером: можно отменить. */ -export interface TweenHandle { - cancel(): void; -} - -interface ActiveTween { - target: Record; - props: Record; - from: Record | null; // снимается при старте (после delay) - elapsed: number; - options: TweenOptions; - cancelled: boolean; -} - -interface ActiveDelay { - remaining: number; - fn: () => void; - cancelled: boolean; -} - -export class TweenManager { - private tweens: ActiveTween[] = []; - private delays: ActiveDelay[] = []; - - /** - * Плавно привести числовые свойства target к заданным значениям. - * Начальные значения снимаются в момент старта (после delay). - */ - to(target: object, props: Record, options: TweenOptions): TweenHandle { - const tween: ActiveTween = { - target: target as Record, - props, - from: null, - elapsed: 0, - options: { ease: linear, ...options }, - cancelled: false - }; - this.tweens.push(tween); - return { cancel: () => (tween.cancelled = true) }; - } - - /** Вызвать fn через seconds секунд. */ - delay(seconds: number, fn: () => void): TweenHandle { - const d: ActiveDelay = { remaining: seconds, fn, cancelled: false }; - this.delays.push(d); - return { cancel: () => (d.cancelled = true) }; - } - - /** Отменить все твины, изменяющие target (таймеры не привязаны к target). */ - cancelFor(target: object): void { - for (const t of this.tweens) { - if (t.target === target) t.cancelled = true; - } - this.tweens = this.tweens.filter((t) => !t.cancelled); - } - - /** Отменить всё. */ - clear(): void { - for (const t of this.tweens) t.cancelled = true; - for (const d of this.delays) d.cancelled = true; - this.tweens = []; - this.delays = []; - } - - /** dt в секундах (фиксированный шаг). */ - update(dt: number): void { - // Твины: компакт на месте (без filter — тик не должен аллоцировать). - let w = 0; - for (const t of this.tweens) { - if (t.cancelled) continue; - const delay = t.options.delay ?? 0; - let dtLeft = dt; - if (t.elapsed < delay) { - const skipped = Math.min(dtLeft, delay - t.elapsed); - t.elapsed += skipped; - dtLeft -= skipped; - if (dtLeft <= 0) { - this.tweens[w++] = t; // dt целиком ушёл в delay — твин остаётся - continue; - } - } - if (!t.from) { - t.from = {}; - for (const key of Object.keys(t.props)) { - t.from[key] = t.target[key] ?? 0; - } - } - t.elapsed += dtLeft; - const raw = Math.min(1, (t.elapsed - (t.options.delay ?? 0)) / t.options.duration); - const k = t.options.ease!(raw); - for (const key of Object.keys(t.props)) { - t.target[key] = t.from[key] + (t.props[key] - t.from[key]) * k; - } - if (raw >= 1) { - t.cancelled = true; - t.options.onDone?.(); - } - if (!t.cancelled) this.tweens[w++] = t; - } - this.tweens.length = w; - - // Таймеры - w = 0; - for (const d of this.delays) { - if (d.cancelled) continue; - d.remaining -= dt; - if (d.remaining <= 0) { - d.cancelled = true; - d.fn(); - } - if (!d.cancelled) this.delays[w++] = d; - } - this.delays.length = w; - } - - get activeCount(): number { - return this.tweens.filter((t) => !t.cancelled).length + this.delays.filter((d) => !d.cancelled).length; - } -} \ No newline at end of file diff --git a/packages/engine/src/core/Updatables.ts b/packages/engine/src/core/Updatables.ts deleted file mode 100644 index 1e00c86..0000000 --- a/packages/engine/src/core/Updatables.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Реестр объектов, тикаемых движком от фиксированного шага: эффекты, - * аниматоры, вспышки. Инстанс живёт в Engine (`engine.fx`) и тикается - * между tween-ами и сценами; допускается и локальный инстанс в сцене. - * - * Владение отсутствует: запись с `destroyed === true` (Pixi-объекты - * выставляют его при destroy) вычищается сама при очередном update. - */ - -/** Всё, что движок может тикать; destroyed — признак самозачистки. */ -export interface Updatable { - update(dt: number): void; - destroyed?: boolean; -} - -export class Updatables { - private list: Updatable[] = []; - - /** Добавить и вернуть тот же объект — удобно: `engine.fx.add(emitter)`. */ - add(u: Updatable): Updatable { - if (!this.list.includes(u)) this.list.push(u); - return u; - } - - remove(u: Updatable): void { - const i = this.list.indexOf(u); - if (i !== -1) this.list.splice(i, 1); - } - - clear(): void { - this.list.length = 0; - } - - /** Обойти живые записи (например, чтобы погасить все эффекты слоя). */ - forEach(fn: (u: Updatable) => void): void { - for (const u of this.list) fn(u); - } - - /** - * Тик в порядке добавления; разрушенные записи не тикаются (Pixi после - * destroy обнуляет transform — касаться их нельзя) и вычищаются - * компактом на месте (без filter — тик не должен аллоцировать). - */ - update(dt: number): void { - let w = 0; - for (const u of this.list) { - if (u.destroyed) continue; - u.update(dt); - if (!u.destroyed) this.list[w++] = u; // update мог разрушить сам объект - } - this.list.length = w; - } - - get count(): number { - return this.list.length; - } -} \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/Cooldown.test.ts b/packages/engine/src/core/__tests__/Cooldown.test.ts deleted file mode 100644 index 3ac915f..0000000 --- a/packages/engine/src/core/__tests__/Cooldown.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Cooldown } from '../Cooldown'; - -describe('Cooldown', () => { - it('изначально готов', () => { - const cd = new Cooldown(1); - expect(cd.ready).toBe(true); - expect(cd.timeRemaining).toBe(0); - expect(cd.progress).toBe(0); - }); - - it('trigger запускает отсчёт, update уменьшает', () => { - const cd = new Cooldown(0.5); - expect(cd.trigger()).toBe(true); - expect(cd.ready).toBe(false); - cd.update(0.2); - expect(cd.timeRemaining).toBeCloseTo(0.3); - cd.update(0.3); - expect(cd.ready).toBe(true); - }); - - it('trigger не срабатывает, пока не готов', () => { - const cd = new Cooldown(1); - cd.trigger(); - expect(cd.trigger()).toBe(false); - cd.update(1); - expect(cd.trigger()).toBe(true); - }); - - it('triggerForce перебивает', () => { - const cd = new Cooldown(2); - cd.trigger(); - cd.update(1.5); - cd.triggerForce(); - expect(cd.timeRemaining).toBeCloseTo(2); - }); - - it('progress: 1 сразу после trigger, 0 к концу', () => { - const cd = new Cooldown(1); - cd.trigger(); - expect(cd.progress).toBeCloseTo(1); - cd.update(0.5); - expect(cd.progress).toBeCloseTo(0.5); - cd.update(0.5); - expect(cd.progress).toBe(0); - }); - - it('обновление больше остатка не уводит в минус', () => { - const cd = new Cooldown(0.1); - cd.trigger(); - cd.update(5); - expect(cd.ready).toBe(true); - expect(cd.timeRemaining).toBe(0); - }); - - it('нулевой кулдаун всегда готов', () => { - const cd = new Cooldown(0); - cd.triggerForce(); - expect(cd.ready).toBe(true); - expect(cd.progress).toBe(0); - }); - - it('reset возвращает готовность', () => { - const cd = new Cooldown(3); - cd.trigger(); - cd.reset(); - expect(cd.ready).toBe(true); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/Engine.test.ts b/packages/engine/src/core/__tests__/Engine.test.ts deleted file mode 100644 index 1569351..0000000 --- a/packages/engine/src/core/__tests__/Engine.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -// Pixi-зависимости — минимальные стабы: Engine в Node не инициализирует WebGL -// (init() не зовётся), но конструктор Renderer создаёт Application сразу. -vi.mock('pixi.js', () => { - class Container { - position = { set: () => undefined }; - scale = { set: () => undefined }; - visible = true; - destroyed = false; - addChild(..._c: unknown[]) { - return this; - } - destroy(_opts?: unknown): void {} - } - class Graphics extends Container {} - class Application { - stage = new Container(); - canvas = { style: {} as Record }; - renderer = { render: () => undefined }; - async init(): Promise {} - render(): void {} - destroy(_a?: unknown, _b?: unknown): void {} - } - class Text extends Container {} - return { Container, Graphics, Application, Text }; -}); - -import { Engine } from '../Engine'; -import type { Scene } from '../../scene/SceneManager'; - -function makeEngine(): Engine { - return new Engine({ - virtualWidth: 480, - virtualHeight: 270, - scale: 2, - background: 0x000000, - parent: {} as HTMLElement, - fixedFps: 60 - }); -} - -function makeScene(update: (dt: number) => void = () => {}, render: () => void = () => {}): Scene { - return { enter: () => {}, exit: () => {}, update, render }; -} - -describe('Engine (шаги без цикла)', () => { - it('stepTick: фиксированный шаг, dt = 1/fps в сцену, событие engine:tick', () => { - const engine = makeEngine(); - const dts: number[] = []; - void engine.scenes.push(makeScene((dt) => dts.push(dt))); - const ticks: number[] = []; - engine.events.on<{ tick: number }>('engine:tick', (p) => ticks.push(p!.tick)); - - engine.stepTicks(3); - - expect(engine.tickCount).toBe(3); - expect(dts).toEqual([1 / 60, 1 / 60, 1 / 60]); - expect(ticks).toEqual([1, 2, 3]); - }); - - it('stepTicks(render: true) вызывает render сцены', () => { - const engine = makeEngine(); - let renders = 0; - void engine.scenes.push(makeScene(() => {}, () => renders++)); - - engine.stepTicks(2, true); - expect(renders).toBe(2); - - renders = 0; - engine.stepTicks(2, false); // без рендера — только логика - expect(renders).toBe(0); - }); - - it('tickCount копится между вызовами, fixedStep = 1/fps', () => { - const engine = makeEngine(); - expect(engine.fixedStep).toBeCloseTo(1 / 60, 10); - engine.stepTicks(5); - expect(engine.tickCount).toBe(5); - engine.stepTicks(2); - expect(engine.tickCount).toBe(7); - }); - - it('исключение в шаге не оставляет счётчики вранья: тик не засчитан', () => { - const engine = makeEngine(); - void engine.scenes.push( - makeScene(() => { - throw new Error('бум'); - }) - ); - expect(() => engine.stepTick()).toThrow('бум'); - // Счётчик и событие не продвинулись — шаг не доигран. - expect(engine.tickCount).toBe(0); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/EventBus.test.ts b/packages/engine/src/core/__tests__/EventBus.test.ts deleted file mode 100644 index f946f2a..0000000 --- a/packages/engine/src/core/__tests__/EventBus.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { EventBus } from '../EventBus'; - -describe('EventBus', () => { - it('emit доставляет payload подписчикам темы', () => { - const bus = new EventBus(); - const got: number[] = []; - bus.on('hit', (n) => got.push(n)); - bus.emit('hit', 5); - bus.emit('hit', 7); - expect(got).toEqual([5, 7]); - }); - - it('чужая тема не получает событий; emit без подписчиков — no-op', () => { - const bus = new EventBus(); - const got: string[] = []; - bus.on('a', (s) => got.push(s)); - expect(() => bus.emit('b', 'x')).not.toThrow(); - bus.emit('a', 'y'); - expect(got).toEqual(['y']); - }); - - it('on возвращает off-функцию: отписка снимает только этот хендлер', () => { - const bus = new EventBus(); - const a = vi.fn(); - const b = vi.fn(); - const offA = bus.on('t', a); - bus.on('t', b); - - offA(); - bus.emit('t'); - expect(a).not.toHaveBeenCalled(); - expect(b).toHaveBeenCalledTimes(1); - }); - - it('повторная off-отписка не роняет (idempotent)', () => { - const bus = new EventBus(); - const fn = vi.fn(); - const off = bus.on('t', fn); - off(); - expect(() => off()).not.toThrow(); - }); - - it('emit во время обработки: подписка внутри хендлера не роняет обход (снапшот Set)', () => { - const bus = new EventBus(); - const late = vi.fn(); - let added = false; - bus.on('t', () => { - if (!added) { - added = true; - bus.on('t', late); - } - }); - - bus.emit('t'); // снапшот на обходе: late на этом emit ещё не зовётся - expect(late).not.toHaveBeenCalled(); - bus.emit('t'); - expect(late).toHaveBeenCalledTimes(1); - }); - - it('clear снимает все подписки', () => { - const bus = new EventBus(); - const fn = vi.fn(); - bus.on('t', fn); - bus.clear(); - bus.emit('t'); - expect(fn).not.toHaveBeenCalled(); - }); - - it('один хендлер, подписанный дважды, зовётся один раз (Set)', () => { - const bus = new EventBus(); - const fn = vi.fn(); - bus.on('t', fn); - bus.on('t', fn); - bus.emit('t'); - expect(fn).toHaveBeenCalledTimes(1); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/FpsMeter.test.ts b/packages/engine/src/core/__tests__/FpsMeter.test.ts deleted file mode 100644 index 2bf7708..0000000 --- a/packages/engine/src/core/__tests__/FpsMeter.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it, vi, afterEach } from 'vitest'; - -// Pixi-зависимости (Text из PixelText, Graphics, Container) — минимальные стабы. -vi.mock('pixi.js', () => { - class Container { - children: unknown[] = []; - position = { set: () => undefined }; - visible = true; - addChild(...c: unknown[]) { - this.children.push(...c); - return this; - } - } - class Text { - text = ''; - width = 30; - height = 10; - position = { set: () => undefined }; - constructor(opts?: { text?: string }) { - this.text = opts?.text ?? ''; - } - } - class Graphics { - clear() { - return this; - } - rect() { - return this; - } - fill() { - return this; - } - stroke() { - return this; - } - } - return { Container, Text, Graphics }; -}); - -import { FpsMeter } from '../FpsMeter'; - -/** Заглушка performance.now + keydown: клик по F3 и продвижение времени вручную. */ -function installEnv() { - let now = 0; - const listeners: ((e: { code: string; preventDefault: () => void }) => void)[] = []; - vi.stubGlobal('performance', { now: () => now }); - vi.stubGlobal('window', { - addEventListener: (_: string, cb: (e: { code: string; preventDefault: () => void }) => void) => listeners.push(cb), - removeEventListener: (_: string, cb: unknown) => { - const i = listeners.indexOf(cb as never); - if (i >= 0) listeners.splice(i, 1); - } - }); - return { - advance: (ms: number) => { - now += ms; - }, - pressF3: () => { - for (const cb of [...listeners]) cb({ code: 'F3', preventDefault: () => undefined }); - }, - listenerCount: () => listeners.length - }; -} - -/** Текст счётчика (второй ребёнок view — label). */ -function labelOf(meter: FpsMeter): string { - return (meter.view.children[1] as unknown as { text: string }).text; -} - -describe('FpsMeter', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('скрыт по умолчанию; F3 показывает, повторное — прячет', () => { - const env = installEnv(); - const meter = new FpsMeter(); - expect(meter.view.visible).toBe(false); - - env.pressF3(); - expect(meter.view.visible).toBe(true); - env.pressF3(); - expect(meter.view.visible).toBe(false); - - meter.dispose(); - expect(env.listenerCount()).toBe(0); - }); - - it('считает кадры и обновляет текст раз в ~полсекунды', () => { - const env = installEnv(); - const meter = new FpsMeter(); - meter.toggle(); // окно открыто на now = 0 - - env.advance(100); - meter.frame(); // кадр 1 - env.advance(16); - meter.frame(); // кадр 2 - expect(labelOf(meter)).toBe('FPS --'); // окно не дозрело - - env.advance(420); // суммарно ~536 мс с показа - meter.frame(); - expect(labelOf(meter)).toBe('FPS 6'); // 3 кадра за 536 мс - - meter.dispose(); - }); - - it('после нового показа окно чистое: счётчики сброшены toggle-ом', () => { - const env = installEnv(); - const meter = new FpsMeter(); - meter.toggle(); - env.advance(600); - meter.frame(); // 1 кадр за 600 мс - expect(labelOf(meter)).toBe('FPS 2'); - - meter.toggle(); // скрыть - env.advance(600); - meter.frame(); // скрыт — не считается - meter.toggle(); // показать заново: счётчики сброшены - env.advance(600); - meter.frame(); - expect(labelOf(meter)).toBe('FPS 2'); // без утечки прежних кадров - - meter.dispose(); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/GameLoop.timing.test.ts b/packages/engine/src/core/__tests__/GameLoop.timing.test.ts deleted file mode 100644 index 9d92b3d..0000000 --- a/packages/engine/src/core/__tests__/GameLoop.timing.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { GameLoop } from '../GameLoop'; - -/** Заглушки rAF: frame() продвигает кадры вручную. */ -function installRaf(): (advanceMs: number) => void { - let now = 0; - const callbacks: ((t: number) => void)[] = []; - vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => { - callbacks.push(cb); - return callbacks.length; - }); - vi.stubGlobal('cancelAnimationFrame', () => undefined); - vi.stubGlobal('performance', { now: () => now }); - return (advanceMs: number) => { - now += advanceMs; - const list = [...callbacks]; - callbacks.length = 0; - for (const cb of list) cb(now); - }; -} - -describe('GameLoop — ручной режим', () => { - it('setManual(true) гасит кадры, resetTiming не даёт догонять', () => { - const frame = installRaf(); - const updates: number[] = []; - const loop = new GameLoop(60, { - update: (dt) => updates.push(dt), - render: () => undefined - }); - loop.start(); - frame(1000); // ~60 шагов сгущаются в максимум 5 - expect(updates.length).toBe(5); - - loop.setManual(true); - frame(1000); - expect(updates.length).toBe(5); // кадры в ручном режиме пустые - - loop.setManual(false); - frame(20); // resetTiming в setManual(true) обнулил аккумулятор — без «догоняния» - expect(updates.length).toBe(6); // ровно один шаг, а не 5+5 - loop.stop(); - vi.unstubAllGlobals(); - }); -}); - -describe('GameLoop — лок кадров (maxFps)', () => { - it('рендер не чаще лимита, шаги копятся и идут пачкой', () => { - const frame = installRaf(); - const updates: number[] = []; - let renders = 0; - const loop = new GameLoop( - 60, - { - update: (dt) => updates.push(dt), - render: () => renders++ - }, - { maxFps: 30 } - ); - loop.start(); - - frame(17); // 17 < 32-1: кадр срезан, ни рендера, ни шагов - expect(renders).toBe(0); - expect(updates.length).toBe(0); - - frame(17); // 34 мс с прошлого рендера: один кадр = 2 шага 60 Гц - expect(renders).toBe(1); - expect(updates.length).toBe(2); - expect(updates[0]).toBeCloseTo(1 / 60); - - frame(17); // снова срезан - frame(100); // крупный скачок: догоняет пачкой до maxStepsPerFrame - expect(renders).toBe(2); - expect(updates.length).toBe(7); // 2 + 5 (лимит за кадр) - loop.stop(); - vi.unstubAllGlobals(); - }); - - it('maxFps = 0 (по умолчанию) — без лока: рендер на каждом rAF', () => { - const frame = installRaf(); - let renders = 0; - const loop = new GameLoop(60, { - update: () => undefined, - render: () => renders++ - }); - loop.start(); - frame(16); - frame(16); - expect(renders).toBe(2); - loop.stop(); - vi.unstubAllGlobals(); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/GameState.test.ts b/packages/engine/src/core/__tests__/GameState.test.ts deleted file mode 100644 index 5f02f4f..0000000 --- a/packages/engine/src/core/__tests__/GameState.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { GameState } from '../GameState'; - -describe('GameState', () => { - it('флаги устанавливаются, проверяются и снимаются', () => { - const s = new GameState(); - expect(s.hasFlag('met_elder')).toBe(false); - s.setFlag('met_elder'); - expect(s.hasFlag('met_elder')).toBe(true); - s.clearFlag('met_elder'); - expect(s.hasFlag('met_elder')).toBe(false); - }); - - it('переменные типизированы по геттерам', () => { - const s = new GameState(); - s.setVar('gold', 42); - s.setVar('name', 'Ирвин'); - expect(s.getNumber('gold')).toBe(42); - expect(s.getString('name')).toBe('Ирвин'); - expect(s.getNumber('name')).toBe(0); // не число -> 0 - expect(s.getString('gold')).toBe(''); // не строка -> '' - expect(s.getVar('missing')).toBeUndefined(); - }); - - it('serialize/load сохраняет состояние', () => { - const s = new GameState(); - s.setFlag('a'); - s.setVar('hp', 7); - const data = s.serialize(); - - const s2 = new GameState(); - s2.load(data); - expect(s2.hasFlag('a')).toBe(true); - expect(s2.getNumber('hp')).toBe(7); - expect(s2.dataVersion).toBe(1); - }); - - it('миграция применяется к сейву старой версии', () => { - const s = new GameState(); - s.dataVersion = 2; - s.addMigration((d) => ({ - ...d, - version: 2, - vars: { ...d.vars, gold: 100 } // в v2 появилось золото - })); - - s.load({ version: 1, flags: ['x'], vars: {} }); - expect(s.dataVersion).toBe(2); - expect(s.getNumber('gold')).toBe(100); - expect(s.hasFlag('x')).toBe(true); - }); - - it('две миграции применяются по порядку, по одной на версию', () => { - const s = new GameState(); - s.dataVersion = 3; - s.addMigration((d) => ({ ...d, version: 2, flags: [...d.flags, 'step1'] })); - s.addMigration((d) => ({ ...d, version: 3, flags: [...d.flags, 'step2'] })); - - s.load({ version: 1, flags: [], vars: {} }); - expect(s.allFlags).toEqual(['step1', 'step2']); - }); - - it('миграция без повышения version не применяется дважды', () => { - const s = new GameState(); - s.dataVersion = 2; - // Миграция нарушает контракт (забыла version) — цикл всё равно - // не даёт следующей миграции испортить данные. - s.addMigration((d) => ({ ...d, vars: { ...d.vars, n: Number(d.vars.n ?? 0) + 1 } })); - s.addMigration((d) => ({ ...d, version: 2, flags: ['done'] })); - - s.load({ version: 1, flags: [], vars: {} }); - expect(s.getNumber('n')).toBe(1); - expect(s.hasFlag('done')).toBe(true); - }); - - it('reset очищает всё', () => { - const s = new GameState(); - s.setFlag('a'); - s.setVar('b', 1); - s.reset(); - expect(s.allFlags).toEqual([]); - expect(s.getVar('b')).toBeUndefined(); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/Settings.test.ts b/packages/engine/src/core/__tests__/Settings.test.ts deleted file mode 100644 index 339dbae..0000000 --- a/packages/engine/src/core/__tests__/Settings.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Settings } from '../Settings'; -import type { StorageLike } from '../../save/SaveManager'; - -function memoryStorage(): StorageLike & { store: Map } { - const store = new Map(); - return { - store, - getItem: (k) => store.get(k) ?? null, - setItem: (k, v) => void store.set(k, v), - removeItem: (k) => void store.delete(k), - key: (i) => [...store.keys()][i] ?? null, - get length() { - return store.size; - } - }; -} - -describe('Settings', () => { - it('отсутствующий слот даёт настройки по умолчанию', () => { - const s = new Settings(memoryStorage()); - s.load(); - expect(s.data.master).toBe(1); - expect(s.data.lang).toBe('ru'); - }); - - it('update применяет патч, сохраняет в хранилище и уведомляет слушателей', () => { - const storage = memoryStorage(); - const s = new Settings(storage); - s.load(); - - let notified = 0; - s.onChange(() => notified++); - s.update({ master: 0.5, music: 0.2 }); - - expect(notified).toBe(1); - expect(s.data.master).toBe(0.5); - const saved = JSON.parse(storage.store.get('settings')!); - expect(saved.master).toBe(0.5); - expect(saved.music).toBe(0.2); - - // новый экземпляр читает то же - const s2 = new Settings(storage); - s2.load(); - expect(s2.data.master).toBe(0.5); - }); - - it('битый JSON в хранилище не роняет загрузку', () => { - const storage = memoryStorage(); - storage.store.set('settings', '{oops'); - const s = new Settings(storage); - expect(() => s.load()).not.toThrow(); - expect(s.data.master).toBe(1); - }); - - it('extra хранит произвольные значения', () => { - const s = new Settings(memoryStorage()); - s.load(); - s.update({ extra: { hintSeen: true } }); - expect(s.getExtra('hintSeen')).toBe(true); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/StateMachine.test.ts b/packages/engine/src/core/__tests__/StateMachine.test.ts deleted file mode 100644 index e2f7bea..0000000 --- a/packages/engine/src/core/__tests__/StateMachine.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { StateMachine } from '../StateMachine'; - -describe('StateMachine', () => { - it('change вызывает exit предыдущего и enter нового', () => { - const log: string[] = []; - const sm = new StateMachine(); - sm.add('idle', { - enter: () => log.push('enter:idle'), - exit: () => log.push('exit:idle') - }); - sm.add('walk', { - enter: () => log.push('enter:walk') - }); - - sm.change('idle'); - sm.change('walk'); - expect(log).toEqual(['enter:idle', 'exit:idle', 'enter:walk']); - expect(sm.current).toBe('walk'); - }); - - it('повторный вход в то же состояние игнорируется', () => { - let enters = 0; - const sm = new StateMachine(); - sm.add('a', { enter: () => enters++ }); - sm.change('a'); - sm.change('a'); - expect(enters).toBe(1); - }); - - it('handleEvent выполняет переход по таблице', () => { - const sm = new StateMachine(); - sm.add('patrol', {}); - sm.add('chase', {}); - sm.transition('patrol', 'seePlayer', 'chase'); - sm.transition('chase', 'losePlayer', 'patrol'); - - sm.change('patrol'); - sm.handleEvent('seePlayer'); - expect(sm.current).toBe('chase'); - sm.handleEvent('losePlayer'); - expect(sm.current).toBe('patrol'); - }); - - it('неизвестное событие попадает в onUnhandled', () => { - let handled: string | undefined; - const sm = new StateMachine((state, event) => (handled = `${state}:${event}`)); - sm.add('idle', {}); - sm.change('idle'); - sm.handleEvent('boom'); - expect(handled).toBe('idle:boom'); - expect(sm.current).toBe('idle'); - }); - - it('update тикает текущее состояние и считает время в нём', () => { - let t = 0; - const sm = new StateMachine(); - sm.add('a', { update: (dt) => (t += dt) }); - sm.add('b', {}); - sm.change('a'); - sm.update(0.5); - sm.update(0.5); - expect(t).toBeCloseTo(1); - expect(sm.time).toBeCloseTo(1); - sm.change('b'); - expect(sm.time).toBe(0); - }); - - it('change в неизвестное состояние бросает', () => { - const sm = new StateMachine(); - expect(() => sm.change('nope')).toThrow(); - }); - - it('transitionAny срабатывает из любого состояния', () => { - const sm = new StateMachine(); - sm.add('idle', {}); - sm.add('chase', {}); - sm.add('windup', {}); - sm.add('dead', { enter: () => deadEntered++ }); - sm.transition('idle', 'wake', 'chase'); - sm.transitionAny('die', 'dead'); - let deadEntered = 0; - - sm.change('idle'); - sm.handleEvent('die'); - expect(sm.current).toBe('dead'); - expect(deadEntered).toBe(1); - - // и после смены состояния переход «из любого» продолжает работать - sm.change('windup'); - sm.handleEvent('die'); - expect(sm.current).toBe('dead'); - expect(deadEntered).toBe(2); - }); - - it('обычный переход приоритетнее transitionAny', () => { - const sm = new StateMachine(); - sm.add('a', {}); - sm.add('b', {}); - sm.add('c', {}); - sm.transitionAny('go', 'b'); - sm.transition('a', 'go', 'c'); - sm.change('a'); - sm.handleEvent('go'); - expect(sm.current).toBe('c'); - }); - - it('transitionAny в неизвестное состояние бросает', () => { - const sm = new StateMachine(); - sm.add('a', {}); - expect(() => sm.transitionAny('go', 'nope')).toThrow(); - }); - - it('transitionAny не даёт повторного входа в то же состояние', () => { - let enters = 0; - const sm = new StateMachine(); - sm.add('loop', { enter: () => enters++ }); - sm.transitionAny('again', 'loop'); - sm.change('loop'); - sm.handleEvent('again'); - expect(enters).toBe(1); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/Tween.test.ts b/packages/engine/src/core/__tests__/Tween.test.ts deleted file mode 100644 index 72264b2..0000000 --- a/packages/engine/src/core/__tests__/Tween.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { TweenManager } from '../Tween'; -import { linear, quadOut, cubicIn, sineInOut, quadIn, cubicOut } from '../easing'; - -describe('easing', () => { - it('края области определения всегда 0 и 1', () => { - for (const e of [linear, quadIn, quadOut, cubicIn, cubicOut, sineInOut]) { - expect(e(0)).toBeCloseTo(0); - expect(e(1)).toBeCloseTo(1); - } - }); - - it('quadOut в середине быстрее linear', () => { - expect(quadOut(0.5)).toBeGreaterThan(0.5); - expect(cubicIn(0.5)).toBeLessThan(0.5); - }); -}); - -describe('TweenManager', () => { - it('линейный твин доходит до цели за duration', () => { - const tw = new TweenManager(); - const obj = { x: 0 }; - tw.to(obj, { x: 10 }, { duration: 2 }); - - tw.update(1); - expect(obj.x).toBeCloseTo(5); - tw.update(1); - expect(obj.x).toBeCloseTo(10); - expect(tw.activeCount).toBe(0); - }); - - it('не проходит цель и вызывает onDone один раз', () => { - const tw = new TweenManager(); - const obj = { y: 0 }; - let done = 0; - tw.to(obj, { y: 8 }, { duration: 1, onDone: () => done++ }); - - tw.update(0.7); - expect(obj.y).toBeCloseTo(5.6); - tw.update(0.7); // переполнение — зажимается - expect(obj.y).toBeCloseTo(8); - expect(done).toBe(1); - - tw.update(5); - expect(done).toBe(1); - }); - - it('ease применяется к прогрессу', () => { - const tw = new TweenManager(); - const obj = { v: 0 }; - tw.to(obj, { v: 100 }, { duration: 2, ease: cubicIn }); - tw.update(1); - expect(obj.v).toBeCloseTo(100 * cubicIn(0.5)); - expect(obj.v).toBeLessThan(50); - }); - - it('delay откладывает старт: значения не трогаются до истечения', () => { - const tw = new TweenManager(); - const obj = { x: 1 }; - tw.to(obj, { x: 5 }, { duration: 1, delay: 0.5 }); - - tw.update(0.4); - expect(obj.x).toBe(1); // ещё в delay - tw.update(0.6); // delay закончился, первый кадр движения - expect(obj.x).toBeGreaterThan(1); - tw.update(1); - expect(obj.x).toBeCloseTo(5); - }); - - it('delay(seconds, fn) вызывает fn один раз', () => { - const tw = new TweenManager(); - let calls = 0; - tw.delay(0.5, () => calls++); - tw.update(0.3); - expect(calls).toBe(0); - tw.update(0.3); - expect(calls).toBe(1); - tw.update(1); - expect(calls).toBe(1); - }); - - it('cancel останавливает твин', () => { - const tw = new TweenManager(); - const obj = { x: 0 }; - const h = tw.to(obj, { x: 10 }, { duration: 1 }); - tw.update(0.5); - h.cancel(); - tw.update(0.5); - expect(obj.x).toBeCloseTo(5); - expect(tw.activeCount).toBe(0); - }); - - it('cancelFor отменяет только твины целевого объекта', () => { - const tw = new TweenManager(); - const a = { x: 0 }; - const b = { x: 0 }; - tw.to(a, { x: 10 }, { duration: 1 }); - tw.to(b, { x: 10 }, { duration: 1 }); - tw.cancelFor(a); - tw.update(1); - expect(a.x).toBe(0); - expect(b.x).toBeCloseTo(10); - }); - - it('начальные значения снимаются при старте (после delay), а не при создании', () => { - const tw = new TweenManager(); - const obj = { x: 0 }; - tw.to(obj, { x: 10 }, { duration: 1, delay: 1 }); - obj.x = 4; // изменили до старта - tw.update(1.5); // delay прошёл, половина движения - expect(obj.x).toBeCloseTo(7); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/Updatables.test.ts b/packages/engine/src/core/__tests__/Updatables.test.ts deleted file mode 100644 index 33d854d..0000000 --- a/packages/engine/src/core/__tests__/Updatables.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Updatables, type Updatable } from '../Updatables'; - -/** Фейковый тикаемый: считает вызовы, может пометить себя destroyed. */ -function make(log: string[], name: string, destroyed = false): Updatable { - return { - destroyed, - update(dt: number) { - log.push(`${name}@${dt}`); - } - }; -} - -describe('Updatables', () => { - it('тикает в порядке добавления', () => { - const log: string[] = []; - const r = new Updatables(); - r.add(make(log, 'a')); - r.add(make(log, 'b')); - r.update(1 / 60); - expect(log).toEqual(['a@0.016666666666666666', 'b@0.016666666666666666']); - expect(r.count).toBe(2); - }); - - it('add возвращает тот же объект (chaining)', () => { - const u: Updatable = { update() {} }; - expect(new Updatables().add(u)).toBe(u); - }); - - it('add не дублирует и remove убирает', () => { - const r = new Updatables(); - const u: Updatable = { update() {} }; - r.add(u); - r.add(u); - expect(r.count).toBe(1); - r.remove(u); - expect(r.count).toBe(0); - r.remove(u); // отсутствие — не ошибка - }); - - it('destroyed-записи не тикаются и вычищаются после прохода', () => { - const log: string[] = []; - const r = new Updatables(); - r.add(make(log, 'zombie', true)); - r.add(make(log, 'alive')); - r.update(0.1); - expect(log).toEqual(['alive@0.1']); // разрушенный не тикается (transform обнулён) - expect(r.count).toBe(1); - r.update(0.1); - expect(log).toEqual(['alive@0.1', 'alive@0.1']); - }); - - it('запись, разрушенная в чужом update, не тикается на следующем проходе', () => { - const log: string[] = []; - const r = new Updatables(); - const victim = make(log, 'victim'); - r.add(victim); - r.add({ - update(dt: number) { - log.push(`killer@${dt}`); - victim.destroyed = true; // сцена разрушила вьюху между тиками - } - }); - r.update(0.1); - expect(log).toEqual(['victim@0.1', 'killer@0.1']); - r.update(0.1); - expect(log).toEqual(['victim@0.1', 'killer@0.1', 'killer@0.1']); // victim молчит - expect(r.count).toBe(1); - }); - - it('clear опустошает реестр', () => { - const r = new Updatables(); - r.add({ update() {} }); - r.clear(); - expect(r.count).toBe(0); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/fade.test.ts b/packages/engine/src/core/__tests__/fade.test.ts deleted file mode 100644 index 22f6b34..0000000 --- a/packages/engine/src/core/__tests__/fade.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { FadeState } from '../fade'; - -const lerpNum = (a: number, b: number, k: number): number => a + (b - a) * k; - -describe('FadeState', () => { - it('мгновенно задаёт значение при seconds = 0', () => { - const f = new FadeState(lerpNum, 0); - f.to(0.7); - expect(f.value).toBe(0.7); - expect(f.target).toBe(0.7); - expect(f.active).toBe(false); - }); - - it('линейно идёт к цели и точно её достигает', () => { - const f = new FadeState(lerpNum, 0); - f.to(1, 2); - f.update(0.5); - expect(f.value).toBeCloseTo(0.25); - f.update(0.5); - expect(f.value).toBeCloseTo(0.5); - f.update(1); - expect(f.value).toBe(1); - expect(f.active).toBe(false); - }); - - it('тик за пределами остатка не уводит значение дальше цели', () => { - const f = new FadeState(lerpNum, 0); - f.to(1, 1); - f.update(0.25); - f.update(10); // остаток 0.75 — лишнее не интерполируется - expect(f.value).toBe(1); - expect(f.active).toBe(false); - }); - - it('повторный to() в середине фейда стартует от текущего значения', () => { - const f = new FadeState(lerpNum, 0); - f.to(1, 4); - f.update(1); // 0.25 - f.to(0, 2); - expect(f.origin).toBeCloseTo(0.25); - f.update(1); - expect(f.value).toBeCloseTo(0.125); - f.update(1); - expect(f.value).toBe(0); - }); - - it('update без активного фейда — no-op', () => { - const f = new FadeState(lerpNum, 5); - f.update(100); - expect(f.value).toBe(5); - }); - - it('работает с произвольным типом (lerp цветов)', () => { - // Простейший лерп RGB-упакованного цвета по каналам. - const lerpColor = (a: number, b: number, k: number): number => { - const ch = (c: number, s: number) => (c >> s) & 0xff; - let out = 0; - for (let s = 0; s < 24; s += 8) { - const v = Math.round(lerpNum(ch(a, s), ch(b, s), k)); - out |= v << s; - } - return out; - }; - const f = new FadeState(lerpColor, 0xffffff); - f.to(0x000000, 1); - f.update(0.5); - expect(f.value).toBe(0x808080); - f.update(0.5); - expect(f.value).toBe(0x000000); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/core/easing.ts b/packages/engine/src/core/easing.ts deleted file mode 100644 index 782896c..0000000 --- a/packages/engine/src/core/easing.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Функции easing: t=0..1 -> 0..1. Используются в Tween и переходах сцен. - */ - -export type EaseFn = (t: number) => number; - -export const linear: EaseFn = (t) => t; - -export const quadIn: EaseFn = (t) => t * t; - -export const quadOut: EaseFn = (t) => 1 - (1 - t) * (1 - t); - -export const cubicIn: EaseFn = (t) => t * t * t; - -export const cubicOut: EaseFn = (t) => 1 - Math.pow(1 - t, 3); - -export const sineInOut: EaseFn = (t) => -(Math.cos(Math.PI * t) - 1) / 2; - -/** Затухающий овершут: 0 → ~1.1 → 1 («появление с пружинкой», pop-эффекты). */ -export const backOut: EaseFn = (t) => { - const s = 1.70158; - const u = t - 1; - return 1 + (s + 1) * u * u * u + s * u * u; -}; \ No newline at end of file diff --git a/packages/engine/src/core/fade.ts b/packages/engine/src/core/fade.ts deleted file mode 100644 index 5b8527d..0000000 --- a/packages/engine/src/core/fade.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Плавный линейный переход значения к цели за заданное время. - * Чистый класс без Pixi (headless-тестируется): ambient/виньетка Lighting, - * оверлей переходов SceneManager — вместо ручных пар fadeLeft/fadeTotal. - */ -export class FadeState { - private from: T; - /** Поле не может называться `to` — имя занято методом to(). */ - private toVal: T; - private current: T; - private left = 0; - private total = 0; - - constructor( - private readonly lerp: (a: T, b: T, k: number) => T, - initial: T - ) { - this.from = this.toVal = this.current = initial; - } - - /** Текущее значение (в процессе фейда — интерполированное). */ - get value(): T { - return this.current; - } - - /** Цель, заданная последним to(). */ - get target(): T { - return this.toVal; - } - - /** Начальное значение текущего фейда (на момент запуска). */ - get origin(): T { - return this.from; - } - - /** Идёт ли сейчас фейд. */ - get active(): boolean { - return this.left > 0; - } - - /** Задать цель; seconds > 0 — плавный лерп от текущего значения. */ - to(target: T, seconds = 0): void { - this.from = this.current; - this.toVal = target; - this.total = Math.max(0, seconds); - this.left = this.total; - if (this.total === 0) this.current = target; - } - - /** Тик; по завершении значение точно равно цели. */ - update(dt: number): void { - if (this.left <= 0) return; - this.left = Math.max(0, this.left - dt); - const k = this.total > 0 ? 1 - this.left / this.total : 1; - this.current = this.lerp(this.from, this.toVal, k); - if (this.left === 0) this.current = this.toVal; - } -} \ No newline at end of file diff --git a/packages/engine/src/cutscene/CutsceneRunner.ts b/packages/engine/src/cutscene/CutsceneRunner.ts deleted file mode 100644 index 415d009..0000000 --- a/packages/engine/src/cutscene/CutsceneRunner.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { sineInOut, type EaseFn } from '../core/easing'; -import { GameState } from '../core/GameState'; -import type { Camera } from '../render/Camera'; -import type { Vec2 } from '../math/Vec2'; -import { applyEase } from './tween'; - -/** - * Раннер кат-сцен: view-агностичная последовательность шагов. - * Каждый шаг при старте выполняет свой эффект; раннер ждёт его длительность - * (0 — шаг «параллельный», следующий стартует сразу) и переходит к следующему. - * Сцена на время сцены ставит геймплей на паузу — раннер только сообщает `active`. - */ - -/** Всё, что можно двигать шагом moveView: структурно — любой Container. */ -export interface Movable { - position: Vec2; -} - -export type CutsceneStep = - /** Подождать N секунд (ничего не делая). */ - | { kind: 'wait'; seconds: number } - /** Ждать внешнее событие (диалог, приход актёра): пока predicate() не true. - * timeout — принудительное завершение шага (сек); без него шаг может - * длиться вечно — скип/агентный мост на таком шаге не повиснет только - * если событие реально наступит. */ - | { kind: 'until'; predicate: () => boolean; label?: string; timeout?: number } - /** Плавно переместить камеру (в юнитах) к точке. */ - | { kind: 'cameraMove'; x: number; y: number; seconds: number; camera: Camera } - /** Плавно сменить зум камеры (seconds: 0 — мгновенно, параллелен ходу). */ - | { kind: 'cameraZoom'; camera: Camera; z: number; seconds?: number } - /** Плавно переместить вьюху (актёра, объект) в локальных координатах её родителя. */ - | { kind: 'moveView'; view: Movable; x: number; y: number; seconds: number; ease?: EaseFn } - /** Поднять флаг прохождения (мгновенно). */ - | { kind: 'setFlag'; flag: string; state: GameState } - /** Разовый эффект (звук, кольцо, вспышка частиц). */ - | { kind: 'burst'; fn: () => void; seconds?: number } - /** Произвольный вызов (диалог, посадка, тост и т.п.). */ - | { kind: 'call'; fn: () => void; seconds?: number }; - -interface RunningStep { - seconds: number; - /** Ожидание внешнего события: шаг кончается, когда предикат стал true. */ - until?: { predicate: () => boolean }; - /** Движение камеры: from -> to по easing. */ - cameraMove?: { from: Vec2; to: Vec2; camera: Camera }; - /** Движение вьюхи: from -> to по easing. */ - moveView?: { from: Vec2; to: Vec2; view: Movable; ease: EaseFn }; -} - -/** Плавность движения камеры по умолчанию. */ -const EASE: EaseFn = sineInOut; - -export class CutsceneRunner { - private queue: CutsceneStep[] = []; - private current: RunningStep | null = null; - private t = 0; - /** Внутри skip(): until форсируются, сцена обязана дойти до конца. */ - private skipping = false; - /** Вызывается, когда все шаги дошли до конца. */ - onComplete: (() => void) | null = null; - - constructor( - /** Наблюдатель: вызывается при старте каждого шага (лог, отладка). */ - private onStep?: (step: CutsceneStep) => void - ) {} - - /** Запустить последовательность шагов (немедленно). */ - play(steps: CutsceneStep[]): void { - this.queue = [...steps]; - this.t = 0; - this.next(); - } - - /** Идёт ли сейчас сцена (геймплей на паузе). */ - get active(): boolean { - return this.current !== null; - } - - /** Секунд прошло с начала текущего шага. */ - get time(): number { - return this.t; - } - - private next(): void { - const step = this.queue.shift(); - if (!step) { - this.current = null; - this.onComplete?.(); - return; - } - this.onStep?.(step); - switch (step.kind) { - case 'wait': - this.current = { seconds: step.seconds }; - break; - case 'until': { - // Предикат уже выполнен — шаг не блокирует (секунды 0 → сразу дальше). - const done = step.predicate(); - this.current = { - seconds: done ? 0 : (step.timeout ?? Infinity), - until: done ? undefined : { predicate: step.predicate } - }; - break; - } - case 'cameraMove': - this.current = { - seconds: step.seconds, - cameraMove: { - from: { x: step.camera.x, y: step.camera.y }, - to: { x: step.x, y: step.y }, - camera: step.camera - } - }; - break; - case 'cameraZoom': - step.camera.zoomTo(step.z, step.seconds ?? 0); - this.current = { seconds: step.seconds ?? 0 }; - break; - case 'moveView': - this.current = { - seconds: step.seconds, - moveView: { - from: { x: step.view.position.x, y: step.view.position.y }, - to: { x: step.x, y: step.y }, - view: step.view, - ease: step.ease ?? EASE - } - }; - break; - case 'setFlag': - step.state.setFlag(step.flag); - this.current = { seconds: 0 }; - break; - case 'burst': - case 'call': - step.fn(); - this.current = { seconds: step.seconds ?? 0 }; - break; - } - // Параллельный шаг (0 секунд) — сразу к следующему. - if (this.current.seconds <= 0) this.next(); - } - - update(dt: number): void { - if (!this.current) return; - this.t += dt; - const current = this.current; - // Ожидание события: предикат сработал — шаг кончен (timeout ловит «>=» ниже). - // Скип форсирует любые until: катсцена обязана кончиться. - if (current.until && (this.skipping || current.until.predicate())) { - this.t = 0; - this.next(); - return; - } - const move = current.cameraMove; - if (move) { - const p = applyEase(move.from, move.to, this.t / current.seconds, EASE); - move.camera.x = p.x; - move.camera.y = p.y; - } - const viewMove = current.moveView; - if (viewMove) { - const p = applyEase(viewMove.from, viewMove.to, this.t / current.seconds, viewMove.ease); - viewMove.view.position.x = p.x; - viewMove.view.position.y = p.y; - } - if (this.t >= current.seconds) { - this.t = 0; - this.next(); - } - } - - /** Дожать сцену до конца немедленно: until форсируются, движение — в конечные точки. */ - skip(): void { - if (!this.current) return; - this.skipping = true; - // Лимит — защита от шага, который при скипе порождает шаги бесконечно. - for (let i = 0; i < 1000 && this.current; i++) { - const remain = this.current.until ? 0 : this.current.seconds - this.t; - this.update(Math.max(0, remain) + 0.001); - } - this.skipping = false; - } -} \ No newline at end of file diff --git a/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts b/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts deleted file mode 100644 index 6d9c9ac..0000000 --- a/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { GameState } from '../../core/GameState'; -import { linear } from '../../core/easing'; -import { Camera } from '../../render/Camera'; -import { CutsceneRunner } from '../CutsceneRunner'; -import { applyEase } from '../tween'; - -/** Продвинуть раннер: dt N раз. */ -function tick(runner: CutsceneRunner, dt: number, times: number): void { - for (let i = 0; i < times; i++) runner.update(dt); -} - -/** Фиктивный «взгляд»: камера с заданным центром. */ -function cameraAt(x: number, y: number): Camera { - const cam = new Camera(480, 270); - cam.x = x; - cam.y = y; - return cam; -} - -describe('CutsceneRunner — последовательность шагов', () => { - it('шаги выполняются по порядку, wait держит паузу', () => { - const runner = new CutsceneRunner(); - const order: string[] = []; - runner.play([ - { kind: 'call', fn: () => order.push('a') }, - { kind: 'wait', seconds: 0.5 }, - { kind: 'call', fn: () => order.push('b') } - ]); - // Первый шаг стартовал сразу, второй ещё ждёт. - expect(order).toEqual(['a']); - expect(runner.active).toBe(true); - tick(runner, 0.1, 4); // 0.4с — всё ещё wait - expect(order).toEqual(['a']); - tick(runner, 0.2, 1); // 0.6с — wait кончился - expect(order).toEqual(['a', 'b']); - expect(runner.active).toBe(false); - }); - - it('шаг без seconds параллелен: следующий стартует сразу', () => { - const runner = new CutsceneRunner(); - const order: string[] = []; - runner.play([ - { kind: 'burst', fn: () => order.push('burst') }, - { kind: 'call', fn: () => order.push('call') } - ]); - // Шаги без длительности не блокируют: оба стартовали в том же тике. - expect(order).toEqual(['burst', 'call']); - expect(runner.active).toBe(false); - }); - - it('cameraMove плавно ведёт камеру к точке', () => { - const runner = new CutsceneRunner(); - const cam = cameraAt(0, 0); - runner.play([{ kind: 'cameraMove', x: 10, y: -20, seconds: 1, camera: cam }]); - tick(runner, 0.25, 2); // половина пути - expect(cam.x).toBeGreaterThan(0); - expect(cam.x).toBeLessThan(10); - expect(cam.y).toBeLessThan(0); - tick(runner, 0.25, 3); // время вышло — точно в точке - expect(cam.x).toBe(10); - expect(cam.y).toBe(-20); - expect(runner.active).toBe(false); - }); - - it('setFlag поднимает флаги, onComplete вызывается в конце', () => { - const runner = new CutsceneRunner(); - const state = new GameState(); - let done = 0; - runner.onComplete = () => done++; - runner.play([ - { kind: 'setFlag', flag: 'flag1', state }, - { kind: 'setFlag', flag: 'flag2', state } - ]); - expect(state.hasFlag('flag1')).toBe(true); - expect(state.hasFlag('flag2')).toBe(true); - expect(done).toBe(1); - }); - - it('onStep наблюдает порядок шагов', () => { - const seen: string[] = []; - const runner = new CutsceneRunner((s) => seen.push(s.kind)); - runner.play([ - { kind: 'call', fn: () => undefined, seconds: 0.1 }, - { kind: 'wait', seconds: 0.1 } - ]); - // Первый шаг стартовал; второй — после его длительности. - expect(seen).toEqual(['call']); - tick(runner, 0.2, 1); - expect(seen).toEqual(['call', 'wait']); - }); - - it('until ждёт внешнее событие и завершается по предикату', () => { - const runner = new CutsceneRunner(); - const order: string[] = []; - let ready = false; - runner.play([ - { kind: 'until', predicate: () => ready, label: 'событие' }, - { kind: 'call', fn: () => order.push('after') } - ]); - tick(runner, 0.1, 5); // событие не наступило — стоим на месте - expect(order).toEqual([]); - expect(runner.active).toBe(true); - ready = true; - runner.update(0.1); - expect(order).toEqual(['after']); - expect(runner.active).toBe(false); - }); - - it('until с уже выполненным предикатом не блокирует', () => { - const runner = new CutsceneRunner(); - const order: string[] = []; - runner.play([ - { kind: 'until', predicate: () => true }, - { kind: 'call', fn: () => order.push('next') } - ]); - expect(order).toEqual(['next']); - expect(runner.active).toBe(false); - }); - - it('until с timeout завершается принудительно по истечении', () => { - const runner = new CutsceneRunner(); - let done = 0; - runner.onComplete = () => done++; - runner.play([{ kind: 'until', predicate: () => false, timeout: 0.3 }]); - tick(runner, 0.1, 2); // 0.2с — ещё ждём - expect(runner.active).toBe(true); - tick(runner, 0.1, 1); // 0.3с — timeout - expect(runner.active).toBe(false); - expect(done).toBe(1); - }); - - it('moveView плавно ведёт вьюху в локальных координатах родителя', () => { - const runner = new CutsceneRunner(); - const view = { position: { x: 0, y: 0 } }; - runner.play([{ kind: 'moveView', view, x: 40, y: -10, seconds: 1 }]); - tick(runner, 0.25, 2); // половина пути - expect(view.position.x).toBeGreaterThan(0); - expect(view.position.x).toBeLessThan(40); - expect(view.position.y).toBeLessThan(0); - tick(runner, 0.25, 3); // время вышло — точно в точке - expect(view.position.x).toBe(40); - expect(view.position.y).toBe(-10); - expect(runner.active).toBe(false); - }); - - it('moveView с кастомным ease', () => { - const runner = new CutsceneRunner(); - const view = { position: { x: 0, y: 0 } }; - runner.play([{ kind: 'moveView', view, x: 10, y: 0, seconds: 1, ease: (t) => t * t }]); - runner.update(0.5); - expect(view.position.x).toBeCloseTo(2.5); // quadIn(0.5) = 0.25 - }); - - it('skip дожимает сцену до конца: движение — в конечные точки', () => { - const runner = new CutsceneRunner(); - const cam = cameraAt(0, 0); - const view = { position: { x: 0, y: 0 } }; - const order: string[] = []; - runner.onComplete = () => order.push('done'); - runner.play([ - { kind: 'moveView', view, x: 12, y: 6, seconds: 5 }, - { kind: 'cameraMove', x: 3, y: 3, seconds: 5, camera: cam }, - { kind: 'call', fn: () => order.push('call') } - ]); - runner.skip(); - expect(order).toEqual(['call', 'done']); - expect(view.position.x).toBe(12); - expect(view.position.y).toBe(6); - expect(cam.x).toBe(3); - expect(cam.y).toBe(3); - expect(runner.active).toBe(false); - }); - - it('skip форсирует until (иначе скип висел бы навсегда)', () => { - const runner = new CutsceneRunner(); - runner.play([ - { kind: 'call', fn: () => undefined }, - { kind: 'until', predicate: () => false }, // событие никогда не наступит - { kind: 'call', fn: () => undefined, seconds: 0.2 } - ]); - runner.skip(); - expect(runner.active).toBe(false); - }); - - it('cameraZoom ведёт зум камеры; seconds: 0 — мгновенно', () => { - const runner = new CutsceneRunner(); - const cam = cameraAt(0, 0); - const order: string[] = []; - runner.play([ - { kind: 'cameraZoom', camera: cam, z: 2, seconds: 1 }, - { kind: 'call', fn: () => order.push('after') } - ]); - runner.update(0.5); - cam.update(0.5); // анимацию зума тикает камера (движок — каждый шаг) - expect(cam.zoomLevel).toBeGreaterThan(1); - expect(cam.zoomLevel).toBeLessThan(2); - expect(order).toEqual([]); // зум ещё идёт — сцена не двинулась - runner.update(0.5); - cam.update(0.5); - expect(cam.zoomLevel).toBe(2); - expect(order).toEqual(['after']); - }); - - it('cameraZoom без seconds — параллельный шаг (не блокирует)', () => { - const runner = new CutsceneRunner(); - const cam = cameraAt(0, 0); - runner.play([{ kind: 'cameraZoom', camera: cam, z: 2 }]); - expect(cam.zoomLevel).toBe(2); - expect(runner.active).toBe(false); - }); -}); - -describe('tween — applyEase', () => { - it('интерполирует точку по кривой easing', () => { - const from = { x: 0, y: 0 }; - const to = { x: 10, y: -20 }; - const mid = applyEase(from, to, 0.5, linear); - expect(mid.x).toBeCloseTo(5); - expect(mid.y).toBeCloseTo(-10); - }); - - it('клэмпит k за пределы 0..1', () => { - const end = applyEase({ x: 0, y: 0 }, { x: 8, y: 4 }, 2, linear); - expect(end.x).toBe(8); - expect(end.y).toBe(4); - const start = applyEase({ x: 0, y: 0 }, { x: 8, y: 4 }, -1, linear); - expect(start.x).toBe(0); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/cutscene/tween.ts b/packages/engine/src/cutscene/tween.ts deleted file mode 100644 index baba792..0000000 --- a/packages/engine/src/cutscene/tween.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { EaseFn } from '../core/easing'; -import type { Vec2 } from '../math/Vec2'; - -/** - * Чистая математика перемещений кат-сцен: интерполяция точки по easing. - * Общая для cameraMove и moveView — движение камеры и вьюх считается одинаково. - */ - -/** Точка на кривой from→to в доле k (0..1), прошедшей через ease. */ -export function applyEase(from: Vec2, to: Vec2, k: number, ease: EaseFn): Vec2 { - const e = ease(Math.min(1, Math.max(0, k))); - return { x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e }; -} \ No newline at end of file diff --git a/packages/engine/src/debug/DebugOverlay.ts b/packages/engine/src/debug/DebugOverlay.ts deleted file mode 100644 index aa5c150..0000000 --- a/packages/engine/src/debug/DebugOverlay.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Text, Container } from 'pixi.js'; -import type { FpsMeter } from '../core/FpsMeter'; - -/** - * Дебаг-оверлей: FPS (из FpsMeter движка) и произвольные строки статистики. - * Добавьте view в uiRoot и вызывайте update(dt) каждый тик; перерисовка Text - * дорога (растеризация) — не чаще 0,5 с. - */ -export class DebugOverlay { - readonly view: Container; - private text: Text; - private lines: string[] = []; - private acc = 0; - - constructor( - private readonly fpsMeter: FpsMeter, - 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; - if (this.acc < 0.5) return; - this.acc = 0; - this.text.text = `fps ${this.fpsMeter.fps}\n${this.lines.join('\n')}`; - } -} \ No newline at end of file diff --git a/packages/engine/src/dialogue/DialogueRunner.ts b/packages/engine/src/dialogue/DialogueRunner.ts deleted file mode 100644 index 2434206..0000000 --- a/packages/engine/src/dialogue/DialogueRunner.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { GameState } from '../core/GameState'; -import { - hasConditions, - hasText, - evalConditions, - type DialogueChoice, - type DialogueConditions, - type DialogueEffects, - type DialogueEffectOp, - type DialogueGraph, - type DialogueHooks, - type DialogueNode, - type DialogueResult -} from './graph'; - -/** - * Рантайм обхода диалоговых графов, отделённый от отрисовки и от модели - * (типы графа и чистые предикаты — dialogue/graph.ts). Игра рисует диалог - * своим view (например DialogueBox) и вызывает advance()/pick(). - */ - -/** Вид: игра рисует реплику и варианты своими средствами. */ -export interface DialogueView { - /** Показать реплику. choices пуст, если выбора нет. */ - show(node: { - speaker?: string; - text: string; - mood?: string; - tags?: string[]; - choices: { index: number; text: string }[]; - }): void; - hide(): void; -} - -const MAX_STEPS = 1000; // защита от циклов в графе без текста - -export class DialogueRunner { - /** Вызывается по завершении диалога: граф + как он был пройден. */ - onFinish: ((graph: DialogueGraph, result: DialogueResult) => void) | null = null; - /** - * Игровой эффект из `do[]`: движок только сообщает, исполнение — игра - * (EffectSink). `at` — где сработал: узел или выбор в узле. - */ - onEffect: ((op: DialogueEffectOp, at: { nodeId: string; choice?: number }) => void) | null = - null; - - private graph: DialogueGraph | null = null; - private view: DialogueView | null = null; - private hooks: DialogueHooks | undefined; - private shownChoices: { index: number; text: string }[] = []; - private pendingNext: string | undefined; - private awaitingChoice = false; - private walkedPath: string[] = []; - private madePicks: { nodeId: string; index: number; text: string }[] = []; - private walkedResult: DialogueResult | null = null; - private currentId: string | null = null; - - constructor( - private state: GameState, - view?: DialogueView, - hooks?: DialogueHooks - ) { - this.view = view ?? null; - this.hooks = hooks; - } - - /** Подключить/заменить view (например, после пересоздания UI). */ - setView(view: DialogueView): void { - this.view = view; - } - - /** Подключить/заменить хуки мира (сумка, локализация строк). */ - setHooks(hooks: DialogueHooks): void { - this.hooks = hooks; - } - - get active(): boolean { - return this.graph !== null; - } - - // --- для агентного моста/отладки: раскрытие текущего состояния --- - - /** id текущего узла (или null, если диалог не активен/завершается). */ - get nodeId(): string | null { - return this.currentId; - } - - /** Текущий узел (для снапшота моста). */ - get node(): DialogueNode | null { - if (!this.graph || this.currentId === null) return null; - return this.graph.nodes[this.currentId] ?? null; - } - - /** Показанные варианты выбора (индексы — в узел графа). */ - get choices(): { index: number; text: string }[] { - return this.shownChoices; - } - - /** Ожидается ли выбор варианта. */ - get waitingForChoice(): boolean { - return this.awaitingChoice; - } - - /** Показанные узлы по порядку (текущий прогон). */ - get path(): string[] { - return this.walkedPath; - } - - /** Итог последнего завершённого диалога (null — не завершался). */ - get result(): DialogueResult | null { - return this.walkedResult; - } - - /** Запустить диалог. */ - start(graph: DialogueGraph): void { - this.graph = graph; - this.awaitingChoice = false; - this.pendingNext = undefined; - this.walkedPath = []; - this.madePicks = []; - this.walkedResult = null; - this.enterNode(graph.start); - } - - /** - * «Дальше»: показывает следующий узел. Во время выбора варианта игнорируется. - */ - advance(): void { - if (!this.graph || this.awaitingChoice) return; - const next = this.pendingNext; - if (next === undefined) { - this.finish(); - return; - } - this.enterNode(next); - } - - /** Выбрать вариант ответа. */ - pick(index: number): void { - if (!this.graph || !this.awaitingChoice) return; - const shown = this.shownChoices[index]; - if (!shown) return; - const choice = this.node?.choices?.[shown.index]; - if (!choice) return; - - const atNode = this.currentId; - this.awaitingChoice = false; - this.shownChoices = []; - if (atNode !== null) { - this.madePicks.push({ nodeId: atNode, index: shown.index, text: shown.text }); - } - this.applyEffects(choice, atNode, shown.index); - if (choice.next === undefined) { - this.finish(); - } else { - this.enterNode(choice.next); - } - } - - /** Завершить диалог досрочно. */ - finish(): void { - const graph = this.graph; - this.graph = null; - this.awaitingChoice = false; - this.shownChoices = []; - this.pendingNext = undefined; - this.view?.hide(); - if (graph) { - this.walkedResult = { - lastNodeId: this.walkedPath.length > 0 ? this.walkedPath[this.walkedPath.length - 1] : null, - path: [...this.walkedPath], - picks: [...this.madePicks] - }; - this.onFinish?.(graph, this.walkedResult); - } - } - - /** Войти в узел: проверить условия, применить эффекты, показать или продолжить. */ - private enterNode(id: string): void { - if (!this.graph) return; - const node = this.graph.nodes[id]; - if (!node) { - // несуществующий next трактуем как конец - this.finish(); - return; - } - - // Шагаем через «пустые» узлы (не прошедшие условия или без текста), - // пока не встретим реплику с выбором, реплику с ожиданием или конец. - let current: { id: string; node: DialogueNode } | null = { id, node }; - for (let steps = 0; current && steps < MAX_STEPS; steps++) { - const n: DialogueNode = current.node; - - if (hasConditions(n) && !this.checkConditions(n)) { - // условие не прошло — уходим по next или заканчиваем - if (n.next === undefined) { - this.currentId = null; - this.finish(); - return; - } - const nextNode = this.graph.nodes[n.next]; - if (!nextNode) { - this.currentId = null; - this.finish(); - return; - } - current = { id: n.next, node: nextNode }; - continue; - } - - this.currentId = current.id; - this.applyEffects(n, this.currentId); - - if (n.choices && n.choices.length > 0) { - const choices: { index: number; text: string }[] = n.choices - .map((c: DialogueChoice, index: number) => ({ c, index })) - .filter(({ c }) => this.checkConditions(c)) - .map(({ c, index }) => ({ index, text: c.text })); - if (hasText(n)) { - this.show(n, choices); - } else if (choices.length > 0) { - // выбор без реплики: показываем только варианты - this.show({ text: '' }, choices); - } else { - // нет ни текста, ни доступных вариантов — конец - this.currentId = null; - this.finish(); - return; - } - return; - } - - if (hasText(n)) { - this.pendingNext = n.next; - this.show(n, []); - return; - } - - // Действие без текста: продолжаем по next или заканчиваем - if (n.next === undefined || n.end) { - this.currentId = null; - this.finish(); - return; - } - const nextNode = this.graph.nodes[n.next]; - if (!nextNode) { - this.currentId = null; - this.finish(); - return; - } - current = { id: n.next, node: nextNode }; - } - - // Защита сработала — принудительный конец - this.currentId = null; - this.finish(); - } - - /** Текст реплики: textKey резолвится хуком (fallback — сам ключ). */ - private resolveText(n: { text?: string; textKey?: string }): string { - if (n.textKey !== undefined) return this.hooks?.resolve?.(n.textKey) ?? n.textKey; - return n.text ?? ''; - } - - private resolveSpeaker(n: { speaker?: string; speakerKey?: string }): string | undefined { - if (n.speakerKey !== undefined) return this.hooks?.resolve?.(n.speakerKey) ?? n.speakerKey; - return n.speaker; - } - - private show( - n: DialogueNode, - choices: { index: number; text: string }[], - textlessChoice = false - ): void { - if (this.currentId !== null) this.walkedPath.push(this.currentId); - this.shownChoices = choices; - this.awaitingChoice = choices.length > 0; - this.view?.show({ - speaker: this.resolveSpeaker(n), - text: textlessChoice ? '' : this.resolveText(n), - mood: n.mood, - tags: n.tags, - choices - }); - } - - private applyEffects(e: DialogueEffects, atNode: string | null, choice?: number): void { - for (const f of e.setFlags ?? []) this.state.setFlag(f); - for (const f of e.clearFlags ?? []) this.state.clearFlag(f); - for (const [k, v] of Object.entries(e.setVars ?? {})) this.state.setVar(k, v); - if (atNode === null) return; - const at = { nodeId: atNode, choice }; - for (const op of e.do ?? []) this.onEffect?.(op, at); - } - - private checkConditions(c: DialogueConditions): boolean { - return evalConditions(c, this.state, this.hooks?.world); - } -} \ No newline at end of file diff --git a/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts b/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts deleted file mode 100644 index 602641b..0000000 --- a/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { DialogueRunner, type DialogueView } from '../DialogueRunner'; -import { evalConditions } from '../graph'; -import { GameState } from '../../core/GameState'; - -/** Тестовый view: запоминает показанные реплики. */ -interface ShownLine { - speaker?: string; - text: string; - mood?: string; - tags?: string[]; - choices: { index: number; text: string }[]; -} - -function makeView(): { view: DialogueView; shown: ShownLine[]; hidden: () => number } { - const shown: ShownLine[] = []; - let hideCount = 0; - const view: DialogueView = { - show: (n) => shown.push(n), - hide: () => hideCount++ - }; - return { view, shown, hidden: () => hideCount }; -} - -describe('DialogueRunner', () => { - it('линейный диалог: advance до конца, onFinish вызывается', () => { - const state = new GameState(); - const { view, shown, hidden } = makeView(); - const dr = new DialogueRunner(state, view); - let finished = 0; - dr.onFinish = () => finished++; - - dr.start({ - start: 'a', - nodes: { - a: { text: 'Привет.', next: 'b' }, - b: { text: 'Пока.', next: 'c' }, - c: { text: '...' } - } - }); - - expect(dr.active).toBe(true); - expect(shown[0]?.text).toBe('Привет.'); - dr.advance(); - expect(shown[1]?.text).toBe('Пока.'); - dr.advance(); - expect(shown[2]?.text).toBe('...'); - dr.advance(); - expect(finished).toBe(1); - expect(dr.active).toBe(false); - expect(hidden()).toBe(1); - }); - - it('узел без next — конец диалога', () => { - const state = new GameState(); - const dr = new DialogueRunner(state, makeView().view); - let finished = 0; - dr.onFinish = () => finished++; - dr.start({ start: 'a', nodes: { a: { text: 'Конец' } } }); - dr.advance(); - expect(finished).toBe(1); - }); - - it('выборы: показываются только прошедшие условия, pick уходит по next', () => { - const state = new GameState(); - const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view); - - dr.start({ - start: 'q', - nodes: { - q: { - speaker: 'Ирвин', - text: 'Поможешь?', - choices: [ - { text: 'Да', next: 'yes', setFlags: ['quest_taken'] }, - { text: 'Нет', next: 'no' }, - { text: 'Секрет', when: ['knows_secret'], next: 'secret' } - ] - }, - yes: { text: 'Спасибо!' }, - no: { text: 'Жаль.' }, - secret: { text: 'Ты знаешь тайну.' } - } - }); - - expect(shown[0]?.speaker).toBe('Ирвин'); - expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Да', 'Нет']); - - dr.pick(0); - expect(state.hasFlag('quest_taken')).toBe(true); - // advance показывает следующий узел после применения эффекта выбора - dr.advance(); - expect(shown[1]?.text).toBe('Спасибо!'); - }); - - it('advance во время выбора игнорируется', () => { - const state = new GameState(); - const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view); - dr.start({ - start: 'q', - nodes: { q: { text: 'Выбор?', choices: [{ text: 'A', next: 'a2' }], next: 'x' }, a2: { text: 'A' }, x: { text: 'X' } } - }); - dr.advance(); - expect(shown.length).toBe(1); // реплика 'X' не показалась - }); - - it('узел-действие без текста применяет флаги и переходит дальше', () => { - const state = new GameState(); - const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view); - dr.start({ - start: 'give', - nodes: { - give: { setFlags: ['reward_given'], next: 'msg' }, - msg: { text: 'Готово.' } - } - }); - expect(shown[0]?.text).toBe('Готово.'); - expect(state.hasFlag('reward_given')).toBe(true); - }); - - it('when/whenNot фильтруют узлы: диалог идёт по другой ветке', () => { - const state = new GameState(); - state.setFlag('was_here'); - const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view); - dr.start({ - start: 'check', - nodes: { - check: { when: ['was_here'], text: 'Опять ты.', next: 'end' }, - first: { text: 'Первый раз.' }, - end: { text: 'Конец.' } - } - }); - // check прошёл условие - expect(shown[0]?.text).toBe('Опять ты.'); - }); - - it('когда узел не прошёл условие, диалог уходит по его next', () => { - const state = new GameState(); - const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view); - dr.start({ - start: 'check', - nodes: { - check: { when: ['vip'], text: 'Ветка VIP.', next: 'end' }, - end: { text: 'Финал.' } - } - }); - // check не прошёл (нет vip) — но у check есть next: показывается end - expect(shown[0]?.text).toBe('Финал.'); - expect(dr.active).toBe(true); - }); - - it('узел-действие с whenNot переходит по next при проходе условия', () => { - const state = new GameState(); - const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view); - dr.start({ - start: 'hub', - nodes: { - hub: { whenNot: ['vip'], next: 'normal' }, - normal: { text: 'Обычная ветка.', next: 'end' }, - end: { text: 'Финал.' } - } - }); - // hub без текста: условие прошло, эффектов нет, next -> normal - expect(shown[0]?.text).toBe('Обычная ветка.'); - }); - - it('whenVar фильтрует по числовой переменной', () => { - const state = new GameState(); - state.setVar('gold', 5); - const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view); - dr.start({ - start: 'q', - nodes: { - q: { - text: 'Торговец:', - choices: [ - { text: 'Купить (10 зол.)', whenVar: { key: 'gold', op: 'ge', value: 10 }, next: 'buy' }, - { text: 'Уйти', next: 'bye' } - ] - }, - buy: { text: 'Куплено.' }, - bye: { text: 'Пока.' } - } - }); - expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Уйти']); - }); - - it('setVars в выборах меняет состояние', () => { - const state = new GameState(); - const dr = new DialogueRunner(state, makeView().view); - dr.start({ - start: 'q', - nodes: { - q: { text: 'Сколько?', choices: [{ text: 'Дать 30', setVars: { gold: 30 }, next: 'ok' }] }, - ok: { text: 'Ок.' } - } - }); - dr.pick(0); - expect(state.getNumber('gold')).toBe(30); - }); - - it('цикл из узлов без текста не зависает — защита по MAX_STEPS', () => { - const state = new GameState(); - const dr = new DialogueRunner(state, makeView().view); - dr.start({ - start: 'a', - nodes: { - a: { next: 'b' }, - b: { next: 'a' } - } - }); - expect(dr.active).toBe(false); // безопасно завершился - }); - - it('whenVars: несколько условий по переменным, AND', () => { - const state = new GameState(); - state.setVar('gold', 5); - state.setVar('trust', 2); - const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view); - dr.start({ - start: 'q', - nodes: { - q: { - text: 'Торговец:', - choices: [ - { - text: 'Дорогой товар', - whenVars: [ - { key: 'gold', op: 'ge', value: 10 }, - { key: 'trust', op: 'ge', value: 1 } - ], - next: 'buy' - }, - { text: 'Уйти', next: 'bye' } - ] - }, - buy: { text: 'Куплено.' }, - bye: { text: 'Пока.' } - } - }); - // gold < 10 — первая ветка скрыта, хотя trust проходит - expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Уйти']); - state.setVar('gold', 50); - dr.start({ - start: 'q', - nodes: { - q: { - text: 'Торговец:', - choices: [ - { - text: 'Дорогой товар', - whenVars: [ - { key: 'gold', op: 'ge', value: 10 }, - { key: 'trust', op: 'ge', value: 1 } - ], - next: 'buy' - }, - { text: 'Уйти', next: 'bye' } - ] - }, - buy: { text: 'Куплено.' }, - bye: { text: 'Пока.' } - } - }); - expect(shown[1]?.choices.map((c) => c.text)).toEqual(['Дорогой товар', 'Уйти']); - }); - - it('hasItem резолвится через DialogueWorld; без world — ложно', () => { - const state = new GameState(); - const world = { hasItem: (id: string) => id === 'cloth' }; - const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view, { world }); - dr.start({ - start: 'q', - nodes: { - q: { - text: 'Торговец:', - choices: [ - { text: 'Продать ткань', hasItem: ['cloth'], next: 'sell' }, - { text: 'Продать соль', hasItem: ['salt'], next: 'nope' }, - { text: 'Уйти', next: 'bye' } - ] - }, - sell: { text: 'Продано.' }, - nope: { text: 'Нет соли.' }, - bye: { text: 'Пока.' } - } - }); - expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Продать ткань', 'Уйти']); - - // узел-выбор без текста и без world: hasItem-варианты скрыты — конец диалога - const dr2 = new DialogueRunner(state, makeView().view); - dr2.start({ - start: 'q', - nodes: { - q: { choices: [{ text: 'Продать ткань', hasItem: ['cloth'], next: 'sell' }] }, - sell: { text: 'Продано.' } - } - }); - expect(dr2.active).toBe(false); // нет доступных вариантов — конец - }); - - it('evalConditions работает без рантайма на чистом GameState', () => { - const state = new GameState(); - state.setFlag('met_elder'); - state.setVar('flowers', 3); - const world = { hasItem: (id: string) => id === 'cloth' }; - - expect(evalConditions({ when: ['met_elder'] }, state)).toBe(true); - expect(evalConditions({ when: ['met_elder'], whenNot: ['quest_done'] }, state)).toBe(true); - expect(evalConditions({ when: ['quest_done'] }, state)).toBe(false); - expect( - evalConditions( - { whenVars: [{ key: 'flowers', op: 'ge', value: 3 }] }, - state, - world - ) - ).toBe(true); - expect( - evalConditions( - { whenVars: [{ key: 'flowers', op: 'ge', value: 4 }] }, - state - ) - ).toBe(false); - expect(evalConditions({ hasItem: ['cloth'] }, state, world)).toBe(true); - expect(evalConditions({ hasItem: ['cloth'] }, state)).toBe(false); // нет world - expect(evalConditions({}, state)).toBe(true); // пустые условия — истина - }); - - it('do[] эмитится через onEffect в порядке следования, у узла и у выбора', () => { - const state = new GameState(); - const { view } = makeView(); - const dr = new DialogueRunner(state, view); - const emitted: { kind: string; at: string; choice?: number }[] = []; - dr.onEffect = (op, at) => emitted.push({ kind: op.kind, at: at.nodeId, choice: at.choice }); - - dr.start({ - start: 'hub', - nodes: { - hub: { - do: [{ kind: 'sound', id: 'bell' }], - text: 'Возьми?', - choices: [ - { text: 'Да', next: 'ok', do: [{ kind: 'giveItem', id: 'cloth' }, { kind: 'toast', text: 'Ткань' }] } - ] - }, - ok: { text: 'Держи.', do: [{ kind: 'custom', id: 'plant' }] } - } - }); - // эффекты узла при входе - expect(emitted).toEqual([{ kind: 'sound', at: 'hub' }]); - dr.pick(0); - // сначала эффекты выбора, потом эффекты узла ok - expect(emitted.map((e) => e.kind)).toEqual(['sound', 'giveItem', 'toast', 'custom']); - expect(emitted[3]).toEqual({ kind: 'custom', at: 'ok' }); - expect(emitted[1].choice).toBe(0); - }); - - it('do[] без подписчика и с неизвестным kind не падают', () => { - const state = new GameState(); - const dr = new DialogueRunner(state, makeView().view); - dr.start({ - start: 'a', - nodes: { a: { text: 'Тишина.', do: [{ kind: 'custom' as const, id: 'unknown_thing' }] } } - }); - dr.advance(); - expect(dr.active).toBe(false); - expect(state.hasFlag('nothing')).toBe(false); - }); - - it('path/result: путь показанных узлов и выборы; onFinish получает результат', () => { - const state = new GameState(); - const { view } = makeView(); - const dr = new DialogueRunner(state, view); - let finishedResult: unknown = null; - dr.onFinish = (_graph, result) => { - finishedResult = result; - }; - - dr.start({ - start: 'q', - nodes: { - q: { text: 'Вопрос?', next: 'mid' }, - mid: { text: 'Выбирай.', choices: [{ text: 'Ок', next: 'end' }, { text: 'Нет' }] }, - end: { text: 'Конец.' } - } - }); - expect(dr.path).toEqual(['q']); - dr.advance(); - dr.pick(0); - dr.advance(); - expect(dr.active).toBe(false); - - const expected = { - lastNodeId: 'end', - path: ['q', 'mid', 'end'], - picks: [{ nodeId: 'mid', index: 0, text: 'Ок' }] - }; - expect(dr.result).toEqual(expected); - expect(finishedResult).toEqual(expected); - // result переживает новый запуск и сбрасывается - dr.start({ start: 'a', nodes: { a: { text: 'Заново' } } }); - expect(dr.result).toBeNull(); - expect(dr.path).toEqual(['a']); - }); - - it('mood/tags доходят до view', () => { - const state = new GameState(); - const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view); - dr.start({ - start: 'a', - nodes: { a: { text: 'Грустно.', mood: 'sad', tags: ['epilogue'] } } - }); - expect(shown[0]?.mood).toBe('sad'); - expect(shown[0]?.tags).toEqual(['epilogue']); - expect(shown[0]?.text).toBe('Грустно.'); - }); - - it('textKey резолвится через hooks.resolve; без хука — сам ключ', () => { - const state = new GameState(); - const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view, { - resolve: (key) => (key === 'elder.greet' ? 'Здравствуй, путник.' : key) - }); - dr.start({ - start: 'a', - nodes: { - a: { textKey: 'elder.greet', speakerKey: 'name.elder', next: 'b' }, - b: { textKey: 'missing.key' } - } - }); - expect(shown[0]?.text).toBe('Здравствуй, путник.'); - expect(shown[0]?.speaker).toBe('name.elder'); // ключ без перевода — сам ключ - dr.advance(); - expect(shown[1]?.text).toBe('missing.key'); - }); - - it('узел только с textKey — реплика (не действие)', () => { - const state = new GameState(); - const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view, { resolve: () => 'Строка' }); - dr.start({ start: 'a', nodes: { a: { textKey: 'only.key' } } }); - expect(shown[0]?.text).toBe('Строка'); - expect(dr.active).toBe(true); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/dialogue/graph.ts b/packages/engine/src/dialogue/graph.ts deleted file mode 100644 index 0ea39d6..0000000 --- a/packages/engine/src/dialogue/graph.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { GameState } from '../core/GameState'; - -/** - * Модель диалоговых графов и чистые предикаты над ней (без рантайма). - * Граф — данные (TS/JSON): узлы с репликами, выборами и условиями по GameState. - * Рантайм обхода графа — DialogueRunner; валидатор/dry-run/квесты пользуются - * типами и evalConditions отсюда. - */ - -/** Условие по переменной состояния. */ -export interface VarCondition { - key: string; - op: 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'; - value: number | string; -} - -/** Действия, применяемые к GameState при входе в узел/выборе. */ -export interface DialogueEffects { - setFlags?: string[]; - clearFlags?: string[]; - setVars?: Record; - /** - * Игровые эффекты как данные: движок только эмитит их через onEffect, - * исполнение — на стороне игры (EffectSink). Неизвестный kind — ошибка - * валидатора, рантайм его игнорирует. - */ - do?: DialogueEffectOp[]; -} - -/** Одна игровая операция эффекта (что именно она значит — знает игра). */ -export interface DialogueEffectOp { - kind: 'giveItem' | 'takeItem' | 'sound' | 'toast' | 'custom'; - /** id предмета (give/take), звука (sound) или имя сюжетного эффекта (custom). */ - id?: string; - /** Количество (give/take), по умолчанию 1. */ - count?: number; - /** Текст всплывашки (toast). */ - text?: string; - /** Свободные параметры (custom). */ - payload?: Record; -} - -/** Итог пройденного диалога: где остановились, как дошли, что выбрали. */ -export interface DialogueResult { - /** id последнего показанного узла (null — ни одного). */ - lastNodeId: string | null; - /** ids узлов по порядку показа. */ - path: string[]; - /** Выборы игрока: узел, индекс в узле, текст варианта. */ - picks: { nodeId: string; index: number; text: string }[]; -} - -/** Хуки мира: предикаты условий и резолв ключей строк (локализация). */ -export interface DialogueHooks { - world?: DialogueWorld; - /** Ключ строки → текст (textKey); нет резолва — ключ и есть текст. */ - resolve?: (key: string) => string; -} - -/** - * Предикаты мира, которых движок знать не может (сумка, репутация...). - * Игра передаёт реализацию в раннер; без неё hasItem-условия ложны. - */ -export interface DialogueWorld { - hasItem(id: string): boolean; -} - -/** Условия показа узла/варианта (все перечисленные группы — AND). */ -export interface DialogueConditions { - /** Все эти флаги должны быть установлены. */ - when?: string[]; - /** Ни один из этих флагов не должен быть установлен. */ - whenNot?: string[]; - /** Условие по переменной. */ - whenVar?: VarCondition; - /** Несколько условий по переменным, AND. */ - whenVars?: VarCondition[]; - /** Все эти предметы должны быть в сумке (резолв — DialogueWorld). */ - hasItem?: string[]; -} - -export interface DialogueChoice extends DialogueEffects, DialogueConditions { - text: string; - /** Следующий узел (по умолчанию — конец диалога). */ - next?: string; - /** Настроение реплики (игра мапит на цвет/портрет). */ - mood?: string; - /** Свободные метки для агента/инструментов (на геймплей не влияют). */ - tags?: string[]; - /** Ключ строки в таблице локализации; переопределяет inline text. */ - textKey?: string; -} - -export interface DialogueNode extends DialogueEffects, DialogueConditions { - /** Имя говорящего (опционально). */ - speaker?: string; - /** Ключ имени говорящего (локализация, как textKey). */ - speakerKey?: string; - /** Текст реплики. Узел без текста — «действие»: применяет эффекты и уходит в next. */ - text?: string; - /** Варианты ответа игрока. */ - choices?: DialogueChoice[]; - /** Следующий узел. */ - next?: string; - /** Явный конец диалога (для конечных узлов без choices/next). */ - end?: boolean; - /** Настроение реплики (игра мапит на цвет/портрет). */ - mood?: string; - /** Свободные метки для агента/инструментов (на геймплей не влияют). */ - tags?: string[]; - /** Ключ строки в таблице локализации; переопределяет inline text. */ - textKey?: string; -} - -export interface DialogueGraph { - /** id стартового узла. */ - start: string; - nodes: Record; -} - -/** Есть ли в условиях хоть что-то для проверки. */ -export function hasConditions(c: DialogueConditions): boolean { - return Boolean( - c.when?.length || c.whenNot?.length || c.whenVar || c.whenVars?.length || c.hasItem?.length - ); -} - -/** Узел-реплика: есть inline text или ключ строки. */ -export function hasText(n: { text?: string; textKey?: string }): boolean { - return n.text !== undefined || n.textKey !== undefined; -} - -/** Сравнение переменной по op; undefined (переменной нет) не проходит gt/lt/ge/le. */ -function checkVar(state: GameState, cond: VarCondition): boolean { - const v = state.getVar(cond.key); - switch (cond.op) { - case 'eq': - return v === cond.value; - case 'ne': - return v !== cond.value; - case 'gt': - return Number(v) > Number(cond.value); - case 'lt': - return Number(v) < Number(cond.value); - case 'ge': - return Number(v) >= Number(cond.value); - case 'le': - return Number(v) <= Number(cond.value); - } -} - -/** - * Чистая проверка условий узла/выбора без рантайма: удобна для квестовых - * стадий, dry-run и валидатора. world не задан → hasItem ложен. - */ -export function evalConditions(c: DialogueConditions, state: GameState, world?: DialogueWorld): boolean { - for (const f of c.when ?? []) { - if (!state.hasFlag(f)) return false; - } - for (const f of c.whenNot ?? []) { - if (state.hasFlag(f)) return false; - } - if (c.whenVar && !checkVar(state, c.whenVar)) return false; - for (const cond of c.whenVars ?? []) { - if (!checkVar(state, cond)) return false; - } - if (c.hasItem) { - if (!world) return false; - for (const id of c.hasItem) { - if (!world.hasItem(id)) return false; - } - } - return true; -} \ 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 deleted file mode 100644 index dd0c484..0000000 --- a/packages/engine/src/ecs/__tests__/ecs.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 18d14a4..0000000 --- a/packages/engine/src/ecs/ecs.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Минимальный 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 deleted file mode 100644 index bdb79a1..0000000 --- a/packages/engine/src/index.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Публичный 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 { FpsMeter } from './core/FpsMeter'; -export { EventBus } from './core/EventBus'; -export { TweenManager, type TweenOptions, type TweenHandle } from './core/Tween'; -export { Updatables, type Updatable } from './core/Updatables'; -export { - linear, - quadIn, - quadOut, - cubicIn, - cubicOut, - sineInOut, - backOut, - type EaseFn -} from './core/easing'; - -// agent (инструменты ИИ-агента: снапшоты, шаги, ввод, инварианты) -export { EngineAgent, ENGINE_SNAPSHOT_KEYS } from './agent/EngineAgent'; -export { - type AgentHost, - type EngineSnapshot, - type Invariant, - type SceneAgent, - type SnapshotLayer, - type JsonValue, - type WaitForOptions -} from './agent/types'; -export { - checkFinite, - checkRange, - checkBounds, - checkWalkable, - mergeInvariants -} from './agent/invariants'; - -// state (см. core) -export { GameState, type GameStateData } from './core/GameState'; -export { StateMachine, type StateDef } from './core/StateMachine'; -export { Cooldown } from './core/Cooldown'; -export { Settings, type SettingsData } from './core/Settings'; - -// inventory -export { - Inventory, - inventoryFromData, - type InventoryData, - type InventoryOptions, - type InventoryChange, - type InventoryChangeKind -} from './inventory/Inventory'; -// cutscene -export { CutsceneRunner, type CutsceneStep, type Movable } from './cutscene/CutsceneRunner'; -export { applyEase } from './cutscene/tween'; - -// scene -export { SceneManager, type Scene, type SceneTransition } from './scene/SceneManager'; -export { - SceneRegistry, - type SceneObjectDef, - type SceneObject -} from './scene/SceneRegistry'; - -// ecs -export { World, type Entity, type System } from './ecs/ecs'; - -// math -export { add, sub, len, dist, moveTo, type Vec2 } from './math/Vec2'; -export { - isoToScreen, - screenToIso, - worldToScreen, - screenToWorld, - unitsToPx, - worldLen, - worldNorm, - worldDist, - moveTowardsW, - tileToWorld, - worldToTile, - worldRectToScreen, - DEFAULT_ISO, - type IsoLayout -} from './math/iso'; -export { createRng, hashTile, type Rng } from './math/rng'; -export { - inCircle, - inCircleW, - inCone, - inConeW, - angleBetween, - nearest, - conePath -} from './math/shapes'; - -// map -export { - IsometricTileMap, - type TileMapData, - type TallSpec, - type PropData, - type PropPlaced, - type PropView, - type DecorData -} from './map/IsometricTileMap'; -export { findPath, findPathToNeighbor, type Grid } from './map/pathfinding'; -export { - gridOf, - circleFits, - moveCircle, - separateCircles -} from './map/collision'; -export { - encodeMap, - parseMap, - type MapFile, - type MapFileV1 -} from './map/mapFormat'; -export { fromTiledIso, type TiledMap } from './map/tiled'; -export { - renderMinimap, - type MinimapImage, - type MinimapMarker, - type MinimapOptions -} from './map/minimap'; - -// render -export { Renderer, type RendererOptions } from './render/Renderer'; -export { Camera } from './render/Camera'; -export { IsoDepthLayer } from './render/IsoDepthLayer'; -export { computeScale } from './render/scale'; -export { ParticleEmitter, type EmitterOptions } from './render/Particles'; -export { - Lighting, - makeGlowTexture, - makeVignetteTexture, - type LightingOptions, - type DarkSpotDef -} from './render/Lighting'; -export { Shake } from './render/shake'; -export { SpriteFlash } from './render/spriteFx'; - -// fx (слой one-shot эффектов и пресеты) -export { FxLayer, type FxLayerOptions } from './render/fx/FxLayer'; -export { FxRing } from './render/fx/FxRing'; -export { FxFloatText } from './render/fx/FxFloatText'; -export { FxFade, type FxFadeOptions } from './render/fx/FxFade'; -export { FxPop } from './render/fx/FxPop'; -export { FxTrail } from './render/fx/FxTrail'; -export { sparkPreset, smokePreset, emberPreset, dustPreset } from './render/fx/particlePresets'; -export { - sampleRing, - ringDuration, - sampleFloat, - readSeconds, - sampleFade, - samplePop, - type FxRingSpec, - type FxRingSample, - type FxFloatSpec, - type FxFloatSample, - type FxFadeSpec, - type FxPopSpec -} from './render/fx/fxSim'; -export { - GLOW_BASE_PX, - flickerFactor, - lightFrame, - lerpAmbient, - dimColor, - pulseEnvelope, - pulseTotal, - dayNightFactor, - DEFAULT_DAY_NIGHT, - dayNightAmbient, - DUSK_PEAK, - DEFAULT_DUSK_COLOR, - type LightDef, - type LightFrame, - type PulseSpec, - type DayNightSpec -} from './render/lightSim'; -export { - blurKernel, - blurStrengthAt, - aberrationShiftAt, - shimmerOffsetY, - type ShimmerParams -} from './render/postfx/postSim'; -export { PostFx, type BlurState, type ShimmerState } from './render/postfx/PostFx'; -export { - sunShadow, - blobPlacement, - SHADOW_COLOR, - type SunShadow, - type BlobPlacement -} from './render/shadowSim'; -export { makeShadowTexture, applyBlobShadow, SHADOW_TEX_PX } from './render/shadows'; -export { - stepParticle, - sampleSpawn, - sampleBurst, - mergeEmitterOptions, - type ParticleState, - type ParticleSimOptions, - type ParticleVisual -} from './render/particleSim'; - -// input -export { InputManager, type PointerState } from './input/InputManager'; -export { VirtualJoystick, type VirtualJoystickOptions } from './input/VirtualJoystick'; - -// anim -export { TintFlash, lerpColor } from './anim/flash'; -export { SpriteAnimator } from './anim/SpriteAnimator'; -export type { SpriteClip, SpriteAnimatorOptions } from './anim/SpriteAnimator'; -export { stepClip } from './anim/clip'; -export type { ClipLoop, ClipDef, ClipTick } from './anim/clip'; -export { sampleMotion } from './anim/motion'; -export type { MotionParams, MotionSample, BobParams, SwayParams, PulseParams, BlinkParams } from './anim/motion'; -export { SpriteMotion } from './anim/SpriteMotion'; -export type { SpriteMotionOptions } from './anim/SpriteMotion'; - -// dialogue: модель графа и чистые предикаты (graph.ts) + рантайм обхода -export { - DialogueRunner, - type DialogueView -} from './dialogue/DialogueRunner'; -export { - evalConditions, - hasConditions, - type DialogueGraph, - type DialogueNode, - type DialogueChoice, - type DialogueWorld, - type DialogueConditions, - type DialogueEffects, - type DialogueEffectOp, - type DialogueResult, - type DialogueHooks, - type VarCondition -} from './dialogue/graph'; - -// ui -export { DialogueBox, type DialogueLine, type DialogueBoxOptions } from './ui/DialogueBox'; -export { revealText, type RevealState } from './ui/reveal'; -export { PixelText, type PixelTextOptions, ensurePixelFont, setPixelTextResolution } from './ui/PixelText'; -export { Panel, type PanelOptions } from './ui/Panel'; -export { Button, type ButtonOptions } from './ui/Button'; -export { MenuList, type MenuListOptions, type MenuItem } from './ui/MenuList'; -export { ListScroll } from './ui/listScroll'; -export { readMenuInput, type MenuActionMap, type MenuNavEvent, type MenuInputSource } from './ui/menuInput'; -export { MenuSceneBase, type MenuSceneContext } from './ui/MenuSceneBase'; - -// audio -export { AudioManager, type AudioBuses, type BusName, type MusicHandle, type PlayOptions, type SfxHandle } from './audio/AudioManager'; -export { renderSpec, RATE, type SoundSpec, type SoundKind, type SpecVoice } from './audio/SoundSpec'; -export { renderMusic, renderMusicStereo, type MusicSpec, type TrackSpec, type NoteEvent, type InstrumentKind } from './audio/Music'; - -// assets -export { AssetLoader } from './assets/AssetLoader'; - -// save -export { SaveManager, type StorageLike } from './save/SaveManager'; - -// debug -export { DebugOverlay } from './debug/DebugOverlay'; -export { SpriteDebugView } from './ui/SpriteDebugView'; \ No newline at end of file diff --git a/packages/engine/src/input/InputManager.ts b/packages/engine/src/input/InputManager.ts deleted file mode 100644 index 10936b6..0000000 --- a/packages/engine/src/input/InputManager.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Ввод: клавиатура (маппинг «действие -> клавиши»), геймпад (стандартная карта) - * и указатель (мышь/тач) в координатах виртуального разрешения. - */ - -export interface PointerState { - /** Виртуальные пиксели. */ - x: number; - y: number; - down: boolean; - /** Нажато в этом кадре. */ - justPressed: boolean; - /** Отпущено в этом кадре. */ - justReleased: boolean; - /** true, если это событие тача (для мобильного управления). */ - isTouch: boolean; - /** Сколько фиксированных шагов удержана (0 — только нажата). */ - downTicks: number; -} - -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 keysReleased = new Set(); - /** code -> действия (одна клавиша может маппиться на несколько действий). */ - private keyActions = new Map(); - - // --- геймпад (стандартная карта кнопок) --- - /** action -> индексы кнопок стандартной карты. */ - private padActions = new Map(); - private padDown = new Set(); - private padPressed = new Set(); - private padReleased = new Set(); - - private pointer: PointerState = { - x: 0, - y: 0, - down: false, - justPressed: false, - justReleased: false, - isTouch: false, - downTicks: 0 - }; - - 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', this.onContextMenu); - } - - /** Маппинг: действие -> список кодов клавиш (e.code). */ - bindActions(map: Record): void { - for (const [action, codes] of Object.entries(map)) { - for (const code of codes) { - const actions = this.keyActions.get(code) ?? []; - if (!actions.includes(action)) actions.push(action); - this.keyActions.set(code, actions); - } - } - } - - /** - * Маппинг геймпада: действие -> индексы кнопок стандартной карты - * (0=A, 1=B, 8=Select, 9=Start, 12..15 — крестовина). - */ - bindGamepad(map: Record): void { - for (const [action, buttons] of Object.entries(map)) { - this.padActions.set(action, buttons); - } - } - - /** - * Опрос геймпадов. Вызывается движком в начале каждого фиксированного шага - * (до scenes.update). - */ - update(): void { - this.padPressed.clear(); - this.padReleased.clear(); - if (typeof navigator === 'undefined' || !navigator.getGamepads) return; - for (const pad of navigator.getGamepads()) { - if (!pad) continue; - pad.buttons.forEach((btn, i) => { - const pressed = btn.pressed; - if (pressed && !this.padDown.has(i)) { - this.padPressed.add(i); - } - if (!pressed && this.padDown.has(i)) { - this.padReleased.add(i); - } - if (pressed) { - this.padDown.add(i); - } else { - this.padDown.delete(i); - } - }); - break; // опрашиваем первый подключённый геймпад - } - } - - /** Оси левого стика (-1..1) первого подключённого геймпада (или null). */ - getLeftStick(): { x: number; y: number } | null { - if (typeof navigator === 'undefined' || !navigator.getGamepads) return null; - for (const pad of navigator.getGamepads()) { - if (!pad) continue; - const [x = 0, y = 0] = pad.axes; - return { - x: Math.abs(x) < 0.15 ? 0 : x, // мёртвая зона - y: Math.abs(y) < 0.15 ? 0 : y - }; - } - return null; - } - - /** Действие активно (зажато): клавиатура или геймпад. */ - isActionActive(action: string): boolean { - for (const [code, actions] of this.keyActions) { - if (actions.includes(action) && this.keysDown.has(code)) return true; - } - const padButtons = this.padActions.get(action); - if (padButtons) { - for (const b of padButtons) { - if (this.padDown.has(b)) return true; - } - } - return false; - } - - /** Действие нажато именно в этом тике. */ - isActionJustPressed(action: string): boolean { - for (const code of this.keysPressed) { - if (this.keyActions.get(code)?.includes(action)) return true; - } - const padButtons = this.padActions.get(action); - if (padButtons) { - for (const b of padButtons) { - if (this.padPressed.has(b)) return true; - } - } - return false; - } - - /** Действие отпущено именно в этом тике (клавиатура или геймпад). */ - isActionJustReleased(action: string): boolean { - for (const code of this.keysReleased) { - if (this.keyActions.get(code)?.includes(action)) return true; - } - const padButtons = this.padActions.get(action); - if (padButtons) { - for (const b of padButtons) { - if (this.padReleased.has(b)) return true; - } - } - return false; - } - - isKeyDown(code: string): boolean { - return this.keysDown.has(code); - } - - wasKeyPressed(code: string): boolean { - return this.keysPressed.has(code); - } - - wasKeyReleased(code: string): boolean { - return this.keysReleased.has(code); - } - - getPointer(): Readonly { - return this.pointer; - } - - /** Вызывается в конце каждого тика: очищает «just pressed/released», копит downTicks. */ - endTick(): void { - this.keysPressed.clear(); - this.keysReleased.clear(); - this.padPressed.clear(); - this.padReleased.clear(); - this.pointer.justPressed = false; - this.pointer.justReleased = false; - if (this.pointer.down) { - this.pointer.downTicks += 1; - } else { - this.pointer.downTicks = 0; - } - } - - 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); - this.canvas.removeEventListener('contextmenu', this.onContextMenu); - } - - // --- инъекция ввода (агентный мост / тесты) --- - // Идёт через ТЕ ЖЕ структуры, что и реальные события: инъекция между тиками - // семантически идентична нажатию, «just pressed» виден ровно один тик. - - /** pointerdown в виртуальных пикселях (без DOM). */ - injectPointerDown(x: number, y: number, isTouch = false): void { - this.pressPointer(x, y, isTouch); - } - - /** pointerup в виртуальных пикселях (без DOM). */ - injectPointerUp(x: number, y: number, isTouch = false): void { - this.releasePointer(x, y, isTouch); - } - - /** Действие «нажато» (isActionJustPressed на следующем тике). */ - injectAction(action: string): void { - for (const code of this.codesOf(action)) this.pressCode(code); - } - - /** Действие «отпущено». */ - injectActionRelease(action: string): void { - for (const code of this.codesOf(action)) this.releaseCode(code); - } - - /** Сырой код клавиши (e.code) — нажать и отпустить нельзя по отдельности: см. injectKeyCodeUp. */ - injectKeyCode(code: string): void { - this.pressCode(code); - } - - /** Сырой код клавиши (e.code) — отпустить. */ - injectKeyCodeUp(code: string): void { - this.releaseCode(code); - } - - // --- общие примитивы (DOM-события и инъекции идут через одни и те же) --- - - private pressCode(code: string): void { - if (!this.keysDown.has(code)) this.keysPressed.add(code); - this.keysDown.add(code); - } - - private releaseCode(code: string): void { - this.keysDown.delete(code); - this.keysReleased.add(code); - } - - /** Все коды клавиш, замапленные на действие. */ - private codesOf(action: string): string[] { - const out: string[] = []; - for (const [code, actions] of this.keyActions) { - if (actions.includes(action)) out.push(code); - } - return out; - } - - private pressPointer(x: number, y: number, isTouch: boolean): void { - this.pointer.x = x; - this.pointer.y = y; - this.pointer.down = true; - this.pointer.justPressed = true; - this.pointer.downTicks = 0; - this.pointer.isTouch = isTouch; - this.handlers.onPointerDown?.(this.pointer); - } - - private releasePointer(x: number, y: number, isTouch: boolean): void { - this.pointer.x = x; - this.pointer.y = y; - this.pointer.down = false; - this.pointer.justReleased = true; - this.pointer.isTouch = isTouch; - this.handlers.onPointerUp?.(this.pointer); - } - - private onKeyDown = (e: KeyboardEvent): void => { - this.pressCode(e.code); - }; - - private onKeyUp = (e: KeyboardEvent): void => { - this.releaseCode(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.pressPointer(x, y, e.pointerType === 'touch'); - }; - - private onPointerUp = (e: PointerEvent): void => { - const { x, y } = this.toVirtual(e); - this.releasePointer(x, y, e.pointerType === 'touch'); - }; - - private onPointerMove = (e: PointerEvent): void => { - const { x, y } = this.toVirtual(e); - this.pointer.x = x; - this.pointer.y = y; - this.handlers.onPointerMove?.(this.pointer); - }; - - private onContextMenu = (e: Event): void => { - e.preventDefault(); - }; -} \ No newline at end of file diff --git a/packages/engine/src/input/VirtualJoystick.ts b/packages/engine/src/input/VirtualJoystick.ts deleted file mode 100644 index 6637ab8..0000000 --- a/packages/engine/src/input/VirtualJoystick.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Container, Graphics, Rectangle } from 'pixi.js'; - -/** - * Виртуальный джойстик для тач-устройств: рисуется в uiRoot, показывает - * базовый круг и ручку. Игра читает getVector() и двигает персонажа. - * Появляется там, где игрок коснулся экрана (полупрозрачно), исчезает при отпускании. - */ -export interface VirtualJoystickOptions { - /** Радиус базового круга (виртуальные пиксели). */ - radius?: number; - /** Цвета. */ - color?: number; - /** Отступ от краёв экрана, за который джойстик не заходит. */ - margin?: number; - /** Полные размеры экрана (виртуальные), чтобы клэмпить позицию. */ - screen: { width: number; height: number }; -} - -export class VirtualJoystick extends Container { - private base: Graphics; - private knob: Graphics; - private radius: number; - private margin: number; - private screen: { width: number; height: number }; - private color: number; - private dragging = false; - - /** Текущий вектор (-1..1 по каждой оси; длина ≤ 1). */ - private vector = { x: 0, y: 0 }; - - constructor(options: VirtualJoystickOptions) { - super(); - this.radius = options.radius ?? 28; - this.margin = options.margin ?? 12; - this.screen = options.screen; - this.color = options.color ?? 0x8899aa; - - this.visible = false; - this.eventMode = 'static'; - this.hitArea = new Rectangle(0, 0, this.screen.width, this.screen.height); - - this.base = new Graphics(); - this.knob = new Graphics(); - this.addChild(this.base, this.knob); - this.drawStatic(); - - this.on('pointerdown', (e) => this.onDown(e.global.x, e.global.y)); - this.on('pointermove', (e) => { - if (this.dragging) this.onMove(e.global.x, e.global.y); - }); - this.on('pointerup', () => this.release()); - this.on('pointerupoutside', () => this.release()); - } - - /** Вектор управления; активен ли джойстик сейчас. */ - getVector(): { x: number; y: number } { - return { x: this.vector.x, y: this.vector.y }; - } - - get active(): boolean { - return this.dragging; - } - - /** Обновить размеры экрана (при ресайзе). */ - setScreen(width: number, height: number): void { - this.screen = { width, height }; - } - - private onDown(px: number, py: number): void { - this.dragging = true; - this.visible = true; - this.reposition(px, py); - this.updateKnob(px, py); - } - - private onMove(px: number, py: number): void { - this.updateKnob(px, py); - } - - private release(): void { - this.dragging = false; - this.visible = false; - this.vector = { x: 0, y: 0 }; - } - - private reposition(px: number, py: number): void { - const x = Math.min(Math.max(px, this.margin + this.radius), this.screen.width - this.margin - this.radius); - const y = Math.min(Math.max(py, this.margin + this.radius), this.screen.height - this.margin - this.radius); - this.position.set(x, y); - } - - private updateKnob(px: number, py: number): void { - let dx = px - this.x; - let dy = py - this.y; - const len = Math.hypot(dx, dy); - const max = this.radius; - if (len > max) { - dx = (dx / len) * max; - dy = (dy / len) * max; - } - this.knob.position.set(dx, dy); - this.vector = { x: dx / max, y: dy / max }; - } - - private drawStatic(): void { - this.base.clear(); - this.base.circle(0, 0, this.radius).stroke({ color: this.color, width: 1, alpha: 0.5 }); - this.base.circle(0, 0, this.radius * 0.6).fill({ color: this.color, alpha: 0.08 }); - this.knob.clear(); - this.knob.circle(0, 0, this.radius * 0.35).fill({ color: this.color, alpha: 0.6 }); - } -} \ No newline at end of file diff --git a/packages/engine/src/input/__tests__/InputManager.test.ts b/packages/engine/src/input/__tests__/InputManager.test.ts deleted file mode 100644 index d8eae25..0000000 --- a/packages/engine/src/input/__tests__/InputManager.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { InputManager, type PointerState } from '../InputManager'; - -/** - * Фейковые canvas/window: достаточно, чтобы InputManager работал без браузера. - * События диспатчим напрямую в сохранённые обработчики. - */ -type Handler = (e: unknown) => void; - -function makeFakeEnv() { - const canvasListeners = new Map(); - const windowListeners = new Map(); - const canvas = { - addEventListener: (type: string, fn: Handler) => canvasListeners.set(type, fn), - removeEventListener: (type: string) => canvasListeners.delete(type), - getBoundingClientRect: () => ({ left: 0, top: 0, width: 480, height: 270 }) - }; - (globalThis as Record).window = { - addEventListener: (type: string, fn: Handler) => windowListeners.set(type, fn), - removeEventListener: (type: string) => windowListeners.delete(type) - }; - return { canvas, canvasListeners, windowListeners } as const; -} - -function keyEvent(code: string) { - return { code, pointerType: 'mouse', clientX: 0, clientY: 0 }; -} - -function pointerEvent(x: number, y: number, type = 'mouse') { - return { clientX: x, clientY: y, pointerType: type }; -} - -describe('InputManager (фейковый canvas/window)', () => { - let input: InputManager; - let env: ReturnType; - - beforeEach(() => { - env = makeFakeEnv(); - input = new InputManager(env.canvas as unknown as HTMLCanvasElement, 480, 270); - input.bindActions({ attack: ['Space'], advance: ['Enter'] }); - }); - - it('justPressed/justReleased по клавиатуре, endTick очищает', () => { - const kd = env.windowListeners.get('keydown')!; - const ku = env.windowListeners.get('keyup')!; - - kd(keyEvent('Space')); - expect(input.isActionJustPressed('attack')).toBe(true); - expect(input.isActionActive('attack')).toBe(true); - input.endTick(); - - expect(input.isActionJustPressed('attack')).toBe(false); - expect(input.isActionActive('attack')).toBe(true); - - ku(keyEvent('Space')); - expect(input.wasKeyReleased('Space')).toBe(true); - expect(input.isActionJustReleased('attack')).toBe(true); - expect(input.isActionActive('attack')).toBe(false); - input.endTick(); - expect(input.isActionJustReleased('attack')).toBe(false); - }); - - it('isActionJustReleased не путает действия', () => { - const kd = env.windowListeners.get('keydown')!; - const ku = env.windowListeners.get('keyup')!; - kd(keyEvent('Space')); - input.endTick(); - ku(keyEvent('Space')); - expect(input.isActionJustReleased('attack')).toBe(true); - expect(input.isActionJustReleased('advance')).toBe(false); - }); - - it('одна клавиша маппится на несколько действий', () => { - const kd = env.windowListeners.get('keydown')!; - input.bindActions({ advance: ['Space'] }); // Space уже занят attack - kd(keyEvent('Space')); - expect(input.isActionJustPressed('attack')).toBe(true); - expect(input.isActionJustPressed('advance')).toBe(true); - expect(input.isActionActive('attack')).toBe(true); - expect(input.isActionActive('advance')).toBe(true); - }); - - it('указатель: downTicks копится, justPressed сбрасывается в endTick', () => { - const pd = env.canvasListeners.get('pointerdown')!; - const pu = env.canvasListeners.get('pointerup')!; - - pd(pointerEvent(240, 135)); - const p = input.getPointer() as PointerState; - expect(p.justPressed).toBe(true); - expect(p.downTicks).toBe(0); - expect(p.x).toBe(240); - input.endTick(); - - expect(p.justPressed).toBe(false); - expect(p.down).toBe(true); - input.endTick(); - expect(p.downTicks).toBe(2); // накопился за два тика удержания - - pu(pointerEvent(240, 135)); - expect(p.justReleased).toBe(true); - expect(p.down).toBe(false); - input.endTick(); - expect(p.downTicks).toBe(0); - }); - - it('координаты переводятся в виртуальное разрешение', () => { - const pm = env.canvasListeners.get('pointermove')!; - // фейковый rect: 0..480x270 1:1, проверяем смещение - pm(pointerEvent(96, 54)); - const p = input.getPointer(); - expect(p.x).toBe(96); - expect(p.y).toBe(54); - }); - - it('isTouch помечает тач-события', () => { - const pd = env.canvasListeners.get('pointerdown')!; - pd(pointerEvent(0, 0, 'touch')); - expect(input.getPointer().isTouch).toBe(true); - }); - - // --- инъекция ввода (агентный мост): та же семантика, что у реальных событий --- - - it('injectPointerDown/Up видны как justPressed ровно один тик', () => { - input.injectPointerDown(96, 54); - const p = input.getPointer(); - expect(p.down).toBe(true); - expect(p.justPressed).toBe(true); - expect(p.x).toBe(96); - input.endTick(); - expect(input.getPointer().justPressed).toBe(false); - input.injectPointerUp(96, 54); - expect(input.getPointer().justReleased).toBe(true); - }); - - it('injectAction срабатывает в isActionJustPressed один тик', () => { - input.injectAction('attack'); - expect(input.isActionJustPressed('attack')).toBe(true); - expect(input.isActionActive('attack')).toBe(true); - input.endTick(); - expect(input.isActionJustPressed('attack')).toBe(false); - expect(input.isActionActive('attack')).toBe(true); // всё ещё зажато - input.injectActionRelease('attack'); - expect(input.isActionActive('attack')).toBe(false); - expect(input.isActionJustReleased('attack')).toBe(true); - }); - - it('injectKeyCode виден в wasKeyPressed/в действие-маппинге', () => { - input.injectKeyCode('Space'); - expect(input.wasKeyPressed('Space')).toBe(true); - expect(input.isActionJustPressed('attack')).toBe(true); - input.injectKeyCodeUp('Space'); - expect(input.isKeyDown('Space')).toBe(false); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/inventory/Inventory.ts b/packages/engine/src/inventory/Inventory.ts deleted file mode 100644 index 892b0a6..0000000 --- a/packages/engine/src/inventory/Inventory.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Инвентарь — жанронезависимый контейнер предметов. - * Хранит счётчики по id: `Map`. Стеки неявные — количество. - * Лимиты (слоты/стак) обрезают добавление и возвращают факт — модель не бросает. - * Смена состава оповещает подписчиков (view-агностично): подписка — как у EventBus, - * но события эмитит сам контейнер, чтобы работать без внешней шины. - */ - -/** Лимиты контейнера (undefined/0 — без лимита). */ -export interface InventoryOptions { - /** Максимум разных слотов (id). */ - maxSlots?: number; - /** Максимум штук в одном слоте. */ - maxPerStack?: number; -} - -export type InventoryChangeKind = 'add' | 'remove' | 'clear' | 'load'; - -/** Деталь изменения: знаковая дельта + итог; clear/load — batch-событие (id '', count 0). */ -export interface InventoryChange { - kind: InventoryChangeKind; - id: string; - count: number; - /** Итоговый счётчик предмета после изменения (0 для clear/load). */ - after: number; -} - -/** Создать инвентарь из сериализованного состояния. */ -export function inventoryFromData( - data: InventoryData | undefined | null, - options: InventoryOptions = {} -): Inventory { - const inv = new Inventory(options); - if (data) inv.load(data); - return inv; -} - -/** Сериализованное состояние инвентаря (для сейвов; формат стабильный). */ -export interface InventoryData { - items: Record; -} - -export class Inventory { - private counts = new Map(); - private listeners = new Set<(change: InventoryChange) => void>(); - private slotLimitValue: number; - private stackLimitValue: number; - - constructor(options: InventoryOptions = {}) { - this.slotLimitValue = options.maxSlots && options.maxSlots > 0 ? options.maxSlots : Infinity; - this.stackLimitValue = options.maxPerStack && options.maxPerStack > 0 ? options.maxPerStack : Infinity; - } - - /** Подписка на изменения. Возвращает отписку. */ - onChange(fn: (change: InventoryChange) => void): () => void { - this.listeners.add(fn); - return () => this.listeners.delete(fn); - } - - /** - * Добавить n штук (лимиты обрезают; n <= 0 — ничего). - * Возвращает, сколько реально влезло. - */ - add(itemId: TId, n = 1): number { - if (n <= 0) return 0; - const before = this.counts.get(itemId) ?? 0; - if (before === 0 && this.counts.size >= this.slotLimitValue) return 0; - const gained = Math.min(n, this.stackLimitValue - before); - if (gained <= 0) return 0; - this.counts.set(itemId, before + gained); - this.notify({ kind: 'add', id: itemId, count: gained, after: before + gained }); - return gained; - } - - /** Сколько ещё влезет этого предмета (лимиты стака и слотов). */ - spaceFor(itemId: TId): number { - const have = this.counts.get(itemId) ?? 0; - if (have > 0) return Math.max(0, this.stackLimitValue - have); - return this.counts.size >= this.slotLimitValue ? 0 : this.stackLimitValue; - } - - /** - * Убрать n штук предмета. Если после вычитания стало <= 0 — слот исчезает. - * Возвращает, сколько реально убрано (нельзя убрать больше, чем есть). - */ - remove(itemId: TId, n = 1): number { - const have = this.counts.get(itemId) ?? 0; - if (have <= 0 || n <= 0) return 0; - const left = Math.max(0, have - n); - if (left === 0) this.counts.delete(itemId); - else this.counts.set(itemId, left); - this.notify({ kind: 'remove', id: itemId, count: -Math.min(n, have), after: left }); - return Math.min(n, have); - } - - /** Сколько штук предмета лежит (0, если нет). */ - count(itemId: TId): number { - return this.counts.get(itemId) ?? 0; - } - - /** Есть ли предмет (в количестве >= 1). */ - has(itemId: TId): boolean { - return this.count(itemId) > 0; - } - - /** Все слоты (id -> количество); порядок вставки. */ - get all(): Array<{ id: string; count: number }> { - return [...this.counts].map(([id, count]) => ({ id, count })); - } - - get empty(): boolean { - return this.counts.size === 0; - } - - /** Занято слотов (разных id). */ - get size(): number { - return this.counts.size; - } - - /** Лимит слотов (Infinity — без лимита). */ - get slotLimit(): number { - return this.slotLimitValue; - } - - /** Лимит стака (Infinity — без лимита). */ - get stackLimit(): number { - return this.stackLimitValue; - } - - /** Полностью очистить (новая игра). */ - clear(): void { - if (this.counts.size === 0) return; - this.counts.clear(); - this.notify({ kind: 'clear', id: '', count: 0, after: 0 }); - } - - serialize(): InventoryData { - return { items: Object.fromEntries(this.counts) }; - } - - /** Загрузить состояние (мусор и count <= 0 игнорируются; кламп под лимиты). */ - load(data: InventoryData): void { - this.counts.clear(); - for (const [id, count] of Object.entries(data.items ?? {})) { - if (typeof count !== 'number' || count <= 0) continue; - if (this.counts.size >= this.slotLimitValue) break; // берём первые N слотов - this.counts.set(id, Math.min(count, this.stackLimitValue)); - } - this.notify({ kind: 'load', id: '', count: 0, after: 0 }); - } - - private notify(change: InventoryChange): void { - for (const fn of this.listeners) fn(change); - } -} \ No newline at end of file diff --git a/packages/engine/src/inventory/__tests__/Inventory.test.ts b/packages/engine/src/inventory/__tests__/Inventory.test.ts deleted file mode 100644 index 9e3a97a..0000000 --- a/packages/engine/src/inventory/__tests__/Inventory.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { Inventory, inventoryFromData } from '../Inventory'; - -describe('Inventory', () => { - it('добавляет и считает предметы', () => { - const inv = new Inventory(); - expect(inv.empty).toBe(true); - inv.add('cloth'); - inv.add('bellflower', 2); - expect(inv.count('cloth')).toBe(1); - expect(inv.count('bellflower')).toBe(2); - expect(inv.has('cloth')).toBe(true); - expect(inv.has('salt')).toBe(false); - expect(inv.empty).toBe(false); - }); - - it('remove вычитает и чистит пустые слоты', () => { - const inv = new Inventory(); - inv.add('bellflower', 3); - expect(inv.remove('bellflower', 2)).toBe(2); - expect(inv.count('bellflower')).toBe(1); - expect(inv.remove('bellflower', 5)).toBe(1); // нельзя убрать больше, чем есть - expect(inv.count('bellflower')).toBe(0); - expect(inv.has('bellflower')).toBe(false); - expect(inv.remove('cloth')).toBe(0); // нет предмета — ничего - }); - - it('add с неположительным n — no-op', () => { - const inv = new Inventory(); - inv.add('salt', 0); - inv.add('salt', -1); - expect(inv.empty).toBe(true); - }); - - it('onChange оповещает о каждом изменении, отписка работает', () => { - const inv = new Inventory(); - const fn = vi.fn(); - const off = inv.onChange(fn); - inv.add('cloth'); - inv.add('cloth'); - inv.remove('cloth'); - off(); - inv.add('cloth'); - expect(fn).toHaveBeenCalledTimes(3); - }); - - it('serialize/load round-trip; load игнорирует мусор', () => { - const inv = new Inventory(); - inv.add('cloth'); - inv.add('bellflower', 3); - const data = inv.serialize(); - const back = inventoryFromData(JSON.parse(JSON.stringify(data))); - expect(back.all).toEqual([ - { id: 'cloth', count: 1 }, - { id: 'bellflower', count: 3 } - ]); - back.load({ items: { salt: 0, bad: -2, cloth: 2 } }); - expect(back.count('salt')).toBe(0); - expect(back.count('bad')).toBe(0); - expect(back.count('cloth')).toBe(2); - }); - - it('clear сбрасывает содержимое', () => { - const inv = new Inventory(); - inv.add('cloth'); - inv.clear(); - expect(inv.empty).toBe(true); - }); - - it('maxPerStack обрезает добавление и возвращает факт', () => { - const inv = new Inventory({ maxPerStack: 3 }); - expect(inv.add('bellflower', 5)).toBe(3); - expect(inv.count('bellflower')).toBe(3); - expect(inv.add('bellflower', 1)).toBe(0); // стак полон - expect(inv.spaceFor('bellflower')).toBe(0); - expect(inv.spaceFor('salt')).toBe(3); - }); - - it('maxSlots не даёт заводить новый слот, но стек в существующий идёт', () => { - const inv = new Inventory({ maxSlots: 2 }); - inv.add('cloth'); - inv.add('salt'); - expect(inv.add('bellflower')).toBe(0); // слотов нет - expect(inv.has('bellflower')).toBe(false); - expect(inv.add('salt', 2)).toBe(2); // свой слот не лимитируем - }); - - it('load клампит под лимиты', () => { - const inv = new Inventory({ maxSlots: 2, maxPerStack: 5 }); - inv.load({ items: { a: 9, b: 2, c: 1 } }); - expect(inv.all).toEqual([ - { id: 'a', count: 5 }, - { id: 'b', count: 2 } - ]); - expect(inv.size).toBe(2); - }); - - it('событие несёт деталь изменения для всех kind', () => { - const inv = new Inventory(); - const fn = vi.fn(); - inv.onChange(fn); - inv.add('cloth', 2); - expect(fn).toHaveBeenLastCalledWith({ kind: 'add', id: 'cloth', count: 2, after: 2 }); - inv.remove('cloth'); - expect(fn).toHaveBeenLastCalledWith({ kind: 'remove', id: 'cloth', count: -1, after: 1 }); - inv.remove('cloth', 1); - expect(fn).toHaveBeenLastCalledWith({ kind: 'remove', id: 'cloth', count: -1, after: 0 }); - inv.clear(); // пусто — clear не эмитится - inv.add('salt'); - inv.load({ items: {} }); - expect(fn).toHaveBeenLastCalledWith({ kind: 'load', id: '', count: 0, after: 0 }); - inv.add('salt'); - inv.clear(); - expect(fn).toHaveBeenLastCalledWith({ kind: 'clear', id: '', count: 0, after: 0 }); - }); - - it('генерик типизирует id (компиляция)', () => { - type Item = 'a' | 'b'; - const inv = new Inventory(); - inv.add('a', 2); - expect(inv.count('a')).toBe(2); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts deleted file mode 100644 index a3aca30..0000000 --- a/packages/engine/src/map/IsometricTileMap.ts +++ /dev/null @@ -1,519 +0,0 @@ -import { Container, Graphics, Texture, Sprite } from 'pixi.js'; -import { Grid } from './pathfinding'; -import { StaticGrid } from './collision'; -import { IsoLayout, DEFAULT_ISO, isoToScreen, screenToIso } from '../math/iso'; -import { hashTile } from '../math/rng'; -import { IsoDepthLayer } from '../render/IsoDepthLayer'; - -/** - * Высокий объект на тайле: рисуется спрайтом (якорь в центре ромба) или, - * если текстуры нет, колонной высотой height мировых юнитов (·tileW px). - * ground — id тайла, каким рисовать землю под объектом (по умолчанию — сам объект). - * shadow — спрайт шириной в тайл экранно накрывает тайл «за» объектом - * (tx-1, ty-1): он блокируется для движения (герой не заходит «внутрь» дома). - */ -export interface TallSpec { - height: number; - ground?: number; - shadow?: boolean; -} - -/** - * Крупный объект на несколько тайлов (дом, большое дерево): footprint w×h - * от северо-западного угла (x, y). Коллизия — весь footprint; земля под ним — - * ground (если задан); спрайт якорится низом в центр footprint'а. - * Спрайт живёт в map.propViews — сцена добавляет его в свой IsoDepthLayer - * через addRect, чтобы герои корректно перекрывались объектом. - */ -export interface PropData { - /** id текстуры спрайта. */ - id: number; - /** Северо-западный тайл footprint. */ - x: number; - y: number; - /** Размер footprint в тайлах (по умолчанию 1×1). */ - w?: number; - h?: number; - /** Чем рисовать землю под footprint (по умолчанию — не трогать). */ - ground?: number; - /** Высота в мировых юнитах — для плейсхолдера без текстуры. */ - height?: number; -} - -/** Проп после нормализации (w/h заполнены). */ -export interface PropPlaced extends PropData { - w: number; - h: number; -} - -/** Спрайт пропа + его footprint — сцена вставляет view в свой depth-слой. */ -export interface PropView { - view: Container; - prop: PropPlaced; -} - -/** - * Мелкий плоский декор на тайле (кустик, камень, проплешина) — рисуется - * плашмя между землёй и высокими объектами, глубина не сортируется. - */ -export interface DecorData { - /** id текстуры декора. */ - id: number; - /** Тайл (ромб), на котором лежит декор. */ - x: number; - y: number; - /** Смещение от центра ромба в px виртуального разрешения. */ - dx?: number; - dy?: number; -} - -/** - * Изометрическая тайл-карта. - * Данные карты — числа (id тайлов); отрисовка — по таблице id -> Texture. - * Проходимость определяется набором блокирующих id; тайлы «высоких» объектов - * (стены, деревья) рисуются поверх земли по строкам глубины. - */ -export interface TileMapData { - width: number; - height: number; - /** Индексы тайлов, length = width * height. */ - tiles: number[]; - /** Тайлы с этими id непроходимы. */ - blocked: number[]; - /** Тайлы с этими id рисуются как вертикальные объекты. */ - tall?: Record; - /** Крупные объекты с footprint'ом в несколько тайлов. */ - props?: PropData[]; - /** - * Варианты текстур: id -> список id-шников вариантов (включая сам id). - * Клетка рисуется одним из вариантов — детерминированно по (x, y, id), - * поэтому вид карты стабилен между запусками и при setTile. - */ - variants?: Record; - /** Мелкий плоский декор (микро-кусты, камни) — движок только рисует список. */ - decor?: DecorData[]; -} - -/** Спрайты одной ячейки — для перерисовки тайла без перестроения карты. */ -interface CellSprites { - ground?: Sprite; - object?: Sprite; - /** Плейсхолдеры без текстуры (чистятся вместе с ячейкой при перерисовке). */ - groundPlaceholder?: Container; - objectPlaceholder?: Container; - /** id тайла, по которому создан спрайт (для регистрации в анимациях). */ - groundId?: number; - objectId?: number; -} - -/** Анимация тайлов одного id: кадры, fps и общий таймлайн на все клетки. */ -interface TileAnim { - frames: Texture[]; - fps: number; - t: number; - sprites: Set; -} - -export class IsometricTileMap implements Grid { - readonly data: TileMapData; - readonly iso: IsoLayout; - readonly view: Container; - - private textures: Map; - /** Проходимость (blocked/пропы/тени) — единая с collision.gridOf логика. */ - private grid: StaticGrid; - private tallSpecs: Map; - /** Пропы после нормализации; их footprint блокирует клетки карты. */ - readonly props: PropPlaced[]; - /** Спрайты пропов: сцена вставляет их в свой IsoDepthLayer (addRect). */ - readonly propViews: PropView[] = []; - /** Depth-слой для tall-объектов (null — рисовать в objectsLayer, как раньше). */ - private tallDepthLayer: IsoDepthLayer | null = null; - private groundLayer: Container; - private decorLayer: Container; - private objectsLayer: Container; - /** Декор по тайлам (key = ty*width+tx) — один спрайт на тайл. */ - private decorSprites = new Map(); - private cells = new Map(); - private anims = new Map(); - private spriteAnims = new Map(); - /** Варианты текстур по id (из данных или setTileVariants). */ - private variants = new Map(); - /** Окно куллинга (тайлы); null — без куллинга (всё видно). */ - private cullRange: { x0: number; y0: number; x1: number; y1: number } | null = null; - - 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.textures = textures; - this.grid = new StaticGrid(data); - for (const [id, ids] of Object.entries(data.variants ?? {})) { - this.variants.set(Number(id), ids); - } - this.tallSpecs = new Map( - Object.entries(data.tall ?? {}).map(([id, spec]) => [ - Number(id), - typeof spec === 'number' ? { height: spec } : spec - ]) - ); - this.view = new Container(); - - const ground = new Container(); - const decor = new Container(); - const objects = new Container(); - this.groundLayer = ground; - this.decorLayer = decor; - this.objectsLayer = objects; - // Порядок детей = порядок отрисовки: земля -> декор (плашмя) -> объекты. - this.view.addChild(ground, decor, objects); - - // Сортировка глубины по диагонали (tx + ty) — классика изометрии. - const cells: { tx: number; ty: number }[] = []; - for (let ty = 0; ty < data.height; ty++) { - for (let tx = 0; tx < data.width; tx++) { - cells.push({ tx, ty }); - } - } - cells.sort((a, b) => a.tx + a.ty - (b.tx + b.ty)); - - for (const cell of cells) { - this.drawCell(cell.tx, cell.ty); - } - - // Пропы: footprint блокирует клетки (в grid), под ним рисуем ground, - // спрайт — в propViews (сцена вставит его в свой depth-слой с addRect). - this.props = (data.props ?? []).map((p) => ({ ...p, w: p.w ?? 1, h: p.h ?? 1 })); - for (const prop of this.props) { - this.drawPropGround(prop); - this.propViews.push({ view: this.makePropView(prop), prop }); - } - - // Декор из данных: движок только рисует список (генерация — на стороне игры). - for (const d of data.decor ?? []) { - const tex = this.textures.get(d.id); - if (tex) this.addDecor(d.x, d.y, tex, { dx: d.dx, dy: d.dy }); - } - } - - /** - * Положить декор на тайл (микро-куст, камень): плашмя, якорь в центре ромба, - * опциональное смещение dx/dy для живости. Один спрайт на тайл — повторный - * addDecor заменяет предыдущий. setTile декор не трогает. - * Возвращает спрайт (игра может его анимировать); тайл вне карты — undefined. - */ - addDecor(tx: number, ty: number, tex: Texture, opts: { dx?: number; dy?: number } = {}): Sprite | undefined { - if (tx < 0 || ty < 0 || tx >= this.data.width || ty >= this.data.height) return undefined; - this.clearDecor(tx, ty); - const p = isoToScreen(tx, ty, this.iso); - const s = new Sprite(tex); - s.anchor.set(0.5, 0.5); - s.position.set(p.x + (opts.dx ?? 0), p.y + this.iso.tileH / 2 + (opts.dy ?? 0)); - this.decorLayer.addChild(s); - this.decorSprites.set(ty * this.data.width + tx, s); - return s; - } - - /** Убрать декор с тайла (no-op, если его нет). */ - clearDecor(tx: number, ty: number): void { - const s = this.decorSprites.get(ty * this.data.width + tx); - if (!s) return; - s.destroy(); - this.decorSprites.delete(ty * this.data.width + tx); - } - - /** Земля под footprint'ом пропа (если задан ground и текстура есть). */ - private drawPropGround(prop: PropPlaced): void { - const groundTex = prop.ground !== undefined ? this.textures.get(prop.ground) : undefined; - if (!groundTex) return; - for (let ty = prop.y; ty < prop.y + prop.h; ty++) { - for (let tx = prop.x; tx < prop.x + prop.w; tx++) { - const p = isoToScreen(tx, ty, this.iso); - const s = new Sprite(groundTex); - s.anchor.set(0.5, 0); - s.position.set(p.x, p.y); - this.groundLayer.addChild(s); - } - } - } - - /** Спрайт пропа (якорь низ-центр footprint'а) или колонна-плейсхолдер. */ - private makePropView(prop: PropPlaced): Container { - const c = isoToScreen(prop.x + prop.w / 2, prop.y + prop.h / 2, this.iso); - const tex = this.textures.get(prop.id); - if (tex) { - const s = new Sprite(tex); - s.anchor.set(0.5, 1); - s.position.set(c.x, c.y); - return s; - } - return this.drawPlaceholder(c.x, c.y, prop.w, prop.h, prop.height ?? 2); - } - - /** - * Колонна-плейсхолдер: основание — параллелограмм footprint (w×h юнитов), - * выдавленный вверх на height юнитов. Три видимые грани: верх, лево, право. - */ - private drawPlaceholder(cx: number, cy: number, w: number, h: number, height: number): Container { - const hw = this.iso.tileW / 2; - const hh = this.iso.tileH / 2; - const top = height * this.iso.tileW; - // Углы основания относительно центра (проекция мирового смещения). - const nw = { x: ((h - w) / 2) * hw, y: -((w + h) / 2) * hh }; - const ne = { x: ((w + h) / 2) * hw, y: ((h - w) / 2) * hh }; - const se = { x: ((w - h) / 2) * hw, y: ((w + h) / 2) * hh }; - const sw = { x: -((w + h) / 2) * hw, y: ((w - h) / 2) * hh }; - const g = new Graphics(); - const face = (a: typeof nw, b: typeof nw, fill: number) => - g.poly([cx + a.x, cy + a.y - top, cx + b.x, cy + b.y - top, cx + b.x, cy + b.y, cx + a.x, cy + a.y]).fill(fill); - // верхняя грань - g.poly([cx + nw.x, cy + nw.y - top, cx + ne.x, cy + ne.y - top, cx + se.x, cy + se.y - top, cx + sw.x, cy + sw.y - top]).fill(0x6b5a44); - // левые и правые грани (к юго-западному и юго-восточному ребру) - face(sw, se, 0x4a3b2a); - face(se, ne, 0x352a1e); - return g; - } - - /** - * Изменить тайл: обновляет данные и перерисовывает одну ячейку - * (сбор предметов, посадка цветов, разрушаемые стены и т.п.). - */ - setTile(x: number, y: number, id: number): void { - if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return; - this.data.tiles[y * this.data.width + x] = id; - this.drawCell(x, y); - } - - /** - * Отдавать tall-спрайты в depth-слой актёров (герой за деревом/домом - * прячется): глубина tx+ty, как у героев. null — вернуть в objectsLayer - * (порядок отрисовки по строкам, как до вызова). Куллинг не ломается: - * видимость переключается по ссылке из cells независимо от родителя. - */ - setTallDepthLayer(layer: IsoDepthLayer | null): void { - this.tallDepthLayer = layer; - for (const [key, cell] of this.cells) { - const tx = key % this.data.width; - const ty = Math.floor(key / this.data.width); - this.placeTallView(cell, tx, ty); - } - } - - /** Положить tall-вьюху клетки в назначенный слой (или обратно в карту). */ - private placeTallView(cell: CellSprites, tx: number, ty: number): void { - const target: Container | null = this.tallDepthLayer ?? this.objectsLayer; - const view = cell.object ?? cell.objectPlaceholder; - if (target && view && view.parent !== target) { - if (this.tallDepthLayer) this.tallDepthLayer.add(view, tx, ty); - else target.addChild(view); - } - } - - /** - * Анимированный тайл: клетки с этим id переключают кадры по одному общему - * таймлайну (дёшево при сотнях клеток воды). Регистрирует и уже - * нарисованные клетки. frames — текстуры кадров (вода_1, вода_2, ...). - * Сочетается с вариантами: анимация регистрируется на фактический - * (вариантный) id клетки, поэтому для полного покрытия id с вариантами - * вызовите setTileAnimation и для каждого варианта. - */ - setTileAnimation(id: number, frames: Texture[], fps: number): void { - if (frames.length === 0 || fps <= 0) return; - this.anims.set(id, { frames, fps, t: 0, sprites: new Set() }); - for (const cell of this.cells.values()) { - if (cell.ground && cell.groundId === id) this.registerAnim(cell.ground, id); - if (cell.object && cell.objectId === id) this.registerAnim(cell.object, id); - } - } - - /** Тик анимаций тайлов; из update сцены (no-op, если анимаций нет). */ - update(dt: number): void { - for (const anim of this.anims.values()) { - anim.t += dt; - const tex = anim.frames[Math.floor(anim.t * anim.fps) % anim.frames.length]; - for (const s of anim.sprites) s.texture = tex; - } - } - - private registerAnim(sprite: Sprite, id: number): void { - this.anims.get(id)?.sprites.add(sprite); - this.spriteAnims.set(sprite, id); - } - - /** - * Рантайм-варианты текстур (движок не знает, что это — трава или пепел): - * клетки id рисуются детерминированным выбором из ids (включая сам id). - * Сочетается с анимацией: каждая клетка анимируется по своему фактическому - * варианту (см. setTileAnimation). - */ - setTileVariants(id: number, ids: number[]): void { - if (ids.length === 0) return; - this.variants.set(id, ids); - for (const [key, cell] of this.cells) { - if (cell.groundId === id || cell.objectId === id) { - this.drawCell(key % this.data.width, Math.floor(key / this.data.width)); - } - } - } - - private unregisterAnim(sprite: Sprite): void { - const id = this.spriteAnims.get(sprite); - if (id === undefined) return; - this.anims.get(id)?.sprites.delete(sprite); - this.spriteAnims.delete(sprite); - } - - /** Нарисовать (или перерисовать после setTile) одну ячейку карты. */ - private drawCell(tx: number, ty: number): void { - const iso = this.iso; - const id = this.data.tiles[ty * this.data.width + tx]; - const key = ty * this.data.width + tx; - const old = this.cells.get(key); - if (old?.ground) { this.unregisterAnim(old.ground); old.ground.destroy(); } - if (old?.object) { this.unregisterAnim(old.object); old.object.destroy(); } - old?.groundPlaceholder?.destroy(); - old?.objectPlaceholder?.destroy(); - const fresh: CellSprites = {}; - this.cells.set(key, fresh); - - const tall = this.tallSpecs.get(id); - const p = isoToScreen(tx, ty, iso); - - // Земля: под высоким объектом рисуем его ground-тайл (или плейсхолдер). - const groundBase = tall?.ground ?? id; - const groundId = this.variantOf(groundBase, tx, ty); - fresh.groundId = groundBase; // для матчинга анимаций — базовый id - const groundTex = this.textures.get(groundId); - if (groundTex) { - const s = new Sprite(groundTex); - s.anchor.set(0.5, 0); - s.position.set(p.x, p.y); - this.groundLayer.addChild(s); - fresh.ground = s; - this.registerAnim(s, groundBase); - } 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.grid.isBlockedId(id) ? 0x4a3b2a : 0x2d5a27); - this.groundLayer.addChild(g); - fresh.groundPlaceholder = g; - } - - // Высокий объект: спрайт с якорем в центре ромба, иначе колонна. - // objectId хранит фактический (вариантный) id — анимация матчится по нему. - if (tall) { - const objId = this.variantOf(id, tx, ty); - fresh.objectId = objId; - const objTex = this.textures.get(objId); - if (objTex) { - const s = new Sprite(objTex); - s.anchor.set(0.5, 1); - s.position.set(p.x, p.y + iso.tileH / 2); - fresh.object = s; - this.registerAnim(s, objId); - } else { - fresh.objectPlaceholder = this.drawPlaceholder(p.x, p.y + iso.tileH / 2, 1, 1, tall.height); - } - // Слой: depth-слой актёров (если назначен) или objectsLayer карты. - this.placeTallView(fresh, tx, ty); - } - } - - /** - * Вариант id для клетки: детерминированный выбор по хэшу (без анимации). - * Публичный — игре полезно знать, какой id фактически нарисован. - */ - variantOf(id: number, tx: number, ty: number): number { - const ids = this.variants.get(id); - return ids ? ids[hashTile(tx, ty, id) % ids.length]! : id; - } - - /** - * Показать только тайлы, чьи спрайты пересекают прямоугольник в px слоя - * view (мировые px: экранный канвас минус position worldRoot); null — - * вернуть всё видимым. Спрайты переключаются только при смене тайлового - * окна; запас по краям — на tall-объекты (растут вверх от якоря). - */ - cullToRect(rect: { x: number; y: number; width: number; height: number } | null): void { - if (!rect) { - if (this.cullRange !== null) this.applyCull(null); - return; - } - const { x, y } = screenToIso(rect.x, rect.y, this.iso); - const { x: x1, y: y1 } = screenToIso(rect.x + rect.width, rect.y, this.iso); - const { x: x2, y: y2 } = screenToIso(rect.x, rect.y + rect.height, this.iso); - const { x: x3, y: y3 } = screenToIso(rect.x + rect.width, rect.y + rect.height, this.iso); - const margin = this.tallMarginRows(); - const range = { - x0: Math.min(x, x1, x2, x3) - 1, // спрайты шире точки якоря - x1: Math.max(x, x1, x2, x3) + 1, - y0: Math.min(y, y1, y2, y3) - margin, // tall растёт вверх по строкам - y1: Math.max(y, y1, y2, y3) + 1 - }; - const last = this.cullRange; - if (last && last.x0 === range.x0 && last.x1 === range.x1 && last.y0 === range.y0 && last.y1 === range.y1) { - return; // окно в тайлах не сдвинулось — спрайты не трогаем - } - this.applyCull(range); - } - - /** Видим ли тайл при текущем окне куллинга (для тестов и отладки). */ - isTileVisible(tx: number, ty: number): boolean { - const r = this.cullRange; - if (!r) return true; - return tx >= r.x0 && tx <= r.x1 && ty >= r.y0 && ty <= r.y1; - } - - /** Запас строк сверху: высота самого высокого tall-спрайта, переведённая в тайлы. */ - private tallMarginRows(): number { - let maxPx = 0; - for (const spec of this.tallSpecs.values()) { - maxPx = Math.max(maxPx, spec.height * this.iso.tileW); - } - return Math.ceil(maxPx / this.iso.tileH) + 1; - } - - /** Переключить видимость всех спрайтов ячеек/декора под окном. */ - private applyCull(range: { x0: number; y0: number; x1: number; y1: number } | null): void { - this.cullRange = range; - const w = this.data.width; - for (const [key, cell] of this.cells) { - const visible = range === null || this.tileInRange(key % w, Math.floor(key / w), range); - if (cell.ground) cell.ground.visible = visible; - if (cell.object) cell.object.visible = visible; - if (cell.groundPlaceholder) cell.groundPlaceholder.visible = visible; - if (cell.objectPlaceholder) cell.objectPlaceholder.visible = visible; - } - for (const [key, s] of this.decorSprites) { - s.visible = range === null || this.tileInRange(key % w, Math.floor(key / w), range); - } - } - - private tileInRange(tx: number, ty: number, r: { x0: number; y0: number; x1: number; y1: number }): boolean { - return tx >= r.x0 && tx <= r.x1 && ty >= r.y0 && ty <= r.y1; - } - - isWalkable(x: number, y: number): boolean { - return this.grid.isWalkable(x, y); - } - - /** Тайл накрыт footprint'ом пропа (для LOS/снарядов: дом — препятствие). */ - isPropBlocked(x: number, y: number): boolean { - return this.grid.isPropBlocked(x, y); - } - - /** Границы карты в мировых юнитах (для ограничения камеры). */ - get worldBounds(): { x: number; y: number; width: number; height: number } { - return { x: 0, y: 0, width: this.data.width, height: this.data.height }; - } -} \ No newline at end of file diff --git a/packages/engine/src/map/__tests__/collision.test.ts b/packages/engine/src/map/__tests__/collision.test.ts deleted file mode 100644 index 7b245cb..0000000 --- a/packages/engine/src/map/__tests__/collision.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { circleFits, gridOf, moveCircle, separateCircles, shadowTilesOf } from '../collision'; -import type { Grid } from '../pathfinding'; -import type { TileMapData } from '../IsometricTileMap'; - -/** Карта 8×8: трава (0) везде, стена по x=4 (id 1), дом-проп 2×1 в (6,1). */ -function testMap(): TileMapData { - const w = 8; - const h = 8; - const tiles = new Array(w * h).fill(0); - for (let y = 0; y < h; y++) tiles[y * w + 4] = 1; // вертикальная стена - return { - width: w, - height: h, - tiles, - blocked: [1], - props: [{ id: 2, x: 6, y: 1, w: 2, h: 1 }] - }; -} - -/** Grid из набора строк-символов: '.' — проходимо, '#' — стена. */ -function gridFrom(rows: string[]): Grid { - return { - width: rows[0]!.length, - height: rows.length, - isWalkable: (x, y) => rows[y]?.[x] === '.' - }; -} - -describe('gridOf', () => { - it('blocked-иды и footprint пропов блокируют тайлы', () => { - const g = gridOf(testMap()); - expect(g.isWalkable(0, 0)).toBe(true); - expect(g.isWalkable(4, 3)).toBe(false); // стена - expect(g.isWalkable(6, 1)).toBe(false); // дом - expect(g.isWalkable(7, 1)).toBe(false); // дом (вторая клетка footprint) - expect(g.isWalkable(6, 2)).toBe(true); - expect(g.isWalkable(-1, 0)).toBe(false); - expect(g.isWalkable(8, 0)).toBe(false); - }); -}); - -describe('shadowTilesOf', () => { - /** Карта 6×6: дом (id 2) в (3,2) с тенью, ещё высокий объект (id 3) в (0,0)... вне поля. */ - function shadowMap(): TileMapData { - const w = 6; - const h = 6; - const tiles = new Array(w * h).fill(0); - tiles[3 * w + 3] = 2; // дом с tall.shadow - tiles[2 * w + 4] = 3; // высокий объект БЕЗ тени - return { - width: w, - height: h, - tiles, - blocked: [2], - tall: { 2: { height: 1.75, shadow: true }, 3: { height: 1 } } - }; - } - - it('тень ложится на тайл за объектом и не блокирует ничего больше', () => { - const shadow = shadowTilesOf(shadowMap()); - expect([...shadow]).toEqual([2 * 6 + 2]); // (2,2) — тайл «за» домом - const g = gridOf(shadowMap()); - expect(g.isWalkable(2, 2)).toBe(false); // тень непроходима - expect(g.isWalkable(3, 3)).toBe(false); // сам дом - expect(g.isWalkable(4, 2)).toBe(true); // высокий без тени не блокирует соседей - expect(g.isWalkable(4, 3)).toBe(true); - }); - - it('два высоких объекта подряд не создают тень друг на друге', () => { - const data = shadowMap(); - data.tiles[2 * 6 + 2] = 3; // высокий сосед прямо в «тени» дома - const shadow = shadowTilesOf(data); - expect([...shadow]).toEqual([]); // сосед высокий — блокируется и без тени - }); -}); - -describe('circleFits', () => { - it('тело меньше тайла проходит в свободном тайле', () => { - const g = gridOf(testMap()); - expect(circleFits(g, { x: 2.5, y: 2.5 }, 0.35)).toBe(true); - }); - - it('не заходит в стену с любой стороны', () => { - const g = gridOf(testMap()); - const r = 0.35; - expect(circleFits(g, { x: 3.5, y: 2.5 }, r)).toBe(true); - expect(circleFits(g, { x: 3.66, y: 2.5 }, r)).toBe(false); // x+r = 4.01 > 4 - expect(circleFits(g, { x: 4.34, y: 2.5 }, r)).toBe(false); // x-r = 3.99 < 5 - expect(circleFits(g, { x: 3.65, y: 2.5 }, r)).toBe(true); // касание вплотную - }); - - it('ловит угловой прокол диагонального тайла (выборка точек его пропускает)', () => { - const g = gridFrom([ - '....', - '...#', - '....', - '....' - ]); - // Угол blocked-тайла (3,1) в круге, а крайние точки — в свободных. - expect(circleFits(g, { x: 2.8, y: 1.8 }, 0.45)).toBe(false); - }); - - it('вне карты — стена', () => { - const g = gridOf(testMap()); - expect(circleFits(g, { x: 0.2, y: 0.5 }, 0.35)).toBe(false); - }); -}); - -describe('moveCircle — скольжение вдоль стены', () => { - it('прижим к стене не мешает движению вдоль неё', () => { - const g = gridOf(testMap()); - const pos = { x: 3.6, y: 1.0 }; - // Вдоль стены (вниз по y): x-компонента блокирована, y — проходит. - const moved = moveCircle(g, pos, { x: 0.2, y: 0.5 }, 0.35); - expect(moved).toBe(true); - expect(pos.x).toBeCloseTo(3.6); // x не сдвинулся - expect(pos.y).toBeCloseTo(1.5); - }); - - it('в угол — обе оси блокированы, позиция не меняется', () => { - const g = gridFrom([ - '.#', - '#.' - ]); - const pos = { x: 0.5, y: 0.5 }; - expect(moveCircle(g, pos, { x: 0.4, y: 0.4 }, 0.3)).toBe(false); - expect(pos).toEqual({ x: 0.5, y: 0.5 }); - }); -}); - -describe('moveCircle — совместимость с A*-маршрутом', () => { - it('путь по центрам тайлов проходим телом r ≤ 0.45 (диагональ у стены)', () => { - const g = gridOf(testMap()); - const path = [{ x: 2, y: 2 }, { x: 3, y: 3 }, { x: 3, y: 4 }]; - const pos = { x: 2.5, y: 2.5 }; - const near = (a: number, b: number) => Math.abs(a - b) < 0.01; - for (const tile of path) { - // Шаг к центру следующего тайла — так двигает waypoint-логика. - const target = { x: tile.x + 0.5, y: tile.y + 0.5 }; - for (let i = 0; i < 200 && !(near(pos.x, target.x) && near(pos.y, target.y)); i++) { - const dx = Math.sign(target.x - pos.x) * 0.1; - const dy = Math.sign(target.y - pos.y) * 0.1; - const before = { ...pos }; - moveCircle(g, pos, { x: dx, y: dy }, 0.35); - expect(pos).not.toEqual(before); // продвижение есть — путь не застрял - } - expect(near(pos.x, target.x) && near(pos.y, target.y)).toBe(true); // дошли - } - }); -}); - -describe('separateCircles', () => { - it('перекрытие — a выталкивается ровно на глубину, b не трогается', () => { - const a = { x: 1.0, y: 0.0 }; - const b = { x: 1.3, y: 0.0 }; - separateCircles(a, 0.35, b, 0.2); - // Перекрытие = (0.35+0.2) − 0.3 = 0.25. - expect(b).toEqual({ x: 1.3, y: 0.0 }); - expect(a.x).toBeCloseTo(0.75); - expect(a.y).toBeCloseTo(0.0); - }); - - it('без перекрытия — ничего не двигается', () => { - const a = { x: 0.0, y: 0.0 }; - const b = { x: 2.0, y: 0.0 }; - separateCircles(a, 0.35, b, 0.35); - expect(a).toEqual({ x: 0.0, y: 0.0 }); - }); - - it('слитые в точку — расходятся по +X (детерминированный фолбэк)', () => { - const a = { x: 5.0, y: 5.0 }; - separateCircles(a, 0.3, { x: 5.0, y: 5.0 }, 0.3); - expect(a.x).toBeCloseTo(5.6); - expect(a.y).toBeCloseTo(5.0); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/map/__tests__/isometricMap.test.ts b/packages/engine/src/map/__tests__/isometricMap.test.ts deleted file mode 100644 index 089d689..0000000 --- a/packages/engine/src/map/__tests__/isometricMap.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Container, Texture } from 'pixi.js'; -import { IsometricTileMap, type TileMapData } from '../IsometricTileMap'; -import { IsoDepthLayer } from '../../render/IsoDepthLayer'; -import { isoToScreen } from '../../math/iso'; - -function makeData(): TileMapData { - return { - width: 4, - height: 4, - tiles: new Array(16).fill(0), - blocked: [2], - tall: { 3: { height: 24, ground: 0 } } - }; -} - -describe('IsometricTileMap.setTile', () => { - it('обновляет данные и проходимость ячейки', () => { - const map = new IsometricTileMap(makeData(), new Map()); - expect(map.isWalkable(1, 1)).toBe(true); - - map.setTile(1, 1, 3); // высокий объект - expect(map.data.tiles[1 * 4 + 1]).toBe(3); - expect(map.isWalkable(1, 1)).toBe(true); // 3 не в blocked - - map.setTile(1, 1, 2); // блокирующий id - expect(map.data.tiles[1 * 4 + 1]).toBe(2); - expect(map.isWalkable(1, 1)).toBe(false); - }); - - it('игнорирует координаты за границей карты', () => { - const map = new IsometricTileMap(makeData(), new Map()); - expect(() => map.setTile(-1, 0, 2)).not.toThrow(); - expect(() => map.setTile(4, 0, 2)).not.toThrow(); - expect(() => map.setTile(0, 4, 2)).not.toThrow(); - // данные не изменились - expect(map.data.tiles.every((t) => t === 0)).toBe(true); - }); -}); - -describe('IsometricTileMap.setTallDepthLayer', () => { - it('tall-вьюхи живут в depth-слое с глубиной tx+ty — герой за деревом прячется', () => { - const map = new IsometricTileMap(makeData(), new Map()); - const layer = new IsoDepthLayer(); - const hero = new Container(); - layer.add(hero, 1, 4); // юг дерева: глубина 5 - map.setTallDepthLayer(layer); - - map.setTile(1, 3, 3); // tall-объект: вьюха сразу в слое, глубина 4 - const tree = layer.children.find((c) => c !== hero); - expect(tree?.parent).toBe(layer); - expect(tree?.zIndex).toBe(1 + 3); - - layer.sortableChildren = true; // север дерева (глубина 3) — дерево поверх героя - hero.zIndex = 1 + 2; - const orderBehind = layer.children.slice().sort((a, b) => a.zIndex - b.zIndex); - expect(orderBehind.indexOf(hero)).toBeLessThan(orderBehind.indexOf(tree!)); - - hero.zIndex = 1 + 4; // юг (глубина 5) — герой поверх дерева - const orderFront = layer.children.slice().sort((a, b) => a.zIndex - b.zIndex); - expect(orderFront.indexOf(tree!)).toBeLessThan(orderFront.indexOf(hero)); - }); - - it('setTile убирает вьюху из слоя, setTallDepthLayer(null) возвращает в карту', () => { - const map = new IsometricTileMap(makeData(), new Map()); - const layer = new IsoDepthLayer(); - map.setTallDepthLayer(layer); - map.setTile(2, 2, 3); - expect(layer.children.length).toBe(1); - - map.setTile(2, 2, 0); // tall больше нет — вьюха уничтожена - expect(layer.children.length).toBe(0); - - map.setTile(2, 2, 3); - map.setTallDepthLayer(null); // назад в objectsLayer карты - const objectsLayer = map.view.children.at(-1)!; - expect(objectsLayer.children.length).toBe(1); - expect(layer.children.length).toBe(0); - }); -}); - -describe('IsometricTileMap.variants', () => { - it('вариант выбирается по хэшу координат и стабилен при setTile', () => { - const map = new IsometricTileMap(makeDataWithVariants(), new Map()); - - const variant = map.variantOf(0, 3, 2); - expect([0, 1, 2]).toContain(variant); - // второй экземпляр той же карты — тот же выбор (детерминизм) - const again = new IsometricTileMap(makeDataWithVariants(), new Map()); - expect(again.variantOf(0, 3, 2)).toBe(variant); - - // setTile других id не меняет выбор для id с вариантами - map.setTile(1, 1, 2); - expect(map.variantOf(0, 3, 2)).toBe(variant); - // setTile на клетке с id из variants — вариант той же клетки стабилен - map.setTile(3, 2, 2); - map.setTile(3, 2, 0); - expect(map.variantOf(0, 3, 2)).toBe(variant); - }); - - it('setTileVariants задаёт таблицу рантаймом, пустой массив — no-op', () => { - const map = new IsometricTileMap(makeData(), new Map()); - expect(map.variantOf(0, 2, 2)).toBe(0); // без вариантов id как есть - map.setTileVariants(0, [0, 5]); - // выбор детерминирован и покидает только варианты - const v22 = map.variantOf(0, 2, 2); - const v32 = map.variantOf(0, 3, 2); - expect([0, 5]).toContain(v22); - expect([0, 5]).toContain(v32); - // выбор стабилен - expect(map.variantOf(0, 2, 2)).toBe(v22); - expect(map.variantOf(0, 3, 2)).toBe(v32); - map.setTileVariants(0, []); // пустой массив — no-op (таблица не сбрасывается) - expect(map.variantOf(0, 2, 2)).toBe(v22); - // id без вариантов рисуется как есть - expect(map.variantOf(2, 3, 2)).toBe(2); - }); - - it('анимация сочетается с вариантами: клетки анимируются по фактическому id', () => { - // id 3 — высокий объект (tall в makeData): с вариантами [3, 5]. - const data = makeData(); - data.tiles = new Array(16).fill(3); - const map = new IsometricTileMap(data, new Map([[3, Texture.WHITE]])); - map.setTileVariants(3, [3, 5]); - // Клетки получили варианты: у части клеток фактический id 5. - const resolved = new Set([map.variantOf(3, 0, 0), map.variantOf(3, 1, 0)]); - // Текстуры вариантных клеток отсутствуют — плейсхолдеры; важна семантика - // variantOf: анимация больше не снимает варианты. - expect(resolved.size).toBeGreaterThan(0); - map.setTileAnimation(3, [Texture.WHITE, Texture.EMPTY], 4); - expect(map.variantOf(3, 0, 0)).not.toBe(3); // вариант не снят - // Анимация фактического id (в т.ч. вариантного) регистрирует кадры. - map.setTileAnimation(5, [Texture.WHITE, Texture.EMPTY], 4); - map.update(0.5); - // Тик не бросает и не ломает клетки (дым-тест семантики). - }); -}); - -function makeDataWithVariants(): TileMapData { - return { ...makeData(), variants: { 0: [0, 1, 2] } }; -} - -describe('IsometricTileMap.decor', () => { - it('decorLayer между землёй и объектами, декор из данных рисуется', () => { - const data = makeData(); - data.decor = [{ id: 7, x: 1, y: 2, dx: 2, dy: -1 }]; - const map = new IsometricTileMap(data, new Map()); - const layers = map.view.children; - expect(layers.length).toBe(3); - // порядок детей = порядок отрисовки: ground -> decor -> objects - const s = map.addDecor(0, 0, Texture.WHITE)!; - expect(s.parent).toBe(layers[1]); - expect(s.anchor.x).toBe(0.5); - // смещение dx/dy — от центра ромба - const shifted = map.addDecor(2, 1, Texture.WHITE, { dx: 3, dy: -2 })!; - const c = isoToScreen(2, 1, map.iso); - expect(shifted.position.x).toBe(c.x + 3); - expect(shifted.position.y).toBe(c.y + map.iso.tileH / 2 - 2); - }); - - it('addDecor кладёт один спрайт на тайл, повторный — заменяет; clearDecor убирает', () => { - const map = new IsometricTileMap(makeData(), new Map()); - const first = map.addDecor(1, 1, Texture.WHITE)!; - const second = map.addDecor(1, 1, Texture.WHITE)!; // замена, а не второй спрайт - map.addDecor(2, 1, Texture.WHITE); - expect(first.parent).toBeNull(); // прежний спрайт убран - expect(second.parent).not.toBeNull(); - expect(map.data.decor).toBeUndefined(); - - map.clearDecor(1, 1); - map.clearDecor(1, 1); // повторный clear — no-op - map.clearDecor(9, 9); // чужой тайл — no-op - expect(second.parent).toBeNull(); - }); - - it('addDecor за границей карты игнорируется; setTile декор не трогает', () => { - const data = makeData(); - data.decor = [{ id: 7, x: 2, y: 2 }]; - const map = new IsometricTileMap(data, new Map()); - expect(map.addDecor(-1, 0, Texture.WHITE)).toBeUndefined(); - expect(map.addDecor(4, 0, Texture.WHITE)).toBeUndefined(); - - map.setTile(2, 2, 0); // перерисовка ячейки не должна задеть декор - map.setTile(2, 2, 2); - expect(() => map.clearDecor(2, 2)).not.toThrow(); - }); -}); - -describe('IsometricTileMap.cullToRect', () => { - it('прячет тайлы вне прямоугольника видимости, null возвращает всё', () => { - const data = makeData(); - delete data.tall; // без tall-объектов минимальный запас по краям - const map = new IsometricTileMap(data, new Map()); - expect(map.isTileVisible(0, 0)).toBe(true); // до куллинга видно всё - - // окно вокруг центра тайла (3,3) размером в полтайла - const c = isoToScreen(3, 3, map.iso); - map.cullToRect({ - x: c.x - map.iso.tileW / 4, - y: c.y - map.iso.tileH / 4, - width: map.iso.tileW / 2, - height: map.iso.tileH / 2 - }); - expect(map.isTileVisible(3, 3)).toBe(true); - expect(map.isTileVisible(2, 2)).toBe(true); // сосед — внутри запаса - expect(map.isTileVisible(0, 0)).toBe(false); // дальний угол спрятан - expect(map.isTileVisible(3, 0)).toBe(false); - - map.cullToRect(null); // сброс — снова видно всё - expect(map.isTileVisible(0, 0)).toBe(true); - }); -}); - -describe('IsometricTileMap.props', () => { - it('блокирует весь footprint и нормализует размеры', () => { - const data = makeData(); - data.props = [{ id: 7, x: 1, y: 1, w: 2, h: 2 }]; - const map = new IsometricTileMap(data, new Map()); - - expect(map.props).toEqual([{ id: 7, x: 1, y: 1, w: 2, h: 2 }]); - // весь footprint 2×2 непроходим, вокруг — проходим - expect(map.isWalkable(1, 1)).toBe(false); - expect(map.isWalkable(2, 2)).toBe(false); - expect(map.isWalkable(0, 1)).toBe(true); - expect(map.isWalkable(1, 3)).toBe(true); - // спрайт пропа отдан сцене для depth-слоя - expect(map.propViews).toHaveLength(1); - expect(map.propViews[0]!.prop).toBe(map.props[0]); - }); - - it('footprint по умолчанию 1×1', () => { - const data = makeData(); - data.props = [{ id: 7, x: 2, y: 0 }]; - const map = new IsometricTileMap(data, new Map()); - expect(map.props[0]!.w).toBe(1); - expect(map.props[0]!.h).toBe(1); - expect(map.isWalkable(2, 0)).toBe(false); - expect(map.isWalkable(3, 0)).toBe(true); - }); - - it('setTile внутри footprint не снимает блокировку', () => { - const data = makeData(); - data.props = [{ id: 7, x: 1, y: 1 }]; - const map = new IsometricTileMap(data, new Map()); - map.setTile(1, 1, 0); - expect(map.isWalkable(1, 1)).toBe(false); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/map/__tests__/mapFormat.test.ts b/packages/engine/src/map/__tests__/mapFormat.test.ts deleted file mode 100644 index c0ebe75..0000000 --- a/packages/engine/src/map/__tests__/mapFormat.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { encodeMap, parseMap, rleEncode, rleDecode } from '../mapFormat'; -import type { TileMapData } from '../IsometricTileMap'; - -const sample: TileMapData = { - width: 3, - height: 2, - tiles: [0, 0, 0, 1, 1, 2], - blocked: [2], - tall: { 3: { height: 1.5, ground: 0 } } -}; - -describe('RLE', () => { - it('кодирует серии одинаковых id', () => { - expect(rleEncode([0, 0, 0, 1, 1, 2])).toEqual([ - [0, 3], - [1, 2], - [2, 1] - ]); - }); - - it('декодирует обратно', () => { - expect(rleDecode([[0, 3], [1, 2], [2, 1]])).toEqual([0, 0, 0, 1, 1, 2]); - }); - - it('пустой массив', () => { - expect(rleEncode([])).toEqual([]); - expect(rleDecode([])).toEqual([]); - }); -}); - -describe('encodeMap/parseMap', () => { - it('roundtrip с RLE сохраняет карту', () => { - const file = encodeMap(sample); - expect(file.format).toBe('rpg-map'); - expect(file.encoding).toBe('rle'); - const parsed = parseMap(JSON.parse(JSON.stringify(file))); - expect(parsed).toEqual(sample); - }); - - it('roundtrip с raw', () => { - const file = encodeMap(sample, 'raw'); - expect(file.tiles).toEqual([0, 0, 0, 1, 1, 2]); - const parsed = parseMap(file); - expect(parsed.tiles).toEqual(sample.tiles); - }); - - it('RLE реально сжимает однородную карту', () => { - const big: TileMapData = { ...sample, width: 28, height: 28, tiles: new Array(784).fill(0) }; - const file = encodeMap(big); - expect(JSON.stringify(file.tiles).length).toBeLessThan(20); - }); - - it('roundtrip сохраняет пропы с footprint', () => { - const withProps: TileMapData = { - ...sample, - props: [{ id: 7, x: 0, y: 0, w: 2, h: 2, ground: 1, height: 3 }] - }; - const parsed = parseMap(JSON.parse(JSON.stringify(encodeMap(withProps)))); - expect(parsed.props).toEqual(withProps.props); - }); - - it('roundtrip сохраняет variants (строковые ключи файла -> числовые)', () => { - const withVariants: TileMapData = { - ...sample, - variants: { 0: [0, 5, 9], 12: [12] } - }; - const parsed = parseMap(JSON.parse(JSON.stringify(encodeMap(withVariants)))); - expect(parsed.variants).toEqual(withVariants.variants); - }); - - it('roundtrip сохраняет decor', () => { - const withDecor: TileMapData = { - ...sample, - decor: [{ id: 7, x: 1, y: 0, dx: 2 }, { id: 9, x: 2, y: 1 }] - }; - const parsed = parseMap(JSON.parse(JSON.stringify(encodeMap(withDecor)))); - expect(parsed.decor).toEqual(withDecor.decor); - // без декора поле опускается (старые карты) - expect(parseMap(encodeMap(sample)).decor).toBeUndefined(); - }); - - it('parseMap отвергает decor вне границ карты и не с целыми числами', () => { - // карта 3×2: тайл (3, 0) вне карты по X - expect(() => parseMap({ ...encodeMap(sample), decor: [{ id: 7, x: 3, y: 0 }] })).toThrow(/границ/); - expect(() => parseMap({ ...encodeMap(sample), decor: [{ id: 7.5, x: 0, y: 0 }] })).toThrow(/целыми/); - expect(() => parseMap({ ...encodeMap(sample), decor: [{ id: 7, x: 0, y: 0, dx: 'a' }] })).toThrow(/числами/); - expect(() => parseMap({ ...encodeMap(sample), decor: [] })).not.toThrow(); - }); - - it('parseMap отвергает пустой/нечисловой variants-массив', () => { - expect(() => parseMap({ ...encodeMap(sample), variants: { 0: [] } })).toThrow(/variants/); - expect(() => parseMap({ ...encodeMap(sample), variants: { 0: [1.5] } })).toThrow(/variants/); - expect(() => parseMap({ ...encodeMap(sample), variants: { 0: ['a'] } })).toThrow(/variants/); - // отсутствует/пустая таблица — ок (старые карты) - expect(parseMap(encodeMap(sample)).variants).toBeUndefined(); - expect(parseMap({ ...encodeMap(sample), variants: {} }).variants).toBeUndefined(); - }); - - it('parseMap отвергает проп за границей карты', () => { - // карта 3×2, footprint 2×2 от (2, 1) вылезает вправо и вниз - const file = encodeMap({ ...sample, props: [{ id: 7, x: 2, y: 1, w: 2, h: 2 }] }); - expect(() => parseMap(file)).toThrow(/границ/); - }); - - it('parseMap отвергает дробный/нулевой footprint', () => { - const bad = (props: unknown) => - parseMap({ ...encodeMap(sample), props }); - expect(() => bad([{ id: 7, x: 0, y: 0, w: 1.5, h: 1 }])).toThrow(/w\/h/); - expect(() => bad([{ id: 7, x: 0, y: 0, w: 0, h: 1 }])).toThrow(/w\/h/); - }); - - it('parseMap отвергает высоту пропа в пикселях', () => { - const file = encodeMap({ ...sample, props: [{ id: 7, x: 0, y: 0, height: 96 }] }); - expect(() => parseMap(file)).toThrow(/пикселях/); - }); - - it('parseMap отвергает не-объект', () => { - expect(() => parseMap('nope')).toThrow(); - expect(() => parseMap(null)).toThrow(); - }); - - it('parseMap отвергает высоту в пикселях (tall.height > 16)', () => { - expect(() => parseMap({ ...encodeMap(sample), tall: { 3: { height: 48, ground: 0 } } })).toThrow(/пикселях/); - }); - - it('parseMap отвергает чужой format/версию', () => { - expect(() => parseMap({ ...encodeMap(sample), format: 'other' })).toThrow(/format/); - expect(() => parseMap({ ...encodeMap(sample), version: 99 })).toThrow(/версия|версию|version/); - }); - - it('parseMap отвергает неверное число тайлов', () => { - const file = encodeMap(sample, 'raw'); - (file as { tiles: number[] }).tiles = [0, 1, 2]; - expect(() => parseMap(file)).toThrow(/784|тайлов|6/); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/map/__tests__/minimap.test.ts b/packages/engine/src/map/__tests__/minimap.test.ts deleted file mode 100644 index 5ef36f6..0000000 --- a/packages/engine/src/map/__tests__/minimap.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { TileMapData } from '../IsometricTileMap'; -import { renderMinimap } from '../minimap'; - -/** Карта 3×2: два разных тайла + заблокированная клетка. */ -function tinyMap(): TileMapData { - return { - width: 3, - height: 2, - tiles: [1, 2, 1, 2, 1, 2], - blocked: [0, 0, 1, 0, 0, 0], - }; -} - -const px = (img: { width: number; rgba: Uint8Array }, x: number, y: number): [number, number, number] => { - const i = (y * img.width + x) * 4; - return [img.rgba[i]!, img.rgba[i + 1]!, img.rgba[i + 2]!]; -}; - -describe('renderMinimap', () => { - it('размер картинки = карта × cell', () => { - const img = renderMinimap(tinyMap(), { colors: { 1: 0x101010, 2: 0x202020 }, cell: 3 }); - expect(img.width).toBe(9); - expect(img.height).toBe(6); - expect(img.rgba.length).toBe(9 * 6 * 4); - }); - - it('клетка целиком красится цветом своего тайла', () => { - const img = renderMinimap(tinyMap(), { colors: { 1: 0xff0000, 2: 0x00ff00 }, cell: 2 }); - // Тайл (0,0) = id 1, тайл (1,0) = id 2. - expect(px(img, 0, 0)).toEqual([255, 0, 0]); - expect(px(img, 1, 1)).toEqual([255, 0, 0]); - expect(px(img, 2, 0)).toEqual([0, 255, 0]); - expect(px(img, 5, 3)).toEqual([0, 255, 0]); - }); - - it('незаданный тайл — fallbackColor', () => { - const img = renderMinimap(tinyMap(), { colors: { 1: 0xff0000 }, fallbackColor: 0x0a0b0c, cell: 2 }); - expect(px(img, 2, 0)).toEqual([10, 11, 12]); - // И без явного fallback — тёмный по умолчанию. - const plain = renderMinimap(tinyMap(), { colors: { 1: 0xff0000 }, cell: 2 }); - expect(px(plain, 2, 0)[0]).toBeLessThan(64); - }); - - it('маркер перекрашивает свою клетку с тёмной рамкой', () => { - const img = renderMinimap(tinyMap(), { - colors: { 1: 0x101010, 2: 0x101010 }, - cell: 3, - markers: [{ x: 1, y: 1, color: 0xffff00 }], - }); - // Центр клетки — цвет маркера, угол — рамка. - expect(px(img, 4, 4)).toEqual([255, 255, 0]); - expect(px(img, 3, 3)[0]).toBeLessThan(64); - // Соседняя клетка не задета. - expect(px(img, 7, 4)).toEqual([16, 16, 16]); - }); - - it('маркер вне карты молча пропускается', () => { - const img = renderMinimap(tinyMap(), { - colors: { 1: 0x101010, 2: 0x101010 }, - cell: 2, - markers: [{ x: 9, y: 9, color: 0xffff00 }], - }); - expect(px(img, 0, 0)).toEqual([16, 16, 16]); - }); - - it('проп красит весь footprint своим цветом поверх тайлов', () => { - const map = { ...tinyMap(), props: [{ id: 7, x: 0, y: 0, w: 2, h: 2 }] }; - const img = renderMinimap(map, { colors: { 1: 0xff0000, 2: 0x00ff00, 7: 0x0000ff }, cell: 3 }); - // Клетки (0,0) и (1,1) — под пропом, тайл 1/2 перекрашен синим. - expect(px(img, 0, 0)).toEqual([0, 0, 255]); - expect(px(img, 4, 4)).toEqual([0, 0, 255]); - // Клетка вне footprint осталась цветом своего тайла (id 1 — красный). - expect(px(img, 7, 0)).toEqual([255, 0, 0]); - }); - - it('проп без записи в colors — fallbackColor', () => { - const map = { ...tinyMap(), props: [{ id: 7, x: 2, y: 1, w: 1, h: 1 }] }; - const img = renderMinimap(map, { colors: { 1: 0x101010, 2: 0x101010 }, fallbackColor: 0x3c3c48, cell: 3 }); - expect(px(img, 8, 4)).toEqual([60, 60, 72]); - }); - - it('footprint у края карты не выходит за границы', () => { - // Проп 3×3 от (1,0): правый столбец клеток вылезает за ширину 3. - const map = { ...tinyMap(), props: [{ id: 7, x: 1, y: 0, w: 3, h: 3 }] }; - const img = renderMinimap(map, { colors: { 1: 0x101010, 7: 0x0000ff }, cell: 3 }); - expect(() => px(img, 8, 5)).not.toThrow(); - // Клетка (2,0) под пропом покрашена, выход за карту не рисуется. - expect(px(img, 8, 0)).toEqual([0, 0, 255]); - }); -}); \ 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 deleted file mode 100644 index de1e75d..0000000 --- a/packages/engine/src/map/__tests__/pathfinding.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { findPath, findPathToNeighbor, 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 }]); - }); -}); -describe('findPathToNeighbor', () => { - it('цель проходима — всё равно останавливаемся рядом', () => { - const path = findPathToNeighbor(makeGrid(10, 10), { x: 0, y: 0 }, { x: 2, y: 0 }); - const end = path!.at(-1)!; - expect(Math.max(Math.abs(end.x - 2), Math.abs(end.y - 0))).toBe(1); - }); - - it('цель занята — путь к соседней клетке', () => { - const grid = makeGrid(10, 10, ['5,5']); - const path = findPathToNeighbor(grid, { x: 0, y: 5 }, { x: 5, y: 5 }); - expect(path).not.toBeNull(); - const end = path!.at(-1)!; - expect(Math.max(Math.abs(end.x - 5), Math.abs(end.y - 5))).toBe(1); - }); - - it('цель окружена — путь не найден', () => { - const grid = makeGrid(5, 5, [ - '1,1', '2,1', '3,1', - '1,2', '2,2', '3,2', - '1,3', '2,3', '3,3' - ]); - expect(findPathToNeighbor(grid, { x: 0, y: 4 }, { x: 2, y: 2 })).toBeNull(); - }); - - it('выбирает ближайшего из 8 соседей за один прогон', () => { - // Цель в углу сетки, старт по диагонали: из трёх соседей цели - // ближайший к старту — (4,4), путь к нему кратчайший возможный. - const grid = makeGrid(6, 6); - const path = findPathToNeighbor(grid, { x: 0, y: 0 }, { x: 5, y: 5 }); - expect(path).not.toBeNull(); - expect(path!.at(-1)).toEqual({ x: 4, y: 4 }); - expect(path!.length).toBe(8); // манхэттен (0,0)->(4,4), без крюка - }); - - it('старт уже сосед цели — пустой путь', () => { - const grid = makeGrid(10, 10); - expect(findPathToNeighbor(grid, { x: 3, y: 5 }, { x: 4, y: 5 })).toEqual([]); - }); -}); diff --git a/packages/engine/src/map/__tests__/tiled.test.ts b/packages/engine/src/map/__tests__/tiled.test.ts deleted file mode 100644 index 2cd153c..0000000 --- a/packages/engine/src/map/__tests__/tiled.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { fromTiledIso, type TiledMap } from '../tiled'; - -const tiled: TiledMap = { - width: 4, - height: 3, - tilewidth: 32, - tileheight: 16, - orientation: 'isometric', - tilesets: [{ firstgid: 1 }], - layers: [ - { type: 'tilelayer', name: 'ground', data: [1, 1, 2, 1, 1, 3, 1, 1, 0, 1, 1, 1] }, - { type: 'tilelayer', name: 'props', data: [0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0] }, - { type: 'objectgroup', name: 'spawns', objects: [] } - ] -} as unknown as TiledMap; - -describe('fromTiledIso', () => { - it('смещает gid на firstgid и склеивает слои', () => { - const map = fromTiledIso(tiled, { blocked: [2] }); - expect(map.width).toBe(4); - expect(map.height).toBe(3); - expect(map.tiles).toEqual([ - 0, 0, 1, 0, // ground: 1,1,2,1 - 0, 2, 0, 0, // ground: 1,3,1,1 - 2, 0, 0, 0 // props: верхний слой кладёт 2 на пустое место (index 8) - ]); - expect(map.blocked).toEqual([2]); - }); - - it('фильтрует слои по имени', () => { - const map = fromTiledIso(tiled, { layers: ['ground'] }); - expect(map.tiles[9]).toBe(0); // props-слой не применён - }); - - it('отвергает не-изометрическую ориентацию', () => { - expect(() => fromTiledIso({ ...tiled, orientation: 'orthogonal' })).toThrow(/isometric/); - }); - - it('отвергает слой неверного размера', () => { - const bad = { - ...tiled, - layers: [{ type: 'tilelayer', name: 'bad', data: [1, 2, 3] }] - }; - expect(() => fromTiledIso(bad)).toThrow(/размера/); - }); - - it('игнорирует невидимые слои', () => { - const hidden = { - ...tiled, - layers: [ - { type: 'tilelayer', name: 'ground', visible: false, data: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - { type: 'tilelayer', name: 'ok', data: [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] } - ] - }; - const map = fromTiledIso(hidden); - expect(map.tiles[0]).toBe(1); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/map/collision.ts b/packages/engine/src/map/collision.ts deleted file mode 100644 index cd0aa7a..0000000 --- a/packages/engine/src/map/collision.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { Grid } from './pathfinding'; -import type { TileMapData } from './IsometricTileMap'; -import type { Vec2 } from '../math/Vec2'; - -/** - * Круговые коллизии поверх тайловой сетки (чистая математика, без Pixi). - * Актор — круг радиуса r в мировых юнитах (тайл = 1×1); правило стиля: - * r < 0.5 — тело уже тайла, тогда путь A* по центрам тайлов остаётся - * проходимым и проверка сводится к соседним с центром тайлам. - * Проверка круга — точная (ближайшая точка квадрата тайла), не выборка - * точек: выборка пропускает угловой прокол Blocked-тайла по диагонали. - */ - -/** - * Тайлы «тени» высоких объектов: спрайт тайла (tx, ty) с tall.shadow - * экранно накрывает тайл (tx-1, ty-1) — герой не должен заходить в тело - * (дом и т.п.). Сосед в (tx-1, ty-1) сам высокий — он блокируется и без тени. - */ -export function shadowTilesOf(data: TileMapData): Set { - const out = new Set(); - for (let ty = 1; ty < data.height; ty++) { - for (let tx = 1; tx < data.width; tx++) { - const spec = data.tall?.[data.tiles[ty * data.width + tx]!]; - if (typeof spec !== 'object' || !spec.shadow) continue; - const behind = data.tiles[(ty - 1) * data.width + (tx - 1)]!; - if (data.tall?.[behind] !== undefined) continue; - out.add((ty - 1) * data.width + (tx - 1)); - } - } - return out; -} - -/** - * Grid над сырыми данными карты (без Pixi): blocked-иды + footprint пропов + - * тени высоких объектов. Единый источник проходимости: IsometricTileMap - * делегирует сюда, headless-потребители (валидатор, фауна, снапшот) берут - * через gridOf. data.tiles читается на каждый вызов — setTile виден сразу. - */ -export class StaticGrid implements Grid { - private blockedSet: Set; - private propBlocked = new Set(); - private shadowSet: Set; - - constructor(private data: TileMapData) { - this.blockedSet = new Set(data.blocked); - this.shadowSet = shadowTilesOf(data); - for (const p of data.props ?? []) { - const w = p.w ?? 1; - const h = p.h ?? 1; - for (let ty = p.y; ty < p.y + h; ty++) { - for (let tx = p.x; tx < p.x + w; tx++) this.propBlocked.add(ty * data.width + tx); - } - } - } - - get width(): number { - return this.data.width; - } - - get height(): number { - return this.data.height; - } - - isWalkable(x: number, y: number): boolean { - if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return false; - if (this.propBlocked.has(y * this.data.width + x)) return false; - if (this.shadowSet.has(y * this.data.width + x)) return false; - return !this.blockedSet.has(this.data.tiles[y * this.data.width + x]!); - } - - /** Тайл накрыт footprint'ом пропа. */ - isPropBlocked(x: number, y: number): boolean { - return this.propBlocked.has(y * this.data.width + x); - } - - /** id тайла — из набора блокирующих (для плейсхолдеров рендера). */ - isBlockedId(id: number): boolean { - return this.blockedSet.has(id); - } -} - -/** Grid-адаптер над сырыми данными карты. */ -export function gridOf(data: TileMapData): Grid { - return new StaticGrid(data); -} - -/** Тайл блокирует движение (вне карты — тоже стена). */ -function isBlocked(grid: Grid, tx: number, ty: number): boolean { - return tx < 0 || ty < 0 || tx >= grid.width || ty >= grid.height || !grid.isWalkable(tx, ty); -} - -/** Помещается ли круг в позицию (точное пересечение с blocked-тайлами bbox). */ -export function circleFits(grid: Grid, pos: Vec2, r: number): boolean { - if (r <= 0) { - return !isBlocked(grid, Math.floor(pos.x), Math.floor(pos.y)); - } - // r < 0.5 — bbox накрывает не более 2×2 тайлов. - const x0 = Math.floor(pos.x - r); - const x1 = Math.floor(pos.x + r); - const y0 = Math.floor(pos.y - r); - const y1 = Math.floor(pos.y + r); - for (let ty = y0; ty <= y1; ty++) { - for (let tx = x0; tx <= x1; tx++) { - if (!isBlocked(grid, tx, ty)) continue; - // Ближайшая к центру круга точка квадрата тайла. - const nx = Math.max(tx, Math.min(pos.x, tx + 1)); - const ny = Math.max(ty, Math.min(pos.y, ty + 1)); - const dx = pos.x - nx; - const dy = pos.y - ny; - if (dx * dx + dy * dy < r * r) return false; // касание стеной разрешено - } - } - return true; -} - -/** - * Сдвинуть круг на delta со скольжением вдоль стен: оси проверяются - * раздельно (X -> Y), прижатая к стене ось не блокирует движение по другой. - * Мутирует pos. Возвращает true, если хоть одна ось сместилась. - */ -export function moveCircle(grid: Grid, pos: Vec2, delta: Vec2, r: number): boolean { - let moved = false; - const nx = pos.x + delta.x; - if (circleFits(grid, { x: nx, y: pos.y }, r)) { - pos.x = nx; - moved = moved || delta.x !== 0; - } - const ny = pos.y + delta.y; - if (circleFits(grid, { x: pos.x, y: ny }, r)) { - pos.y = ny; - moved = moved || delta.y !== 0; - } - return moved; -} - -/** - * Растолкать круг a из круга b: a сдвигается на глубину перекрытия - * (двигаем только a — каждый актор выталкивает себя сам). Слившиеся - * в точку круги расходятся по +X (детерминированный фолбэк). - */ -export function separateCircles(a: Vec2, ra: number, b: Vec2, rb: number): void { - const dx = a.x - b.x; - const dy = a.y - b.y; - const d = Math.hypot(dx, dy); - const overlap = ra + rb - d; - if (overlap <= 0) return; - if (d < 1e-6) { - a.x += overlap; - return; - } - const k = overlap / d; - a.x += dx * k; - a.y += dy * k; -} \ No newline at end of file diff --git a/packages/engine/src/map/mapFormat.ts b/packages/engine/src/map/mapFormat.ts deleted file mode 100644 index fc71de2..0000000 --- a/packages/engine/src/map/mapFormat.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { TileMapData, TallSpec, PropData, DecorData } from './IsometricTileMap'; - -/** - * Файловый формат карт движка: JSON с RLE-сжатием тайлов. - * RLE — пары [id, длина серии]: карта лугов 28×28 сжимается в разы, - * и её можно править текстовым редактором. - */ - -export interface MapFileV1 { - format: 'rpg-map'; - version: 1; - width: number; - height: number; - /** id тайлов, которые считаются блокирующими. */ - blocked: number[]; - /** Высокие объекты: id -> высота или полная спецификация. */ - tall?: Record; - /** Крупные объекты с footprint'ом в несколько тайлов. */ - props?: PropData[]; - /** Варианты текстур: id -> список id-шников вариантов (ключи — строки JSON). */ - variants?: Record; - /** Мелкий плоский декор на тайлах. */ - decor?: DecorData[]; - /** 'rle' — tiles как пары [id, runLength]; 'raw' — плоский массив. */ - encoding: 'rle' | 'raw'; - tiles: number[] | [number, number][]; -} - -export type MapFile = MapFileV1; - -/** Упаковать тайлы в RLE: последовательности одинаковых id -> пары [id, длина]. */ -export function rleEncode(tiles: number[]): [number, number][] { - const out: [number, number][] = []; - let i = 0; - while (i < tiles.length) { - const id = tiles[i]!; - let run = 1; - while (i + run < tiles.length && tiles[i + run] === id) run++; - out.push([id, run]); - i += run; - } - return out; -} - -/** Распаковать RLE обратно в плоский массив. */ -export function rleDecode(pairs: [number, number][]): number[] { - const out: number[] = []; - for (const [id, run] of pairs) { - for (let k = 0; k < run; k++) out.push(id); - } - return out; -} - -/** Сериализовать карту в файловый формат (RLE по умолчанию). */ -export function encodeMap(data: TileMapData, encoding: 'rle' | 'raw' = 'rle'): MapFileV1 { - return { - format: 'rpg-map', - version: 1, - width: data.width, - height: data.height, - blocked: data.blocked, - tall: data.tall, - props: data.props, - variants: data.variants - ? Object.fromEntries(Object.entries(data.variants).map(([id, ids]) => [String(id), ids])) - : undefined, - decor: data.decor, - encoding, - tiles: encoding === 'rle' ? rleEncode(data.tiles) : data.tiles - }; -} - -/** Распарсить файл карты (с валидацией размеров). */ -export function parseMap(file: unknown): TileMapData { - if (typeof file !== 'object' || file === null) { - throw new Error('Карта: ожидался JSON-объект'); - } - const f = file as Partial; - if (f.format !== 'rpg-map') { - throw new Error('Карта: неверный format (ожидался rpg-map)'); - } - if (f.version !== 1) { - throw new Error(`Карта: неподдерживаемая версия ${f.version}`); - } - if (typeof f.width !== 'number' || typeof f.height !== 'number' || !Array.isArray(f.tiles)) { - throw new Error('Карта: missing width/height/tiles'); - } - let tiles: number[]; - if (f.encoding === 'rle') { - tiles = rleDecode(f.tiles as [number, number][]); - } else { - tiles = f.tiles as number[]; - if (tiles.some((t) => typeof t !== 'number')) { - throw new Error('Карта: raw-тайлы должны быть числами'); - } - } - const expected = f.width * f.height; - if (tiles.length !== expected) { - throw new Error(`Карта: тайлов ${tiles.length}, ожидалось ${expected} (${f.width}×${f.height})`); - } - // Высоты высоких объектов задаются в мировых юнитах (1 юнит = 1 тайл); - // значение больше 16 почти наверняка значит протухший файл с высотой в px. - for (const spec of Object.values(f.tall ?? {})) { - const h = typeof spec === 'number' ? spec : spec.height; - if (h > 16) { - throw new Error(`Карта: tall.height=${h} — похоже, высота задана в пикселях (ожидается мировые юниты)`); - } - } - validateProps(f.props ?? [], f.width, f.height); - const decor = validateDecor(f.decor, f.width, f.height); - const variants = validateVariants(f.variants); - return { - width: f.width, - height: f.height, - tiles, - blocked: f.blocked ?? [], - tall: f.tall, - props: f.props, - decor, - variants - }; -} - -/** Валидация декора: тайл в границах карты, смещения — числа (пустой список -> undefined). */ -function validateDecor(decor: DecorData[] | undefined, width: number, height: number): DecorData[] | undefined { - if (!decor || decor.length === 0) return undefined; - for (const d of decor) { - if (!Number.isInteger(d.id) || !Number.isInteger(d.x) || !Number.isInteger(d.y)) { - throw new Error('Карта: decor — id/x/y должны быть целыми числами'); - } - if (d.x < 0 || d.y < 0 || d.x >= width || d.y >= height) { - throw new Error(`Карта: decor (${d.x}, ${d.y}) вне границ карты ${width}×${height}`); - } - if ((d.dx !== undefined && typeof d.dx !== 'number') || (d.dy !== undefined && typeof d.dy !== 'number')) { - throw new Error(`Карта: decor (${d.x}, ${d.y}) — dx/dy должны быть числами`); - } - } - return decor; -} - -/** Валидация вариантов: непустые массивы целых id (пустой объект -> undefined). */ -function validateVariants(variants: Record | undefined): Record | undefined { - if (!variants || Object.keys(variants).length === 0) return undefined; - const out: Record = {}; - for (const [key, ids] of Object.entries(variants)) { - if (!Array.isArray(ids) || ids.length === 0 || ids.some((v) => !Number.isInteger(v))) { - throw new Error(`Карта: variants[${key}] — ожидался непустой массив целых id`); - } - out[Number(key)] = ids; - } - return out; -} - -/** Валидация пропов: целые w/h ≥ 1 и footprint целиком внутри карты. */ -function validateProps(props: PropData[], width: number, height: number): void { - for (const p of props) { - const w = p.w ?? 1; - const h = p.h ?? 1; - if (!Number.isInteger(w) || !Number.isInteger(h) || w < 1 || h < 1) { - throw new Error(`Карта: prop (${p.x}, ${p.y}) — w/h должны быть целыми ≥ 1, получено ${w}×${h}`); - } - if (p.x < 0 || p.y < 0 || p.x + w > width || p.y + h > height) { - throw new Error( - `Карта: prop (${p.x}, ${p.y}) ${w}×${h} выходит за границы карты ${width}×${height}` - ); - } - if (p.height !== undefined && p.height > 16) { - throw new Error(`Карта: prop.height=${p.height} — похоже, высота задана в пикселях (ожидается мировые юниты)`); - } - } -} \ No newline at end of file diff --git a/packages/engine/src/map/minimap.ts b/packages/engine/src/map/minimap.ts deleted file mode 100644 index 50eef23..0000000 --- a/packages/engine/src/map/minimap.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { TileMapData } from './IsometricTileMap'; - -/** - * Миникарта: чистый рендер TileMapData в RGBA-картинку (без Pixi — движок - * не знает про текстуры; в пиксельную текстуру картинку превращает игра). - * Схема «вид сверху»: клетка сетки = тайл, цвет — из палитры вызывающей - * стороны (Record). Незаданные тайлы — fallbackColor. - * Пропы рисуются поверх — весь footprint цветом `colors[prop.id]`. - */ - -export interface MinimapMarker { - /** Тайловые координаты точки интереса. */ - x: number; - y: number; - /** Цвет маркера (0xRRGGBB). */ - color: number; -} - -export interface MinimapOptions { - /** Цвет клетки по id тайла (0xRRGGBB). */ - colors: Record; - /** Цвет тайла без записи в colors (по умолчанию тёмный). */ - fallbackColor?: number; - /** Размер клетки в пикселях (2–3 читаются лучше всего). */ - cell?: number; - /** Точки интереса поверх карты (герой, переходы, NPC). */ - markers?: MinimapMarker[]; -} - -/** Результат рендера: RGBA-байты, 4 на пиксель. */ -export interface MinimapImage { - width: number; - height: number; - rgba: Uint8Array; -} - -/** 0xRRGGBB → [r, g, b]. */ -function rgbOf(color: number): [number, number, number] { - return [(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff]; -} - -/** - * Отрисовать карту в пиксельную картинку: сетка width×height клеток - * (cell px каждая), маркеры закрашивают свою клетку поверх. - */ -export function renderMinimap(data: TileMapData, opts: MinimapOptions): MinimapImage { - const cell = opts.cell ?? 3; - const width = data.width * cell; - const height = data.height * cell; - const rgba = new Uint8Array(width * height * 4); - const put = (x: number, y: number, [r, g, b]: [number, number, number]): void => { - const i = (y * width + x) * 4; - rgba[i] = r; - rgba[i + 1] = g; - rgba[i + 2] = b; - rgba[i + 3] = 255; - }; - for (let ty = 0; ty < data.height; ty++) { - for (let tx = 0; tx < data.width; tx++) { - const id = data.tiles[ty * data.width + tx]!; - const color = rgbOf(opts.colors[id] ?? opts.fallbackColor ?? 0x1a1a20); - for (let dy = 0; dy < cell; dy++) { - for (let dx = 0; dx < cell; dx++) put(tx * cell + dx, ty * cell + dy, color); - } - } - } - // Пропы: весь footprint цветом пропа — крупный объект читается схемой, - // а не только цветом земли под ним. - for (const p of data.props ?? []) { - const color = rgbOf(opts.colors[p.id] ?? opts.fallbackColor ?? 0x1a1a20); - const w = p.w ?? 1; - const h = p.h ?? 1; - for (let ty = p.y; ty < p.y + h && ty < data.height; ty++) { - for (let tx = p.x; tx < p.x + w && tx < data.width; tx++) { - for (let dy = 0; dy < cell; dy++) { - for (let dx = 0; dx < cell; dx++) put(tx * cell + dx, ty * cell + dy, color); - } - } - } - } - // Маркеры: закрашенная клетка с рамкой цвета фона — читается на любом поле. - for (const m of opts.markers ?? []) { - if (m.x < 0 || m.y < 0 || m.x >= data.width || m.y >= data.height) continue; - const [r, g, b] = rgbOf(m.color); - for (let dy = 0; dy < cell; dy++) { - for (let dx = 0; dx < cell; dx++) { - const border = dx === 0 || dy === 0 || dx === cell - 1 || dy === cell - 1; - put(m.x * cell + dx, m.y * cell + dy, border ? [20, 20, 24] : [r, g, b]); - } - } - } - return { width, height, rgba }; -} \ No newline at end of file diff --git a/packages/engine/src/map/pathfinding.ts b/packages/engine/src/map/pathfinding.ts deleted file mode 100644 index 0040506..0000000 --- a/packages/engine/src/map/pathfinding.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Поиск пути 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; - /** Порядок вставки: при равном f выбираем ранее вставленный (детерминизм обхода). */ - seq: number; -} - -interface Vec2 { - x: number; - y: number; -} - -/** Соседство 4/8 (диагонали без среза углов проверяются отдельно). */ -const DIRS4: readonly Vec2[] = [ - { x: 1, y: 0 }, - { x: -1, y: 0 }, - { x: 0, y: 1 }, - { x: 0, y: -1 } -]; -const DIRS8: readonly Vec2[] = [...DIRS4, { x: 1, y: 1 }, { x: 1, y: -1 }, { x: -1, y: 1 }, { x: -1, y: -1 }]; - -function manhattan(a: Vec2, b: Vec2): number { - return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); -} - -/** a важнее b в куче: меньшее f, при равном — более ранней вставки. */ -function before(a: Node, b: Node): boolean { - return a.f < b.f || (a.f === b.f && a.seq < b.seq); -} - -/** Мин-куча открытых узлов по f — вместо линейного скана (O(log n) на операцию). */ -class OpenHeap { - private nodes: Node[] = []; - - get size(): number { - return this.nodes.length; - } - - push(n: Node): void { - const a = this.nodes; - a.push(n); - let i = a.length - 1; - while (i > 0) { - const p = (i - 1) >> 1; - if (!before(a[i]!, a[p]!)) break; - a[i] = a[p]!; - a[p] = n; - i = p; - } - } - - pop(): Node | null { - const a = this.nodes; - const top = a[0] ?? null; - const last = a.pop()!; - if (a.length > 0) { - a[0] = last; - let i = 0; - for (;;) { - const l = 2 * i + 1; - const r = l + 1; - let m = i; - if (l < a.length && before(a[l]!, a[m]!)) m = l; - if (r < a.length && before(a[r]!, a[m]!)) m = r; - if (m === i) break; - const t = a[i]!; - a[i] = a[m]!; - a[m] = t; - i = m; - } - } - return top; - } -} - -/** - * Общий каркас A*: цель и эвристика — параметры (многоцелевой прогон - * findPathToNeighbor переиспользует тот же цикл). Возвращает конечный узел - * (путь — по цепочке parent) или null. - */ -function astar( - grid: Grid, - start: Vec2, - dirs: readonly Vec2[], - isGoal: (x: number, y: number) => boolean, - h: (x: number, y: number) => number -): Node | null { - const key = (x: number, y: number) => y * grid.width + x; - const open = new OpenHeap(); - const best = new Map(); - let seq = 0; - open.push({ x: start.x, y: start.y, g: 0, f: h(start.x, start.y), parent: null, seq: seq++ }); - best.set(key(start.x, start.y), 0); - - while (open.size > 0) { - const cur = open.pop()!; - if (isGoal(cur.x, cur.y)) return cur; - - for (const d of dirs) { - 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 + h(nx, ny), parent: cur, seq: seq++ }); - } - } - - return null; -} - -/** Путь из цепочки parent (без стартового тайла, включая конечный). */ -function tracePath(node: Node): { x: number; y: number }[] { - const path: { x: number; y: number }[] = []; - for (let n: Node | null = node; n !== null; n = n.parent) { - path.unshift({ x: n.x, y: n.y }); - } - path.shift(); // убираем стартовый тайл - return path; -} - -/** - * Возвращает путь (без стартового тайла, включая конечный) или null, если пути нет. - * Включение диагоналей добавляет шаги (±1, ±1), но только если оба ортогональных - * соседа проходимы (без среза углов). - */ -export function findPath( - grid: Grid, - start: Vec2, - goal: Vec2, - 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 node = astar( - grid, - start, - allowDiagonal ? DIRS8 : DIRS4, - (x, y) => x === goal.x && y === goal.y, - (x, y) => manhattan({ x, y }, goal) - ); - return node ? tracePath(node) : null; -} - -/** - * Путь к ближайшей проходимой клетке, соседней с goal (8-соседство). - * Идём к краю цели: goal может быть непроходим (тайл NPC/объекта) или занят — - * стоять на нём не нужно. Один прогон A* со всеми 8 соседями как целями - * (эвристика — минимум манхэттена по целям); пути нет — null. - */ -export function findPathToNeighbor( - grid: Grid, - start: Vec2, - goal: Vec2 -): { x: number; y: number }[] | null { - const goals: Vec2[] = []; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - if (dx === 0 && dy === 0) continue; - const tx = goal.x + dx; - const ty = goal.y + dy; - if (tx < 0 || ty < 0 || tx >= grid.width || ty >= grid.height) continue; - if (grid.isWalkable(tx, ty)) goals.push({ x: tx, y: ty }); - } - } - if (goals.length === 0) return null; - if (goals.some((g) => g.x === start.x && g.y === start.y)) return []; - - const goalKeys = new Set(goals.map((g) => g.y * grid.width + g.x)); - const node = astar( - grid, - start, - DIRS4, - (x, y) => goalKeys.has(y * grid.width + x), - (x, y) => { - let min = Infinity; - for (const g of goals) { - const d = Math.abs(x - g.x) + Math.abs(y - g.y); - if (d < min) min = d; - } - return min; - } - ); - return node ? tracePath(node) : null; -} \ No newline at end of file diff --git a/packages/engine/src/map/tiled.ts b/packages/engine/src/map/tiled.ts deleted file mode 100644 index f1f8231..0000000 --- a/packages/engine/src/map/tiled.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { TileMapData } from './IsometricTileMap'; - -/** - * Импортёр карт из Tiled (https://www.mapeditor.org). - * Поддерживается isometric-карта с tilelayer-слоями (CSV или сжатые — - * Tiled отдаёт их уже распакованными в JSON). GID 0 — пустой тайл; - * gid смещаются на firstgid первого тайлсета, чтобы id шли с 0. - */ - -/** Минимум того, что читаем из JSON Tiled. */ -export interface TiledMap { - width: number; - height: number; - tilewidth: number; - tileheight: number; - orientation: string; - layers: { - type: string; - name?: string; - data?: number[]; - visible?: boolean; - }[]; - tilesets?: { firstgid: number }[]; -} - -export interface FromTiledOptions { - /** Имена слоёв в порядке снизу вверх (по умолчанию — все tilelayer в порядке следования). */ - layers?: string[]; - /** id тайлов, которые считаются блокирующими (после смещения). */ - blocked?: number[]; - /** Высокие объекты. */ - tall?: TileMapData['tall']; -} - -/** - * Преобразовать isometric-карту Tiled в TileMapData движка. - * Верхние слои перезаписывают нули (пустые клетки) нижних. - */ -export function fromTiledIso(tiled: TiledMap, options: FromTiledOptions = {}): TileMapData { - if (tiled.orientation !== 'isometric') { - throw new Error(`Tiled: ожидается orientation=isometric, получен ${tiled.orientation}`); - } - const { width, height } = tiled; - - const firstGid = tiled.tilesets?.[0]?.firstgid ?? 1; - const layers = tiled.layers.filter( - (l) => - l.type === 'tilelayer' && - l.visible !== false && - (options.layers ? options.layers.includes(l.name ?? '') : true) - ); - if (layers.length === 0) { - throw new Error('Tiled: в карте нет tilelayer-слоёв'); - } - - const tiles = new Array(width * height).fill(0); - for (const layer of layers) { - if (!layer.data || layer.data.length !== width * height) { - throw new Error(`Tiled: слой ${layer.name ?? '?'} неверного размера`); - } - for (let i = 0; i < layer.data.length; i++) { - const gid = layer.data[i]!; - if (gid !== 0 && tiles[i] === 0) { - tiles[i] = gid - firstGid; - } - } - } - - return { - width, - height, - tiles, - blocked: options.blocked ?? [], - tall: options.tall - }; -} \ No newline at end of file diff --git a/packages/engine/src/math/Vec2.ts b/packages/engine/src/math/Vec2.ts deleted file mode 100644 index 45ed782..0000000 --- a/packages/engine/src/math/Vec2.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Простейшая 2D-векторная математика (виртуальные пиксели). - */ -export interface Vec2 { - x: number; - y: number; -} - -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 deleted file mode 100644 index 8a8ea4a..0000000 --- a/packages/engine/src/math/__tests__/iso.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isoToScreen, screenToIso } 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); - } - } - }); -}); \ No newline at end of file diff --git a/packages/engine/src/math/__tests__/rng.test.ts b/packages/engine/src/math/__tests__/rng.test.ts deleted file mode 100644 index 5c49d04..0000000 --- a/packages/engine/src/math/__tests__/rng.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createRng, hashTile } from '../rng'; - -describe('createRng (mulberry32)', () => { - it('одинаковый seed — одинаковая последовательность', () => { - const a = createRng(12345); - const b = createRng(12345); - for (let i = 0; i < 100; i++) { - expect(a.next()).toBe(b.next()); - } - }); - - it('разные seed — разные последовательности', () => { - const a = createRng(1); - const b = createRng(2); - const seqA = Array.from({ length: 10 }, () => a.next()); - const seqB = Array.from({ length: 10 }, () => b.next()); - expect(seqA).not.toEqual(seqB); - }); - - it('next в диапазоне 0..1', () => { - const rng = createRng(42); - for (let i = 0; i < 1000; i++) { - const v = rng.next(); - expect(v).toBeGreaterThanOrEqual(0); - expect(v).toBeLessThan(1); - } - }); - - it('int внутри [min, max] включительно', () => { - const rng = createRng(7); - for (let i = 0; i < 500; i++) { - const v = rng.int(2, 5); - expect(v).toBeGreaterThanOrEqual(2); - expect(v).toBeLessThanOrEqual(5); - expect(Number.isInteger(v)).toBe(true); - } - }); - - it('int достигает обоих концов диапазона', () => { - const rng = createRng(9); - const seen = new Set(); - for (let i = 0; i < 500; i++) seen.add(rng.int(1, 3)); - expect(seen.has(1)).toBe(true); - expect(seen.has(3)).toBe(true); - }); - - it('pick берёт элемент из массива', () => { - const rng = createRng(3); - const arr = ['a', 'b', 'c'] as const; - for (let i = 0; i < 50; i++) { - expect(arr).toContain(rng.pick(arr)); - } - }); - - it('pick из пустого массива бросает', () => { - const rng = createRng(3); - expect(() => rng.pick([])).toThrow(); - }); - - it('chance(0) всегда false, chance(1) всегда true', () => { - const rng = createRng(11); - for (let i = 0; i < 100; i++) { - expect(rng.chance(0)).toBe(false); - expect(rng.chance(1)).toBe(true); - } - }); - - it('state() отражает позицию в последовательности (для сейва в связке с seed)', () => { - const a = createRng(777); - a.next(); - const s1 = a.state(); - a.next(); - const s2 = a.state(); - expect(s2).not.toBe(s1); - - // тот же seed и те же два вызова дают то же состояние - const b = createRng(777); - b.next(); - b.next(); - expect(b.state()).toBe(s2); - }); -}); - -describe('hashTile', () => { - it('детерминирован и не зависит от порядка вызовов', () => { - expect(hashTile(3, 5, 7)).toBe(hashTile(3, 5, 7)); - // отсутствие состояния: значения не «плывут» между вызовами - const first = hashTile(0, 0, 1); - hashTile(10, 20, 2); - hashTile(30, 40, 3); - expect(hashTile(0, 0, 1)).toBe(first); - }); - - it('разные координаты/seed — разные хэши (в массе)', () => { - const seen = new Set(); - for (let x = 0; x < 20; x++) { - for (let y = 0; y < 20; y++) seen.add(hashTile(x, y, 42)); - } - // 400 тайлов — коллизий почти нет (32-битный хэш) - expect(seen.size).toBeGreaterThan(390); - }); - - it('возвращает неотрицательное целое', () => { - for (let i = -5; i <= 5; i++) { - const h = hashTile(i, i * 3, 9); - expect(Number.isInteger(h)).toBe(true); - expect(h).toBeGreaterThanOrEqual(0); - expect(h).toBeLessThan(2 ** 32); - } - }); -}); \ No newline at end of file diff --git a/packages/engine/src/math/__tests__/shapes.test.ts b/packages/engine/src/math/__tests__/shapes.test.ts deleted file mode 100644 index 1a5539b..0000000 --- a/packages/engine/src/math/__tests__/shapes.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { inCircle, inCircleW, inCone, inConeW, angleBetween, nearest, conePath } from '../shapes'; -import { worldToScreen } from '../iso'; - -describe('inCircle', () => { - it('точка внутри и на границе', () => { - const c = { x: 0, y: 0 }; - expect(inCircle(c, 10, { x: 3, y: 4 })).toBe(true); - expect(inCircle(c, 5, { x: 3, y: 4 })).toBe(true); // ровно 5 - expect(inCircle(c, 5, { x: 3, y: 5 })).toBe(false); - expect(inCircle(c, 0, { x: 0, y: 0 })).toBe(true); - }); - - it('точка вне центра', () => { - expect(inCircle({ x: 100, y: 50 }, 10, { x: 0, y: 0 })).toBe(false); - // расстояние sqrt(50² + 30²) ≈ 58.3 - expect(inCircle({ x: 100, y: 50 }, 60, { x: 50, y: 20 })).toBe(true); - }); -}); - -describe('inCone', () => { - const from = { x: 0, y: 0 }; - const dir = { x: 1, y: 0 }; // смотрит вправо - const halfAngle = Math.PI / 4; // 45° - - it('внутри конуса', () => { - expect(inCone(from, dir, 50, halfAngle, { x: 30, y: 10 })).toBe(true); - expect(inCone(from, dir, 50, halfAngle, { x: 30, y: -10 })).toBe(true); - }); - - it('за пределами дальности', () => { - expect(inCone(from, dir, 50, halfAngle, { x: 60, y: 0 })).toBe(false); - }); - - it('за границей угла («слепая зона» за спиной)', () => { - expect(inCone(from, dir, 50, halfAngle, { x: 30, y: 40 })).toBe(false); - expect(inCone(from, dir, 50, halfAngle, { x: -30, y: 0 })).toBe(false); - }); - - it('вблизи границы угла и радиуса', () => { - // чуть внутри границы угла 45° (tan 45°=1) - expect(inCone(from, dir, 50, halfAngle, { x: 10, y: 9.9 })).toBe(true); - // чуть снаружи - expect(inCone(from, dir, 50, halfAngle, { x: 10, y: 10.1 })).toBe(false); - // расстояние ровно 50 — граница радиуса включена - expect(inCone(from, dir, 50, halfAngle, { x: 50, y: 0 })).toBe(true); - expect(inCone(from, dir, 50, halfAngle, { x: 50.1, y: 0 })).toBe(false); - }); - - it('нулевое направление — только дальность', () => { - expect(inCone(from, { x: 0, y: 0 }, 50, halfAngle, { x: 0, y: -40 })).toBe(true); - expect(inCone(from, { x: 0, y: 0 }, 50, halfAngle, { x: 60, y: 0 })).toBe(false); - }); -}); - -describe('angleBetween', () => { - it('известные углы', () => { - expect(angleBetween({ x: 1, y: 0 }, { x: 0, y: 1 })).toBeCloseTo(Math.PI / 2); - expect(angleBetween({ x: 1, y: 0 }, { x: -1, y: 0 })).toBeCloseTo(Math.PI); - expect(angleBetween({ x: 2, y: 0 }, { x: 5, y: 0 })).toBeCloseTo(0); - }); - - it('нулевой вектор даёт 0 (без NaN)', () => { - expect(angleBetween({ x: 0, y: 0 }, { x: 1, y: 0 })).toBe(0); - expect(angleBetween({ x: 1, y: 0 }, { x: 0, y: 0 })).toBe(0); - }); -}); - -describe('nearest', () => { - it('выбирает ближайшую', () => { - const list = [ - { x: 50, y: 0 }, - { x: 10, y: 0 }, - { x: 30, y: 0 } - ]; - expect(nearest(list, { x: 0, y: 0 })).toEqual({ x: 10, y: 0 }); - }); - - it('учитывает maxRange', () => { - const list = [{ x: 100, y: 0 }]; - expect(nearest(list, { x: 0, y: 0 }, 50)).toBeNull(); - expect(nearest(list, { x: 0, y: 0 }, 100)).toEqual({ x: 100, y: 0 }); - }); - - it('пустой список — null', () => { - expect(nearest([], { x: 0, y: 0 })).toBeNull(); - }); -}); - -describe('W-варианты (мировые юниты)', () => { - const iso = { tileW: 32, tileH: 16, originX: 0, originY: 0 }; - - it('inCircleW эквивалентен проекции в экранные px', () => { - const center = { x: 3, y: 1 }; - for (let wx = 0; wx <= 6; wx += 0.5) { - for (let wy = 0; wy <= 2; wy += 0.5) { - const point = { x: wx, y: wy }; - expect(inCircleW(center, 1.5, point, iso)).toBe( - inCircle(worldToScreen(center.x, center.y, iso), 48, worldToScreen(point.x, point.y, iso)) - ); - } - } - }); - - it('inConeW эквивалентен проекции в экранные px', () => { - const from = { x: 3, y: 1 }; - const dir = { x: 1, y: -1 }; // «вправо» в мировых юнитах - for (let wx = 0; wx <= 6; wx += 0.5) { - for (let wy = 0; wy <= 2; wy += 0.5) { - const point = { x: wx, y: wy }; - expect(inConeW(from, dir, 2, Math.PI / 3, point, iso)).toBe( - inCone( - worldToScreen(from.x, from.y, iso), - worldToScreen(dir.x, dir.y, iso), - 64, - Math.PI / 3, - worldToScreen(point.x, point.y, iso) - ) - ); - } - } - }); -}); -describe('conePath', () => { - it('вершина в from, точки дуги на радиусе', () => { - const from = { x: 10, y: 20 }; - const pts = conePath(from, { x: 1, y: 0 }, 50, Math.PI / 4, 8); - expect(pts[0]).toEqual({ x: 10, y: 20 }); - expect(pts).toHaveLength(10); // вершина + segments + 1 - for (const p of pts.slice(1)) { - const d = Math.hypot(p.x - from.x, p.y - from.y); - expect(d).toBeCloseTo(50, 6); - } - }); - - it('дуга симметрична относительно dir, крайние точки на ±halfAngle', () => { - const from = { x: 0, y: 0 }; - const half = Math.PI / 3; - const pts = conePath(from, { x: 0, y: -1 }, 100, half, 12); - const first = pts[1]!; - const last = pts[pts.length - 1]!; - // зеркальность по экранному X (dir вверх — ось симметрии x = 0) - expect(first.x).toBeCloseTo(-last.x, 6); - expect(first.y).toBeCloseTo(last.y, 6); - // средняя точка дуги — ровно по dir - const mid = pts[1 + 6]!; - expect(mid.x).toBeCloseTo(0, 6); - expect(mid.y).toBeCloseTo(-100, 6); - }); - - it('нулевой halfAngle — вырожденный сектор (луч)', () => { - const pts = conePath({ x: 1, y: 1 }, { x: 1, y: 0 }, 40, 0, 5); - expect(pts).toHaveLength(7); - for (const p of pts.slice(1)) expect(p.y).toBeCloseTo(1, 6); - }); -}); diff --git a/packages/engine/src/math/__tests__/world.test.ts b/packages/engine/src/math/__tests__/world.test.ts deleted file mode 100644 index 8805efd..0000000 --- a/packages/engine/src/math/__tests__/world.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { Vec2 } from '../Vec2'; -import { - worldToScreen, - screenToWorld, - unitsToPx, - worldLen, - worldNorm, - worldDist, - moveTowardsW, - tileToWorld, - worldToTile, - worldRectToScreen, - DEFAULT_ISO -} from '../iso'; - -describe('проекция мир↔экран', () => { - it('совпадает с iso-формулой', () => { - // Центр тайла (2, 3) на экране: (2.5-3.5)*16, (2.5+3.5)*8 - const s = worldToScreen(2.5, 3.5); - expect(s.x).toBeCloseTo(-16, 10); - expect(s.y).toBeCloseTo(48, 10); - }); - - it('round-trip worldToScreen ∘ screenToWorld', () => { - for (let wx = -5; wx <= 5; wx += 0.7) { - for (let wy = -5; wy <= 5; wy += 0.7) { - const s = worldToScreen(wx, wy); - const back = screenToWorld(s.x, s.y); - expect(back.x).toBeCloseTo(wx, 10); - expect(back.y).toBeCloseTo(wy, 10); - } - } - }); - - it('учитывает origin', () => { - const iso = { ...DEFAULT_ISO, originX: 100, originY: 50 }; - const s = worldToScreen(1, 0, iso); - expect(s.x).toBeCloseTo(100 + 16, 10); - expect(s.y).toBeCloseTo(50 + 8, 10); - }); -}); - -describe('скаляры: юниты↔пиксели', () => { - it('линейка проекции — tileW px на юнит', () => { - expect(unitsToPx(1)).toBe(32); - expect(unitsToPx(1.5)).toBe(48); - }); -}); - -describe('метрика проекции', () => { - it('длины характерных смещений', () => { - // Экранные оси — мировые диагонали: «вправо» = (1,-1) -> 1 юнит. - expect(worldLen(1, -1)).toBeCloseTo(1, 10); - expect(worldLen(-1, 1)).toBeCloseTo(1, 10); - // Диагональ сетки (1,1) — вертикальный шаг: на экране 8+8=16 px -> 0.5 юнита. - expect(worldLen(1, 1)).toBeCloseTo(0.5, 10); - // По оси сетки: соседние тайлы — sqrt(16² + 8²)/32 ≈ 0.56 юнита. - expect(worldLen(1, 0)).toBeCloseTo(Math.hypot(16, 8) / 32, 10); - }); - - it('worldDist совпадает с worldLen', () => { - expect(worldDist({ x: 0, y: 0 }, { x: 3, y: -3 })).toBeCloseTo(3, 10); - expect(worldDist({ x: 1, y: 2 }, { x: 1, y: 2 })).toBeCloseTo(0, 10); - }); - - it('worldNorm даёт постоянную экранную скорость', () => { - // Нормализованный вектор в юнитах при скорости s юнит/с даёт ровно 32·s px/с. - const s = 1.75; - const dirs: Vec2[] = [ - { x: 1, y: 0 }, - { x: 0, y: 1 }, - { x: 1, y: 1 }, - { x: -1, y: 1 }, - { x: 2, y: -1 } - ]; - for (const d of dirs) { - const n = worldNorm(d.x, d.y); - const step = { x: n.x * s, y: n.y * s }; - const px = worldToScreen(step.x, step.y); - expect(Math.hypot(px.x, px.y)).toBeCloseTo(32 * s, 6); - } - }); - - it('worldNorm нулевого вектора', () => { - expect(worldNorm(0, 0)).toEqual({ x: 0, y: 0 }); - }); -}); - -describe('moveTowardsW', () => { - const a = { x: 0, y: 0 }; - const b = { x: 3, y: -3 }; // 3 юнита - - it('не проскакивает цель', () => { - expect(moveTowardsW(a, b, 10)).toEqual(b); - }); - - it('постоянная экранная скорость на любом направлении', () => { - const step = moveTowardsW(a, b, 1.75); - const px = worldToScreen(step.x, step.y); - expect(Math.hypot(px.x, px.y)).toBeCloseTo(32 * 1.75, 6); - }); - - it('последовательность шагов достигает цели', () => { - let p = a; - for (let i = 0; i < 100 && p !== b; i++) { - const next = moveTowardsW(p, b, 1.75); - if (next === p) break; - p = next; - } - expect(p).toEqual(b); - }); -}); - -describe('тайлы↔мир', () => { - it('tileToWorld — центр тайла', () => { - expect(tileToWorld(2, 3)).toEqual({ x: 2.5, y: 3.5 }); - }); - - it('worldToTile — floor внутри карты', () => { - expect(worldToTile(2.5, 3.5, 10, 10)).toEqual({ x: 2, y: 3 }); - expect(worldToTile(0, 0, 10, 10)).toEqual({ x: 0, y: 0 }); - }); - - it('worldToTile вне карты -> null', () => { - expect(worldToTile(-0.1, 5, 10, 10)).toBeNull(); - expect(worldToTile(5, 10, 10, 10)).toBeNull(); - }); -}); - -describe('worldRectToScreen', () => { - it('карта 28x28 — границы от западного угла', () => { - const r = worldRectToScreen(0, 0, 28, 28); - expect(r).toEqual({ x: -448, y: 0, width: 896, height: 448 }); - }); - - it('произвольный прямоугольник', () => { - // Углы (2,0) и (2,1): x от (2-1)*16 до 2*16. - const r = worldRectToScreen(2, 0, 1, 1); - expect(r.x).toBeCloseTo((2 - 1) * 16, 10); - expect(r.width).toBeCloseTo(2 * 16, 10); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/math/iso.ts b/packages/engine/src/math/iso.ts deleted file mode 100644 index 17e57e0..0000000 --- a/packages/engine/src/math/iso.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Изометрия 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 { - /** Ширина ромба тайла (пиксели, чётное) — проекция 1 мирового юнита. */ - 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) }; -} - -// ---------- мировые юниты ---------- -// -// Позиции хранятся в мировых юнитах (1 юнит = 1 тайл), float в мировой плоскости. -// Экран — виртуальные пиксели; перевод мир→экран — проекция, округление только -// на границе отрисовки (Camera.apply). Две «линейки»: -// • точка (позиция) — анизотропная проекция: tileW/2 и tileH/2 px на юнит; -// • скаляр (дистанция, радиус, скорость, высота) — линейка проекции: tileW px на юнит. - -import type { Vec2 } from './Vec2'; - -/** Мировая позиция → экранные пиксели (float, без округления). */ -export function worldToScreen(wx: number, wy: number, iso: IsoLayout = DEFAULT_ISO): Vec2 { - return { - x: (wx - wy) * (iso.tileW / 2) + iso.originX, - y: (wx + wy) * (iso.tileH / 2) + iso.originY - }; -} - -/** Экранные пиксели → мировая позиция (float). */ -export function screenToWorld(sx: number, sy: number, iso: IsoLayout = DEFAULT_ISO): Vec2 { - const halfW = iso.tileW / 2; - const halfH = iso.tileH / 2; - const dx = (sx - iso.originX) / halfW; - const dy = (sy - iso.originY) / halfH; - return { x: (dx + dy) / 2, y: (dy - dx) / 2 }; -} - -/** Скаляр (дистанция, скорость…) из юнитов в пиксели. */ -export function unitsToPx(units: number, iso: IsoLayout = DEFAULT_ISO): number { - return units * iso.tileW; -} - -/** Длина смещения в юнитах по «метрике проекции»: |M(d)| / tileW. */ -export function worldLen(dx: number, dy: number, iso: IsoLayout = DEFAULT_ISO): number { - const sx = (dx - dy) * (iso.tileW / 2); - const sy = (dx + dy) * (iso.tileH / 2); - return Math.hypot(sx, sy) / iso.tileW; -} - -/** Нормализованный мировой вектор (нулевой -> {0, 0}). */ -export function worldNorm(dx: number, dy: number, iso: IsoLayout = DEFAULT_ISO): Vec2 { - const l = worldLen(dx, dy, iso); - if (l < 1e-8) return { x: 0, y: 0 }; - return { x: dx / l, y: dy / l }; -} - -/** Расстояние между точками в юнитах по метрике проекции. */ -export function worldDist(a: Vec2, b: Vec2, iso: IsoLayout = DEFAULT_ISO): number { - return worldLen(b.x - a.x, b.y - a.y, iso); -} - -/** Шаг из a в сторону b на step юнитов (аналог moveTo в мировых юнитах). */ -export function moveTowardsW(a: Vec2, b: Vec2, step: number, iso: IsoLayout = DEFAULT_ISO): Vec2 { - const dx = b.x - a.x; - const dy = b.y - a.y; - const l = worldLen(dx, dy, iso); - if (l <= step || l < 1e-8) return { x: b.x, y: b.y }; - const k = step / l; - return { x: a.x + dx * k, y: a.y + dy * k }; -} - -/** Центр тайла в мировых юнитах. */ -export function tileToWorld(tx: number, ty: number): Vec2 { - return { x: tx + 0.5, y: ty + 0.5 }; -} - -/** Мировая позиция -> тайл (floor + границы карты); вне карты -> null. */ -export function worldToTile( - wx: number, - wy: number, - mapW: number, - mapH: number -): { x: number; y: number } | null { - const tx = Math.floor(wx); - const ty = Math.floor(wy); - if (tx < 0 || ty < 0 || tx >= mapW || ty >= mapH) return null; - return { x: tx, y: ty }; -} - -/** Мировой прямоугольник -> экранный bbox (для bounds камеры). */ -export function worldRectToScreen( - x: number, - y: number, - w: number, - h: number, - iso: IsoLayout = DEFAULT_ISO -): { x: number; y: number; width: number; height: number } { - const tl = worldToScreen(x, y, iso); - const hw = iso.tileW / 2; - const hh = iso.tileH / 2; - // Левый край bbox — у соседа-близнеца (x, y + h), правый — у (x + w, y). - return { - x: tl.x - h * hw, - y: tl.y, - width: (w + h) * hw, - height: (w + h) * hh - }; -} \ No newline at end of file diff --git a/packages/engine/src/math/rng.ts b/packages/engine/src/math/rng.ts deleted file mode 100644 index 41a008c..0000000 --- a/packages/engine/src/math/rng.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Детерминированный RNG (mulberry32): одинаковый seed — одинаковая последовательность. - * Используется для генерации мира, лута, частиц — везде, где нужен повторяемый «случай». - */ - -export interface Rng { - /** Следующее число 0..1. */ - next(): number; - /** Целое в диапазоне [min, max] включительно. */ - int(min: number, max: number): number; - /** Случайный элемент массива (массив должен быть непустым). */ - pick(arr: readonly T[]): T; - /** true с вероятностью p (0..1). */ - chance(p: number): boolean; - /** Текущее состояние — можно сохранить и восстановить для продолжения последовательности. */ - state(): number; -} - -/** Создать RNG из 32-битного seed. */ -export function createRng(seed: number): Rng { - let s = seed >>> 0; - const next = (): number => { - s = (s + 0x6d2b79f5) >>> 0; - let t = s; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; - return { - next, - int: (min, max) => min + Math.floor(next() * (max - min + 1)), - pick: (arr: readonly T[]) => { - if (arr.length === 0) throw new Error('pick из пустого массива'); - return arr[Math.floor(next() * arr.length)]; - }, - chance: (p) => next() < p, - state: () => s - }; -} - -/** - * Детерминированный хэш координат тайла без состояния: одинаковые (x, y, seed) - * — одинаковый результат навсегда. В отличие от createRng не потребляет - * последовательность и не зависит от порядка вызовов — годится для выбора - * варианта текстуры по тайлу (hashTile(x, y, id) % варианты.length). - */ -export function hashTile(x: number, y: number, seed = 0): number { - let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 2654435761)) >>> 0; - h = (h ^ (h >>> 13)) >>> 0; - h = Math.imul(h, 1274126177) >>> 0; - return (h ^ (h >>> 16)) >>> 0; -} \ No newline at end of file diff --git a/packages/engine/src/math/shapes.ts b/packages/engine/src/math/shapes.ts deleted file mode 100644 index 893bda0..0000000 --- a/packages/engine/src/math/shapes.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Простейшие проверки фигур и выбора целей — чистая математика без Pixi. - * Используется для боёв (конус удара, радиус взрыва), триггеров и подбора ближайшего. - */ - -import type { Vec2 } from './Vec2'; -import { DEFAULT_ISO, type IsoLayout, worldToScreen } from './iso'; - -/** Точка внутри круга (включая границу). */ -export function inCircle(center: Vec2, radius: number, point: Vec2): boolean { - const dx = point.x - center.x; - const dy = point.y - center.y; - return dx * dx + dy * dy <= radius * radius; -} - -/** Угол между векторами a и b (радианы, 0..PI). */ -export function angleBetween(a: Vec2, b: Vec2): number { - const la = Math.hypot(a.x, a.y); - const lb = Math.hypot(b.x, b.y); - if (la === 0 || lb === 0) return 0; - let cos = (a.x * b.x + a.y * b.y) / (la * lb); - cos = Math.min(1, Math.max(-1, cos)); - return Math.acos(cos); -} - -/** - * Точка внутри конуса: в пределах range от from и угол до dir не больше halfAngle. - * Нулевое направление — конус «схлопывается» в отрезок по range (угол не проверяется). - */ -export function inCone( - from: Vec2, - dir: Vec2, - range: number, - halfAngle: number, - point: Vec2 -): boolean { - const dx = point.x - from.x; - const dy = point.y - from.y; - if (dx * dx + dy * dy > range * range) return false; - if (dir.x === 0 && dir.y === 0) return true; - return angleBetween(dir, { x: dx, y: dy }) <= halfAngle; -} - -// ---------- мировые юниты: метризованные варианты ---------- - -/** Точка внутри круга; позиции и радиус — в мировых юнитах. */ -export function inCircleW( - center: Vec2, - radius: number, - point: Vec2, - iso: IsoLayout = DEFAULT_ISO -): boolean { - const c = worldToScreen(center.x, center.y, iso); - const p = worldToScreen(point.x, point.y, iso); - return inCircle(c, radius * iso.tileW, p); -} - -/** Точка внутри конуса; позиции, range и dir — в мировых юнитах (dir проецируется). */ -export function inConeW( - from: Vec2, - dir: Vec2, - range: number, - halfAngle: number, - point: Vec2, - iso: IsoLayout = DEFAULT_ISO -): boolean { - const f = worldToScreen(from.x, from.y, iso); - const p = worldToScreen(point.x, point.y, iso); - // Углы меняются под анизотропной проекцией — меряем их в экранном пространстве. - const d = worldToScreen(dir.x, dir.y, iso); - return inCone(f, d, range * iso.tileW, halfAngle, p); -} - -/** Ближайшая к from точка из списка (в пределах maxRange включительно), или null. */ -export function nearest(list: T[], from: Vec2, maxRange = Infinity): T | null { - let best: T | null = null; - let bestDist = maxRange * maxRange; - for (const p of list) { - const dx = p.x - from.x; - const dy = p.y - from.y; - const d = dx * dx + dy * dy; - if (d <= bestDist) { - bestDist = d; - best = p; - } - } - return best; -} - -/** - * Полигон сектора для отрисовки (Graphics): вершина в from, дуга по углам - * dir±halfAngle из segments сегментов. Углы — в том же пространстве, что inCone - * (экранном); радиус — px. Возвращает [from, ...дуга]; замыкание контура — - * на вызывающей стороне (closePath). Нулевое направление — дуга по base = 0. - */ -export function conePath( - from: Vec2, - dir: Vec2, - radius: number, - halfAngle: number, - segments = 10 -): Vec2[] { - const base = Math.atan2(dir.y, dir.x); - const out: Vec2[] = [{ x: from.x, y: from.y }]; - for (let i = 0; i <= segments; i++) { - const a = base - halfAngle + (2 * halfAngle * i) / segments; - out.push({ x: from.x + Math.cos(a) * radius, y: from.y + Math.sin(a) * radius }); - } - return out; -} \ No newline at end of file diff --git a/packages/engine/src/render/Camera.ts b/packages/engine/src/render/Camera.ts deleted file mode 100644 index f3a1eda..0000000 --- a/packages/engine/src/render/Camera.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { Container } from 'pixi.js'; -import { Shake } from './shake'; -import { sineInOut, type EaseFn } from '../core/easing'; -import { DEFAULT_ISO, type IsoLayout, screenToWorld, worldRectToScreen, worldToScreen } from '../math/iso'; -import type { Vec2 } from '../math/Vec2'; - -/** - * Камера виртуального разрешения: позиция — центр взгляда в мировых юнитах - * (1 юнит = 1 тайл). Округляет проекцию до целого пикселя (apply — единственная - * точка перевода мир→экран), чтобы пиксель-арт не «дрожал». - * Границы прокрутки — мировой прямоугольник в юнитах (у карты: {0, 0, w, h}); - * клэмп выполняется по его экранному bbox (проекция). - * Поддерживает тряску (addShake) — движок вызывает update(dt) каждый тик. - * Зум: apply ставит scale контейнера мира; игровые уровни — целые (1/2), - * анимация промежуточно квантуется до 0.25 (пиксель-арт читается и в движении). - */ -export interface CameraBounds { - x: number; - y: number; - width: number; - height: number; -} - -/** Минимальный зум (сильнее отдалять нельзя — пустые края). */ -const ZOOM_MIN = 0.5; - -/** Шаг квантования анимации зума (финал — ровно целевой z). */ -const ZOOM_STEP = 0.25; - -export class Camera { - /** Центр взгляда, мировые юниты. */ - x = 0; - y = 0; - /** Мировой прямоугольник прокрутки, юниты. */ - bounds: CameraBounds | null = null; - /** - * «Мёртвая зона» (виртуальные границы экрана), экранные пиксели: пока цель - * внутри окна, камера стоит; у края окна цель толкает камеру на величину выхода. - * null — камера всегда центрируется на цели. - */ - deadZonePx: { width: number; height: number } | null = null; - private readonly shake = new Shake(); - private zoom = 1; - private zoomAnim: { from: number; to: number; t: number; seconds: number; ease: EaseFn } | null = null; - - constructor( - private readonly viewWidth: number, - private readonly viewHeight: number, - private readonly iso: IsoLayout = DEFAULT_ISO - ) {} - - /** Текущий зум (1 — без масштабирования). */ - get zoomLevel(): number { - return this.zoom; - } - - /** Мгновенно поставить зум (и пережать границы). */ - setZoom(z: number): void { - this.zoom = Math.max(ZOOM_MIN, z); - this.zoomAnim = null; - this.clamp(); - } - - /** Плавный переход к зуму (сек); промежуточные значения квантуются до 0.25. */ - zoomTo(z: number, seconds: number, ease: EaseFn = sineInOut): void { - const target = Math.max(ZOOM_MIN, z); - if (seconds <= 0 || target === this.zoom) { - this.setZoom(target); - return; - } - this.zoomAnim = { from: this.zoom, to: target, t: 0, seconds, ease }; - } - - follow(targetX: number, targetY: number): void { - const dz = this.deadZonePx; - if (dz) { - // Окно живёт на экране: сравниваем экранный разнос проекций - // (умножен на зум), двигаем каждую ось на величину выхода за окно. - const z = this.zoom; - const c = worldToScreen(this.x, this.y, this.iso); - const t = worldToScreen(targetX, targetY, this.iso); - const dxs = (t.x - c.x) * z; - const dys = (t.y - c.y) * z; - const ex = dxs < -dz.width / 2 ? dxs + dz.width / 2 : dxs > dz.width / 2 ? dxs - dz.width / 2 : 0; - const ey = dys < -dz.height / 2 ? dys + dz.height / 2 : dys > dz.height / 2 ? dys - dz.height / 2 : 0; - if (ex !== 0 || ey !== 0) { - // Сдвиг в экране ex — это мировой сдвиг ex/z после масштаба. - const w = screenToWorld(ex / z, ey / z, this.iso); - this.x += w.x; - this.y += w.y; - } - } else { - this.x = targetX; - this.y = targetY; - } - this.clamp(); - } - - /** Мгновенно поставить камеру на точку (спавн, телепорт, смена локации). */ - snap(targetX: number, targetY: number): void { - const dz = this.deadZonePx; - this.deadZonePx = null; - this.follow(targetX, targetY); - this.deadZonePx = dz; - } - - /** Запустить толчок: амплитуда в экранных пикселях, длительность в секундах. */ - addShake(strength: number, duration: number): void { - this.shake.add(strength, duration); - } - - clearShake(): void { - this.shake.clear(); - } - - get shaking(): boolean { - return this.shake.active; - } - - /** Обновление тряски и анимации зума — движок вызывает каждый фиксированный шаг. */ - update(dt: number): void { - this.shake.update(dt); - const anim = this.zoomAnim; - if (!anim) return; - anim.t += dt; - const k = Math.min(1, anim.t / anim.seconds); - if (k >= 1) { - this.setZoom(anim.to); // финал — ровно целевой зум - return; - } - const raw = anim.from + (anim.to - anim.from) * anim.ease(k); - const quantized = Math.round(raw / ZOOM_STEP) * ZOOM_STEP; - if (quantized !== this.zoom) { - this.zoom = Math.max(ZOOM_MIN, quantized); - this.clamp(); - } - } - - /** Позиция и масштаб контейнера мира (единственная точка мир→экран). */ - apply(container: Container): void { - const o = this.shake.offset; - const c = worldToScreen(this.x, this.y, this.iso); - container.position.set(this.originX(c.x * this.zoom) + o.x, this.originY(c.y * this.zoom) + o.y); - if (container.scale.x !== this.zoom) container.scale.set(this.zoom); - } - - /** - * Экранная позиция мировой точки с учётом камеры, зума и тряски — то, куда - * точка реально спроецируется следующим кадром (без округления). Для слоёв - * вне worldRoot (свет, эффекты), которым нужна проекция без лага на кадр. - * Потомки worldRoot лежат в мировых px и масштабируются контейнером сами — - * формула совпадает и для них. - */ - toScreen(wx: number, wy: number): Vec2 { - const o = this.shake.offset; - const c = worldToScreen(this.x, this.y, this.iso); - const p = worldToScreen(wx, wy, this.iso); - const z = this.zoom; - return { x: this.originX(c.x * z) + o.x + p.x * z, y: this.originY(c.y * z) + o.y + p.y * z }; - } - - private originX(cProjX: number): number { - return Math.floor(this.viewWidth / 2) - Math.round(cProjX); - } - - private originY(cProjY: number): number { - return Math.floor(this.viewHeight / 2) - Math.round(cProjY); - } - - private clamp(): void { - if (!this.bounds) return; - const z = this.zoom; - const halfW = this.viewWidth / 2; - const halfH = this.viewHeight / 2; - // Мировой прямоугольник -> экранный bbox (×зум); клэмпим проекцию центра. - const b = worldRectToScreen( - this.bounds.x, - this.bounds.y, - this.bounds.width, - this.bounds.height, - this.iso - ); - const c = worldToScreen(this.x, this.y, this.iso); - const cx = c.x * z; - const cy = c.y * z; - const px = b.width * z > this.viewWidth - ? Math.min(Math.max(cx, b.x * z + halfW), (b.x + b.width) * z - halfW) - : (b.x + b.width / 2) * z; - const py = b.height * z > this.viewHeight - ? Math.min(Math.max(cy, b.y * z + halfH), (b.y + b.height) * z - halfH) - : (b.y + b.height / 2) * z; - const w = screenToWorld(px / z, py / z, this.iso); - this.x = w.x; - this.y = w.y; - } -} \ No newline at end of file diff --git a/packages/engine/src/render/IsoDepthLayer.ts b/packages/engine/src/render/IsoDepthLayer.ts deleted file mode 100644 index a82c6a6..0000000 --- a/packages/engine/src/render/IsoDepthLayer.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Container } from 'pixi.js'; - -/** - * Контейнер с сортировкой глубины для изометрической сцены. - * Глубина = tx + ty (классика изометрии 2:1): чем юго-восточнее объект, - * тем «ближе к камере» он рисуется. Герой, NPC, деревья и прочие сущности - * добавляются сюда, а не прямо в слой карты. - * - * Если объект движется, каждый кадр (или при смене тайла) вызывайте setDepth. - */ -export class IsoDepthLayer extends Container { - constructor() { - super(); - this.sortableChildren = true; - } - - /** Добавить объект и выставить глубину по тайловым координатам. */ - add(view: Container, tx: number, ty: number): Container { - this.setDepth(view, tx, ty); - this.addChild(view); - return view; - } - - /** Обновить глубину объекта по тайловым координатам (без смены — сортировку не будим). */ - setDepth(view: Container, tx: number, ty: number): void { - const z = tx + ty; - if (view.zIndex !== z) view.zIndex = z; - } - - /** Добавить крупный объект (проп) с footprint w×h от северо-западного угла. */ - addRect(view: Container, x: number, y: number, w: number, h: number): Container { - this.setDepthRect(view, x, y, w, h); - this.addChild(view); - return view; - } - - /** - * Глубина крупного объекта по footprint'у: диагональ юго-восточного - * (ближнего к камере) угла. Актёр перед южным краем дома рисуется поверх, - * за северным — под ним. - */ - setDepthRect(view: Container, x: number, y: number, w: number, h: number): void { - const z = x + w - 1 + (y + h - 1); - if (view.zIndex !== z) view.zIndex = z; - } - - /** - * Эффект на тайле (искры удара, кольца): как add, но с надбавкой bias — - * искры поверх актора на том же тайле. Движущийся эффект — setDepth - * в том же месте, где обновляется глубина вью. - */ - addFx(view: Container, tx: number, ty: number, bias = 0.5): Container { - view.zIndex = tx + ty + bias; - this.addChild(view); - return view; - } -} \ No newline at end of file diff --git a/packages/engine/src/render/Lighting.ts b/packages/engine/src/render/Lighting.ts deleted file mode 100644 index 4a43f5f..0000000 --- a/packages/engine/src/render/Lighting.ts +++ /dev/null @@ -1,608 +0,0 @@ -/** - * Слой освещения «тьма с прорезями»: карта силы тьмы в RenderTexture, - * источники прорезают её erase-спрайтами, тон (мягкая аддитивная подсветка - * вокруг прорези) рисуется поверх. Плюс импульсы (pulseLight/pulseAmbient), - * виньетка и тёмные пятна. Тонкий Pixi-адаптер над lightSim (чистая математика — там). - * - * Механика: RT хранит не цвет, а силу тьмы (белый fill → erase-спрайты); - * display-спрайт RT рисуется с blendMode 'multiply' и tint = ambient — - * смена ambient-цвета не требует перерисовки RT. Bake (отрисовка в RT) - * делается только при изменении квантованных подписей источников — - * мерцание живёт в квантовании альфы и тоне, обновляемом каждый тик. - * - * Живёт в renderer.lightRoot — экранное пространство между миром и UI. - * Порядок детей: карта тьмы → виньетка → тёмные пятна → тон → ambient-вспышка. - * Важно: bake-контейнер НЕ в дереве сцены — erase-спрайты вне RT стёрли бы мир. - */ - -import { Container, Graphics, RenderTexture, Sprite, Texture } from 'pixi.js'; -import { FadeState } from '../core/fade'; -import type { Renderer } from './Renderer'; -import { - GLOW_BASE_PX, - HOLE_PROFILE, - TONE_PROFILE, - lightFrame, - lerpAmbient, - pulseEnvelope, - pulseTotal, - quantizeHoleAlpha, - stepAlphas, - type LightDef, - type LightFrame, - type PulseSpec, - type RingStep -} from './lightSim'; - -export interface LightingOptions { - /** Виртуальные размеры сцены (480×270). */ - width: number; - height: number; - /** Живой рендерер — для генерации текстур и bake; без него Texture.WHITE. */ - renderer?: Renderer; - /** Размер пула спрайтов источников (по умолчанию 16). */ - maxLights?: number; - /** Размер пула спрайтов тёмных пятен (по умолчанию 16). */ - maxDarkSpots?: number; -} - -/** Сила аддитивного тона поверх прорези: тон намёк, яркость несёт сама прорезь. */ -const TONE_GAIN = 0.3; - -/** Масштаб тона относительно радиуса прорези: тон уже, чем дыра. */ -const TONE_SCALE = 0.8; - -/** - * Ступенчатая круговая текстура из колец (в порядке stepAlphas) — общая - * для прорези, тона и свечения. resolution 1 + nearest — пиксельные ступени. - */ -function makeSteppedTexture(renderer: Renderer, rings: RingStep[], dither: boolean): Texture { - const g = new Graphics(); - // Дизер-кольцо: редкие точки по внешнему радиусу — смягчают резкую ступень. - if (dither) { - const outer = rings[0].radius; - for (let i = 0; i < 32; i++) { - const a = ((i * 2) / 64) * Math.PI * 2; - g.rect(Math.cos(a) * outer - 0.5, Math.sin(a) * outer - 0.5, 1, 1).fill({ - color: 0xffffff, - alpha: rings[0].paintAlpha - }); - } - } - for (const ring of rings) { - g.circle(0, 0, ring.radius).fill({ color: 0xffffff, alpha: ring.paintAlpha }); - } - const tex = renderer.app.renderer.generateTexture({ - target: g, - resolution: 1, - antialias: false, - textureSourceOptions: { scaleMode: 'nearest' } - }); - g.destroy(); - return tex; -} - -/** - * Ступенчатая пиксельная текстура свечения: концентрические круги с дискретной - * альфой (никаких гладких градиентов) + дизер-кольцо. Используется тёмными - * пятнами. Кэшируется вызывающим (Lighting держит её на инстансе — модульный - * кэш переживал destroy рендерера). - */ -export function makeGlowTexture(renderer: Renderer, steps = 4): Texture { - const profile: number[] = []; - for (let i = 1; i <= steps; i++) profile.push(1 - 1 / (i + 1)); // накопление красок 1/(i+1) - return makeSteppedTexture(renderer, stepAlphas(profile), true); -} - -/** Текстура прорези (erase в RT): 7 ступеней из HOLE_PROFILE + дизер-кольцо. */ -export function makeHoleTexture(renderer: Renderer): Texture { - return makeSteppedTexture(renderer, stepAlphas(HOLE_PROFILE), true); -} - -/** Текстура тона (аддитивный свет вокруг прорези): приглушённое ядро. */ -export function makeToneTexture(renderer: Renderer): Texture { - return makeSteppedTexture(renderer, stepAlphas(TONE_PROFILE), false); -} - -/** - * Ступенчатая виньетка: белый центр → серые кольца к тёмным углам (multiply). - * Интенсивность задаётся alpha спрайта, текстура постоянна. Кэшируется - * вызывающим (см. makeGlowTexture). - */ -export function makeVignetteTexture(renderer: Renderer, width: number, height: number): Texture { - const g = new Graphics(); - const r = Math.hypot(width, height) / 2; // дистанция центра до угла - g.rect(0, 0, width, height).fill({ color: 0x6a6a74 }); - g.circle(width / 2, height / 2, r * 0.72).fill({ color: 0xa8a8b0 }); - g.circle(width / 2, height / 2, r * 0.42).fill({ color: 0xffffff }); - const tex = renderer.app.renderer.generateTexture({ - target: g, - resolution: 1, - antialias: false, - textureSourceOptions: { scaleMode: 'nearest' } - }); - g.destroy(); - return tex; -} - -/** Тёмное пятно: локальная аура опасности/тени (multiply). */ -export interface DarkSpotDef { - id: string; - /** Позиция центра в экранных px. */ - x: number; - y: number; - /** Радиус в px. */ - radius: number; - /** Сила затемнения 0..1. */ - alpha: number; -} - -/** Цвет тени тёмного пятна (умножается на сцену) — тёплая пепельная мгла. */ -const DARK_SPOT_TINT = 0x4a4440; - -interface LightEntry { - def: LightDef; - /** Спрайт тона (аддитивный, в toneLayer). */ - sprite: Sprite; - /** Erase-спрайт в bake — прорезает карту тьмы (есть только с RT). */ - hole: Sprite | null; -} - -/** Апсейт def по полям (id — ключ карты, не меняется): без пересоздания на тике. */ -function copyLightDef(into: LightDef, from: LightDef): void { - into.x = from.x; - into.y = from.y; - into.color = from.color; - into.intensity = from.intensity; - into.radius = from.radius; - into.flicker = from.flicker; - into.flickerPeriod = from.flickerPeriod; - into.seed = from.seed; - into.enabled = from.enabled; -} - -interface PulseEntry { - def: LightDef; - /** Базовая интенсивность до огибающей. */ - base: number; - spec: PulseSpec; - elapsed: number; -} - -interface AmbientPulse { - color: number; - peak: number; - spec: PulseSpec; - elapsed: number; -} - -interface DarkEntry { - def: DarkSpotDef; - sprite: Sprite; -} - -export class Lighting extends Container { - /** Display-спрайт карты тьмы: multiply, tint = ambient-цвет. */ - private readonly darkSprite: Sprite; - private readonly vignette: Sprite; - private readonly darkLayer: Container; - private readonly toneLayer: Container; - private readonly pulseAmb: Sprite; - private readonly toneFree: Sprite[] = []; - private readonly holeFree: Sprite[] = []; - private readonly darkFree: Sprite[] = []; - private readonly lights = new Map(); - private readonly darks = new Map(); - private readonly pulses = new Map(); - private readonly renderer: Renderer | null; - /** Карта силы тьмы (resolution 1); null — fallback без RT (Vitest, node). */ - private readonly rt: RenderTexture | null; - /** Bake-контейнер: fill + erase-спрайты. НЕ в дереве сцены! */ - private readonly bake: Container; - private readonly bakeFill: Sprite; - private readonly glow: Texture; - private readonly hole: Texture; - private readonly tone: Texture; - /** Сгенерированные (не WHITE) текстуры — уничтожаются в destroy(). */ - private readonly generated: Texture[] = []; - private readonly maxLights: number; - private readonly maxDarkSpots: number; - private pulseSeq = 0; - /** Подпись набора квантованных прорезей; null — bake ещё не делался. */ - private bakeSig: string | null = null; - /** Тьма была активна на прошлом тике (для форс-bake на включении). */ - private darkOn = false; - /** Переиспользуемый кадр applyFrame — без аллокации на источник/тик. */ - private readonly frame: LightFrame = { id: '', x: 0, y: 0, tint: 0xffffff, alpha: 0, scale: 1 }; - - private time = 0; - private ambientFade: FadeState; - private vignetteFade: FadeState; - private ambPulse: AmbientPulse | null = null; - - constructor(options: LightingOptions) { - super(); - this.maxLights = options.maxLights ?? 16; - this.maxDarkSpots = options.maxDarkSpots ?? 16; - this.renderer = options.renderer ?? null; - - // Карта тьмы: Texture.WHITE без рендерера (multiply белого = не трогает). - this.darkSprite = new Sprite(Texture.WHITE); - this.darkSprite.width = options.width; - this.darkSprite.height = options.height; - this.darkSprite.blendMode = 'multiply'; - this.darkSprite.tint = 0xffffff; - this.ambientFade = new FadeState(lerpAmbient, 0xffffff); - this.addChild(this.darkSprite); - - // Виньетка: Texture.WHITE без рендерера (multiply белого = не трогает). - const vignetteTex = this.renderer - ? makeVignetteTexture(this.renderer, options.width, options.height) - : Texture.WHITE; - if (this.renderer) this.generated.push(vignetteTex); - this.vignette = new Sprite(vignetteTex); - this.vignette.width = options.width; - this.vignette.height = options.height; - this.vignette.blendMode = 'multiply'; - this.vignette.alpha = 0; - this.vignetteFade = new FadeState((a, b, k) => a + (b - a) * k, 0); - this.addChild(this.vignette); - - // Тёмные пятна — под тоном: свет пробивает локальную тень. - this.darkLayer = new Container(); - this.addChild(this.darkLayer); - - // Тон источников — аддитивный слой над тьмой (не в RT: цвет каждый тик). - this.toneLayer = new Container(); - this.addChild(this.toneLayer); - - // Ambient-вспышка: Texture.WHITE, аддитивная, alpha по огибающей. - this.pulseAmb = new Sprite(Texture.WHITE); - this.pulseAmb.width = options.width; - this.pulseAmb.height = options.height; - this.pulseAmb.blendMode = 'add'; - this.pulseAmb.alpha = 0; - this.addChild(this.pulseAmb); - - // RT карты тьмы + bake-контейнер (вне сцены): fill → erase-прорези. - this.rt = this.renderer - ? RenderTexture.create({ - width: options.width, - height: options.height, - resolution: 1, - scaleMode: 'nearest', - antialias: false - }) - : null; - this.bake = new Container(); - this.bakeFill = new Sprite(Texture.WHITE); - this.bakeFill.width = options.width; - this.bakeFill.height = options.height; - this.bake.addChild(this.bakeFill); - // Display-спрайт показывает саму карту силы тьмы (без рендерера RT нет — - // остаётся Texture.WHITE: multiply белого сцену не трогает). - if (this.rt) this.darkSprite.texture = this.rt; - - this.glow = this.renderer ? makeGlowTexture(this.renderer) : Texture.WHITE; - this.hole = this.renderer ? makeHoleTexture(this.renderer) : Texture.WHITE; - this.tone = this.renderer ? makeToneTexture(this.renderer) : Texture.WHITE; - for (const tex of [this.glow, this.hole, this.tone]) { - if (tex !== Texture.WHITE) this.generated.push(tex); - } - for (let i = 0; i < this.maxLights; i++) this.toneFree.push(this.makeToneSprite()); - for (let i = 0; i < this.maxLights; i++) this.holeFree.push(this.makeHoleSprite()); - for (let i = 0; i < this.maxDarkSpots; i++) this.darkFree.push(this.makeDarkSprite()); - } - - private makeToneSprite(): Sprite { - const s = new Sprite(this.tone); - s.anchor.set(0.5); - s.blendMode = 'add'; - s.visible = false; - this.toneLayer.addChild(s); - return s; - } - - private makeHoleSprite(): Sprite { - const s = new Sprite(this.hole); - s.anchor.set(0.5); - s.blendMode = 'erase'; - s.visible = false; - this.bake.addChild(s); - return s; - } - - private makeDarkSprite(): Sprite { - const s = new Sprite(this.glow); - s.anchor.set(0.5); - s.blendMode = 'multiply'; - s.tint = DARK_SPOT_TINT; - s.visible = false; - this.darkLayer.addChild(s); - return s; - } - - // --- ambient (фундамент смены времени дня) --- - - /** Задать ambient-цвет; fadeSec > 0 — плавный лерп (день/ночь, закат). */ - setAmbient(color: number, fadeSec = 0): void { - this.ambientFade.to(color, Math.max(0, fadeSec)); - this.applyAmbient(); - } - - /** Целевой (заданный) ambient-цвет. */ - get ambientColor(): number { - return this.ambientFade.target; - } - - /** ambient на display-спрайт карты тьмы; белый — день, слой выключен. */ - private applyAmbient(): void { - this.darkSprite.tint = this.ambientFade.value; - this.darkSprite.visible = this.ambientFade.value !== 0xffffff; - } - - // --- виньетка (край экрана темнеет: низкий hp, опасная зона) --- - - /** Виньетка 0..1 (0 — не трогает); fadeSec > 0 — плавный лерп. */ - setVignette(intensity: number, fadeSec = 0): void { - this.vignetteFade.to(Math.max(0, Math.min(1, intensity)), Math.max(0, fadeSec)); - this.vignette.alpha = this.vignetteFade.value; - } - - /** Целевая интенсивность виньетки. */ - get vignetteLevel(): number { - return this.vignetteFade.target; - } - - // --- источники: runtime add/remove/move/color/enable --- - - /** Добавить источник или обновить его параметры целиком. */ - upsertLight(def: LightDef): void { - let entry = this.lights.get(def.id); - if (!entry) { - const sprite = this.toneFree.pop(); - if (!sprite) return; // пул исчерпан — лишние источники игнорируются - entry = { def: { ...def }, sprite, hole: this.takeHole() }; - this.lights.set(def.id, entry); - sprite.visible = true; - } - copyLightDef(entry.def, def); // апсейт по полям — без пересоздания на тике - } - - /** Erase-слот из пула (или null без RT — прорезать нечего). */ - private takeHole(): Sprite | null { - if (!this.rt) return null; - const hole = this.holeFree.pop(); - if (hole) hole.visible = true; - return hole ?? null; - } - - removeLight(id: string): void { - const entry = this.lights.get(id); - if (!entry) return; - entry.sprite.visible = false; - entry.sprite.alpha = 0; - this.toneFree.push(entry.sprite); - if (entry.hole) { - entry.hole.visible = false; - entry.hole.alpha = 0; - this.holeFree.push(entry.hole); - } - this.lights.delete(id); - } - - hasLight(id: string): boolean { - return this.lights.has(id); - } - - setLightPos(id: string, x: number, y: number): void { - const entry = this.lights.get(id); - if (entry) { - entry.def.x = x; - entry.def.y = y; - } - } - - setLightColor(id: string, color: number): void { - const entry = this.lights.get(id); - if (entry) entry.def.color = color; - } - - setLightEnabled(id: string, enabled: boolean): void { - const entry = this.lights.get(id); - if (entry) entry.def.enabled = enabled; - } - - // --- импульсы: короткие вспышки в пуле источников и на весь экран --- - - /** - * Импульс света в точке: занимает спрайт общего пула, снимается по концу - * огибающей. Возвращает id (или пустую строку, если пул исчерпан). - */ - pulseLight(args: { - x: number; - y: number; - color: number; - intensity?: number; - radius?: number; - spec: PulseSpec; - }): string { - const id = `pulse#${this.pulseSeq++}`; - const sprite = this.toneFree.pop(); - if (!sprite) return ''; - const base = args.intensity ?? 1; - const def: LightDef = { - id, - x: args.x, - y: args.y, - color: args.color, - intensity: base * pulseEnvelope(0, args.spec), - radius: args.radius, - flicker: 0, - seed: 0 - }; - this.lights.set(id, { def, sprite, hole: this.takeHole() }); - sprite.visible = true; - this.pulses.set(id, { def, base, spec: args.spec, elapsed: 0 }); - return id; - } - - /** Вспышка на весь экран (аддитивная); один слот — новая побеждает старую. */ - pulseAmbient(args: { color: number; peak: number; spec: PulseSpec }): void { - this.pulseAmb.tint = args.color; - this.ambPulse = { ...args, elapsed: 0 }; - this.pulseAmb.alpha = 0; - this.pulseAmb.visible = true; // оживёт в ближайшем update - } - - /** Текущая альфа ambient-вспышки (0..1) — для тестов и снапшота. */ - get pulseAmbientAlpha(): number { - return this.pulseAmb.alpha; - } - - // --- тёмные пятна: локальные ауры опасности (multiply) --- - - /** Добавить тёмное пятно или обновить его параметры. */ - upsertDarkSpot(def: DarkSpotDef): void { - let entry = this.darks.get(def.id); - if (!entry) { - const sprite = this.darkFree.pop(); - if (!sprite) return; // пул исчерпан — лишние пятна игнорируются - entry = { def: { ...def }, sprite }; - this.darks.set(def.id, entry); - sprite.visible = true; - } - const d = entry.def; - d.x = def.x; - d.y = def.y; - d.radius = def.radius; - d.alpha = def.alpha; - const s = entry.sprite; - s.position.set(def.x, def.y); - s.alpha = Math.max(0, Math.min(1, def.alpha)); - s.scale.set(def.radius / GLOW_BASE_PX); - } - - removeDarkSpot(id: string): void { - const entry = this.darks.get(id); - if (!entry) return; - entry.sprite.visible = false; - entry.sprite.alpha = 0; - this.darkFree.push(entry.sprite); - this.darks.delete(id); - } - - hasDarkSpot(id: string): boolean { - return this.darks.has(id); - } - - // --- тик --- - - /** Тик: время, лерп ambient/виньетки, огибающие импульсов, кадры источников. */ - update(dt: number): void { - this.time += dt; - this.ambientFade.update(dt); - this.applyAmbient(); - // Тьма включилась на этом тике — форс-bake, даже если подпись та же. - const on = this.ambientFade.value !== 0xffffff; - if (on && !this.darkOn) this.bakeSig = null; - this.darkOn = on; - this.vignetteFade.update(dt); - this.vignette.alpha = this.vignetteFade.value; - this.vignette.visible = this.vignetteFade.value > 0; - for (const [id, p] of this.pulses) { - p.elapsed += dt; - if (p.elapsed >= pulseTotal(p.spec)) { - this.removeLight(id); - this.pulses.delete(id); // удаление во время итерации Map — безопасно - continue; - } - p.def.intensity = p.base * pulseEnvelope(p.elapsed, p.spec); - } - this.applyLights(); - if (this.ambPulse) { - this.ambPulse.elapsed += dt; - if (this.ambPulse.elapsed >= pulseTotal(this.ambPulse.spec)) { - this.ambPulse = null; - this.pulseAmb.alpha = 0; - this.pulseAmb.visible = false; - } else { - const e = pulseEnvelope(this.ambPulse.elapsed, this.ambPulse.spec); - this.pulseAmb.alpha = Math.min(1, this.ambPulse.peak * e); - this.pulseAmb.visible = this.pulseAmb.alpha > 0; - } - } - } - - /** Кадры источников: тон каждый тик, прорези — по квантованной подписи. */ - private applyLights(): void { - let sig = ''; - let idx = 0; - for (const entry of this.lights.values()) { - const f = lightFrame(entry.def, this.time, this.frame); - // Тон: полный precision, обновляется каждый тик (живость мерцания). - const s = entry.sprite; - s.position.set(f.x, f.y); - s.tint = f.tint; - s.alpha = Math.min(1, f.alpha) * TONE_GAIN; - s.scale.set(f.scale * TONE_SCALE); - s.visible = s.alpha > 0.01; - // Прорезь: квантованные значения — сигнатура = содержимое RT. - if (entry.hole) { - const qx = Math.round(f.x); - const qy = Math.round(f.y); - const qs = Math.round(f.scale * 8) / 8; - const qa = quantizeHoleAlpha(f.alpha); - entry.hole.position.set(qx, qy); - entry.hole.scale.set(qs); - entry.hole.alpha = qa; - entry.hole.visible = qa > 0; - sig += `${idx}:${qx},${qy},${qs},${qa};`; - } - idx++; - } - if (sig !== this.bakeSig) { - this.bakeSig = sig; - this.bakeNow(); - } - } - - /** Перерисовать карту тьмы в RT; день (белый ambient) — не нужно. */ - private bakeNow(): void { - if (!this.rt || !this.renderer || !this.darkSprite.visible) return; - this.renderer.app.renderer.render({ container: this.bake, target: this.rt, clear: true }); - } - - /** Видимые кадры источников (пост-мерцание, включая импульсы) — для снапшота. */ - frames(): readonly LightFrame[] { - const out: LightFrame[] = []; - for (const { def } of this.lights.values()) out.push(lightFrame(def, this.time)); - return out; - } - - override destroy(options?: Parameters[0]): void { - this.pulses.clear(); - for (const id of [...this.lights.keys()]) this.removeLight(id); - for (const id of [...this.darks.keys()]) this.removeDarkSpot(id); - for (const s of this.toneFree) s.destroy(); - this.toneFree.length = 0; - for (const s of this.holeFree) s.destroy(); - this.holeFree.length = 0; - this.bakeFill.destroy(); - this.bake.destroy({ children: false }); - this.rt?.destroy(true); - this.darks.clear(); - // Тёмные спрайты — дети darkLayer: destroy явно и один раз, слой без children. - for (const s of this.darkFree) s.destroy(); - this.darkFree.length = 0; - this.darkLayer.destroy({ children: false }); - this.darkSprite.destroy(); - this.vignette.destroy(); - this.pulseAmb.destroy(); - for (const tex of this.generated) { - if (tex !== Texture.WHITE) tex.destroy(true); // стаб-рендерер отдаёт WHITE - } - this.generated.length = 0; - super.destroy(options); - } -} \ No newline at end of file diff --git a/packages/engine/src/render/Particles.ts b/packages/engine/src/render/Particles.ts deleted file mode 100644 index 53b4f47..0000000 --- a/packages/engine/src/render/Particles.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { Container, Sprite, Texture } from 'pixi.js'; -import { createRng, type Rng } from '../math/rng'; -import { stepParticle, sampleSpawn, sampleBurst, mergeEmitterOptions, type ParticleState, type ParticleVisual } from './particleSim'; - -/** - * Система частиц: квадраты (Texture.WHITE) или текстуры, цвета/кривые по - * жизни, пул спрайтов с лимитом. Используется для атмосферы (пепел, мотыльки) - * и эффектов (взрывы — burst/oneShot). Тикается через engine.fx либо вручную. - */ -export interface EmitterOptions { - /** Цвет частиц (0xRRGGBB); по умолчанию белый (для текстур — без подкраски). */ - color?: number; - /** Вариация: случайный цвет из списка при спавне (вместо color). */ - colors?: number[]; - /** Цвет по жизни: лерп 0xRRGGBB от начала к концу. */ - colorOverLife?: [number, number]; - /** Текстура частицы вместо подкрашенного квадрата. */ - texture?: Texture; - /** Частиц в секунду. */ - rate: number; - /** Время жизни частицы, секунды (min..max). */ - lifetime: [number, number]; - /** Начальная скорость, виртуальных пикселей/сек: (minX..maxX, minY..maxY). */ - velocity?: { x: [number, number]; y: [number, number] }; - /** Ускорение (гравитация/ветер), пикселей/сек². */ - acceleration?: { x?: number; y?: number }; - /** Размер квадрата в px (1..4) или диапазон размеров. */ - size?: number | [number, number]; - /** Множитель масштаба текстурных частиц. */ - scale?: number; - /** Начальный угол, рад (min..max). */ - rotation?: [number, number]; - /** Скорость вращения, рад/сек (min..max). */ - spin?: [number, number]; - /** Масштаб по жизни: диапазон или предустановка ('shrink'/'grow'). */ - scaleOverLife?: [number, number] | 'shrink' | 'grow'; - /** Сек нарастания альфы в начале жизни. */ - fadeIn?: number; - /** Затухание альфы: true (по умолчанию) — вся жизнь; число — хвост в сек. */ - fadeOut?: boolean | number; - /** Торможение, 1/сек (искры, пух). */ - drag?: number; - /** Амплитуда синус-дрейфа по X, px/сек (мотыльки). */ - wobble?: number; - /** Спавн в прямоугольнике вокруг позиции эмиттера (по умолчанию точка). */ - spawnArea?: { width: number; height: number }; - /** Seed для повторяемого движения. */ - seed?: number; - /** - * Радиальный разброс вместо velocity: скорость частиц (min..max) по кругу - * (для burst-взрывов). Если задан — velocity игнорируется. - */ - radialSpeed?: [number, number]; - /** Лимит живых частиц (пул переиспользуется), по умолчанию 256. */ - max?: number; - /** Режим смешивания: 'add' — светящиеся (огонь, искры, мотыльки). */ - blend?: 'normal' | 'add'; -} - -interface Particle { - state: ParticleState; - sprite: Sprite; -} - -export class ParticleEmitter extends Container { - private particles: Particle[] = []; - private readonly free: Sprite[] = []; - private spawnTimer = 0; - private rng: Rng; - private selfDestruct = false; - /** Переиспользуемый визуал шага: без аллокации на частицу/тик. */ - private readonly visual: ParticleVisual = { alpha: 0, scale: 1, rotation: 0, tint: 0xffffff }; - - constructor(private options: EmitterOptions) { - super(); - this.rng = createRng(options.seed ?? 0x9e3779b9); - } - - update(dt: number): void { - // Спавн по rate (для burst-эмиттеров rate = 0) - this.spawnTimer += dt * this.options.rate; - while (this.spawnTimer >= 1) { - this.spawnTimer -= 1; - this.spawn(); - } - - // Обновление через чистую симуляцию + компакт в один проход: - // умершие (age >= lifetime) — спрайт в пул, не destroy; без filter. - let w = 0; - for (let i = 0; i < this.particles.length; i++) { - const p = this.particles[i]; - if (p.state.age >= p.state.lifetime) { - p.sprite.visible = false; - p.sprite.alpha = 0; - this.free.push(p.sprite); - continue; - } - const v = stepParticle(p.state, dt, this.options, this.visual); - p.sprite.x = p.state.x; - p.sprite.y = p.state.y; - p.sprite.alpha = v.alpha; - p.sprite.tint = v.tint; - p.sprite.rotation = v.rotation; - if (this.options.texture) { - p.sprite.scale.set(v.scale); - } else { - p.sprite.width = v.scale; - p.sprite.height = v.scale; - } - this.particles[w++] = p; - } - this.particles.length = w; - // oneShot: все частицы умерли и новые не рождаются — уничтожиться - if (this.selfDestruct && this.particles.length === 0 && this.options.rate <= 0) { - this.onFinish?.(); - // children: pooled-спрайты — дети контейнера, с ними же и уничтожаются - this.destroy({ children: true }); - } - } - - /** Вызывается один раз при самоуничтожении oneShot (цепочки эффектов). */ - onFinish?: () => void; - - /** Убрать все частицы (например, при выходе со сцены). */ - clear(): void { - for (const p of this.particles) p.sprite.destroy(); - for (const s of this.free) s.destroy(); - this.particles = []; - this.free.length = 0; - } - - get count(): number { - return this.particles.length; - } - - /** Заменить опции эмиттера (цвет/скорость/кривые) на лету. */ - setOptions(opts: Partial): void { - this.options = { ...this.options, ...opts }; - } - - /** - * Мгновенно выпустить count частиц (взрыв/удар): игнорирует rate. - * opts — разовые переопределения; базовые опции эмиттера не мутируют. - */ - burst(count: number, opts?: Partial): void { - const o = mergeEmitterOptions(this.options, opts); - if (o.radialSpeed) { - for (const s of sampleBurst(count, o.radialSpeed, o.lifetime, this.rng, o)) { - this.addParticle(s, o); - } - } else { - for (let i = 0; i < count; i++) this.spawnFrom(o); - } - } - - /** - * Одноразовый эмиттер-взрыв: спавнит count частиц сразу и уничтожает себя, - * когда все умерли. Добавить в world и в engine.fx — тик и зачистка сами. - */ - static oneShot(count: number, opts: EmitterOptions): ParticleEmitter { - const e = new ParticleEmitter({ ...opts, rate: 0 }); - e.selfDestruct = true; - e.burst(count); - return e; - } - - private spawn(): void { - this.spawnFrom(this.options); - } - - private spawnFrom(options: EmitterOptions): void { - this.addParticle(sampleSpawn(options, this.rng), options); - } - - private addParticle(state: ParticleState, options: EmitterOptions): void { - const max = options.max ?? 256; - if (this.particles.length >= max) return; - const sprite = this.free.pop() ?? this.makeSprite(options); - const size = typeof options.size === 'number' ? options.size - : options.size ? options.size[0] + this.rng.next() * (options.size[1] - options.size[0]) - : 2; - // Базовый размер частицы — в state.scale (кривые по жизни умножают его). - state.scale = options.texture ? options.scale ?? 1 : size; - sprite.visible = true; - sprite.alpha = 0; // появится в следующем кадре без «вспышки» - sprite.tint = state.tint; - sprite.rotation = state.rot; - this.particles.push({ state, sprite }); - if (sprite.parent !== this) this.addChild(sprite); - } - - private makeSprite(options: EmitterOptions): Sprite { - const sprite = new Sprite(options.texture ?? Texture.WHITE); - if (options.texture) sprite.anchor.set(0.5, 0.5); - sprite.blendMode = options.blend === 'add' ? 'add' : 'normal'; - return sprite; - } -} \ No newline at end of file diff --git a/packages/engine/src/render/Renderer.ts b/packages/engine/src/render/Renderer.ts deleted file mode 100644 index a188796..0000000 --- a/packages/engine/src/render/Renderer.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Application, Container } from 'pixi.js'; -import { setPixelTextResolution } from '../ui/PixelText'; - -/** - * Рендерер: виртуальное «пиксельное» разрешение. CSS-размер канваса — виртуальное - * разрешение, растянутое целым числом (image-rendering: pixelated); бэкинг-стор — - * в пикселях устройства (scale × dpr), поэтому UI и текст резкие, а мир остаётся - * пиксельным (текстуры мира сэмплируются nearest — см. AssetLoader). - */ -export interface RendererOptions { - /** Виртуальная ширина (например 480). */ - virtualWidth: number; - /** Виртуальная высота (например 270). */ - virtualHeight: number; - /** Целочисленный множитель масштаба. */ - scale: number; - /** Цвет фона. */ - background: number; - /** Родительский элемент для канваса. */ - parent: HTMLElement; -} - -export class Renderer { - readonly app: Application; - /** Корневой контейнер мира (к нему применяется камера). */ - readonly worldRoot: Container; - /** - * Освещение — экранное пространство: fullscreen ambient + аддитивные источники. - * Камерой не двигается; ниже uiRoot (HUD/fog/fade остаются читаемыми поверх). - */ - readonly lightRoot: Container; - /** Контейнер UI поверх мира (камерой не двигается). */ - readonly uiRoot: Container; - - readonly virtualWidth: number; - readonly virtualHeight: number; - private _scale: number; - - /** Текущий целочисленный масштаб. */ - get scale(): number { - return this._scale; - } - - private readonly parentEl: HTMLElement; - private readonly initPromise: Promise; - - constructor(options: RendererOptions) { - this.virtualWidth = options.virtualWidth; - this.virtualHeight = options.virtualHeight; - this._scale = options.scale; - this.parentEl = options.parent; - - this.app = new Application(); - this.initPromise = this.app.init({ - width: options.virtualWidth, - height: options.virtualHeight, - backgroundColor: options.background, - antialias: false, - roundPixels: true, - autoStart: false, - sharedTicker: false, - // Детерминизм рендера: всегда WebGL, WebGPU-бэкенд не выбирается. - preference: 'webgl' - }); - - this.worldRoot = new Container(); - this.lightRoot = new Container(); - this.uiRoot = new Container(); - } - - /** Дождаться инициализации WebGL и добавить канвас на страницу. */ - async setup(): Promise { - await this.initPromise; - this.app.stage.addChild(this.worldRoot, this.lightRoot, this.uiRoot); - - const canvas = this.app.canvas; - canvas.style.imageRendering = 'pixelated'; - this.applySize(); - this.parentEl.appendChild(canvas); - } - - /** - * Сменить целочисленный масштаб (обычно при ресайзе окна). - * Виртуальное разрешение не меняется — меняется только CSS-размер канваса. - */ - resize(scale: number): void { - this._scale = Math.max(1, Math.floor(scale)); - this.applySize(); - } - - private applySize(): void { - const canvas = this.app.canvas; - // Бэкинг-стор — в пикселях устройства: UI и текст рендерятся 1:1 с экраном. - const dpr = window.devicePixelRatio || 1; - const resolution = Math.max(1, Math.round(this._scale * dpr)); - if (this.app.renderer.resolution !== resolution) { - this.app.renderer.resolution = resolution; - this.app.renderer.resize(this.virtualWidth, this.virtualHeight); - setPixelTextResolution(resolution); - } - canvas.style.width = `${this.virtualWidth * this._scale}px`; - canvas.style.height = `${this.virtualHeight * this._scale}px`; - } - - /** Рендер кадра (вызывается игровым циклом). */ - render(): void { - this.app.renderer.render(this.app.stage); - } -} \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/IsoDepthLayer.test.ts b/packages/engine/src/render/__tests__/IsoDepthLayer.test.ts deleted file mode 100644 index 7f025e2..0000000 --- a/packages/engine/src/render/__tests__/IsoDepthLayer.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Container } from 'pixi.js'; -import { IsoDepthLayer } from '../IsoDepthLayer'; - -describe('IsoDepthLayer', () => { - it('глубина точки = диагональ тайла', () => { - const layer = new IsoDepthLayer(); - const v = new Container(); - layer.add(v, 3, 4); - expect(v.zIndex).toBe(7); - layer.setDepth(v, 1, 1); - expect(v.zIndex).toBe(2); - }); - - it('глубина footprint = диагональ юго-восточного угла', () => { - const layer = new IsoDepthLayer(); - const house = new Container(); - layer.addRect(house, 2, 2, 3, 3); // юго-восточный угол (4, 4) - expect(house.zIndex).toBe(8); - - const north = new Container(); - layer.addRect(north, 2, 0, 1, 1); // герой к северу — под домом - const southwest = new Container(); - layer.addRect(southwest, 2, 5, 1, 1); // у южной стены — стена перекрывает - const southeast = new Container(); - layer.addRect(southeast, 4, 6, 1, 1); // перед южным углом — поверх дома - expect(north.zIndex).toBeLessThan(house.zIndex); - expect(southwest.zIndex).toBeLessThan(house.zIndex); - expect(southeast.zIndex).toBeGreaterThan(house.zIndex); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/Lighting.test.ts b/packages/engine/src/render/__tests__/Lighting.test.ts deleted file mode 100644 index e66ebc6..0000000 --- a/packages/engine/src/render/__tests__/Lighting.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Container, Texture } from 'pixi.js'; -import { Lighting } from '../Lighting'; -import { GLOW_BASE_PX, HOLE_PROFILE, quantizeHoleAlpha, stepAlphas, type LightFrame } from '../lightSim'; -import type { Renderer } from '../Renderer'; - -function makeLighting(maxLights = 16): Lighting { - // Без renderer: RT не создаётся, glow/hole/tone = Texture.WHITE, bake no-op. - return new Lighting({ width: 480, height: 270, maxLights }); -} - -/** - * Стаб-рендерер: generateTexture отдаёт WHITE (destroy защищён guard'ом), - * render() записывает вызовы bake — dirty-логика тестируется без GPU. - */ -function makeFakeRenderer() { - const bakes: Array<{ container: Container; clear: boolean }> = []; - const renderer = { - app: { - renderer: { - generateTexture: () => Texture.WHITE, - render: (opts: { container: Container; clear: boolean }) => { - bakes.push(opts); - } - } - } - } as unknown as Renderer; - return { renderer, bakes }; -} - -describe('Lighting: ambient', () => { - it('карта тьмы: multiply, белый (день), на всю сцену', () => { - const l = makeLighting(); - const dark = l.children[0]; - expect(dark.blendMode).toBe('multiply'); - expect(dark.tint).toBe(0xffffff); - expect(dark.width).toBe(480); - expect(dark.height).toBe(270); - l.destroy(); - }); - - it('setAmbient без fade применяется мгновенно', () => { - const l = makeLighting(); - l.setAmbient(0x54586a); - expect(l.ambientColor).toBe(0x54586a); - expect(l.children[0].tint).toBe(0x54586a); - l.destroy(); - }); - - it('setAmbient с fade — лерп к целевому цвету', () => { - const l = makeLighting(); - l.setAmbient(0x000000); - l.setAmbient(0xffffff, 1); - l.update(0.5); - const mid = l.children[0].tint; - expect(mid).not.toBe(0x000000); - expect(mid).not.toBe(0xffffff); - l.update(0.5); - expect(l.children[0].tint).toBe(0xffffff); - expect(l.ambientColor).toBe(0xffffff); - l.destroy(); - }); -}); - -describe('Lighting: источники', () => { - it('upsertLight добавляет источник, кадр читается после update', () => { - const l = makeLighting(); - l.upsertLight({ id: 'hearth', x: 100, y: 50, color: 0xf2b45a }); - l.update(1 / 60); - const frames = l.frames(); - expect(frames.length).toBe(1); - expect(frames[0].tint).toBe(0xf2b45a); - expect(frames[0].alpha).toBeGreaterThan(0); - l.destroy(); - }); - - it('лишние источники сверх пула игнорируются', () => { - const l = makeLighting(2); - l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); - l.upsertLight({ id: 'b', x: 0, y: 0, color: 0xffffff }); - l.upsertLight({ id: 'c', x: 0, y: 0, color: 0xffffff }); - expect(l.frames().length).toBe(2); - expect(l.hasLight('c')).toBe(false); - l.destroy(); - }); - - it('removeLight возвращает спрайты в пул: число детей стабильно', () => { - const l = makeLighting(); - const toneLayer = l.children[3]; - const before = toneLayer.children.length; - l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); - l.upsertLight({ id: 'b', x: 0, y: 0, color: 0xffffff }); - l.removeLight('a'); - l.upsertLight({ id: 'c', x: 0, y: 0, color: 0xffffff }); - expect(toneLayer.children.length).toBe(before); - expect(l.hasLight('a')).toBe(false); - expect(l.hasLight('c')).toBe(true); - l.destroy(); - }); - - it('setLightPos/setLightEnabled отражаются в кадрах', () => { - const l = makeLighting(); - l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff, radius: 2 * GLOW_BASE_PX }); - l.setLightPos('a', 123, 45); - l.setLightEnabled('a', false); - l.update(1 / 60); - const f: LightFrame = l.frames()[0]; - expect(f.x).toBe(123); - expect(f.y).toBe(45); - expect(f.alpha).toBe(0); - l.destroy(); - }); - - it('upsertLight обновляет параметры существующего', () => { - const l = makeLighting(); - l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff, intensity: 0.5 }); - l.upsertLight({ id: 'a', x: 7, y: 8, color: 0x112233, intensity: 1, flicker: 0 }); - l.update(1 / 60); - const f = l.frames()[0]; - expect(f.x).toBe(7); - expect(f.tint).toBe(0x112233); - l.destroy(); - }); - - it('мерцание меняет alpha между апдейтами', () => { - const l = makeLighting(); - l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff, flicker: 0.5 }); - l.update(0); - const a1 = l.frames()[0].alpha; - l.update(0.37); - const a2 = l.frames()[0].alpha; - expect(a1).not.toBe(a2); - l.destroy(); - }); -}); - -describe('Lighting: импульсы', () => { - it('pulseLight появляется в кадрах, модулируется огибающей и снимается', () => { - const l = makeLighting(2); - const id = l.pulseLight({ x: 10, y: 20, color: 0xf2b45a, intensity: 0.5, spec: { attack: 0.05, decay: 0.1 } }); - expect(id).toMatch(/^pulse#/); - l.update(0.01); - let f = l.frames().find((fr) => fr.id === id); - expect(f).toBeTruthy(); - expect(f!.tint).toBe(0xf2b45a); - expect(f!.alpha).toBeCloseTo(0.5 * 0.2, 5); // elapsed 0.01 из attack 0.05 - l.update(0.04); // elapsed 0.05 — пик огибающей - f = l.frames().find((fr) => fr.id === id); - expect(f!.alpha).toBeCloseTo(0.5, 5); - l.update(0.1); // elapsed 0.15 = total — снят - expect(l.frames().length).toBe(0); - expect(l.hasLight(id)).toBe(false); - expect(l.children.length).toBe(5); // карта тьмы+виньетка+пятна+тон+вспышка - l.destroy(); - }); - - it('pulseLight при исчерпании пула не рисуется (пустой id)', () => { - const l = makeLighting(1); - l.upsertLight({ id: 'hearth', x: 0, y: 0, color: 0xffffff }); - const id = l.pulseLight({ x: 0, y: 0, color: 0xffffff, spec: { attack: 0.05, decay: 0.1 } }); - expect(id).toBe(''); - expect(l.hasLight('pulse#1')).toBe(false); - expect(l.frames().length).toBe(1); - l.destroy(); - }); - - it('pulseAmbient: alpha по огибающей, новая вспышка побеждает', () => { - const l = makeLighting(); - l.pulseAmbient({ color: 0xd99a32, peak: 0.2, spec: { attack: 0.1, decay: 0.1 } }); - l.update(0.05); - expect(l.pulseAmbientAlpha).toBeCloseTo(0.1, 5); // 0.2 * 0.5 - l.pulseAmbient({ color: 0xb0453f, peak: 0.3, spec: { attack: 0, decay: 0.2 } }); - l.update(0.1); - expect(l.pulseAmbientAlpha).toBeCloseTo(0.15, 5); // 0.3 * 0.5 - l.update(0.2); // конец второй вспышки - expect(l.pulseAmbientAlpha).toBe(0); - l.destroy(); - }); -}); - -describe('Lighting: виньетка', () => { - it('setVignette мгновенно; значение клампится в 0..1', () => { - const l = makeLighting(); - l.setVignette(1.5); - expect(l.vignetteLevel).toBe(1); - expect(l.children[1].alpha).toBe(1); // спрайт виньетки — второй ребёнок - l.setVignette(-0.5); - expect(l.vignetteLevel).toBe(0); - expect(l.children[1].alpha).toBe(0); - l.destroy(); - }); - - it('setVignette с fade — лерп к целевой интенсивности', () => { - const l = makeLighting(); - l.setVignette(1); - l.setVignette(0, 1); - expect(l.vignetteLevel).toBe(0); - l.update(0.5); - const mid = l.children[1].alpha; - expect(mid).toBeGreaterThan(0); - expect(mid).toBeLessThan(1); - l.update(0.5); - expect(l.children[1].alpha).toBe(0); - l.destroy(); - }); -}); - -describe('Lighting: тёмные пятна', () => { - it('upsert/remove/has; спрайт multiply с нужным масштабом и альфой', () => { - const l = makeLighting(2); - l.upsertDarkSpot({ id: 'hazard@3,4', x: 10, y: 10, radius: 51, alpha: 0.22 }); - expect(l.hasDarkSpot('hazard@3,4')).toBe(true); - const layer = l.children[2]; // darkLayer — третий ребёнок - expect(layer.children.filter((c) => c.visible).length).toBe(1); - const spot = layer.children.find((c) => c.visible)!; - expect(spot.blendMode).toBe('multiply'); - expect(spot.alpha).toBeCloseTo(0.22, 5); - expect(spot.scale.x).toBeCloseTo(51 / GLOW_BASE_PX, 5); - l.upsertDarkSpot({ id: 'hazard@3,4', x: 12, y: 14, radius: 51, alpha: 0.3 }); - expect(layer.children.filter((c) => c.visible).length).toBe(1); // обновление без нового спрайта - expect(spot.x).toBe(12); - expect(spot.y).toBe(14); - l.removeDarkSpot('hazard@3,4'); - expect(l.hasDarkSpot('hazard@3,4')).toBe(false); - expect(l.children.length).toBe(5); // слои на месте, спрайт в пуле - l.destroy(); - }); - - it('пятна сверх пула игнорируются', () => { - const l = new Lighting({ width: 480, height: 270, maxDarkSpots: 2 }); - l.upsertDarkSpot({ id: 'a', x: 0, y: 0, radius: 32, alpha: 0.2 }); - l.upsertDarkSpot({ id: 'b', x: 0, y: 0, radius: 32, alpha: 0.2 }); - l.upsertDarkSpot({ id: 'c', x: 0, y: 0, radius: 32, alpha: 0.2 }); - expect(l.hasDarkSpot('a')).toBe(true); - expect(l.hasDarkSpot('b')).toBe(true); - expect(l.hasDarkSpot('c')).toBe(false); - l.destroy(); - }); -}); - -describe('Lighting: bake карты тьмы (fake-renderer)', () => { - it('bake-контейнер не в дереве сцены; erase-спрайты вне Lighting', () => { - const { renderer } = makeFakeRenderer(); - const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); - l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff }); - l.setAmbient(0x3e4658); - l.update(1 / 60); - // дети: карта тьмы, виньетка, пятна, тон, вспышка — bake среди них нет - expect(l.children.length).toBe(5); - for (const child of l.children) { - expect(child.blendMode).not.toBe('erase'); - } - l.destroy(); - }); - - it('первый update с тьмой — один bake; без изменений — без повторных', () => { - const { renderer, bakes } = makeFakeRenderer(); - const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); - l.setAmbient(0x3e4658); - l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff }); - l.update(1 / 60); - expect(bakes.length).toBe(1); - l.update(1 / 60); - l.update(1 / 60); - expect(bakes.length).toBe(1); // подпись не изменилась — bake пропущен - l.destroy(); - }); - - it('квантованные значения в прорези: движение на суб-пиксель — без bake', () => { - const { renderer, bakes } = makeFakeRenderer(); - const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); - l.setAmbient(0x3e4658); - l.upsertLight({ id: 'a', x: 40.7, y: 30.2, color: 0xffffff, flicker: 0 }); - l.update(1 / 60); - const first = bakes.length; - l.setLightPos('a', 41.3, 30.4); // округление то же (41, 30) - l.update(1 / 60); - expect(bakes.length).toBe(first); // округление погасило изменение - l.setLightPos('a', 44, 30); - l.update(1 / 60); - expect(bakes.length).toBe(first + 1); // сдвиг на целый px — bake - l.destroy(); - }); - - it('день (белый ambient) — fast-path: bake не вызывается', () => { - const { renderer, bakes } = makeFakeRenderer(); - const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); - l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff }); - l.update(1 / 60); - expect(bakes.length).toBe(0); // день — карта тьмы выключена - l.setAmbient(0x3e4658); - l.update(1 / 60); - expect(bakes.length).toBeGreaterThan(0); // тьма включилась — bake - l.setAmbient(0xffffff); - l.update(1 / 60); - const n = bakes.length; - l.upsertLight({ id: 'b', x: 10, y: 10, color: 0xffffff }); - l.update(1 / 60); - expect(bakes.length).toBe(n); // снова день — bake не нужен - l.destroy(); - }); - - it('тьма включилась после дня — форс-bake даже при той же подписи', () => { - const { renderer, bakes } = makeFakeRenderer(); - const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); - l.setAmbient(0x3e4658); - l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff, flicker: 0 }); - l.update(1 / 60); - expect(bakes.length).toBe(1); - l.setAmbient(0xffffff); - l.update(1 / 60); // день — без bake - l.setAmbient(0x3e4658); - l.update(1 / 60); // та же подпись, но тьма включилась заново - expect(bakes.length).toBe(2); - l.destroy(); - }); - - it('removeLight меняет подпись — перерисовка', () => { - const { renderer, bakes } = makeFakeRenderer(); - const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); - l.setAmbient(0x3e4658); - l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff, flicker: 0 }); - l.update(1 / 60); - const first = bakes.length; - l.removeLight('a'); - l.update(1 / 60); - expect(bakes.length).toBe(first + 1); - l.destroy(); - }); - - it('без RT (fallback) update и destroy не бросают', () => { - const l = makeLighting(); - l.setAmbient(0x3e4658); - l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff }); - expect(() => l.update(1 / 60)).not.toThrow(); - expect(l.frames().length).toBe(1); - l.destroy(); - expect(() => l.destroy()).not.toThrow(); - }); -}); - -describe('Lighting: профиль прорези (ступени)', () => { - it('paint-краски воспроизводят накопленный профиль при рисовании снаружи внутрь', () => { - const rings = stepAlphas(HOLE_PROFILE); - expect(rings.length).toBe(HOLE_PROFILE.length); - expect(rings[0].radius).toBe(GLOW_BASE_PX); // внешнее кольцо = полный радиус - // Симуляция source-over: красим снаружи внутрь, накопление к центру. - // rings[i] — кольцо k=n-i, накопленный профиль — HOLE_PROFILE[n-1-i]. - let acc = 0; - for (let i = 0; i < rings.length; i++) { - acc = 1 - (1 - rings[i].paintAlpha) * (1 - acc); - expect(acc).toBeCloseTo(HOLE_PROFILE[rings.length - 1 - i], 5); - } - // Центр прорези: тьма снята полностью. - expect(rings[rings.length - 1].paintAlpha).toBe(1); - }); - - it('quantizeHoleAlpha — сетка 1/8 с клампом', () => { - expect(quantizeHoleAlpha(0)).toBe(0); - expect(quantizeHoleAlpha(1)).toBe(1); - expect(quantizeHoleAlpha(0.1)).toBe(0.125); - expect(quantizeHoleAlpha(0.13)).toBe(0.125); - expect(quantizeHoleAlpha(2)).toBe(1); - expect(quantizeHoleAlpha(-1)).toBe(0); - expect(quantizeHoleAlpha(0.0625)).toBe(0.125); // половина шага — вверх - expect(quantizeHoleAlpha(0.06)).toBe(0); - }); -}); - -describe('Lighting: destroy', () => { - it('не бросает и повторный вызов безопасен', () => { - const l = makeLighting(); - l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); - l.setAmbient(0x334455, 0.5); - l.destroy(); - expect(() => l.destroy()).not.toThrow(); - }); - - it('с fake-renderer (RT + bake) тоже безопасен', () => { - const { renderer } = makeFakeRenderer(); - const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); - l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); - l.setAmbient(0x3e4658); - l.update(1 / 60); - expect(() => l.destroy()).not.toThrow(); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/camera.test.ts b/packages/engine/src/render/__tests__/camera.test.ts deleted file mode 100644 index 82786d9..0000000 --- a/packages/engine/src/render/__tests__/camera.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Container } from 'pixi.js'; -import { Camera } from '../Camera'; -import { screenToWorld, worldToScreen } from '../../math/iso'; - -/** Смещение цели от центра камеры в экранных пикселях: мировая точка. */ -function targetAtScreenOffset(cam: Camera, dxPx: number, dyPx: number): { x: number; y: number } { - const c = worldToScreen(cam.x, cam.y); - return screenToWorld(c.x + dxPx, c.y + dyPx); -} - -describe('Camera dead zone', () => { - it('без dead zone — камера центрируется на цели', () => { - const cam = new Camera(480, 270); - cam.follow(3, 5); - expect(cam.x).toBe(3); - expect(cam.y).toBe(5); - }); - - it('цель внутри окна — камера стоит', () => { - const cam = new Camera(480, 270); - cam.snap(3, 5); - cam.deadZonePx = { width: 160, height: 100 }; - // Цель на 60 px правее и 30 px ниже центра экрана — внутри окна. - const t = targetAtScreenOffset(cam, 60, 30); - cam.follow(t.x, t.y); - expect(cam.x).toBe(3); - expect(cam.y).toBe(5); - }); - - it('цель толкает камеру ровно на величину выхода', () => { - const cam = new Camera(480, 270); - cam.snap(3, 5); - cam.deadZonePx = { width: 160, height: 100 }; - // Выход за окно: 20 px вправо и 20 px вверх. - const t = targetAtScreenOffset(cam, 100, -70); - cam.follow(t.x, t.y); - // Центр проекции сместился ровно на (20, −20) экранных пикселей. - const c0 = worldToScreen(3, 5); - const c = worldToScreen(cam.x, cam.y); - expect(c.x - c0.x).toBeCloseTo(20, 6); - expect(c.y - c0.y).toBeCloseTo(-20, 6); - }); - - it('snap ставит камеру на точку, минуя окно', () => { - const cam = new Camera(480, 270); - cam.deadZonePx = { width: 160, height: 100 }; - cam.snap(5, 2); - expect(cam.x).toBe(5); - expect(cam.y).toBe(2); - // Окно сохранено: цель чуть правее центра — камера не сдвигается. - const t = targetAtScreenOffset(cam, 50, 0); - cam.follow(t.x, t.y); - expect(cam.x).toBe(5); - }); -}); - -describe('Camera bounds (мировые юниты)', () => { - it('границы мира держат камеру прижатой', () => { - const cam = new Camera(480, 270); - cam.bounds = { x: 0, y: 0, width: 64, height: 64 }; - cam.snap(-1000, 500); // за границей по обеим осям - // bbox карты: x = (0 - 64)*16, y = 0; клэмп по проекции центра. - const c = worldToScreen(cam.x, cam.y); - expect(c.x).toBeCloseTo(-1024 + 240, 6); - expect(c.y).toBeCloseTo(135, 6); - }); - - it('apply ставит worldRoot по округлённой проекции', () => { - const cam = new Camera(480, 270); - cam.snap(3, 5); - const view = new Container(); - cam.apply(view); - const applied = worldToScreen(cam.x, cam.y); - expect(view.x).toBe(240 - Math.round(applied.x)); - expect(view.y).toBe(135 - Math.round(applied.y)); - }); - - it('toScreen согласована с apply: точка мира проецируется в то же место', () => { - const cam = new Camera(480, 270); - cam.snap(3, 5); - const view = new Container(); - cam.apply(view); - // Точка мира в worldRoot: worldToScreen(p) + позиция worldRoot. - const wx = 3.5, wy = 5.5; - const p = worldToScreen(wx, wy); - expect(view.x + p.x).toBeCloseTo(cam.toScreen(wx, wy).x, 6); - expect(view.y + p.y).toBeCloseTo(cam.toScreen(wx, wy).y, 6); - }); - - it('toScreen учитывает тряску так же, как apply', () => { - const cam = new Camera(480, 270); - cam.snap(3, 5); - cam.addShake(8, 1); // тряска активна, offset фиксирован до update - const view = new Container(); - cam.apply(view); - const p = worldToScreen(3.5, 5.5); - expect(view.x + p.x).toBeCloseTo(cam.toScreen(3.5, 5.5).x, 6); - expect(view.y + p.y).toBeCloseTo(cam.toScreen(3.5, 5.5).y, 6); - }); -}); - -describe('Camera zoom', () => { - it('apply ставит scale и позицию при z=2, точка мира проецируется на место', () => { - const cam = new Camera(480, 270); - cam.snap(3, 5); - cam.setZoom(2); - const view = new Container(); - cam.apply(view); - expect(view.scale.x).toBe(2); - // Точка мира в worldRoot: p*zoom + позиция worldRoot (формула toScreen). - const wx = 3.5, wy = 5.5; - const p = worldToScreen(wx, wy); - expect(view.x + p.x * 2).toBeCloseTo(cam.toScreen(wx, wy).x, 6); - expect(view.y + p.y * 2).toBeCloseTo(cam.toScreen(wx, wy).y, 6); - // Центр взгляда — в центре экрана. - const c = worldToScreen(3, 5); - expect(view.x + c.x * 2).toBeCloseTo(240, 6); - expect(view.y + c.y * 2).toBeCloseTo(135, 6); - }); - - it('тряска не масштабируется зумом', () => { - const cam = new Camera(480, 270); - cam.snap(3, 5); - cam.setZoom(2); - cam.addShake(4, 1); // offset фиксирован до update - const view = new Container(); - cam.apply(view); - // Та же камера без тряски — разница позиций и есть offset. - const calm = new Camera(480, 270); - calm.snap(3, 5); - calm.setZoom(2); - const calmView = new Container(); - calm.apply(calmView); - // Сдвиг от тряски одинаков в apply и toScreen (добавлен один раз). - expect(view.x - calmView.x).toBeCloseTo(cam.toScreen(3.5, 5.5).x - calm.toScreen(3.5, 5.5).x, 6); - expect(view.y - calmView.y).toBeCloseTo(cam.toScreen(3.5, 5.5).y - calm.toScreen(3.5, 5.5).y, 6); - }); - - it('clamp при z=2 держит прижатие к границе мира', () => { - const cam = new Camera(480, 270); - cam.bounds = { x: 0, y: 0, width: 64, height: 64 }; - cam.setZoom(2); - cam.snap(-1000, 500); - // bbox карты ×2: x = (0-64)*16*2 = -2048; центр прижат: -2048 + 480/2. - const c = worldToScreen(cam.x, cam.y); - expect(c.x * 2).toBeCloseTo(-2048 + 240, 6); - expect(c.y * 2).toBeCloseTo(135, 6); - }); - - it('zoomTo анимируется и квантуется, финал — ровно целевой зум', () => { - const cam = new Camera(480, 270); - cam.zoomTo(2, 1); - cam.update(0.5); - const mid = cam.zoomLevel; - expect(mid).toBeGreaterThan(1); - expect(mid).toBeLessThan(2); - expect(mid * 4).toBeCloseTo(Math.round(mid * 4), 6); // кратно 0.25 - cam.update(0.5); - expect(cam.zoomLevel).toBe(2); - expect(cam.zoomLevel * 4).toBeCloseTo(Math.round(cam.zoomLevel * 4), 6); - }); - - it('zoomTo с seconds=0 — мгновенно; setZoom сбрасывает анимацию', () => { - const cam = new Camera(480, 270); - cam.zoomTo(2, 0); - expect(cam.zoomLevel).toBe(2); - cam.zoomTo(1, 10); - cam.setZoom(1.5); - cam.update(1); - expect(cam.zoomLevel).toBe(1.5); // анимация отменена - }); - - it('dead zone при зуме: выход цели считается в экранных пикселях', () => { - const cam = new Camera(480, 270); - cam.snap(3, 5); - cam.setZoom(2); - cam.deadZonePx = { width: 160, height: 100 }; - // Экранный разнос цели 60px при зуме 2 — внутри окна: камера стоит. - const c = worldToScreen(3, 5); - const t = screenToWorld(c.x + 30, c.y + 15); // 30 мировых px = 60 экранных - cam.follow(t.x, t.y); - expect(cam.x).toBe(3); - expect(cam.y).toBe(5); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/lightSim.test.ts b/packages/engine/src/render/__tests__/lightSim.test.ts deleted file mode 100644 index 1f60853..0000000 --- a/packages/engine/src/render/__tests__/lightSim.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - DEFAULT_DAY_NIGHT, - DEFAULT_DUSK_COLOR, - DUSK_PEAK, - GLOW_BASE_PX, - dayNightAmbient, - dayNightFactor, - dimColor, - flickerFactor, - lightFrame, - lerpAmbient, - pulseEnvelope, - pulseTotal -} from '../lightSim'; - -describe('flickerFactor', () => { - it('детерминирован: одинаковые (time, seed) — одинаковый результат', () => { - const a = flickerFactor(1.234, 0.37, 0.3, 0.9); - const b = flickerFactor(1.234, 0.37, 0.3, 0.9); - expect(a).toBe(b); - }); - - it('разные seed дают разные значения', () => { - const a = flickerFactor(0.5, 0.1, 0.3, 0.9); - const b = flickerFactor(0.5, 0.9, 0.3, 0.9); - expect(a).not.toBe(b); - }); - - it('в диапазоне [1-amount, 1] на сетке времени', () => { - for (let i = 0; i < 180; i++) { - const f = flickerFactor(i / 60, 0.25, 0.35, 0.9); - expect(f).toBeGreaterThanOrEqual(1 - 0.35 - 1e-9); - expect(f).toBeLessThanOrEqual(1 + 1e-9); - } - }); - - it('amount = 0 — ровный свет', () => { - for (let i = 0; i < 60; i++) { - expect(flickerFactor(i / 60, 0.7, 0, 0.9)).toBe(1); - } - }); -}); - -describe('lightFrame', () => { - it('дефолты: intensity 1, radius 2*GLOW_BASE_PX', () => { - const f = lightFrame({ id: 'l', x: 10, y: 20, color: 0xf2b45a }, 0); - expect(f.tint).toBe(0xf2b45a); - expect(f.scale).toBe(2); - expect(f.alpha).toBeGreaterThan(0); - expect(f.alpha).toBeLessThanOrEqual(1); - expect(f.x).toBe(10); - expect(f.y).toBe(20); - }); - - it('alpha = intensity * flickerFactor, scale = radius / GLOW_BASE_PX', () => { - const def = { id: 'l', x: 0, y: 0, color: 0xffaa00, intensity: 1.5, radius: 64, flicker: 0, seed: 0 }; - const f = lightFrame(def, 1); - expect(f.alpha).toBeCloseTo(1.5, 10); - expect(f.scale).toBe(64 / GLOW_BASE_PX); - // alpha клампится в адаптере, но чистый кадр может превышать 1 - expect(f.alpha).toBe(1.5); - }); - - it('выключенный источник — alpha 0', () => { - const f = lightFrame({ id: 'l', x: 0, y: 0, color: 0xffffff, enabled: false }, 0); - expect(f.alpha).toBe(0); - }); - - it('мерцание меняет alpha между шагами', () => { - const def = { id: 'l', x: 0, y: 0, color: 0xffffff, intensity: 1, flicker: 0.5 }; - const a1 = lightFrame(def, 0).alpha; - const a2 = lightFrame(def, 0.37).alpha; - expect(a1).not.toBe(a2); - }); -}); - -describe('pulseEnvelope / pulseTotal', () => { - it('трапеция: рост, плато, спад, нули за пределами', () => { - const spec = { attack: 0.1, hold: 0.2, decay: 0.3 }; - expect(pulseEnvelope(0, spec)).toBe(0); - expect(pulseEnvelope(0.05, spec)).toBeCloseTo(0.5, 10); - expect(pulseEnvelope(0.15, spec)).toBe(1); - expect(pulseEnvelope(0.25, spec)).toBe(1); - expect(pulseEnvelope(0.45, spec)).toBeCloseTo(0.5, 10); - expect(pulseEnvelope(0.7, spec)).toBe(0); - expect(pulseTotal(spec)).toBeCloseTo(0.6, 10); - }); - - it('без hold — пик сразу после attack', () => { - const spec = { attack: 0.1, decay: 0.2 }; - expect(pulseEnvelope(0.1, spec)).toBe(1); - expect(pulseEnvelope(0.2, spec)).toBeCloseTo(0.5, 10); - expect(pulseTotal(spec)).toBeCloseTo(0.3, 10); - }); - - it('attack = 0 — мгновенный пик', () => { - const spec = { attack: 0, decay: 0.2 }; - expect(pulseEnvelope(0, spec)).toBe(0); - expect(pulseEnvelope(0.1, spec)).toBeCloseTo(0.5, 10); - expect(pulseEnvelope(0.2, spec)).toBe(0); - expect(pulseTotal(spec)).toBeCloseTo(0.2, 10); - }); - - it('отрицательное время и значения вне диапазона — 0', () => { - const spec = { attack: 0.1, decay: 0.2 }; - expect(pulseEnvelope(-0.5, spec)).toBe(0); - expect(pulseEnvelope(10, spec)).toBe(0); - }); -}); - -describe('lerpAmbient', () => { - it('краи и середина по каналам', () => { - expect(lerpAmbient(0x000000, 0xffffff, 0)).toBe(0x000000); - expect(lerpAmbient(0x000000, 0xffffff, 1)).toBe(0xffffff); - expect(lerpAmbient(0xff0000, 0x00ff00, 0.5)).toBe(0x808000); // round(127.5) = 128 - }); -}); - -describe('dayNightAmbient (золотой час)', () => { - it('краи: k=0 — день, k=1 — ночь', () => { - expect(dayNightAmbient(0xf7f4ea, 0x3e4658, 0)).toBe(0xf7f4ea); - expect(dayNightAmbient(0xf7f4ea, 0x3e4658, 1)).toBe(0x3e4658); - }); - - it('на пике (k = DUSK_PEAK) ambient равен закатному тону', () => { - expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK)).toBe(DEFAULT_DUSK_COLOR); - expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK, 0xffcc88)).toBe(0xffcc88); // кастомный тон - }); - - it('до пика — лерп день→закат, после — закат→ночь', () => { - expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK / 2)).toBe( - lerpAmbient(0xf7f4ea, DEFAULT_DUSK_COLOR, 0.5) - ); - expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK + (1 - DUSK_PEAK) / 2)).toBe( - lerpAmbient(DEFAULT_DUSK_COLOR, 0x3e4658, 0.5) - ); - }); - - it('непрерывен и монотонен по k (по каждому каналу)', () => { - let prevR = 256; - for (let k = 0; k <= 1.0001; k += 0.01) { - const c = dayNightAmbient(0xf7f4ea, 0x3e4658, k); - const r = (c >> 16) & 0xff; - expect(r).toBeLessThanOrEqual(prevR); // день светлее ночи по R - prevR = r; - } - // стык на пике: значения слева и справа совпадают - expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK - 0.001)).toBeCloseTo( - dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK + 0.001), - 1 - ); - }); -}); - -describe('dimColor', () => { - it('краи: полная яркость и ноль', () => { - expect(dimColor(0x54586a, 1)).toBe(0x54586a); - expect(dimColor(0x54586a, 0)).toBe(0x000000); - }); - - it('монотонность по яркости', () => { - let prev = 0; - for (let k = 0; k <= 1.001; k += 0.1) { - const c = dimColor(0x808080, k); - expect(c).toBeGreaterThanOrEqual(prev); - prev = c; - } - }); -}); -describe('dayNightFactor', () => { - it('день — 0', () => { - expect(dayNightFactor(8)).toBe(0); - expect(dayNightFactor(12)).toBe(0); - expect(dayNightFactor(17.9)).toBe(0); - }); - - it('закат 18–20 — линейный рост', () => { - expect(dayNightFactor(18)).toBe(0); - expect(dayNightFactor(19)).toBeCloseTo(0.5, 10); - expect(dayNightFactor(20)).toBe(1); - }); - - it('ночь — 1', () => { - expect(dayNightFactor(23)).toBe(1); - expect(dayNightFactor(0)).toBe(1); - expect(dayNightFactor(5.9)).toBe(1); - }); - - it('рассвет 6–8 — линейный спад', () => { - expect(dayNightFactor(6)).toBe(1); - expect(dayNightFactor(7)).toBeCloseTo(0.5, 10); - expect(dayNightFactor(8)).toBe(0); - }); - - it('стык 24/0 непрерывен, отрицательные и большие часы ок', () => { - expect(dayNightFactor(24)).toBe(1); - expect(dayNightFactor(25)).toBe(dayNightFactor(1)); - expect(dayNightFactor(-4)).toBe(1); - expect(dayNightFactor(-16)).toBe(0); // -16 ≡ 8 - }); - - it('монотонность на переходах', () => { - for (let t = 18; t < 20; t += 0.1) { - expect(dayNightFactor(t + 0.1)).toBeGreaterThanOrEqual(dayNightFactor(t)); - } - for (let t = 6; t < 8; t += 0.1) { - expect(dayNightFactor(t + 0.1)).toBeLessThanOrEqual(dayNightFactor(t)); - } - }); - - it('кастомный spec', () => { - const spec = { dawn: 0, day: 1, dusk: 10, night: 12 }; - expect(dayNightFactor(5, spec)).toBe(0); - expect(dayNightFactor(11, spec)).toBeCloseTo(0.5, 10); - expect(dayNightFactor(12, spec)).toBe(1); - expect(DEFAULT_DAY_NIGHT.dusk).toBe(18); // дефолт не тронут - }); -}); diff --git a/packages/engine/src/render/__tests__/particleSim.test.ts b/packages/engine/src/render/__tests__/particleSim.test.ts deleted file mode 100644 index 8098d9d..0000000 --- a/packages/engine/src/render/__tests__/particleSim.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { stepParticle, sampleSpawn, sampleBurst, mergeEmitterOptions, type ParticleState, type ParticleVisual } from '../particleSim'; -import { createRng } from '../../math/rng'; -import type { EmitterOptions } from '../Particles'; - -const opts: EmitterOptions = { - color: 0xffffff, - rate: 10, - lifetime: [1, 2], - velocity: { x: [-10, 10], y: [-10, 10] } -}; - -function state(over: Partial = {}): ParticleState { - return { x: 0, y: 0, vx: 0, vy: 0, age: 0, lifetime: 2, rot: 0, spin: 0, scale: 1, tint: 0xffffff, k: 0, ...over }; -} - -describe('stepParticle', () => { - it('позиция после шагов (полу-неявный Эйлер)', () => { - const p = state({ vx: 10, vy: -5, lifetime: 10 }); - stepParticle(p, 0.5, {}); - expect(p.x).toBeCloseTo(5); - expect(p.y).toBeCloseTo(-2.5); - expect(p.age).toBeCloseTo(0.5); - }); - - it('ускорение влияет на скорость', () => { - const p = state({ lifetime: 10 }); - stepParticle(p, 1, { acceleration: { y: 100 } }); - expect(p.vy).toBeCloseTo(100); - expect(p.y).toBeCloseTo(100); // скорость применилась в том же шаге - }); - - it('fadeOut: true — вся жизнь; число — хвост в сек; false — без затухания', () => { - const a = state(); - expect(stepParticle(a, 1, { fadeOut: true }).alpha).toBeCloseTo(0.5); // 1 из 2 сек - const b = state({ lifetime: 2 }); - expect(stepParticle(b, 1, { fadeOut: 0.5 }).alpha).toBe(1); // хвост ещё далеко - const c = state({ age: 0.75 }); - expect(stepParticle(c, 1, { fadeOut: 0.5 }).alpha).toBeCloseTo(0.5); // после шага age=1.75, хвост 0.5 - const d = state(); - expect(stepParticle(d, 1, { fadeOut: false }).alpha).toBe(1); - }); - - it('fadeIn наращивает альфу в начале', () => { - const p = state(); - expect(stepParticle(p, 0.25, { fadeIn: 1 }).alpha).toBeCloseTo(0.25); - }); - - it('drag тормозит', () => { - const p = state({ vx: 100 }); - stepParticle(p, 0.5, { drag: 2 }); // v *= 1 - 1.0 - expect(p.vx).toBeCloseTo(0); - }); - - it('spin вращает, scaleOverLife меняет масштаб', () => { - const a = state({ spin: Math.PI }); - expect(stepParticle(a, 0.5, {}).rotation).toBeCloseTo(Math.PI / 2); - const b = state({ scale: 4 }); - expect(stepParticle(b, 1, { scaleOverLife: [1, 2] }).scale).toBeCloseTo(6); // 4 × 1.5 - const c = state({ scale: 4 }); - expect(stepParticle(c, 1, { scaleOverLife: 'shrink' }).scale).toBeCloseTo(2); // 4 × 0.5 - }); - - it('colorOverLife лерпит цвет по жизни', () => { - const p = state({ lifetime: 2 }); - const v = stepParticle(p, 1, { colorOverLife: [0xff0000, 0x0000ff] }); - expect(v.tint).toBe(0x800080); - }); - - it('wobble дрейфует по синусу с фазой из k', () => { - const p = state({ k: 0, lifetime: 10 }); - const before = p.x; - stepParticle(p, 0.1, { wobble: 10 }); // sin(0.3) > 0 → дрейф вправо - expect(p.x).toBeGreaterThan(before); - }); - - it('out — визуал заполняется без аллокации (горячий путь)', () => { - const p = state({ lifetime: 2, spin: 1 }); - const out: ParticleVisual = { alpha: 0, scale: 0, rotation: 0, tint: 0 }; - const v = stepParticle(p, 1, { colorOverLife: [0xff0000, 0x0000ff] }, out); - expect(v).toBe(out); // тот же объект, не новый - expect(v.tint).toBe(0x800080); - expect(v.rotation).toBeCloseTo(1); - }); -}); - -describe('sampleSpawn', () => { - it('учитывает spawnArea и диапазоны', () => { - const rng = createRng(1); - for (let i = 0; i < 50; i++) { - const s = sampleSpawn({ ...opts, spawnArea: { width: 20, height: 10 } }, rng); - expect(s.x).toBeGreaterThanOrEqual(-10); - expect(s.x).toBeLessThanOrEqual(10); - expect(s.y).toBeGreaterThanOrEqual(-5); - expect(s.y).toBeLessThanOrEqual(5); - expect(s.vx).toBeGreaterThanOrEqual(-10); - expect(s.vx).toBeLessThanOrEqual(10); - expect(s.lifetime).toBeGreaterThanOrEqual(1); - expect(s.lifetime).toBeLessThanOrEqual(2); - expect(s.age).toBe(0); - } - }); - - it('без области — старт из точки', () => { - const s = sampleSpawn(opts, createRng(1)); - expect(s.x).toBe(0); - expect(s.y).toBe(0); - }); - - it('colors выбираются из списка, spin/rotation из диапазонов', () => { - const rng = createRng(3); - const o: EmitterOptions = { - ...opts, - colors: [0xff0000, 0x00ff00], - rotation: [0, 1], - spin: [-2, 2] - }; - for (let i = 0; i < 30; i++) { - const s = sampleSpawn(o, rng); - expect([0xff0000, 0x00ff00]).toContain(s.tint); - expect(s.rot).toBeGreaterThanOrEqual(0); - expect(s.rot).toBeLessThanOrEqual(1); - expect(s.spin).toBeGreaterThanOrEqual(-2); - expect(s.spin).toBeLessThanOrEqual(2); - } - }); -}); - -describe('sampleBurst', () => { - it('нужное количество, скорость в диапазоне', () => { - const rng = createRng(5); - const parts = sampleBurst(12, [30, 80], [0.2, 0.5], rng, { color: 0x52525c }); - expect(parts).toHaveLength(12); - for (const p of parts) { - const v = Math.hypot(p.vx, p.vy); - expect(v).toBeGreaterThanOrEqual(30 - 1e-9); - expect(v).toBeLessThanOrEqual(80 + 1e-9); - expect(p.lifetime).toBeGreaterThanOrEqual(0.2); - expect(p.lifetime).toBeLessThanOrEqual(0.5); - expect(p.tint).toBe(0x52525c); - } - }); - - it('детерминирован по seed', () => { - const a = sampleBurst(5, [10, 20], [0.1, 0.2], createRng(99)); - const b = sampleBurst(5, [10, 20], [0.1, 0.2], createRng(99)); - expect(a).toEqual(b); - }); -}); - -describe('mergeEmitterOptions', () => { - it('слияет чисто: база и переопределение не мутируются', () => { - const base: EmitterOptions = { ...opts }; - const over = { color: 0xff0000, lifetime: [0.1, 0.2] as [number, number] }; - const merged = mergeEmitterOptions(base, over); - expect(merged.color).toBe(0xff0000); - expect(merged.lifetime).toEqual([0.1, 0.2]); - expect(base.color).toBe(0xffffff); // база цела - expect(base.lifetime).toEqual([1, 2]); - expect(merged.velocity).toBe(base.velocity); // непереопределённые — те же ссылки - const back = mergeEmitterOptions(base); - expect(back).not.toBe(base); - expect(back).toEqual(base); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/scale.test.ts b/packages/engine/src/render/__tests__/scale.test.ts deleted file mode 100644 index 867fda6..0000000 --- a/packages/engine/src/render/__tests__/scale.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { computeScale } from '../scale'; - -describe('computeScale', () => { - it('целый множитель: 480×270 в окно 960×540 — x2', () => { - expect(computeScale(480, 270, 960, 540)).toBe(2); - }); - - it('берёт минимум по осям (вписать целиком)', () => { - expect(computeScale(480, 270, 960, 810)).toBe(2); // по высоте больше — режет ширина - expect(computeScale(480, 270, 1440, 540)).toBe(2); // по ширине больше — режет высота - }); - - it('дробное вмещение округляется вниз до целого (pixel-perfect)', () => { - expect(computeScale(480, 270, 1000, 600)).toBe(2); - expect(computeScale(480, 270, 1200, 675)).toBe(2); - expect(computeScale(480, 270, 1440, 810)).toBe(3); - }); - - it('маленькое окно — минимум 1 (картинка не исчезает)', () => { - expect(computeScale(480, 270, 100, 100)).toBe(1); - expect(computeScale(480, 270, 0, 0)).toBe(1); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/shadowSim.test.ts b/packages/engine/src/render/__tests__/shadowSim.test.ts deleted file mode 100644 index ea78658..0000000 --- a/packages/engine/src/render/__tests__/shadowSim.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { blobPlacement, sunShadow } from '../shadowSim'; - -describe('sunShadow', () => { - it('ночью тени нет', () => { - expect(sunShadow(2)).toBeNull(); - expect(sunShadow(23)).toBeNull(); - expect(sunShadow(5.5)).toBeNull(); - }); - - it('на границах дня альфа нулевая, в разгар дня максимальная', () => { - expect(sunShadow(6)!.alpha).toBeCloseTo(0); - expect(sunShadow(19.9)!.alpha).toBeCloseTo(0); // к закату гаснет (в 20 — null) - const noon = sunShadow(13)!.alpha; - expect(noon).toBeGreaterThan(0.3); - const morning = sunShadow(7)!.alpha; // ещё в разгоне (dawn..day = 6..8) - expect(morning).toBeGreaterThan(0); - expect(morning).toBeLessThan(noon); - }); - - it('тень утром на запад экрана, вечером на восток', () => { - expect(sunShadow(7)!.dirX).toBeLessThan(-0.5); - expect(sunShadow(19)!.dirX).toBeGreaterThan(0.5); - expect(sunShadow(13)!.dirX).toBeCloseTo(0); // полдень — почти под объектом - }); - - it('длина максимальна утром/вечером, минимальна в полдень', () => { - const noon = sunShadow(13)!.len; - const morning = sunShadow(7)!.len; - const evening = sunShadow(19)!.len; - expect(noon).toBeLessThan(morning); - expect(noon).toBeLessThan(evening); - expect(morning).toBeCloseTo(evening, 1); // симметричный день - }); - - it('вектор направления единичный', () => { - for (let h = 6; h < 20; h += 0.5) { - const s = sunShadow(h)!; - expect(Math.hypot(s.dirX, s.dirY)).toBeCloseTo(1); - } - }); -}); - -describe('blobPlacement', () => { - it('масса эллипса сидит у базы: ближний край заходит за базу, вылет — вперёд', () => { - const sun = { dirX: 1, dirY: 0, len: 1, alpha: 0.3 }; - const b = blobPlacement(10, 40, sun); - const center = 10 * 0.3 + 40 * 0.2; // 11 — центр массы от базы - expect(b.x).toBeCloseTo(center); - expect(b.y).toBe(0); - expect(b.scaleX).toBeCloseTo(center + 5.5 + 8.8); // полуось вдоль тени - expect(b.scaleY).toBeCloseTo(10); // 0.75 + 0.25·min(1,len) = 1 при len=1 - expect(b.alpha).toBeCloseTo(0.3); - expect(b.rotation).toBeCloseTo(0); - // Ближний край эллипса за базой (тень «приклеена» к объекту)… - expect(b.x - b.scaleX).toBeLessThan(0); - // …а дальний край вылетает вперёд по тени. - expect(b.x + b.scaleX).toBeGreaterThan(0); - }); - - it('длинная утренняя тень дальше, чем короткая полуденная', () => { - const morning = blobPlacement(10, 40, { dirX: -1, dirY: 0, len: 1.5, alpha: 0.3 }); - const noon = blobPlacement(10, 40, { dirX: 0, dirY: 1, len: 0.6, alpha: 0.3 }); - expect(Math.hypot(morning.x, morning.y)).toBeGreaterThan(Math.hypot(noon.x, noon.y)); - }); - - it('поворот соответствует направлению тени', () => { - const b = blobPlacement(10, 40, { dirX: 0, dirY: 1, len: 1, alpha: 0.3 }); - expect(b.rotation).toBeCloseTo(Math.PI / 2); - }); - - it('центр массы близко к базе даже при длинной тени (объект не «висит»)', () => { - const long = blobPlacement(9, 12, { dirX: -0.9, dirY: 0.45, len: 1.6, alpha: 0.3 }); - expect(Math.hypot(long.x, long.y)).toBeLessThan(9); // герой: центр ~5-6 px от ног - }); -}); \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/shake.test.ts b/packages/engine/src/render/__tests__/shake.test.ts deleted file mode 100644 index c4e5ff4..0000000 --- a/packages/engine/src/render/__tests__/shake.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Shake } from '../shake'; - -describe('Shake', () => { - it('неактивен без толчков, offset нулевой', () => { - const s = new Shake(); - expect(s.active).toBe(false); - expect(s.offset).toEqual({ x: 0, y: 0 }); - }); - - it('толчок с нулевой/отрицательной силой игнорируется', () => { - const s = new Shake(); - s.add(0, 1); - s.add(5, 0); - s.add(-3, 1); - expect(s.active).toBe(false); - }); - - it('активен во время действия и выключается по окончании', () => { - const s = new Shake(); - s.add(4, 0.5); - expect(s.active).toBe(true); - s.update(0.3); - expect(s.active).toBe(true); - s.update(0.3); - expect(s.active).toBe(false); - expect(s.offset).toEqual({ x: 0, y: 0 }); - }); - - it('амплитуда не превышает силу толчка и затухает', () => { - const s = new Shake(42); - s.add(6, 1); - let maxAbs = 0; - for (let i = 0; i < 60; i++) { - s.update(1 / 60); - const { x, y } = s.offset; - expect(Math.abs(x)).toBeLessThanOrEqual(6); - expect(Math.abs(y)).toBeLessThanOrEqual(6); - maxAbs = Math.max(maxAbs, Math.abs(x)); - } - // в какой-то из ранних кадров смещение ощутимо - expect(maxAbs).toBeGreaterThan(0); - }); - - it('одинаковый seed — одинаковые смещения', () => { - const a = new Shake(7); - const b = new Shake(7); - a.add(5, 0.3); - b.add(5, 0.3); - for (let i = 0; i < 20; i++) { - a.update(1 / 60); - b.update(1 / 60); - expect(a.offset).toEqual(b.offset); - } - }); - - it('offset округляется до целых пикселей', () => { - const s = new Shake(1); - s.add(0.5, 0.1); - s.update(1 / 120); - const { x, y } = s.offset; - expect(Number.isInteger(x)).toBe(true); - expect(Number.isInteger(y)).toBe(true); - }); - - it('два толчка складываются, clear сбрасывает', () => { - const s = new Shake(9); - s.add(3, 1); - s.add(3, 1); - s.update(0.1); - expect(s.active).toBe(true); - s.clear(); - expect(s.active).toBe(false); - expect(s.offset).toEqual({ x: 0, y: 0 }); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/spriteFx.test.ts b/packages/engine/src/render/__tests__/spriteFx.test.ts deleted file mode 100644 index 1119744..0000000 --- a/packages/engine/src/render/__tests__/spriteFx.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Sprite, Texture } from 'pixi.js'; -import { SpriteFlash } from '../spriteFx'; - -describe('SpriteFlash', () => { - it('вспышка перекрашивает спрайт и возвращает базовый tint', () => { - const sprite = new Sprite(Texture.WHITE); - const flash = new SpriteFlash(sprite, 0xff0000, 0.15); - flash.start(); - flash.update(1 / 60); - expect(sprite.tint).not.toBe(0xffffff); - for (let i = 0; i < 15; i++) flash.update(1 / 60); // 0.15+ с — доиграл - expect(sprite.tint).toBe(0xffffff); - }); - - it('destroyed следует за разрушением спрайта (самозачистка из реестра)', () => { - const sprite = new Sprite(Texture.WHITE); - const flash = new SpriteFlash(sprite, 0xff0000); - expect(flash.destroyed).toBe(false); - flash.update(1 / 60); // неактивен — no-op - sprite.destroy(); - expect(flash.destroyed).toBe(true); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxFade.ts b/packages/engine/src/render/fx/FxFade.ts deleted file mode 100644 index 4aab21c..0000000 --- a/packages/engine/src/render/fx/FxFade.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Container } from 'pixi.js'; -import type { Updatable } from '../../core/Updatables'; -import { sampleFade, type FxFadeSpec } from './fxSim'; - -/** Опции растворения: математика (FxFadeSpec) + жизненный цикл вьюхи. */ -export interface FxFadeOptions extends FxFadeSpec { - /** true — не уничтожать вьюху в конце (только альфа). */ - keep?: boolean; - /** Колбэк завершения (в т.ч. после destroy вьюхи). */ - onDone?: () => void; -} - -/** - * Растворение/проявление произвольной вьюхи + авто-удаление. Не display-объект: - * тикается в реестре, применяет альфу к чужой вьюхе. В конце removeChild + - * destroy({children: true}) — с защитой от двойного destroy (вьюху могли - * уничтожить извне, пока эффект играл). - */ -export class FxFade implements Updatable { - private t = 0; - private finished = false; - private readonly from: number; - - constructor( - private readonly view: Container, - private readonly opts: FxFadeOptions - ) { - this.from = view.alpha; - } - - /** - * Прекратить эффект: вернуть базовую альфу; вьюху, которую эффект уничтожил - * бы в конце (!keep), гасим сразу — контракт fx: cancel = конец эффекта. - */ - cancel(): void { - if (this.finished) return; - this.finished = true; - if (this.view.destroyed) return; - this.view.alpha = this.from; - if (!this.opts.keep) { - this.view.parent?.removeChild(this.view); - this.view.destroy({ children: true }); - } - } - - /** Вьюха разрушена извне или эффект закончился — запись вычистится сама. */ - get destroyed(): boolean { - return this.finished || this.view.destroyed; - } - - update(dt: number): void { - if (this.finished) return; - if (this.view.destroyed) return; // реестр вычистит по destroyed - this.t += dt; - this.view.alpha = sampleFade(this.t, this.from, this.opts); - const total = (this.opts.delay ?? 0) + this.opts.duration; - if (this.t >= total - 1e-6) { // эпсилон: накопление 1/60 не дотягивает до границы - this.finished = true; - if (!this.opts.keep) { - this.view.parent?.removeChild(this.view); - this.view.destroy({ children: true }); - } - this.opts.onDone?.(); - } - } -} \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxFloatText.ts b/packages/engine/src/render/fx/FxFloatText.ts deleted file mode 100644 index 409071b..0000000 --- a/packages/engine/src/render/fx/FxFloatText.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Updatable } from '../../core/Updatables'; -import type { Vec2 } from '../../math/Vec2'; -import { PixelText, type PixelTextOptions } from '../../ui/PixelText'; -import { sampleFloat, type FxFloatSpec } from './fxSim'; - -/** - * Всплывающий текст: подъём + затухание (числа урона, тосты, подборы). - * Владеет своим PixelText: создаёт, тикает и уничтожает себя по завершении. - * Позиция задаётся при создании (at), update только смещает от неё. - */ -export class FxFloatText extends PixelText implements Updatable { - private t = 0; - private cancelled = false; - private readonly baseX: number; - private readonly baseY: number; - - constructor( - text: string, - at: Vec2, - private readonly spec: FxFloatSpec, - style?: Partial - ) { - super({ text, size: 11, ...style }); - this.anchor.set(0.5); - this.baseX = at.x; - this.baseY = at.y; - this.position.set(at.x, at.y); - } - - /** Досрочно погасить (например, новый тост вытесняет старый). */ - cancel(): void { - if (this.cancelled) return; - this.cancelled = true; - this.destroy({ children: true }); - } - - /** true, когда текст доиграл (или погашен). */ - get done(): boolean { - return this.cancelled || this.t >= this.spec.duration - 1e-6; - } - - update(dt: number): void { - if (this.destroyed) return; - this.t += dt; - const s = sampleFloat(this.t, this.spec); - this.x = this.baseX + s.x; - this.y = this.baseY + s.y; - this.alpha = s.alpha; - this.scale.set(s.scale); - if (this.done) this.destroy({ children: true }); - } -} \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxLayer.ts b/packages/engine/src/render/fx/FxLayer.ts deleted file mode 100644 index fe2b632..0000000 --- a/packages/engine/src/render/fx/FxLayer.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Container, Sprite } from 'pixi.js'; -import { Updatables, type Updatable } from '../../core/Updatables'; -import type { Vec2 } from '../../math/Vec2'; -import type { PixelTextOptions } from '../../ui/PixelText'; -import { ParticleEmitter, type EmitterOptions } from '../Particles'; -import { SpriteFlash } from '../spriteFx'; -import { FxRing } from './FxRing'; -import { FxFloatText } from './FxFloatText'; -import { FxFade, type FxFadeOptions } from './FxFade'; -import { FxPop } from './FxPop'; -import { FxTrail } from './FxTrail'; -import type { FxRingSpec, FxFloatSpec, FxPopSpec } from './fxSim'; - -/** Опции слоя. */ -export interface FxLayerOptions { - /** Seed по умолчанию для разовых частиц (burst), если opts не передал свой. */ - seed?: number; -} - -/** Опции floatText: математика + стиль текста + родитель (для depth-слоёв). */ -export interface FxLayerFloatOptions extends Partial { - style?: Partial; - parent?: Container; -} - -/** Родитель вьюхи эффекта вместо самого слоя (для IsoDepthLayer.addFx). */ -export interface FxParentOptions { - parent?: Container; -} - -/** - * Слой визуальных эффектов: сам тикается (реализует Updatable — добавить один - * раз в engine.fx), спавнит one-shot пресеты и сам вычищает закончившиеся. - * Все тикающиеся эффекты — в одном реестре items; flashes — только индекс для - * перезапуска флэша по спрайту (сами SpriteFlash тикаются из items). - * Контракт эффектов: cancel() = остановить, вернуть базовое состояние, - * уничтожить свою вьюху; конец жизни — destroyed (done — алиас у своих). - * Позиции — локальные px контейнера, куда добавлен слой (worldRoot — мировые - * эффекты через проекцию сцены, uiRoot — тосты/HUD). Изометрию слой не знает. - */ -export class FxLayer extends Container implements Updatable { - private readonly items = new Updatables(); - /** Индекс флэшей по спрайту (перезапуск вместо дубля; тик — из items). */ - private readonly flashes = new Map(); - private readonly defaultSeed: number; - private seedCounter = 0; - - constructor(opts?: FxLayerOptions) { - super(); - this.defaultSeed = opts?.seed ?? 0x5eed; - } - - /** Ручное добавление своего эффекта (эмиттер, SpriteMotion, FxTrail…). */ - add(fx: T): T { - this.items.add(fx); - return fx; - } - - /** Погасить и уничтожить все активные эффекты (exit сцены, смена тоста). */ - clear(): void { - this.items.forEach((u) => { - const cancellable = u as { cancel?: () => void }; - if (cancellable.cancel) cancellable.cancel(); - else if (!u.destroyed && u instanceof Container) { - u.destroy({ children: true }); - } - }); - this.items.clear(); - this.flashes.clear(); - } - - update(dt: number): void { - this.items.update(dt); - } - - /** Расходящееся/сходящееся кольцо; repeat > 1 — телеграф зоны. */ - ring(spec: FxRingSpec, at: Vec2, opts?: FxParentOptions): FxRing { - const fx = new FxRing(spec); - fx.position.set(at.x, at.y); - return this.mount(fx, opts); - } - - /** Всплывающий текст (числа урона, тосты, подборы). */ - floatText(text: string, at: Vec2, opts?: FxLayerFloatOptions): FxFloatText { - const { style, parent, ...spec } = opts ?? {}; - const fx = new FxFloatText(text, at, { duration: 1, ...spec }, style); - return this.mount(fx, { parent }); - } - - /** Растворение (to < 1) или проявление произвольной вьюхи + авто-destroy. */ - fade(view: Container, spec: FxFadeOptions): FxFade { - return this.items.add(new FxFade(view, spec)) as FxFade; - } - - /** Появление с овершутом масштаба. */ - pop(view: Container, spec?: FxPopSpec): FxPop { - return this.items.add(new FxPop(view, spec)) as FxPop; - } - - /** Хит-флэш: один SpriteFlash на спрайт, повторный вызов перезапускает. */ - flash(sprite: Sprite, color: number, seconds?: number): void { - let flash = this.flashes.get(sprite); - if (!flash || flash.destroyed) { - flash = new SpriteFlash(sprite, color, seconds); - this.flashes.set(sprite, flash); - this.items.add(flash); - } - flash.start(); - } - - /** Разовые частицы в точке (ParticleEmitter.oneShot) с самоуничтожением. */ - burst(count: number, at: Vec2, opts: EmitterOptions, parent?: Container): ParticleEmitter { - const seed = opts.seed ?? (this.defaultSeed + this.seedCounter++); - const fx = ParticleEmitter.oneShot(count, { ...opts, seed }); - fx.position.set(at.x, at.y); - (parent ?? this).addChild(fx); - return this.items.add(fx) as ParticleEmitter; - } - - /** Шлейф за движущейся вьюхой (снаряды, герой). */ - trail(view: Container, opts: EmitterOptions, parent?: Container): FxTrail { - const fx = new FxTrail(view, opts); - (parent ?? this).addChild(fx.view); - return this.items.add(fx) as FxTrail; - } - - /** Повесить вьюху эффекта на слой или переданного родителя. */ - private mount(fx: T, opts?: FxParentOptions): T { - (opts?.parent ?? this).addChild(fx); - return this.items.add(fx) as T; - } - - /** Разрушение слоя гасит все эффекты (exit сцены — одна строка). */ - override destroy(options?: Parameters[0]): void { - this.clear(); - super.destroy(options); - } -} \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxPop.ts b/packages/engine/src/render/fx/FxPop.ts deleted file mode 100644 index 835c496..0000000 --- a/packages/engine/src/render/fx/FxPop.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Container } from 'pixi.js'; -import type { Updatable } from '../../core/Updatables'; -import { samplePop, type FxPopSpec } from './fxSim'; - -/** - * Появление с овершутом масштаба (подбор, спавн, UI-подтверждение). Базовый - * масштаб снимается в конструкторе — эффект не конфликтует с вьюхами, масштаб - * которых меняет игра; вьюху не уничтожает (она «уже есть»). - */ -export class FxPop implements Updatable { - private t = 0; - private finished = false; - private readonly base: number; - - constructor( - private readonly view: Container, - private readonly spec: FxPopSpec = {} - ) { - this.base = view.scale.x; - } - - /** Прекратить эффект и вернуть базовый масштаб. */ - cancel(): void { - this.finished = true; - if (!this.view.destroyed) this.view.scale.set(this.base); - } - - /** Вьюха разрушена извне или эффект закончился. */ - get destroyed(): boolean { - return this.finished || this.view.destroyed; - } - - update(dt: number): void { - if (this.finished) return; - if (this.view.destroyed) return; - this.t += dt; - this.view.scale.set(this.base * samplePop(this.t, this.spec)); - if (this.t >= (this.spec.duration ?? 0.25) - 1e-6) this.finished = true; - } -} \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxRing.ts b/packages/engine/src/render/fx/FxRing.ts deleted file mode 100644 index bd9faae..0000000 --- a/packages/engine/src/render/fx/FxRing.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Graphics } from 'pixi.js'; -import type { Updatable } from '../../core/Updatables'; -import { sampleRing, ringDuration, type FxRingSpec, type FxRingSample } from './fxSim'; - -/** - * Кольцо/волна: расходящаяся или сходящаяся окружность (или ромб — «footprint» - * зоны). repeat > 1 — телеграф зоны (проходы с паузами); repeat: Infinity — - * зацикленный индикатор, гасится только cancel(). Перерисовка каждый тик - * (clear + один stroke) — тот же приём, что у рукописных колец боевых вьюх. - */ -export class FxRing extends Graphics implements Updatable { - private t = 0; - private cancelled = false; - - constructor(private readonly spec: FxRingSpec) { - super(); - this.draw(sampleRing(0, spec)); - } - - /** Досрочно погасить (смена телеграфа, exit сцены). */ - cancel(): void { - if (this.cancelled) return; - this.cancelled = true; - this.destroy(); - } - - /** true, когда все проходы сыграны (или кольцо погашено). */ - get done(): boolean { - return this.cancelled || this.t >= ringDuration(this.spec) - 1e-6; - } - - update(dt: number): void { - if (this.destroyed) return; - this.t += dt; - this.draw(sampleRing(this.t, this.spec)); - if (this.done) this.destroy(); - } - - private draw(s: FxRingSample): void { - this.clear(); - if (this.spec.fill) { - this.shapePath(s.radius).fill({ - color: this.spec.fill, - alpha: (this.spec.fillAlpha ?? 0.25) * s.alpha - }); - } - this.shapePath(s.radius).stroke({ - color: this.spec.color, - width: this.spec.width ?? 1, - alpha: s.alpha - }); - } - - /** Контур зоны текущего радиуса: круг или ромб (квадрат, повёрнутый на 45°). */ - private shapePath(r: number): this { - if (this.spec.shape === 'diamond') { - return this.poly([0, -r, r, 0, 0, r, -r, 0]); - } - return this.circle(0, 0, r); - } -} \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxTrail.ts b/packages/engine/src/render/fx/FxTrail.ts deleted file mode 100644 index 9706d3d..0000000 --- a/packages/engine/src/render/fx/FxTrail.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Container } from 'pixi.js'; -import type { Updatable } from '../../core/Updatables'; -import { ParticleEmitter, type EmitterOptions } from '../Particles'; - -/** - * Шлейф за движущейся вьюхой (снаряды, бегущий герой): эмиттер тянется за - * якорем каждый тик, пока не остановлен. stop() выключает спавн; когда - * последняя частица умрёт, эмиттер уничтожается и эффект заканчивается. - */ -export class FxTrail implements Updatable { - /** Готовый эмиттер (можно дотюнить setOptions — цвет, rate, lifetime). */ - readonly view: ParticleEmitter; - private stopped = false; - private finished = false; - - constructor( - private readonly anchor: Container, - opts: EmitterOptions - ) { - this.view = new ParticleEmitter({ - ...opts, - rate: opts.rate ?? 24, - lifetime: opts.lifetime ?? [0.3, 0.6] - }); - this.view.position.set(anchor.x, anchor.y); - } - - /** Перестать испускать (снаряд погас/умер): хвост самораспадается. */ - stop(): void { - this.stopped = true; - this.view.setOptions({ rate: 0 }); - } - - /** Погасить немедленно (контракт fx: cancel = конец + уничтожить свою вьюху). */ - cancel(): void { - if (this.finished) return; - this.finished = true; - if (!this.view.destroyed) this.view.destroy({ children: true }); - } - - get destroyed(): boolean { - return this.finished || this.anchor.destroyed; - } - - update(dt: number): void { - if (this.finished) return; - if (!this.stopped) this.view.position.set(this.anchor.x, this.anchor.y); - this.view.update(dt); - if (this.stopped && this.view.count === 0) { - this.view.destroy({ children: true }); - this.finished = true; - } - } -} \ No newline at end of file diff --git a/packages/engine/src/render/fx/__tests__/FxLayer.test.ts b/packages/engine/src/render/fx/__tests__/FxLayer.test.ts deleted file mode 100644 index 8cebaf9..0000000 --- a/packages/engine/src/render/fx/__tests__/FxLayer.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Container, Sprite, Texture } from 'pixi.js'; -import { FxLayer } from '../FxLayer'; -import { FxRing } from '../FxRing'; - -const TICK = 1 / 60; - -describe('FxLayer.ring', () => { - it('кольцо доигрывает и удаляется из слоя', () => { - const layer = new FxLayer(); - const ring = layer.ring({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 }, { x: 10, y: 20 }); - expect(layer.children).toContain(ring); - for (let i = 0; i < 24; i++) layer.update(TICK); - expect(ring.destroyed).toBe(true); - expect(layer.children).not.toContain(ring); - }); - - it('зацикленное кольцо не самоуничтожается, cancel() гасит', () => { - const layer = new FxLayer(); - const ring = layer.ring( - { color: 0xff0000, from: 8, to: 96, duration: 0.3, repeat: Infinity }, - { x: 0, y: 0 } - ); - for (let i = 0; i < 120; i++) layer.update(TICK); - expect(ring.destroyed).toBe(false); - ring.cancel(); - expect(ring.destroyed).toBe(true); - }); - - it('родитель-опция: вьюха уходит в переданный контейнер', () => { - const layer = new FxLayer(); - const depth = new Container(); - const ring = layer.ring({ color: 0xffffff, from: 0, to: 10, duration: 0.1 }, { x: 0, y: 0 }, { parent: depth }); - expect(ring.parent).toBe(depth); - }); -}); - -describe('FxLayer.floatText', () => { - it('текст создаётся, поднимается и уничтожается по завершении', () => { - const layer = new FxLayer(); - const fx = layer.floatText('+1', { x: 100, y: 100 }, { duration: 1, rise: 8 }); - expect(layer.children).toContain(fx); - layer.update(TICK); - expect(fx.alpha).toBeGreaterThan(0); - expect(fx.y).toBeLessThanOrEqual(100); - for (let i = 0; i < 60; i++) layer.update(TICK); - expect(fx.destroyed).toBe(true); - expect(layer.children).not.toContain(fx); - }); -}); - -describe('FxLayer.fade', () => { - it('вьюха растворяется и уничтожается', () => { - const layer = new FxLayer(); - const parent = new Container(); - const view = new Container(); - view.alpha = 1; - parent.addChild(view); - layer.fade(view, { duration: 0.5 }); - for (let i = 0; i < 30; i++) layer.update(TICK); - expect(view.destroyed).toBe(true); - expect(view.parent).toBeNull(); - }); - - it('keep: true — вьюха жива с целевой альфой', () => { - const layer = new FxLayer(); - const parent = new Container(); - const view = new Container(); - parent.addChild(view); - layer.fade(view, { duration: 0.5, to: 0.4, keep: true }); - for (let i = 0; i < 30; i++) layer.update(TICK); - expect(view.destroyed).toBe(false); - expect(view.alpha).toBeCloseTo(0.4, 5); - expect(view.parent).toBe(parent); - }); - - it('вьюха, разрушенная извне во время эффекта, не роняет update', () => { - const layer = new FxLayer(); - const view = new Container(); - layer.fade(view, { duration: 1 }); - layer.update(TICK); - view.destroy({ children: true }); - expect(() => layer.update(TICK)).not.toThrow(); - }); - - it('cancel без keep: альфа возвращена, вьюха погашена (контракт fx)', () => { - const layer = new FxLayer(); - const view = new Container(); - view.alpha = 1; - const fx = layer.fade(view, { duration: 1 }); - layer.update(TICK); - fx.cancel(); - expect(view.alpha).toBe(1); - expect(view.destroyed).toBe(true); - }); - - it('cancel с keep: альфа возвращена, вьюха жива (чужая — не наша)', () => { - const layer = new FxLayer(); - const view = new Container(); - view.alpha = 1; - const fx = layer.fade(view, { duration: 1, keep: true }); - layer.update(TICK); - fx.cancel(); - expect(view.alpha).toBe(1); - expect(view.destroyed).toBe(false); - }); -}); - -describe('FxLayer.pop', () => { - it('масштаб возвращается к базовому, вьюха жива', () => { - const layer = new FxLayer(); - const view = new Container(); - view.scale.set(2); - layer.pop(view, { duration: 0.25, fromScale: 0.5 }); - layer.update(TICK); - expect(view.scale.x).toBeGreaterThan(0.5 * 2); // разгоны от базового - for (let i = 0; i < 15; i++) layer.update(TICK); - expect(view.scale.x).toBeCloseTo(2, 5); - expect(view.destroyed).toBe(false); - }); -}); - -describe('FxLayer.flash', () => { - it('повторный флэш по тому же спрайту не дублируется, destroy спрайта чистится', () => { - const layer = new FxLayer(); - const sprite = new Sprite(Texture.WHITE); - layer.flash(sprite, 0xff0000); - layer.flash(sprite, 0xff0000); // перезапуск, а не вторая запись - layer.update(TICK); - expect(sprite.tint).not.toBe(0xffffff); // вспышка применилась - for (let i = 0; i < 20; i++) layer.update(TICK); // 0.15 с — флэш доиграл - expect(sprite.tint).toBe(0xffffff); - sprite.destroy(); - expect(() => layer.update(TICK)).not.toThrow(); - }); -}); - -describe('FxLayer.burst', () => { - it('детерминирован по seed: одинаковые позиции частиц', () => { - const a = new FxLayer({ seed: 42 }); - const b = new FxLayer({ seed: 42 }); - const opts = { rate: 0, lifetime: [0.5, 0.5] as [number, number], radialSpeed: [50, 100] as [number, number] }; - const ea = a.burst(5, { x: 0, y: 0 }, opts); - const eb = b.burst(5, { x: 0, y: 0 }, opts); - a.update(TICK); - b.update(TICK); - expect(ea.children.map((c) => `${c.x},${c.y}`)).toEqual(eb.children.map((c) => `${c.x},${c.y}`)); - }); - - it('эмиттер самоуничтожается, когда частицы умерли', () => { - const layer = new FxLayer({ seed: 1 }); - const fx = layer.burst(2, { x: 0, y: 0 }, { rate: 0, lifetime: [0.2, 0.2] }); - for (let i = 0; i < 20; i++) layer.update(TICK); - expect(fx.destroyed).toBe(true); - }); -}); - -describe('FxLayer.trail', () => { - it('тянет эмиттер за якорем; stop() гасит хвост и завершает эффект', () => { - const layer = new FxLayer(); - const anchor = new Container(); - anchor.position.set(10, 10); - const trail = layer.trail(anchor, { rate: 30, lifetime: [0.1, 0.1], seed: 7 }); - anchor.position.set(50, 30); - layer.update(TICK); - expect(trail.view.x).toBe(50); - trail.stop(); - for (let i = 0; i < 20; i++) layer.update(TICK); - expect(trail.destroyed).toBe(true); - }); - - it('cancel() гасит эмиттер немедленно', () => { - const layer = new FxLayer(); - const anchor = new Container(); - const trail = layer.trail(anchor, { rate: 30, lifetime: [0.1, 0.1], seed: 7 }); - trail.cancel(); - expect(trail.destroyed).toBe(true); - expect(trail.view.destroyed).toBe(true); - }); -}); - -describe('FxLayer.clear', () => { - it('гасит все активные эффекты и флэш-записи', () => { - const layer = new FxLayer(); - const ring = layer.ring({ color: 0xffffff, from: 0, to: 10, duration: 5 }, { x: 0, y: 0 }); - const view = new Container(); - layer.pop(view, { duration: 5 }); - const sprite = new Sprite(Texture.WHITE); - layer.flash(sprite, 0xff0000); - layer.clear(); - expect(ring.destroyed).toBe(true); - expect(view.scale.x).toBe(1); // pop вернул базовый масштаб - layer.update(TICK); // не падает - }); -}); - -describe('FxRing', () => { - it('радиус растёт по спеку, альфа гаснет к концу', () => { - const ring = new FxRing({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 }); - ring.update(0.2); - ring.update(0.2); - expect(ring.done).toBe(true); - expect(ring.destroyed).toBe(true); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/render/fx/__tests__/fxSim.test.ts b/packages/engine/src/render/fx/__tests__/fxSim.test.ts deleted file mode 100644 index 037d172..0000000 --- a/packages/engine/src/render/fx/__tests__/fxSim.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - sampleRing, ringDuration, sampleFloat, readSeconds, sampleFade, samplePop -} from '../fxSim'; -import { quadOut } from '../../../core/easing'; - -describe('sampleRing', () => { - const spec = { color: 0xffffff, from: 8, to: 96, duration: 0.4 }; - - it('границы прохода: t=0 → from/alphaFrom, t=duration → to/alphaTo', () => { - expect(sampleRing(0, spec)).toEqual({ radius: 8, alpha: 1 }); - expect(sampleRing(0.4, spec)).toEqual({ radius: 96, alpha: 0 }); - }); - - it('ease применяется к радиусу, но не к альфе', () => { - const eased = sampleRing(0.2, { ...spec, ease: quadOut }); - // quadOut(0.5) = 0.75 → радиус дальше середины - expect(eased.radius).toBeCloseTo(8 + 88 * 0.75, 5); - expect(eased.alpha).toBeCloseTo(0.5, 5); - }); - - it('repeat с gap: второй проход начинается с from после паузы', () => { - const s = { ...spec, repeat: 3, gap: 0.1 }; - // конец первого прохода + пауза — радиус снова from (начало второго) - const second = sampleRing(0.4 + 0.1 + 0.01, s); - expect(second.radius).toBeCloseTo(8 + 88 * 0.025, 1); - // после всех проходов (3*0.4 + 2*0.1 = 1.4) — конец - expect(ringDuration(s)).toBeCloseTo(1.4, 5); - expect(sampleRing(1.4, s).alpha).toBeCloseTo(0, 5); - }); - - it('repeat: Infinity — бесконечная длительность, циклы повторяются', () => { - const s = { ...spec, repeat: Infinity, gap: 0.1 }; - expect(ringDuration(s)).toBe(Infinity); - // t = 0.5 (заход во второй цикл) — как t = 0.5 - 0.5 цикла... проверяем цикличность - const a = sampleRing(0.4 + 0.1 + 0.0, s); - const b = sampleRing(0.4 + 0.1 + 0.5 + 0.4 + 0.1, s); - expect(a.radius).toBeCloseTo(b.radius, 5); - expect(a.alpha).toBeCloseTo(b.alpha, 5); - }); - - it('отрицательный t клампится к началу', () => { - expect(sampleRing(-1, spec).radius).toBe(8); - }); -}); - -describe('sampleFloat', () => { - it('границы: t=0 → y=0/alpha=0, конец → y=-rise/alpha=0', () => { - const spec = { duration: 1, rise: 8 }; - expect(sampleFloat(0, spec)).toEqual({ x: 0, y: 0, alpha: 0, scale: 1 }); - const end = sampleFloat(1, spec); - expect(end.y).toBeCloseTo(-8, 5); - expect(end.alpha).toBe(0); - }); - - it('в середине — alpha 1, подъём по quadOut', () => { - const s = sampleFloat(0.5, { duration: 1, rise: 8 }); - expect(s.alpha).toBe(1); - expect(s.y).toBeCloseTo(-8 * 0.75, 5); // quadOut(0.5) - }); - - it('hold: плато позиции и альфы до конца hold', () => { - const spec = { duration: 2, hold: 1, rise: 10 }; - const mid = sampleFloat(0.5, spec); - expect(mid.y).toBe(0); - expect(mid.alpha).toBe(1); - }); - - it('driftX смещает x пропорционально прогрессу', () => { - const s = sampleFloat(1, { duration: 1, driftX: 12 }); - expect(s.x).toBeCloseTo(12, 5); - }); - - it('pop: масштаб растёт от fromScale к 1 в течение pop', () => { - const s = sampleFloat(0.15, { duration: 1, pop: 0.3, fromScale: 0.6 }); - expect(s.scale).toBeCloseTo(0.6 + 0.4 * 0.5, 5); - expect(sampleFloat(0.4, { duration: 1, pop: 0.3, fromScale: 0.6 }).scale).toBe(1); - }); -}); - -describe('readSeconds', () => { - it('база + perChar за символ', () => { - expect(readSeconds('')).toBeCloseTo(1.4, 5); - expect(readSeconds('ab')).toBeCloseTo(1.4 + 2 * 0.045, 5); - }); - - it('не больше max', () => { - expect(readSeconds('x'.repeat(500))).toBe(6); - }); -}); - -describe('sampleFade', () => { - it('границы: from → to', () => { - expect(sampleFade(0, 1, { duration: 0.5 })).toBe(1); - expect(sampleFade(0.5, 1, { duration: 0.5 })).toBeCloseTo(0, 5); - }); - - it('delay сдвигает старт', () => { - const spec = { duration: 0.5, delay: 0.25 }; - expect(sampleFade(0.25, 1, spec)).toBe(1); - expect(sampleFade(0.5, 1, spec)).toBeCloseTo(0.25, 5); // половина пути - expect(sampleFade(0.75, 1, spec)).toBeCloseTo(0, 5); - }); - - it('кастомная цель: проявление до 0.8', () => { - expect(sampleFade(1, 0.2, { duration: 1, to: 0.8 })).toBeCloseTo(0.8, 5); - }); -}); - -describe('samplePop', () => { - it('границы: t=0 → fromScale, конец → ровно 1', () => { - expect(samplePop(0, {})).toBeCloseTo(0.5, 5); - expect(samplePop(0.25, {})).toBe(1); - expect(samplePop(10, {})).toBe(1); - }); - - it('пик у overshoot в районе 70% времени', () => { - const peak = samplePop(0.175, { duration: 0.25 }); // u = 0.7 - expect(peak).toBeCloseTo(1.12, 5); - }); - - it('кастомные параметры honored', () => { - const spec = { duration: 1, fromScale: 0.2, overshoot: 1.5 }; - expect(samplePop(0, spec)).toBeCloseTo(0.2, 5); - expect(samplePop(0.7, spec)).toBeCloseTo(1.5, 5); - }); -}); - -describe('чистота сэмплов', () => { - it('двойной прогон даёт одинаковые значения (нет Math.random)', () => { - const ringSpec = { color: 0xd99a32, from: 8, to: 96, duration: 0.4, repeat: 3, gap: 0.1 }; - const floatSpec = { duration: 2, hold: 0.8, driftX: 5, pop: 0.3 }; - const a = [0, 0.13, 0.5, 1.23, 2].map((t) => JSON.stringify(sampleFloat(t, floatSpec))); - const b = [0, 0.2, 0.5, 0.9, 1.4].map((t) => JSON.stringify(sampleRing(t, ringSpec))); - const a2 = [0, 0.13, 0.5, 1.23, 2].map((t) => JSON.stringify(sampleFloat(t, floatSpec))); - const b2 = [0, 0.2, 0.5, 0.9, 1.4].map((t) => JSON.stringify(sampleRing(t, ringSpec))); - expect(a).toEqual(a2); - expect(b).toEqual(b2); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/render/fx/fxSim.ts b/packages/engine/src/render/fx/fxSim.ts deleted file mode 100644 index 09f33fb..0000000 --- a/packages/engine/src/render/fx/fxSim.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Чистая математика one-shot эффектов: сэмплы состояния (радиус/альфа/смещение) - * в момент t. Без Pixi — тестируется в Vitest headless; адаптеры в этом каталоге - * только применяют сэмплы к вьюхам. - */ - -import { backOut, linear, quadOut, type EaseFn } from '../../core/easing'; - -/** Спецификация кольца/волны; repeat > 1 — телеграф зоны. */ -export interface FxRingSpec { - /** Цвет обводки (0xRRGGBB). */ - color: number; - /** Стартовый радиус, px. */ - from: number; - /** Конечный радиус, px. */ - to: number; - /** Секунд на один проход. */ - duration: number; - /** Альфа в начале/конце прохода (по умолчанию 1 → 0). */ - alphaFrom?: number; - alphaTo?: number; - /** Толщина обводки, px (по умолчанию 1). */ - width?: number; - /** Сглаживание радиуса (по умолчанию linear). */ - ease?: EaseFn; - /** Число проходов (по умолчанию 1; Infinity — зацикленный телеграф). */ - repeat?: number; - /** Пауза между проходами, сек (по умолчанию 0). */ - gap?: number; - /** Форма зоны: круг или «footprint»-ромб (квадрат, повёрнутый на 45°). */ - shape?: 'circle' | 'diamond'; - /** Цвет заливки зоны (телеграф); 0 — без заливки. */ - fill?: number; - /** Альфа заливки (по умолчанию 0.25). */ - fillAlpha?: number; -} - -/** Состояние кольца в момент t. */ -export interface FxRingSample { - radius: number; - alpha: number; -} - -/** Радиус и альфа кольца в момент t; repeat > 1 — проходы с паузой gap. */ -export function sampleRing(t: number, spec: FxRingSpec): FxRingSample { - const dur = Math.max(spec.duration, 1e-6); - const gap = spec.gap ?? 0; - const repeat = spec.repeat ?? 1; - const cycle = dur + gap; - const total = repeat === Infinity ? Infinity : repeat * cycle - gap; - // Прошли все проходы — стоим в конце последнего (mod не должен завернуть границу). - const ended = total !== Infinity && t >= total; - const k = repeat === Infinity ? t % cycle : Math.min(t, total); - const u = ended ? 1 : Math.min(1, Math.max(0, (k % cycle) / dur)); - const ease = spec.ease ?? linear; - const aFrom = spec.alphaFrom ?? 1; - const aTo = spec.alphaTo ?? 0; - return { - radius: spec.from + (spec.to - spec.from) * ease(u), - alpha: aFrom + (aTo - aFrom) * u - }; -} - -/** Полная длительность эффекта, сек (Infinity при repeat: Infinity). */ -export function ringDuration(spec: FxRingSpec): number { - const repeat = spec.repeat ?? 1; - if (repeat === Infinity) return Infinity; - return repeat * (spec.duration + (spec.gap ?? 0)) - (spec.gap ?? 0); -} - -/** Спецификация всплывающего текста (урон, тосты, подборы). */ -export interface FxFloatSpec { - /** Полная длительность, сек. */ - duration: number; - /** Плато после подъёма, сек (тосты — «держать, пока читается»). */ - hold?: number; - /** Подъём вверх, px (по умолчанию 8; отрицательный — вниз). */ - rise?: number; - /** Дрейф по X, px (число урона «уезжает» вбок). */ - driftX?: number; - /** Сек нарастания альфы в начале (по умолчанию 0.06). */ - fadeIn?: number; - /** Сек затухания в конце (по умолчанию 0.4). */ - fadeOut?: number; - /** Ease подъёма (по умолчанию quadOut). */ - ease?: EaseFn; - /** Пик овершута масштаба (0 — без «пружинки»). */ - pop?: number; - /** Стартовый масштаб при pop (по умолчанию 0.6). */ - fromScale?: number; -} - -/** Состояние текста в момент t (x/y — смещение от стартовой точки). */ -export interface FxFloatSample { - x: number; - y: number; - alpha: number; - scale: number; -} - -/** Положение, альфа и масштаб всплывающего текста в момент t. */ -export function sampleFloat(t: number, spec: FxFloatSpec): FxFloatSample { - const rise = spec.rise ?? 8; - const fadeIn = spec.fadeIn ?? 0.06; - const fadeOut = spec.fadeOut ?? 0.4; - const hold = spec.hold ?? 0; - const travel = Math.max(spec.duration - hold, 1e-6); - const u = Math.min(1, Math.max(0, (t - hold) / travel)); - const ease = spec.ease ?? quadOut; - const alphaIn = Math.min(1, t / Math.max(fadeIn, 1e-6)); - const alphaOut = Math.min(1, (spec.duration - t) / Math.max(fadeOut, 1e-6)); - const pop = spec.pop ?? 0; - const fromScale = spec.fromScale ?? 0.6; - const scale = pop > 0 - ? fromScale + (1 - fromScale) * Math.min(1, t / Math.max(pop, 1e-6)) - : 1; - return { - x: (spec.driftX ?? 0) * u, - y: 0 - rise * ease(u), // 0 − (…) вместо -(…): не даём «минус ноль» на старте - alpha: Math.max(0, Math.min(alphaIn, alphaOut)), - scale - }; -} - -/** - * Время чтения строки (для тостов): base + perChar*len, не больше max. - * Дефолты совпадают с историческим тостом демо: 1.4 + 0.045/символ, не дольше 6. - */ -export function readSeconds( - text: string, - opts?: { base?: number; perChar?: number; max?: number } -): number { - const base = opts?.base ?? 1.4; - const perChar = opts?.perChar ?? 0.045; - const max = opts?.max ?? 6; - return Math.min(max, base + text.length * perChar); -} - -/** Спецификация растворения/проявления произвольной вьюхи. */ -export interface FxFadeSpec { - /** Секунд до конца. */ - duration: number; - /** Целевая альфа (по умолчанию 0 — растворение). */ - to?: number; - /** Задержка старта, сек. */ - delay?: number; - /** Ease альфы (по умолчанию quadOut). */ - ease?: EaseFn; -} - -/** Альфа вьюхи в момент t: from → to за duration после delay. */ -export function sampleFade(t: number, from: number, spec: FxFadeSpec): number { - const delay = spec.delay ?? 0; - const u = Math.min(1, Math.max(0, (t - delay) / Math.max(spec.duration, 1e-6))); - const ease = spec.ease ?? quadOut; - const to = spec.to ?? 0; - return from + (to - from) * ease(u); -} - -/** Спецификация появления с овершутом (pop/squash). */ -export interface FxPopSpec { - /** Секунд (по умолчанию 0.25). */ - duration?: number; - /** Стартовый множитель масштаба (по умолчанию 0.5). */ - fromScale?: number; - /** Пик овершута (по умолчанию 1.12; 1 — без пружинки). */ - overshoot?: number; - /** Ease масштаба (по умолчанию backOut). */ - ease?: EaseFn; -} - -/** Множитель масштаба в момент t (1 в покое): from → overshoot → 1. */ -export function samplePop(t: number, spec: FxPopSpec = {}): number { - const dur = spec.duration ?? 0.25; - const from = spec.fromScale ?? 0.5; - const over = spec.overshoot ?? 1.12; - const ease = spec.ease ?? backOut; - const u = Math.min(1, Math.max(0, t / Math.max(dur, 1e-6))); - if (u >= 1) return 1; - // Две фазы: разгон from → overshoot (70% времени), затем плавный возврат к 1. - const peakT = 0.7; - if (u < peakT) { - return from + (over - from) * ease(u / peakT); - } - const settle = (u - peakT) / (1 - peakT); - return over + (1 - over) * settle; -} \ No newline at end of file diff --git a/packages/engine/src/render/fx/particlePresets.ts b/packages/engine/src/render/fx/particlePresets.ts deleted file mode 100644 index 88522f3..0000000 --- a/packages/engine/src/render/fx/particlePresets.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { EmitterOptions } from '../Particles'; -import { mergeEmitterOptions } from '../particleSim'; - -/** - * Готовые наборы EmitterOptions для частых one-shot эффектов. Каждая фабрика — - * детерминированные дефолты + переопределения вызывающего (mergeEmitterOptions): - * `oneShot(10, sparkPreset(0xf2b45a, { seed }))`. Цвет — обязательный первый - * аргумент там, где он определяет смысл эффекта (искры, угольки). - */ - -/** Искры удара: быстрые разлетающиеся, тормозят, светятся (add-блендинг). */ -export function sparkPreset(color: number, over?: Partial): EmitterOptions { - return mergeEmitterOptions({ - color, - rate: 0, - lifetime: [0.2, 0.45], - radialSpeed: [70, 170], - drag: 2.5, - size: [1, 2], - blend: 'add' - }, over); -} - -/** Дым/пепел: медленный подъём, рост и растворение, без блендинга. */ -export function smokePreset(color: number, over?: Partial): EmitterOptions { - return mergeEmitterOptions({ - color, - rate: 0, - lifetime: [0.6, 1.2], - velocity: { x: [-8, 8], y: [-24, -10] }, - acceleration: { y: 6 }, - size: [2, 4], - scaleOverLife: 'grow' - }, over); -} - -/** Угольки/мотыльки: медленный дрейф вверх с синус-качанием, тёплый свет. */ -export function emberPreset(color: number, over?: Partial): EmitterOptions { - return mergeEmitterOptions({ - color, - rate: 0, - lifetime: [1.0, 2.0], - velocity: { x: [-10, 10], y: [-18, -6] }, - wobble: 6, - size: 1, - fadeIn: 0.3, - blend: 'add' - }, over); -} - -/** Пыль от шага/приземления: короткая, низкая, быстро тормозит. */ -export function dustPreset(color: number, over?: Partial): EmitterOptions { - return mergeEmitterOptions({ - color, - rate: 0, - lifetime: [0.25, 0.5], - velocity: { x: [-14, 14], y: [-6, -2] }, - drag: 2, - size: [1, 2] - }, over); -} \ No newline at end of file diff --git a/packages/engine/src/render/lightSim.ts b/packages/engine/src/render/lightSim.ts deleted file mode 100644 index 37fcaec..0000000 --- a/packages/engine/src/render/lightSim.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Чистая математика освещения — без Pixi, тестируется в Vitest. - * Lighting использует эти функции для кадров источников и лерпа ambient. - */ - -import { lerpColor } from '../anim/flash'; - -/** Базовый радиус текстуры свечения в px — на него нормируется scale спрайта. */ -export const GLOW_BASE_PX = 32; - -/** - * Источник света в экранных пикселях (позицию считает сцена через Camera.toScreen — - * движок ничего не знает об изометрии). - */ -export interface LightDef { - id: string; - x: number; - y: number; - /** Цвет свечения 0xRRGGBB. */ - color: number; - /** Яркость: 0..~1.5 (по умолчанию 1). */ - intensity?: number; - /** Радиус гало в px (по умолчанию 2 * GLOW_BASE_PX). */ - radius?: number; - /** Амплитуда мерцания 0..1 (0 — ровный свет). */ - flicker?: number; - /** Период мерцания, сек (по умолчанию 0.9). */ - flickerPeriod?: number; - /** Фаза мерцания — детерминизм между запусками. */ - seed?: number; - /** Выключенный источник не рисуется (кадр с alpha 0). */ - enabled?: boolean; -} - -/** Видимое состояние источника на шаге. */ -export interface LightFrame { - id: string; - x: number; - y: number; - tint: number; - alpha: number; - scale: number; -} - -/** - * Детерминированное мерцание: сумма двух синусов с фазой от seed. - * Одинаковые (time, seed) — одинаковый результат; диапазон [1-amount, 1]. - */ -export function flickerFactor(time: number, seed: number, amount: number, period: number): number { - if (amount <= 0) return 1; - const p = period > 0 ? period : 0.9; - const f = time / p + seed * Math.PI * 2; - const wave = (Math.sin(f) + Math.sin(f * 2.37 + seed * 5)) * 0.5; // -1..1 - return 1 - amount * (wave * 0.5 + 0.5); -} - -/** Кадр источника: чистая функция, без Pixi и без мутаций. */ -export function lightFrame(def: LightDef, time: number, out?: LightFrame): LightFrame { - const intensity = def.intensity ?? 1; - const radius = def.radius ?? 2 * GLOW_BASE_PX; - const enabled = def.enabled ?? true; - const alpha = enabled - ? intensity * flickerFactor(time, def.seed ?? 0, def.flicker ?? 0, def.flickerPeriod ?? 0.9) - : 0; - const f: LightFrame = out ?? { id: '', x: 0, y: 0, tint: 0xffffff, alpha: 0, scale: 1 }; - f.id = def.id; - f.x = def.x; - f.y = def.y; - f.tint = def.color; - f.alpha = alpha; - f.scale = radius / GLOW_BASE_PX; - return f; -} - -/** - * Огибающая импульса (вспышки): трапеция «рост → плато → спад». - * attack/decay — сек; hold — плато на пике (по умолчанию 0). - */ -export interface PulseSpec { - /** Рост 0→1, сек (0 — мгновенный пик). */ - attack: number; - /** Плато на пике, сек (по умолчанию 0). */ - hold?: number; - /** Спад 1→0, сек. */ - decay: number; -} - -/** Полная длительность импульса, сек. */ -export function pulseTotal(spec: PulseSpec): number { - return Math.max(0, spec.attack) + Math.max(0, spec.hold ?? 0) + Math.max(0, spec.decay); -} - -/** Огибающая импульса в момент t от старта: 0..1, вне [0, total] — 0. */ -export function pulseEnvelope(t: number, spec: PulseSpec): number { - const attack = Math.max(0, spec.attack); - const hold = Math.max(0, spec.hold ?? 0); - const decay = Math.max(0, spec.decay); - if (t <= 0 || t >= attack + hold + decay) return 0; - if (t < attack) return t / attack; - if (t < attack + hold) return 1; - return 1 - (t - attack - hold) / decay; -} - -// --- тьма с прорезями: ступенчатые профили текстур света --- - -/** Ступень круговой текстуры: внешнее кольцо и его краска. */ -export interface RingStep { - /** Радиус кольца в px (текстура радиусом GLOW_BASE_PX). */ - radius: number; - /** Альфа краски кольца (кольца рисуются в порядке массива, снаружи внутрь). */ - paintAlpha: number; -} - -/** - * Ступени из накопленного профиля A (изнутри наружу, A[0] — центр): - * радиус кольца k — GLOW_BASE_PX·k/n, краска p_k = 1 − (1−A_k)/(1−A_{k+1}) — - * чтобы перекрытие колец (рисование снаружи внутрь, source-over) дало в - * кольце k ровно накопленную A_k. Возвращает в порядке рисования. - */ -export function stepAlphas(profile: readonly number[]): RingStep[] { - const n = profile.length; - const out: RingStep[] = []; - let nextA = 0; // накопление за внутренним краем текущего кольца - for (let k = n; k >= 1; k--) { - const a = Math.max(0, Math.min(1, profile[k - 1])); - const paint = nextA >= 1 ? 0 : Math.max(0, 1 - (1 - a) / (1 - nextA)); - out.push({ radius: (GLOW_BASE_PX * k) / n, paintAlpha: paint }); - nextA = a; - } - return out; -} - -/** - * Профиль прорези тьмы: накопленная сила стирания тьмы изнутри наружу - * (7 ступеней: центр — тьма снята полностью, край — едва тронута). - */ -export const HOLE_PROFILE: readonly number[] = [1, 0.95, 0.82, 0.62, 0.4, 0.2, 0.08]; - -/** Профиль тона: приглушённое ядро, максимум на среднем кольце (6 ступеней). */ -export const TONE_PROFILE: readonly number[] = [0.35, 0.45, 0.4, 0.3, 0.18, 0.08]; - -/** Ступени текстуры прорези (erase-карта силы тьмы). */ -export function holeStepAlphas(): RingStep[] { - return stepAlphas(HOLE_PROFILE); -} - -/** Ступени текстуры тона (аддитивная подсветка вокруг прорези). */ -export function toneStepAlphas(): RingStep[] { - return stepAlphas(TONE_PROFILE); -} - -/** Шаг квантования силы прорези: меньше перерисовок RT, мерцание «ступеньками». */ -export const HOLE_ALPHA_STEPS = 8; - -/** Квантование альфы стирания до 1/HOLE_ALPHA_STEPS — детерминизм bake. */ -export function quantizeHoleAlpha(alpha: number): number { - const a = Math.max(0, Math.min(1, alpha)); - return Math.round(a * HOLE_ALPHA_STEPS) / HOLE_ALPHA_STEPS; -} - -/** Лерп ambient-цвета по каналам: k=0 → from, k=1 → to. */ -export function lerpAmbient(from: number, to: number, k: number): number { - return lerpColor(from, to, k); -} - -/** Яркость ambient-цвета: dimColor(c, 1) === c, dimColor(c, 0) === 0x000000. */ -export function dimColor(color: number, brightness: number): number { - const k = Math.max(0, Math.min(1, brightness)); - const r = Math.round(((color >> 16) & 0xff) * k); - const g = Math.round(((color >> 8) & 0xff) * k); - const b = Math.round((color & 0xff) * k); - return (r << 16) | (g << 8) | b; -} - -/** - * Границы суток в часах: рассвет dawn..day (ночь → день), - * день day..dusk, закат dusk..night (день → ночь), ночь night..dawn. - */ -export interface DayNightSpec { - dawn: number; - day: number; - dusk: number; - night: number; -} - -/** Дефолтная кривая игры: рассвет 06–08, закат 18–20, ночь 20–06. */ -export const DEFAULT_DAY_NIGHT: DayNightSpec = { dawn: 6, day: 8, dusk: 18, night: 20 }; - -/** Доля перехода день→ночь, на которой пик золотого часа. */ -export const DUSK_PEAK = 0.45; - -/** Дефолтный закатный тон (тёпло-золотой) — игра может переопределить. */ -export const DEFAULT_DUSK_COLOR = 0xe8c090; - -/** - * Трёхопорный микс суток по фактору ночи k: день → золотой час → ночь - * (рассвет проходит через тот же тёплый тон — это ожидаемо). Без к≥1/≤0 - * — крайние опоры; непрерывность и монотонность по k сохранены. - */ -export function dayNightAmbient( - day: number, - night: number, - k: number, - duskColor: number = DEFAULT_DUSK_COLOR -): number { - if (k <= 0) return day; - if (k >= 1) return night; - if (k < DUSK_PEAK) return lerpAmbient(day, duskColor, k / DUSK_PEAK); - return lerpAmbient(duskColor, night, (k - DUSK_PEAK) / (1 - DUSK_PEAK)); -} - -/** - * Фактор ночи 0..1 по времени суток (часы, mod 24): 0 — день, 1 — ночь, - * линейный переход на рассвете и закате. Непрерывен на стыке 24/0. - */ -export function dayNightFactor(tHours: number, spec: DayNightSpec = DEFAULT_DAY_NIGHT): number { - const t = ((tHours % 24) + 24) % 24; - if (t >= spec.night || t < spec.dawn) return 1; - if (t < spec.day) return 1 - (t - spec.dawn) / (spec.day - spec.dawn); - if (t < spec.dusk) return 0; - return (t - spec.dusk) / (spec.night - spec.dusk); -} \ No newline at end of file diff --git a/packages/engine/src/render/particleSim.ts b/packages/engine/src/render/particleSim.ts deleted file mode 100644 index 502386c..0000000 --- a/packages/engine/src/render/particleSim.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Чистая симуляция частицы — без Pixi, тестируется в Vitest. - * ParticleEmitter использует эти функции для интеграции и спавна. - */ - -import type { Rng } from '../math/rng'; -import { lerpColor } from '../anim/flash'; -import type { EmitterOptions } from './Particles'; - -/** Состояние частицы без привязки к отображению. */ -export interface ParticleState { - x: number; - y: number; - vx: number; - vy: number; - age: number; - /** Секунд до смерти. */ - lifetime: number; - /** Угол (рад) и скорость вращения (рад/сек). */ - rot: number; - spin: number; - /** Базовый размер: px для квадратов, множитель для текстурных частиц. */ - scale: number; - /** Цвет, выбранный при спавне (из color/colors). */ - tint: number; - /** Персональное «зерно» 0..1: фаза wobble и прочая вариация. */ - k: number; -} - -/** Опции симуляции — общие для всех частиц эмиттера. */ -export interface ParticleSimOptions { - acceleration?: { x?: number; y?: number }; - /** Затухание альфы: true — вся жизнь; число — хвост в секундах. */ - fadeOut?: boolean | number; - /** Сек нарастания альфы в начале жизни. */ - fadeIn?: number; - /** Торможение, 1/сек: v *= max(0, 1 - drag*dt). */ - drag?: number; - /** Масштаб по жизни: явный диапазон или предустановка. */ - scaleOverLife?: [number, number] | 'shrink' | 'grow'; - /** Цвет по жизни: лерп 0xRRGGBB от начала к концу. */ - colorOverLife?: [number, number]; - /** Амплитуда синус-дрейфа по X, px/сек (мотыльки, планктон). */ - wobble?: number; -} - -/** Видимая часть частицы на шаге (базовый размер уже учтён в scale). */ -export interface ParticleVisual { - alpha: number; - scale: number; - rotation: number; - tint: number; -} - -/** - * Один шаг интеграции (полу-неявный Эйлер) + видимые кривые по жизни. - * Мутирует p (позиция/скорость/угол/возраст), возвращает визуал. - * out — переиспользуемый визуал (горячий путь: без аллокации на частицу/тик); - * без out — свежий объект. - */ -export function stepParticle(p: ParticleState, dt: number, opts: ParticleSimOptions, out?: ParticleVisual): ParticleVisual { - p.age += dt; - const drag = Math.max(0, 1 - (opts.drag ?? 0) * dt); - p.vx *= drag; - p.vy *= drag; - p.vx += (opts.acceleration?.x ?? 0) * dt; - p.vy += (opts.acceleration?.y ?? 0) * dt; - const wobble = opts.wobble ? Math.sin(p.age * 3 + p.k * Math.PI * 2) * opts.wobble : 0; - p.x += (p.vx + wobble) * dt; - p.y += p.vy * dt; - p.rot += p.spin * dt; - - // Альфа: нарастание в начале × затухание в конце. - const fadeIn = opts.fadeIn ? Math.min(1, p.age / opts.fadeIn) : 1; - const fadeTail = opts.fadeOut === false ? 1 - : opts.fadeOut === true || opts.fadeOut === undefined - ? 1 - p.age / p.lifetime - : Math.min(1, (p.lifetime - p.age) / opts.fadeOut); - const alpha = Math.max(0, Math.min(fadeIn, fadeTail)); - - // Масштаб: кривая по жизни, умноженная на базовый размер частицы. - const lifeK = Math.min(1, p.age / p.lifetime); - const curve = opts.scaleOverLife === 'shrink' - ? 1 - lifeK - : opts.scaleOverLife === 'grow' - ? lifeK - : opts.scaleOverLife - ? opts.scaleOverLife[0] + (opts.scaleOverLife[1] - opts.scaleOverLife[0]) * lifeK - : 1; - - const v: ParticleVisual = out ?? { alpha: 0, scale: 1, rotation: 0, tint: 0xffffff }; - v.alpha = alpha; - v.scale = Math.max(0, p.scale * curve); - v.rotation = p.rot; - v.tint = opts.colorOverLife ? lerpColor(opts.colorOverLife[0], opts.colorOverLife[1], lifeK) : p.tint; - return v; -} - -/** Случайная начальная частица по опциям эмиттера (позиция относительно центра). */ -export function sampleSpawn(options: EmitterOptions, rng: Rng): ParticleState { - const area = options.spawnArea; - const v = options.velocity; - return { - x: area ? rng.int(0, area.width) - area.width / 2 : 0, - y: area ? rng.int(0, area.height) - area.height / 2 : 0, - vx: v ? v.x[0] + rng.next() * (v.x[1] - v.x[0]) : 0, - vy: v ? v.y[0] + rng.next() * (v.y[1] - v.y[0]) : 0, - age: 0, - lifetime: - options.lifetime[0] + rng.next() * (options.lifetime[1] - options.lifetime[0]), - rot: sampleRange(options.rotation, rng), - spin: sampleRange(options.spin, rng), - scale: options.scale ?? 1, - tint: options.colors ? options.colors[rng.int(0, options.colors.length - 1)] : options.color ?? 0xffffff, - k: rng.next() - }; -} - -/** - * Случайный «взрыв»: частицы разлетаются из точки по кругу. - * speed — (min..max) пикселей/сек; spin/scale — опциональная вариация. - */ -export function sampleBurst( - count: number, - speed: [number, number], - lifetime: [number, number], - rng: Rng, - opts: { spin?: [number, number]; scale?: number; colors?: number[]; color?: number } = {} -): ParticleState[] { - const out: ParticleState[] = []; - for (let i = 0; i < count; i++) { - const angle = rng.next() * Math.PI * 2; - const v = speed[0] + rng.next() * (speed[1] - speed[0]); - out.push({ - x: 0, - y: 0, - vx: Math.cos(angle) * v, - vy: Math.sin(angle) * v, - age: 0, - lifetime: lifetime[0] + rng.next() * (lifetime[1] - lifetime[0]), - rot: sampleRange(opts.spin, rng) + angle, - spin: 0, - scale: opts.scale ?? 1, - tint: opts.colors ? opts.colors[rng.int(0, opts.colors.length - 1)] : opts.color ?? 0xffffff, - k: rng.next() - }); - } - return out; -} - -/** Слияние базовых опций с переопределением burst-а — чистое, без мутации. */ -export function mergeEmitterOptions(base: EmitterOptions, over?: Partial): EmitterOptions { - return over ? { ...base, ...over } : { ...base }; -} - -function sampleRange(range: [number, number] | undefined, rng: Rng): number { - return range ? range[0] + rng.next() * (range[1] - range[0]) : 0; -} \ No newline at end of file diff --git a/packages/engine/src/render/postfx/PostFx.ts b/packages/engine/src/render/postfx/PostFx.ts deleted file mode 100644 index 53241f6..0000000 --- a/packages/engine/src/render/postfx/PostFx.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * Пост-эффекты: один кастомный фильтр на корне мира — переменный блюр - * (глобальный и точечный с радиусом), хроматическая аберрация, марево. - * API по образцу Lighting: состояние set* + затухающие импульсы pulse* - * (огибающая — pulseEnvelope из postSim). Координаты точек — виртуальные - * экранные пиксели (текстура фильтра выровнена по вьюпорту, камера уже - * учтена); радиусы — в тех же пикселях. blurObject — отдельный BlurFilter - * на конкретной вьюхе (не участвует в общем фильтре). - */ -import { BlurFilter, Container, Filter, GlProgram, UniformGroup } from 'pixi.js'; -import { blurKernel, pulseEnvelope, pulseTotal, type PulseSpec, type ShimmerParams } from './postSim'; - -const VERT = `in vec2 aPosition; -out vec2 vTextureCoord; -uniform vec4 uInputSize; -uniform vec4 uOutputFrame; -uniform vec4 uOutputTexture; -vec4 filterVertexPosition(vec2 aPosition) -{ - vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy; - position.x = position.x * (2.0 / uOutputTexture.x) - 1.0; - position.y = position.y * (2.0 * uOutputTexture.z / uOutputTexture.y) - uOutputTexture.z; - return vec4(position, 0.0, 1.0); -} -vec2 filterTextureCoord(vec2 aPosition) -{ - return aPosition * (uOutputFrame.zw * uInputSize.zw); -} -void main(void) -{ - gl_Position = filterVertexPosition(aPosition); - vTextureCoord = filterTextureCoord(aPosition); -}`; - -// Ядро блюра (постоянное): спираль золотого сечения из postSim, развёрнутая -// в явные сэмплы — const-массивы недопустимы в GLSL ES 1.00 (WebGL1-фолбэк). -const KERNEL = blurKernel(8) - .map(([x, y]) => `acc += texture(uTexture, base + vec2(${x.toFixed(4)}, ${y.toFixed(4)}) * s / uViewport);`) - .join('\n '); - -const FRAG = `in vec2 vTextureCoord; -out vec4 finalColor; -uniform sampler2D uTexture; -uniform vec2 uViewport; -uniform float uBlurStrength; -uniform vec2 uBlurCenter; -uniform float uBlurRadius; -uniform float uAberration; -uniform float uShimmerAmp; -uniform vec2 uShimmerCenter; -uniform float uShimmerRadius; -uniform float uShimmerWl; -uniform float uShimmerSpeed; -uniform float uTime; -// Спад силы от точки (гладкая ступень, как в postSim); радиус <= 0 — глобально. -float fall(vec2 px, vec2 center, float radius) -{ - if (radius <= 0.0) return 1.0; - float t = clamp(1.0 - length(px - center) / radius, 0.0, 1.0); - return t * t * (3.0 - 2.0 * t); -} -void main(void) -{ - vec2 px = vTextureCoord * uViewport; - vec2 base = vTextureCoord; - if (uShimmerAmp > 0.001) { - float dy = uShimmerAmp * sin((px.x / uShimmerWl + uTime * uShimmerSpeed) * 6.2831853); - base.y += (dy * fall(px, uShimmerCenter, uShimmerRadius)) / uViewport.y; - } - if (uAberration > 0.001) { - // Радиальный сдвиг каналов от центра экрана: R наружу, B внутрь. - vec2 c = uViewport * 0.5; - vec2 d = px - c; - vec2 dir = length(d) > 0.0001 ? normalize(d) : vec2(0.0); - float r = texture(uTexture, base + dir * (uAberration / uViewport.x)).r; - vec4 g = texture(uTexture, base); - float b = texture(uTexture, base - dir * (uAberration / uViewport.x)).b; - finalColor = vec4(r, g.g, b, g.a); - return; - } - if (uBlurStrength > 0.001) { - float s = fall(px, uBlurCenter, uBlurRadius) * uBlurStrength; - vec4 acc = vec4(0.0); - ${KERNEL} - finalColor = acc / 8.0; - return; - } - finalColor = texture(uTexture, base); -}`; - -/** Состояние блюра: сила (px выборки), точка и радиус (px; радиус <= 0 — вся сцена). */ -export interface BlurState { - strength: number; - center?: { x: number; y: number }; - radius?: number; -} - -/** Марево: параметры из postSim + опорная зона (вне зоны амплитуда 0). */ -export type ShimmerState = ShimmerParams & { center?: { x: number; y: number }; radius?: number }; - -interface PulseEntry { - elapsed: number; - spec: PulseSpec; - blur?: BlurState; - aberration?: number; -} - -export class PostFx { - /** Фильтр — вешается на корень мира (Renderer.worldRoot.filters). */ - readonly filter: Filter; - private readonly worldRoot: Container; - private readonly viewportX: number; - private readonly viewportY: number; - private readonly uniforms: UniformGroup; - - private time = 0; - private blur: BlurState | null = null; - private aberration = 0; - private shimmer: ShimmerState | null = null; - private readonly pulses = new Map(); - private pulseSeq = 0; - private readonly objectBlurs = new Map(); - private disposed = false; - - constructor(worldRoot: Container, viewportX: number, viewportY: number) { - this.worldRoot = worldRoot; - this.viewportX = viewportX; - this.viewportY = viewportY; - this.uniforms = new UniformGroup({ - uViewport: { value: new Float32Array([viewportX, viewportY]), type: 'vec2' }, - uBlurStrength: { value: 0, type: 'f32' }, - uBlurCenter: { value: new Float32Array([viewportX / 2, viewportY / 2]), type: 'vec2' }, - uBlurRadius: { value: 0, type: 'f32' }, - uAberration: { value: 0, type: 'f32' }, - uShimmerAmp: { value: 0, type: 'f32' }, - uShimmerCenter: { value: new Float32Array([viewportX / 2, viewportY / 2]), type: 'vec2' }, - uShimmerRadius: { value: 0, type: 'f32' }, - uShimmerWl: { value: 40, type: 'f32' }, - uShimmerSpeed: { value: 1, type: 'f32' }, - uTime: { value: 0, type: 'f32' } - }); - this.filter = new Filter({ - glProgram: GlProgram.from({ vertex: VERT, fragment: FRAG }), - resources: { postFxUniforms: this.uniforms }, - // inherit: текстура фильтра в разрешении рендера — пиксель-арт не мылится. - resolution: 'inherit', - padding: 0 - }); - worldRoot.filters = [this.filter]; - } - - /** Блюр сцены/точки: null — выключить. Импульсы поверх (см. pulseBlur). */ - setBlur(args: BlurState | null): void { - this.blur = args ? { ...args } : null; - } - - /** Импульс блюра с огибающей (attack/hold/decay, сек) — удар, резонанс. */ - pulseBlur(args: BlurState, spec: PulseSpec): void { - const id = `blur#${this.pulseSeq++}`; - this.pulses.set(id, { elapsed: 0, spec, blur: { ...args } }); - } - - /** Хроматическая аберрация, px сдвига каналов (0 — выкл). */ - setAberration(px: number): void { - this.aberration = px; - } - - /** Импульс аберрации — урон, вспышки. */ - pulseAberration(px: number, spec: PulseSpec): void { - const id = `aberr#${this.pulseSeq++}`; - this.pulses.set(id, { elapsed: 0, spec, aberration: px }); - } - - /** Марево (горячий воздух); null — выключить. */ - setShimmer(args: ShimmerState | null): void { - this.shimmer = args ? { ...args } : null; - } - - /** Блюр конкретной вьюхи (отдельный BlurFilter, не общий фильтр). */ - blurObject(view: Container, strength = 3): void { - const existing = this.objectBlurs.get(view); - if (existing) { - existing.strength = strength; - return; - } - const blur = new BlurFilter({ strength, quality: 2, kernelSize: 5 }); - this.objectBlurs.set(view, blur); - view.filters = [...(view.filters ?? []), blur]; - } - - /** Снять объектный блюр. */ - clearBlurObject(view: Container): void { - const blur = this.objectBlurs.get(view); - if (!blur) return; - this.objectBlurs.delete(view); - view.filters = (view.filters ?? []).filter((f) => f !== blur); - } - - /** Сброс состояний и импульсов (объектный блюр не трогает). */ - clear(): void { - this.blur = null; - this.aberration = 0; - this.shimmer = null; - this.pulses.clear(); - } - - /** Тик: время марева, затухание импульсов, перенос состояния в шейдер. */ - update(dt: number): void { - if (this.disposed) return; - this.time += dt; - for (const [id, p] of [...this.pulses]) { - p.elapsed += dt; - if (p.elapsed >= pulseTotal(p.spec)) this.pulses.delete(id); - } - // Эффективные значения: импульс перекрывает установленное состояние. - let blur = this.blur; - let aberration = this.aberration; - for (const p of this.pulses.values()) { - const env = pulseEnvelope(p.elapsed, p.spec); - if (p.blur && env > 0) blur = { ...p.blur, strength: p.blur.strength * env }; - if (p.aberration !== undefined && env > 0) aberration = Math.max(aberration, p.aberration * env); - } - // uniforms-карта v8 даёт сами значения (не обёртки {value}). - const u = this.uniforms.uniforms; - u.uTime = this.time; - u.uBlurStrength = blur ? blur.strength : 0; - (u.uBlurCenter as Float32Array).set([ - blur?.center?.x ?? this.viewportX / 2, - blur?.center?.y ?? this.viewportY / 2 - ]); - u.uBlurRadius = blur?.radius ?? 0; - u.uAberration = aberration; - u.uShimmerAmp = this.shimmer ? this.shimmer.amp : 0; - (u.uShimmerCenter as Float32Array).set([ - this.shimmer?.center?.x ?? this.viewportX / 2, - this.shimmer?.center?.y ?? this.viewportY / 2 - ]); - u.uShimmerRadius = this.shimmer?.radius ?? 0; - u.uShimmerWl = this.shimmer?.wavelength ?? 40; - u.uShimmerSpeed = this.shimmer?.speed ?? 1; - this.filter.enabled = - (blur !== null && blur.strength > 0.001) || - aberration > 0.001 || - (this.shimmer !== null && this.shimmer.amp > 0.001) || - this.pulses.size > 0; - } - - /** Снять фильтр с корня мира и объектные блюры. */ - dispose(): void { - this.disposed = true; - this.clear(); - for (const view of [...this.objectBlurs.keys()]) this.clearBlurObject(view); - this.worldRoot.filters = (this.worldRoot.filters ?? []).filter((f) => f !== this.filter); - } -} \ No newline at end of file diff --git a/packages/engine/src/render/postfx/__tests__/postSim.test.ts b/packages/engine/src/render/postfx/__tests__/postSim.test.ts deleted file mode 100644 index ffc289f..0000000 --- a/packages/engine/src/render/postfx/__tests__/postSim.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - aberrationShiftAt, - blurKernel, - blurStrengthAt, - pulseEnvelope, - pulseTotal, - shimmerOffsetY -} from '../postSim'; - -describe('blurStrengthAt', () => { - it('глобальный режим: сила постоянна', () => { - expect(blurStrengthAt(0, 0, 4)).toBe(4); - expect(blurStrengthAt(1000, 0, 4)).toBe(4); - }); - - it('пик в центре, гладкий спад до нуля на радиусе', () => { - expect(blurStrengthAt(0, 100, 4)).toBe(4); - expect(blurStrengthAt(100, 100, 4)).toBe(0); - expect(blurStrengthAt(150, 100, 4)).toBe(0); // за радиусом — 0 - const mid = blurStrengthAt(50, 100, 4); - expect(mid).toBeGreaterThan(0); - expect(mid).toBeLessThan(4); - }); - - it('спад симметричен и монотонен', () => { - let prev = Infinity; - for (let d = 0; d <= 100; d += 5) { - const v = blurStrengthAt(d, 100, 4); - expect(v).toBeLessThanOrEqual(prev); - prev = v; - } - }); -}); - -describe('blurKernel', () => { - it('смещения в пределах единичного круга и различны', () => { - const k = blurKernel(8); - expect(k.length).toBe(8); - const seen = new Set(k.map(([x, y]) => `${x.toFixed(4)},${y.toFixed(4)}`)); - expect(seen.size).toBe(8); - for (const [x, y] of k) { - expect(Math.hypot(x, y)).toBeLessThanOrEqual(1); - } - }); -}); - -describe('aberrationShiftAt', () => { - it('тот же профиль спада, что у блюра', () => { - expect(aberrationShiftAt(0, 100, 2)).toBe(2); - expect(aberrationShiftAt(100, 100, 2)).toBe(0); - expect(aberrationShiftAt(0, 0, 2)).toBe(2); // глобальный режим - }); -}); - -describe('shimmerOffsetY', () => { - it('периодичен по X и по времени', () => { - const p = { amp: 1.5, wavelength: 40, speed: 2 }; - expect(shimmerOffsetY(0, 0, p)).toBeCloseTo(0); - expect(shimmerOffsetY(40, 0, p)).toBeCloseTo(0); // период по X = wavelength - expect(shimmerOffsetY(0, 0.5, p)).toBeCloseTo(0); // период по t = 1/speed - expect(Math.abs(shimmerOffsetY(10, 0, p))).toBeCloseTo(p.amp); // четверть волны - }); -}); - -describe('переэкспорт pulseEnvelope', () => { - it('атака/плато/спад сходятся', () => { - const spec = { attack: 0.1, hold: 0.2, decay: 0.3 }; - expect(pulseTotal(spec)).toBeCloseTo(0.6); - expect(pulseEnvelope(0.05, spec)).toBeCloseTo(0.5); - expect(pulseEnvelope(0.2, spec)).toBe(1); - expect(pulseEnvelope(0.45, spec)).toBeCloseTo(0.5); - expect(pulseEnvelope(0.7, spec)).toBe(0); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/render/postfx/postSim.ts b/packages/engine/src/render/postfx/postSim.ts deleted file mode 100644 index 41be933..0000000 --- a/packages/engine/src/render/postfx/postSim.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Чистая математика пост-эффектов: переменный блюр (радиальный спад силы), - * сэмпл-ядро блюра, хроматическая аберрация, марево. Без Pixi — тестируется - * в Vitest headless; PostFx.ts только переносит сэмплы в шейдер. - */ -export { pulseEnvelope, pulseTotal, type PulseSpec } from '../lightSim'; - -/** Гладкая ступень 0..1 (с нулевой производной на краях — без «шва» спада). */ -function smoothstep(t: number): number { - const x = Math.max(0, Math.min(1, t)); - return x * x * (3 - 2 * x); -} - -/** - * Сила блюра на расстоянии distPx от центра, px выборки: пик в центре, - * гладкий спад до нуля на радиусе. radiusPx <= 0 — глобальный режим - * (сила постоянна по всему кадру). - */ -export function blurStrengthAt(distPx: number, radiusPx: number, strengthPx: number): number { - if (radiusPx <= 0) return strengthPx; - return strengthPx * smoothstep(1 - distPx / radiusPx); -} - -/** - * Ядро блюра: taps смещений равномерного диска (спираль золотого сечения) — - * покрывает круг без кластерных артефактов сетки; норма 1 px выборки. - */ -export function blurKernel(taps = 8): ReadonlyArray { - const golden = Math.PI * (3 - Math.sqrt(5)) * 2; - const out: Array = []; - for (let i = 0; i < taps; i++) { - const r = Math.sqrt((i + 0.5) / taps); - const a = i * golden; - out.push([Math.cos(a) * r, Math.sin(a) * r]); - } - return out; -} - -/** - * Сдвиг каналов хроматической аберрации на расстоянии distPx от центра, px: - * R/B разводятся по радиалии; спад как у блюра — импульсы «от точки». - */ -export function aberrationShiftAt(distPx: number, radiusPx: number, strengthPx: number): number { - if (radiusPx <= 0) return strengthPx; - return strengthPx * smoothstep(1 - distPx / radiusPx); -} - -/** Параметры марева: амплитуда px, длина волны px, скорость фаз/с. */ -export interface ShimmerParams { - amp: number; - wavelength: number; - speed: number; -} - -/** Смещение по Y (px) синусоидой по X и времени — «горячий воздух». */ -export function shimmerOffsetY(xPx: number, timeSec: number, p: ShimmerParams): number { - return p.amp * Math.sin((xPx / p.wavelength + timeSec * p.speed) * Math.PI * 2); -} \ No newline at end of file diff --git a/packages/engine/src/render/scale.ts b/packages/engine/src/render/scale.ts deleted file mode 100644 index e8df89c..0000000 --- a/packages/engine/src/render/scale.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Целочисленный масштаб виртуального разрешения под размер окна (pixel-perfect). - */ - -/** Наибольший целый множитель, при котором виртуальный экран влезает в окно. */ -export function computeScale(vw: number, vh: number, winW: number, winH: number): number { - return Math.max(1, Math.floor(Math.min(winW / vw, winH / vh))); -} \ No newline at end of file diff --git a/packages/engine/src/render/shadowSim.ts b/packages/engine/src/render/shadowSim.ts deleted file mode 100644 index 766fdb5..0000000 --- a/packages/engine/src/render/shadowSim.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Чистая математика теней — без Pixi, тестируется в Vitest. - * Солнце ходит по дуге в пределах дня из DayNightSpec (dawn..night): тень - * утром «влево по экрану», вечером «вправо», в полдень короткая; направление - * проецируется изометрией из плоскости мира — тень всегда лежит на земле. - * Мир пепельный — свет рассеянный, тени мягкие: альфа скромная, края - * ступенчатые (рисует makeShadowTexture), геометрия — эллипс, сидящий на базе. - */ - -import { type DayNightSpec, DEFAULT_DAY_NIGHT } from './lightSim'; -import { DEFAULT_ISO } from '../math/iso'; - -/** Цвет тени — нейтральный тёмно-серый (пепельная гамма, не чёрный). */ -export const SHADOW_COLOR = 0x2f2f38; - -/** Гладкая ступень 0..1 (нулевой наклон на краях — без рывков альфы). */ -function smooth(t: number): number { - const x = Math.max(0, Math.min(1, t)); - return x * x * (3 - 2 * x); -} - -/** Состояние солнца для теней: направление тени (экран), длина, альфа. */ -export interface SunShadow { - /** Единичный вектор тени на экране (dx вправо, dy вниз); лежит на изо-земле. */ - dirX: number; - dirY: number; - /** Длина тени на единицу высоты объекта (короткая в полдень). */ - len: number; - /** Сила тени 0..1 (пепельный мир — солнце рассеянное). */ - alpha: number; -} - -/** - * Солнце по часам суток: null — ночи (тьма рисует darkness-слой, тени от - * солнца не видны). Границы и разгон альфы — из DayNightSpec: подъём - * dawn..day, спад dusk..night. Направление тени считается АЗИМУТОМ в - * плоскости мира и проецируется изометрией (мировой юг → экран влево-вниз, - * мировая диагональ юго-восток → ровно вниз): любой вектор с этой дуги - * лежит на земле — тень не «стекает по стене» и не отрывает объект от пола. - */ -export function sunShadow(hours: number, spec: DayNightSpec = DEFAULT_DAY_NIGHT): SunShadow | null { - const t = ((hours % 24) + 24) % 24; - const rise = spec.dawn; - const set = spec.night; - if (t < rise || t >= set) return null; - const up = smooth((t - rise) / Math.max(0.001, spec.day - rise)); - const down = 1 - smooth((t - spec.dusk) / Math.max(0.001, set - spec.dusk)); - const k = (t - rise) / (set - rise); // 0..1 — путь солнца за день - const az = (Math.PI / 2) * (1 - k); // мировой юг (утро) → юго-восток (вечер) - const gx = Math.cos(az); - const gy = Math.sin(az); - const px = (gx - gy) * (DEFAULT_ISO.tileW / 2); - const py = (gx + gy) * (DEFAULT_ISO.tileH / 2); - const n = Math.hypot(px, py); - const len = 0.55 + 1.05 * Math.pow(Math.abs(k - 0.5) * 2, 1.4); - return { dirX: px / n, dirY: py / n, len, alpha: 0.5 * Math.min(up, down) }; -} - -/** Размещение блоба-тени относительно базы объекта (в px виртуального экрана). */ -export interface BlobPlacement { - /** Центр эллипса относительно точки базы (ноги/основания). */ - x: number; - y: number; - /** Поворот эллипса — вдоль направления тени. */ - rotation: number; - /** Полуоси эллипса, px. */ - scaleX: number; - scaleY: number; - /** Прозрачность — сила солнца. */ - alpha: number; -} - -/** - * Эллипс тени для объекта высотой heightPx с «толщиной» radiusPx. Масса - * эллипса сидит у базы: центр лишь чуть съезжает вдоль тени, а ближний край - * заходит за базу — иначе видимая тёмная масса висит в стороне от объекта - * и тот читается «летающим». Вылет вдоль направления растёт с высотой и - * низким солнцем (len), ширина поперёк почти не меняется от длины. - */ -export function blobPlacement(radiusPx: number, heightPx: number, sun: SunShadow): BlobPlacement { - const reach = heightPx * sun.len; - const center = radiusPx * 0.3 + reach * 0.2; // центр массы от базы - const a = center + radiusPx * 0.55 + reach * 0.22; // полуось вдоль тени - const b = radiusPx * (0.75 + 0.25 * Math.min(1, sun.len)); // полуось поперёк - return { - x: sun.dirX * center, - y: sun.dirY * center, - rotation: Math.atan2(sun.dirY, sun.dirX), - scaleX: a, - scaleY: b, - alpha: sun.alpha - }; -} \ No newline at end of file diff --git a/packages/engine/src/render/shadows.ts b/packages/engine/src/render/shadows.ts deleted file mode 100644 index 2ebfb42..0000000 --- a/packages/engine/src/render/shadows.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Вьюхи теней поверх чистой математики shadowSim: текстура блоба - * (ступенчатый эллипс — мягкий край без мыла, пиксель-стиль) и раскладка - * спрайта по размещению из blobPlacement. Тонко: состояние считает сцена. - */ -import { Graphics, Sprite, Texture } from 'pixi.js'; -import { type BlobPlacement } from './shadowSim'; -import { SHADOW_COLOR } from './shadowSim'; -import { type Renderer } from './Renderer'; - -/** Полуось текстуры блоба, px виртуального разрешения (масштабируется спрайтом). */ -export const SHADOW_TEX_PX = 32; - -/** Текстура блоба-тени: три ступени прозрачности от края к центру. */ -export function makeShadowTexture(renderer: Renderer): Texture { - const g = new Graphics(); - // Ступени с запасом: спрайт-альфа (до ~0.5 в разгар дня) умножается - // поверх — суммарная плотность центра должна оставаться читаемой на - // тёмной траве, иначе объект «висит» без контактной тени. - g.ellipse(0, 0, SHADOW_TEX_PX, SHADOW_TEX_PX).fill({ color: SHADOW_COLOR, alpha: 0.4 }); - g.ellipse(0, 0, SHADOW_TEX_PX * 0.72, SHADOW_TEX_PX * 0.72).fill({ color: SHADOW_COLOR, alpha: 0.44 }); - g.ellipse(0, 0, SHADOW_TEX_PX * 0.45, SHADOW_TEX_PX * 0.45).fill({ color: SHADOW_COLOR, alpha: 0.5 }); - const tex = renderer.app.renderer.generateTexture({ target: g, resolution: 1 }); - g.destroy(); - return tex; -} - -/** - * Положить спрайт-блоб (текстура makeShadowTexture) по размещению blobPlacement: - * base — точка базы объекта (ноги/основание) в координатах родителя. - */ -export function applyBlobShadow( - sprite: Sprite, - base: { x: number; y: number }, - blob: BlobPlacement -): void { - sprite.position.set(base.x + blob.x, base.y + blob.y); - sprite.rotation = blob.rotation; - sprite.scale.set(blob.scaleX / SHADOW_TEX_PX, blob.scaleY / SHADOW_TEX_PX); - sprite.alpha = blob.alpha; -} \ No newline at end of file diff --git a/packages/engine/src/render/shake.ts b/packages/engine/src/render/shake.ts deleted file mode 100644 index 6acf696..0000000 --- a/packages/engine/src/render/shake.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Тряска камеры: несколько одновременных толчков, затухание линейно по времени. - * Чистая логика (без Pixi) — смещение детерминировано seed'ом, тестируется в Vitest. - */ - -import { createRng, type Rng } from '../math/rng'; - -interface ShakeEntry { - strength: number; - duration: number; - elapsed: number; -} - -export class Shake { - private entries: ShakeEntry[] = []; - private rng: Rng; - private offsetX = 0; - private offsetY = 0; - - constructor(seed = 0x5eed1234) { - this.rng = createRng(seed); - } - - /** Добавить толчок: амплитуда в пикселях, длительность в секундах. */ - add(strength: number, duration: number): void { - if (strength <= 0 || duration <= 0) return; - this.entries.push({ strength, duration, elapsed: 0 }); - } - - update(dt: number): void { - this.offsetX = 0; - this.offsetY = 0; - let w = 0; - for (const e of this.entries) { - e.elapsed += dt; - const k = Math.max(0, 1 - e.elapsed / e.duration); // линейное затухание - this.offsetX += (this.rng.next() * 2 - 1) * e.strength * k; - this.offsetY += (this.rng.next() * 2 - 1) * e.strength * k; - if (e.elapsed < e.duration) this.entries[w++] = e; // компакт на месте, без filter - } - this.entries.length = w; - } - - /** Текущее смещение в пикселях (0 вне активной тряски). */ - get offset(): { x: number; y: number } { - return { x: Math.round(this.offsetX), y: Math.round(this.offsetY) }; - } - - get active(): boolean { - return this.entries.length > 0; - } - - clear(): void { - this.entries = []; - this.offsetX = 0; - this.offsetY = 0; - } -} \ No newline at end of file diff --git a/packages/engine/src/render/spriteFx.ts b/packages/engine/src/render/spriteFx.ts deleted file mode 100644 index a821bb7..0000000 --- a/packages/engine/src/render/spriteFx.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Sprite } from 'pixi.js'; -import { TintFlash } from '../anim/flash'; - -/** - * Тонкий Pixi-адаптер над anim/flash: хит-флэш спрайта. - * Логика лерпа — в TintFlash (тестируется), здесь только применение tint. - */ -export class SpriteFlash { - private flash: TintFlash | null = null; - - constructor( - private readonly sprite: Sprite, - private readonly flashColor: number, - private readonly seconds = 0.15 - ) {} - - /** Запустить вспышку (перезапускается с нуля). */ - start(): void { - if (!this.flash) { - this.flash = new TintFlash(this.sprite.tint, this.flashColor, this.seconds); - } - this.flash.start(); - } - - /** Обновлять каждый тик владельцем (например, боевой системой). */ - update(dt: number): void { - if (!this.flash) return; - const tint = this.flash.update(dt); - if (tint !== null) { - this.sprite.tint = tint; - } else { - this.flash = null; - } - } - - /** Прекратить вспышку и вернуть исходный tint (контракт fx: cancel = сброс). */ - cancel(): void { - const f = this.flash; - this.flash = null; - if (!f || this.sprite.destroyed) return; - this.sprite.tint = f.finish(); - } - - /** Самозачистка из реестра: спрайт разрушен — запись вычистится сама. */ - get destroyed(): boolean { - return this.sprite.destroyed; - } -} \ No newline at end of file diff --git a/packages/engine/src/save/SaveManager.ts b/packages/engine/src/save/SaveManager.ts deleted file mode 100644 index 25ec53d..0000000 --- a/packages/engine/src/save/SaveManager.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Сохранения в JSON-слотах. Хранилище инъекцией (localStorage или любой - * Storage-подобный объект) — чтобы работать и в тестах без браузера. - * Метаданные слота (заголовок, время) — в параллельном ключе `meta:`: - * формат самих сейвов не меняется, старые сейвы остаются читаемыми. - */ - -/** Метаданные слота — читаются без полной загрузки сейва (задаёт игра). */ -export interface SaveSlotMeta { - /** Человекочитаемый заголовок (движок не знает жанра). */ - title?: string; - /** Момент записи (Date.now()). */ - savedAt?: number; - /** Версия формата сейва. */ - version?: number; - /** Жанровые поля игры (область, уровень, часы игры…). */ - extras?: Record; -} - -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, meta?: SaveSlotMeta): void { - this.storage.setItem(this.prefix + slot, JSON.stringify(data)); - if (meta) this.storage.setItem(this.metaKey(slot), JSON.stringify(meta)); - } - - 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; - } - } - - /** Метаданные слота (null — мета не писалась, например у старых сейвов). */ - slotMeta(slot: string): SaveSlotMeta | null { - const raw = this.storage.getItem(this.metaKey(slot)); - if (raw === null) return null; - try { - return JSON.parse(raw) as SaveSlotMeta; - } catch { - return null; - } - } - - /** Слоты с метой (мета может отсутствовать — у сейвов старых версий). */ - list(): Array<{ slot: string; meta: SaveSlotMeta | null }> { - return this.listSlots().map((slot) => ({ slot, meta: this.slotMeta(slot) })); - } - - delete(slot: string): void { - this.storage.removeItem(this.prefix + slot); - this.storage.removeItem(this.metaKey(slot)); - } - - /** Есть ли сохранение в слоте. */ - has(slot: string): boolean { - return this.storage.getItem(this.prefix + slot) !== null; - } - - /** Список занятых слотов (без префикса; мета-ключи не попадают). */ - 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)) { - const slot = key.slice(this.prefix.length); - if (!slot.startsWith('meta:')) out.push(slot); - } - } - return out; - } - - private metaKey(slot: string): string { - return this.prefix + 'meta:' + slot; - } -} \ 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 deleted file mode 100644 index 9d4bae9..0000000 --- a/packages/engine/src/save/__tests__/SaveManager.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -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']); - }); - - it('мета пишется в параллельный ключ и читается без сейва', () => { - const storage = memoryStorage(); - const sm = new SaveManager(storage); - sm.save('slot1', { hp: 3 }, { title: 'Луга', savedAt: 42, version: 3 }); - expect(storage.getItem('save:slot1')).not.toContain('Луга'); - expect(sm.slotMeta('slot1')).toEqual({ title: 'Луга', savedAt: 42, version: 3 }); - expect(sm.list()).toEqual([{ slot: 'slot1', meta: { title: 'Луга', savedAt: 42, version: 3 } }]); - }); - - it('сейв без меты и битая мета -> slotMeta null, сейв читается', () => { - const storage = memoryStorage(); - const sm = new SaveManager(storage); - sm.save('old', { hp: 1 }); // без меты — как старые сейвы - expect(sm.slotMeta('old')).toBeNull(); - storage.setItem('save:meta:broken', '{oops'); - expect(sm.slotMeta('broken')).toBeNull(); - }); - - it('delete чистит и сейв, и мету; listSlots без meta:*', () => { - const sm = new SaveManager(memoryStorage()); - sm.save('a', 1, { title: 'A' }); - expect(sm.listSlots()).toEqual(['a']); // meta-ключ не в списке - sm.delete('a'); - expect(sm.has('a')).toBe(false); - expect(sm.slotMeta('a')).toBeNull(); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/scene/SceneManager.ts b/packages/engine/src/scene/SceneManager.ts deleted file mode 100644 index ba282df..0000000 --- a/packages/engine/src/scene/SceneManager.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { Graphics } from 'pixi.js'; -import { FadeState } from '../core/fade'; - -/** Линейная интерполяция числа (для фейда оверлея). */ -const lerpNum = (a: number, b: number, k: number): number => a + (b - a) * k; - -/** - * Сцена — экран игры (меню, локация, бой). Менеджер держит стек, - * но в 2D-RPG чаще всего достаточно push/pop/replace. - */ -export interface Scene { - /** Вызывается один раз при входе. */ - enter(): void | Promise; - exit(): void | Promise; - /** dt в секундах, фиксированный шаг. */ - update(dt: number): void; - render(): void; -} - -/** Затемнение экрана при смене сцены. */ -export interface SceneTransition { - /** Общая длительность (затухание + проявление), секунды. По умолчанию 0.5. */ - duration?: number; - /** Цвет затемнения. По умолчанию чёрный. */ - color?: number; -} - -type TransitionKind = 'push' | 'pop' | 'replace'; - -interface PendingTransition { - kind: TransitionKind; - scene?: Scene; - duration: number; - color: number; - phase: 'out' | 'in'; - /** Прозрачность оверлея 0..1 (FadeState вместо ручного t/duration). */ - alpha: FadeState; -} - -export class SceneManager { - private stack: Scene[] = []; - private overlay: Graphics | null = null; - private overlayWidth = 0; - private overlayHeight = 0; - private pending: PendingTransition | null = null; - - /** Движок подключает оверлей затемнения (верхний слой uiRoot). */ - setOverlay(g: Graphics, width: number, height: number): void { - this.overlay = g; - this.overlayWidth = width; - this.overlayHeight = height; - } - - async push(scene: Scene, transition?: SceneTransition): Promise { - if (transition) { - this.begin('push', scene, transition); - return; - } - this.stack.push(scene); - await scene.enter(); - } - - async pop(transition?: SceneTransition): Promise { - if (transition) { - this.begin('pop', undefined, transition); - return; - } - const scene = this.stack.pop(); - if (scene) { - await scene.exit(); - } - } - - /** Заменить верхнюю сцену (например меню -> локация). */ - async replace(scene: Scene, transition?: SceneTransition): Promise { - if (transition) { - this.begin('replace', scene, transition); - return; - } - await this.pop(); - await this.push(scene); - } - - get current(): Scene | undefined { - return this.stack[this.stack.length - 1]; - } - - /** Идёт ли сейчас переход (для блокировки ввода). */ - get transitioning(): boolean { - return this.pending !== null; - } - - update(dt: number): void { - if (this.pending) { - this.stepTransition(dt); - } - this.current?.update(dt); - } - - render(): void { - for (const scene of this.stack) { - scene.render(); - } - } - - private begin(kind: TransitionKind, scene: Scene | undefined, tr: SceneTransition): void { - if (this.pending) return; // параллельные переходы запрещены - // Оверлей должен быть поверх всего UI: сцены добавляли свои view позже. - if (this.overlay?.parent) { - this.overlay.parent.addChild(this.overlay); - } - const duration = tr.duration ?? 0.5; - const alpha = new FadeState(lerpNum, 0); - alpha.to(1, duration); - this.pending = { - kind, - scene, - duration, - color: tr.color ?? 0x000000, - phase: 'out', - alpha - }; - } - - private stepTransition(dt: number): void { - const p = this.pending!; - p.alpha.update(dt); - this.drawOverlay(p.color, p.alpha.value); - - if (p.phase === 'out') { - if (p.alpha.active) return; - // экран скрыт — меняем сцену - this.performSwap(p); - p.phase = 'in'; - p.alpha.to(0, p.duration); - return; - } - - // фаза 'in': проявление - if (!p.alpha.active) { - this.pending = null; - this.overlay?.clear(); - } - } - - private performSwap(p: PendingTransition): void { - switch (p.kind) { - case 'push': - if (p.scene) { - this.stack.push(p.scene); - } - break; - case 'pop': - this.stack.pop()?.exit(); - break; - case 'replace': - this.stack.pop()?.exit(); - if (p.scene) { - this.stack.push(p.scene); - } - break; - } - // enter может быть асинхронным — переход не ждёт его, - // новая сцена проявляется параллельно со своей инициализацией. - void this.stack[this.stack.length - 1]?.enter(); - } - - private drawOverlay(color: number, alpha: number): void { - if (!this.overlay) return; - const g = this.overlay; - g.clear(); - if (alpha > 0) { - g.rect(0, 0, this.overlayWidth, this.overlayHeight).fill({ color, alpha }); - } - } -} \ No newline at end of file diff --git a/packages/engine/src/scene/SceneRegistry.ts b/packages/engine/src/scene/SceneRegistry.ts deleted file mode 100644 index 3bc5c12..0000000 --- a/packages/engine/src/scene/SceneRegistry.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { Vec2 } from '../math/Vec2'; - -/** - * Реестр объектов сцены — жанронезависимый примитив «что-то стоит на карте»: - * позиция в мировых юнитах + радиус тела + footprint в тайлах + непрозрачная - * ссылка на контент. Движок не знает ни NPC, ни интерактивов: kind и ref - * задаёт игра. Индекс по тайлам даёт запросы «кто здесь / кто рядом» без - * перебора всей сцены. - */ - -/** Описание объекта при добавлении. pos — ноги объекта, мутабельно. */ -export interface SceneObjectDef { - /** Уникальный в реестре идентификатор ('npc:elder', 'enemy:7', 'prop:14,9'). */ - id: string; - /** Род объекта (строки задаёт игра: 'npc', 'interactable', 'enemy', ...). */ - kind: string; - /** Позиция в мировых юнитах (точка контакта с землёй). */ - pos: Vec2; - /** Радиус тела в юнитах (0 — точка без тела). */ - radius?: number; - /** Занимаемые тайлы от тайла pos (пропы: footprint w×h от низа-центра). */ - footprint?: { w: number; h: number }; - /** Ссылка на контент игры (NpcDef / InteractableDef / сущность ECS) — движок не читает. */ - ref?: unknown; -} - -/** Объект в реестре: def + вычисляемый тайл привязки. */ -export interface SceneObject extends SceneObjectDef { - readonly radius: number; - /** Тайл, к которому привязан объект (floor от pos). */ - tile: { x: number; y: number }; -} - -/** Ключ индекса: тайлы упаковываются в одно число (карты до 4096×4096). */ -const tileKey = (tx: number, ty: number): number => ty * 4096 + tx; - -export class SceneRegistry { - private byId = new Map(); - /** Индекс по тайлу → множество объектов, чей тайл/footprint покрывает его. */ - private byTile = new Map>(); - /** Переиспользуемый Set дедупликации near() — без Set на вызов. */ - private nearSeen = new Set(); - - /** Добавить объект; дубликат id — ошибка (ловится инвариантом сцены). */ - add(def: SceneObjectDef): SceneObject { - if (this.byId.has(def.id)) { - throw new Error(`SceneRegistry: дубликат id ${def.id}`); - } - const obj: SceneObject = { - ...def, - radius: def.radius ?? 0, - tile: { x: Math.floor(def.pos.x), y: Math.floor(def.pos.y) } - }; - this.byId.set(obj.id, obj); - for (const key of this.coveredKeys(obj)) { - this.tileSet(key).add(obj); - } - return obj; - } - - remove(id: string): void { - const obj = this.byId.get(id); - if (!obj) return; - for (const key of this.coveredKeys(obj)) { - this.tileSet(key).delete(obj); - } - this.byId.delete(id); - } - - get(id: string): SceneObject | undefined { - return this.byId.get(id); - } - - /** Все объекты рода kind (порядок добавления). */ - byKind(kind: string): readonly SceneObject[] { - return this.all.filter((o) => o.kind === kind); - } - - get size(): number { - return this.byId.size; - } - - /** Переместить объект (юниты); переиндексация — O(1), если тайл не сменился. */ - move(id: string, pos: Vec2): void { - const obj = this.byId.get(id); - if (!obj) return; - const tx = Math.floor(pos.x); - const ty = Math.floor(pos.y); - obj.pos.x = pos.x; - obj.pos.y = pos.y; - if (tx === obj.tile.x && ty === obj.tile.y) return; - for (const key of this.coveredKeys(obj)) { - this.tileSet(key).delete(obj); - } - obj.tile.x = tx; - obj.tile.y = ty; - for (const key of this.coveredKeys(obj)) { - this.tileSet(key).add(obj); - } - } - - /** Объект, привязанный к тайлу (точка тайла или footprint, накрывающий его). */ - at(tx: number, ty: number, kind?: string): SceneObject | null { - for (const obj of this.byTile.get(tileKey(tx, ty)) ?? []) { - if (!kind || obj.kind === kind) return obj; - } - return null; - } - - /** Объект в мировой точке: тайл точки или ближайший тайл с телом рядом. */ - atWorld(p: Vec2, kind?: string): SceneObject | null { - const hit = this.at(Math.floor(p.x), Math.floor(p.y), kind); - if (hit) return hit; - // Тело может выступать из тайла привязки — ищем в соседних 3×3. - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - if (dx === 0 && dy === 0) continue; - for (const obj of this.byTile.get(tileKey(Math.floor(p.x) + dx, Math.floor(p.y) + dy)) ?? []) { - if (obj.radius > 0 && (!kind || obj.kind === kind)) { - const ddx = p.x - obj.pos.x; - const ddy = p.y - obj.pos.y; - if (ddx * ddx + ddy * ddy <= obj.radius * obj.radius) return obj; - } - } - } - } - return null; - } - - /** - * Объекты в радиусе (юниты) от точки; фильтр по роду опционален. - * out — переиспользуемый массив результата (горячий путь без аллокаций). - */ - near(p: Vec2, radius: number, kind?: string, out?: SceneObject[]): SceneObject[] { - const found = out ?? []; - found.length = 0; - const seen = this.nearSeen; - seen.clear(); - const x0 = Math.floor(p.x - radius - 1); - const x1 = Math.floor(p.x + radius + 1); - const y0 = Math.floor(p.y - radius - 1); - const y1 = Math.floor(p.y + radius + 1); - for (let ty = y0; ty <= y1; ty++) { - for (let tx = x0; tx <= x1; tx++) { - for (const obj of this.byTile.get(tileKey(tx, ty)) ?? []) { - if (seen.has(obj) || (kind && obj.kind !== kind)) continue; - seen.add(obj); - const dx = p.x - obj.pos.x; - const dy = p.y - obj.pos.y; - if (dx * dx + dy * dy <= radius * radius) found.push(obj); - } - } - } - return found; - } - - private get all(): SceneObject[] { - return [...this.byId.values()]; - } - - /** Ключи тайлов, покрываемых объектом: сам тайл + footprint. */ - private *coveredKeys(obj: SceneObject): Iterable { - const fw = obj.footprint?.w ?? 1; - const fh = obj.footprint?.h ?? 1; - for (let dy = 0; dy < fh; dy++) { - for (let dx = 0; dx < fw; dx++) { - yield tileKey(obj.tile.x + dx, obj.tile.y + dy); - } - } - } - - private tileSet(key: number): Set { - let set = this.byTile.get(key); - if (!set) { - set = new Set(); - this.byTile.set(key, set); - } - return set; - } -} \ No newline at end of file diff --git a/packages/engine/src/scene/__tests__/SceneManager.test.ts b/packages/engine/src/scene/__tests__/SceneManager.test.ts deleted file mode 100644 index cd503d2..0000000 --- a/packages/engine/src/scene/__tests__/SceneManager.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { SceneManager, type Scene } from '../SceneManager'; - -/** Сцена-болванка с журналом вызовов (для проверки порядка enter/exit/update). */ -function makeScene(name: string) { - const events: string[] = []; - const scene: Scene = { - enter: () => { - events.push(`${name}:enter`); - }, - exit: () => { - events.push(`${name}:exit`); - }, - update: () => { - events.push(`${name}:update`); - }, - render: () => { - events.push(`${name}:render`); - } - }; - return { scene, events }; -} - -describe('SceneManager', () => { - it('push без перехода: enter сразу, сцена в стеке и тикает', () => { - const sm = new SceneManager(); - const a = makeScene('a'); - void sm.push(a.scene); - expect(sm.current).toBe(a.scene); - sm.update(0.016); - expect(a.events).toContain('a:enter'); - expect(a.events).toContain('a:update'); - }); - - it('pop без перехода: exit верхней сцены, стек пуст', async () => { - const sm = new SceneManager(); - const a = makeScene('a'); - void sm.push(a.scene); - await sm.pop(); - expect(a.events).toContain('a:exit'); - expect(sm.current).toBeUndefined(); - }); - - it('replace с переходом: out -> swap (exit/enter) -> in, затем transitioning=false', async () => { - const sm = new SceneManager(); - const a = makeScene('a'); - const b = makeScene('b'); - await sm.replace(a.scene); // replace асинхронен даже без перехода - a.events.length = 0; - - void sm.replace(b.scene, { duration: 0.2 }); - expect(sm.transitioning).toBe(true); - - sm.update(0.1); // половина out — сцены ещё не менялись - expect(sm.current).toBe(a.scene); - expect(sm.transitioning).toBe(true); - - sm.update(0.1); // out завершён — swap: exit(a), enter(b) - expect(a.events).toContain('a:exit'); - expect(b.events).toContain('b:enter'); - expect(sm.current).toBe(b.scene); - - sm.update(0.1); - sm.update(0.1); // in завершён - expect(sm.transitioning).toBe(false); - }); - - it('push с переходом: новая сцена входит в середине (за чёрным экраном)', () => { - const sm = new SceneManager(); - const a = makeScene('a'); - const b = makeScene('b'); - void sm.push(a.scene); - - void sm.push(b.scene, { duration: 0.2 }); - sm.update(0.2); // out завершён - expect(sm.current).toBe(b.scene); - expect(b.events).toContain('b:enter'); - }); - - it('параллельный переход глотается: новая сцена не входит', () => { - const sm = new SceneManager(); - const a = makeScene('a'); - const b = makeScene('b'); - const c = makeScene('c'); - void sm.push(a.scene); - - void sm.replace(b.scene, { duration: 0.2 }); - void sm.replace(c.scene, { duration: 0.2 }); // глотается - sm.update(0.2); - sm.update(0.2); - sm.update(0.2); - - expect(sm.current).toBe(b.scene); - expect(c.events).toEqual([]); - }); - - it('update во время перехода тикает текущую (старую) сцену', () => { - const sm = new SceneManager(); - const a = makeScene('a'); - void sm.push(a.scene); - a.events.length = 0; - - void sm.replace(makeScene('b').scene, { duration: 0.2 }); - sm.update(0.05); - expect(a.events).toContain('a:update'); - }); - - it('render рисует весь стек (нижние сцены видны под верхними)', () => { - const sm = new SceneManager(); - const a = makeScene('a'); - const b = makeScene('b'); - void sm.push(a.scene); - void sm.push(b.scene); - - sm.render(); - expect(a.events).toContain('a:render'); - expect(b.events).toContain('b:render'); - }); - - it('enter может быть асинхронным — переход его не ждёт', async () => { - const sm = new SceneManager(); - let resolved = false; - const scene: Scene = { - enter: () => - new Promise((res) => { - setTimeout(() => { - resolved = true; - res(); - }, 5); - }), - exit: () => {}, - update: () => {}, - render: () => {} - }; - const spy = vi.spyOn(scene, 'enter'); - await sm.push(scene); - expect(spy).toHaveBeenCalled(); - // Не ждём промис — переход уже вернулся. - await new Promise((r) => setTimeout(r, 10)); - expect(resolved).toBe(true); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/scene/__tests__/SceneRegistry.test.ts b/packages/engine/src/scene/__tests__/SceneRegistry.test.ts deleted file mode 100644 index a7f4889..0000000 --- a/packages/engine/src/scene/__tests__/SceneRegistry.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { SceneRegistry } from '../SceneRegistry'; - -describe('SceneRegistry', () => { - it('add/get/remove и byKind по роду', () => { - const r = new SceneRegistry(); - const a = r.add({ id: 'npc:1', kind: 'npc', pos: { x: 3.5, y: 4.5 } }); - r.add({ id: 'enemy:1', kind: 'enemy', pos: { x: 8.5, y: 2.5 } }); - expect(r.get('npc:1')).toBe(a); - expect(a.tile).toEqual({ x: 3, y: 4 }); - expect(r.byKind('npc').map((o) => o.id)).toEqual(['npc:1']); - expect(r.size).toBe(2); - r.remove('npc:1'); - expect(r.get('npc:1')).toBeUndefined(); - expect(r.at(3, 4)).toBeNull(); - }); - - it('дубликат id — throw', () => { - const r = new SceneRegistry(); - r.add({ id: 'x', kind: 'npc', pos: { x: 1, y: 1 } }); - expect(() => r.add({ id: 'x', kind: 'npc', pos: { x: 2, y: 2 } })).toThrow('дубликат'); - }); - - it('move переиндексирует тайл (и O(1) внутри тайла)', () => { - const r = new SceneRegistry(); - r.add({ id: 'e', kind: 'enemy', pos: { x: 2.5, y: 2.5 }, radius: 0.3 }); - r.move('e', { x: 2.9, y: 2.9 }); // тот же тайл - expect(r.at(2, 2, 'enemy')).not.toBeNull(); - expect(r.at(3, 3, 'enemy')).toBeNull(); - r.move('e', { x: 5.2, y: 7.8 }); - expect(r.at(2, 2, 'enemy')).toBeNull(); - expect(r.at(5, 7, 'enemy')).not.toBeNull(); - expect(r.get('e')!.pos).toEqual({ x: 5.2, y: 7.8 }); - r.remove('e'); // remove после move не оставляет «призраков» в старом тайле - expect(r.at(5, 7)).toBeNull(); - }); - - it('footprint-объект находится во всех покрытых тайлах', () => { - const r = new SceneRegistry(); - r.add({ - id: 'prop:1', kind: 'prop', pos: { x: 10.5, y: 10.5 }, - footprint: { w: 2, h: 2 } - }); - expect(r.at(10, 10, 'prop')).not.toBeNull(); - expect(r.at(11, 10, 'prop')).not.toBeNull(); - expect(r.at(10, 11, 'prop')).not.toBeNull(); - expect(r.at(11, 11, 'prop')).not.toBeNull(); - expect(r.at(12, 11, 'prop')).toBeNull(); - r.remove('prop:1'); - expect(r.at(11, 11)).toBeNull(); - }); - - it('atWorld находит тело, выступающее из тайла привязки', () => { - const r = new SceneRegistry(); - r.add({ id: 'npc:1', kind: 'npc', pos: { x: 10.9, y: 10.5 }, radius: 0.4 }); - // Точка в соседнем тайле, но внутри радиуса тела. - expect(r.atWorld({ x: 11.2, y: 10.5 }, 'npc')).not.toBeNull(); - // Вне радиуса — промах. - expect(r.atWorld({ x: 12.2, y: 10.5 }, 'npc')).toBeNull(); - }); - - it('near: по радиусу, с фильтром рода и без дублей на границе', () => { - const r = new SceneRegistry(); - r.add({ id: 'a', kind: 'npc', pos: { x: 5, y: 5 } }); - r.add({ id: 'b', kind: 'enemy', pos: { x: 5.5, y: 5.5 } }); - r.add({ id: 'c', kind: 'npc', pos: { x: 7.5, y: 7.5 } }); - const ids = (list: { id: string }[]) => list.map((o) => o.id).sort(); - expect(ids(r.near({ x: 5, y: 5 }, 1))).toEqual(['a', 'b']); - expect(ids(r.near({ x: 5, y: 5 }, 1, 'npc'))).toEqual(['a']); - expect(ids(r.near({ x: 5, y: 5 }, 10))).toEqual(['a', 'b', 'c']); - expect(r.near({ x: 20, y: 20 }, 2)).toEqual([]); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/ui/Button.ts b/packages/engine/src/ui/Button.ts deleted file mode 100644 index 3d4f57b..0000000 --- a/packages/engine/src/ui/Button.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Container, Graphics, Rectangle, FederatedPointerEvent } from 'pixi.js'; -import { PixelText } from './PixelText'; - -/** - * Кнопка UI: пиксельная рамка, состояния normal/hover/pressed/focused. - * Активация — клик мышью или onSelect() из клавиатурной навигации (MenuList). - */ -export interface ButtonOptions { - label: string; - width: number; - height: number; - size?: number; - /** Значение справа (громкость, счётчик) — меняется без пересборки списка. */ - value?: string; - /** Недоступная кнопка: приглушена, клик/активация игнорируются. */ - disabled?: boolean; - onSelect?: () => void; -} - -type ButtonState = 'normal' | 'hover' | 'pressed' | 'focused'; - -const STATE_COLORS: Record = { - normal: { fill: 0x16161f, border: 0x8899aa, text: 0xcccccc }, - hover: { fill: 0x1e1e2a, border: 0xf0d878, text: 0xffffff }, - pressed: { fill: 0x2a2a38, border: 0xf0d878, text: 0xffffff }, - focused: { fill: 0x1e1e2a, border: 0xd99a32, text: 0xffffff } -}; - -const DISABLED_COLORS = { fill: 0x101018, border: 0x555560, text: 0x777780 }; - -/** Цвет значения (приглушённый акцент — не путать с меткой). */ -const VALUE_COLOR = 0xb8c4cc; - -export class Button extends Container { - readonly labelText: PixelText; - readonly valueText: PixelText | null = null; - onSelect: (() => void) | null; - - private bg: Graphics; - private state: ButtonState = 'normal'; - private readonly w: number; - private readonly h: number; - private readonly size: number; - private readonly disabled: boolean; - - constructor(options: ButtonOptions) { - super(); - this.w = options.width; - this.h = options.height; - this.size = options.size ?? 10; - this.disabled = options.disabled ?? false; - this.onSelect = options.onSelect ?? null; - - this.bg = new Graphics(); - // Защита от длинной метки: перенос по ширине кнопки (иначе текст - // вылезает за рамку; двухстрочная метка в высокой кнопке читаема). - this.labelText = new PixelText({ - text: options.label, - size: this.size, - color: this.disabled ? DISABLED_COLORS.text : STATE_COLORS.normal.text, - align: options.value !== undefined ? 'left' : 'center', - wordWrapWidth: this.w - 16 - }); - if (options.value !== undefined) { - // С value метка прижата влево, значение — к правому краю. - this.labelText.anchor.set(0, 0.5); - this.labelText.position.set(8, this.h / 2); - this.valueText = new PixelText({ - text: options.value, - size: this.size, - color: VALUE_COLOR, - align: 'right' - }); - this.valueText.anchor.set(1, 0.5); - this.valueText.position.set(this.w - 8, this.h / 2); - } else { - this.labelText.anchor.set(0.5); - this.labelText.position.set(this.w / 2, this.h / 2); - } - - this.addChild(this.bg, this.labelText); - if (this.valueText) this.addChild(this.valueText); - - // Недоступная кнопка прозрачна для ввода (клики проходят сквозь) - this.eventMode = this.disabled ? 'none' : 'static'; - if (!this.disabled) { - this.cursor = 'pointer'; - this.hitArea = new Rectangle(0, 0, this.w, this.h); - this.on('pointerover', () => this.setState(this.state === 'pressed' ? 'pressed' : 'hover')); - this.on('pointerout', () => this.setState(this.focused ? 'focused' : 'normal')); - this.on('pointerdown', (e: FederatedPointerEvent) => { - e.stopPropagation(); - this.setState('pressed'); - }); - this.on('pointerup', () => this.setState('hover')); - this.on('pointerupoutside', () => this.setState('normal')); - this.on('pointertap', () => this.onSelect?.()); - } - - this.draw(); - } - - /** Клавиатурный фокус (визуально — рамка акцентного цвета). */ - set focused(value: boolean) { - if (value && this.state === 'normal') this.setState('focused'); - else if (!value && this.state === 'focused') this.setState('normal'); - } - - get focused(): boolean { - return this.state === 'focused'; - } - - /** Программная активация (Enter из MenuList). */ - activate(): void { - if (this.disabled) return; - this.onSelect?.(); - } - - private setState(s: ButtonState): void { - this.state = s; - this.labelText.style.fill = STATE_COLORS[s].text; - if (this.valueText) this.valueText.style.fill = this.disabled ? DISABLED_COLORS.text : VALUE_COLOR; - this.draw(); - } - - /** Обновить значение справа (без пересборки списка). */ - set value(v: string) { - if (this.valueText) this.valueText.text = v; - } - - private draw(): void { - const c = this.disabled ? DISABLED_COLORS : STATE_COLORS[this.state]; - this.bg.clear(); - this.bg.rect(0, 0, this.w, this.h).fill({ color: c.fill, alpha: 0.95 }); - this.bg.rect(0, 0, this.w, this.h).stroke({ color: c.border, width: 1 }); - } -} \ No newline at end of file diff --git a/packages/engine/src/ui/DialogueBox.ts b/packages/engine/src/ui/DialogueBox.ts deleted file mode 100644 index 11fa1e1..0000000 --- a/packages/engine/src/ui/DialogueBox.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { Container, Graphics } from 'pixi.js'; -import { PixelText } from './PixelText'; -import { ListCursor } from './listCursor'; -import { revealText } from './reveal'; - -/** - * Универсальное окно диалога: имя говорящего, текст, варианты ответа, подсказка «далее». - * Жанронезависимо: игра сама решает, чей это диалог и что идёт дальше. - * Подходит как view для DialogueRunner. - * - * v2: typewriter (чистый revealText), цвета настроений, клавиатурный курсор - * выбора (ListCursor), высота панели растёт от контента вверх. - * В update обязательно звать update(dt), если задан typewriter. - */ -export interface DialogueLine { - speaker: string; - text: string; -} - -export interface DialogueChoiceItem { - index: number; - text: string; -} - -export interface DialogueBoxOptions { - /** Виртуальные пиксели. */ - width: number; - height: number; - /** Отступы панели от краёв экрана. */ - margin: number; - /** Выбор варианта ответа (из DialogueRunner.pick). */ - onChoice?: (index: number) => void; - /** Размер шрифта (виртуальные пиксели). */ - size?: number; - /** Скорость печати, символов в секунду; не задана — текст сразу. */ - typewriter?: number; - /** mood реплики → цвет текста (по умолчанию белый). */ - moodColors?: Record; -} - -const COLOR_BODY = 0xffffff; -const COLOR_CHOICE = 0xcccccc; -const COLOR_CHOICE_ACTIVE = 0xf0d878; - -export class DialogueBox { - readonly view: Container; - visible = false; - - private panel: Graphics; - private nameText: PixelText; - private bodyText: PixelText; - private hintText: PixelText; - private choiceViews: { item: DialogueChoiceItem; label: PixelText }[] = []; - private onChoice: ((index: number) => void) | null; - private readonly width: number; - private readonly height: number; - private readonly margin: number; - private readonly size: number; - private readonly cps: number; - private readonly moodColors: Record; - - // typewriter - private fullText = ''; - private revealElapsed = 0; - private revealingNow = false; - // клавиатурный курсор по вариантам - private cursor: ListCursor = new ListCursor(0); - - constructor(options: DialogueBoxOptions) { - this.width = options.width; - this.height = options.height; - this.margin = options.margin; - // 8–9px у VT323 нечитаемы (тонкие штрихи размазываются) — минимум 10. - this.size = Math.max(options.size ?? 10, 10); - this.onChoice = options.onChoice ?? null; - this.cps = options.typewriter ?? 0; - this.moodColors = options.moodColors ?? {}; - - this.view = new Container(); - this.view.visible = false; - this.view.eventMode = 'static'; - - this.panel = new Graphics(); - // Имя — с переносом: длинное имя говорящего не вылезет за панель. - this.nameText = new PixelText({ - text: '', - size: this.size, - color: 0xf0d878, - wordWrapWidth: options.width - options.margin * 4 - 10 - }); - this.bodyText = new PixelText({ - text: '', - size: this.size, - color: COLOR_BODY, - wordWrapWidth: options.width - options.margin * 4, - lineHeight: this.size + 3 - }); - this.hintText = new PixelText({ text: 'далее ▸', size: this.size, color: 0xaaaaaa }); - - this.view.addChild(this.panel, this.nameText, this.bodyText, this.hintText); - this.relayout(); - } - - /** - * Показать реплику. Если есть choices — рисуются варианты с курсором - * (клик и moveCursor/activate). mood подкрашивает текст, typewriter - * печатает текст постепенно (обновлять update(dt)). - */ - show(line: { - speaker?: string; - text: string; - mood?: string; - choices?: DialogueChoiceItem[]; - }): void { - this.nameText.text = line.speaker ?? ''; - this.fullText = line.text; - this.bodyText.style.fill = (line.mood && this.moodColors[line.mood]) || COLOR_BODY; - this.view.visible = true; - this.visible = true; - - this.rebuildChoices(line.choices ?? []); - - this.cursor = new ListCursor(this.choiceViews.length); - this.refreshChoiceColors(); - - // печать с начала; без typewriter — весь текст сразу - this.revealElapsed = 0; - this.revealingNow = this.cps > 0 && this.fullText.length > 0; - // Панель сразу полной высоты: измеряем полный текст и строим панель, - // только потом прячем текст в печать — иначе relayout видит пустую - // строку, панель схлопывается и текст печатается поверх мира. - this.bodyText.text = this.fullText; - this.relayout(); - if (this.revealingNow) this.bodyText.text = ''; - } - - /** Печать и анимации; звать из update сцены при typewriter (dt в секундах). */ - update(dt: number): void { - if (!this.revealingNow) return; - this.revealElapsed += dt * 1000; - const r = revealText(this.fullText, this.revealElapsed, this.cps); - this.bodyText.text = this.fullText.slice(0, r.shown); - if (r.done) { - this.revealingNow = false; - this.relayout(); // панель подросла — подсказка встанет под текст - } - } - - /** Идёт ли печать текста. */ - get revealing(): boolean { - return this.revealingNow; - } - - /** Показать текст целиком (пропуск печати). */ - skipReveal(): void { - if (!this.revealingNow) return; - this.revealingNow = false; - this.bodyText.text = this.fullText; - this.relayout(); - } - - /** Листать выбор курсором (up/down); без вариантов — no-op. */ - moveCursor(delta: number): void { - if (this.choiceViews.length === 0) return; - this.cursor.move(delta); - this.refreshChoiceColors(); - } - - /** Выбрать вариант под курсором (Enter); без вариантов — no-op. */ - activate(): void { - if (this.choiceViews.length === 0 || this.cursor.index < 0) return; - const current = this.choiceViews[this.cursor.index]; - if (current) this.onChoice?.(current.item.index); - } - - /** Индекс варианта под курсором (-1 — выбора нет). */ - get cursorIndex(): number { - return this.choiceViews.length > 0 ? this.cursor.index : -1; - } - - hide(): void { - this.view.visible = false; - this.visible = false; - for (const c of this.choiceViews) c.label.destroy(); - this.choiceViews = []; - this.revealingNow = false; - this.fullText = ''; - } - - // --- внутреннее --- - - private rebuildChoices(choices: DialogueChoiceItem[]): void { - for (const c of this.choiceViews) c.label.destroy(); - this.choiceViews = []; - for (const item of choices) { - const label = new PixelText({ - text: `▸ ${item.text}`, - size: this.size, - color: COLOR_CHOICE, - wordWrapWidth: this.width - this.margin * 4 - }); - label.eventMode = 'static'; - label.cursor = 'pointer'; - label.on('pointerover', () => { - const i = this.choiceViews.findIndex((c) => c.label === label); - if (i >= 0) { - this.cursor.index = i; - this.refreshChoiceColors(); - } - }); - label.on('pointerout', () => this.refreshChoiceColors()); - label.on('pointertap', () => this.onChoice?.(item.index)); - this.view.addChild(label); - this.choiceViews.push({ item, label }); - } - } - - private refreshChoiceColors(): void { - this.choiceViews.forEach((c, i) => { - c.label.style.fill = i === this.cursor.index ? COLOR_CHOICE_ACTIVE : COLOR_CHOICE; - }); - } - - /** Панель от контента: растёт вверх от нижнего края. */ - private relayout(): void { - const w = this.width; - const h = this.height; - const m = this.margin; - // Место под «далее ▸» резервируется всегда (без выбора) — панель не - // прыгает в конце печати; сам текст виден только после печати. - const withHint = this.choiceViews.length === 0; - this.hintText.visible = withHint && !this.revealingNow; - - this.panel.clear(); - const pad = 6; - const nameH = this.nameText.text ? this.nameText.height + 2 : 0; - const bodyH = this.fullText || this.bodyText.text ? this.bodyText.height : 0; - let choicesH = 0; - for (const c of this.choiceViews) choicesH += c.label.height + 3; - const hintH = withHint ? this.hintText.height + 2 : 0; - const panelH = Math.round(pad * 2 + nameH + bodyH + choicesH + hintH); - const top = h - m - panelH; - - this.panel.rect(m, top, w - m * 2, panelH).fill({ color: 0x101018, alpha: 0.92 }); - this.panel.rect(m, top, w - m * 2, panelH).stroke({ color: 0x8899aa, width: 1 }); - - let y = top + pad; - if (nameH > 0) { - this.nameText.position.set(m + 5, y); - y += nameH; - } - this.bodyText.position.set(m + 5, y); - y += bodyH; - for (const c of this.choiceViews) { - c.label.position.set(m + 5, y); - y += c.label.height + 3; - } - if (withHint) { - this.hintText.position.set(w - m - 5 - this.hintText.width, y + 2); - } - } -} \ No newline at end of file diff --git a/packages/engine/src/ui/MenuList.ts b/packages/engine/src/ui/MenuList.ts deleted file mode 100644 index 9221648..0000000 --- a/packages/engine/src/ui/MenuList.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Container } from 'pixi.js'; -import { Button } from './Button'; -import { ListCursor } from './listCursor'; -import { ListScroll } from './listScroll'; - -/** - * Вертикальное меню: список кнопок с клавиатурным фокусом и навигацией. - * Игра сама читает ввод и вызывает moveCursor/activate — движок не привязан к раскладке. - * Недоступные пункты (disabled) приглушены: курсор их пропускает, клик игнорируется. - * Длинные списки: visibleRows задаёт окно видимых строк (скролл — ListScroll); - * hover мышью переставляет курсор; пункт может нести value (громкость, счётчик). - */ - -/** Пункт меню. */ -export interface MenuItem { - label: string; - onSelect?: () => void; - /** Недоступный пункт: приглушён, курсор пропускает, активация игнорируется. */ - disabled?: boolean; - /** Значение справа (громкость, счётчик) — обновляется через setValue. */ - value?: string; -} - -export interface MenuListOptions { - width: number; - height: number; - /** Отступ между кнопками (виртуальные пиксели). */ - gap?: number; - size?: number; - /** Видимых строк (скролл); нет/0 — окно = весь список, как раньше. */ - visibleRows?: number; -} - -export class MenuList extends Container { - private buttons: Button[] = []; - private listCursor: ListCursor; - private scroll: ListScroll | null = null; - - constructor(private options: MenuListOptions) { - super(); - this.listCursor = new ListCursor(0); - } - - /** Пересобрать список пунктов (старые кнопки уничтожаются); фокус сохраняется. */ - setItems(items: MenuItem[]): void { - for (const b of this.buttons) b.destroy({ children: true }); - this.buttons = []; - const rows = this.options.visibleRows ?? 0; - this.scroll = rows > 0 ? new ListScroll(rows) : null; - let y = 0; - for (const item of items) { - const btn = new Button({ - label: item.label, - width: this.options.width, - height: this.options.height, - size: this.options.size, - value: item.value, - disabled: item.disabled, - onSelect: item.onSelect - }); - btn.position.set(0, y); - // Hover мышью/тачем переставляет курсор (как выбор в DialogueBox). - const index = this.buttons.length; - btn.on('pointerover', () => { - if (!item.disabled && index !== this.listCursor.index) { - this.listCursor.index = index; - this.applyFocus(); - } - }); - this.addChild(btn); - this.buttons.push(btn); - y += this.step(); - } - const keep = Math.min(this.listCursor.index, Math.max(0, items.length - 1)); - this.listCursor = new ListCursor(items.length, (i) => !items[i]?.disabled); - this.listCursor.index = keep; - if (items[keep]?.disabled) this.listCursor.move(1); // старт на выбираемом - this.applyFocus(); - } - - /** Сдвинуть фокус (↑ = -1, ↓ = +1); недоступные пункты пропускаются. */ - moveCursor(delta: number): void { - this.listCursor.move(delta); - this.applyFocus(); - } - - /** Выбранный индекс. */ - get index(): number { - return this.listCursor.index; - } - - /** Активировать кнопку под фокусом (Enter). */ - activate(): void { - this.buttons[this.listCursor.index]?.activate(); - } - - /** Обновить значение пункта справа (настройки — без пересборки списка). */ - setValue(index: number, value: string): void { - const btn = this.buttons[index]; - if (btn) btn.value = value; - } - - /** Окно видимых строк (или весь список, если скролла нет). */ - get window(): { first: number; last: number } | null { - return this.scroll ? { first: this.scroll.first, last: this.scroll.last } : null; - } - - private applyFocus(): void { - this.buttons.forEach((b, i) => (b.focused = i === this.listCursor.index)); - if (this.scroll) { - this.scroll.sync(this.listCursor.index, this.buttons.length); - for (let i = 0; i < this.buttons.length; i++) { - const visible = i >= this.scroll.first && i < this.scroll.last; - this.buttons[i]!.visible = visible; - this.buttons[i]!.position.y = (i - this.scroll.first) * this.step(); - } - } - } - - private step(): number { - return this.options.height + (this.options.gap ?? 2); - } -} \ No newline at end of file diff --git a/packages/engine/src/ui/MenuSceneBase.ts b/packages/engine/src/ui/MenuSceneBase.ts deleted file mode 100644 index 9e55209..0000000 --- a/packages/engine/src/ui/MenuSceneBase.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Container } from 'pixi.js'; -import type { Scene } from '../scene/SceneManager'; -import type { MenuList } from './MenuList'; -import { readMenuInput, type MenuActionMap } from './menuInput'; - -/** - * Базовая сцена-меню: панель поверх вызывающей сцены (push/pop) с клавиатурной - * навигацией. Устраняет дублирование «up/down/confirm/cancel → moveCursor/ - * activate + гард перехода» из четырёх сцен игры. Игра строит панель и меню - * своим стилем в build(); ввод читается по карте действий сцены. - */ -export interface MenuSceneContext { - /** Ввод (InputManager или заглушка). */ - input: { isActionJustPressed(action: string): boolean }; - /** true, пока стек сцен в переходе — ввод блокируется. */ - inputBlocked(): boolean; -} - -export abstract class MenuSceneBase implements Scene { - /** Корень вью сцены; build() добавляет его в uiRoot. */ - protected readonly view = new Container(); - /** Список меню (null — сцена без списка, например просмотр сумки). */ - protected menu: MenuList | null = null; - - protected constructor( - private readonly ctx: MenuSceneContext, - private readonly actions: MenuActionMap - ) {} - - /** Построить панель/заголовок/меню и добавить this.view в uiRoot. */ - protected abstract build(): void; - - enter(): void { - this.build(); - } - - exit(): void { - this.view.destroy({ children: true }); - this.menu = null; - } - - render(): void {} - - update(_dt: number): void { - if (this.ctx.inputBlocked()) return; - const index = this.menu?.index ?? 0; - for (const ev of readMenuInput(this.ctx.input, index, this.actions)) { - switch (ev.kind) { - case 'move': - this.onMove(ev.delta, ev.index); - break; - case 'adjust': - this.onAdjust(ev.delta, ev.index); - break; - case 'confirm': - this.onConfirm(ev.index); - break; - case 'cancel': - this.onCancel(); - break; - case 'action': - this.onAction(ev.action, ev.index); - break; - } - } - } - - /** Дефолт: двигать курсор списка. */ - protected onMove(delta: number, _index: number): void { - this.menu?.moveCursor(delta); - } - - /** Подстройка значения выбранной строки (настройки); по умолчанию — ничего. */ - protected onAdjust(_delta: number, _index: number): void {} - - /** Дефолт: активировать кнопку под фокусом. */ - protected onConfirm(_index: number): void { - this.menu?.activate(); - } - - /** Назад/закрыть; по умолчанию — ничего (главное меню не закрывается). */ - protected onCancel(): void {} - - /** Прочее действие сцены (extra из карты); по умолчанию — ничего. */ - protected onAction(_action: string, _index: number): void {} -} \ No newline at end of file diff --git a/packages/engine/src/ui/Panel.ts b/packages/engine/src/ui/Panel.ts deleted file mode 100644 index 569bd41..0000000 --- a/packages/engine/src/ui/Panel.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Container, Graphics, Texture, NineSliceSprite } from 'pixi.js'; - -/** - * Панель UI: тёмный прямоугольник с однопиксельной рамкой — базовый стиль движка. - * Если передана текстура, рисуется NineSliceSprite (растяжимые углы 4px). - */ -export interface PanelOptions { - width: number; - height: number; - fill?: number; - border?: number; - /** Прозрачность заливки 0..1. */ - fillAlpha?: number; - /** Текстура 9-slice (опционально). */ - texture?: Texture; -} - -export class Panel extends Container { - constructor(options: PanelOptions) { - super(); - if (options.texture) { - const nine = new NineSliceSprite({ - texture: options.texture, - leftWidth: 4, - topHeight: 4, - rightWidth: 4, - bottomHeight: 4, - width: options.width, - height: options.height - }); - this.addChild(nine); - return; - } - - const g = new Graphics(); - g.rect(0, 0, options.width, options.height) - .fill({ color: options.fill ?? 0x101018, alpha: options.fillAlpha ?? 0.92 }); - g.rect(0, 0, options.width, options.height) - .stroke({ color: options.border ?? 0x8899aa, width: 1 }); - this.addChild(g); - } -} \ No newline at end of file diff --git a/packages/engine/src/ui/PixelText.ts b/packages/engine/src/ui/PixelText.ts deleted file mode 100644 index 7e6237a..0000000 --- a/packages/engine/src/ui/PixelText.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Text, TextStyleOptions } from 'pixi.js'; - -/** - * Пиксельный шрифт движка: VT323 (OFL, есть кириллица). - * Загружается через FontFace API; PixelText — обёртка над Pixi Text - * с этим шрифтом. Если игры понадобится свой шрифт — меняется источник, - * обёртка остаётся. - */ - -export const PIXEL_FONT_FAMILY = 'VT323'; - -/** Фолбэк, если шрифт ещё не загрузился или не поддерживается. */ -export const PIXEL_FONT_FALLBACK = 'monospace'; - -let fontLoaded: Promise | null = null; - -/** - * Resolution растеризации текста (пикселей устройства на виртуальный пиксель). - * Задаётся Renderer'ом при смене масштаба; до этого тексты чуть мягче — не критично. - */ -let textResolution = 1; - -/** Задать resolution растеризации PixelText (вызывается движком при ресайзе). */ -export function setPixelTextResolution(resolution: number): void { - textResolution = Math.max(1, Math.round(resolution)); -} - -/** - * Загрузить пиксельный шрифт. url указывает на TTF (приложение решает, где он лежит). - * Повторные вызовы возвращают уже начатую загрузку. Безопасно вызывать до Pixi. - */ -export function ensurePixelFont(url: string, family: string = PIXEL_FONT_FAMILY): Promise { - if (fontLoaded) return fontLoaded; - fontLoaded = (async () => { - try { - const face = new FontFace(family, `url(${url})`); - await face.load(); - document.fonts.add(face); - } catch (err) { - // Не критично: PixelText останется с фолбэком monospace. - console.warn(`[engine] пиксельный шрифт не загрузился (${url}):`, err); - } - })(); - return fontLoaded; -} - -export interface PixelTextOptions { - text: string; - /** Размер в виртуальных пикселях (шрифт растровый — целые числа). */ - size: number; - /** Цвет (числом, 0xRRGGBB). */ - color?: number; - /** Перенос по ширине (виртуальные пиксели). */ - wordWrapWidth?: number; - lineHeight?: number; - align?: 'left' | 'center' | 'right'; - letterSpacing?: number; - fontFamily?: string; -} - -/** Текст пиксельным шрифтом движка. */ -export class PixelText extends Text { - constructor(options: PixelTextOptions) { - const family = `${options.fontFamily ?? PIXEL_FONT_FAMILY}, ${PIXEL_FONT_FALLBACK}`; - const style: TextStyleOptions = { - fontFamily: family, - fontSize: options.size, - fill: options.color ?? 0xffffff, - letterSpacing: options.letterSpacing ?? 0, - lineHeight: options.lineHeight - }; - if (options.wordWrapWidth !== undefined) { - style.wordWrap = true; - style.wordWrapWidth = options.wordWrapWidth; - style.breakWords = true; - } - if (options.align !== undefined) { - style.align = options.align; - } - // resolution > 1: глифы растеризуются 1:1 с пикселями устройства — резкий текст. - super({ text: options.text, style, resolution: textResolution }); - } -} \ No newline at end of file diff --git a/packages/engine/src/ui/SpriteDebugView.ts b/packages/engine/src/ui/SpriteDebugView.ts deleted file mode 100644 index 1754d7c..0000000 --- a/packages/engine/src/ui/SpriteDebugView.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Container, Graphics, Sprite, Texture } from 'pixi.js'; - -/** - * Дебаг-просмотр спрайта: текстура, увеличенная целым множителем (nearest), - * с сеткой по границам пикселей. Для правки пропорций и проверки кадров - * анимации. Показ текущего кадра — просто вызывайте setTexture каждый тик. - */ -export class SpriteDebugView { - readonly view: Container; - - private holder: Sprite; - private grid: Graphics; - private readonly zoom: number; - private readonly gridColor: number; - private readonly gridAlpha: number; - - constructor(opts: { zoom?: number; gridColor?: number; gridAlpha?: number } = {}) { - this.zoom = Math.max(1, Math.round(opts.zoom ?? 8)); - this.gridColor = opts.gridColor ?? 0x000000; - this.gridAlpha = opts.gridAlpha ?? 0.2; - - this.view = new Container(); - this.view.visible = false; - - this.holder = new Sprite(Texture.EMPTY); - this.holder.anchor.set(0.5); - this.grid = new Graphics(); - this.view.addChild(this.holder, this.grid); - } - - /** Показать текстуру: центрирует, масштабирует целым множителем, рисует сетку. */ - setTexture(tex: Texture): void { - this.holder.texture = tex; - this.holder.scale.set(this.zoom); - const w = tex.width * this.zoom; - const h = tex.height * this.zoom; - const g = this.grid; - g.clear(); - for (let x = 0; x <= tex.width; x++) { - g.moveTo(x * this.zoom - w / 2, -h / 2).lineTo(x * this.zoom - w / 2, h / 2); - } - for (let y = 0; y <= tex.height; y++) { - g.moveTo(-w / 2, y * this.zoom - h / 2).lineTo(w / 2, y * this.zoom - h / 2); - } - g.stroke({ color: this.gridColor, width: 1, alpha: this.gridAlpha }); - } -} \ No newline at end of file diff --git a/packages/engine/src/ui/__tests__/listCursor.test.ts b/packages/engine/src/ui/__tests__/listCursor.test.ts deleted file mode 100644 index 8d9b0cd..0000000 --- a/packages/engine/src/ui/__tests__/listCursor.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { ListCursor } from '../listCursor'; - -describe('ListCursor', () => { - it('move зацикливается по кругу', () => { - const c = new ListCursor(3); - c.move(1); - expect(c.index).toBe(1); - c.move(1); - c.move(1); - expect(c.index).toBe(0); // 2 + 1 -> 0 - c.move(-1); - expect(c.index).toBe(2); - }); - - it('пустой список не двигается', () => { - const c = new ListCursor(0); - c.move(1); - c.move(-1); - expect(c.empty).toBe(true); - expect(c.index).toBe(0); - }); - - it('setCount зажимает курсор при уменьшении списка', () => { - const c = new ListCursor(5); - c.move(4); - expect(c.index).toBe(4); - c.setCount(2); - expect(c.index).toBe(1); - }); - - it('setCount с ростом списка сохраняет индекс', () => { - const c = new ListCursor(2); - c.move(1); - c.setCount(4); - expect(c.index).toBe(1); - }); - - it('home/end ставят крайние позиции', () => { - const c = new ListCursor(4); - c.end(); - expect(c.index).toBe(3); - c.home(); - expect(c.index).toBe(0); - }); - - it('предикат выбираемости пропускает недоступные пункты', () => { - const c = new ListCursor(4, (i) => i !== 1 && i !== 2); // 1 и 2 — disabled - c.setCount(4); - c.move(1); // 0 -> 3 (1 и 2 пропущены) - expect(c.index).toBe(3); - c.move(1); // 3 -> 0 (по кругу, через 1 и 2) - expect(c.index).toBe(0); - c.move(-1); // 0 -> 3 - expect(c.index).toBe(3); - }); - - it('setCount на недоступном пункте сдвигается к выбираемому', () => { - const c = new ListCursor(3, (i) => i !== 1); - c.index = 1; - c.setCount(3); - expect(c.index).toBe(2); - }); - - it('все пункты недоступны — курсор стоит на месте', () => { - const c = new ListCursor(2, () => false); - c.move(1); - c.move(-1); - expect(c.index).toBe(0); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/ui/__tests__/listScroll.test.ts b/packages/engine/src/ui/__tests__/listScroll.test.ts deleted file mode 100644 index a2fd645..0000000 --- a/packages/engine/src/ui/__tests__/listScroll.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ListScroll } from '../listScroll'; - -describe('ListScroll — окно видимых строк', () => { - it('курсор внутри окна — offset не меняется', () => { - const s = new ListScroll(5); - s.sync(0, 20); - s.sync(3, 20); - expect(s.offset).toBe(0); - expect(s.first).toBe(0); - expect(s.last).toBe(5); - }); - - it('движение вниз у нижнего края листает окно на 1', () => { - const s = new ListScroll(5); - s.sync(4, 20); - s.sync(5, 20); - expect(s.offset).toBe(1); - expect(s.first).toBe(1); - expect(s.last).toBe(6); - }); - - it('движение вверх у верхнего края листает окно на 1', () => { - const s = new ListScroll(5); - s.sync(5, 20); - s.sync(4, 20); - expect(s.offset).toBe(1); - s.sync(0, 20); - expect(s.offset).toBe(0); - }); - - it('прыжок в конец показывает хвост списка', () => { - const s = new ListScroll(5); - s.sync(19, 20); - expect(s.offset).toBe(15); - expect(s.last).toBe(20); - }); - - it('список короче окна — offset 0, clamped', () => { - const s = new ListScroll(5); - s.sync(3, 4); - expect(s.offset).toBe(0); - expect(s.last).toBe(4); - expect(s.clamped).toBe(true); - }); - - it('пересборка списка короче — offset зажимается', () => { - const s = new ListScroll(5); - s.sync(15, 20); - expect(s.offset).toBe(11); // окно [11..16), курсор в конце окна - s.sync(2, 6); // после пересборки курсор в начале, список почти пуст - expect(s.offset).toBe(1); // 6-5=1, курсор 2 в окне [1,6) - }); -}); \ No newline at end of file diff --git a/packages/engine/src/ui/__tests__/menuInput.test.ts b/packages/engine/src/ui/__tests__/menuInput.test.ts deleted file mode 100644 index 0fea326..0000000 --- a/packages/engine/src/ui/__tests__/menuInput.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { readMenuInput, type MenuActionMap, type MenuInputSource } from '../menuInput'; - -/** Фейковый источник: нажатия задаются списком действий на тик. */ -function fakeInput(pressed: string[]): MenuInputSource & { pressed: string[] } { - return { pressed, isActionJustPressed: (a) => pressed.includes(a) }; -} - -const MAP: MenuActionMap = { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu' }; - -describe('readMenuInput', () => { - it('движение и подтверждение по карте действий', () => { - const ev = readMenuInput(fakeInput(['down']), 2, MAP); - expect(ev).toEqual([{ kind: 'move', delta: 1, index: 2 }]); - expect(readMenuInput(fakeInput(['advance']), 1, MAP)).toEqual([ - { kind: 'confirm', index: 1 } - ]); - expect(readMenuInput(fakeInput(['menu']), 1, MAP)).toEqual([{ kind: 'cancel' }]); - }); - - it('cancel замыкает тик — остальные события не читаются', () => { - const ev = readMenuInput(fakeInput(['menu', 'down', 'advance']), 0, MAP); - expect(ev).toEqual([{ kind: 'cancel' }]); - }); - - it('up и down в один тик дают один move', () => { - const ev = readMenuInput(fakeInput(['up', 'down']), 0, MAP); - expect(ev).toEqual([{ kind: 'move', delta: -1, index: 0 }]); - }); - - it('нажатые left/right подавляют confirm (настройки: без двойного шага)', () => { - const map: MenuActionMap = { ...MAP, left: 'left', right: 'right' }; - const ev = readMenuInput(fakeInput(['right', 'advance']), 3, map); - expect(ev).toEqual([{ kind: 'adjust', delta: 1, index: 3 }]); - const onlyEnter = readMenuInput(fakeInput(['advance']), 3, map); - expect(onlyEnter).toEqual([{ kind: 'confirm', index: 3 }]); - }); - - it('extra-действия читаются последними', () => { - const map: MenuActionMap = { ...MAP, extra: ['attack'] }; - const ev = readMenuInput(fakeInput(['down', 'attack']), 1, map); - expect(ev).toEqual([ - { kind: 'move', delta: 1, index: 1 }, - { kind: 'action', action: 'attack', index: 1 } - ]); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/ui/__tests__/reveal.test.ts b/packages/engine/src/ui/__tests__/reveal.test.ts deleted file mode 100644 index 6e45892..0000000 --- a/packages/engine/src/ui/__tests__/reveal.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { revealText } from '../reveal'; - -describe('revealText', () => { - it('в начале не показано ничего', () => { - expect(revealText('привет', 0, 10)).toEqual({ shown: 0, done: false }); - }); - - it('печатает пропорционально времени', () => { - // 20 cps: 250мс → 5 символов - expect(revealText('привет мир', 250, 20)).toEqual({ shown: 5, done: false }); - expect(revealText('привет мир', 550, 20)).toEqual({ shown: 10, done: true }); - }); - - it('перелистывание назад невозможно: показ не больше длины', () => { - expect(revealText('ok', 10000, 5).shown).toBe(2); - }); - - it('cps <= 0 — весь текст сразу', () => { - expect(revealText('привет', 0, 0)).toEqual({ shown: 6, done: true }); - expect(revealText('привет', 0, -5)).toEqual({ shown: 6, done: true }); - }); - - it('пустой текст — сразу готов', () => { - expect(revealText('', 0, 10)).toEqual({ shown: 0, done: true }); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/ui/listCursor.ts b/packages/engine/src/ui/listCursor.ts deleted file mode 100644 index a6f3e80..0000000 --- a/packages/engine/src/ui/listCursor.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Чистая логика курсора вертикального списка: без Pixi, тестируется изолированно. - * MenuList использует её для клавиатурной навигации; предикат выбираемости - * пропускает недоступные пункты (disabled). - */ - -export class ListCursor { - /** Индекс выбранного элемента. */ - index = 0; - - constructor( - private count: number, - private selectable: (index: number) => boolean = () => true - ) {} - - /** Изменить количество элементов (список пересобран); курсор зажимается. */ - setCount(count: number): void { - this.count = count; - if (this.index >= count) { - this.index = Math.max(0, count - 1); - } - if (!this.selectable(this.index)) this.move(1); - } - - /** Сдвиг на |delta| шагов; каждый шаг — к соседнему выбираемому элементу. */ - move(delta: number): void { - if (this.count === 0) return; - const dir = Math.sign(delta); - let steps = Math.abs(delta); - while (steps > 0) { - let landed = false; - for (let i = 1; i <= this.count; i++) { - const next = (((this.index + dir * i) % this.count) + this.count) % this.count; - if (this.selectable(next)) { - this.index = next; - landed = true; - break; - } - } - if (!landed) return; // все элементы недоступны — курсор стоит - steps--; - } - } - - /** Курсор в начало/конец. */ - home(): void { - this.index = 0; - } - - end(): void { - if (this.count > 0) this.index = this.count - 1; - } - - /** Есть ли элементы. */ - get empty(): boolean { - return this.count === 0; - } -} \ No newline at end of file diff --git a/packages/engine/src/ui/listScroll.ts b/packages/engine/src/ui/listScroll.ts deleted file mode 100644 index 800756e..0000000 --- a/packages/engine/src/ui/listScroll.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Чистая логика окна видимых строк вертикального списка: без Pixi, - * тестируется изолированно (по образцу ListCursor). MenuList использует её - * для скролла длинных списков: курсор всегда в окне, окно — в [0, count]. - */ -export class ListScroll { - /** Индекс первой видимой строки. */ - offset = 0; - /** Число элементов (из последнего sync) — для clamped. */ - private count = 0; - - constructor(readonly rows: number) {} - - /** Пересчёт окна под выбранный индекс; возвращает offset. */ - sync(index: number, count: number): number { - this.count = count; - const max = Math.max(0, count - this.rows); - if (this.offset > max) this.offset = max; // список пересобран короче - if (index < this.offset) this.offset = index; // курсор ушёл выше окна - else if (index >= this.offset + this.rows) { - this.offset = Math.min(max, index - this.rows + 1); // ниже окна - } - return this.offset; - } - - /** Индекс первой видимой строки. */ - get first(): number { - return this.offset; - } - - /** Исключающая граница окна (после последней видимой строки). */ - get last(): number { - return Math.min(this.count, this.offset + this.rows); - } - - /** Список короче окна: всё видно, прокрутки нет. */ - get clamped(): boolean { - return this.count <= this.rows; - } -} \ No newline at end of file diff --git a/packages/engine/src/ui/menuInput.ts b/packages/engine/src/ui/menuInput.ts deleted file mode 100644 index 39a9aa1..0000000 --- a/packages/engine/src/ui/menuInput.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Чистое чтение навигации меню: без Pixi, тестируется изолированно. - * Движок не привязан к раскладке — имена действий даёт сцена (биндятся игрой). - * Порядок событий за тик: cancel → move → (adjust XOR confirm) → extra. - */ - -/** Карта действий меню (все — имена действий InputManager). */ -export interface MenuActionMap { - up: string; - down: string; - /** Активировать выбранный пункт. */ - confirm: string; - /** Назад / закрыть панель. */ - cancel: string; - /** Подстройка значения (настройки); нажатые left/right подавляют confirm. */ - left?: string; - right?: string; - /** Прочие действия сцены (удаление сейва, открытие сумки и т.п.). */ - extra?: readonly string[]; -} - -export type MenuNavEvent = - | { kind: 'move'; delta: -1 | 1; index: number } - | { kind: 'adjust'; delta: -1 | 1; index: number } - | { kind: 'confirm'; index: number } - | { kind: 'cancel' } - | { kind: 'action'; action: string; index: number }; - -/** Источник «нажато в этом тике» — InputManager (или заглушка в тестах). */ -export interface MenuInputSource { - isActionJustPressed(action: string): boolean; -} - -/** Прочитать навигационные события за один тик. */ -export function readMenuInput(input: MenuInputSource, index: number, map: MenuActionMap): MenuNavEvent[] { - if (input.isActionJustPressed(map.cancel)) return [{ kind: 'cancel' }]; - const events: MenuNavEvent[] = []; - if (input.isActionJustPressed(map.up)) events.push({ kind: 'move', delta: -1, index }); - else if (input.isActionJustPressed(map.down)) events.push({ kind: 'move', delta: 1, index }); - // left/right перекрывают confirm: в настройках Enter не должен давать двойной шаг - const left = map.left !== undefined && input.isActionJustPressed(map.left); - const right = map.right !== undefined && input.isActionJustPressed(map.right); - if (left || right) events.push({ kind: 'adjust', delta: right ? 1 : -1, index }); - else if (input.isActionJustPressed(map.confirm)) events.push({ kind: 'confirm', index }); - for (const action of map.extra ?? []) { - if (input.isActionJustPressed(action)) events.push({ kind: 'action', action, index }); - } - return events; -} \ No newline at end of file diff --git a/packages/engine/src/ui/reveal.ts b/packages/engine/src/ui/reveal.ts deleted file mode 100644 index 1882bac..0000000 --- a/packages/engine/src/ui/reveal.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Печать текста по символам (typewriter) — чистая логика без Pixi: - * тестируется в node, DialogueBox только применяет результат к PixelText. - */ - -export interface RevealState { - /** Сколько символов показано. */ - shown: number; - /** Печатание закончилось. */ - done: boolean; -} - -/** - * Сколько символов текста `full` видно через `elapsedMs` при скорости - * `cps` (символов в секунду). cps <= 0 — текст показан сразу. - */ -export function revealText(full: string, elapsedMs: number, cps: number): RevealState { - if (cps <= 0 || full.length === 0) return { shown: full.length, done: true }; - const shown = Math.min(full.length, Math.floor((elapsedMs * cps) / 1000)); - return { shown, done: shown >= full.length }; -} \ No newline at end of file diff --git a/packages/engine/tools/__tests__/atlas.test.mjs b/packages/engine/tools/__tests__/atlas.test.mjs deleted file mode 100644 index 48b5771..0000000 --- a/packages/engine/tools/__tests__/atlas.test.mjs +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { findFigures, resizeRect, quantize } from '../imaging.mjs'; -import { buildAtlas } from '../atlas.mjs'; -import { encodePng, decodePng } from '../png.mjs'; - -/** Однотонное изображение w×h цвета [r,g,b] (alpha 255). */ -const solid = (w, h, [r, g, b]) => { - const rgba = new Uint8Array(w * h * 4); - for (let p = 0; p < rgba.length; p += 4) { - rgba[p] = r; rgba[p + 1] = g; rgba[p + 2] = b; rgba[p + 3] = 255; - } - return { width: w, height: h, rgba }; -}; - -/** Прямоугольник цвета color на фоне bg (внутри — сплошной). */ -const withRect = (img, rect, [r, g, b]) => { - for (let y = rect.y; y < rect.y + rect.h; y++) { - for (let x = rect.x; x < rect.x + rect.w; x++) { - const i = (y * img.width + x) * 4; - img.rgba[i] = r; img.rgba[i + 1] = g; img.rgba[i + 2] = b; - } - } - return img; -}; - -describe('findFigures', () => { - it('находит фигуры на фоне и сортирует сверху-вниз/слева-направо', () => { - const img = withRect(withRect(withRect( - solid(64, 64, [255, 255, 255]), - { x: 4, y: 8, w: 10, h: 12 }, [10, 20, 30]), - { x: 40, y: 4, w: 12, h: 14 }, [40, 50, 60]), - { x: 20, y: 40, w: 16, h: 16 }, [70, 80, 90] - ); - expect(findFigures(img, { minArea: 30 })).toEqual([ - { x: 40, y: 4, w: 12, h: 14 }, // y=4 раньше y=8 - { x: 4, y: 8, w: 10, h: 12 }, - { x: 20, y: 40, w: 16, h: 16 } - ]); - }); - - it('bg: corner берёт фон из углового пикселя, мелочь отсеивается по minArea', () => { - const img = withRect(solid(32, 32, [240, 240, 240]), - { x: 2, y: 2, w: 20, h: 20 }, [0, 0, 0]); - img.rgba[(31 * 32 + 31) * 4] = 0; // одиночный пиксель в углу — шум - const figs = findFigures(img, { minArea: 100 }); - expect(figs).toEqual([{ x: 2, y: 2, w: 20, h: 20 }]); - }); - - it('явный bg и dist: пиксели ближе порога сливаются с фоном', () => { - const img = withRect(solid(32, 32, [0, 0, 0]), - { x: 1, y: 1, w: 10, h: 10 }, [30, 30, 30]); - // dist 100: |30|*3 = 90 < 100 — фигура не видна - expect(findFigures(img, { bg: [0, 0, 0], dist: 100, minArea: 10 })).toEqual([]); - // dist 50: фигура видна - expect(findFigures(img, { bg: [0, 0, 0], dist: 50, minArea: 10 })).toEqual([ - { x: 1, y: 1, w: 10, h: 10 } - ]); - }); -}); - -describe('resizeRect', () => { - it('nearest-сэмпл по центрам ячеек', () => { - const img = withRect(solid(8, 8, [255, 255, 255]), - { x: 2, y: 2, w: 4, h: 4 }, [255, 0, 0]); - const out = resizeRect(img, { x: 2, y: 2, w: 4, h: 4 }, 2, 2); - // k = 2, центры (0.5,0.5)*2 = (1,1) — все сэмплы внутри красного - expect(out[0]).toBe(255); expect(out[1]).toBe(0); expect(out[3]).toBe(255); - }); - - it('фон становится прозрачным (bg corner + dist)', () => { - const img = withRect(solid(8, 8, [255, 255, 255]), - { x: 2, y: 2, w: 4, h: 4 }, [255, 0, 0]); - const out = resizeRect(img, { x: 0, y: 0, w: 8, h: 8 }, 4, 4); - // k = 2: углы сэмплят фон -> alpha 0; центр (2..3,2..3) — красный - expect(out[3]).toBe(0); - const center = (2 * 4 + 2) * 4; - expect(out[center]).toBe(255); expect(out[center + 1]).toBe(0); - expect(out[center + 3]).toBe(255); - }); -}); - -describe('buildAtlas', () => { - const frame = (r) => { - const f = new Uint8Array(16 * 24 * 4); - for (let p = 0; p < f.length; p += 4) f[p] = r, f[p + 3] = 255; - return f; - }; - it('кадры в строку + Pixi JSON (формат hero_sheet)', () => { - const { png, json } = buildAtlas([frame(1), frame(2)], ['a', 'b'], { frameW: 16, frameH: 24, image: 'x.png' }); - const sheet = decodePng(png); - expect(sheet.width).toBe(32); - expect(sheet.height).toBe(24); - expect(sheet.rgba[0]).toBe(1); - expect(sheet.rgba[(0 * 32 + 16) * 4]).toBe(2); // второй кадр с x=16 - expect(json.frames.a.frame).toEqual({ x: 0, y: 0, w: 16, h: 24 }); - expect(json.frames.b.frame).toEqual({ x: 16, y: 0, w: 16, h: 24 }); - expect(json.meta).toEqual({ image: 'x.png', size: { w: 32, h: 24 }, scale: 1 }); - }); - it('бросает при расхождении длины имён и размере кадра', () => { - expect(() => buildAtlas([frame(1)], ['a', 'b'], { frameW: 16, frameH: 24, image: 'x.png' })).toThrow(); - expect(() => buildAtlas([new Uint8Array(10)], ['a'], { frameW: 16, frameH: 24, image: 'x.png' })).toThrow(); - }); - it('animations: индексы → списки имён кадров (формат Pixi)', () => { - const { json } = buildAtlas([frame(1), frame(2), frame(3)], ['a', 'b', 'c'], { - frameW: 16, frameH: 24, image: 'x.png', - animations: { walk: [0, 1], idle: [0] } - }); - expect(json.animations).toEqual({ walk: ['a', 'b'], idle: ['a'] }); - // Без animations ключа — поле отсутствует (обратная совместимость) - const plain = buildAtlas([frame(1)], ['a'], { frameW: 16, frameH: 24, image: 'x.png' }); - expect('animations' in plain.json).toBe(false); - }); - it('animations: индекс вне кадров — ошибка', () => { - expect(() => buildAtlas([frame(1)], ['a'], { - frameW: 16, frameH: 24, image: 'x.png', animations: { walk: [1] } - })).toThrow(); - }); -}); - -describe('roundtrip findFigures -> resizeRect -> quantize на PNG', () => { - it('лист с двумя квадратами -> кадры в палитре', () => { - const img = withRect(withRect(solid(64, 32, [255, 255, 255]), - { x: 4, y: 4, w: 20, h: 24 }, [255, 0, 0]), - { x: 36, y: 4, w: 20, h: 24 }, [0, 0, 255]); - const figs = findFigures(img, { minArea: 100 }); - expect(figs).toHaveLength(2); - const first = quantize(resizeRect(img, figs[0], 8, 8), [[255, 0, 0], [0, 0, 255], [255, 255, 255]]); - const second = quantize(resizeRect(img, figs[1], 8, 8), [[255, 0, 0], [0, 0, 255], [255, 255, 255]]); - expect(first.length).toBe(8 * 8 * 4); - // все сэмплы внутри bbox фигуры -> весь кадр в цвете фигуры, без фона - expect([...first.slice(0, 3)]).toEqual([255, 0, 0]); - expect([...second.slice(0, 3)]).toEqual([0, 0, 255]); - }); -}); \ No newline at end of file diff --git a/packages/engine/tools/__tests__/boundary.test.mjs b/packages/engine/tools/__tests__/boundary.test.mjs deleted file mode 100644 index d1a2a44..0000000 --- a/packages/engine/tools/__tests__/boundary.test.mjs +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { checkBoundary, importSpecs } from '../boundary.mjs'; - -/** Пути в test-параметрах пишутся со слэшем, приводим к системным сепараторам. */ -const sep = process.platform === 'win32' ? '\\' : '/'; -const files = (entries) => new Map(entries.map(([f, src]) => [f.replaceAll('/', sep), src])); - -describe('importSpecs', () => { - it('вытаскивает статические, side-effect, re-export и динамические импорты', () => { - const src = [ - "import { a } from './a';", - "import './b';", - 'import * as ns from "../c/d";', - "const m = await import('./dynamic');", - "export { x } from './reexport';" - ].join('\n'); - expect(importSpecs(src)).toEqual(['./a', './b', '../c/d', './dynamic', './reexport']); - }); -}); - -describe('checkBoundary: ядро', () => { - it('импорт из приложения — error', () => { - const v = checkBoundary(files([ - ['packages/engine/src/core/x.ts', "import { hero } from '../../../apps/game/src/data/npcs';"] - ])); - expect(v).toEqual([ - expect.objectContaining({ severity: 'error', rule: 'engine-no-game' }) - ]); - }); - - it('относительный импорт мимо пакета — error', () => { - const v = checkBoundary(files([ - ['packages/engine/src/core/x.ts', "import { t } from '../../../CLAUDE.md';"] - ])); - expect(v[0]).toMatchObject({ rule: 'engine-self-contained', severity: 'error' }); - }); - - it('npm-импорты и внутренние относительные — чисто', () => { - const v = checkBoundary(files([ - ['packages/engine/src/render/y.ts', "import { Container } from 'pixi.js';\nimport { a } from '../core/a';"] - ])); - expect(v).toEqual([]); - }); -}); - -describe('checkBoundary: приложение', () => { - it('под-путь ядра мимо публичного API — error', () => { - const v = checkBoundary(files([ - ['apps/game/src/systems/z.ts', "import { iso } from '@rpg/engine/math/iso';"] - ])); - expect(v[0]).toMatchObject({ rule: 'game-engine-public-api', severity: 'error', spec: '@rpg/engine/math/iso' }); - }); - - it('публичное API разрешено: сам пакет, assets/* и tools/*', () => { - const v = checkBoundary(files([ - ['apps/game/src/main.ts', "import { Game } from '@rpg/engine';\nimport fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url';"], - ['apps/game/src/systems/steps.ts', "import { bandNoise } from '@rpg/engine/tools/wav.mjs';"] - ])); - expect(v).toEqual([]); - }); - - it('относительный импорт в ядро мимо API — error', () => { - const v = checkBoundary(files([ - ['apps/game/src/data/map.ts', "import { rleDecode } from '../../../packages/engine/src/map/mapFormat';"] - ])); - expect(v[0]).toMatchObject({ rule: 'game-self-contained', severity: 'error' }); - }); - - it('npm-импорты и внутренние относительные — чисто', () => { - const v = checkBoundary(files([ - ['apps/game/src/scenes/s.ts', "import * as PIXI from 'pixi.js';\nimport { AREAS } from '../data/locations';"] - ])); - expect(v).toEqual([]); - }); - - it('опции расширяют публичное API (tools-под-путь)', () => { - const v = checkBoundary(files([ - ['apps/game/src/main.ts', "import { encodePng } from '@rpg/engine/tools/png.mjs';"] - ]), { publicApi: ['@rpg/engine', '@rpg/engine/assets/', '@rpg/engine/tools/'] }); - expect(v).toEqual([]); - }); - - it('файлы вне пакетов игнорируются', () => { - const v = checkBoundary(files([ - ['scripts/x.ts', "import { a } from '../../../packages/engine/src/index';"] - ])); - expect(v).toEqual([]); - }); -}); \ No newline at end of file diff --git a/packages/engine/tools/__tests__/frames.test.mjs b/packages/engine/tools/__tests__/frames.test.mjs deleted file mode 100644 index 4d3cf3f..0000000 --- a/packages/engine/tools/__tests__/frames.test.mjs +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { shiftFrame, bobFrame, squashFrame, mirrorFrame, swingFrame, deriveCycle } from '../frames.mjs'; - -/** Кадр 4×4 из строк символов: '.' — прозрачный, '#' — непрозрачный белый. */ -const frame = (rows) => { - const w = rows[0].length; - const h = rows.length; - const rgba = new Uint8Array(w * h * 4); - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - if (rows[y][x] === '#') rgba[(y * w + x) * 4 + 3] = 255; - } - } - return rgba; -}; - -/** Кадр обратно в строки символов (по alpha). */ -const toRows = (rgba, w, h) => { - const rows = []; - for (let y = 0; y < h; y++) { - let row = ''; - for (let x = 0; x < w; x++) row += rgba[(y * w + x) * 4 + 3] ? '#' : '.'; - rows.push(row); - } - return rows; -}; - -const SRC = frame([ - '....', - '.##.', - '.##.', - '....' -]); - -describe('shiftFrame', () => { - it('сдвигает содержимое, пустота — прозрачность', () => { - expect(toRows(shiftFrame(SRC, 4, 4, 1, 0), 4, 4)).toEqual([ - '....', - '..##', - '..##', - '....' - ]); - expect(toRows(shiftFrame(SRC, 4, 4, 0, -1), 4, 4)).toEqual([ - '.##.', - '.##.', - '....', - '....' - ]); - // Сдвиг за край — кадр полностью прозрачный - expect(toRows(shiftFrame(SRC, 4, 4, 4, 0), 4, 4)).toEqual(['....', '....', '....', '....']); - }); -}); - -describe('bobFrame', () => { - it('поднимает тело на dy (по умолчанию 1)', () => { - expect(toRows(bobFrame(SRC, 4, 4), 4, 4)).toEqual([ - '.##.', - '.##.', - '....', - '....' - ]); - expect(toRows(bobFrame(SRC, 4, 4, 2), 4, 4)).toEqual([ - '.##.', - '....', - '....', - '....' - ]); - }); -}); - -describe('squashFrame', () => { - it('приседание: спрайт на 1 px вниз, нижний ряд срезается', () => { - const src = frame([ - '....', - '.##.', - '.##.', - '.##.' - ]); - expect(toRows(squashFrame(src, 4, 4), 4, 4)).toEqual([ - '....', - '....', - '.##.', - '.##.' - ]); - // Спрайт стал ниже: было 3 ряда содержимого, осталось 2 - const out = squashFrame(src, 4, 4); - expect(out[(0 * 4 + 1) * 4 + 3]).toBe(0); - expect(out[(3 * 4 + 1) * 4 + 3]).toBe(255); - }); -}); - -describe('mirrorFrame', () => { - it('отражает по горизонтали', () => { - const src = frame([ - '#...', - '.##.', - '....', - '....' - ]); - expect(toRows(mirrorFrame(src, 4, 4), 4, 4)).toEqual([ - '...#', - '.##.', - '....', - '....' - ]); - }); -}); - -describe('swingFrame', () => { - it('поворот вокруг низ-центра: 0 рад — кадр без изменений', () => { - expect(swingFrame(SRC, 4, 4, 0)).toEqual(SRC); - }); - - it('точка опоры остаётся на месте при малом угле', () => { - const out = swingFrame(SRC, 4, 4, 0.2); - // Нижняя пара пикселей фигуры (y=2) около оси — почти не сместилась - expect(out[(2 * 4 + 1) * 4 + 3]).toBe(255); - // Верх фигуры (y=1) ушла за пределы или сместилась — но что-то осталось - expect(toRows(out, 4, 4).join('')).not.toBe('................'); - }); -}); - -describe('deriveCycle', () => { - it('по дескриптору на кадр, операции применяются по порядку', () => { - const cycle = deriveCycle(SRC, 4, 4, [{}, { bob: 1 }, { mirror: true }]); - expect(cycle).toHaveLength(3); - expect(toRows(cycle[0], 4, 4)).toEqual(toRows(SRC, 4, 4)); - expect(toRows(cycle[1], 4, 4)).toEqual(toRows(bobFrame(SRC, 4, 4), 4, 4)); - expect(toRows(cycle[2], 4, 4)).toEqual(toRows(mirrorFrame(SRC, 4, 4), 4, 4)); - }); -}); \ No newline at end of file diff --git a/packages/engine/tools/__tests__/spectro.test.mjs b/packages/engine/tools/__tests__/spectro.test.mjs deleted file mode 100644 index 61939a1..0000000 --- a/packages/engine/tools/__tests__/spectro.test.mjs +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { encodeWav, decodeWav } from '../wav.mjs'; -import { spectrogram, envelope, spectroSheet } from '../spectro.mjs'; - -/** Синус 440 Гц амплитуды 0.5 (2 с — за пределами окна анализа). */ -const tone = (rate = 22050) => { - const n = rate * 2; - const s = new Float32Array(n); - for (let i = 0; i < n; i++) s[i] = 0.5 * Math.sin((2 * Math.PI * 440 * i) / rate); - return s; -}; - -describe('decodeWav', () => { - it('читает обратно то, что написал encodeWav', () => { - const src = tone(); - const { samples, rate } = decodeWav(encodeWav(src, 22050)); - expect(rate).toBe(22050); - expect(samples.length).toBe(src.length); - // 16-бит квантование: расхождение с оригиналом микроскопическое. - let err = 0; - for (let i = 0; i < samples.length; i++) err = Math.max(err, Math.abs(samples[i] - src[i])); - expect(err).toBeLessThan(1e-4); - }); - - it('падает с внятной ошибкой на не-WAV', () => { - expect(() => decodeWav(Buffer.from('not a wav file at all'))).toThrow(/не WAV/); - }); -}); - -describe('spectrogram/envelope', () => { - it('пик спектрограммы тона попадает на его частоту', () => { - const rate = 22050; - const spec = spectrogram(tone(rate), { win: 1024, hop: 512 }); - expect(spec.cols).toBeGreaterThan(0); - expect(Number.isFinite(spec.max)).toBe(true); - // 440 Гц → бин 440/22050*1024 ≈ 20 (из 512). - const bin = Math.round((440 / rate) * 1024); - let best = -Infinity; - let bestRow = 0; - for (let r = 0; r < spec.rows; r++) { - if (spec.db[r] > best) { - best = spec.db[r]; - bestRow = r; - } - } - expect(Math.abs(bestRow - bin)).toBeLessThanOrEqual(2); - }); - - it('огибающая нормализована в 0..1', () => { - const env = envelope(tone(), 22050); - for (const v of env) { - expect(v).toBeGreaterThanOrEqual(0); - expect(v).toBeLessThanOrEqual(1); - } - expect(env.some((v) => v > 0.9)).toBe(true); // максимум достижим - }); -}); - -describe('spectroSheet', () => { - it('пишет PNG-лист по нескольким клипам', () => { - const dir = mkdtempSync(join(tmpdir(), 'spectro-')); - try { - const out = join(dir, 'sheet.png'); - const s = tone(); - spectroSheet( - [ - { name: 'a', samples: s, rate: 22050, loop: true }, - { name: 'b', samples: s, rate: 22050, loop: false } - ], - out - ); - const buf = readFileSync(out); - expect(buf.subarray(1, 4).toString()).toBe('PNG'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); -}); \ No newline at end of file diff --git a/packages/engine/tools/agent-lib.mjs b/packages/engine/tools/agent-lib.mjs deleted file mode 100644 index b1e342a..0000000 --- a/packages/engine/tools/agent-lib.mjs +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Библиотека агентных проверок (puppeteer-core + системный Chromium) поверх - * моста window.__agent (EngineAgent). Единственное место с браузерным - * бойлерплейтом: смоуки и сценарии строятся поверх openGame()/AgentClient. - * Контент приложения (маяк загрузки и т.п.) передаётся параметрами. - */ -import puppeteer from 'puppeteer-core'; -import { spawn } from 'node:child_process'; -import { setTimeout as sleep } from 'node:timers/promises'; - -/** Корень монорепо (тулза живёт в packages/engine/tools — 3 уровня). */ -const ROOT = new URL('../../..', import.meta.url); - -export const CHROMIUM = '/usr/bin/chromium'; - -/** Запустить браузер с нужными headless-флагами (см. грабли autoplay-policy). */ -export async function launchBrowser() { - return puppeteer.launch({ - executablePath: CHROMIUM, - headless: true, - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', - '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] - }); -} - -/** - * Поднять dev-сервер vite (spawn + ожидание HTTP 200). Возвращает {url, stop}. - * cwd — каталог приложения с его vite-конфигом и index.html; по умолчанию - * корень монорепо (где vite есть, но конфига приложения нет — приложение - * должно передавать свой каталог, иначе получит 404 на всё). - */ -export async function startDevServer({ port = 5199, cwd = ROOT.pathname } = {}) { - // detached: true — процесс в своей группе: stop() убивает и детей npx-обёртки - // (голый kill обёртки оставляет зомби-вите, который держит порт). - const proc = spawn('npx', ['vite', '--port', String(port), '--strictPort'], { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - detached: true - }); - const stop = () => { - try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); } - }; - // strictPort при занятом порте завершает наш vite сразу, но fetch продолжил - // бы отвечать чужим сервером — фиксируем выход и падаем. - let exited = null; - proc.on('exit', (code, signal) => { exited = { code, signal }; }); - const url = `http://localhost:${port}/`; - const deadline = Date.now() + 30000; - while (Date.now() < deadline) { - if (exited !== null) { - throw new Error(`vite (cwd ${cwd}) завершился сразу (код ${exited.code}) — порт ${port} занят чужим сервером?`); - } - try { - const res = await fetch(url); - if (res.ok) return { url, stop }; - } catch { /* ещё не поднялся */ } - await sleep(250); - } - stop(); - throw new Error(`dev-сервер на :${port} не поднялся за 30 с (cwd ${cwd})`); -} - -/** - * Открыть приложение и дождаться готовности агента. - * newGame=true — выполнить agent.newGame() и дождаться конца fade-перехода. - * beacon — консольная строка-маяк готовности; без него ждать сразу networkidle2. - * Возвращает {browser, page, agent}. - */ -export async function openGame({ url = 'http://localhost:5199/', newGame = true, beacon = null } = {}) { - const browser = await launchBrowser(); - const page = await browser.newPage(); - await page.setViewport({ width: 960, height: 540 }); - const consoleLogs = []; - page.on('console', (m) => consoleLogs.push(m.text())); - page.on('pageerror', (e) => consoleLogs.push(`[pageerror] ${e.message}`)); - - const booted = beacon - ? new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error(`маяк «${beacon}» не пришёл за 30 с`)), 30000); - page.on('console', (m) => { if (m.text().includes(beacon)) { clearTimeout(t); resolve(); } }); - }) - : Promise.resolve(); - await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); - await booted; - // Дождаться конца fade-перехода Boot->Menu: begin() молча отбрасывает - // команды, пришедшие во время перехода. - await sleep(1500); - - const agent = new AgentClient(page); - if (newGame) { - await agent.newGame(); - // fade-переход в локацию ещё идёт: сцена игнорирует клики до его конца. - await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); - } - return { browser, page, agent, consoleLogs }; -} - -/** - * Тонкий клиент поверх window.__agent: каждое поле — прокси-вызов page.evaluate. - * snapshot()/invariants() возвращают готовые объекты; waitFor — строка-pred. - */ -export class AgentClient { - constructor(page) { this.page = page; } - - async call(method, ...args) { - return this.page.evaluate((m, a) => { - const api = window.__agent; - if (!api) return { __agentError: 'window.__agent не зарегистрирован (DEV-сборка?)' }; - return api[m](...a); - }, method, args); - } - - snapshot() { return this.call('snapshot'); } - invariants() { return this.call('invariants'); } - step(n = 1, opts) { return this.call('step', n, opts); } - waitFor(pred, opts) { return this.call('waitFor', pred, opts); } - tapVirtual(x, y) { return this.call('tapVirtual', x, y); } - press(action, holdTicks) { return this.call('press', action, holdTicks); } - key(code) { return this.call('key', code); } - command(name, args) { return this.call('command', name, args); } - walkTo(x, y, opts) { return this.call('walkTo', x, y, opts); } - approach(x, y, opts) { return this.call('approach', x, y, opts); } - runDialogue(timeoutTicks) { return this.call('runDialogue', timeoutTicks); } - pickChoiceByText(text, timeoutTicks) { return this.call('pickChoiceByText', text, timeoutTicks); } - newGame() { return this.call('newGame'); } - currentArea() { return this.call('currentArea'); } - - async screenshot(path) { return this.page.screenshot({ path }); } -} - -/** - * Каркас сценариев: run() ловит исключения, finish() печатает JSON и ставит код. - * Использование: const c = new Checks('имя'); await c.run('шаг', async () => ...); c.finish(); - */ -export class Checks { - constructor(name) { - this.name = name; - this.results = []; - this.started = Date.now(); - } - /** Добавить проверку; исключение/ложь → неуспех, прогон продолжается. */ - async run(checkName, fn) { - const t0 = Date.now(); - try { - const details = await fn(); - this.results.push({ name: checkName, ok: details !== false, ms: Date.now() - t0, - details: details === true ? null : details ?? null }); - } catch (err) { - this.results.push({ name: checkName, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) }); - } - return this; - } - /** Ассерт-хелпер: неистинное условие кидает исключение с сообщением. */ - expect(cond, msg, details) { - if (!cond) throw new Error(msg + (details !== undefined ? `: ${JSON.stringify(details)}` : '')); - return details; - } - /** Напечатать JSON-отчёт и вернуть его (код выхода — через report.ok). */ - finish({ pretty = false } = {}) { - const report = { - name: this.name, - ok: this.results.every((r) => r.ok), - ms: Date.now() - this.started, - results: this.results - }; - console.log(pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report)); - return report; - } -} - -/** Разбор общих флагов CLI: --pretty/--json/--port/--only/--skip. */ -export function parseArgs(argv = process.argv.slice(2)) { - const out = { _: [] }; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === '--pretty' || a === '--json') out.pretty = a === '--pretty'; - else if (a === '--port') out.port = Number(argv[++i]); - else if (a === '--only') out.only = (argv[++i] ?? '').split(',').filter(Boolean); - else if (a === '--skip') out.skip = (argv[++i] ?? '').split(',').filter(Boolean); - else if (a === '--new-game') out.newGame = true; - else if (a === '--out') out.out = argv[++i]; - else if (a === '--steps') out.steps = Number(argv[++i]); - else out._.push(a); - } - return out; -} \ No newline at end of file diff --git a/packages/engine/tools/atlas.mjs b/packages/engine/tools/atlas.mjs deleted file mode 100644 index dc1b13a..0000000 --- a/packages/engine/tools/atlas.mjs +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Сборка спрайт-атласа (Pixi Spritesheet): кадры в одну строку + JSON-описание. - * Модуль ничего не знает о контенте: кадры — массив Uint8Array w×h×4 (RGBA), - * имена и размеры задаёт вызывающий. - */ -import { encodePng } from './png.mjs'; - -/** - * Кадры в строку атласа шириной frames.length × frameW. - * @param frames массив Uint8Array (frameW×frameH×4, RGBA) - * @param names имена кадров (ключи JSON.frames) - * @param opts { frameW, frameH, image, animations? } — image: имя PNG рядом - * с JSON; animations: Record<имя, number[]> (индексы в names) — - * попадает в JSON.animations списками имён кадров (формат Pixi) - * @returns { png: Buffer, json: object } - */ -export function buildAtlas(frames, names, { frameW, frameH, image, animations }) { - if (frames.length !== names.length) { - throw new Error(`кадры и имена разной длины: ${frames.length} != ${names.length}`); - } - const sheetW = frameW * frames.length; - const sheet = new Uint8Array(sheetW * frameH * 4); - const framesJson = {}; - frames.forEach((src, i) => { - if (src.length !== frameW * frameH * 4) { - throw new Error(`кадр ${names[i]}: ожидается ${frameW}×${frameH}×4 байт, пришло ${src.length}`); - } - for (let y = 0; y < frameH; y++) { - for (let x = 0; x < frameW; x++) { - sheet.set(src.subarray((y * frameW + x) * 4, (y * frameW + x) * 4 + 4), ((y * sheetW) + i * frameW + x) * 4); - } - } - framesJson[names[i]] = { - frame: { x: i * frameW, y: 0, w: frameW, h: frameH }, - rotated: false, - trimmed: false, - sourceSize: { w: frameW, h: frameH } - }; - }); - const json = { - frames: framesJson, - meta: { image, size: { w: sheetW, h: frameH }, scale: 1 } - }; - if (animations) { - // Формат Pixi Spritesheet: имя анимации → список имён кадров. - json.animations = Object.fromEntries( - Object.entries(animations).map(([name, idx]) => [ - name, - idx.map((i) => { - if (i < 0 || i >= names.length) { - throw new Error(`анимация ${name}: индекс ${i} вне кадров (${names.length})`); - } - return names[i]; - }) - ]) - ); - } - return { png: encodePng(sheetW, frameH, Buffer.from(sheet)), json }; -} \ No newline at end of file diff --git a/packages/engine/tools/boundary.mjs b/packages/engine/tools/boundary.mjs deleted file mode 100644 index e5cc679..0000000 --- a/packages/engine/tools/boundary.mjs +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Гвард границы «ядро / приложение» — правила проверки импортов: - * - * - src ядра не импортирует ничего из приложения и не выходит за пределы - * пакета относительными импортами; - * - src приложения импортирует ядро только через публичное API (сам пакет - * и разрешённые под-пути) и не выходит за пределы пакета. - * - * Библиотека не знает конкретной раскладки: корни пакетов и публичное API - * передаются параметрами (с дефолтами под раскладку этого монорепо). - * Сценарий запуска для конкретного репо — у приложения. - */ -import { readdirSync, readFileSync } from 'node:fs'; -import { dirname, join, relative, resolve, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -/** Дефолтный корень монорепо (тулза живёт в packages/engine/tools — 3 уровня). */ -const DEFAULT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); - -/** @returns {string[]} файлы .ts/.tsx рекурсивно. */ -export function listSources(dir) { - const out = []; - for (const e of readdirSync(dir, { withFileTypes: true })) { - const p = join(dir, e.name); - if (e.isDirectory()) out.push(...listSources(p)); - else if (e.isFile() && /\.tsx?$/.test(e.name)) out.push(p); - } - return out; -} - -/** Спецификаторы всех статических и динамических импортов файла. */ -export function importSpecs(source) { - const specs = []; - // Тело импорта без ';', чтобы не переползти в следующий import (side-effect - // `import './b'` — без from: группа пропускается, кавычка берётся сразу). - const re = /(?:\bimport\s+(?:[^;'"]*?\sfrom\s*)?|\bexport\s+[^;'"]*?\sfrom\s+|import\s*\(\s*)(['"])([^'"]+)\1/g; - for (const m of source.matchAll(re)) specs.push(m[2]); - return specs; -} - -/** - * Опции границы: root — корень репо (ключи files — пути относительно него), - * engineDir/gameDir — корни пакетов, enginePkg — имя пакета ядра, publicApi — - * разрешённые префиксы импорта ядра из приложения. - */ -function resolveOpts({ root = DEFAULT_ROOT, engineDir = join(root, 'packages', 'engine'), - gameDir = join(root, 'apps', 'game'), enginePkg = '@rpg/engine', - publicApi = [enginePkg, `${enginePkg}/assets/`, `${enginePkg}/tools/`] } = {}) { - return { root, engineDir, gameDir, enginePkg, publicApi }; -} - -/** - * Чистая проверка: пары «путь файла → содержимое» (относительно root, - * с системными разделителями). Возвращает нарушения: - * {severity, file, spec, rule, message}. - */ -export function checkBoundary(files, opts = {}) { - const { root, engineDir, gameDir, enginePkg, publicApi } = resolveOpts(opts); - const engineRel = relative(root, engineDir); - const gameRel = relative(root, gameDir); - const out = []; - for (const [rel, source] of files) { - const pkg = rel.startsWith(engineRel + sep) ? 'engine' : rel.startsWith(gameRel + sep) ? 'game' : null; - if (!pkg) continue; - for (const spec of importSpecs(source)) { - // Ядро не должно знать ничего о приложении. - if (pkg === 'engine') { - if (spec.includes(gameRel) || /(^|[\\/])game([\\/]|$)/.test(spec)) { - out.push({ severity: 'error', file: rel, spec, rule: 'engine-no-game', message: 'ядро импортирует приложение' }); - continue; - } - if (spec.startsWith('.')) { - const target = resolve(dirname(join(root, rel)), spec); - if (!target.startsWith(resolve(engineDir) + sep)) { - out.push({ severity: 'error', file: rel, spec, rule: 'engine-self-contained', message: 'относительный импорт выходит из пакета ядра' }); - } - } - continue; - } - // Приложение: ядро только через публичное API (голое имя пакета — - // только само по себе, префиксы — по началу строки). - if (spec.startsWith(enginePkg)) { - const allowed = spec === enginePkg || - publicApi.some((p) => p !== enginePkg && spec.startsWith(p)); - if (!allowed) { - out.push({ severity: 'error', file: rel, spec, rule: 'game-engine-public-api', message: 'под-путь ядра мимо публичного API' }); - } - continue; - } - if (spec.startsWith('.')) { - const target = resolve(dirname(join(root, rel)), spec); - if (!target.startsWith(resolve(gameDir) + sep)) { - out.push({ severity: 'error', file: rel, spec, rule: 'game-self-contained', message: 'относительный импорт выходит из пакета приложения' }); - } - } - } - } - return out; -} - -/** Собрать исходники обоих пакетов: путь (относительно root) → содержимое. */ -export function collectSources(opts = {}) { - const { root, engineDir, gameDir } = resolveOpts(opts); - const files = new Map(); - for (const dir of [join(engineDir, 'src'), join(gameDir, 'src')]) { - for (const abs of listSources(dir)) { - files.set(relative(root, abs), readFileSync(abs, 'utf8')); - } - } - return files; -} \ No newline at end of file diff --git a/packages/engine/tools/canvas.mjs b/packages/engine/tools/canvas.mjs deleted file mode 100644 index 1d800f0..0000000 --- a/packages/engine/tools/canvas.mjs +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Мини-канвас для пиксель-арта: пиксель, ромб 2:1, детерминированный шум, - * ASCII-карты спрайтов. Таблица цветов (ключ -> [r,g,b]) передаётся параметром — - * модуль не знает ни одной конкретной палитры. - */ -import { encodePng } from './png.mjs'; - -export class Canvas { - /** - * @param {number} width - * @param {number} height - * @param {Record} [colors] таблица цветов - * по ключам; без неё set() принимает только [r,g,b]-массивы. - */ - constructor(width, height, colors = {}) { - this.width = width; - this.height = height; - this.colors = colors; - this.data = Buffer.alloc(width * height * 4); - this.data.fill(0); - } - - /** color — ключ из таблицы цветов, [r,g,b]-массив или null (стереть). */ - set(x, y, color, alpha = 255) { - if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; - const i = (y * this.width + x) * 4; - if (color === null) { - this.data.fill(0, i, i + 4); - return; - } - const [r, g, b] = this.colors[color] ?? color; - this.data[i] = r; - this.data[i + 1] = g; - this.data[i + 2] = b; - this.data[i + 3] = alpha; - } - - /** Пиксель как [r,g,b,255] или null (вне канваса / прозрачный). */ - get(x, y) { - if (x < 0 || y < 0 || x >= this.width || y >= this.height) return null; - const i = (y * this.width + x) * 4; - if (this.data[i + 3] === 0) return null; - return [this.data[i], this.data[i + 1], this.data[i + 2], 255]; - } - - toPng() { - return encodePng(this.width, this.height, this.data); - } -} - -/** Детерминированный ГПСЧ (mulberry32). */ -export function rng(seed) { - 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; - }; -} - -/** - * Залить ромб 2:1 по канвасу: base — базовый цвет; свет на верхних гранях, - * тень на нижних. Цвета — ключи палитры канваса или null. - * Центр ромба (cx, cy), ширина w, высота h. - */ -export function fillDiamond(canvas, cx, cy, w, h, base, light = null, dark = null) { - const hw = w / 2; - const hh = h / 2; - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - const rx = x - hw + 0.5; - const ry = y - hh + 0.5; - if (Math.abs(rx) / hw + Math.abs(ry) / hh <= 1) { - canvas.set(cx - hw + x, cy - hh + y, base); - } - } - } - for (let i = 0; i < hw; i++) { - // Верхний склон: от левого угла к верхней вершине (свет) и от вершины к правому углу. - const leftY = cy - Math.round((i / hw) * hh); - const rightY = cy - hh + Math.round((i / hw) * hh); - if (light) { - canvas.set(cx - hw + i, leftY, light); - canvas.set(cx + i, rightY, light); - } - // Нижний склон: от левого угла вниз к нижней вершине (тень) и от неё к правому углу. - const botLeftY = cy + Math.round((i / hw) * hh); - const botRightY = cy + hh - Math.round((i / hw) * hh); - if (dark) { - canvas.set(cx - hw + i, botLeftY, dark); - canvas.set(cx + i, botRightY, dark); - } - } -} - -/** - * ASCII-карта -> спрайт на новом канвасе. Каждый символ — ключ таблицы цветов; - * '.' — прозрачный пиксель; extra отображает дополнительные символы ('x' -> 'P1'). - */ -export function fromAscii(rows, colors = {}, extra = {}) { - const h = rows.length; - const w = Math.max(...rows.map((r) => r.length)); - const canvas = new Canvas(w, h, colors); - for (let y = 0; y < h; y++) { - for (let x = 0; x < rows[y].length; x++) { - const ch = rows[y][x]; - if (ch === '.') { - continue; - } - const key = extra[ch] ?? ch; - if (!colors[key]) { - throw new Error(`Неизвестный символ '${ch}' в строке ${y}: ${rows[y]}`); - } - canvas.set(x, y, key); - } - } - return canvas; -} \ No newline at end of file diff --git a/packages/engine/tools/frames.mjs b/packages/engine/tools/frames.mjs deleted file mode 100644 index db29148..0000000 --- a/packages/engine/tools/frames.mjs +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Операции над кадрами (Uint8Array w×h×4 RGBA) — синтез производных кадров - * из опорных: псевдо-walk-циклы, idle-кадры оживителей, зеркалирование. - * Всё nearest, без сглаживания — пиксель-арт не мылится. - * Модуль жанронезависим: знает только формат RGBA-кадра. - */ - -/** Прозрачный ли пиксель (alpha 0). */ -const isEmpty = (rgba, i) => rgba[i + 3] === 0; - -/** Копия пикселя src[i] в dst[j] (или прозрачность, если src вне кадра). */ -function blit(src, dst, w, h, sx, sy, dx, dy) { - const j = (dy * w + dx) * 4; - if (sx < 0 || sy < 0 || sx >= w || sy >= h) { - dst[j + 3] = 0; - return; - } - const i = (sy * w + sx) * 4; - dst[j] = src[i]; - dst[j + 1] = src[i + 1]; - dst[j + 2] = src[i + 2]; - dst[j + 3] = src[i + 3]; -} - -/** - * Сдвиг кадра на (dx, dy) px; пустота заполняется прозрачностью. - * dx > 0 — вправо, dy > 0 — вниз. - */ -export function shiftFrame(src, w, h, dx, dy) { - const out = new Uint8Array(w * h * 4); - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - blit(src, out, w, h, x - dx, y - dy, x, y); - } - } - return out; -} - -/** - * Idle-кадр «парения»: тело поднимается на dy px (по умолчанию 1), - * снизу открывается прозрачность — для спрайтов с якорем по низу. - */ -export function bobFrame(src, w, h, dy = 1) { - return shiftFrame(src, w, h, 0, -dy); -} - -/** - * «Приседание»: весь спрайт уходит вниз на 1 px, нижний ряд срезается — - * спрайт становится ниже при том же якоре. Дешёвый 2-й кадр idle для - * спрайтов с полем прозрачности под ногами. - */ -export function squashFrame(src, w, h) { - return shiftFrame(src, w, h, 0, 1); -} - -/** Зеркалирование по горизонтали (кадр «вправо» → «влево»). */ -export function mirrorFrame(src, w, h) { - const out = new Uint8Array(w * h * 4); - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - blit(src, out, w, h, w - 1 - x, y, x, y); - } - } - return out; -} - -/** - * Покачивание: nearest-поворот на angle рад вокруг точки опоры - * (по умолчанию низ-центр — «укоренённое» качание, как sway). - * pivot: { x, y } в px. - */ -export function swingFrame(src, w, h, angle, pivot) { - const px = pivot ? pivot.x : (w - 1) / 2; - const py = pivot ? pivot.y : h - 1; - const cos = Math.cos(angle); - const sin = Math.sin(angle); - const out = new Uint8Array(w * h * 4); - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - // Обратный поворот dest → src (экранные координаты, y вниз). - const dx = x - px; - const dy = y - py; - blit(src, out, w, h, Math.round(px + dx * cos + dy * sin), Math.round(py - dx * sin + dy * cos), x, y); - } - } - return out; -} - -/** - * Цикл из опорного кадра: ops — список дескрипторов, один выходной кадр на - * дескриптор; операции внутри дескриптора применяются по порядку: - * { dx?, dy?, bob?, squash?, swing?, mirror? } - * (dx/dy — сдвиг; bob — парение вверх; squash — приседание; - * swing — угол покачивания в рад; mirror — зеркалирование). - */ -export function deriveCycle(src, w, h, ops) { - return ops.map((op) => applyOps(src, w, h, op)); -} - -function applyOps(src, w, h, op) { - let frame = src; - if (op.dx || op.dy) frame = shiftFrame(frame, w, h, op.dx ?? 0, op.dy ?? 0); - if (op.bob) frame = bobFrame(frame, w, h, op.bob); - if (op.squash) frame = squashFrame(frame, w, h); - if (op.swing) frame = swingFrame(frame, w, h, op.swing, op.pivot); - if (op.mirror) frame = mirrorFrame(frame, w, h); - return frame; -} \ No newline at end of file diff --git a/packages/engine/tools/imaging.mjs b/packages/engine/tools/imaging.mjs deleted file mode 100644 index b3f6e1f..0000000 --- a/packages/engine/tools/imaging.mjs +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Обработка растровых спрайтов: кроп по альфе, nearest-масштаб, квантизация - * в заданную палитру, обзорный лист. Палитра — параметр (массив [r,g,b]); - * модуль не знает ни одной конкретной палитры. - */ -import { writeFileSync } from 'node:fs'; -import { encodePng } from './png.mjs'; - -// ---------- кроп / масштаб ---------- - -/** Габариты непрозрачного содержимого (alpha >= 128). */ -function bbox({ width, height, rgba }) { - let x0 = width, y0 = height, x1 = -1, y1 = -1; - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - if (rgba[(y * width + x) * 4 + 3] >= 128) { - if (x < x0) x0 = x; - if (y < y0) y0 = y; - if (x > x1) x1 = x; - if (y > y1) y1 = y; - } - } - } - return x1 < 0 ? null : { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 }; -} - -/** Пиксель (x,y) кропнутого изображения. */ -function at({ width, rgba }, b, x, y) { - const i = ((b.y + y) * width + b.x + x) * 4; - return [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]; -} - -/** Кроп по альфе + nearest-масштаб до size×size (вписывание с центровкой). */ -export function normalizeToSize(img, size = 16) { - const b = bbox(img); - if (!b) return new Uint8Array(size * size * 4); // пустой спрайт - const k = Math.max(b.w / size, b.h / size); - const dw = Math.max(1, Math.round(b.w / k)); - const dh = Math.max(1, Math.round(b.h / k)); - const ox = (size - dw) >> 1, oy = (size - dh) >> 1; - const out = new Uint8Array(size * size * 4); - for (let y = 0; y < dh; y++) { - for (let x = 0; x < dw; x++) { - const sx = b.x + Math.min(b.w - 1, Math.floor((x + 0.5) * k)); - const sy = b.y + Math.min(b.h - 1, Math.floor((y + 0.5) * k)); - const src = at(img, b, sx - b.x, sy - b.y); - const i = ((oy + y) * size + (ox + x)) * 4; - out.set(src, i); - } - } - return out; -} - -/** - * Квантизация RGBA в палитру (массив [r,g,b]): полупрозрачные пиксели -> - * прозрачные, остальные — ближайший по RGB цвет палитры. - */ -export function quantize(rgba, pal) { - const out = new Uint8Array(rgba.length); - for (let p = 0; p < rgba.length; p += 4) { - if (rgba[p + 3] < 128) continue; - let best = pal[0], bd = Infinity; - for (const c of pal) { - const d = (rgba[p] - c[0]) ** 2 + (rgba[p + 1] - c[1]) ** 2 + (rgba[p + 2] - c[2]) ** 2; - if (d < bd) { bd = d; best = c; } - } - out[p] = best[0]; out[p + 1] = best[1]; out[p + 2] = best[2]; out[p + 3] = 255; - } - return out; -} - -// ---------- фигуры на однотонном фоне (AI-листы) ---------- - -/** Манхэттен-дистанция RGB до фонового цвета (порог задаёт вызывающий). */ -function bgDist(img, i, bg) { - return Math.abs(img.rgba[i] - bg[0]) + Math.abs(img.rgba[i + 1] - bg[1]) + Math.abs(img.rgba[i + 2] - bg[2]); -} - -/** - * Поиск фигур на однотонном фоне (AI-лист с несколькими фигурами): связные - * компоненты пикселей, отстоящих от фона дальше `dist`. `bg: 'corner'` — фон - * берётся из углового пикселя листа; либо явный [r,g,b]. Возвращает bbox'ы - * фигур, отсортированные сверху-вниз, затем слева-направо (не более maxCount, - * площадь каждой — не меньше minArea). - */ -export function findFigures(img, { bg = 'corner', dist = 60, maxCount = 4, minArea = 400 } = {}) { - const bgColor = bg === 'corner' - ? [img.rgba[0], img.rgba[1], img.rgba[2]] - : bg; - const { width: w, height: h } = img; - const seen = new Uint8Array(w * h); - const boxes = []; - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - const p = y * w + x; - if (seen[p] || bgDist(img, p * 4, bgColor) < dist) continue; - // BFS компоненты с бегущими границами bbox. - let x0 = x, y0 = y, x1 = x, y1 = y, area = 0; - const queue = [p]; - seen[p] = 1; - while (queue.length) { - const q = queue.pop(); - const qx = q % w, qy = (q / w) | 0; - area++; - if (qx < x0) x0 = qx; if (qx > x1) x1 = qx; - if (qy < y0) y0 = qy; if (qy > y1) y1 = qy; - for (const [nx, ny] of [[qx - 1, qy], [qx + 1, qy], [qx, qy - 1], [qx, qy + 1]]) { - if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; - const n = ny * w + nx; - if (!seen[n] && bgDist(img, n * 4, bgColor) >= dist) { - seen[n] = 1; - queue.push(n); - } - } - } - if (area >= minArea) boxes.push({ x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 }); - } - } - return boxes - .sort((a, b) => a.y - b.y || a.x - b.x) - .slice(0, maxCount); -} - -/** - * Nearest-даунскейл прямоугольника `rect = {x,y,w,h}` изображения до w×h - * с фоном-прозрачностью: сэмпл по центрам ячеек, пиксели ближе `dist` к фону - * становятся прозрачными (квантовать результат — шаг вызывающего). - */ -export function resizeRect(img, rect, w, h, { bg = 'corner', dist = 60 } = {}) { - const bgColor = bg === 'corner' ? [img.rgba[0], img.rgba[1], img.rgba[2]] : bg; - const out = new Uint8Array(w * h * 4); - const k = Math.max(rect.w / w, rect.h / h); - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - const sx = rect.x + Math.min(rect.w - 1, Math.floor((x + 0.5) * k)); - const sy = rect.y + Math.min(rect.h - 1, Math.floor((y + 0.5) * k)); - const si = (sy * img.width + sx) * 4; - if (bgDist(img, si, bgColor) < dist) continue; - out.set(img.rgba.subarray(si, si + 4), (y * w + x) * 4); - } - } - return out; -} - -// ---------- обзорный лист ---------- - -/** Лист PNG: сетка спрайтов (scale — во сколько раз увеличить каждый). */ -export function reviewSheet(files, path, { size = 16, scale = 4, cols = 6 } = {}) { - const pad = 6, cell = size * scale; - const rows = Math.ceil(files.length / cols); - const W = cols * (cell + pad) + pad; - const H = rows * (cell + pad) + pad; - const bg = Buffer.alloc(W * H * 4); - for (let p = 0; p < bg.length; p += 4) { bg[p] = 30; bg[p + 1] = 32; bg[p + 2] = 38; bg[p + 3] = 255; } - files.forEach(({ rgba }, i) => { - const cx = pad + (i % cols) * (cell + pad); - const cy = pad + Math.floor(i / cols) * (cell + pad); - for (let y = 0; y < cell; y++) { - for (let x = 0; x < cell; x++) { - const sp = ((y / scale) | 0) * size + ((x / scale) | 0); - const si = sp * 4, di = (cy + y) * W + cx + x; - if (rgba[si + 3] === 0) continue; - bg.set(rgba.subarray(si, si + 4), di * 4); - } - } - }); - writeFileSync(path, encodePng(W, H, Buffer.from(bg))); -} \ No newline at end of file diff --git a/packages/engine/tools/png.mjs b/packages/engine/tools/png.mjs deleted file mode 100644 index 7375ecb..0000000 --- a/packages/engine/tools/png.mjs +++ /dev/null @@ -1,109 +0,0 @@ -/** - * PNG-кодек без зависимостей (zlib из node:zlib): RGBA-пиксели <-> PNG-буфер. - * Энкодер пишет color type 6 (RGBA), bit depth 8, без чересстрочности; - * декодер читает ровно этот формат. - */ -import { deflateSync, inflateSync } from 'node:zlib'; - -// CRC32 для чанков PNG. -const CRC_TABLE = (() => { - const table = new Uint32Array(256); - for (let n = 0; n < 256; n++) { - let c = n; - for (let k = 0; k < 8; k++) { - c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - } - table[n] = c >>> 0; - } - return table; -})(); - -function crc32(buf) { - let c = 0xffffffff; - for (const b of buf) { - c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); - } - return (c ^ 0xffffffff) >>> 0; -} - -function chunk(type, data) { - const len = Buffer.alloc(4); - len.writeUInt32BE(data.length); - const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); - const crc = Buffer.alloc(4); - crc.writeUInt32BE(crc32(body)); - return Buffer.concat([len, body, crc]); -} - -/** rgba — Uint8Array длиной w*h*4. Возвращает PNG-буфер. */ -export function encodePng(width, height, rgba) { - // Сырые строки: байт фильтра (0 = None) + строка пикселей. - const raw = Buffer.alloc(height * (width * 4 + 1)); - for (let y = 0; y < height; y++) { - raw[y * (width * 4 + 1)] = 0; - rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4); - } - - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; // bit depth - ihdr[9] = 6; // RGBA - ihdr[10] = 0; - ihdr[11] = 0; - ihdr[12] = 0; - - return Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - chunk('IHDR', ihdr), - chunk('IDAT', deflateSync(raw, { level: 9 })), - chunk('IEND', Buffer.alloc(0)) - ]); -} - -/** PNG-буфер -> { width, height, rgba: Buffer }. Поддержан только color type 6, 8 бит. */ -export function decodePng(buf) { - if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('не PNG'); - let pos = 8, width = 0, height = 0, bitDepth = 0, colorType = 0; - const idat = []; - while (pos < buf.length) { - const len = buf.readUInt32BE(pos); - const type = buf.toString('ascii', pos + 4, pos + 8); - const data = buf.subarray(pos + 8, pos + 8 + len); - if (type === 'IHDR') { - width = data.readUInt32BE(0); - height = data.readUInt32BE(4); - bitDepth = data[8]; - colorType = data[9]; - } else if (type === 'IDAT') idat.push(data); - pos += 12 + len; - } - if (colorType !== 6 || bitDepth !== 8) { - throw new Error(`PNG colorType=${colorType} bitDepth=${bitDepth} — жду RGBA 8-бит`); - } - const raw = inflateSync(Buffer.concat(idat)); - const stride = width * 4; - const rgba = Buffer.alloc(height * stride); - let prev = Buffer.alloc(stride); - for (let y = 0; y < height; y++) { - const filter = raw[y * (stride + 1)]; - const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); - const cur = rgba.subarray(y * stride, (y + 1) * stride); - for (let x = 0; x < stride; x++) { - const a = x >= 4 ? cur[x - 4] : 0; - const b = prev[x]; - const c = x >= 4 ? prev[x - 4] : 0; - let v = line[x]; - if (filter === 1) v += a; - else if (filter === 2) v += b; - else if (filter === 3) v += (a + b) >> 1; - else if (filter === 4) { - const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); - v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; - } - cur[x] = v & 0xff; - } - prev = cur; - } - return { width, height, rgba }; -} \ No newline at end of file diff --git a/packages/engine/tools/spectro.mjs b/packages/engine/tools/spectro.mjs deleted file mode 100644 index a15c7f2..0000000 --- a/packages/engine/tools/spectro.mjs +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Спектро-лист: «взгляд» ИИ-агента на WAV без слушания (аудио-гейты работ - * AI-контента). Жанронезависимо: вход — сэмплы, выход — PNG. На каждый клип — - * спектрограмма (лог-частота, dB-палитра), огибающая RMS и метки времени; - * для лупов — маркер шва (стык конца в начало). Модуль не знает контента игры. - */ -import { readFileSync, writeFileSync } from 'node:fs'; -import { pathToFileURL } from 'node:url'; -import { encodePng } from './png.mjs'; -import { decodeWav } from './wav.mjs'; - -const EPS = 1e-10; -/** Порог динамики спектрограммы, dB ниже максимума записи. */ -const DB_FLOOR = 66; - -/** Палитра dB→цвет: чёрный → синева → бирюза → золото → белый (жар). */ -const HEAT = [ - [12, 14, 20], - [26, 34, 62], - [38, 92, 108], - [70, 160, 130], - [158, 202, 96], - [222, 198, 74], - [252, 246, 220] -]; - -/** Итеративный radix-2 FFT (in-place, re/im длиной в степень двойки). */ -function fft(re, im) { - const n = re.length; - for (let i = 1, j = 0; i < n; i++) { - let bit = n >> 1; - for (; j & bit; bit >>= 1) j ^= bit; - j ^= bit; - if (i < j) { - [re[i], re[j]] = [re[j], re[i]]; - [im[i], im[j]] = [im[j], im[i]]; - } - } - for (let len = 2; len <= n; len <<= 1) { - const ang = (-2 * Math.PI) / len; - const wr = Math.cos(ang); - const wi = Math.sin(ang); - for (let i = 0; i < n; i += len) { - let cr = 1; - let ci = 0; - for (let k = 0; k < len / 2; k++) { - const ur = re[i + k]; - const ui = im[i + k]; - const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci; - const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr; - re[i + k] = ur + vr; - im[i + k] = ui + vi; - re[i + k + len / 2] = ur - vr; - im[i + k + len / 2] = ui - vi; - const ncr = cr * wr - ci * wi; - ci = cr * wi + ci * wr; - cr = ncr; - } - } - } -} - -/** - * Спектрограмма: окна Ханна win с шагом hop, |FFT| в dB. Возвращает - * { cols, rows, db: Float32Array (cols*rows, частота: строка 0 — верх/высокая) }. - */ -export function spectrogram(samples, { win = 1024, hop = 512 } = {}) { - const cols = Math.max(1, Math.floor((samples.length - win) / hop) + 1); - const rows = win / 2; - const db = new Float32Array(cols * rows); - const re = new Float32Array(win); - const im = new Float32Array(win); - let max = -Infinity; - for (let c = 0; c < cols; c++) { - for (let i = 0; i < win; i++) { - const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / win); - re[i] = (samples[c * hop + i] ?? 0) * w; - im[i] = 0; - } - fft(re, im); - for (let r = 0; r < rows; r++) { - const mag = Math.hypot(re[r], im[r]); // нижняя половина спектра - const v = 20 * Math.log10(mag + EPS); - db[c * rows + r] = v; - if (v > max) max = v; - } - } - return { cols, rows, db, max }; -} - -/** Огибающая RMS (0..1): окно winMs, шаг окно/2 — для полосы «дыхания». */ -export function envelope(samples, rate, winMs = 60) { - const win = Math.max(1, Math.round((winMs / 1000) * rate)); - const cols = Math.max(1, Math.floor(samples.length / win)); - const out = new Float32Array(cols); - let max = 0; - for (let c = 0; c < cols; c++) { - let sum = 0; - for (let i = 0; i < win; i++) { - const s = samples[c * win + i] ?? 0; - sum += s * s; - } - out[c] = Math.sqrt(sum / win); - if (out[c] > max) max = out[c]; - } - if (max > 0) for (let c = 0; c < cols; c++) out[c] /= max; - return out; -} - -/** dB→цвет палитры (0 = тише DB_FLOOR ниже максимума записи). */ -function heatIndex(db, max) { - const t = Math.max(0, Math.min(1, (db - (max - DB_FLOOR)) / DB_FLOOR)); - const seg = t * (HEAT.length - 1); - const i = Math.min(HEAT.length - 2, Math.floor(seg)); - const f = seg - i; - const mix = (c) => Math.round(HEAT[i][c] + (HEAT[i + 1][c] - HEAT[i][c]) * f); - return [mix(0), mix(1), mix(2)]; -} - -/** Крошечный шрифт цифр 3×5 (для номеров строк листа). */ -const DIGITS = ['111101101101111', '010110010010111', '111001111100111', '111001111001111', '101101111001001', '111100111001111', '111100111101111', '111001010100100', '111101111101111', '111101111001111']; - -/** Нарисовать цифру (индекс строки) в 3×5 с масштабом. */ -function drawNumber(px, x, y, n, scale = 2, color = [160, 168, 180]) { - for (const ch of String(n)) { - const g = DIGITS[Number(ch)]; - for (let i = 0; i < 15; i++) { - if (g[i] === '1') { - const gx = i % 3; - const gy = (i / 3) | 0; - for (let dy = 0; dy < scale; dy++) { - for (let dx = 0; dx < scale; dx++) { - const px0 = (y + gy * scale + dy) * px.width + x + gx * scale + dx; - px.data.set(color, px0 * 4); - } - } - } - } - x += 4 * scale; - } -} - -/** - * Лист клипов: спектро (log-частота) + огибающая + ось времени на строку. - * clips: { name, samples, rate, loop? }. loop=true — красный маркер шва - * (конец→начало) на правом краю спектро. Пишет PNG в path, возвращает путь. - */ -export function spectroSheet(clips, path, { width = 720, specH = 140, envH = 22 } = {}) { - const labelH = 12; - const rowH = labelH + specH + envH + 10; - const W = width; - const H = clips.length * rowH + 8; - const px = { width: W, height: H, data: Buffer.alloc(W * H * 4, 255) }; - const bg = [235, 236, 240]; - for (let i = 0; i < W * H; i++) px.data.set(bg, i * 4); - - clips.forEach((clip, row) => { - const y0 = row * rowH + 4; - const spec = spectrogram(clip.samples, { win: 1024, hop: Math.max(1, Math.floor(clip.samples.length / (W - 16))) }); - const fmin = 40; - const fmax = clip.rate / 2; - const x0 = 20; - const gw = W - x0 - 12; - for (let x = 0; x < gw; x++) { - const col = Math.min(spec.cols - 1, Math.floor((x / gw) * spec.cols)); - for (let y = 0; y < specH; y++) { - const f = fmax * Math.pow(fmin / fmax, y / specH); // лог-частота сверху вниз - const binIdx = Math.min(spec.rows - 1, Math.round((f / fmax) * spec.rows)); - const [r, g, b] = heatIndex(spec.db[col * spec.rows + binIdx], spec.max); - const pi = ((y0 + labelH + y) * W + x0 + x) * 4; - px.data.set([r, g, b], pi); - } - } - // Огибающая RMS: заполненная полоса под спектро. - const env = envelope(clip.samples, clip.rate); - const ey = y0 + labelH + specH + 4; - for (let x = 0; x < gw; x++) { - const v = env[Math.min(env.length - 1, Math.floor((x / gw) * env.length))] ?? 0; - const h = Math.round(v * (envH - 4)); - for (let y = 0; y < h; y++) { - const pi = ((ey + envH - 4 - y) * W + x0 + x) * 4; - px.data.set([70, 110, 140], pi); - } - } - // Секундные тики времени под огибающей. - const totalSec = clip.samples.length / clip.rate; - for (let s = 0; s <= totalSec; s++) { - const x = Math.round((s / totalSec) * (gw - 1)); - for (let y = 0; y < 4; y++) { - const pi = ((ey + envH + y) * W + x0 + x) * 4; - px.data.set([120, 126, 138], pi); - } - } - // Маркер шва лупа: красные кромки на стыке конец→начало. - if (clip.loop) { - for (let y = 0; y < specH; y++) { - const pl = ((y0 + labelH + y) * W + x0) * 4; - const pr = ((y0 + labelH + y) * W + x0 + gw - 1) * 4; - px.data.set([200, 60, 50], pl); - px.data.set([200, 60, 50], pr); - } - } - drawNumber(px, 6, y0 + labelH / 2 - 4, row + 1); - }); - - writeFileSync(path, encodePng(W, H, px.data)); - return path; -} - -// CLI: node spectro.mjs [clip.wav[:loop]...] -// :loop — пометить шов; порядок файлов = номера строк на листе. -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const [out, ...files] = process.argv.slice(2); - if (!out || files.length === 0) { - console.error('использование: node spectro.mjs [...]'); - process.exit(1); - } - const clips = files.map((f, i) => { - const loop = f.endsWith(':loop'); - const file = f.replace(/:loop$/, ''); - const buf = readFileSync(file); - const { samples, rate } = decodeWav(buf); - console.log(`${i + 1}: ${file} (${(samples.length / rate).toFixed(1)} с, ${rate} Гц)${loop ? ' [луп]' : ''}`); - return { name: file, samples, rate, loop }; - }); - console.log(`лист: ${spectroSheet(clips, out)}`); -} \ No newline at end of file diff --git a/packages/engine/tools/synth.d.mts b/packages/engine/tools/synth.d.mts deleted file mode 100644 index d445fb8..0000000 --- a/packages/engine/tools/synth.d.mts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Типы для synth.mjs: чистые примитивы синтеза без node-зависимостей - * (их можно импортировать и в браузере — в отличие от wav.mjs с node:fs). - * Сам .mjs без JSDoc-типов, декларация рядом покрывает оба tsconfig-проекта. - */ - -/** Частота дискретизации синтеза, Гц. */ -export declare const RATE: number; - -/** Детерминированный ГПСЧ (mulberry32): (seed) → функция () => [0..1). */ -export declare function makeRng(seed: number): () => number; - -/** Сэмплы длительностью seconds: fn(t секунды, i индекс) → значение волны. */ -export declare function synth(seconds: number, fn: (t: number, i: number) => number, rate?: number): Float32Array; - -/** Огибающая затухания: 1 на старте → 0 к концу (кривая t^power). */ -export declare function decay(t: number, dur: number, power?: number): number; - -/** Партиал колокола: sin(2π·freq·stretch·t) с затуханием decay. */ -export declare function bellPartial(freq: number, stretch: number, dur: number, power?: number): (t: number) => number; - -/** Полосовой шум: детерминированная последовательность сэмплов в полосе. */ -export declare function bandNoise(seed: number, lowHz: number, highHz: number, dur: number): () => number; - -/** Нормализация к пику (по модулю), дефолт 0.9. */ -export declare function normalize(samples: Float32Array, peak?: number): Float32Array; - -/** Сшить края в луп коротким кроссфейдом (возвращает копию без хвоста фейда). */ -export declare function loopify(samples: Float32Array, fadeSeconds?: number): Float32Array; \ No newline at end of file diff --git a/packages/engine/tools/synth.mjs b/packages/engine/tools/synth.mjs deleted file mode 100644 index ad584a2..0000000 --- a/packages/engine/tools/synth.mjs +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Чистые примитивы синтеза (детерминированный шум, полосовой фильтр, - * огибающие, нормализация, кроссфейд-луп) — без node-импортов, чтобы - * работать и в тулзах (wav.mjs, gen.mjs), и в рантайме браузера - * (вариативные шаги игры поверх AudioManager.createBuffer). - * Частота по умолчанию — RATE Гц (моно). - */ - -export const RATE = 22050; - -/** Детерминированный шум. */ -export function makeRng(seed) { - let s = seed >>> 0; - return () => { - s = (s + 0x6d2b79f5) >>> 0; - let t = Math.imul(s ^ (s >>> 15), 1 | s); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** Синтез: секунды -> Float32[]. fn(t, i) -> сэмпл. */ -export function synth(seconds, fn, rate = RATE) { - const n = Math.floor(seconds * rate); - const out = new Float32Array(n); - for (let i = 0; i < n; i++) out[i] = fn(i / rate, i); - return out; -} - -/** Экспоненциальный спад: полный на старте, ~0 к концу. */ -export function decay(t, dur, power = 4) { - return Math.pow(Math.max(0, 1 - t / dur), power); -} - -/** Колокольный партиал: негармоничный (частота * растяжка), быстрый спад. */ -export function bellPartial(freq, stretch, dur, power) { - return (t) => Math.sin(2 * Math.PI * freq * stretch * t) * decay(t, dur, power); -} - -/** Полосовой шум через однополюсные фильтры (низ + верх). */ -export function bandNoise(seed, lowHz, highHz, dur) { - const r = makeRng(seed); - let l1 = 0; - let l2 = 0; - const aLow = 1 - Math.exp(-2 * Math.PI * lowHz / RATE); - const aHigh = 1 - Math.exp(-2 * Math.PI * highHz / RATE); - return () => { - const white = r() * 2 - 1; - l1 += aHigh * (white - l1); // фильтр верхов: выше highHz глушим - l2 += aLow * (l1 - l2); // фильтр низов: ниже lowHz глушим - return l2 - l1 < -1 ? -1 : Math.max(-1, Math.min(1, l1 - l2)); - }; -} - -/** Нормализовать по пику. */ -export function normalize(samples, peak = 0.9) { - let max = 0; - for (const s of samples) max = Math.max(max, Math.abs(s)); - if (max === 0) return samples; - const k = peak / max; - return samples.map((s) => s * k); -} - -/** Сшить луп: кроссфейд первых fade-секунд с концом. */ -export function loopify(samples, fadeSeconds = 0.5) { - const n = Math.floor(fadeSeconds * RATE); - for (let i = 0; i < n; i++) { - const k = i / n; - samples[i] = samples[i] * k + samples[samples.length - n + i] * (1 - k); - } - return samples.slice(0, samples.length - n); -} \ No newline at end of file diff --git a/packages/engine/tools/wav.mjs b/packages/engine/tools/wav.mjs deleted file mode 100644 index d3e8519..0000000 --- a/packages/engine/tools/wav.mjs +++ /dev/null @@ -1,79 +0,0 @@ -/** - * WAV-тулкит без зависимостей: энкодер поверх чистых примитивов синтеза - * (сами примитивы — в synth.mjs, они же используются в рантайме браузера). - * Формат: моно, RATE Гц, 16-бит PCM. - */ -import { - RATE, - bandNoise, - bellPartial, - decay, - loopify, - makeRng, - normalize, - synth -} from './synth.mjs'; - -export { RATE, bandNoise, bellPartial, decay, loopify, makeRng, normalize, synth }; - -/** Собрать WAV-файл из Float32 сэмплов (-1..1). */ -export function encodeWav(samples, rate = RATE) { - const n = samples.length; - const data = new ArrayBuffer(44 + n * 2); - const view = new DataView(data); - const str = (offset, s) => { - for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i)); - }; - str(0, 'RIFF'); - view.setUint32(4, 36 + n * 2, true); - str(8, 'WAVE'); - str(12, 'fmt '); - view.setUint32(16, 16, true); // размер fmt - view.setUint16(20, 1, true); // PCM - view.setUint16(22, 1, true); // моно - view.setUint32(24, rate, true); - view.setUint32(28, rate * 2, true); // байт/сек - view.setUint16(32, 2, true); // блок - view.setUint16(34, 16, true); // бит - str(36, 'data'); - view.setUint32(40, n * 2, true); - for (let i = 0; i < n; i++) { - const s = Math.max(-1, Math.min(1, samples[i])); - view.setInt16(44 + i * 2, Math.round(s * 32767), true); - } - return Buffer.from(data); -} - -/** - * Разобрать WAV в сэмплы: PCM 16-бит, моно или стерео (даунмикс в моно - * усреднением). Возвращает { samples: Float32Array (-1..1), rate }. Чанки - * (fmt/data) ищем по ID — файлы с LIST-примечаниями читаются корректно. - */ -export function decodeWav(buf) { - const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); - const tag = (offset, n) => String.fromCharCode(...Array.from({ length: n }, (_, i) => view.getUint8(offset + i))); - if (tag(0, 4) !== 'RIFF' || tag(8, 4) !== 'WAVE') throw new Error('не WAV-файл'); - let rate = 0, bits = 0, channels = 0, data = null; - let off = 12; - while (off + 8 <= buf.length) { - const id = tag(off, 4); - const size = view.getUint32(off + 4, true); - if (id === 'fmt ') { - channels = view.getUint16(off + 10, true); - rate = view.getUint32(off + 12, true); - bits = view.getUint16(off + 22, true); - } else if (id === 'data') { - data = { off: off + 8, frames: Math.floor(size / 2 / channels) }; - } - off += 8 + size + (size % 2); // чанк выравнивается по слову - } - if (!data || !rate) throw new Error('WAV без fmt/data или не PCM'); - if (bits !== 16) throw new Error(`поддержан только PCM 16-бит (в файле ${bits})`); - const samples = new Float32Array(data.frames); - for (let i = 0; i < data.frames; i++) { - let sum = 0; - for (let c = 0; c < channels; c++) sum += view.getInt16(data.off + (i * channels + c) * 2, true) / 32768; - samples[i] = sum / channels; - } - return { samples, rate }; -} diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json deleted file mode 100644 index 495443e..0000000 --- a/packages/engine/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": {}, - "include": ["src"] -} \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json deleted file mode 100644 index 84105cc..0000000 --- a/tsconfig.base.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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 diff --git a/v1/CLAUDE.md b/v1/CLAUDE.md new file mode 100644 index 0000000..36a7875 --- /dev/null +++ b/v1/CLAUDE.md @@ -0,0 +1,104 @@ +# 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 (v1/apps/game) +npm run build # прод-сборка игры +npm test # все тесты (Vitest) +npx vitest run v1/packages/engine/src/map/__tests__/pathfinding.test.ts # один файл тестов +npm run typecheck # tsc --noEmit для обоих пакетов +npm run lint # eslint (typescript-eslint, flat config) на оба пакета + тулзы +npm run guard # гвард границы движок/игра (импорты, JSON) +npm run check:fast # typecheck + guard + все тесты (~30 с, это же делает pre-commit хук) +npm run art # перегенерация пиксель-арта из v1/apps/game/tools/pixelart +npm run art:lint # линтер арта: палитра/слоты/«мыло» по v1/apps/game/assets +npm run aiart # пайплайн AI-атласов: gen/build/promote (v1/apps/game/tools/aiart) +node v1/apps/game/tools/aiart/tryon.mjs # примерка AI-сборки в игре до promote (скриншот /tmp/rpg_tryon.png) +npm run dialogues # визуальный редактор графов диалогов (порт 5299) +npm run audio # перегенерация WAV (v1/apps/game/tools/audio/gen.mjs) +npm run maps # перезапись карт-файлов (RUN_MAPS_GEN=1; обычный npm test только сверяет fresh) +npm run agent:check # полный прогон проверок через агентный мост (JSON) +node v1/apps/game/tools/agent.mjs run v1/apps/game/tools/checks/xxx.mjs # один сценарий проверки +node v1/apps/game/tools/agent.mjs snapshot --new-game # снапшот игры (JSON) +node v1/apps/game/tools/smoke.mjs # смоук-тест в реальном Chromium (скриншот + консоль) +node v1/apps/game/tools/smoke-act1.mjs # полный прогон акта 1 через агентный мост +node v1/apps/game/tools/final-smoke.mjs # интеграционный смоук: 3 поездки луга<->Звенец, бой, guard аудио +``` + +Dev-сервер по умолчанию на http://localhost:5173; для отдельного порта: `npm run dev -- --port 5199`. + +## Архитектура + +Monorepo из двух workspace-пакетов с **жёсткой границей API**: + +- `v1/packages/engine` (`@rpg/engine`) — жанронезависимый движок-библиотека. **Не должен знать ничего о RPG-контенте** (квесты, предметы, сюжет). Вся игра импортирует движок **только через `v1/packages/engine/src/index.ts`** — другие внутренние пути движка импортировать нельзя. Границу проверяет гвард `npm run guard` (проба `guard:boundary` в `agent:check`). +- `v1/apps/game` (`@rpg/game`) — сама RPG: контент, сцены, геймплейные системы. + +### Ключевые решения, которые нельзя сломать + +1. **PixiJS — только рендер-бэкенд.** Игровой цикл, сцены, ввод, камера, изометрия — собственные (`v1/packages/engine/src/core`, `scene`, `input`, `render`). Pixi используется как низкоуровневый слой отрисовки спрайтов. +2. **Пиксель-арт pixel-perfect, резкий UI.** Логика — в виртуальном разрешении 480×270 (`Game.VIRTUAL_W/H`); CSS-размер канваса — виртуальное × **целое** (`computeScale` в `@rpg/engine`, движок сам слушает `window.resize`), а бэкинг-стор — в пикселях устройства (scale × dpr): текст/UI резкие, мир пиксельный (текстуры `AssetLoader` сэмплируются `nearest`). `roundPixels: true`, `antialias: false`. При изменении разрешения следи, чтобы CSS-масштаб оставался целым. +3. **Фиксированный шаг.** `GameLoop` (60 Гц update) + аккумулятор с ограничением 5 шагов/кадр; `InputManager.endTick()` очищает «just pressed» в конце каждого тика — не вызывать update вне цикла движка. +4. **Изометрия 2:1, мир в юнитах.** Источник истины для позиций — **мировые юниты** (float в плоскости изометрии, 1 юнит = 1 тайл): `worldToScreen`/`screenToWorld`/`worldDist`/`worldNorm` в `math/iso.ts`. Две «линейки»: точка проецируется анизотропно (`tileW/2` px по X, `tileH/2` по Y), скаляры (дистанция, радиус, скорость, высота) — через `unitsToPx` (`px = units·tileW`, 32 px/юнит). Округление до целого px — только на границе мир→экран. Тайловые координаты всюду именуются `{x, y}` (как `Grid` в pathfinding); для них есть мосты `tileToWorld`/`worldToTile`. +5. **Собственные классы движка с инъекцией хранилища.** `SaveManager` принимает `StorageLike` (в браузере — `localStorage`), чтобы тесты работали без DOM. Чистую математику (iso, A*, ECS) держи без зависимостей от Pixi — она тестируется в Vitest без браузера. + +### Где что лежит + +- `docs/llms.txt` — **точка входа для ИИ-агента**: карта всех док одной строкой на док. +- `docs/engine/` — **документация движка** (по-русски): `README.md` (архитектура и принципы), `getting-started.md`, `core.md`, `render.md`, `anim.md` (клипы/аниматоры/тик), `input.md`, `maps.md`, `inventory.md` (модель Inventory), `registry.md` (SceneRegistry), `ui-and-dialogue.md`, `cutscene.md`, `assets-audio-save.md`, `art-pipeline.md`, `recipes.md`, `agent.md` (агентный мост), `practices.md` (живой документ практик агента). При изменении API движка обновляй соответствующий файл и `practices.md` (если появился новый приём) **в том же коммите**. +- `docs/demo.md` — **внутренняя проектная дока среза**: матрица «подсистема движка → где показана в игре» + статус, боевая модель, архитектура, дорожная карта. +- `docs/plan.md` — **живой план развития**: актуальные задачи, проработка крупных направлений (AI-генерация спрайтов/аудио, ограничения железа). Новые планы вести здесь. +- `docs/world.md` — библия мира (сеттинг, локации, персонажи, сюжет). **Любой новый контент сверять с ней.** +- `docs/art-style.md` — арт-библия: палитра (32 цвета), размеры спрайтов, правила стиля, чеклист ассетов. +- `v1/packages/engine/src/` — модули движка: `core/` (Engine, GameLoop, Tween, GameState, StateMachine, Settings, EventBus), `scene/` (SceneManager + fade-переходы), `render/` (Renderer, Camera, IsoDepthLayer, Particles, scale), `input/` (действия, геймпад, VirtualJoystick), `map/` (изометрия, A*, mapFormat, Tiled-импорт), `ui/` (PixelText/VT323, Panel, Button, MenuList, DialogueBox), `dialogue/` (DialogueRunner — view-агностик), `audio/` (шины master/music/sfx), `assets/` (AssetLoader + атласы), `save/`, `math/` (iso, rng, easing). +- `v1/apps/game/src/data/` — весь контент: карта (`map.ts`), переходы и области (`locations.ts` — `TransitionDef`/`AREAS`), интерактивные объекты (`interactables.ts` — `INTERACTABLES`), диалоги (JSON-графы в `data/dialogues/`, реестр `dialogues.ts` — графы `DialogueGraph`), NPC (`npcs.ts`); `validate.ts` — runtime-валидация контента → `Invariant[]` (проверяется в `agent:invariants` и юнит-тестах); `dialogueRules.ts` — чистые правила графов (общие с CLI `npm run dialogues:dry` и редактором). +- `v1/apps/game/src/agent/` — контентный слой агентного моста: `snapshot.ts` (сборка слоёв), `GameAgent.ts` (`window.__agent`, только DEV). +- `v1/apps/game/tools/` — **сценарии применения тулз под эту игру** (знают контент: палитру, тайлы, локации, сценарии акта): `agent.mjs` (диспетчер агентных проверок) + `lib.mjs` (маяк загрузки поверх движкового каркаса), `checks/`, `pixelart/` (`gen.mjs` + `palette.mjs`), `audio/gen.mjs`, `aiart/`, `maps/`, смоуки; `guards/boundary.mjs` — сценарий запуска гварда (`npm run guard`, входит в `agent:check`). +- `v1/packages/engine/tools/` — **жанронезависимые тулзы движка** (ничего не знают об игре, палитра/маяк/корни — параметры): `png.mjs` (PNG-кодек), `canvas.mjs` (мини-канвас, ASCII-карты), `imaging.mjs` (кроп/квантизация/обзорный лист), `wav.mjs` (WAV-энкодер + синтез), `agent-lib.mjs` (браузерный каркас агентных проверок), `boundary.mjs` (правила гварда + тесты). Импорт из игры — только через `@rpg/engine/tools/*` (см. `exports` движка). Правило: тулза знает контент игры — ей место в `v1/apps/game/tools`; нет — в движке. +- `v1/apps/game/src/scenes/` — BootScene (грузит ассеты и шрифт) → MenuScene (MenuList) → LocationScene; сцены меняются через `SceneManager.replace/push/pop` (опционально с fade). LocationScene — только оркестратор вьюх: клик-роутинг вынесен в `systems/ClickRouting.ts` (`resolveClick` — чистый резолвер приоритетов + `InteractionRouter`), агентный мост — в `agent/SceneAgentView.ts`. +- `v1/apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация из атласа), диалоги (обёртка над DialogueRunner + DialogueBox), интерактивные объекты (`Interactables.ts` — резолв реакций, used-флаги, `InteractSink`), маршрутизация кликов по миру (`ClickRouting.ts` — приоритеты: NPC → переход/заперто → интерактив → цветок → враг → движение). +- Флаги/переменные сюжета — в `GameState` (`game.state`), сериализуются в автосейв `autosave` (Esc в локации); настройки — `game.settings` (отдельный слот, не в сейвах). **Флаги/вары — только через реестры `data/ids.ts`** (`FLAGS`/`VARS`, типы `FlagId`/`VarId`, `usedFlag(id)` для used:): строковое упоминание вне реестра ловится `validateReferences()` как error, незадействованный ключ — как warn. + +### Пиксель-арт + +- `v1/apps/game/tools/pixelart/gen.mjs` — генератор ассетов (PNG из кода, без зависимостей): `node v1/apps/game/tools/pixelart/gen.mjs`. +- Выход — `v1/apps/game/assets/` (`tiles/*.png`, `chars/*.png`); загружается `AssetLoader` в BootScene, ключ = путь без расширения. +- Атласы (кадры героя): `gen.mjs` собирает `chars/hero_sheet.png` + `hero_sheet.json` (Pixi Spritesheet); загрузка — `assets.loadAtlas('chars/hero_sheet.json')`, кадры — `assets.frames(key, prefix)`. +- Палитра — только из `v1/apps/game/tools/pixelart/palette.mjs` (она же в `docs/art-style.md`); тёплые цвета (B*/F*) — только «жизнь»: цветы, бронза, огонь. +- Имена файлов и размеры спрайтов фиксированы арт-библией — при замене сгенерированного арта нарисованным руками их не менять. +- Тайл «дерево» — высокий объект: `TileMapData.tall` c `TallSpec { height, ground }` (спрайт якорится низом в центр ромба, под ним рисуется ground-тайл). + +### Известные грабли (уже собранные) + +- **Pixi `Assets.load` без `Assets.init()` виснет навсегда** (без ошибок!) — `AssetLoader` делает init сам, не обходи его. +- **`app.renderer` недоступен до завершения `app.init()`** — `Renderer.setup()` ждёт init-промис; не трогай renderer раньше. +- **Изометрический «ромб» уходит в минус по X** (экранный bbox западного угла при 28×28 ≈ -448) — но границы камеры задаются в юнитах через `map.worldBounds` (`{0, 0, w, h}`), клэмп через проекцию сам учитывает отрицательный угол. +- **Vite-алиас `@rpg/engine` — только regex** (`{ find: /^@rpg\/engine$/, replacement: ... }`). Строковый алиас перехватывает и под-пути (`@rpg/engine/assets/...`), ломая их в `index.ts/...`. +- **Игровые PNG должны попадать в dist**: в `v1/apps/game/vite.config.ts` стоит `publicDir: 'assets'`, а `resolveUrl` не добавляет префикс `assets/`. Без этого прод-сборка пустая. +- **Шрифт движка подключается `?url`-импортом** (`import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url'`) — для этого в exports движка есть `"./assets/*"`. Обычный `new URL(...)` вне корня Vite не обрабатывает. +- **Атласы грузятся только через `assets.loadAtlas('<ключ>.json')`** — ключ с `.json` резолвится как есть, без `.png`. `Assets.load` на JSON вернёт Texture без `.textures` — не перепутай с `texture()`. +- **Fade-оверлей переходов должен быть поверх UI**: `SceneManager` поднимает его наверх `uiRoot` в `begin()` — сцены добавляют свои view позже, и «наивный» оверлей из `Engine.init()` оказывается под ними (карты просвечивают сквозь меню). +- **TweenManager.tick учитывает пересечение границы `delay`**: часть dt, приходящаяся на delay, вычитается из движения — не упрощай тик до последовательных прибавлений. +- **Headless-скриншоты с `--virtual-time-budget` зависают на загрузке картинок** (колбэки декодинга не срабатывают) — для визуальных проверок использовать `v1/apps/game/tools/smoke.mjs` (puppeteer-core + системный Chromium, реальное время). +- **`npm run dev -- --port 5199` не пробрасывает порт** через два слоя `npm run` (Vite получает `5199` как root-путь и встаёт на 5173) — отдельный порт задавать напрямую: `npx vite --port 5199` в `v1/apps/game`. +- **В headless Chromium `ctx.resume()` без флага `--autoplay-policy=no-user-gesture-required` не резолвится** — смоук-скрипты обязаны передавать этот флаг, а навигация не должна ждать `.then` у `audio.play`. +- **Одна клавиша маппится на несколько действий** (`bindActions` дополняет, а не перезаписывает): Space = `advance` в диалоге и `attack` вне его — не развести обратно нельзя, гардится уровнем сцены. +- **Координаты указателя и worldRoot — виртуальные пиксели** (480×270), экранные CSS-пиксели — это виртуальные × масштаб: `экран = мир + worldRoot.position` (перевод в CSS — домножением на scale). Цепочка клика по миру: `pointer px − worldRoot.position → screenToWorld → юниты → worldToTile`. Не смешивай пространства: позиция героя/врагов — юниты, вью — px. +- **Поворот героя (facing) — по экранным компонентам мирового смещения** (`worldToScreen(delta)`), не по мировым: экранные оси — мировые диагонали, кадры down/up/side соответствуют экранному направлению. +- **Клик по тайлу вне экрана не работает**: если целевой тайл за краем канваса (виртуальный y > 270 или x < 0), клик уходит мимо канваса — герой не двигается. В смоук-скриптах целиться в промежуточный тайл рядом с героем по направлению к цели (шаг по доминирующей оси). +- Мир/арт сверять с `docs/world.md` и `docs/art-style.md`: тёплые цвета — только жизнь; палитра — только из `v1/apps/game/tools/pixelart/palette.mjs`. +- **Агентный мост** (`window.__agent`, DEV): предпочитай `snapshot`/`agent:check` скриншотам — состояние игры читается снапшотом, скриншот только для визуальных вопросов. Fade-переход молча глотает параллельные `begin()` и клики (`transitioning`) — перед действием `waitFor('!s.transitioning')`. Инъекция ввода и шаг — атомарны (один JS-стек): высокоуровневые `tapTile/press/key/walkTo` моста уже атомарны, сырые `inject*` из страницы + отдельный шаг — гонка с rAF. Подробности: `docs/engine/agent.md`, приёмы: `docs/engine/practices.md` (пополнять в том же коммите). + +## Рабочие привычки + +- Для задач из 3+ шагов веди список задач (todowrite/TaskCreate) и иди по нему автономно, не спрашивая подтверждения на каждый шаг. +- Подражай существующему стилю кода; комментарии и докстринги — по-русски; хелперы держи до ~70 строк. +- Плейсхолдеры графики рисуются `Graphics`-ом в коде (пока нет арта); настоящий пиксель-арт в будущем кладётся в `v1/apps/game/assets/`. \ No newline at end of file diff --git a/v1/apps/game/assets/audio/ambience/houses.wav b/v1/apps/game/assets/audio/ambience/houses.wav new file mode 100644 index 0000000..98e3962 --- /dev/null +++ b/v1/apps/game/assets/audio/ambience/houses.wav Binary files differ diff --git a/v1/apps/game/assets/audio/ambience/old_siding.wav b/v1/apps/game/assets/audio/ambience/old_siding.wav new file mode 100644 index 0000000..f590253 --- /dev/null +++ b/v1/apps/game/assets/audio/ambience/old_siding.wav Binary files differ diff --git a/v1/apps/game/assets/audio/ambience/ponds.wav b/v1/apps/game/assets/audio/ambience/ponds.wav new file mode 100644 index 0000000..0912cb4 --- /dev/null +++ b/v1/apps/game/assets/audio/ambience/ponds.wav Binary files differ diff --git a/v1/apps/game/assets/audio/ambience/ponds_night.wav b/v1/apps/game/assets/audio/ambience/ponds_night.wav new file mode 100644 index 0000000..ff5793f --- /dev/null +++ b/v1/apps/game/assets/audio/ambience/ponds_night.wav Binary files differ diff --git a/v1/apps/game/assets/audio/ambience/ponds_water.wav b/v1/apps/game/assets/audio/ambience/ponds_water.wav new file mode 100644 index 0000000..e12bcf4 --- /dev/null +++ b/v1/apps/game/assets/audio/ambience/ponds_water.wav Binary files differ diff --git a/v1/apps/game/assets/audio/ambience/rust_grove.wav b/v1/apps/game/assets/audio/ambience/rust_grove.wav new file mode 100644 index 0000000..dba7d52 --- /dev/null +++ b/v1/apps/game/assets/audio/ambience/rust_grove.wav Binary files differ diff --git a/v1/apps/game/assets/audio/ambience/tower_hum.wav b/v1/apps/game/assets/audio/ambience/tower_hum.wav new file mode 100644 index 0000000..f7cb40c --- /dev/null +++ b/v1/apps/game/assets/audio/ambience/tower_hum.wav Binary files differ diff --git a/v1/apps/game/assets/audio/ambience/wind.wav b/v1/apps/game/assets/audio/ambience/wind.wav new file mode 100644 index 0000000..9983afd --- /dev/null +++ b/v1/apps/game/assets/audio/ambience/wind.wav Binary files differ diff --git a/v1/apps/game/assets/audio/ambience/zvenets.wav b/v1/apps/game/assets/audio/ambience/zvenets.wav new file mode 100644 index 0000000..c01c316 --- /dev/null +++ b/v1/apps/game/assets/audio/ambience/zvenets.wav Binary files differ diff --git a/v1/apps/game/assets/audio/ambience/zvenets_night.wav b/v1/apps/game/assets/audio/ambience/zvenets_night.wav new file mode 100644 index 0000000..4e9740f --- /dev/null +++ b/v1/apps/game/assets/audio/ambience/zvenets_night.wav Binary files differ diff --git a/v1/apps/game/assets/chars/clint.png b/v1/apps/game/assets/chars/clint.png new file mode 100644 index 0000000..e540b1a --- /dev/null +++ b/v1/apps/game/assets/chars/clint.png Binary files differ diff --git a/v1/apps/game/assets/chars/clump_crawler_1.png b/v1/apps/game/assets/chars/clump_crawler_1.png new file mode 100644 index 0000000..47d4dff --- /dev/null +++ b/v1/apps/game/assets/chars/clump_crawler_1.png Binary files differ diff --git a/v1/apps/game/assets/chars/clump_crawler_2.png b/v1/apps/game/assets/chars/clump_crawler_2.png new file mode 100644 index 0000000..c3b7ff5 --- /dev/null +++ b/v1/apps/game/assets/chars/clump_crawler_2.png Binary files differ diff --git a/v1/apps/game/assets/chars/clump_heavy_1.png b/v1/apps/game/assets/chars/clump_heavy_1.png new file mode 100644 index 0000000..661c43c --- /dev/null +++ b/v1/apps/game/assets/chars/clump_heavy_1.png Binary files differ diff --git a/v1/apps/game/assets/chars/clump_heavy_2.png b/v1/apps/game/assets/chars/clump_heavy_2.png new file mode 100644 index 0000000..b73288b --- /dev/null +++ b/v1/apps/game/assets/chars/clump_heavy_2.png Binary files differ diff --git a/v1/apps/game/assets/chars/clump_spitter_1.png b/v1/apps/game/assets/chars/clump_spitter_1.png new file mode 100644 index 0000000..9df11c0 --- /dev/null +++ b/v1/apps/game/assets/chars/clump_spitter_1.png Binary files differ diff --git a/v1/apps/game/assets/chars/clump_spitter_2.png b/v1/apps/game/assets/chars/clump_spitter_2.png new file mode 100644 index 0000000..4e1b5d9 --- /dev/null +++ b/v1/apps/game/assets/chars/clump_spitter_2.png Binary files differ diff --git a/v1/apps/game/assets/chars/clumps_sheet.json b/v1/apps/game/assets/chars/clumps_sheet.json new file mode 100644 index 0000000..1d098a7 --- /dev/null +++ b/v1/apps/game/assets/chars/clumps_sheet.json @@ -0,0 +1,110 @@ +{ + "frames": { + "clump_crawler_1": { + "frame": { + "x": 0, + "y": 0, + "w": 20, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 20, + "h": 20 + } + }, + "clump_crawler_2": { + "frame": { + "x": 20, + "y": 0, + "w": 20, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 20, + "h": 20 + } + }, + "clump_spitter_1": { + "frame": { + "x": 40, + "y": 0, + "w": 20, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 20, + "h": 20 + } + }, + "clump_spitter_2": { + "frame": { + "x": 60, + "y": 0, + "w": 20, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 20, + "h": 20 + } + }, + "clump_heavy_1": { + "frame": { + "x": 80, + "y": 0, + "w": 20, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 20, + "h": 20 + } + }, + "clump_heavy_2": { + "frame": { + "x": 100, + "y": 0, + "w": 20, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 20, + "h": 20 + } + } + }, + "meta": { + "image": "clumps_sheet.png", + "size": { + "w": 120, + "h": 20 + }, + "scale": 1 + }, + "animations": { + "clump_crawler": [ + "clump_crawler_1", + "clump_crawler_2" + ], + "clump_spitter": [ + "clump_spitter_1", + "clump_spitter_2" + ], + "clump_heavy": [ + "clump_heavy_1", + "clump_heavy_2" + ] + } +} \ No newline at end of file diff --git a/v1/apps/game/assets/chars/clumps_sheet.png b/v1/apps/game/assets/chars/clumps_sheet.png new file mode 100644 index 0000000..aa8b68a --- /dev/null +++ b/v1/apps/game/assets/chars/clumps_sheet.png Binary files differ diff --git a/v1/apps/game/assets/chars/elder_irwin.png b/v1/apps/game/assets/chars/elder_irwin.png new file mode 100644 index 0000000..038b7b9 --- /dev/null +++ b/v1/apps/game/assets/chars/elder_irwin.png Binary files differ diff --git a/v1/apps/game/assets/chars/fauna_deer_1.png b/v1/apps/game/assets/chars/fauna_deer_1.png new file mode 100644 index 0000000..d7724fe --- /dev/null +++ b/v1/apps/game/assets/chars/fauna_deer_1.png Binary files differ diff --git a/v1/apps/game/assets/chars/fauna_deer_2.png b/v1/apps/game/assets/chars/fauna_deer_2.png new file mode 100644 index 0000000..d7d0d60 --- /dev/null +++ b/v1/apps/game/assets/chars/fauna_deer_2.png Binary files differ diff --git a/v1/apps/game/assets/chars/fauna_sheet.json b/v1/apps/game/assets/chars/fauna_sheet.json new file mode 100644 index 0000000..7ba44b5 --- /dev/null +++ b/v1/apps/game/assets/chars/fauna_sheet.json @@ -0,0 +1,46 @@ +{ + "frames": { + "fauna_deer_1": { + "frame": { + "x": 0, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "fauna_deer_2": { + "frame": { + "x": 16, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + } + }, + "meta": { + "image": "fauna_sheet.png", + "size": { + "w": 32, + "h": 24 + }, + "scale": 1 + }, + "animations": { + "fauna_deer_walk": [ + "fauna_deer_1", + "fauna_deer_2" + ] + } +} \ No newline at end of file diff --git a/v1/apps/game/assets/chars/fauna_sheet.png b/v1/apps/game/assets/chars/fauna_sheet.png new file mode 100644 index 0000000..b08f29b --- /dev/null +++ b/v1/apps/game/assets/chars/fauna_sheet.png Binary files differ diff --git a/v1/apps/game/assets/chars/hero_down_1.png b/v1/apps/game/assets/chars/hero_down_1.png new file mode 100644 index 0000000..996100a --- /dev/null +++ b/v1/apps/game/assets/chars/hero_down_1.png Binary files differ diff --git a/v1/apps/game/assets/chars/hero_down_2.png b/v1/apps/game/assets/chars/hero_down_2.png new file mode 100644 index 0000000..e7b22d3 --- /dev/null +++ b/v1/apps/game/assets/chars/hero_down_2.png Binary files differ diff --git a/v1/apps/game/assets/chars/hero_lamp.png b/v1/apps/game/assets/chars/hero_lamp.png new file mode 100644 index 0000000..d7f4891 --- /dev/null +++ b/v1/apps/game/assets/chars/hero_lamp.png Binary files differ diff --git a/v1/apps/game/assets/chars/hero_sheet.json b/v1/apps/game/assets/chars/hero_sheet.json new file mode 100644 index 0000000..68b9132 --- /dev/null +++ b/v1/apps/game/assets/chars/hero_sheet.json @@ -0,0 +1,110 @@ +{ + "frames": { + "hero_down_1": { + "frame": { + "x": 0, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "hero_down_2": { + "frame": { + "x": 16, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "hero_up_1": { + "frame": { + "x": 32, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "hero_up_2": { + "frame": { + "x": 48, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "hero_side_1": { + "frame": { + "x": 64, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "hero_side_2": { + "frame": { + "x": 80, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + } + }, + "meta": { + "image": "hero_sheet.png", + "size": { + "w": 96, + "h": 24 + }, + "scale": 1 + }, + "animations": { + "hero_walk_down": [ + "hero_down_1", + "hero_down_2" + ], + "hero_walk_up": [ + "hero_up_1", + "hero_up_2" + ], + "hero_walk_side": [ + "hero_side_1", + "hero_side_2" + ] + } +} \ No newline at end of file diff --git a/v1/apps/game/assets/chars/hero_sheet.png b/v1/apps/game/assets/chars/hero_sheet.png new file mode 100644 index 0000000..b85284e --- /dev/null +++ b/v1/apps/game/assets/chars/hero_sheet.png Binary files differ diff --git a/v1/apps/game/assets/chars/hero_side_1.png b/v1/apps/game/assets/chars/hero_side_1.png new file mode 100644 index 0000000..e2ae35b --- /dev/null +++ b/v1/apps/game/assets/chars/hero_side_1.png Binary files differ diff --git a/v1/apps/game/assets/chars/hero_side_2.png b/v1/apps/game/assets/chars/hero_side_2.png new file mode 100644 index 0000000..b29df09 --- /dev/null +++ b/v1/apps/game/assets/chars/hero_side_2.png Binary files differ diff --git a/v1/apps/game/assets/chars/hero_up_1.png b/v1/apps/game/assets/chars/hero_up_1.png new file mode 100644 index 0000000..8cfd256 --- /dev/null +++ b/v1/apps/game/assets/chars/hero_up_1.png Binary files differ diff --git a/v1/apps/game/assets/chars/hero_up_2.png b/v1/apps/game/assets/chars/hero_up_2.png new file mode 100644 index 0000000..395d62e --- /dev/null +++ b/v1/apps/game/assets/chars/hero_up_2.png Binary files differ diff --git a/v1/apps/game/assets/chars/npcs_sheet.json b/v1/apps/game/assets/chars/npcs_sheet.json new file mode 100644 index 0000000..bf57c26 --- /dev/null +++ b/v1/apps/game/assets/chars/npcs_sheet.json @@ -0,0 +1,110 @@ +{ + "frames": { + "elder_irwin_1": { + "frame": { + "x": 0, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "elder_irwin_2": { + "frame": { + "x": 16, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "trader_mila_1": { + "frame": { + "x": 32, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "trader_mila_2": { + "frame": { + "x": 48, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "clint_1": { + "frame": { + "x": 64, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "clint_2": { + "frame": { + "x": 80, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + } + }, + "meta": { + "image": "npcs_sheet.png", + "size": { + "w": 96, + "h": 24 + }, + "scale": 1 + }, + "animations": { + "elder_irwin_idle": [ + "elder_irwin_1", + "elder_irwin_2" + ], + "trader_mila_idle": [ + "trader_mila_1", + "trader_mila_2" + ], + "clint_idle": [ + "clint_1", + "clint_2" + ] + } +} \ No newline at end of file diff --git a/v1/apps/game/assets/chars/npcs_sheet.png b/v1/apps/game/assets/chars/npcs_sheet.png new file mode 100644 index 0000000..dbaa542 --- /dev/null +++ b/v1/apps/game/assets/chars/npcs_sheet.png Binary files differ diff --git a/v1/apps/game/assets/chars/trader_mila.png b/v1/apps/game/assets/chars/trader_mila.png new file mode 100644 index 0000000..b71dad8 --- /dev/null +++ b/v1/apps/game/assets/chars/trader_mila.png Binary files differ diff --git a/v1/apps/game/assets/maps/house_elder.map b/v1/apps/game/assets/maps/house_elder.map new file mode 100644 index 0000000..dcb6e9b --- /dev/null +++ b/v1/apps/game/assets/maps/house_elder.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":11,"height":9,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"encoding":"rle","tiles":[[9,12],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,6],[8,1],[9,5]]} \ No newline at end of file diff --git a/v1/apps/game/assets/maps/machine_hall.map b/v1/apps/game/assets/maps/machine_hall.map new file mode 100644 index 0000000..af77033 --- /dev/null +++ b/v1/apps/game/assets/maps/machine_hall.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":26,"height":22,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"encoding":"rle","tiles":[[9,27],[8,11],[21,2],[8,11],[9,2],[8,24],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,12],[19,1],[8,11],[9,2],[8,24],[9,14],[8,1],[9,12]]} \ No newline at end of file diff --git a/v1/apps/game/assets/maps/meadows.map b/v1/apps/game/assets/maps/meadows.map new file mode 100644 index 0000000..14a80fb --- /dev/null +++ b/v1/apps/game/assets/maps/meadows.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":56,"height":56,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"variants":{"0":[0,11,12],"1":[1,14],"3":[3,22,23],"4":[4,13],"7":[7,24,25,26,27],"28":[28,30,31],"29":[29,32,33]},"decor":[{"id":16,"x":7,"y":0,"dx":-3,"dy":-2},{"id":16,"x":17,"y":0,"dx":-4,"dy":2},{"id":15,"x":30,"y":0,"dx":1,"dy":1},{"id":16,"x":38,"y":0,"dx":-3,"dy":0},{"id":16,"x":39,"y":0,"dx":-3,"dy":1},{"id":15,"x":2,"y":1,"dx":-2,"dy":-2},{"id":16,"x":8,"y":1,"dx":-2,"dy":1},{"id":15,"x":22,"y":1,"dx":3,"dy":1},{"id":15,"x":24,"y":1,"dx":-3,"dy":-1},{"id":17,"x":43,"y":1,"dx":1,"dy":1},{"id":17,"x":44,"y":1,"dx":1,"dy":1},{"id":17,"x":3,"y":2,"dx":-3,"dy":2},{"id":17,"x":8,"y":2,"dx":-2,"dy":0},{"id":15,"x":10,"y":2,"dx":-3,"dy":1},{"id":16,"x":20,"y":2,"dx":-2,"dy":2},{"id":16,"x":28,"y":2,"dx":-4,"dy":0},{"id":16,"x":31,"y":2,"dx":3,"dy":1},{"id":15,"x":42,"y":2,"dx":4,"dy":0},{"id":17,"x":7,"y":3,"dx":1,"dy":0},{"id":16,"x":52,"y":3,"dx":-1,"dy":-2},{"id":16,"x":15,"y":4,"dx":-2,"dy":1},{"id":16,"x":17,"y":4,"dx":3,"dy":1},{"id":17,"x":26,"y":4,"dx":-1,"dy":0},{"id":17,"x":33,"y":4,"dx":2,"dy":-1},{"id":17,"x":35,"y":4,"dx":-1,"dy":-2},{"id":15,"x":42,"y":4,"dx":-3,"dy":0},{"id":16,"x":51,"y":4,"dx":-3,"dy":1},{"id":16,"x":14,"y":5,"dx":0,"dy":0},{"id":16,"x":28,"y":5,"dx":-4,"dy":0},{"id":16,"x":34,"y":5,"dx":-3,"dy":0},{"id":15,"x":35,"y":5,"dx":2,"dy":1},{"id":16,"x":38,"y":5,"dx":-2,"dy":2},{"id":15,"x":43,"y":5,"dx":3,"dy":-2},{"id":15,"x":51,"y":5,"dx":-2,"dy":1},{"id":16,"x":52,"y":5,"dx":-1,"dy":-1},{"id":15,"x":0,"y":6,"dx":1,"dy":1},{"id":17,"x":1,"y":6,"dx":1,"dy":1},{"id":16,"x":29,"y":6,"dx":4,"dy":0},{"id":17,"x":39,"y":6,"dx":4,"dy":0},{"id":16,"x":46,"y":6,"dx":-2,"dy":-2},{"id":17,"x":51,"y":6,"dx":2,"dy":-1},{"id":15,"x":1,"y":7,"dx":0,"dy":1},{"id":16,"x":3,"y":7,"dx":-1,"dy":0},{"id":16,"x":15,"y":7,"dx":-1,"dy":0},{"id":15,"x":17,"y":7,"dx":3,"dy":-1},{"id":16,"x":18,"y":7,"dx":0,"dy":1},{"id":15,"x":24,"y":7,"dx":4,"dy":-1},{"id":16,"x":37,"y":7,"dx":1,"dy":0},{"id":15,"x":38,"y":7,"dx":-3,"dy":-1},{"id":15,"x":39,"y":7,"dx":-3,"dy":-2},{"id":17,"x":47,"y":7,"dx":0,"dy":-1},{"id":15,"x":51,"y":7,"dx":3,"dy":-1},{"id":17,"x":26,"y":8,"dx":4,"dy":2},{"id":16,"x":30,"y":8,"dx":1,"dy":-2},{"id":16,"x":32,"y":8,"dx":-4,"dy":-2},{"id":15,"x":35,"y":8,"dx":-1,"dy":0},{"id":16,"x":38,"y":8,"dx":-2,"dy":-2},{"id":15,"x":45,"y":8,"dx":-2,"dy":0},{"id":15,"x":47,"y":8,"dx":4,"dy":0},{"id":17,"x":2,"y":9,"dx":3,"dy":0},{"id":16,"x":20,"y":9,"dx":-4,"dy":-2},{"id":16,"x":24,"y":9,"dx":-2,"dy":1},{"id":17,"x":33,"y":9,"dx":-3,"dy":0},{"id":16,"x":50,"y":9,"dx":2,"dy":1},{"id":15,"x":15,"y":10,"dx":-3,"dy":0},{"id":15,"x":18,"y":10,"dx":0,"dy":-1},{"id":15,"x":20,"y":10,"dx":0,"dy":2},{"id":16,"x":22,"y":10,"dx":-3,"dy":1},{"id":15,"x":25,"y":10,"dx":2,"dy":-1},{"id":15,"x":49,"y":10,"dx":-3,"dy":2},{"id":16,"x":51,"y":10,"dx":1,"dy":2},{"id":17,"x":17,"y":11,"dx":3,"dy":2},{"id":15,"x":18,"y":11,"dx":3,"dy":-2},{"id":17,"x":21,"y":11,"dx":1,"dy":1},{"id":16,"x":29,"y":11,"dx":-1,"dy":0},{"id":16,"x":31,"y":11,"dx":-3,"dy":0},{"id":15,"x":37,"y":11,"dx":1,"dy":0},{"id":17,"x":1,"y":12,"dx":2,"dy":-1},{"id":15,"x":9,"y":12,"dx":-3,"dy":1},{"id":17,"x":10,"y":12,"dx":0,"dy":0},{"id":17,"x":22,"y":12,"dx":4,"dy":-2},{"id":16,"x":26,"y":12,"dx":-4,"dy":2},{"id":16,"x":36,"y":12,"dx":0,"dy":1},{"id":15,"x":40,"y":12,"dx":-2,"dy":-1},{"id":17,"x":46,"y":12,"dx":-3,"dy":0},{"id":15,"x":16,"y":13,"dx":1,"dy":1},{"id":17,"x":28,"y":13,"dx":2,"dy":-1},{"id":16,"x":46,"y":13,"dx":-1,"dy":2},{"id":17,"x":1,"y":14,"dx":0,"dy":-1},{"id":16,"x":31,"y":14,"dx":1,"dy":-1},{"id":16,"x":36,"y":14,"dx":-2,"dy":-2},{"id":16,"x":0,"y":15,"dx":-3,"dy":1},{"id":15,"x":13,"y":15,"dx":1,"dy":2},{"id":17,"x":21,"y":15,"dx":-3,"dy":-2},{"id":16,"x":47,"y":15,"dx":0,"dy":-1},{"id":16,"x":35,"y":16,"dx":1,"dy":-1},{"id":16,"x":1,"y":17,"dx":-3,"dy":-1},{"id":17,"x":27,"y":17,"dx":2,"dy":1},{"id":16,"x":31,"y":17,"dx":4,"dy":1},{"id":16,"x":34,"y":17,"dx":2,"dy":2},{"id":16,"x":46,"y":17,"dx":2,"dy":1},{"id":15,"x":16,"y":18,"dx":4,"dy":2},{"id":15,"x":34,"y":18,"dx":-3,"dy":0},{"id":17,"x":48,"y":18,"dx":2,"dy":1},{"id":17,"x":54,"y":18,"dx":2,"dy":2},{"id":17,"x":1,"y":19,"dx":-3,"dy":0},{"id":16,"x":12,"y":19,"dx":1,"dy":0},{"id":16,"x":13,"y":19,"dx":-1,"dy":0},{"id":15,"x":21,"y":19,"dx":0,"dy":0},{"id":16,"x":29,"y":19,"dx":2,"dy":0},{"id":15,"x":44,"y":19,"dx":4,"dy":1},{"id":17,"x":55,"y":19,"dx":0,"dy":-1},{"id":16,"x":10,"y":20,"dx":-3,"dy":1},{"id":15,"x":24,"y":20,"dx":-2,"dy":1},{"id":17,"x":36,"y":20,"dx":-2,"dy":-1},{"id":17,"x":25,"y":21,"dx":-1,"dy":0},{"id":15,"x":37,"y":21,"dx":-4,"dy":2},{"id":16,"x":47,"y":21,"dx":-1,"dy":2},{"id":15,"x":3,"y":22,"dx":-4,"dy":1},{"id":15,"x":11,"y":22,"dx":0,"dy":-1},{"id":16,"x":21,"y":22,"dx":2,"dy":0},{"id":16,"x":22,"y":22,"dx":2,"dy":-1},{"id":15,"x":55,"y":22,"dx":-4,"dy":0},{"id":17,"x":3,"y":23,"dx":3,"dy":0},{"id":16,"x":34,"y":23,"dx":2,"dy":-1},{"id":15,"x":41,"y":23,"dx":-2,"dy":-2},{"id":15,"x":46,"y":23,"dx":2,"dy":1},{"id":16,"x":50,"y":23,"dx":2,"dy":0},{"id":15,"x":9,"y":24,"dx":1,"dy":-1},{"id":15,"x":28,"y":24,"dx":3,"dy":-2},{"id":15,"x":32,"y":24,"dx":4,"dy":-1},{"id":16,"x":39,"y":24,"dx":2,"dy":0},{"id":15,"x":1,"y":25,"dx":-3,"dy":1},{"id":15,"x":11,"y":25,"dx":-1,"dy":1},{"id":15,"x":23,"y":25,"dx":3,"dy":-1},{"id":17,"x":25,"y":25,"dx":2,"dy":-1},{"id":17,"x":28,"y":25,"dx":1,"dy":1},{"id":17,"x":32,"y":25,"dx":-3,"dy":1},{"id":15,"x":44,"y":25,"dx":-1,"dy":-1},{"id":15,"x":0,"y":26,"dx":-1,"dy":-2},{"id":17,"x":16,"y":26,"dx":2,"dy":-1},{"id":17,"x":17,"y":26,"dx":-4,"dy":2},{"id":15,"x":26,"y":26,"dx":-3,"dy":-1},{"id":17,"x":31,"y":26,"dx":-3,"dy":-2},{"id":15,"x":51,"y":26,"dx":0,"dy":0},{"id":15,"x":6,"y":27,"dx":-1,"dy":-1},{"id":16,"x":11,"y":27,"dx":3,"dy":0},{"id":17,"x":16,"y":27,"dx":3,"dy":1},{"id":15,"x":29,"y":27,"dx":2,"dy":0},{"id":16,"x":36,"y":27,"dx":4,"dy":-1},{"id":16,"x":38,"y":27,"dx":1,"dy":0},{"id":17,"x":50,"y":27,"dx":-3,"dy":-2},{"id":17,"x":53,"y":27,"dx":1,"dy":-1},{"id":15,"x":14,"y":28,"dx":-4,"dy":1},{"id":17,"x":16,"y":29,"dx":-3,"dy":-1},{"id":17,"x":20,"y":29,"dx":-1,"dy":-1},{"id":15,"x":29,"y":29,"dx":0,"dy":1},{"id":16,"x":33,"y":29,"dx":-2,"dy":2},{"id":16,"x":37,"y":29,"dx":0,"dy":2},{"id":15,"x":7,"y":30,"dx":2,"dy":-1},{"id":15,"x":9,"y":30,"dx":0,"dy":2},{"id":16,"x":21,"y":30,"dx":3,"dy":1},{"id":17,"x":41,"y":30,"dx":-4,"dy":0},{"id":17,"x":28,"y":32,"dx":1,"dy":1},{"id":15,"x":32,"y":32,"dx":-1,"dy":-1},{"id":17,"x":33,"y":32,"dx":0,"dy":-2},{"id":17,"x":41,"y":32,"dx":-3,"dy":0},{"id":16,"x":50,"y":32,"dx":0,"dy":-2},{"id":15,"x":11,"y":33,"dx":-3,"dy":2},{"id":17,"x":54,"y":33,"dx":-3,"dy":1},{"id":15,"x":2,"y":34,"dx":1,"dy":-2},{"id":16,"x":24,"y":34,"dx":3,"dy":0},{"id":15,"x":38,"y":34,"dx":3,"dy":0},{"id":15,"x":14,"y":35,"dx":1,"dy":0},{"id":16,"x":53,"y":35,"dx":2,"dy":-1},{"id":15,"x":4,"y":36,"dx":-2,"dy":1},{"id":17,"x":6,"y":36,"dx":1,"dy":-1},{"id":16,"x":10,"y":36,"dx":0,"dy":0},{"id":17,"x":18,"y":36,"dx":-1,"dy":-1},{"id":16,"x":24,"y":36,"dx":0,"dy":0},{"id":16,"x":26,"y":36,"dx":0,"dy":-2},{"id":17,"x":32,"y":36,"dx":-1,"dy":-2},{"id":15,"x":35,"y":36,"dx":-1,"dy":-1},{"id":17,"x":36,"y":36,"dx":-1,"dy":-1},{"id":15,"x":40,"y":36,"dx":0,"dy":0},{"id":15,"x":43,"y":36,"dx":1,"dy":1},{"id":17,"x":12,"y":37,"dx":-2,"dy":-1},{"id":16,"x":27,"y":37,"dx":0,"dy":0},{"id":15,"x":48,"y":37,"dx":-1,"dy":2},{"id":15,"x":50,"y":37,"dx":4,"dy":2},{"id":15,"x":55,"y":37,"dx":2,"dy":1},{"id":16,"x":3,"y":38,"dx":-3,"dy":2},{"id":17,"x":8,"y":38,"dx":2,"dy":0},{"id":16,"x":28,"y":38,"dx":-1,"dy":-1},{"id":15,"x":34,"y":38,"dx":0,"dy":0},{"id":17,"x":25,"y":39,"dx":3,"dy":2},{"id":17,"x":29,"y":39,"dx":2,"dy":0},{"id":15,"x":33,"y":39,"dx":3,"dy":-2},{"id":16,"x":51,"y":39,"dx":-1,"dy":-1},{"id":16,"x":52,"y":39,"dx":-1,"dy":0},{"id":16,"x":53,"y":39,"dx":-2,"dy":0},{"id":15,"x":54,"y":39,"dx":-4,"dy":-2},{"id":16,"x":4,"y":40,"dx":-2,"dy":1},{"id":17,"x":41,"y":40,"dx":-2,"dy":-2},{"id":16,"x":52,"y":40,"dx":1,"dy":2},{"id":17,"x":10,"y":41,"dx":-3,"dy":2},{"id":16,"x":22,"y":41,"dx":0,"dy":0},{"id":16,"x":23,"y":41,"dx":-3,"dy":-1},{"id":15,"x":31,"y":41,"dx":1,"dy":1},{"id":15,"x":43,"y":41,"dx":2,"dy":-1},{"id":15,"x":47,"y":41,"dx":2,"dy":0},{"id":16,"x":53,"y":41,"dx":3,"dy":1},{"id":17,"x":8,"y":42,"dx":1,"dy":-1},{"id":16,"x":14,"y":42,"dx":0,"dy":-1},{"id":17,"x":19,"y":42,"dx":1,"dy":1},{"id":16,"x":23,"y":42,"dx":-2,"dy":1},{"id":15,"x":28,"y":42,"dx":3,"dy":-1},{"id":16,"x":35,"y":42,"dx":2,"dy":0},{"id":17,"x":41,"y":42,"dx":2,"dy":-2},{"id":15,"x":48,"y":42,"dx":0,"dy":1},{"id":16,"x":52,"y":42,"dx":-1,"dy":1},{"id":17,"x":8,"y":43,"dx":-2,"dy":0},{"id":15,"x":16,"y":43,"dx":-1,"dy":1},{"id":15,"x":0,"y":44,"dx":2,"dy":2},{"id":15,"x":30,"y":44,"dx":1,"dy":0},{"id":17,"x":43,"y":44,"dx":-1,"dy":0},{"id":16,"x":14,"y":45,"dx":0,"dy":1},{"id":15,"x":31,"y":45,"dx":3,"dy":2},{"id":15,"x":37,"y":45,"dx":-2,"dy":2},{"id":15,"x":38,"y":45,"dx":1,"dy":1},{"id":15,"x":42,"y":45,"dx":-2,"dy":1},{"id":15,"x":46,"y":45,"dx":-2,"dy":-2},{"id":15,"x":1,"y":46,"dx":-1,"dy":2},{"id":16,"x":15,"y":46,"dx":-2,"dy":-1},{"id":17,"x":29,"y":46,"dx":3,"dy":1},{"id":15,"x":41,"y":46,"dx":-1,"dy":0},{"id":17,"x":42,"y":46,"dx":4,"dy":-1},{"id":15,"x":44,"y":46,"dx":1,"dy":1},{"id":15,"x":52,"y":46,"dx":-3,"dy":-2},{"id":16,"x":54,"y":46,"dx":0,"dy":0},{"id":15,"x":4,"y":47,"dx":3,"dy":2},{"id":16,"x":15,"y":47,"dx":1,"dy":2},{"id":16,"x":22,"y":47,"dx":-3,"dy":-1},{"id":16,"x":24,"y":47,"dx":1,"dy":2},{"id":16,"x":27,"y":47,"dx":-4,"dy":1},{"id":16,"x":33,"y":47,"dx":-1,"dy":2},{"id":15,"x":38,"y":47,"dx":-2,"dy":-1},{"id":17,"x":37,"y":48,"dx":3,"dy":0},{"id":15,"x":41,"y":48,"dx":0,"dy":0},{"id":15,"x":45,"y":48,"dx":0,"dy":-2},{"id":17,"x":46,"y":48,"dx":-4,"dy":1},{"id":16,"x":34,"y":49,"dx":1,"dy":-1},{"id":17,"x":46,"y":49,"dx":-4,"dy":2},{"id":15,"x":49,"y":49,"dx":-3,"dy":-1},{"id":17,"x":1,"y":50,"dx":-1,"dy":1},{"id":16,"x":8,"y":50,"dx":4,"dy":1},{"id":16,"x":22,"y":50,"dx":3,"dy":-1},{"id":16,"x":31,"y":50,"dx":1,"dy":-2},{"id":16,"x":32,"y":50,"dx":-4,"dy":-1},{"id":15,"x":37,"y":50,"dx":-2,"dy":0},{"id":16,"x":54,"y":50,"dx":3,"dy":-1},{"id":16,"x":17,"y":51,"dx":-3,"dy":-1},{"id":15,"x":18,"y":51,"dx":-2,"dy":0},{"id":16,"x":9,"y":52,"dx":-1,"dy":0},{"id":16,"x":10,"y":52,"dx":-1,"dy":0},{"id":16,"x":13,"y":52,"dx":2,"dy":2},{"id":15,"x":19,"y":52,"dx":0,"dy":1},{"id":15,"x":25,"y":52,"dx":3,"dy":-1},{"id":15,"x":26,"y":52,"dx":0,"dy":0},{"id":15,"x":29,"y":52,"dx":-4,"dy":0},{"id":17,"x":40,"y":52,"dx":3,"dy":-1},{"id":16,"x":52,"y":52,"dx":3,"dy":0},{"id":16,"x":11,"y":53,"dx":1,"dy":1},{"id":16,"x":13,"y":53,"dx":-2,"dy":-1},{"id":16,"x":34,"y":53,"dx":-2,"dy":1},{"id":16,"x":52,"y":53,"dx":0,"dy":-1},{"id":16,"x":16,"y":54,"dx":-1,"dy":1},{"id":16,"x":17,"y":54,"dx":0,"dy":1},{"id":15,"x":19,"y":54,"dx":3,"dy":-1},{"id":15,"x":41,"y":54,"dx":-2,"dy":-1},{"id":16,"x":44,"y":54,"dx":1,"dy":-2},{"id":17,"x":6,"y":55,"dx":2,"dy":-2},{"id":15,"x":25,"y":55,"dx":-3,"dy":-1},{"id":15,"x":34,"y":55,"dx":1,"dy":-1}],"encoding":"rle","tiles":[[0,3],[3,1],[0,1],[4,1],[3,1],[4,1],[0,4],[4,8],[0,18],[4,4],[0,7],[3,2],[0,2],[3,1],[0,3],[1,1],[0,3],[4,2],[3,1],[4,3],[0,1],[4,1],[0,1],[4,7],[0,4],[4,1],[0,1],[4,1],[0,9],[4,5],[0,7],[4,2],[3,1],[0,3],[3,2],[1,2],[0,2],[4,1],[3,1],[4,1],[0,1],[4,1],[0,2],[4,10],[0,3],[4,1],[0,1],[4,5],[0,5],[4,4],[0,3],[4,8],[0,3],[3,1],[0,2],[1,2],[0,1],[1,1],[0,2],[4,3],[0,1],[4,12],[0,1],[4,4],[0,1],[4,2],[0,5],[4,3],[0,4],[4,2],[0,1],[4,1],[0,2],[3,1],[0,1],[4,1],[0,6],[1,5],[0,4],[4,1],[0,1],[4,8],[0,1],[4,1],[0,3],[4,4],[0,6],[4,3],[0,3],[4,10],[0,3],[3,1],[0,3],[3,1],[4,1],[0,1],[1,1],[0,4],[4,5],[0,1],[4,11],[0,2],[4,4],[0,2],[4,3],[0,4],[4,5],[0,1],[4,1],[0,1],[4,1],[0,2],[3,1],[0,2],[3,2],[4,1],[3,1],[4,1],[1,1],[0,4],[4,5],[0,1],[4,17],[0,9],[4,1],[0,1],[4,3],[0,9],[3,1],[4,1],[0,1],[4,1],[3,1],[1,1],[0,5],[4,1],[0,1],[4,1],[0,2],[4,6],[0,1],[4,6],[0,1],[4,3],[0,2],[4,1],[0,14],[3,1],[0,5],[3,3],[4,2],[1,1],[0,5],[4,2],[0,1],[4,1],[0,3],[4,1],[0,2],[4,1],[0,1],[4,1],[0,1],[4,6],[0,1],[28,1],[0,2],[4,2],[0,9],[3,1],[0,12],[3,1],[0,1],[1,1],[0,4],[4,5],[0,3],[4,3],[0,1],[4,9],[0,5],[4,2],[0,10],[4,5],[0,4],[4,1],[3,3],[1,1],[0,13],[4,2],[0,1],[29,1],[0,1],[4,7],[0,15],[3,1],[0,2],[4,3],[0,5],[3,1],[0,3],[1,1],[0,18],[4,1],[29,1],[4,4],[0,3],[5,1],[0,14],[4,3],[0,1],[4,1],[0,1],[3,1],[0,1],[4,3],[3,1],[0,1],[1,2],[0,14],[4,5],[0,2],[4,1],[0,2],[4,4],[0,1],[4,1],[0,10],[3,1],[0,1],[4,1],[0,2],[4,1],[0,1],[3,1],[0,2],[3,1],[0,3],[3,1],[1,1],[0,9],[28,1],[0,4],[4,2],[0,4],[4,2],[0,2],[4,2],[0,1],[4,1],[29,1],[4,1],[0,2],[4,3],[0,2],[4,1],[0,3],[4,1],[0,1],[4,3],[0,6],[3,1],[0,2],[1,1],[0,14],[4,1],[0,1],[4,1],[0,5],[4,2],[0,1],[4,4],[2,2],[0,1],[2,4],[0,2],[4,7],[0,2],[4,2],[3,2],[0,2],[3,1],[0,1],[1,1],[0,22],[4,6],[0,2],[2,6],[0,1],[4,8],[0,10],[1,2],[0,19],[4,8],[0,1],[2,4],[0,1],[2,2],[0,1],[5,1],[4,7],[0,2],[3,1],[4,2],[3,1],[4,1],[3,3],[0,1],[1,4],[0,16],[4,1],[0,1],[4,5],[0,2],[2,1],[0,1],[2,5],[0,1],[4,1],[0,1],[4,3],[0,2],[4,1],[0,3],[3,4],[0,1],[3,1],[4,3],[0,1],[4,1],[1,4],[0,15],[4,3],[0,1],[4,1],[0,2],[2,4],[0,1],[2,1],[0,5],[3,1],[28,1],[0,5],[3,1],[0,1],[3,6],[0,1],[4,1],[0,2],[4,2],[0,1],[1,1],[0,10],[4,4],[0,1],[4,2],[0,27],[3,1],[0,3],[4,3],[0,4],[1,1],[0,5],[4,1],[0,1],[4,1],[0,3],[4,2],[0,1],[4,1],[0,17],[3,1],[0,9],[3,1],[0,1],[3,1],[0,1],[3,2],[28,1],[0,1],[4,1],[0,1],[3,1],[4,2],[1,1],[0,5],[4,4],[0,2],[29,1],[4,3],[0,8],[4,3],[0,1],[4,1],[0,3],[4,4],[0,9],[3,1],[0,1],[3,1],[4,4],[0,1],[4,1],[3,1],[4,1],[1,1],[0,5],[4,3],[0,3],[4,1],[0,1],[4,2],[0,3],[4,2],[0,1],[4,2],[0,1],[4,4],[0,3],[4,4],[3,2],[0,3],[3,1],[0,3],[3,1],[0,1],[3,1],[0,6],[4,2],[1,2],[4,2],[0,1],[4,2],[0,6],[4,1],[0,5],[4,1],[0,1],[4,3],[28,1],[4,1],[0,2],[4,1],[0,3],[4,1],[3,1],[0,1],[4,2],[0,8],[3,1],[0,1],[3,1],[0,9],[1,1],[0,1],[4,1],[29,1],[4,4],[0,10],[4,8],[0,5],[4,1],[0,1],[4,3],[0,7],[3,1],[0,9],[28,1],[0,2],[1,1],[0,1],[4,1],[0,1],[4,2],[0,11],[4,5],[29,1],[4,1],[0,1],[4,3],[0,3],[4,5],[0,1],[3,1],[0,8],[3,1],[0,12],[4,2],[3,1],[0,2],[28,1],[0,9],[4,5],[0,2],[4,1],[0,1],[4,5],[0,5],[4,3],[0,4],[3,1],[0,2],[3,1],[0,3],[4,1],[0,1],[4,1],[0,5],[29,1],[0,1],[4,1],[0,2],[4,3],[0,1],[4,1],[0,4],[4,1],[0,2],[4,5],[0,2],[4,6],[0,6],[4,1],[0,1],[3,1],[0,2],[3,3],[0,1],[29,1],[0,2],[4,3],[0,5],[1,33],[0,1],[1,2],[0,1],[3,1],[4,4],[0,1],[4,1],[0,4],[4,1],[0,1],[4,1],[0,8],[4,7],[0,3],[29,1],[4,3],[0,1],[4,4],[0,2],[4,5],[0,3],[4,1],[0,3],[3,2],[4,4],[3,1],[4,1],[0,4],[29,1],[0,7],[4,5],[0,1],[4,1],[0,2],[4,1],[0,4],[4,3],[0,4],[29,1],[0,2],[4,1],[0,1],[4,3],[0,2],[4,1],[0,1],[4,3],[0,1],[4,3],[0,1],[3,1],[4,1],[3,1],[0,8],[4,4],[0,1],[4,7],[0,6],[4,3],[0,1],[4,2],[0,5],[4,3],[0,1],[4,1],[0,2],[3,1],[4,3],[0,1],[3,2],[4,2],[3,1],[4,1],[3,2],[0,4],[2,4],[4,1],[2,1],[0,1],[4,1],[28,1],[4,4],[0,9],[4,2],[0,1],[4,3],[0,7],[4,1],[0,1],[4,4],[0,1],[4,1],[0,1],[3,1],[4,4],[0,1],[4,1],[0,5],[2,3],[0,1],[2,3],[4,1],[0,1],[4,4],[0,5],[29,1],[0,6],[4,1],[0,1],[4,1],[0,7],[4,2],[0,6],[3,2],[0,3],[3,1],[0,1],[3,2],[29,1],[0,3],[2,4],[0,1],[2,2],[4,1],[5,1],[4,1],[0,1],[4,2],[0,19],[4,1],[0,2],[4,3],[0,1],[3,1],[0,1],[4,1],[3,1],[0,2],[3,1],[0,2],[3,4],[0,1],[29,1],[4,2],[0,1],[2,1],[0,1],[2,4],[0,2],[4,2],[0,1],[4,5],[0,13],[4,1],[0,1],[4,1],[0,5],[3,1],[4,1],[0,1],[4,1],[0,2],[3,2],[0,5],[3,1],[0,1],[4,2],[5,1],[2,6],[0,6],[4,5],[0,15],[4,1],[0,6],[4,1],[0,1],[4,1],[3,1],[0,2],[4,1],[3,2],[4,1],[3,1],[0,4],[4,2],[0,4],[29,1],[0,9],[4,3],[0,28],[4,1],[3,1],[4,1],[3,1],[0,20],[4,4],[0,9],[29,1],[0,1],[4,1],[0,9],[4,4],[0,2],[3,1],[4,1],[3,1],[4,1],[0,1],[4,1],[0,8],[4,5],[0,5],[4,1],[0,1],[4,3],[0,10],[4,3],[0,6],[3,1],[0,1],[4,4],[0,3],[3,1],[4,4],[3,2],[0,7],[4,2],[0,1],[4,1],[0,20],[4,2],[0,3],[10,1],[0,6],[4,3],[0,1],[3,2],[4,3],[0,1],[4,1],[3,1],[0,7],[4,3],[0,1],[4,1],[0,3],[4,3],[0,1],[4,1],[0,22],[4,2],[0,1],[4,4],[0,1],[3,1],[0,3],[3,1],[0,8],[4,2],[0,1],[4,2],[0,2],[4,6],[0,8],[4,2],[0,12],[3,1],[4,5],[3,5],[0,1],[3,1],[0,6],[28,1],[0,1],[4,2],[0,1],[4,2],[0,2],[4,6],[0,7],[4,3],[0,12],[4,5],[0,1],[4,1],[0,1],[3,1],[0,1],[3,2],[0,1],[3,2],[0,3],[4,1],[0,1],[4,1],[0,7],[4,6],[0,7],[4,2],[0,13],[4,1],[0,1],[4,1],[0,1],[4,2],[0,2],[3,1],[0,2],[3,3],[0,3],[4,5],[0,5],[4,6],[0,23],[4,1],[0,1],[4,1],[3,1],[4,1],[3,1],[4,1],[0,3],[3,1],[0,6],[4,2],[0,1],[4,1],[0,1],[4,1],[0,4],[4,6],[0,24],[3,1],[28,1],[0,2],[4,1],[0,1],[3,1],[0,4],[3,1],[0,4],[4,6],[0,4],[4,8],[0,5],[4,3],[0,15],[3,1],[0,1],[4,2],[0,3],[3,1],[0,2],[3,3],[4,2],[0,1],[4,1],[0,1],[4,1],[0,1],[4,1],[0,4],[4,6],[0,1],[4,1],[0,3],[4,2],[0,2],[4,1],[0,17],[4,2],[0,1],[3,1],[0,1],[3,1],[0,2],[3,1],[0,1],[4,7],[0,1],[4,1],[0,6],[4,2],[0,2],[4,2],[0,3],[4,6],[0,17],[3,1],[4,1],[0,3],[3,1],[0,1],[3,2],[4,6],[0,2],[4,1],[0,1],[4,1],[0,4],[4,2],[0,7],[4,5],[0,14],[3,1],[4,4],[0,2],[3,4],[4,1],[0,1],[4,3],[0,1],[4,2],[0,3],[4,2],[0,13],[4,5],[0,13],[4,3],[3,1],[4,2],[0,3],[3,4],[4,5],[0,1],[4,2],[0,1],[4,3],[0,14],[4,1],[0,2],[4,2],[0,11],[3,1],[4,6],[0,1],[3,2],[0,3],[3,3],[4,10],[0,15],[4,3],[0,6],[4,1],[0,1],[4,3],[0,1],[3,1],[0,3],[4,1],[0,1],[4,1],[0,3],[3,2],[4,2],[0,1],[3,1],[0,1],[4,1],[0,1],[4,2],[0,1],[4,4],[0,6],[28,1],[0,7],[28,1],[0,9],[4,5],[0,5],[4,1],[3,1],[4,1],[0,2],[3,1],[0,1],[4,1],[0,3],[3,1],[0,1],[4,2],[0,1],[4,5],[0,25],[4,4],[0,10]]} \ No newline at end of file diff --git a/v1/apps/game/assets/maps/old_siding.map b/v1/apps/game/assets/maps/old_siding.map new file mode 100644 index 0000000..017631f --- /dev/null +++ b/v1/apps/game/assets/maps/old_siding.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":48,"height":40,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"variants":{"0":[0,11,12],"1":[1,14],"3":[3,22,23],"4":[4,13],"7":[7,24,25,26,27],"28":[28,30,31],"29":[29,32,33]},"decor":[{"id":16,"x":5,"y":0,"dx":-1,"dy":0},{"id":16,"x":13,"y":0,"dx":-4,"dy":0},{"id":16,"x":18,"y":0,"dx":-2,"dy":-1},{"id":16,"x":24,"y":0,"dx":-2,"dy":-2},{"id":16,"x":28,"y":0,"dx":0,"dy":-1},{"id":16,"x":30,"y":0,"dx":-2,"dy":2},{"id":16,"x":13,"y":1,"dx":3,"dy":1},{"id":16,"x":17,"y":1,"dx":0,"dy":-2},{"id":16,"x":37,"y":1,"dx":1,"dy":1},{"id":16,"x":39,"y":1,"dx":1,"dy":1},{"id":16,"x":43,"y":1,"dx":-1,"dy":2},{"id":16,"x":2,"y":2,"dx":-2,"dy":-1},{"id":16,"x":8,"y":2,"dx":-3,"dy":-1},{"id":16,"x":12,"y":2,"dx":-1,"dy":-1},{"id":16,"x":22,"y":2,"dx":-3,"dy":-1},{"id":16,"x":43,"y":2,"dx":0,"dy":0},{"id":16,"x":46,"y":2,"dx":0,"dy":0},{"id":16,"x":19,"y":3,"dx":-3,"dy":1},{"id":16,"x":24,"y":3,"dx":1,"dy":-1},{"id":16,"x":17,"y":4,"dx":1,"dy":0},{"id":16,"x":19,"y":4,"dx":-1,"dy":-1},{"id":16,"x":22,"y":4,"dx":1,"dy":1},{"id":16,"x":29,"y":4,"dx":2,"dy":0},{"id":16,"x":1,"y":5,"dx":-3,"dy":0},{"id":16,"x":3,"y":5,"dx":-3,"dy":1},{"id":16,"x":41,"y":5,"dx":-2,"dy":1},{"id":16,"x":44,"y":5,"dx":-1,"dy":-1},{"id":16,"x":27,"y":6,"dx":2,"dy":0},{"id":16,"x":31,"y":6,"dx":-2,"dy":1},{"id":16,"x":17,"y":7,"dx":-2,"dy":-2},{"id":16,"x":27,"y":7,"dx":0,"dy":1},{"id":16,"x":32,"y":7,"dx":-3,"dy":1},{"id":16,"x":45,"y":7,"dx":-2,"dy":-1},{"id":16,"x":5,"y":8,"dx":-3,"dy":0},{"id":16,"x":27,"y":8,"dx":-3,"dy":-2},{"id":16,"x":28,"y":8,"dx":3,"dy":2},{"id":16,"x":8,"y":9,"dx":1,"dy":0},{"id":16,"x":14,"y":9,"dx":-4,"dy":-1},{"id":16,"x":31,"y":9,"dx":-1,"dy":1},{"id":16,"x":38,"y":9,"dx":0,"dy":1},{"id":16,"x":0,"y":10,"dx":3,"dy":-2},{"id":16,"x":2,"y":10,"dx":-4,"dy":0},{"id":16,"x":4,"y":10,"dx":0,"dy":0},{"id":16,"x":10,"y":10,"dx":-3,"dy":-1},{"id":16,"x":21,"y":10,"dx":-4,"dy":-1},{"id":16,"x":40,"y":10,"dx":-3,"dy":-2},{"id":16,"x":42,"y":10,"dx":3,"dy":1},{"id":16,"x":9,"y":11,"dx":-2,"dy":2},{"id":16,"x":12,"y":11,"dx":0,"dy":1},{"id":16,"x":13,"y":11,"dx":3,"dy":2},{"id":16,"x":31,"y":11,"dx":-3,"dy":-1},{"id":16,"x":41,"y":11,"dx":-4,"dy":-2},{"id":16,"x":42,"y":11,"dx":3,"dy":-1},{"id":16,"x":43,"y":11,"dx":-1,"dy":1},{"id":16,"x":1,"y":12,"dx":1,"dy":2},{"id":16,"x":4,"y":12,"dx":0,"dy":2},{"id":16,"x":7,"y":12,"dx":-1,"dy":2},{"id":16,"x":25,"y":12,"dx":1,"dy":0},{"id":16,"x":13,"y":13,"dx":-2,"dy":1},{"id":16,"x":16,"y":13,"dx":1,"dy":1},{"id":16,"x":20,"y":13,"dx":2,"dy":2},{"id":16,"x":4,"y":14,"dx":-3,"dy":-1},{"id":16,"x":44,"y":14,"dx":0,"dy":-2},{"id":16,"x":3,"y":15,"dx":3,"dy":1},{"id":16,"x":19,"y":15,"dx":-1,"dy":0},{"id":16,"x":20,"y":15,"dx":0,"dy":-2},{"id":16,"x":21,"y":15,"dx":-1,"dy":0},{"id":16,"x":28,"y":15,"dx":-2,"dy":-2},{"id":16,"x":35,"y":15,"dx":-1,"dy":1},{"id":16,"x":38,"y":15,"dx":0,"dy":2},{"id":16,"x":46,"y":15,"dx":-4,"dy":-1},{"id":16,"x":2,"y":16,"dx":4,"dy":1},{"id":16,"x":11,"y":16,"dx":3,"dy":0},{"id":16,"x":21,"y":16,"dx":3,"dy":0},{"id":16,"x":7,"y":17,"dx":-1,"dy":2},{"id":16,"x":31,"y":17,"dx":1,"dy":1},{"id":16,"x":32,"y":17,"dx":-4,"dy":1},{"id":16,"x":42,"y":17,"dx":3,"dy":0},{"id":16,"x":27,"y":18,"dx":-3,"dy":-1},{"id":16,"x":39,"y":18,"dx":-1,"dy":0},{"id":16,"x":40,"y":18,"dx":1,"dy":2},{"id":16,"x":41,"y":18,"dx":-3,"dy":-2},{"id":16,"x":21,"y":19,"dx":2,"dy":1},{"id":16,"x":30,"y":19,"dx":1,"dy":-1},{"id":16,"x":0,"y":21,"dx":3,"dy":-1},{"id":16,"x":35,"y":21,"dx":-2,"dy":1},{"id":16,"x":37,"y":21,"dx":3,"dy":-2},{"id":16,"x":29,"y":22,"dx":-3,"dy":-2},{"id":16,"x":38,"y":22,"dx":4,"dy":1},{"id":16,"x":42,"y":22,"dx":-1,"dy":1},{"id":16,"x":14,"y":23,"dx":-1,"dy":-2},{"id":16,"x":33,"y":23,"dx":0,"dy":2},{"id":16,"x":36,"y":23,"dx":-2,"dy":-1},{"id":16,"x":42,"y":23,"dx":-1,"dy":-2},{"id":16,"x":10,"y":24,"dx":-1,"dy":-1},{"id":16,"x":21,"y":24,"dx":1,"dy":1},{"id":16,"x":30,"y":24,"dx":3,"dy":1},{"id":16,"x":31,"y":24,"dx":2,"dy":2},{"id":16,"x":34,"y":24,"dx":-2,"dy":-1},{"id":16,"x":46,"y":24,"dx":3,"dy":1},{"id":16,"x":1,"y":25,"dx":0,"dy":0},{"id":16,"x":4,"y":25,"dx":2,"dy":2},{"id":16,"x":20,"y":25,"dx":-1,"dy":-2},{"id":16,"x":33,"y":25,"dx":-2,"dy":2},{"id":16,"x":1,"y":26,"dx":3,"dy":2},{"id":16,"x":25,"y":26,"dx":-3,"dy":1},{"id":16,"x":0,"y":27,"dx":3,"dy":1},{"id":16,"x":3,"y":27,"dx":4,"dy":0},{"id":16,"x":6,"y":27,"dx":3,"dy":0},{"id":16,"x":11,"y":27,"dx":-2,"dy":-1},{"id":16,"x":14,"y":27,"dx":2,"dy":-2},{"id":16,"x":20,"y":27,"dx":-4,"dy":-2},{"id":16,"x":25,"y":27,"dx":3,"dy":-2},{"id":16,"x":29,"y":27,"dx":1,"dy":0},{"id":16,"x":36,"y":27,"dx":-1,"dy":-2},{"id":16,"x":39,"y":27,"dx":-4,"dy":-1},{"id":16,"x":46,"y":27,"dx":-3,"dy":-1},{"id":16,"x":14,"y":28,"dx":-4,"dy":2},{"id":16,"x":29,"y":28,"dx":-1,"dy":1},{"id":16,"x":38,"y":28,"dx":0,"dy":2},{"id":16,"x":40,"y":28,"dx":0,"dy":1},{"id":16,"x":42,"y":29,"dx":0,"dy":-1},{"id":17,"x":1,"y":30,"dx":1,"dy":1},{"id":16,"x":4,"y":30,"dx":2,"dy":1},{"id":16,"x":7,"y":30,"dx":-1,"dy":1},{"id":15,"x":21,"y":30,"dx":0,"dy":1},{"id":16,"x":30,"y":30,"dx":-2,"dy":-1},{"id":16,"x":41,"y":30,"dx":-1,"dy":0},{"id":16,"x":8,"y":31,"dx":-1,"dy":1},{"id":16,"x":15,"y":31,"dx":-2,"dy":0},{"id":16,"x":36,"y":31,"dx":4,"dy":-1},{"id":16,"x":12,"y":32,"dx":2,"dy":2},{"id":16,"x":15,"y":32,"dx":3,"dy":-1},{"id":16,"x":17,"y":32,"dx":-3,"dy":-1},{"id":16,"x":24,"y":32,"dx":2,"dy":1},{"id":16,"x":32,"y":32,"dx":3,"dy":0},{"id":16,"x":36,"y":32,"dx":0,"dy":-2},{"id":16,"x":43,"y":32,"dx":2,"dy":1},{"id":16,"x":4,"y":33,"dx":-1,"dy":1},{"id":16,"x":16,"y":33,"dx":0,"dy":0},{"id":16,"x":26,"y":33,"dx":2,"dy":1},{"id":16,"x":43,"y":33,"dx":4,"dy":0},{"id":16,"x":17,"y":34,"dx":-2,"dy":-2},{"id":16,"x":22,"y":34,"dx":-4,"dy":1},{"id":16,"x":26,"y":34,"dx":2,"dy":1},{"id":16,"x":35,"y":34,"dx":2,"dy":2},{"id":16,"x":2,"y":35,"dx":3,"dy":2},{"id":16,"x":26,"y":35,"dx":3,"dy":1},{"id":16,"x":28,"y":35,"dx":-1,"dy":1},{"id":16,"x":43,"y":35,"dx":-3,"dy":1},{"id":16,"x":10,"y":36,"dx":0,"dy":1},{"id":16,"x":11,"y":36,"dx":2,"dy":1},{"id":16,"x":27,"y":36,"dx":-1,"dy":1},{"id":16,"x":28,"y":36,"dx":-4,"dy":2},{"id":16,"x":31,"y":36,"dx":-1,"dy":1},{"id":16,"x":33,"y":36,"dx":3,"dy":-2},{"id":16,"x":42,"y":36,"dx":-1,"dy":-1},{"id":16,"x":10,"y":37,"dx":-4,"dy":-1},{"id":16,"x":18,"y":37,"dx":2,"dy":1},{"id":16,"x":31,"y":37,"dx":-4,"dy":-1},{"id":16,"x":8,"y":38,"dx":3,"dy":1},{"id":16,"x":11,"y":38,"dx":-1,"dy":1},{"id":16,"x":14,"y":38,"dx":-2,"dy":0},{"id":16,"x":27,"y":38,"dx":-1,"dy":-1}],"encoding":"rle","tiles":[[4,10],[0,1],[4,89],[0,1],[4,42],[3,1],[4,1],[3,1],[4,43],[3,1],[4,11],[0,1],[4,24],[29,1],[4,4],[0,1],[4,4],[3,1],[4,7],[3,1],[4,19],[29,1],[4,24],[3,1],[4,11],[28,1],[4,8],[19,3],[4,16],[0,1],[4,7],[3,1],[4,20],[19,2],[4,1],[19,1],[4,44],[19,2],[4,2],[19,1],[4,8],[29,1],[4,11],[3,1],[4,1],[0,1],[4,1],[3,1],[4,1],[3,1],[4,16],[19,2],[4,3],[19,1],[4,7],[0,1],[4,7],[3,1],[4,26],[19,2],[4,4],[19,1],[4,20],[3,1],[4,20],[19,2],[4,5],[19,1],[28,1],[4,22],[3,1],[4,9],[0,1],[7,1],[4,5],[19,2],[4,6],[19,1],[4,39],[19,2],[4,7],[19,1],[4,4],[28,1],[4,10],[3,1],[4,22],[19,2],[4,8],[19,1],[4,20],[3,1],[4,16],[19,2],[4,3],[0,1],[4,5],[19,1],[4,10],[3,2],[4,2],[3,1],[4,2],[3,1],[4,18],[19,2],[4,10],[19,1],[4,12],[3,1],[4,3],[3,1],[4,18],[19,2],[4,11],[19,1],[4,1],[0,1],[4,11],[3,1],[4,20],[19,2],[4,11],[0,1],[19,1],[4,7],[3,1],[4,8],[3,1],[4,8],[0,1],[4,7],[19,2],[4,13],[19,1],[4,7],[0,1],[4,3],[1,20],[4,1],[19,2],[1,3],[4,11],[19,1],[4,7],[3,1],[4,23],[19,2],[4,2],[1,1],[4,43],[19,2],[4,2],[1,1],[4,43],[19,2],[4,2],[1,1],[4,17],[0,1],[4,25],[19,2],[4,2],[1,1],[4,5],[7,1],[4,2],[29,1],[4,7],[0,1],[4,7],[29,1],[4,3],[28,1],[4,14],[19,2],[4,2],[1,1],[4,17],[3,1],[4,13],[9,1],[4,11],[19,2],[4,2],[1,1],[4,22],[3,2],[4,19],[19,2],[4,2],[1,1],[4,43],[19,2],[4,2],[1,1],[4,25],[3,2],[4,16],[19,2],[4,2],[1,1],[4,22],[0,1],[4,19],[0,1],[19,2],[4,2],[1,2],[4,15],[3,1],[4,26],[19,2],[4,3],[1,1],[20,1],[4,41],[19,2],[4,27],[3,1],[4,11],[0,1],[4,69],[28,1],[4,29],[0,1],[4,24],[3,1],[4,6],[3,1],[4,42],[0,1],[4,21],[29,1],[4,22],[3,1],[4,2],[3,1],[4,4],[3,1],[4,40],[3,1],[4,5],[3,1],[4,40],[3,1],[4,4]]} \ No newline at end of file diff --git a/v1/apps/game/assets/maps/ponds.map b/v1/apps/game/assets/maps/ponds.map new file mode 100644 index 0000000..bcca04f --- /dev/null +++ b/v1/apps/game/assets/maps/ponds.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":48,"height":48,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"variants":{"0":[0,11,12],"1":[1,14],"3":[3,22,23],"4":[4,13],"7":[7,24,25,26,27],"28":[28,30,31],"29":[29,32,33]},"decor":[{"id":15,"x":3,"y":0,"dx":-2,"dy":2},{"id":16,"x":13,"y":0,"dx":0,"dy":0},{"id":16,"x":25,"y":0,"dx":1,"dy":-1},{"id":16,"x":37,"y":0,"dx":-4,"dy":-2},{"id":16,"x":43,"y":0,"dx":-4,"dy":0},{"id":16,"x":47,"y":0,"dx":-2,"dy":1},{"id":16,"x":13,"y":1,"dx":2,"dy":1},{"id":16,"x":22,"y":1,"dx":3,"dy":1},{"id":16,"x":39,"y":1,"dx":-1,"dy":0},{"id":15,"x":34,"y":2,"dx":3,"dy":-2},{"id":16,"x":47,"y":2,"dx":-4,"dy":0},{"id":16,"x":6,"y":3,"dx":0,"dy":-1},{"id":16,"x":23,"y":3,"dx":-3,"dy":0},{"id":16,"x":12,"y":4,"dx":-1,"dy":-1},{"id":16,"x":15,"y":4,"dx":2,"dy":-1},{"id":16,"x":28,"y":4,"dx":2,"dy":-2},{"id":16,"x":32,"y":4,"dx":0,"dy":2},{"id":15,"x":36,"y":4,"dx":-2,"dy":-1},{"id":16,"x":37,"y":4,"dx":-3,"dy":1},{"id":16,"x":0,"y":5,"dx":1,"dy":-1},{"id":16,"x":23,"y":5,"dx":3,"dy":0},{"id":16,"x":26,"y":5,"dx":3,"dy":-1},{"id":17,"x":31,"y":5,"dx":1,"dy":-1},{"id":16,"x":11,"y":6,"dx":1,"dy":0},{"id":16,"x":18,"y":6,"dx":-3,"dy":0},{"id":16,"x":21,"y":6,"dx":-3,"dy":-2},{"id":16,"x":34,"y":6,"dx":3,"dy":-1},{"id":16,"x":37,"y":6,"dx":-4,"dy":-2},{"id":16,"x":47,"y":6,"dx":-1,"dy":1},{"id":16,"x":4,"y":7,"dx":2,"dy":-1},{"id":17,"x":7,"y":7,"dx":4,"dy":0},{"id":16,"x":11,"y":7,"dx":-4,"dy":0},{"id":16,"x":21,"y":7,"dx":1,"dy":2},{"id":16,"x":1,"y":8,"dx":2,"dy":1},{"id":16,"x":2,"y":8,"dx":1,"dy":-2},{"id":16,"x":14,"y":8,"dx":2,"dy":0},{"id":16,"x":28,"y":8,"dx":4,"dy":-1},{"id":17,"x":41,"y":8,"dx":3,"dy":2},{"id":16,"x":47,"y":8,"dx":-2,"dy":2},{"id":16,"x":17,"y":9,"dx":0,"dy":0},{"id":16,"x":28,"y":9,"dx":3,"dy":0},{"id":16,"x":33,"y":9,"dx":0,"dy":-1},{"id":16,"x":17,"y":10,"dx":-2,"dy":-2},{"id":16,"x":22,"y":10,"dx":3,"dy":-1},{"id":16,"x":28,"y":10,"dx":-1,"dy":1},{"id":16,"x":37,"y":10,"dx":2,"dy":1},{"id":16,"x":8,"y":11,"dx":1,"dy":1},{"id":16,"x":9,"y":11,"dx":-1,"dy":1},{"id":16,"x":17,"y":11,"dx":0,"dy":-1},{"id":16,"x":21,"y":11,"dx":0,"dy":-1},{"id":16,"x":22,"y":11,"dx":-1,"dy":-2},{"id":16,"x":25,"y":11,"dx":-4,"dy":-2},{"id":17,"x":34,"y":11,"dx":-2,"dy":-1},{"id":17,"x":41,"y":11,"dx":3,"dy":0},{"id":16,"x":1,"y":12,"dx":1,"dy":0},{"id":15,"x":9,"y":12,"dx":3,"dy":-2},{"id":16,"x":10,"y":12,"dx":4,"dy":1},{"id":16,"x":16,"y":12,"dx":-3,"dy":1},{"id":16,"x":19,"y":12,"dx":1,"dy":-2},{"id":16,"x":26,"y":12,"dx":4,"dy":1},{"id":16,"x":3,"y":13,"dx":0,"dy":1},{"id":16,"x":31,"y":13,"dx":-1,"dy":0},{"id":16,"x":33,"y":13,"dx":2,"dy":-1},{"id":16,"x":37,"y":13,"dx":1,"dy":1},{"id":17,"x":43,"y":13,"dx":0,"dy":-1},{"id":16,"x":1,"y":14,"dx":-1,"dy":0},{"id":16,"x":5,"y":14,"dx":2,"dy":-2},{"id":15,"x":41,"y":14,"dx":-3,"dy":1},{"id":16,"x":42,"y":14,"dx":-1,"dy":-1},{"id":16,"x":3,"y":15,"dx":4,"dy":-1},{"id":16,"x":13,"y":15,"dx":1,"dy":0},{"id":16,"x":16,"y":15,"dx":0,"dy":2},{"id":16,"x":44,"y":15,"dx":2,"dy":1},{"id":16,"x":41,"y":16,"dx":2,"dy":2},{"id":16,"x":45,"y":16,"dx":-2,"dy":-2},{"id":16,"x":46,"y":16,"dx":4,"dy":-1},{"id":16,"x":45,"y":17,"dx":-4,"dy":1},{"id":16,"x":47,"y":17,"dx":3,"dy":1},{"id":15,"x":38,"y":18,"dx":-2,"dy":0},{"id":16,"x":1,"y":19,"dx":2,"dy":0},{"id":16,"x":40,"y":19,"dx":-3,"dy":-1},{"id":16,"x":3,"y":21,"dx":1,"dy":-1},{"id":15,"x":6,"y":22,"dx":-2,"dy":2},{"id":15,"x":7,"y":22,"dx":2,"dy":-2},{"id":15,"x":9,"y":22,"dx":2,"dy":-1},{"id":16,"x":39,"y":23,"dx":-1,"dy":0},{"id":16,"x":46,"y":23,"dx":0,"dy":0},{"id":16,"x":1,"y":24,"dx":2,"dy":-2},{"id":16,"x":3,"y":24,"dx":-3,"dy":0},{"id":16,"x":2,"y":25,"dx":2,"dy":0},{"id":16,"x":46,"y":26,"dx":2,"dy":1},{"id":16,"x":4,"y":27,"dx":1,"dy":-2},{"id":16,"x":38,"y":27,"dx":-2,"dy":1},{"id":16,"x":41,"y":27,"dx":4,"dy":-1},{"id":16,"x":3,"y":28,"dx":2,"dy":-2},{"id":16,"x":47,"y":28,"dx":-2,"dy":2},{"id":16,"x":41,"y":29,"dx":0,"dy":0},{"id":15,"x":3,"y":30,"dx":-3,"dy":0},{"id":16,"x":9,"y":30,"dx":-3,"dy":1},{"id":16,"x":40,"y":30,"dx":-4,"dy":0},{"id":16,"x":41,"y":30,"dx":0,"dy":-2},{"id":16,"x":43,"y":30,"dx":0,"dy":-1},{"id":16,"x":41,"y":31,"dx":4,"dy":-2},{"id":16,"x":31,"y":32,"dx":-2,"dy":-2},{"id":16,"x":36,"y":32,"dx":-2,"dy":-2},{"id":16,"x":47,"y":32,"dx":-3,"dy":1},{"id":17,"x":36,"y":33,"dx":2,"dy":0},{"id":16,"x":39,"y":33,"dx":2,"dy":0},{"id":16,"x":45,"y":33,"dx":2,"dy":2},{"id":16,"x":7,"y":34,"dx":3,"dy":0},{"id":15,"x":27,"y":34,"dx":1,"dy":1},{"id":16,"x":35,"y":34,"dx":-3,"dy":1},{"id":16,"x":39,"y":34,"dx":1,"dy":0},{"id":16,"x":1,"y":35,"dx":-1,"dy":-1},{"id":16,"x":31,"y":35,"dx":2,"dy":-1},{"id":16,"x":8,"y":36,"dx":-1,"dy":1},{"id":16,"x":12,"y":36,"dx":-3,"dy":0},{"id":17,"x":17,"y":36,"dx":-1,"dy":-1},{"id":16,"x":40,"y":36,"dx":2,"dy":-2},{"id":16,"x":45,"y":36,"dx":-1,"dy":-1},{"id":16,"x":46,"y":36,"dx":3,"dy":2},{"id":16,"x":9,"y":37,"dx":2,"dy":-1},{"id":16,"x":13,"y":37,"dx":-2,"dy":1},{"id":15,"x":16,"y":37,"dx":2,"dy":-1},{"id":16,"x":33,"y":37,"dx":1,"dy":1},{"id":16,"x":39,"y":37,"dx":-1,"dy":0},{"id":16,"x":40,"y":37,"dx":-1,"dy":1},{"id":16,"x":41,"y":37,"dx":-4,"dy":1},{"id":16,"x":10,"y":38,"dx":-3,"dy":1},{"id":16,"x":15,"y":38,"dx":2,"dy":0},{"id":17,"x":18,"y":38,"dx":1,"dy":2},{"id":16,"x":44,"y":38,"dx":-3,"dy":-1},{"id":16,"x":45,"y":38,"dx":-3,"dy":0},{"id":16,"x":5,"y":39,"dx":1,"dy":-1},{"id":16,"x":17,"y":39,"dx":-3,"dy":1},{"id":16,"x":3,"y":40,"dx":-2,"dy":0},{"id":16,"x":16,"y":40,"dx":3,"dy":-2},{"id":16,"x":21,"y":40,"dx":-1,"dy":-1},{"id":16,"x":29,"y":40,"dx":-2,"dy":2},{"id":16,"x":41,"y":40,"dx":2,"dy":0},{"id":16,"x":5,"y":41,"dx":-1,"dy":-1},{"id":16,"x":2,"y":42,"dx":3,"dy":-2},{"id":15,"x":6,"y":42,"dx":3,"dy":0},{"id":16,"x":9,"y":42,"dx":-3,"dy":2},{"id":16,"x":42,"y":42,"dx":-2,"dy":1},{"id":16,"x":43,"y":42,"dx":-3,"dy":2},{"id":16,"x":47,"y":42,"dx":4,"dy":1},{"id":16,"x":5,"y":43,"dx":2,"dy":1},{"id":16,"x":8,"y":43,"dx":3,"dy":2},{"id":16,"x":21,"y":43,"dx":-1,"dy":0},{"id":17,"x":28,"y":43,"dx":2,"dy":-1},{"id":15,"x":32,"y":43,"dx":-1,"dy":-2},{"id":16,"x":38,"y":43,"dx":-2,"dy":-2},{"id":16,"x":41,"y":43,"dx":2,"dy":2},{"id":16,"x":42,"y":43,"dx":-3,"dy":-1},{"id":16,"x":46,"y":43,"dx":-1,"dy":0},{"id":16,"x":0,"y":44,"dx":-1,"dy":1},{"id":16,"x":2,"y":44,"dx":3,"dy":2},{"id":15,"x":30,"y":44,"dx":4,"dy":-1},{"id":16,"x":35,"y":44,"dx":1,"dy":0},{"id":16,"x":37,"y":44,"dx":3,"dy":-1},{"id":16,"x":42,"y":44,"dx":1,"dy":-1},{"id":16,"x":14,"y":45,"dx":1,"dy":0},{"id":16,"x":17,"y":45,"dx":-3,"dy":1},{"id":16,"x":25,"y":45,"dx":-4,"dy":0},{"id":15,"x":29,"y":45,"dx":4,"dy":1},{"id":16,"x":30,"y":45,"dx":-2,"dy":2},{"id":16,"x":36,"y":45,"dx":2,"dy":1},{"id":16,"x":39,"y":45,"dx":-1,"dy":0},{"id":16,"x":40,"y":45,"dx":0,"dy":1},{"id":16,"x":9,"y":46,"dx":-3,"dy":2},{"id":16,"x":11,"y":46,"dx":2,"dy":-2},{"id":16,"x":21,"y":46,"dx":1,"dy":-1},{"id":16,"x":22,"y":46,"dx":-3,"dy":2},{"id":16,"x":24,"y":46,"dx":3,"dy":0},{"id":16,"x":3,"y":47,"dx":-1,"dy":2},{"id":16,"x":18,"y":47,"dx":3,"dy":-1},{"id":16,"x":20,"y":47,"dx":1,"dy":1},{"id":15,"x":30,"y":47,"dx":3,"dy":1}],"encoding":"rle","tiles":[[4,1],[0,5],[4,26],[0,1],[4,1],[0,2],[4,8],[3,1],[4,4],[3,2],[0,3],[4,23],[0,1],[4,1],[0,4],[4,10],[3,1],[4,3],[0,1],[1,1],[0,3],[4,3],[0,2],[4,18],[0,2],[4,1],[0,4],[4,13],[3,2],[0,3],[4,3],[0,1],[4,1],[0,2],[4,16],[0,3],[4,1],[0,1],[4,1],[0,1],[4,8],[3,2],[4,6],[1,1],[0,1],[4,2],[28,1],[0,2],[4,5],[0,1],[4,2],[0,1],[4,4],[28,1],[4,4],[0,2],[4,2],[0,1],[4,1],[0,2],[4,15],[0,2],[1,3],[0,1],[4,6],[0,1],[4,2],[0,1],[4,5],[29,1],[4,3],[0,3],[4,4],[0,2],[4,8],[3,1],[4,6],[0,3],[1,3],[4,6],[0,1],[4,1],[0,2],[4,23],[3,1],[4,6],[3,1],[4,1],[3,1],[4,1],[0,2],[4,1],[1,1],[4,4],[0,3],[4,1],[0,2],[4,10],[29,1],[4,2],[0,1],[4,6],[0,1],[4,1],[0,3],[3,1],[4,4],[3,3],[4,1],[0,2],[4,1],[1,1],[4,1],[0,2],[4,1],[0,2],[4,1],[0,3],[4,12],[0,1],[4,2],[0,1],[4,4],[0,4],[4,3],[3,1],[4,4],[0,1],[4,2],[0,2],[1,2],[4,2],[0,2],[4,2],[29,1],[4,7],[0,1],[4,2],[0,2],[4,1],[0,1],[4,1],[0,2],[4,7],[0,5],[3,1],[4,2],[3,1],[4,7],[1,1],[0,1],[4,1],[0,3],[4,9],[0,2],[4,1],[0,1],[4,3],[0,4],[4,2],[0,1],[4,1],[0,5],[4,2],[3,1],[4,2],[3,1],[4,7],[1,1],[0,2],[4,1],[28,1],[4,10],[0,2],[4,1],[0,2],[4,2],[0,2],[4,4],[0,3],[3,1],[0,1],[4,1],[3,1],[4,11],[0,1],[4,1],[1,1],[0,1],[4,1],[0,2],[4,11],[0,2],[4,1],[0,1],[4,2],[0,1],[4,1],[0,1],[4,2],[5,1],[0,3],[4,1],[0,2],[3,2],[4,1],[3,1],[4,7],[0,1],[4,2],[1,2],[4,8],[2,2],[4,1],[2,1],[4,1],[0,1],[4,1],[0,1],[4,1],[2,1],[4,8],[0,1],[28,1],[0,3],[4,1],[0,1],[4,6],[3,1],[4,3],[0,2],[4,2],[1,1],[4,4],[2,1],[0,1],[2,2],[4,1],[2,3],[4,2],[0,1],[2,2],[4,6],[0,1],[4,1],[0,2],[4,1],[0,1],[4,7],[3,1],[4,3],[3,1],[4,3],[0,1],[4,2],[1,1],[4,4],[0,1],[2,13],[0,1],[2,1],[4,1],[2,1],[4,2],[0,1],[4,1],[0,2],[4,14],[0,1],[5,1],[4,1],[0,1],[4,1],[1,2],[4,2],[2,19],[4,2],[0,1],[4,1],[0,1],[4,3],[3,1],[4,4],[3,2],[4,5],[0,3],[2,3],[1,1],[2,21],[4,1],[2,2],[0,1],[4,1],[0,1],[4,3],[3,1],[4,9],[29,1],[4,1],[0,1],[4,1],[2,1],[4,1],[2,1],[1,3],[2,18],[4,2],[2,1],[4,1],[0,1],[2,1],[4,3],[3,1],[4,9],[0,4],[2,1],[0,1],[4,1],[2,3],[1,4],[2,17],[4,2],[2,1],[0,1],[4,5],[3,1],[4,4],[3,1],[4,3],[0,2],[4,1],[0,2],[2,7],[1,1],[2,21],[4,4],[3,3],[4,3],[3,1],[4,4],[0,2],[2,1],[4,1],[2,7],[1,1],[2,20],[4,1],[2,1],[4,2],[3,1],[4,2],[28,1],[4,5],[3,1],[4,1],[0,2],[4,1],[0,1],[2,8],[1,1],[2,19],[4,1],[2,1],[4,9],[3,1],[4,4],[0,2],[2,2],[4,1],[2,7],[1,1],[2,19],[4,2],[2,1],[4,2],[3,1],[4,11],[2,1],[4,1],[2,9],[1,3],[2,18],[4,18],[2,1],[4,1],[2,9],[1,3],[2,17],[4,3],[0,2],[4,3],[0,2],[3,1],[4,1],[3,1],[4,5],[2,12],[1,2],[2,14],[4,2],[28,1],[4,1],[0,1],[4,1],[3,1],[4,3],[3,1],[4,1],[0,1],[4,5],[2,1],[4,1],[2,13],[1,1],[2,14],[4,5],[3,1],[4,1],[3,2],[4,1],[0,2],[4,2],[3,1],[4,5],[2,1],[4,1],[2,11],[1,1],[2,15],[4,5],[3,1],[4,3],[0,4],[4,5],[2,1],[4,1],[2,12],[1,2],[2,12],[4,11],[0,1],[4,1],[0,2],[4,7],[2,2],[29,1],[2,10],[1,6],[2,7],[4,9],[3,1],[4,3],[3,1],[4,2],[3,1],[4,5],[2,2],[4,1],[2,23],[4,6],[3,1],[4,3],[3,1],[4,1],[0,2],[4,8],[2,1],[4,1],[2,2],[0,1],[2,1],[0,1],[2,13],[4,1],[2,3],[4,8],[3,1],[4,2],[3,1],[4,2],[0,1],[3,2],[4,8],[2,1],[4,2],[0,1],[2,1],[4,1],[2,13],[4,2],[2,1],[4,1],[2,1],[0,1],[4,1],[0,1],[4,4],[3,2],[4,4],[0,2],[3,1],[4,8],[5,1],[4,2],[0,1],[4,1],[0,2],[2,8],[0,1],[2,1],[4,1],[2,3],[4,1],[2,1],[4,1],[5,1],[0,2],[4,11],[3,1],[0,1],[4,11],[0,1],[4,1],[0,5],[2,1],[0,2],[2,1],[0,2],[4,1],[2,1],[4,3],[29,1],[4,2],[0,1],[4,1],[0,1],[4,10],[3,1],[4,2],[3,1],[4,11],[0,5],[4,1],[0,2],[4,2],[0,2],[4,24],[3,1],[4,1],[0,1],[4,9],[0,2],[4,1],[0,5],[4,1],[0,2],[4,22],[3,2],[4,15],[0,5],[4,13],[0,1],[4,15],[0,1],[4,1],[0,1],[4,13],[0,2],[4,1],[0,1],[4,11],[0,2],[4,6],[3,1],[4,5],[3,1],[4,7],[28,1],[4,9],[0,1],[29,1],[4,1],[0,1],[4,9],[0,1],[4,1],[0,3],[4,13],[3,1],[4,3],[0,2],[4,11],[0,1],[29,1],[0,5],[4,6],[0,2],[4,1],[0,2],[4,17],[0,3],[4,11],[0,1],[4,1],[0,2],[5,1],[4,7],[29,1],[4,1],[0,3],[4,9],[3,1],[4,2],[3,3],[4,2],[0,2],[4,15],[0,3],[4,1],[0,2],[4,1],[0,1],[4,1],[0,3],[4,1],[0,1],[4,12],[3,1],[4,1],[3,1],[4,15],[0,2],[4,6],[0,1],[4,2],[0,4],[4,2],[0,1],[4,9],[3,1],[4,21],[0,1],[4,6],[0,1],[4,1],[0,1],[4,1],[0,1],[4,17],[3,1],[4,1],[3,1],[4,3],[28,1],[4,2],[0,1],[4,7],[0,2],[4,8],[0,1],[4,1],[0,2],[4,6],[29,1],[4,6],[3,1],[4,10],[0,2],[4,17],[0,1],[4,1],[0,2],[4,12],[3,1],[4,4]]} \ No newline at end of file diff --git a/v1/apps/game/assets/maps/rust_grove.map b/v1/apps/game/assets/maps/rust_grove.map new file mode 100644 index 0000000..411fdc3 --- /dev/null +++ b/v1/apps/game/assets/maps/rust_grove.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":48,"height":40,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"variants":{"0":[0,11,12],"1":[1,14],"3":[3,22,23],"4":[4,13],"7":[7,24,25,26,27],"28":[28,30,31],"29":[29,32,33]},"decor":[{"id":16,"x":10,"y":0,"dx":1,"dy":1},{"id":16,"x":3,"y":1,"dx":3,"dy":2},{"id":16,"x":26,"y":1,"dx":-3,"dy":1},{"id":16,"x":42,"y":1,"dx":-2,"dy":1},{"id":16,"x":46,"y":1,"dx":3,"dy":0},{"id":16,"x":14,"y":2,"dx":3,"dy":1},{"id":16,"x":23,"y":2,"dx":-1,"dy":-2},{"id":16,"x":16,"y":5,"dx":0,"dy":-1},{"id":16,"x":9,"y":6,"dx":2,"dy":1},{"id":16,"x":12,"y":6,"dx":1,"dy":0},{"id":16,"x":20,"y":6,"dx":-2,"dy":-1},{"id":16,"x":47,"y":6,"dx":-3,"dy":1},{"id":16,"x":2,"y":7,"dx":-4,"dy":1},{"id":16,"x":10,"y":7,"dx":3,"dy":2},{"id":16,"x":16,"y":7,"dx":2,"dy":-1},{"id":16,"x":17,"y":7,"dx":-2,"dy":2},{"id":16,"x":20,"y":7,"dx":-2,"dy":0},{"id":16,"x":37,"y":8,"dx":1,"dy":1},{"id":16,"x":0,"y":9,"dx":-1,"dy":-1},{"id":16,"x":15,"y":9,"dx":-2,"dy":2},{"id":16,"x":27,"y":9,"dx":2,"dy":1},{"id":15,"x":38,"y":9,"dx":-3,"dy":-2},{"id":16,"x":23,"y":10,"dx":3,"dy":-1},{"id":16,"x":2,"y":11,"dx":3,"dy":2},{"id":16,"x":21,"y":11,"dx":-2,"dy":0},{"id":16,"x":31,"y":12,"dx":1,"dy":-2},{"id":16,"x":10,"y":13,"dx":-3,"dy":-1},{"id":16,"x":29,"y":13,"dx":-3,"dy":0},{"id":16,"x":36,"y":13,"dx":-3,"dy":1},{"id":16,"x":44,"y":13,"dx":2,"dy":2},{"id":16,"x":45,"y":13,"dx":-1,"dy":-2},{"id":16,"x":13,"y":15,"dx":-2,"dy":1},{"id":16,"x":30,"y":15,"dx":3,"dy":2},{"id":16,"x":33,"y":15,"dx":-2,"dy":0},{"id":16,"x":22,"y":16,"dx":2,"dy":-2},{"id":16,"x":26,"y":16,"dx":-3,"dy":0},{"id":16,"x":30,"y":16,"dx":4,"dy":1},{"id":16,"x":32,"y":16,"dx":3,"dy":0},{"id":16,"x":37,"y":16,"dx":0,"dy":-1},{"id":16,"x":12,"y":17,"dx":-4,"dy":0},{"id":16,"x":27,"y":17,"dx":-2,"dy":-1},{"id":16,"x":19,"y":18,"dx":-3,"dy":-2},{"id":16,"x":29,"y":18,"dx":3,"dy":-1},{"id":16,"x":19,"y":19,"dx":2,"dy":-1},{"id":16,"x":43,"y":19,"dx":2,"dy":-2},{"id":16,"x":44,"y":19,"dx":-2,"dy":1},{"id":16,"x":27,"y":20,"dx":0,"dy":-2},{"id":16,"x":20,"y":21,"dx":2,"dy":0},{"id":16,"x":21,"y":21,"dx":-2,"dy":-2},{"id":16,"x":24,"y":21,"dx":1,"dy":0},{"id":16,"x":1,"y":22,"dx":3,"dy":1},{"id":16,"x":16,"y":22,"dx":1,"dy":0},{"id":16,"x":19,"y":22,"dx":1,"dy":-2},{"id":16,"x":46,"y":22,"dx":0,"dy":1},{"id":16,"x":47,"y":22,"dx":0,"dy":0},{"id":16,"x":2,"y":23,"dx":-3,"dy":0},{"id":16,"x":20,"y":23,"dx":4,"dy":0},{"id":16,"x":33,"y":23,"dx":2,"dy":1},{"id":16,"x":41,"y":23,"dx":3,"dy":-1},{"id":16,"x":47,"y":23,"dx":-2,"dy":-2},{"id":15,"x":0,"y":24,"dx":-3,"dy":-1},{"id":16,"x":16,"y":24,"dx":1,"dy":2},{"id":16,"x":27,"y":24,"dx":0,"dy":0},{"id":16,"x":6,"y":25,"dx":-3,"dy":0},{"id":16,"x":38,"y":25,"dx":-2,"dy":-1},{"id":16,"x":2,"y":26,"dx":2,"dy":-1},{"id":16,"x":13,"y":26,"dx":1,"dy":1},{"id":16,"x":8,"y":28,"dx":0,"dy":2},{"id":16,"x":31,"y":28,"dx":-1,"dy":-1},{"id":16,"x":7,"y":30,"dx":2,"dy":0},{"id":16,"x":32,"y":30,"dx":2,"dy":-1},{"id":16,"x":43,"y":30,"dx":1,"dy":1},{"id":16,"x":23,"y":31,"dx":-2,"dy":2},{"id":16,"x":24,"y":31,"dx":0,"dy":1},{"id":16,"x":25,"y":31,"dx":1,"dy":1},{"id":16,"x":17,"y":32,"dx":-3,"dy":0},{"id":16,"x":21,"y":32,"dx":4,"dy":0},{"id":16,"x":14,"y":33,"dx":2,"dy":2},{"id":16,"x":16,"y":33,"dx":2,"dy":1},{"id":16,"x":22,"y":33,"dx":2,"dy":0},{"id":16,"x":14,"y":34,"dx":-1,"dy":1},{"id":16,"x":22,"y":34,"dx":2,"dy":1},{"id":16,"x":12,"y":35,"dx":-2,"dy":1},{"id":16,"x":21,"y":35,"dx":1,"dy":1},{"id":16,"x":11,"y":36,"dx":4,"dy":-1},{"id":16,"x":17,"y":37,"dx":-2,"dy":-2},{"id":16,"x":23,"y":37,"dx":-2,"dy":2},{"id":16,"x":24,"y":37,"dx":-3,"dy":-1},{"id":16,"x":34,"y":37,"dx":0,"dy":-1},{"id":16,"x":0,"y":38,"dx":1,"dy":2},{"id":16,"x":27,"y":38,"dx":1,"dy":0},{"id":16,"x":29,"y":38,"dx":-2,"dy":1},{"id":16,"x":34,"y":38,"dx":-2,"dy":-1}],"encoding":"rle","tiles":[[3,8],[4,3],[3,5],[4,1],[3,3],[4,1],[3,1],[4,1],[3,2],[4,1],[3,2],[4,1],[3,1],[4,1],[3,7],[4,1],[3,4],[4,1],[3,3],[4,1],[3,3],[4,2],[3,5],[4,2],[3,1],[4,1],[3,2],[4,1],[3,6],[4,1],[3,2],[4,1],[3,1],[4,1],[3,5],[4,1],[3,7],[4,1],[3,3],[4,1],[3,7],[29,1],[3,2],[28,1],[3,1],[4,1],[3,2],[4,1],[3,1],[0,1],[3,4],[4,3],[3,10],[4,1],[3,6],[4,2],[3,11],[4,1],[3,1],[4,1],[3,2],[4,1],[3,4],[4,3],[3,1],[4,1],[3,4],[4,2],[3,1],[4,1],[3,1],[4,3],[3,2],[4,1],[3,4],[4,2],[3,15],[4,1],[3,4],[29,1],[3,4],[4,4],[3,4],[4,6],[3,3],[4,1],[3,3],[4,1],[3,3],[4,1],[3,6],[4,2],[3,1],[4,3],[3,5],[4,3],[3,2],[4,1],[3,3],[4,1],[3,6],[4,4],[3,1],[4,1],[3,1],[4,1],[3,6],[4,2],[3,6],[4,1],[3,3],[4,2],[3,2],[4,4],[3,4],[4,1],[3,2],[4,1],[3,8],[18,1],[4,2],[3,1],[0,1],[4,2],[3,3],[4,1],[3,4],[4,1],[3,2],[4,1],[3,2],[4,1],[3,4],[4,1],[3,2],[4,1],[3,2],[4,5],[3,1],[4,1],[3,3],[0,1],[3,3],[4,1],[3,1],[4,1],[3,1],[4,3],[3,3],[4,2],[3,1],[4,2],[3,1],[4,1],[3,5],[4,2],[3,1],[4,1],[3,1],[4,1],[3,2],[18,1],[4,1],[3,1],[4,1],[3,1],[4,1],[3,1],[4,2],[3,3],[4,1],[3,3],[4,3],[3,1],[4,2],[3,3],[4,2],[3,8],[4,4],[3,7],[4,1],[18,1],[3,3],[4,6],[3,1],[4,1],[3,1],[4,1],[3,2],[4,1],[3,2],[4,1],[3,3],[4,3],[3,1],[0,1],[3,3],[4,1],[3,6],[4,2],[3,3],[4,1],[3,2],[4,1],[3,1],[4,1],[18,1],[3,1],[0,1],[4,2],[3,2],[4,5],[18,1],[4,1],[3,1],[4,1],[3,2],[4,3],[3,1],[4,2],[3,1],[4,1],[3,2],[4,1],[3,1],[4,2],[3,6],[4,1],[3,4],[4,1],[3,2],[4,2],[3,2],[4,1],[3,3],[4,1],[3,1],[4,3],[3,1],[18,1],[3,1],[4,3],[3,1],[4,1],[3,1],[4,3],[3,4],[0,1],[3,4],[4,1],[3,1],[4,1],[3,1],[4,2],[0,1],[4,3],[3,1],[4,1],[3,1],[4,1],[3,1],[4,3],[3,1],[4,1],[3,3],[4,1],[29,1],[3,5],[4,2],[3,1],[4,1],[3,1],[4,5],[3,2],[4,1],[3,1],[4,1],[3,2],[4,4],[3,1],[4,2],[3,1],[4,1],[3,5],[4,5],[3,2],[4,2],[3,3],[4,3],[3,2],[4,4],[3,1],[4,1],[3,1],[4,4],[3,1],[4,1],[3,4],[4,4],[3,1],[4,1],[3,2],[4,3],[3,2],[4,7],[3,3],[4,4],[3,2],[4,4],[3,2],[4,1],[3,1],[4,5],[3,3],[4,6],[3,5],[4,1],[3,3],[4,3],[3,2],[4,2],[3,3],[4,1],[3,1],[4,15],[3,2],[4,2],[3,2],[4,1],[3,1],[4,2],[3,1],[4,2],[3,2],[4,2],[3,1],[4,5],[3,2],[4,1],[3,1],[4,3],[3,2],[4,11],[3,1],[4,5],[3,2],[4,1],[29,1],[3,1],[4,1],[3,2],[4,2],[3,1],[4,1],[3,6],[4,2],[3,1],[4,2],[3,2],[4,2],[3,1],[4,17],[3,5],[4,1],[3,1],[4,3],[3,4],[4,1],[3,2],[4,2],[3,2],[4,2],[3,1],[4,1],[3,1],[4,3],[3,1],[4,2],[3,2],[4,13],[3,1],[4,1],[3,1],[4,1],[3,1],[4,1],[3,2],[4,1],[3,2],[4,2],[3,1],[4,4],[3,2],[4,1],[3,3],[4,1],[3,1],[4,1],[3,1],[4,2],[3,1],[4,3],[3,1],[4,13],[3,1],[4,1],[3,1],[4,1],[28,1],[3,1],[4,1],[3,1],[4,1],[3,2],[29,1],[4,3],[3,3],[1,17],[4,13],[1,16],[4,3],[3,9],[4,1],[3,1],[4,1],[3,1],[4,2],[3,1],[4,13],[3,2],[4,1],[3,2],[4,3],[3,2],[4,1],[3,5],[4,3],[3,1],[4,1],[3,3],[28,1],[4,1],[3,3],[4,1],[3,2],[4,17],[3,2],[4,1],[3,1],[4,1],[3,3],[4,2],[3,4],[4,3],[3,1],[4,2],[3,4],[4,1],[3,1],[4,1],[3,1],[4,1],[3,1],[4,3],[3,1],[4,14],[3,1],[4,1],[3,1],[4,2],[3,1],[4,2],[3,1],[4,1],[3,3],[4,1],[3,1],[4,1],[0,1],[3,1],[4,1],[3,1],[4,1],[3,3],[4,1],[3,4],[4,1],[3,2],[4,1],[3,1],[4,1],[3,1],[4,9],[3,1],[4,3],[3,2],[4,1],[3,1],[4,1],[29,1],[4,3],[3,3],[4,1],[3,3],[4,1],[3,3],[4,2],[3,1],[4,1],[3,1],[4,2],[3,1],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[4,4],[3,1],[4,1],[3,1],[4,2],[3,1],[4,1],[3,5],[4,1],[3,1],[4,4],[3,1],[4,1],[3,2],[4,1],[3,3],[4,1],[3,4],[4,3],[3,1],[4,3],[3,1],[4,4],[3,3],[4,3],[3,3],[4,3],[3,2],[4,3],[3,1],[4,1],[3,3],[4,2],[3,1],[4,2],[3,3],[4,1],[3,8],[0,1],[3,4],[4,1],[3,2],[4,3],[3,1],[4,3],[3,1],[4,2],[3,1],[4,1],[3,5],[4,4],[3,4],[4,3],[3,5],[4,1],[3,1],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[4,5],[3,1],[4,2],[3,2],[4,6],[0,1],[3,1],[4,2],[3,1],[4,1],[18,1],[3,1],[18,1],[3,2],[4,2],[3,2],[4,2],[3,11],[4,2],[3,4],[4,1],[3,1],[4,1],[3,4],[4,3],[3,2],[4,2],[3,2],[4,3],[3,3],[4,1],[3,2],[4,1],[3,3],[4,1],[3,4],[4,2],[3,2],[4,1],[3,1],[4,2],[3,6],[4,2],[3,2],[18,1],[3,1],[18,1],[3,1],[4,1],[3,1],[4,3],[3,2],[4,4],[3,1],[4,2],[3,2],[4,1],[3,4],[4,2],[3,3],[4,1],[3,5],[4,1],[3,1],[4,1],[3,2],[4,1],[3,1],[4,2],[3,1],[4,1],[3,1],[4,1],[3,2],[4,1],[3,1],[4,3],[3,1],[4,2],[3,1],[4,3],[3,2],[4,1],[3,3],[4,1],[3,3],[4,1],[3,1],[4,1],[3,2],[4,1],[3,1],[4,3],[3,6],[4,1],[3,3],[4,1],[3,1],[4,1],[3,2],[4,2],[3,1],[4,1],[3,1],[4,2],[3,1],[4,6],[3,1],[4,2],[3,1],[4,1],[3,1],[4,4],[3,4],[4,1],[3,6],[28,1],[3,2],[4,1],[3,1],[4,1],[3,1],[4,4],[3,4],[4,3],[3,1],[4,1],[3,1],[4,2],[3,10],[4,1],[3,11],[4,1],[3,1],[4,1],[3,1],[4,2],[3,2],[4,1],[3,1],[4,4],[3,2],[4,4],[3,1],[4,2],[3,3],[4,1],[3,1],[4,4],[3,1],[4,2],[3,1],[4,1],[3,1],[28,1],[4,2],[3,4],[4,1],[3,7],[4,1],[3,2],[4,1],[3,2],[4,1],[3,1],[4,1],[3,1],[4,1],[3,1],[4,1],[3,2],[4,2],[3,2],[4,1],[3,1],[29,1],[3,1],[4,1],[3,1],[4,2],[3,2],[4,1],[3,3],[4,1],[3,4],[4,2],[3,7],[4,2],[3,1],[4,1],[3,1],[4,4],[3,2],[4,2],[3,1],[4,2],[3,3],[4,1],[3,5],[4,3],[3,2],[4,1],[3,5],[4,1],[3,4],[4,1],[3,4],[4,2],[3,1],[4,2],[3,1],[4,2],[3,3],[4,2],[3,1],[29,1],[3,2],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[4,1],[3,1],[4,1],[3,1],[4,2],[3,4],[4,1],[3,7],[4,1],[3,7],[4,3],[3,4],[4,1],[3,2],[4,1],[3,5],[4,6],[3,1],[4,4],[3,2],[4,2],[3,3],[4,1],[3,8],[4,1],[3,6],[4,4],[3,3],[4,2],[3,2],[4,1],[3,1],[4,2],[3,2],[4,1],[3,1],[4,2],[3,4],[4,2],[3,3],[4,1],[3,1],[4,1],[3,5]]} \ No newline at end of file diff --git a/v1/apps/game/assets/maps/shop.map b/v1/apps/game/assets/maps/shop.map new file mode 100644 index 0000000..dcb6e9b --- /dev/null +++ b/v1/apps/game/assets/maps/shop.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":11,"height":9,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"encoding":"rle","tiles":[[9,12],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,2],[8,9],[9,6],[8,1],[9,5]]} \ No newline at end of file diff --git a/v1/apps/game/assets/maps/zvenets.map b/v1/apps/game/assets/maps/zvenets.map new file mode 100644 index 0000000..6e37910 --- /dev/null +++ b/v1/apps/game/assets/maps/zvenets.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":56,"height":40,"blocked":[2,3,6,7,24,25,26,27,9,10,18,21,28,29,30,31,32,33],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4,"shadow":true},"9":{"height":1,"ground":8},"18":{"height":0.6,"ground":4},"21":{"height":2.5,"ground":8},"22":{"height":1.5,"ground":0},"23":{"height":1.5,"ground":0},"24":{"height":1.75,"ground":4,"shadow":true},"25":{"height":1.75,"ground":4,"shadow":true},"26":{"height":2.5,"ground":4,"shadow":true},"27":{"height":2.5,"ground":4,"shadow":true},"28":{"height":1.05,"ground":4},"29":{"height":1.25,"ground":4},"30":{"height":1.25,"ground":4},"31":{"height":0.75,"ground":4},"32":{"height":1,"ground":4},"33":{"height":1.45,"ground":4}},"props":[{"id":34,"x":46,"y":24,"w":2,"h":2,"ground":4}],"variants":{"0":[0,11,12],"1":[1,14],"3":[3,22,23],"4":[4,13],"7":[7,24,25,26,27],"28":[28,30,31],"29":[29,32,33]},"decor":[{"id":16,"x":14,"y":0,"dx":1,"dy":1},{"id":16,"x":23,"y":0,"dx":0,"dy":1},{"id":16,"x":26,"y":0,"dx":3,"dy":1},{"id":16,"x":31,"y":0,"dx":0,"dy":0},{"id":16,"x":37,"y":0,"dx":3,"dy":0},{"id":16,"x":39,"y":0,"dx":3,"dy":2},{"id":16,"x":49,"y":0,"dx":-1,"dy":1},{"id":16,"x":3,"y":1,"dx":1,"dy":0},{"id":16,"x":5,"y":1,"dx":4,"dy":1},{"id":15,"x":13,"y":1,"dx":-1,"dy":-2},{"id":16,"x":39,"y":1,"dx":4,"dy":1},{"id":17,"x":45,"y":1,"dx":0,"dy":0},{"id":16,"x":55,"y":2,"dx":3,"dy":-1},{"id":16,"x":21,"y":3,"dx":1,"dy":-1},{"id":16,"x":27,"y":3,"dx":-2,"dy":-2},{"id":16,"x":34,"y":3,"dx":1,"dy":-2},{"id":16,"x":36,"y":3,"dx":0,"dy":0},{"id":16,"x":3,"y":4,"dx":1,"dy":-1},{"id":16,"x":4,"y":4,"dx":3,"dy":2},{"id":16,"x":11,"y":4,"dx":2,"dy":0},{"id":16,"x":13,"y":4,"dx":2,"dy":1},{"id":16,"x":18,"y":4,"dx":1,"dy":0},{"id":16,"x":51,"y":4,"dx":1,"dy":-1},{"id":16,"x":5,"y":5,"dx":0,"dy":2},{"id":16,"x":19,"y":5,"dx":0,"dy":1},{"id":16,"x":31,"y":5,"dx":-3,"dy":-1},{"id":15,"x":5,"y":6,"dx":4,"dy":0},{"id":16,"x":13,"y":6,"dx":2,"dy":2},{"id":16,"x":18,"y":6,"dx":-3,"dy":-1},{"id":16,"x":24,"y":6,"dx":-2,"dy":2},{"id":16,"x":41,"y":6,"dx":1,"dy":0},{"id":16,"x":48,"y":6,"dx":-2,"dy":1},{"id":15,"x":6,"y":7,"dx":-1,"dy":-1},{"id":16,"x":13,"y":7,"dx":3,"dy":1},{"id":16,"x":20,"y":7,"dx":-4,"dy":2},{"id":16,"x":26,"y":8,"dx":3,"dy":-2},{"id":16,"x":47,"y":8,"dx":3,"dy":-2},{"id":15,"x":9,"y":9,"dx":0,"dy":2},{"id":16,"x":17,"y":10,"dx":3,"dy":2},{"id":16,"x":20,"y":10,"dx":-3,"dy":-2},{"id":16,"x":38,"y":10,"dx":-3,"dy":1},{"id":16,"x":45,"y":10,"dx":0,"dy":-2},{"id":16,"x":12,"y":11,"dx":2,"dy":-1},{"id":16,"x":17,"y":11,"dx":-3,"dy":1},{"id":16,"x":46,"y":11,"dx":0,"dy":1},{"id":15,"x":51,"y":11,"dx":2,"dy":0},{"id":16,"x":8,"y":12,"dx":1,"dy":-1},{"id":16,"x":14,"y":12,"dx":-4,"dy":1},{"id":16,"x":41,"y":12,"dx":3,"dy":2},{"id":16,"x":45,"y":12,"dx":3,"dy":0},{"id":16,"x":0,"y":13,"dx":1,"dy":-2},{"id":16,"x":4,"y":13,"dx":-3,"dy":1},{"id":16,"x":8,"y":13,"dx":3,"dy":-1},{"id":16,"x":42,"y":13,"dx":3,"dy":2},{"id":16,"x":44,"y":13,"dx":0,"dy":2},{"id":16,"x":50,"y":13,"dx":-1,"dy":2},{"id":16,"x":54,"y":13,"dx":-1,"dy":2},{"id":16,"x":49,"y":14,"dx":-3,"dy":2},{"id":16,"x":55,"y":14,"dx":-2,"dy":-2},{"id":16,"x":6,"y":15,"dx":0,"dy":1},{"id":16,"x":8,"y":15,"dx":0,"dy":-1},{"id":16,"x":10,"y":15,"dx":-4,"dy":1},{"id":16,"x":30,"y":15,"dx":-1,"dy":-1},{"id":16,"x":47,"y":15,"dx":3,"dy":-1},{"id":16,"x":51,"y":15,"dx":-1,"dy":-1},{"id":16,"x":6,"y":16,"dx":-1,"dy":2},{"id":16,"x":8,"y":16,"dx":3,"dy":2},{"id":16,"x":11,"y":16,"dx":1,"dy":1},{"id":16,"x":38,"y":16,"dx":2,"dy":1},{"id":16,"x":44,"y":16,"dx":0,"dy":1},{"id":16,"x":47,"y":16,"dx":1,"dy":0},{"id":16,"x":3,"y":17,"dx":2,"dy":1},{"id":16,"x":11,"y":17,"dx":3,"dy":-2},{"id":16,"x":1,"y":18,"dx":2,"dy":-2},{"id":16,"x":14,"y":18,"dx":-2,"dy":-1},{"id":16,"x":48,"y":18,"dx":0,"dy":-1},{"id":16,"x":4,"y":19,"dx":-1,"dy":0},{"id":16,"x":6,"y":19,"dx":3,"dy":-1},{"id":17,"x":9,"y":19,"dx":3,"dy":0},{"id":16,"x":14,"y":19,"dx":-1,"dy":2},{"id":16,"x":54,"y":19,"dx":-1,"dy":2},{"id":16,"x":1,"y":20,"dx":-4,"dy":2},{"id":16,"x":29,"y":20,"dx":1,"dy":1},{"id":16,"x":40,"y":20,"dx":-1,"dy":0},{"id":16,"x":4,"y":21,"dx":-1,"dy":1},{"id":16,"x":8,"y":21,"dx":-1,"dy":-2},{"id":17,"x":16,"y":21,"dx":-4,"dy":1},{"id":16,"x":17,"y":21,"dx":4,"dy":-2},{"id":16,"x":13,"y":22,"dx":2,"dy":-1},{"id":16,"x":52,"y":22,"dx":0,"dy":-2},{"id":16,"x":5,"y":23,"dx":0,"dy":-1},{"id":16,"x":46,"y":23,"dx":1,"dy":-1},{"id":16,"x":55,"y":23,"dx":-3,"dy":1},{"id":16,"x":5,"y":24,"dx":-2,"dy":-1},{"id":16,"x":17,"y":24,"dx":-1,"dy":1},{"id":16,"x":49,"y":24,"dx":4,"dy":-1},{"id":15,"x":3,"y":25,"dx":-2,"dy":-1},{"id":15,"x":10,"y":25,"dx":2,"dy":-2},{"id":16,"x":17,"y":25,"dx":-3,"dy":-2},{"id":15,"x":34,"y":25,"dx":-1,"dy":-1},{"id":16,"x":41,"y":25,"dx":-3,"dy":-1},{"id":16,"x":42,"y":25,"dx":-2,"dy":0},{"id":16,"x":3,"y":26,"dx":2,"dy":0},{"id":17,"x":7,"y":26,"dx":-3,"dy":-1},{"id":16,"x":13,"y":26,"dx":-2,"dy":0},{"id":16,"x":45,"y":26,"dx":-4,"dy":0},{"id":16,"x":51,"y":26,"dx":-2,"dy":0},{"id":16,"x":13,"y":27,"dx":-1,"dy":-1},{"id":16,"x":16,"y":27,"dx":-3,"dy":1},{"id":16,"x":46,"y":27,"dx":3,"dy":-2},{"id":16,"x":53,"y":27,"dx":3,"dy":2},{"id":16,"x":12,"y":28,"dx":2,"dy":1},{"id":17,"x":19,"y":28,"dx":-2,"dy":-2},{"id":16,"x":40,"y":28,"dx":0,"dy":-2},{"id":16,"x":41,"y":28,"dx":2,"dy":1},{"id":15,"x":2,"y":29,"dx":0,"dy":-2},{"id":16,"x":6,"y":29,"dx":-4,"dy":-1},{"id":15,"x":20,"y":29,"dx":-2,"dy":-1},{"id":16,"x":46,"y":29,"dx":0,"dy":1},{"id":16,"x":52,"y":29,"dx":-1,"dy":1},{"id":16,"x":53,"y":29,"dx":-4,"dy":0},{"id":16,"x":54,"y":29,"dx":-3,"dy":1},{"id":16,"x":0,"y":30,"dx":3,"dy":-2},{"id":16,"x":1,"y":30,"dx":2,"dy":-2},{"id":16,"x":25,"y":30,"dx":1,"dy":0},{"id":16,"x":34,"y":30,"dx":-2,"dy":1},{"id":16,"x":36,"y":30,"dx":-2,"dy":1},{"id":16,"x":37,"y":30,"dx":0,"dy":-2},{"id":16,"x":41,"y":30,"dx":4,"dy":-1},{"id":16,"x":48,"y":30,"dx":2,"dy":1},{"id":15,"x":20,"y":31,"dx":2,"dy":1},{"id":16,"x":29,"y":31,"dx":0,"dy":-1},{"id":16,"x":36,"y":31,"dx":-1,"dy":-2},{"id":16,"x":48,"y":31,"dx":1,"dy":1},{"id":16,"x":49,"y":31,"dx":3,"dy":-1},{"id":16,"x":32,"y":32,"dx":0,"dy":0},{"id":16,"x":37,"y":32,"dx":-2,"dy":2},{"id":16,"x":41,"y":32,"dx":-1,"dy":-2},{"id":16,"x":52,"y":32,"dx":-1,"dy":-2},{"id":16,"x":54,"y":32,"dx":3,"dy":1},{"id":16,"x":7,"y":33,"dx":1,"dy":1},{"id":16,"x":10,"y":33,"dx":-1,"dy":0},{"id":16,"x":48,"y":33,"dx":-2,"dy":1},{"id":15,"x":0,"y":34,"dx":4,"dy":-1},{"id":16,"x":12,"y":34,"dx":0,"dy":-1},{"id":16,"x":33,"y":34,"dx":2,"dy":-2},{"id":16,"x":38,"y":34,"dx":3,"dy":1},{"id":16,"x":53,"y":34,"dx":0,"dy":-1},{"id":15,"x":20,"y":35,"dx":-1,"dy":1},{"id":16,"x":35,"y":35,"dx":2,"dy":-2},{"id":16,"x":39,"y":35,"dx":-2,"dy":1},{"id":16,"x":47,"y":35,"dx":-3,"dy":-1},{"id":16,"x":55,"y":35,"dx":-2,"dy":-2},{"id":16,"x":11,"y":36,"dx":1,"dy":-1},{"id":16,"x":14,"y":36,"dx":-2,"dy":0},{"id":16,"x":26,"y":36,"dx":0,"dy":2},{"id":16,"x":39,"y":36,"dx":0,"dy":0},{"id":16,"x":42,"y":36,"dx":0,"dy":-2},{"id":16,"x":45,"y":36,"dx":2,"dy":-1},{"id":16,"x":4,"y":37,"dx":-3,"dy":1},{"id":16,"x":10,"y":37,"dx":3,"dy":1},{"id":16,"x":15,"y":37,"dx":-3,"dy":1},{"id":16,"x":34,"y":37,"dx":0,"dy":-2},{"id":16,"x":47,"y":37,"dx":1,"dy":1},{"id":16,"x":49,"y":37,"dx":-2,"dy":1},{"id":16,"x":53,"y":37,"dx":3,"dy":-1},{"id":16,"x":55,"y":37,"dx":-1,"dy":0},{"id":16,"x":6,"y":38,"dx":-3,"dy":2},{"id":16,"x":7,"y":38,"dx":0,"dy":2},{"id":16,"x":38,"y":38,"dx":-3,"dy":-2},{"id":16,"x":41,"y":38,"dx":-1,"dy":-1},{"id":16,"x":45,"y":38,"dx":2,"dy":0},{"id":16,"x":7,"y":39,"dx":2,"dy":1},{"id":16,"x":16,"y":39,"dx":2,"dy":0},{"id":16,"x":19,"y":39,"dx":-3,"dy":0},{"id":16,"x":22,"y":39,"dx":0,"dy":0},{"id":16,"x":26,"y":39,"dx":2,"dy":-1},{"id":16,"x":38,"y":39,"dx":-4,"dy":-1},{"id":16,"x":43,"y":39,"dx":-2,"dy":-2},{"id":16,"x":44,"y":39,"dx":-3,"dy":-2},{"id":16,"x":46,"y":39,"dx":1,"dy":0},{"id":16,"x":52,"y":39,"dx":-1,"dy":1}],"encoding":"rle","tiles":[[4,10],[0,4],[4,5],[0,1],[4,12],[0,2],[4,11],[0,3],[4,17],[0,1],[4,3],[0,1],[4,4],[0,2],[4,10],[0,4],[4,11],[0,3],[4,4],[3,2],[4,7],[3,1],[4,3],[0,1],[4,8],[0,3],[4,10],[0,1],[4,10],[0,2],[4,1],[0,3],[4,18],[0,4],[4,16],[0,1],[4,12],[0,2],[4,11],[3,1],[4,41],[0,4],[4,12],[3,1],[0,1],[4,2],[0,5],[4,33],[0,2],[4,9],[3,1],[0,1],[4,1],[0,7],[4,34],[0,2],[4,1],[0,1],[4,7],[3,1],[4,1],[0,1],[4,4],[3,1],[0,3],[4,1],[0,1],[4,8],[29,1],[4,34],[3,1],[4,1],[0,2],[4,3],[0,5],[4,39],[0,1],[4,2],[0,2],[4,2],[0,1],[4,1],[3,1],[4,1],[3,1],[0,6],[4,39],[0,4],[4,6],[3,1],[0,1],[4,1],[0,2],[4,3],[0,1],[4,19],[0,1],[4,4],[27,1],[4,11],[0,4],[3,1],[4,8],[28,1],[4,3],[0,2],[4,19],[0,3],[4,15],[0,5],[4,6],[3,1],[4,5],[0,1],[4,1],[0,1],[7,1],[4,1],[0,3],[1,7],[4,1],[1,13],[4,10],[0,5],[3,1],[4,16],[0,1],[10,1],[0,1],[1,11],[4,1],[1,5],[4,1],[1,4],[4,9],[0,1],[4,1],[0,1],[4,1],[0,1],[4,6],[3,2],[4,9],[0,2],[4,1],[1,1],[0,1],[1,8],[6,1],[1,11],[4,10],[0,1],[4,1],[0,1],[4,17],[0,3],[4,1],[1,5],[4,1],[1,6],[4,1],[1,8],[4,1],[0,2],[4,28],[0,2],[4,1],[0,1],[1,3],[4,1],[1,16],[4,1],[1,1],[4,1],[0,3],[4,8],[3,1],[4,1],[3,1],[4,16],[0,3],[1,3],[4,1],[1,18],[0,1],[4,1],[0,1],[4,8],[3,2],[4,5],[3,1],[4,5],[0,4],[4,3],[0,2],[4,1],[1,22],[4,1],[0,3],[4,15],[3,1],[4,1],[3,1],[4,2],[0,2],[4,8],[1,5],[4,1],[1,16],[0,3],[26,1],[4,14],[1,21],[4,1],[1,4],[0,1],[4,1],[1,10],[4,11],[3,1],[4,1],[1,2],[4,10],[0,2],[4,5],[0,1],[4,1],[1,22],[4,3],[29,1],[4,12],[0,2],[4,2],[3,1],[4,3],[0,1],[4,2],[0,1],[4,6],[0,1],[1,12],[4,1],[1,2],[4,2],[1,4],[4,11],[3,1],[4,4],[0,2],[4,4],[0,1],[4,2],[0,1],[4,8],[1,12],[4,1],[1,1],[4,1],[1,7],[4,8],[0,3],[4,1],[0,3],[4,1],[0,2],[3,1],[4,4],[0,2],[4,9],[1,8],[4,1],[1,9],[4,1],[1,3],[4,10],[0,1],[3,1],[4,1],[3,1],[0,2],[4,1],[0,1],[4,1],[0,2],[3,1],[0,1],[4,3],[0,1],[4,7],[1,4],[4,1],[1,9],[0,1],[1,1],[0,1],[1,5],[4,8],[0,1],[4,1],[0,1],[3,2],[4,1],[0,1],[3,1],[4,1],[0,1],[3,1],[4,1],[3,1],[4,1],[0,2],[4,1],[0,2],[4,7],[1,16],[4,1],[1,4],[4,9],[0,2],[4,2],[0,2],[4,2],[0,1],[4,1],[0,2],[4,2],[0,1],[4,1],[0,2],[4,8],[0,1],[1,21],[4,9],[0,2],[4,1],[0,1],[4,1],[0,1],[3,1],[0,1],[4,1],[3,1],[0,2],[4,14],[0,4],[24,1],[4,1],[0,1],[4,5],[0,2],[25,1],[4,18],[3,1],[4,3],[0,4],[4,13],[0,1],[4,1],[0,5],[4,7],[0,3],[4,26],[3,1],[4,12],[0,7],[4,7],[0,1],[4,20],[3,1],[4,2],[3,1],[4,2],[3,1],[4,15],[0,1],[4,1],[0,4],[4,2],[0,1],[4,34],[3,1],[4,11],[0,10],[4,24],[3,1],[4,8],[3,1],[4,12],[0,2],[4,2],[0,2],[4,1],[0,1],[4,1],[0,1],[4,11],[29,1],[4,16],[3,1],[0,2],[4,7],[29,1],[4,11],[0,2],[4,1],[0,1],[4,1],[0,1],[4,27],[3,1],[4,1],[0,3],[4,2],[3,1],[4,14],[0,2],[4,1],[0,4],[4,29],[0,3],[4,19],[0,1],[4,34],[3,1],[4,50],[0,1],[4,6],[3,1],[4,9],[28,1],[4,19],[0,2],[4,15],[0,1],[4,1],[0,1],[3,2],[4,4],[3,1],[4,28],[0,3],[4,21]]} \ No newline at end of file diff --git a/v1/apps/game/assets/tiles/ash.png b/v1/apps/game/assets/tiles/ash.png new file mode 100644 index 0000000..64299da --- /dev/null +++ b/v1/apps/game/assets/tiles/ash.png Binary files differ diff --git a/v1/apps/game/assets/tiles/ash_2.png b/v1/apps/game/assets/tiles/ash_2.png new file mode 100644 index 0000000..9108d44 --- /dev/null +++ b/v1/apps/game/assets/tiles/ash_2.png Binary files differ diff --git a/v1/apps/game/assets/tiles/bell_rope.png b/v1/apps/game/assets/tiles/bell_rope.png new file mode 100644 index 0000000..3583885 --- /dev/null +++ b/v1/apps/game/assets/tiles/bell_rope.png Binary files differ diff --git a/v1/apps/game/assets/tiles/bellflower.png b/v1/apps/game/assets/tiles/bellflower.png new file mode 100644 index 0000000..212022c --- /dev/null +++ b/v1/apps/game/assets/tiles/bellflower.png Binary files differ diff --git a/v1/apps/game/assets/tiles/boulder.png b/v1/apps/game/assets/tiles/boulder.png new file mode 100644 index 0000000..21b65b1 --- /dev/null +++ b/v1/apps/game/assets/tiles/boulder.png Binary files differ diff --git a/v1/apps/game/assets/tiles/boulder_2.png b/v1/apps/game/assets/tiles/boulder_2.png new file mode 100644 index 0000000..3625344 --- /dev/null +++ b/v1/apps/game/assets/tiles/boulder_2.png Binary files differ diff --git a/v1/apps/game/assets/tiles/boulder_3.png b/v1/apps/game/assets/tiles/boulder_3.png new file mode 100644 index 0000000..8219d43 --- /dev/null +++ b/v1/apps/game/assets/tiles/boulder_3.png Binary files differ diff --git a/v1/apps/game/assets/tiles/bush.png b/v1/apps/game/assets/tiles/bush.png new file mode 100644 index 0000000..3c7eba4 --- /dev/null +++ b/v1/apps/game/assets/tiles/bush.png Binary files differ diff --git a/v1/apps/game/assets/tiles/bush_2.png b/v1/apps/game/assets/tiles/bush_2.png new file mode 100644 index 0000000..0c6b220 --- /dev/null +++ b/v1/apps/game/assets/tiles/bush_2.png Binary files differ diff --git a/v1/apps/game/assets/tiles/bush_3.png b/v1/apps/game/assets/tiles/bush_3.png new file mode 100644 index 0000000..69a3695 --- /dev/null +++ b/v1/apps/game/assets/tiles/bush_3.png Binary files differ diff --git a/v1/apps/game/assets/tiles/chest.png b/v1/apps/game/assets/tiles/chest.png new file mode 100644 index 0000000..163b9f9 --- /dev/null +++ b/v1/apps/game/assets/tiles/chest.png Binary files differ diff --git a/v1/apps/game/assets/tiles/counter.png b/v1/apps/game/assets/tiles/counter.png new file mode 100644 index 0000000..6d85c49 --- /dev/null +++ b/v1/apps/game/assets/tiles/counter.png Binary files differ diff --git a/v1/apps/game/assets/tiles/decor_bald.png b/v1/apps/game/assets/tiles/decor_bald.png new file mode 100644 index 0000000..7fbb154 --- /dev/null +++ b/v1/apps/game/assets/tiles/decor_bald.png Binary files differ diff --git a/v1/apps/game/assets/tiles/decor_stone.png b/v1/apps/game/assets/tiles/decor_stone.png new file mode 100644 index 0000000..20fc4c5 --- /dev/null +++ b/v1/apps/game/assets/tiles/decor_stone.png Binary files differ diff --git a/v1/apps/game/assets/tiles/decor_tuft.png b/v1/apps/game/assets/tiles/decor_tuft.png new file mode 100644 index 0000000..8e17f52 --- /dev/null +++ b/v1/apps/game/assets/tiles/decor_tuft.png Binary files differ diff --git a/v1/apps/game/assets/tiles/floor.png b/v1/apps/game/assets/tiles/floor.png new file mode 100644 index 0000000..6a1de4b --- /dev/null +++ b/v1/apps/game/assets/tiles/floor.png Binary files differ diff --git a/v1/apps/game/assets/tiles/grass.png b/v1/apps/game/assets/tiles/grass.png new file mode 100644 index 0000000..8ead393 --- /dev/null +++ b/v1/apps/game/assets/tiles/grass.png Binary files differ diff --git a/v1/apps/game/assets/tiles/grass_2.png b/v1/apps/game/assets/tiles/grass_2.png new file mode 100644 index 0000000..2999cf4 --- /dev/null +++ b/v1/apps/game/assets/tiles/grass_2.png Binary files differ diff --git a/v1/apps/game/assets/tiles/grass_3.png b/v1/apps/game/assets/tiles/grass_3.png new file mode 100644 index 0000000..0ade55d --- /dev/null +++ b/v1/apps/game/assets/tiles/grass_3.png Binary files differ diff --git a/v1/apps/game/assets/tiles/hearth.png b/v1/apps/game/assets/tiles/hearth.png new file mode 100644 index 0000000..1850270 --- /dev/null +++ b/v1/apps/game/assets/tiles/hearth.png Binary files differ diff --git a/v1/apps/game/assets/tiles/house.png b/v1/apps/game/assets/tiles/house.png new file mode 100644 index 0000000..d5820b1 --- /dev/null +++ b/v1/apps/game/assets/tiles/house.png Binary files differ diff --git a/v1/apps/game/assets/tiles/house_2.png b/v1/apps/game/assets/tiles/house_2.png new file mode 100644 index 0000000..1529b35 --- /dev/null +++ b/v1/apps/game/assets/tiles/house_2.png Binary files differ diff --git a/v1/apps/game/assets/tiles/house_3.png b/v1/apps/game/assets/tiles/house_3.png new file mode 100644 index 0000000..f6d5f29 --- /dev/null +++ b/v1/apps/game/assets/tiles/house_3.png Binary files differ diff --git a/v1/apps/game/assets/tiles/house_4.png b/v1/apps/game/assets/tiles/house_4.png new file mode 100644 index 0000000..c10460a --- /dev/null +++ b/v1/apps/game/assets/tiles/house_4.png Binary files differ diff --git a/v1/apps/game/assets/tiles/house_5.png b/v1/apps/game/assets/tiles/house_5.png new file mode 100644 index 0000000..8f12ce9 --- /dev/null +++ b/v1/apps/game/assets/tiles/house_5.png Binary files differ diff --git a/v1/apps/game/assets/tiles/house_big.png b/v1/apps/game/assets/tiles/house_big.png new file mode 100644 index 0000000..29c4be7 --- /dev/null +++ b/v1/apps/game/assets/tiles/house_big.png Binary files differ diff --git a/v1/apps/game/assets/tiles/machine.png b/v1/apps/game/assets/tiles/machine.png new file mode 100644 index 0000000..000d932 --- /dev/null +++ b/v1/apps/game/assets/tiles/machine.png Binary files differ diff --git a/v1/apps/game/assets/tiles/mote.png b/v1/apps/game/assets/tiles/mote.png new file mode 100644 index 0000000..3f7d3cf --- /dev/null +++ b/v1/apps/game/assets/tiles/mote.png Binary files differ diff --git a/v1/apps/game/assets/tiles/note.png b/v1/apps/game/assets/tiles/note.png new file mode 100644 index 0000000..ea6ba26 --- /dev/null +++ b/v1/apps/game/assets/tiles/note.png Binary files differ diff --git a/v1/apps/game/assets/tiles/path.png b/v1/apps/game/assets/tiles/path.png new file mode 100644 index 0000000..7ab718c --- /dev/null +++ b/v1/apps/game/assets/tiles/path.png Binary files differ diff --git a/v1/apps/game/assets/tiles/path_2.png b/v1/apps/game/assets/tiles/path_2.png new file mode 100644 index 0000000..2283872 --- /dev/null +++ b/v1/apps/game/assets/tiles/path_2.png Binary files differ diff --git a/v1/apps/game/assets/tiles/rail.png b/v1/apps/game/assets/tiles/rail.png new file mode 100644 index 0000000..4c4f8ad --- /dev/null +++ b/v1/apps/game/assets/tiles/rail.png Binary files differ diff --git a/v1/apps/game/assets/tiles/resonator.png b/v1/apps/game/assets/tiles/resonator.png new file mode 100644 index 0000000..ff132bf --- /dev/null +++ b/v1/apps/game/assets/tiles/resonator.png Binary files differ diff --git a/v1/apps/game/assets/tiles/signpost.png b/v1/apps/game/assets/tiles/signpost.png new file mode 100644 index 0000000..c6f20d8 --- /dev/null +++ b/v1/apps/game/assets/tiles/signpost.png Binary files differ diff --git a/v1/apps/game/assets/tiles/spit.png b/v1/apps/game/assets/tiles/spit.png new file mode 100644 index 0000000..c571f94 --- /dev/null +++ b/v1/apps/game/assets/tiles/spit.png Binary files differ diff --git a/v1/apps/game/assets/tiles/stairs.png b/v1/apps/game/assets/tiles/stairs.png new file mode 100644 index 0000000..96bdcea --- /dev/null +++ b/v1/apps/game/assets/tiles/stairs.png Binary files differ diff --git a/v1/apps/game/assets/tiles/tone_tree.png b/v1/apps/game/assets/tiles/tone_tree.png new file mode 100644 index 0000000..50006e9 --- /dev/null +++ b/v1/apps/game/assets/tiles/tone_tree.png Binary files differ diff --git a/v1/apps/game/assets/tiles/tower.png b/v1/apps/game/assets/tiles/tower.png new file mode 100644 index 0000000..a7564da --- /dev/null +++ b/v1/apps/game/assets/tiles/tower.png Binary files differ diff --git a/v1/apps/game/assets/tiles/tree.png b/v1/apps/game/assets/tiles/tree.png new file mode 100644 index 0000000..99bd669 --- /dev/null +++ b/v1/apps/game/assets/tiles/tree.png Binary files differ diff --git a/v1/apps/game/assets/tiles/tree_2.png b/v1/apps/game/assets/tiles/tree_2.png new file mode 100644 index 0000000..5ba00cd --- /dev/null +++ b/v1/apps/game/assets/tiles/tree_2.png Binary files differ diff --git a/v1/apps/game/assets/tiles/tree_2_sway.png b/v1/apps/game/assets/tiles/tree_2_sway.png new file mode 100644 index 0000000..b437dec --- /dev/null +++ b/v1/apps/game/assets/tiles/tree_2_sway.png Binary files differ diff --git a/v1/apps/game/assets/tiles/tree_3.png b/v1/apps/game/assets/tiles/tree_3.png new file mode 100644 index 0000000..7955876 --- /dev/null +++ b/v1/apps/game/assets/tiles/tree_3.png Binary files differ diff --git a/v1/apps/game/assets/tiles/tree_3_sway.png b/v1/apps/game/assets/tiles/tree_3_sway.png new file mode 100644 index 0000000..a408056 --- /dev/null +++ b/v1/apps/game/assets/tiles/tree_3_sway.png Binary files differ diff --git a/v1/apps/game/assets/tiles/tree_sway.png b/v1/apps/game/assets/tiles/tree_sway.png new file mode 100644 index 0000000..1d83975 --- /dev/null +++ b/v1/apps/game/assets/tiles/tree_sway.png Binary files differ diff --git a/v1/apps/game/assets/tiles/wall.png b/v1/apps/game/assets/tiles/wall.png new file mode 100644 index 0000000..9f52e1c --- /dev/null +++ b/v1/apps/game/assets/tiles/wall.png Binary files differ diff --git a/v1/apps/game/assets/tiles/water_1.png b/v1/apps/game/assets/tiles/water_1.png new file mode 100644 index 0000000..8fe3ff8 --- /dev/null +++ b/v1/apps/game/assets/tiles/water_1.png Binary files differ diff --git a/v1/apps/game/assets/tiles/water_2.png b/v1/apps/game/assets/tiles/water_2.png new file mode 100644 index 0000000..e4f9879 --- /dev/null +++ b/v1/apps/game/assets/tiles/water_2.png Binary files differ diff --git a/v1/apps/game/assets/tiles/well.png b/v1/apps/game/assets/tiles/well.png new file mode 100644 index 0000000..3e84997 --- /dev/null +++ b/v1/apps/game/assets/tiles/well.png Binary files differ diff --git a/v1/apps/game/assets/tiles/wire.png b/v1/apps/game/assets/tiles/wire.png new file mode 100644 index 0000000..f193cbd --- /dev/null +++ b/v1/apps/game/assets/tiles/wire.png Binary files differ diff --git a/v1/apps/game/assets/ui/icon_bellflower.png b/v1/apps/game/assets/ui/icon_bellflower.png new file mode 100644 index 0000000..cdad729 --- /dev/null +++ b/v1/apps/game/assets/ui/icon_bellflower.png Binary files differ diff --git a/v1/apps/game/assets/ui/icon_clock.png b/v1/apps/game/assets/ui/icon_clock.png new file mode 100644 index 0000000..df5fca8 --- /dev/null +++ b/v1/apps/game/assets/ui/icon_clock.png Binary files differ diff --git a/v1/apps/game/assets/ui/icon_cloth.png b/v1/apps/game/assets/ui/icon_cloth.png new file mode 100644 index 0000000..c2089d8 --- /dev/null +++ b/v1/apps/game/assets/ui/icon_cloth.png Binary files differ diff --git a/v1/apps/game/assets/ui/icon_flask.png b/v1/apps/game/assets/ui/icon_flask.png new file mode 100644 index 0000000..161bc59 --- /dev/null +++ b/v1/apps/game/assets/ui/icon_flask.png Binary files differ diff --git a/v1/apps/game/assets/ui/icon_map.png b/v1/apps/game/assets/ui/icon_map.png new file mode 100644 index 0000000..e1e5f23 --- /dev/null +++ b/v1/apps/game/assets/ui/icon_map.png Binary files differ diff --git a/v1/apps/game/assets/ui/icon_mote.png b/v1/apps/game/assets/ui/icon_mote.png new file mode 100644 index 0000000..99aba24 --- /dev/null +++ b/v1/apps/game/assets/ui/icon_mote.png Binary files differ diff --git a/v1/apps/game/assets/ui/icon_salt.png b/v1/apps/game/assets/ui/icon_salt.png new file mode 100644 index 0000000..83bb73e --- /dev/null +++ b/v1/apps/game/assets/ui/icon_salt.png Binary files differ diff --git a/v1/apps/game/index.html b/v1/apps/game/index.html new file mode 100644 index 0000000..db2fd6c --- /dev/null +++ b/v1/apps/game/index.html @@ -0,0 +1,18 @@ + + + + + + + Пепельные луга + + + +
+ + + \ No newline at end of file diff --git a/v1/apps/game/package.json b/v1/apps/game/package.json new file mode 100644 index 0000000..43c6a00 --- /dev/null +++ b/v1/apps/game/package.json @@ -0,0 +1,18 @@ +{ + "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": { + "puppeteer-core": "^25.10.0", + "vite": "^6.0.0" + } +} diff --git a/v1/apps/game/src/Game.ts b/v1/apps/game/src/Game.ts new file mode 100644 index 0000000..6c3d598 --- /dev/null +++ b/v1/apps/game/src/Game.ts @@ -0,0 +1,255 @@ +import { + Engine, + SaveManager, + AssetLoader, + AudioManager, + GameState, + Settings, + Inventory, + parseMap, + RATE, + renderMusicStereo, + type MusicHandle, + type Renderer, + type SceneManager, + type TileMapData +} from '@rpg/engine'; +import { AUDIO_KEYS } from './data/audio'; +import { THEMES } from './data/music'; +import { GameClock } from './systems/Clock'; +import { registerStateMigrations } from './data/migrations'; +// Шрифт идёт с движком (OFL): Vite кладёт файл в сборку и даёт готовый URL. +import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url'; + +/** + * Игровой контекст: движок, сохранения, загрузчик ассетов, состояние, настройки. + * Движок ничего не знает об этом классе — он только потребляет его API. + */ +export class Game { + /** Виртуальное разрешение (пиксель-арт). */ + static readonly VIRTUAL_W = 480; + static readonly VIRTUAL_H = 270; + + readonly saves: SaveManager; + readonly assets: AssetLoader; + /** Звуки и музыка (WAV из apps/game/tools/audio/gen.mjs). */ + readonly audio = new AudioManager((key) => `${import.meta.env.BASE_URL}audio/${key}.wav`); + /** Флаги и переменные прохождения. */ + readonly state = new GameState(); + /** Сумка героя (предметы квестов и торговли). */ + readonly inventory = new Inventory(); + /** Время суток (непрерывный цикл; 1 с реального = 1 игровая минута). */ + readonly clock = new GameClock(); + /** Настройки игрока (громкости и т.п.) — вне сейвов. */ + readonly settings = new Settings(window.localStorage); + + /** + * Активная локация (для автосейва при закрытии/скрытии вкладки и для + * оверлея карты: область + тайл героя). null — вне локации; сумка/оверлеи + * поверх не мешают: локация зарегистрирована. + */ + activeLocation: { + autosave(): void; + /** id области текущей локации (карта из Game.mapFiles). */ + readonly areaId: string; + /** Текущий тайл героя (мигающий маркер на карте). */ + heroTile(): { x: number; y: number }; + } | null = null; + + /** Текстуры тайлов и одиночные спрайты NPC. */ + static readonly ASSET_KEYS = [ + 'tiles/grass', + 'tiles/grass_2', + 'tiles/grass_3', + 'tiles/path', + 'tiles/path_2', + 'tiles/water_1', + 'tiles/water_2', + 'tiles/ash', + 'tiles/ash_2', + 'tiles/decor_tuft', + 'tiles/decor_stone', + 'tiles/decor_bald', + 'tiles/bellflower', + 'tiles/tree', + 'tiles/tree_2', + 'tiles/tree_3', + 'tiles/tree_sway', + 'tiles/tree_2_sway', + 'tiles/tree_3_sway', + 'tiles/house', + 'tiles/house_2', + 'tiles/house_3', + 'tiles/house_4', + 'tiles/house_5', + 'tiles/house_big', + 'tiles/boulder', + 'tiles/boulder_2', + 'tiles/boulder_3', + 'tiles/bush', + 'tiles/bush_2', + 'tiles/bush_3', + 'tiles/tower', + 'tiles/floor', + 'tiles/wall', + 'tiles/well', + 'tiles/chest', + 'tiles/wire', + 'tiles/rail', + 'tiles/stairs', + 'tiles/machine', + // AI-арт B4 (задача 5): интерактивы и снаряд плевка. + 'tiles/signpost', + 'tiles/mote', + 'tiles/note', + 'tiles/spit', + 'tiles/bell_rope', + 'tiles/counter', + 'tiles/resonator', + 'tiles/tone_tree', + 'tiles/hearth', + // Иконки предметов сумки 8×8 (арт-библия §4). + 'ui/icon_clock', + 'ui/icon_cloth', + 'ui/icon_bellflower', + 'ui/icon_salt', + 'ui/icon_flask', + 'ui/icon_mote', + 'ui/icon_map', + 'chars/hero_lamp' + ]; + + /** Ключи аудио (WAV): sfx + амбиент-лупы областей (реестр в data/audio.ts). */ + static readonly AUDIO_KEYS = AUDIO_KEYS; + + constructor(readonly engine: Engine) { + this.saves = new SaveManager(window.localStorage); + // Миграции формата state (старые сейвы поднимаются при load). + registerStateMigrations(this.state); + // Ключи без расширения -> .png; ключи .json (атласы) резолвятся как есть. + this.assets = new AssetLoader((key) => + key.endsWith('.json') ? `${import.meta.env.BASE_URL}${key}` : `${import.meta.env.BASE_URL}${key}.png` + ); + this.settings.load(); + // Настройки громкости -> шины аудио (до unlock шин нет — применится после). + this.settings.onChange((s) => { + this.audio.setBusVolume('master', s.master); + this.audio.setBusVolume('ambience', s.ambience); + this.audio.setBusVolume('sfx', s.sfx); + }); + // Применить текущие значения сразу. + this.audio.setBusVolume('master', this.settings.data.master); + this.audio.setBusVolume('ambience', this.settings.data.ambience); + this.audio.setBusVolume('sfx', this.settings.data.sfx); + // Хук для смоук-тестов (apps/game/tools/smoke*.mjs): доступ к контексту из страницы. + if (import.meta.env.DEV) { + (window as unknown as { __game?: Game }).__game = this; + // Шпион аудио: последние 24 запуска (sfx + лупы) — для агентных + // проверок звука (на слух в headless не проверить, tools/checks/audio.mjs). + const audioLog: { key: string; t: number; volume: number; pan: number }[] = []; + this.audio.onPlayed = (key, opts) => { + audioLog.push({ key, t: Math.round(performance.now()), volume: opts.volume ?? 1, pan: opts.pan ?? 0 }); + if (audioLog.length > 24) audioLog.shift(); + }; + (window as unknown as { __gameAudioLog?: typeof audioLog }).__gameAudioLog = audioLog; + // Агентный мост: window.__agent (см. docs/engine/agent.md, apps/game/tools/agent.mjs). + import('./agent/GameAgent').then(({ registerAgent }) => registerAgent(this)); + } + } + + /** URL пиксельного шрифта (VT323 идёт вместе с движком, OFL). */ + static readonly FONT_URL: string = fontUrl; + + /** Карты локаций, распарсенные из assets/maps/.map (формат rpg-map). */ + readonly mapFiles = new Map(); + + /** Загрузить и распарсить карту локации (вызывается в BootScene). */ + async loadMap(id: string): Promise { + const res = await fetch(`${import.meta.env.BASE_URL}maps/${id}.map`); + if (!res.ok) throw new Error(`Карта ${id}: HTTP ${res.status}`); + const data = parseMap(await res.json()); + this.mapFiles.set(id, data); + return data; + } + + get scenes(): SceneManager { + return this.engine.scenes; + } + + get renderer(): Renderer { + return this.engine.renderer; + } + + get camera() { + return this.engine.camera; + } + + get worldRoot() { + return this.engine.renderer.worldRoot; + } + + /** Амбиент области с кроссфейдом; повторный вызов с тем же ключом не перезапускает его. */ + playAmbience(key: string, volume = 0.7): void { + if (this.ambienceKey === key) return; + this.ambienceKey = key; + if (key === '') { + this.audio.stopAmbience(1.5); + return; + } + void this.audio.playAmbience(key, { fade: 1.5, volume }); + } + + /** + * Зарегистрировать и декодировать аудио в фоне (ленивый префетч амбиента + * области при входе; playAmbience/playLoop и сами умеют грузить на месте — + * это только чтобы первый вход в область не тормозил). + */ + preloadAudio(keys: readonly string[]): void { + for (const key of keys) { + this.audio.register(key); + void this.audio.preload(key); + } + } + + /** Текущий амбиент (защита от перезапуска того же ключа; '' — тишина). */ + private ambienceKey: string | null = null; + + /** + * Тема области (спек-музыка из data/music.ts, компилируется в рантайме): + * кроссфейд при смене, повторный вызов с тем же ключом не перезапускает, + * '' — тишина. Буфер темы кэшируется — компиляция один раз на ключ. + */ + playTheme(key: string): void { + if (this.themeKey === key) return; + this.themeKey = key; + this.themeHandle?.stop(2); + this.themeHandle = null; + const spec = THEMES[key]; + if (!spec) return; + let buf = this.themeBuffers.get(key); + if (!buf) { + // Тема компилируется синхронно, вне async-цепочки play* — контекст + // заводим на месте (prepare), иначе до первого жеста будет тишина. + this.audio.prepare(); + const created = this.audio.createBuffer(renderMusicStereo(spec), RATE, 2); + if (!created) { + this.themeKey = null; // до unlock — компиляция при следующем входе + return; + } + buf = created; + this.themeBuffers.set(key, buf); + } + void this.audio.playLoopBuffer(`music/${key}`, buf, { fade: 2, volume: 0.5 }).then((handle) => { + // Тема сменилась, пока луп заводился, — гасим без хвостов. + if (this.themeKey !== key) { + handle?.stop(1); + return; + } + this.themeHandle = handle; + }); + } + + private themeKey: string | null = null; + private themeHandle: MusicHandle | null = null; + private themeBuffers = new Map(); +} \ No newline at end of file diff --git a/v1/apps/game/src/agent/GameAgent.ts b/v1/apps/game/src/agent/GameAgent.ts new file mode 100644 index 0000000..f0662e5 --- /dev/null +++ b/v1/apps/game/src/agent/GameAgent.ts @@ -0,0 +1,301 @@ +import { + EngineAgent, + dayNightFactor, + type AgentHost, + type Invariant, + type JsonValue, + type SceneAgent, + type SnapshotLayer +} from '@rpg/engine'; +import type { Game } from '../Game'; +import { + AGENT_SNAPSHOT_KEYS, + gameLayer, + type DialogueSnapshot, + type GameSnapshot, + type InteractableSnapshot +} from './snapshot'; +import { validateContent } from '../data/validate'; +import { usedFlag } from '../data/ids'; + +/** + * Агентный мост игры: поверх движкового каркаса (EngineAgent) добавляет + * контентные слои (флаги/вары/инвентарь/сцена) и помощники высокого уровня + * (ходьба по маршруту, диалоги, «новая игра»). Регистрируется как + * `window.__agent` (только DEV) — инструменты в apps/game/tools/ ходят через него. + */ + +export interface AgentApi { + version: 1; + snapshot(): GameSnapshot; + invariants(): Invariant[]; + /** n фиксированных шагов (детерминированно, без реального ожидания). */ + step(n?: number, opts?: { render?: boolean }): { tick: number }; + /** pred — строка-выражение над снапшотом (из браузера функцию не передать). */ + waitFor( + pred: string, + opts?: { timeoutTicks?: number; render?: boolean } + ): Promise; + /** Клик в виртуальных пикселях (480×270). */ + tapVirtual(vx: number, vy: number): void; + /** Реальный DOM-клик (Pixi-кнопки меню). */ + uiTap(vx: number, vy: number): void; + /** Действие по маппингу (advance/attack/...). */ + press(action: string, holdTicks?: number): void; + /** Сырой код клавиши (e.code). */ + key(code: string): void; + /** Whitelist-команда сцены (scene:sleepAll, scene:teleport, ...). */ + command(name: string, args?: JsonValue): JsonValue; + /** Пойти в тайл по маршруту A* (в сцене). false — путь не найден/не дошёл. */ + walkTo(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise; + /** Подойти к тайлу (цель может быть непроходима). null — соседей нет/не дошёл. */ + approach(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise<{ x: number; y: number } | null>; + /** Дождаться и долистать активный диалог. false — диалог не открылся. */ + runDialogue(timeoutTicks?: number): Promise; + /** Дождаться выбора в диалоге и выбрать вариант по тексту. false — не дождались/нет варианта. */ + pickChoiceByText(text: string, timeoutTicks?: number): Promise; + /** «Новая игра» из меню. */ + newGame(): Promise; + /** id текущей локации (null — сцена не локация). */ + currentArea(): string | null; + /** Масштаб и позиция worldRoot (проверки зума). */ + worldRootView(): { scale: number; position: { x: number; y: number } }; +} + +/** Действие «листать диалог» (маппится и на Space, и на Enter). */ +const ADVANCE = 'advance'; + +/** Итог waitFor: error заполняется только при отклонённом предикате. */ +export interface WaitResult { + ok: boolean; + snapshot: GameSnapshot; + ticks: number; + /** Предикат ссылался на неизвестный ключ (опечатка) — исполнение не начато. */ + error?: string; +} + +/** Top-level обращения `s.<ключ>` в строке-предикате (вложенные пути не трогаем). */ +export function predKeys(pred: string): string[] { + const re = /(?:^|[^.\w$])s\.([A-Za-z_$][\w$]*)/g; + const keys = new Set(); + let m: RegExpExecArray | null; + while ((m = re.exec(pred)) !== null) keys.add(m[1]!); + return [...keys]; +} + +export class GameAgent { + private engineAgent: EngineAgent; + + constructor(private game: Game) { + this.engineAgent = new EngineAgent( + game.engine as unknown as AgentHost, + () => (game.scenes.current as unknown as SceneAgent | undefined) ?? null + ); + } + + /** Масштаб и позиция worldRoot (проверки зума из tools/checks/zoom.mjs). */ + worldRootView(): { scale: number; position: { x: number; y: number } } { + const root = this.game.worldRoot; + return { scale: root.scale.x, position: { x: root.position.x, y: root.position.y } }; + } + + snapshot(): GameSnapshot { + const s = this.engineAgent.snapshot() as GameSnapshot; + // Слой игры поверх движкового и сценического. + Object.assign(s, gameLayer({ + flags: this.game.state.allFlags, + vars: { ...this.game.state.serialize().vars }, + inventory: this.game.inventory.all, + time: { + minutes: this.game.clock.minutes, + label: this.game.clock.label, + night: dayNightFactor(this.game.clock.hours) + } + })); + return s; + } + + invariants(): Invariant[] { + // Контентная валидация живёт здесь (не в сцене), чтобы работать и в меню. + const list: Invariant[] = [...this.engineAgent.invariants(), ...validateContent(this.game.mapFiles)]; + // used-флаги и снапшот идут разными путями — сверяем их на месте. + const s = this.snapshot(); + const flags = new Set(s.flags ?? []); + for (const o of (s.interactables ?? []) as InteractableSnapshot[]) { + if (flags.has(usedFlag(o.id)) !== o.used) { + list.push({ + id: 'interact-used-consistent', + severity: 'error', + message: `объект ${o.id}: флаг used=${flags.has(usedFlag(o.id))}, снапшот used=${o.used}`, + where: 'agent/GameAgent' + }); + } + } + return list; + } + + step(n = 1, opts?: { render?: boolean }): { tick: number } { + return this.engineAgent.step(n, opts); + } + + async waitFor( + pred: string, + opts?: { timeoutTicks?: number; render?: boolean } + ): Promise { + // Опечатка в ключе предиката — тихий false на весь таймаут. Отклоняем + // сразу: unknown key -> { ok: false, error }, шаги не тратятся. + const unknown = predKeys(pred).filter((k) => !AGENT_SNAPSHOT_KEYS.includes(k)); + if (unknown.length > 0) { + return { + ok: false, + snapshot: this.snapshot(), + ticks: 0, + error: `неизвестный ключ s.${unknown[0]} в предикате (см. AGENT_SNAPSHOT_KEYS)` + }; + } + // Строка-выражение приходит из apps/game/tools/ (доверенная среда репозитория). + const fn = new Function('s', `"use strict"; return (${pred});`) as (s: SnapshotLayer) => boolean; + const limit = opts?.timeoutTicks ?? 600; + const render = opts?.render ?? false; + // Ждём по ПОЛНОМУ снапшоту игры (flags/vars/inventory поверх сцены): + // engineAgent.waitFor видит только движковый + сценический слои. + for (let i = 0; i < limit; i++) { + this.step(1, { render }); + const s = this.snapshot(); + let ok = false; + try { + ok = fn(s); + } catch { + // предикат упал — трактуем как «не совпало» + } + if (ok) return { ok: true, snapshot: s, ticks: i + 1 }; + } + return { ok: false, snapshot: this.snapshot(), ticks: limit }; + } + + tapVirtual(vx: number, vy: number): void { + this.engineAgent.tapVirtual(vx, vy); + } + + uiTap(vx: number, vy: number): void { + this.engineAgent.uiTap(vx, vy); + } + + press(action: string, holdTicks = 1): void { + this.engineAgent.press(action, holdTicks); + } + + key(code: string): void { + this.engineAgent.key(code); + } + + command(name: string, args?: JsonValue): JsonValue { + return this.engineAgent.command(name, args) as JsonValue; + } + + /** + * Идти в тайл: сцена строит A* и ведёт героя сама (scene:walk), мост ждёт + * тайла цели. Без `!moving`: step-переход срабатывает, как только округлённый + * тайл героя становится целевым (сцена замирает ещё в moving), — окна «на + * тайле и стоит» может не быть. false — пути нет. + */ + async walkTo(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise { + const started = this.command('scene:walk', { x: tx, y: ty }); + if (started !== true) return false; + const wait = await this.waitFor( + `s.hero && s.hero.tile && s.hero.tile.x === ${tx} && s.hero.tile.y === ${ty}`, + { timeoutTicks: opts?.timeoutTicks ?? 600 } + ); + return wait.ok; + } + + /** + * Подойти к тайлу (цель может быть непроходима: стены, объекты, NPC-тайлы, + * тела врагов). Сцена ведёт к ближайшему проходимому соседу (scene:approach). + * Возврат — тайл прибытия или null (соседей нет / не дошёл). + */ + async approach( + tx: number, + ty: number, + opts?: { timeoutTicks?: number } + ): Promise<{ x: number; y: number } | null> { + const dest = this.command('scene:approach', { x: tx, y: ty }); + const d = dest as { x?: unknown; y?: unknown } | null; + if (!d || typeof d.x !== 'number' || typeof d.y !== 'number') return null; + const wait = await this.waitFor( + `s.hero && s.hero.tile && s.hero.tile.x === ${d.x} && s.hero.tile.y === ${d.y} && !s.hero.moving`, + { timeoutTicks: opts?.timeoutTicks ?? 600 } + ); + return wait.ok ? { x: d.x, y: d.y } : null; + } + + /** Долистать активный диалог: жать advance, пока он открыт. */ + async runDialogue(timeoutTicks = 600): Promise { + const opened = await this.waitFor('s.dialogue != null', { timeoutTicks }); + if (!opened.ok) return false; + for (let i = 0; i < 50; i++) { + const d = this.snapshot().dialogue as DialogueSnapshot | null; + if (!d) return true; // диалог закрылся — завершён + if (d.waitingForChoice) { + this.command('scene:pickChoice', { index: 0 }); + } else { + this.press(ADVANCE); + } + this.step(2); + } + return !this.snapshot().dialogue; + } + + /** + * Дождаться выбора в диалоге и выбрать вариант по тексту (через + * scene:pickChoiceByText — порядок вариантов сценарию знать не надо). + */ + async pickChoiceByText(text: string, timeoutTicks = 600): Promise { + const opened = await this.waitFor('s.dialogue != null && s.dialogue.waitingForChoice', { + timeoutTicks + }); + if (!opened.ok) return false; + const picked = this.command('scene:pickChoiceByText', { text }); + this.step(2); + return picked === true; + } + + /** «Новая игра» из меню (команда сцены — без координат кнопок). */ + async newGame(): Promise { + // begin() молча отбрасывает команды во время fade-перехода — ждём его конца. + await this.waitFor('!s.transitioning', { timeoutTicks: 1200 }); + this.command('menu:newGame'); + await this.waitFor('s.scene === "location"', { timeoutTicks: 1200 }); + } + + currentArea(): string | null { + const s = this.snapshot(); + return typeof s.area === 'string' ? s.area : null; + } +} + +/** Зарегистрировать window.__agent (только DEV). */ +export function registerAgent(game: Game): AgentApi { + const agent = new GameAgent(game); + const api: AgentApi = { + version: 1, + snapshot: () => agent.snapshot(), + invariants: () => agent.invariants(), + step: (n, opts) => agent.step(n, opts), + waitFor: (pred, opts) => agent.waitFor(pred, opts), + tapVirtual: (vx, vy) => agent.tapVirtual(vx, vy), + uiTap: (vx, vy) => agent.uiTap(vx, vy), + press: (action, holdTicks) => agent.press(action, holdTicks), + key: (code) => agent.key(code), + command: (name, args) => agent.command(name, args), + walkTo: (tx, ty, opts) => agent.walkTo(tx, ty, opts), + approach: (tx, ty, opts) => agent.approach(tx, ty, opts), + runDialogue: (timeoutTicks) => agent.runDialogue(timeoutTicks), + pickChoiceByText: (text, timeoutTicks) => agent.pickChoiceByText(text, timeoutTicks), + newGame: () => agent.newGame(), + currentArea: () => agent.currentArea(), + worldRootView: () => agent.worldRootView() + }; + (window as unknown as { __agent?: AgentApi }).__agent = api; + return api; +} \ No newline at end of file diff --git a/v1/apps/game/src/agent/SceneAgentView.ts b/v1/apps/game/src/agent/SceneAgentView.ts new file mode 100644 index 0000000..25e70eb --- /dev/null +++ b/v1/apps/game/src/agent/SceneAgentView.ts @@ -0,0 +1,467 @@ +import { + dayNightFactor, + unitsToPx, + findPath, + findPathToNeighbor, + checkFinite, + checkRange, + checkWalkable, + mergeInvariants, + tileToWorld, + type Invariant, + type Grid, + type IsometricTileMap, + type JsonValue, + type SceneAgent, + type SceneRegistry, + type SnapshotLayer, + type PostFx, + type ShimmerState, + type Camera, + type SoundKind, + type SoundSpec, + type Vec2 +} from '@rpg/engine'; +import type { CutsceneRunner } from '@rpg/engine'; +import type { Game } from '../Game'; +import type { AreaDef, HazardDef } from '../data/locations'; +import type { NpcDef } from '../data/npcs'; +import { PLAYER_COMBAT } from '../systems/combat/stats'; +import type { CombatWorld } from '../systems/combat/CombatWorld'; +import type { PlayerCombat } from '../systems/combat/PlayerCombat'; +import type { PlayerController } from '../systems/PlayerController'; +import type { Interactables } from '../systems/Interactables'; +import type { DialogueSystem } from '../systems/DialogueSystem'; +import type { + HeroSnapshot, + EnemySnapshot, + NpcSnapshot, + TransitionSnapshot, + LocationSnapshot, + InteractableSnapshot +} from './snapshot'; +import { heroLayer, enemiesLayer, npcsLayer, dialogueLayer, collisionLayer, lightingLayer } from './snapshot'; +import type { GameLighting } from '../systems/Lighting'; +import { MAX_LIGHTS } from '../systems/Lighting'; +import { hasLineOfSight } from '../systems/combat/los'; + +/** Допустимые kind спек-синтеза (как в движке, строками — спек идёт по мосту). */ +const SOUND_KINDS: readonly string[] = ['hit', 'chime', 'scrape', 'hum', 'tone']; + +/** Аргументы whitelist-команд моста (все опциональны; типы проверяют обработчики). */ +interface CommandArgs { + x?: number; + y?: number; + level?: number; + id?: string; + value?: number | string | boolean; + flag?: string; + index?: number; + hours?: number; + text?: string; + from?: { x?: number; y?: number }; + to?: { x?: number; y?: number }; + key?: string; + volume?: number; + spec?: Partial; + effect?: string; +} + +/** Обработчик команды моста: deps сцены + разобранные аргументы. */ +type CommandHandler = (d: SceneAgentDeps, a: CommandArgs) => JsonValue; + +/** Аргументы-тайлы x/y заданы числами (общая проверка тайловых команд). */ +function hasXY(a: CommandArgs): a is CommandArgs & { x: number; y: number } { + return typeof a.x === 'number' && typeof a.y === 'number'; +} + +/** Таблица whitelist-команд (name → обработчик); неизвестное имя — null в agentCommand. */ +const COMMANDS: Record = { + 'scene:sleepAll': (d) => { + for (const [, en] of d.combat.enemies) en.brain.putToSleep(9999); + return true; + }, + 'scene:noise': (d, a) => { + // Шум в тайле (юниты моста — тайлы): уровень 0.35 слышат бодрые, 0.7+ будит спящих. + if (!hasXY(a)) return null; + d.combat.noise(tileToWorld(a.x, a.y), typeof a.level === 'number' ? a.level : 1); + return true; + }, + 'scene:damageEnemy': (d, a) => { + // Урон живому сгустку (проверки отступления); id — фильтр по виду. + if (typeof a.value !== 'number') return null; + for (const [e, en] of d.combat.enemies) { + if (en.brain.dead) continue; + if (typeof a.id === 'string' && en.kind.id !== a.id) continue; + d.combat.damageEnemy(e, en, a.value, d.player.position); + return true; + } + return false; + }, + 'scene:damagePlayer': (d, a) => { + // Урон герою полным боевым путём (CombatWorld.deps → onPlayerDamaged): + // вспышка, шейк, виньетка при низком hp. + if (typeof a.value !== 'number') return null; + d.damagePlayer(a.value); + return true; + }, + 'scene:give': (d, a) => { + if (typeof a.id !== 'string') return null; + d.game.inventory.add(a.id); + return true; + }, + 'scene:setVar': (d, a) => { + if (typeof a.id !== 'string') return null; + d.game.state.setVar(a.id, a.value ?? 0); + return true; + }, + 'scene:setFlag': (d, a) => { + if (typeof a.flag !== 'string') return null; + d.game.state.setFlag(a.flag); + return true; + }, + 'scene:setTime': (d, a) => { + // Перемотка времени суток (часы → минуты) — детерминизм проверок света. + if (typeof a.hours !== 'number') return null; + d.game.clock.set(a.hours * 60); + return true; + }, + 'scene:teleport': (d, a) => { + if (!hasXY(a)) return null; + d.player.teleportTo({ x: a.x, y: a.y }); + d.followCamera(true); + return true; + }, + 'scene:route': (d, a) => { + if (!hasXY(a)) return null; + // Маршрут как у героя: тайлы NPC заняты, иначе мост ведёт сквозь тело. + return findPath(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }, false) ?? null; + }, + 'scene:walk': (d, a) => { + // Пойти в тайл (как герой): A* + followPath, без инъекции клика. + if (!hasXY(a)) return null; + const path = findPath(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }, false); + if (!path || path.length === 0) return false; + d.player.followPath(path); + return true; + }, + 'scene:approach': (d, a) => { + // Подойти к краю (не)проходимого тайла: цель может быть стеной/объектом/NPC. + // Возврат — тайл прибытия ({x, y}); уже рядом — текущий тайл героя. + if (!hasXY(a)) return null; + const path = findPathToNeighbor(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }); + if (path === null) return null; + const dest = path.length > 0 ? path[path.length - 1]! : d.player.currentTile(); + if (path.length > 0) d.player.followPath(path); + return { x: dest.x, y: dest.y }; + }, + 'scene:walkable': (d, a) => { + // Проходим ли тайл (юниты моста — тайлы; стены, вода и footprint пропов блокируют). + if (!hasXY(a)) return null; + return d.map.isWalkable(Math.floor(a.x), Math.floor(a.y)); + }, + 'scene:raycast': (d, a) => { + // Прямая видимость между тайлами: true — чисто, false — стена/дом на отрезке. + const f = a.from; + const t = a.to; + if (!f || !t || typeof f.x !== 'number' || typeof f.y !== 'number' || typeof t.x !== 'number' || typeof t.y !== 'number') { + return null; + } + return hasLineOfSight(tileToWorld(f.x, f.y), tileToWorld(t.x, t.y), d.combat.opaque); + }, + 'scene:pickChoice': (d, a) => { + if (typeof a.index !== 'number') return null; + d.dialogue.pickChoice(a.index); + return true; + }, + 'scene:pickChoiceByText': (d, a) => { + // Выбор по тексту реплики — сценариям не надо знать порядок вариантов. + if (typeof a.text !== 'string') return null; + return d.dialogue.pickChoiceByText(a.text); + }, + 'scene:skipCutscene': (d) => { + if (!d.cutscene.active) return false; + d.cutscene.skip(); // until форсируются — скип конечен даже на шаге-ожидании + return true; + }, + 'scene:synthesize': (d, a) => { + // Звук по описанию (спек → playSpec): агент без слуха описывает + // звук параметрами; факт запуска виден в DEV-логе аудио. + const s = a.spec; + if (!s || !SOUND_KINDS.includes(s.kind as SoundKind) || typeof s.dur !== 'number') { + return null; + } + const spec: SoundSpec = { kind: s.kind as SoundKind, dur: s.dur }; + if (typeof s.low === 'number') spec.low = s.low; + if (typeof s.high === 'number') spec.high = s.high; + if (typeof s.tone === 'number') spec.tone = s.tone; + if (typeof s.freq === 'number') spec.freq = s.freq; + if (typeof s.power === 'number') spec.power = s.power; + if (typeof s.seed === 'number') spec.seed = s.seed; + if (typeof s.peak === 'number') spec.peak = s.peak; + if (typeof s.loop === 'boolean') spec.loop = s.loop; + void d.game.audio.playSpec( + typeof a.key === 'string' ? a.key : 'agent/synth', + spec, + typeof a.volume === 'number' ? a.volume : 0.6 + ); + return true; + }, + 'scene:postfx': (d, a) => { + // Пост-эффекты для проверок: импульс блюра/аберрации, марево зоной, сброс. + // effect: blur | aberration | shimmer | clear; точка — тайл (x/y), + // level — радиус зоны в юнитах, value — сила (px выборки / px сдвига / amp). + const center = hasXY(a) + ? (() => { + const w = tileToWorld(a.x!, a.y!); + return d.camera.toScreen(w.x, w.y); + })() + : undefined; + const radius = + typeof a.level === 'number' ? unitsToPx(a.level) * d.camera.zoomLevel : undefined; + switch (a.effect) { + case 'blur': + d.postfx.pulseBlur( + { + strength: typeof a.value === 'number' ? a.value : 6, + center, + radius: radius ?? 0 + }, + { attack: 0.05, hold: 0.1, decay: 0.5 } + ); + return true; + case 'aberration': + d.postfx.pulseAberration( + typeof a.value === 'number' ? a.value : 3, + { attack: 0.02, hold: 0.06, decay: 0.4 } + ); + return true; + case 'shimmer': + d.setShimmer({ + amp: typeof a.value === 'number' ? a.value : 1.4, + wavelength: 26, + speed: 1.8, + center, + radius: radius ?? unitsToPx(2.5) * d.camera.zoomLevel + }); + return true; + case 'clear': + d.postfx.clear(); + d.setShimmer(undefined); // вернуть авто-зоны (источники области) + return true; + default: + return null; + } + } +}; + +/** + * Агентный фасад сцены локации: слой снапшота, инварианты, whitelist-команды. + * Вынесен из LocationScene (SceneAgent), чтобы сцена оставалась оркестратором + * вьюх, а мосту хватало узкого deps-объекта. Слои снапшота собираются теми же + * фабриками, что тестируются в snapshot.test.ts, — расхождение невозможно. + */ +export interface SceneAgentDeps { + game: Game; + map: IsometricTileMap; + area: AreaDef; + player: PlayerController; + playerCombat: PlayerCombat; + combat: CombatWorld; + /** Определения NPC области (вьюхи не нужны мосту). */ + npcs: readonly NpcDef[]; + interactables: Interactables; + /** Реестр объектов сцены (для инварианта согласованности). */ + registry: SceneRegistry; + /** Grid путей с занятыми NPC-тайлами (для scene:route — как у героя). */ + walkGrid(): Grid; + dialogue: DialogueSystem; + cutscene: CutsceneRunner; + lastToast(): { text: string; tick: number } | null; + /** Ближайшая цель кнопки действия (или null) — из роутера взаимодействия. */ + interactHint(): { kind: string; label: string; tile: { x: number; y: number } } | null; + inHazard(): HazardDef | null; + /** Урон герою полным боевым путём (как у сгустков — for scene:damagePlayer). */ + damagePlayer(damage: number, from?: Vec2): void; + /** Игровая обвязка освещения (ambient + источники для снапшота). */ + lighting(): GameLighting; + /** Камера (проекция мир→экран для точечных пост-эффектов). */ + camera: Camera; + /** Пост-эффекты сцены (scene:postfx — блюр/аберрация/марево). */ + postfx: PostFx; + /** + * Ручная зона марева: перекрывает авто-источники области, пока не снять. + * undefined возвращает авто-режим (ставит scene:postfx clear). + */ + setShimmer(z: ShimmerState | null | undefined): void; + /** Камера в ногах героя (snap — телепорты). */ + followCamera(snap: boolean): void; +} + +export class SceneAgentView implements SceneAgent { + constructor(private deps: SceneAgentDeps) {} + + /** Контентный слой снапшота — см. snapshot.ts. */ + agentSnapshot(): SnapshotLayer { + const d = this.deps; + const hero: HeroSnapshot = { + tile: d.player.currentTile(), + pos: d.player.position, + hp: d.playerCombat.hp, + maxHp: PLAYER_COMBAT.maxHp, + facing: d.player.dir, + moving: d.player.moving, + invuln: d.playerCombat.invuln, + inHazard: d.inHazard()?.name ?? null + }; + const enemies: EnemySnapshot[] = []; + for (const [, en] of d.combat.enemies) { + enemies.push({ + kind: en.kind.id, + state: en.brain.state, + hp: en.hp, + pos: en.pos, + asleep: en.brain.asleep, + dead: en.brain.dead + }); + } + const npcs: NpcSnapshot[] = d.npcs.map((def) => ({ + id: def.id, + name: def.name, + tile: def.tile, + met: d.game.state.hasFlag(def.flagKey) + })); + const transitions: TransitionSnapshot[] = d.area.transitions.map((t) => ({ + tile: t.tile, + to: t.target.kind === 'area' ? t.target.area : '', + trigger: t.trigger ?? 'step', + label: t.label ?? null + })); + const interactables: InteractableSnapshot[] = (d.area.interactables ?? []).map((def) => ({ + id: def.id, + tile: def.tile, + label: def.label ?? null, + used: d.interactables.isUsed(def.id) + })); + const layer: LocationSnapshot = { + scene: 'location', + area: d.area.id, + areaName: d.area.name, + hero: heroLayer(hero).hero, + enemies: enemiesLayer(enemies).enemies, + npcs: npcsLayer(npcs).npcs, + transitions, + interactables, + collision: collisionLayer(d.map.data).collision, + lighting: lightingLayer(d.lighting().snapshot()).lighting, + dialogue: dialogueLayer(d.dialogue.agentState).dialogue, + cutscene: { active: d.cutscene.active }, + lastToast: d.lastToast(), + interactHint: d.interactHint() + }; + return layer; + } + + /** Инварианты сцены: валидность контента + целостность героя/врагов. */ + agentInvariants(): Invariant[] { + const where = 'scene/LocationScene'; + const d = this.deps; + const heroTile = d.player.currentTile(); + const heroPos = d.player.position; + const enemyPos: Record = {}; + const enemyChecks: Invariant[] = []; + for (const [e, en] of d.combat.enemies) { + enemyPos[`enemy#${e}.x`] = en.pos.x; + enemyPos[`enemy#${e}.y`] = en.pos.y; + if (!en.brain.dead && !d.map.isWalkable(Math.floor(en.pos.x), Math.floor(en.pos.y))) { + enemyChecks.push({ + id: 'enemy-in-wall', + severity: 'error', + message: `${en.kind.id} в непроходимом тайле (${en.pos.x},${en.pos.y})`, + where + }); + } + } + return mergeInvariants( + checkFinite( + { 'hero.pos.x': heroPos.x, 'hero.pos.y': heroPos.y, ...enemyPos }, + where + ), + checkRange('hero.hp', d.playerCombat.hp, 0, PLAYER_COMBAT.maxHp, where), + checkWalkable('герой', heroTile, d.map, where), + enemyChecks, + this.registryInvariant(where), + this.lightingInvariant(where), + this.timeInvariant(where), + d.combat.agentInvariants() + ); + } + + /** Время суток в допустимых пределах: конечные минуты 0..1439, night 0..1, label «H:MM». */ + private timeInvariant(where: string): Invariant[] { + const bad: Invariant = { + id: 'time-bounded', + severity: 'error', + message: 'время суток вне допустимых пределов', + where + }; + const t = { + minutes: this.deps.game.clock.minutes, + label: this.deps.game.clock.label, + night: dayNightFactor(this.deps.game.clock.hours) + }; + if (!Number.isFinite(t.minutes) || t.minutes < 0 || t.minutes >= 1440) return [bad]; + if (!Number.isFinite(t.night) || t.night < 0 || t.night > 1) return [bad]; + if (!/^\d{1,2}:\d{2}$/.test(t.label)) return [bad]; + return []; + } + + /** Свет в допустимых пределах: конечные значения, интенсивность 0..2, виньетка 0..1, источников ≤24. */ + private lightingInvariant(where: string): Invariant[] { + const bad: Invariant = { + id: 'lighting-bounded', + severity: 'error', + message: 'источники света вне допустимых пределов', + where + }; + const l = this.deps.lighting().snapshot(); + if (l.sources.length > MAX_LIGHTS || !Number.isFinite(l.ambient)) return [bad]; + if (!Number.isFinite(l.vignette) || l.vignette < 0 || l.vignette > 1) return [bad]; + for (const s of l.sources) { + if (!Number.isFinite(s.x) || !Number.isFinite(s.y) || s.intensity < 0 || s.intensity > 2) return [bad]; + } + return []; + } + + /** Реестр согласован с ECS: у каждого живого врага есть запись, позиции совпадают. */ + private registryInvariant(where: string): Invariant[] { + const out: Invariant[] = []; + for (const [e, en] of this.deps.combat.enemies) { + if (en.brain.dead) continue; + const obj = this.deps.registry.get(`enemy:${e}`); + if (!obj) { + out.push({ + id: 'registry-consistent', + severity: 'error', + message: `враг #${e} (${en.kind.id}) нет в реестре сцены`, + where + }); + } else if (obj.pos.x !== en.pos.x || obj.pos.y !== en.pos.y) { + out.push({ + id: 'registry-consistent', + severity: 'error', + message: `враг #${e} (${en.kind.id}): позиция реестра разошлась с ECS`, + where + }); + } + } + return out; + } + + /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */ + agentCommand(name: string, args?: JsonValue): JsonValue { + const handler = COMMANDS[name]; + if (!handler) return null; + return handler(this.deps, (args ?? {}) as CommandArgs); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/agent/__tests__/snapshot.test.ts b/v1/apps/game/src/agent/__tests__/snapshot.test.ts new file mode 100644 index 0000000..beb287d --- /dev/null +++ b/v1/apps/game/src/agent/__tests__/snapshot.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest'; +import { + AGENT_SNAPSHOT_KEYS, + collisionLayer, + dialogueLayer, + enemiesLayer, + gameLayer, + heroLayer, + lightingLayer, + npcsLayer, + type EnemySnapshot, + type GameSnapshot, + type DialogueSnapshot, + type LocationSnapshot, + type NpcSnapshot +} from '../snapshot'; +import { predKeys } from '../GameAgent'; + +describe('снапшот агента — слои', () => { + it('герой: позиция округляется до 3 знаков, остальное копируется', () => { + const layer = heroLayer({ + tile: { x: 12, y: 8 }, + pos: { x: 1.23456, y: 5.0001 }, + hp: 9, + maxHp: 12, + facing: 'side', + moving: false, + invuln: false, + inHazard: null + }); + expect(layer.hero).toMatchObject({ + tile: { x: 12, y: 8 }, + pos: { x: 1.235, y: 5 }, + hp: 9, + maxHp: 12, + facing: 'side' + }); + }); + + it('враги: поля на месте, позиция округлена', () => { + const e: EnemySnapshot = { + kind: 'ash', + state: 'chase', + hp: 4, + pos: { x: 0.123456, y: 2.5 }, + asleep: true, + dead: false + }; + const layer = enemiesLayer([e]); + expect(layer.enemies).toEqual([ + { kind: 'ash', state: 'chase', hp: 4, pos: { x: 0.123, y: 2.5 }, asleep: true, dead: false } + ]); + }); + + it('слой коллизий: blocked по тайлам, пропы отдельным списком', () => { + const data = { + width: 2, + height: 2, + tiles: [0, 1, 0, 0], + blocked: [1], + props: [{ id: 2, x: 0, y: 1, w: 2, h: 1 }] + }; + const layer = collisionLayer(data); + expect(layer.collision).toEqual({ + width: 2, + height: 2, + blocked: [0, 1, 1, 1], // стена (1,0) + весь footprint пропа + props: [{ x: 0, y: 1, w: 2, h: 1 }] + }); + }); + + it('свет: ambient как есть, источники с округлением координат и интенсивности', () => { + const layer = lightingLayer({ + ambient: 0x54586a, + vignette: 0.34567, + sources: [ + { id: 'hearth', x: 123.4567, y: 67.8912, color: 0xf2b45a, intensity: 0.87654 }, + { id: 'lamp', x: 240, y: 135, color: 0xf2b45a, intensity: 0.5 } + ] + }); + expect(layer.lighting).toEqual({ + ambient: 0x54586a, + vignette: 0.346, + sources: [ + { id: 'hearth', x: 123.457, y: 67.891, color: 0xf2b45a, intensity: 0.877 }, + { id: 'lamp', x: 240, y: 135, color: 0xf2b45a, intensity: 0.5 } + ] + }); + }); + + it('свет: пустой список источников допустим (дневная улица)', () => { + const layer = lightingLayer({ ambient: 0xffffff, vignette: 0, sources: [] }); + expect(layer.lighting).toEqual({ ambient: 0xffffff, vignette: 0, sources: [] }); + }); + + it('NPC: копия с met, без ссылок на источник', () => { + const n: NpcSnapshot = { id: 'elder', name: 'Ирвин', tile: { x: 20, y: 12 }, met: false }; + const layer = npcsLayer([n]); + const out = layer.npcs[0]!; + expect(out).toEqual(n); + expect(out).not.toBe(n); + }); + + it('диалог: объект проходит, null остаётся null', () => { + const d: DialogueSnapshot = { + id: 'elder', + nodeId: 'n1', + speaker: 'Ирвин', + text: 'Привет', + mood: null, + tags: [], + choices: [], + path: ['n1'], + waitingForChoice: false + }; + expect(dialogueLayer(d).dialogue).toEqual(d); + expect(dialogueLayer(null).dialogue).toBeNull(); + }); + + it('слой игры: флаги/вары/сумка/время копируются защитно', () => { + const flags = ['metElder']; + const vars = { flowers: 2 }; + const inv = [{ id: 'flower', count: 2 }]; + const time = { minutes: 480, label: '8:00', night: 0 }; + const layer = gameLayer({ flags, vars, inventory: inv, time }); + expect(layer).toEqual({ + flags: ['metElder'], + vars: { flowers: 2 }, + inventory: [{ id: 'flower', count: 2 }], + time: { minutes: 480, label: '8:00', night: 0 } + }); + // Мутация источника не меняет слой. + flags.push('x'); + vars.flowers = 99; + inv[0]!.count = 99; + time.minutes = 999; + expect(layer.flags).toEqual(['metElder']); + expect(layer.vars).toEqual({ flowers: 2 }); + expect(layer.inventory[0]!.count).toBe(2); + expect(layer.time.minutes).toBe(480); + }); +}); + +describe('AGENT_SNAPSHOT_KEYS — анти-дрейф', () => { + const locationSample: LocationSnapshot = { + scene: 'location', + area: 'meadows', + areaName: 'Выжженные луга', + hero: { + tile: { x: 0, y: 0 }, + pos: { x: 0, y: 0 }, + hp: 10, + maxHp: 10, + facing: 'down', + moving: false, + invuln: false, + inHazard: null + }, + enemies: [], + npcs: [], + transitions: [], + interactables: [], + collision: { width: 1, height: 1, blocked: [0], props: [] }, + lighting: { ambient: 0xffffff, vignette: 0, sources: [] }, + dialogue: null, + cutscene: null, + lastToast: null, + interactHint: null + }; + + it('все top-level ключи сцены и слоя игры есть в списке', () => { + const gamePart: GameSnapshot = gameLayer({ + flags: [], + vars: {}, + inventory: [], + time: { minutes: 480, label: '8:00', night: 0 } + }); + const keys = new Set([...Object.keys(locationSample), ...Object.keys(gamePart)]); + expect([...keys].filter((k) => !AGENT_SNAPSHOT_KEYS.includes(k))).toEqual([]); + }); + + it('движковые ключи (слоя EngineAgent) не утеряны из списка', () => { + for (const k of ['tick', 'fps', 'fixedStep', 'scenes', 'transitioning', 'camera', 'pointer']) { + expect(AGENT_SNAPSHOT_KEYS).toContain(k); + } + }); + + it('в списке нет лишних (список = сцена + игра + движок)', () => { + const locationKeys = Object.keys(locationSample); + const gameKeys = ['flags', 'vars', 'inventory', 'time']; + const extra = AGENT_SNAPSHOT_KEYS.filter( + (k) => !locationKeys.includes(k) && !gameKeys.includes(k) && !['tick', 'fps', 'fixedStep', 'scenes', 'transitioning', 'camera', 'pointer'].includes(k) + ); + expect(extra).toEqual([]); + }); +}); + +describe('predKeys — top-level ключи предиката', () => { + it('одиночное обращение и вложенный путь', () => { + expect(predKeys('s.hero')).toEqual(['hero']); + expect(predKeys('s.hero && s.hero.tile.x === 1')).toEqual(['hero']); + }); + + it('несколько ключей и сложные выражения', () => { + expect(predKeys('!s.transitioning && s.area === "zvenets"')).toEqual( + expect.arrayContaining(['transitioning', 'area']) + ); + expect(predKeys('s.vars.motes >= 1 || s.flags.includes("x")')).toEqual( + expect.arrayContaining(['vars', 'flags']) + ); + expect(predKeys('(s.dialogue != null)')).toEqual(['dialogue']); + }); + + it('не ловит не-обращения (строки, другие объекты)', () => { + expect(predKeys('s.scene === "location"')).toEqual(['scene']); + expect(predKeys('s.x === 1')).toEqual(['x']); // pointer-слой — легальный ключ движка + expect(predKeys('foo.s.bar === 1')).toEqual([]); // не наш s + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/agent/snapshot.ts b/v1/apps/game/src/agent/snapshot.ts new file mode 100644 index 0000000..95d0535 --- /dev/null +++ b/v1/apps/game/src/agent/snapshot.ts @@ -0,0 +1,272 @@ +import { gridOf, ENGINE_SNAPSHOT_KEYS, type SnapshotLayer, type TileMapData } from '@rpg/engine'; + +/** + * Контентный слой снапшота агентного моста. Чистые функции — тестируются + * в Vitest без браузера; LocationScene/GameAgent подставляют живые данные. + */ + +/** Top-level ключи полного снапшота игры: движковый слой + сцена + слой игры. */ +export const AGENT_SNAPSHOT_KEYS: readonly string[] = [ + ...ENGINE_SNAPSHOT_KEYS, + // слой сцены локации (LocationSnapshot) + 'scene', + 'area', + 'areaName', + 'hero', + 'enemies', + 'npcs', + 'transitions', + 'interactables', + 'collision', + 'lighting', + 'dialogue', + 'cutscene', + 'lastToast', + 'interactHint', + // слой игры (gameLayer) + 'flags', + 'vars', + 'inventory', + 'time' +]; + +/* + * Все снапшот-типы ниже — type alias'ы, не interface: у alias'ов есть неявная + * индексная подпись, поэтому они присваиваются JsonValue/SnapshotLayer без + * кастов (interface — нет). Рассинхрон слоя и типа — ошибка компиляции. + */ + +/** Герой. */ +export type HeroSnapshot = { + tile: { x: number; y: number }; + pos: { x: number; y: number }; + hp: number; + maxHp: number; + facing: string; + moving: boolean; + invuln: boolean; + inHazard: string | null; +}; + +/** Враг. */ +export type EnemySnapshot = { + kind: string; + state: string | null; + hp: number; + pos: { x: number; y: number }; + asleep: boolean; + dead: boolean; +}; + +/** NPC. */ +export type NpcSnapshot = { + id: string; + name: string; + tile: { x: number; y: number }; + met: boolean; +}; + +/** Активный диалог (или null). */ +export type DialogueSnapshot = { + id: string | null; + nodeId: string | null; + speaker: string | null; + text: string | null; + /** Настроение реплики (презентационное; на геймплей не влияет). */ + mood: string | null; + /** Свободные метки узла — для фильтров проверок. */ + tags: string[]; + choices: string[]; + /** Показанные узлы по порядку — как диалог был пройден. */ + path: string[]; + waitingForChoice: boolean; +} + +/** Переход (дверь/выход/портал) — из данных локации. */ +export type TransitionSnapshot = { + tile: { x: number; y: number }; + to: string; + /** Как срабатывает: шагом на тайл или кликом. */ + trigger: 'step' | 'click'; + label: string | null; +} + +/** Интерактивный объект области (used — для once-объектов). */ +export type InteractableSnapshot = { + id: string; + tile: { x: number; y: number }; + label: string | null; + used: boolean; +} + +/** Источник света (пост-мерцание). */ +export type LightSourceSnapshot = { + id: string; + x: number; + y: number; + color: number; + intensity: number; +} + +/** Освещение сцены: ambient (multiply-цвет) + виньетка + активные источники. */ +export type LightingSnapshot = { + ambient: number; + /** Целевая интенсивность виньетки 0..1. */ + vignette: number; + sources: LightSourceSnapshot[]; +} + +/** Карта коллизий для агента: стены/вода по тайлам + крупные пропы. */ +export type CollisionSnapshot = { + width: number; + height: number; + /** Проходимость по тайлам, построчно: 0 — свободен, 1 — блок (включая footprint пропов). */ + blocked: number[]; + /** Крупные объекты с footprint'ом (дома и т.п.). */ + props: { x: number; y: number; w: number; h: number }[]; +} + +/** Контентный слой сцены локации (структурно = SnapshotLayer). */ +export type LocationSnapshot = { + scene: 'location'; + area: string; + areaName: string; + hero: HeroSnapshot; + enemies: EnemySnapshot[]; + npcs: NpcSnapshot[]; + transitions: TransitionSnapshot[]; + interactables: InteractableSnapshot[]; + collision: CollisionSnapshot; + /** Освещение области (ambient + активные источники). */ + lighting: LightingSnapshot; + dialogue: DialogueSnapshot | null; + cutscene: { active: boolean } | null; + /** Последний тост (текст + тик) — единственный канал текста реакций. */ + lastToast: { text: string; tick: number } | null; + /** Ближайшая цель кнопки действия (или null — вне радиуса взаимодействия). */ + interactHint: { kind: string; label: string; tile: { x: number; y: number } } | null; +} + +/** Время суток (ход игры — непрерывный, 1 с реального = 1 игровая минута). */ +export type TimeSnapshot = { + /** Целые минуты суток 0..1439. */ + minutes: number; + /** Строка «8:24» (как на часах предмета). */ + label: string; + /** Фактор ночи 0..1 (dayNightFactor по часам). */ + night: number; +} + +/** Полный снапшот игры: движковый слой + сценический + слой игры. */ +export type GameSnapshot = SnapshotLayer & { + /** Флаги прохождения. */ + flags: string[]; + /** Переменные прохождения. */ + vars: Record; + /** Сумка. */ + inventory: { id: string; count: number }[]; + /** Время суток. */ + time: TimeSnapshot; +}; + +/** Округление до 3 знаков — JSON читабельнее, точность в юнитах не теряется. */ +function r(v: number): number { + return Math.round(v * 1000) / 1000; +} + +/** Слой карты коллизий: blocked по тайлам (gridOf учитывает footprint пропов). */ +export function collisionLayer(data: TileMapData): SnapshotLayer & { collision: CollisionSnapshot } { + const grid = gridOf(data); + const blocked: number[] = []; + for (let y = 0; y < data.height; y++) { + for (let x = 0; x < data.width; x++) { + blocked.push(grid.isWalkable(x, y) ? 0 : 1); + } + } + const collision: CollisionSnapshot = { + width: data.width, + height: data.height, + blocked, + props: (data.props ?? []).map((p) => ({ x: p.x, y: p.y, w: p.w ?? 1, h: p.h ?? 1 })) + }; + return { collision }; +} + +/** Слой героя. */ +export function heroLayer(o: HeroSnapshot): SnapshotLayer & { hero: HeroSnapshot } { + return { + hero: { + tile: { x: o.tile.x, y: o.tile.y }, + pos: { x: r(o.pos.x), y: r(o.pos.y) }, + hp: o.hp, + maxHp: o.maxHp, + facing: o.facing, + moving: o.moving, + invuln: o.invuln, + inHazard: o.inHazard + } + }; +} + +/** Слой врагов. */ +export function enemiesLayer(enemies: EnemySnapshot[]): SnapshotLayer & { enemies: EnemySnapshot[] } { + return { + enemies: enemies.map((e) => ({ + kind: e.kind, + state: e.state, + hp: e.hp, + pos: { x: r(e.pos.x), y: r(e.pos.y) }, + asleep: e.asleep, + dead: e.dead + })) + }; +} + +/** Слой NPC. */ +export function npcsLayer(npcs: NpcSnapshot[]): SnapshotLayer & { npcs: NpcSnapshot[] } { + return { + npcs: npcs.map((n) => ({ + id: n.id, + name: n.name, + tile: { x: n.tile.x, y: n.tile.y }, + met: n.met + })) + }; +} + +/** Слой диалога. */ +export function dialogueLayer(d: DialogueSnapshot | null): SnapshotLayer & { dialogue: DialogueSnapshot | null } { + return { dialogue: d ? { ...d } : null }; +} + +/** Слой освещения: ambient + виньетка + активные источники (координаты экранные px). */ +export function lightingLayer(o: LightingSnapshot): SnapshotLayer & { lighting: LightingSnapshot } { + return { + lighting: { + ambient: o.ambient, + vignette: r(o.vignette), + sources: o.sources.map((s) => ({ + id: s.id, + x: r(s.x), + y: r(s.y), + color: s.color, + intensity: r(s.intensity) + })) + } + }; +} + +/** Слой уровня игры (вне сцен). */ +export function gameLayer(o: { + flags: string[]; + vars: Record; + inventory: { id: string; count: number }[]; + time: TimeSnapshot; +}): GameSnapshot { + return { + flags: [...o.flags], + vars: { ...o.vars }, + inventory: o.inventory.map((s) => ({ id: s.id, count: s.count })), + time: { ...o.time } + }; +} \ No newline at end of file diff --git a/v1/apps/game/src/data/__tests__/dialogueRules.test.ts b/v1/apps/game/src/data/__tests__/dialogueRules.test.ts new file mode 100644 index 0000000..7d78a48 --- /dev/null +++ b/v1/apps/game/src/data/__tests__/dialogueRules.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import type { DialogueGraph } from '@rpg/engine'; +import { analyzeGraph, checkGraph, type GraphRefs } from '../dialogueRules'; + +const refs = (): GraphRefs => ({ + flags: new Set(['met_elder', 'quest_bells_taken']), + vars: new Set(['flowers']), + items: new Set(['cloth']), + customs: new Set(['plant_flowers']), + strings: new Set(['line.key']) +}); + +describe('checkGraph — ссылки и структура', () => { + it('чистый граф — ошибок нет', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { a: { text: 'Привет', next: 'b' }, b: { text: 'Пока' } } + }; + expect(checkGraph('g', g, refs()).filter((i) => i.severity === 'error')).toEqual([]); + }); + + it('битые ссылки: флаг, вар, предмет, custom, textKey, next, start', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { + text: 'x', + when: ['нет_такого'], + whenVars: [{ key: 'нет_вара', op: 'ge', value: 1 }], + hasItem: ['salt'], + do: [ + { kind: 'giveItem', id: 'нет_предмета' }, + { kind: 'custom', id: 'нет_эффекта' } + ], + textKey: 'нет.строки', + next: 'в_никуда' + } + } + }; + const ids = checkGraph('g', g, refs()).map((i) => i.id); + expect(ids).toContain('flag-unknown'); + expect(ids).toContain('var-unknown'); + expect(ids).toContain('item-unknown'); + expect(ids).toContain('do-item-unknown'); + expect(ids).toContain('do-custom-unknown'); + expect(ids).toContain('string-unknown'); + expect(ids).toContain('dialogue-next'); + // битый start + const ids2 = checkGraph('g', { start: 'нет', nodes: {} }, refs()).map((i) => i.id); + expect(ids2).toContain('dialogue-start'); + }); + + it('правильные ссылки проходят, упоминания пишутся в used-множества', () => { + const usedFlags = new Set(); + const usedVars = new Set(); + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { + when: ['met_elder'], + whenVars: [{ key: 'flowers', op: 'ge', value: 3 }], + setFlags: ['quest_bells_taken'], + do: [{ kind: 'giveItem', id: 'cloth' }, { kind: 'custom', id: 'plant_flowers' }], + text: 'ok' + } + } + }; + expect(checkGraph('g', g, { ...refs(), usedFlags, usedVars })).toEqual([]); + expect([...usedFlags]).toEqual(['met_elder', 'quest_bells_taken']); + expect([...usedVars]).toEqual(['flowers']); + }); +}); + +describe('analyzeGraph — достижимость', () => { + it('сироты и тихие концы', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { text: 'ok', next: 'silent' }, + lost: { text: 'никто не ссылается' }, + silent: { setFlags: ['met_elder'] } // нет next и текста — молча кончается + } + }; + const a = analyzeGraph(g); + expect(a.reachable).toEqual(['a', 'silent']); + expect(a.orphans).toEqual(['lost']); + expect(a.silentEnds).toEqual(['silent']); + }); + + it('цикл без текста — error, цикл с текстом — не цикл-ошибка', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { text: 'Слово', next: 'hub' }, + hub: { next: 'hub' } // действие само в себя + } + }; + const a = analyzeGraph(g); + expect(a.textlessCycles).toEqual([['hub']]); + expect(a.silentEnds).toEqual([]); + }); + + it('выборы считаются рёбрами: цель выбора достижима', () => { + const g: DialogueGraph = { + start: 'q', + nodes: { + q: { text: '?', choices: [{ text: 'Да', next: 'yes' }] }, + yes: { text: 'Да' } + } + }; + const a = analyzeGraph(g); + expect(a.orphans).toEqual([]); + expect(a.reachable).toEqual(['q', 'yes']); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/data/__tests__/dialogues.test.ts b/v1/apps/game/src/data/__tests__/dialogues.test.ts new file mode 100644 index 0000000..a3d7737 --- /dev/null +++ b/v1/apps/game/src/data/__tests__/dialogues.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { DialogueRunner, type DialogueView } from '@rpg/engine'; +import { GameState } from '@rpg/engine'; +import { DIALOGUES } from '../dialogues'; + +/** Заглушка view: запоминает показанные реплики. */ +function stubView(): DialogueView & { shown: string[] } { + const shown: string[] = []; + return { + shown, + show: (n: { text: string }) => void shown.push(n.text), + hide: () => undefined + }; +} + +describe('trader_repeat — ветка по вару motes', () => { + it('без мотов — только базовая реплика', () => { + const view = stubView(); + const runner = new DialogueRunner(new GameState(), view); + runner.start(DIALOGUES.trader_repeat); + runner.advance(); + expect(view.shown).toHaveLength(1); + expect(view.shown[0]).toContain('Звони тихо'); + }); + + it('с мотами (motes >= 1) — добавляется реплика о мота́х', () => { + const view = stubView(); + const state = new GameState(); + state.setVar('motes', 2); + const runner = new DialogueRunner(state, view); + runner.start(DIALOGUES.trader_repeat); + runner.advance(); + expect(view.shown).toHaveLength(2); + expect(view.shown[1]).toContain('Моты'); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/data/__tests__/interactables.test.ts b/v1/apps/game/src/data/__tests__/interactables.test.ts new file mode 100644 index 0000000..a3f5a6d --- /dev/null +++ b/v1/apps/game/src/data/__tests__/interactables.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { resolveInteractable, type InteractableDef, type InteractCtx } from '../interactables'; + +const ctx = (over: Partial = {}): InteractCtx => ({ + hasFlag: (f) => f === 'quest_bells_done', + hasItem: (i) => i === 'cloth', + getVar: (id) => (id === 'motes' ? 3 : 0), + used: (id) => id === 'spent', + ...over +}); + +const def = (over: Partial = {}): InteractableDef => ({ + id: 'obj', + pos: { x: 5.5, y: 5.5 }, + tile: { x: 5, y: 5 }, + kind: 'examine', + responses: [{ text: 'обычная' }], + ...over +}); + +describe('resolveInteractable — выбор реакции', () => { + it('без when — первая реакция', () => { + const pick = resolveInteractable(def(), ctx()); + expect(pick?.ok && pick.response.text).toBe('обычная'); + }); + + it('первая подходящая по when побеждает', () => { + const d = def({ + responses: [ + { when: { flag: 'quest_bells_done' }, text: 'после' }, + { text: 'до' } + ] + }); + expect(resolveInteractable(d, ctx())?.ok && 'после').toBe('после'); + expect(resolveInteractable(d, ctx({ hasFlag: () => false }))?.ok && 'до').toBe('до'); + }); + + it('notFlag и varAtLeast учитываются', () => { + const d = def({ responses: [{ when: { notFlag: 'quest_bells_done' }, text: 'x' }] }); + expect(resolveInteractable(d, ctx())).toBeNull(); + const v = def({ responses: [{ when: { varAtLeast: { id: 'motes', value: 5 } }, text: 'x' }] }); + expect(resolveInteractable(v, ctx())).toBeNull(); + expect(resolveInteractable(v, ctx({ getVar: () => 7 }))?.ok).toBe(true); + }); +}); + +describe('resolveInteractable — once и requiresItem', () => { + it('once: использованный молчит, неиспользованный реагирует', () => { + const d = def({ id: 'chest', once: true, responses: [{ text: 'сундук' }] }); + expect(resolveInteractable(d, ctx())?.ok).toBe(true); + expect(resolveInteractable(d, ctx({ used: () => true }))).toBeNull(); + }); + + it('requiresItem без предмета — отказ; с предметом реагирует', () => { + const d = def({ + requiresItem: 'salt', + lockedText: 'Нужна соль.', + responses: [{ text: 'взял' }] + }); + expect(resolveInteractable(d, ctx())).toEqual({ ok: false, lockedText: 'Нужна соль.' }); + expect(resolveInteractable(d, ctx({ hasItem: () => true }))?.ok).toBe(true); + }); + + it('requiresItem без lockedText — отказ по умолчанию', () => { + const pick = resolveInteractable(def({ requiresItem: 'salt' }), ctx()); + expect(pick && !pick.ok && pick.lockedText.length > 0).toBe(true); + }); + + it('once-использованный с requiresItem — отказ важнее молчания', () => { + const d = def({ once: true, requiresItem: 'salt', responses: [{ text: 'x' }] }); + expect(resolveInteractable(d, ctx({ used: () => true }))).toMatchObject({ ok: false }); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/data/__tests__/items.test.ts b/v1/apps/game/src/data/__tests__/items.test.ts new file mode 100644 index 0000000..2e52f50 --- /dev/null +++ b/v1/apps/game/src/data/__tests__/items.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { ITEMS, itemName, useItemLine } from '../items'; + +describe('useItemLine — применение предметов', () => { + it('часы показывают замороженное время сумки', () => { + expect(useItemLine('clock', { label: '8:24' })).toBe('Время: 8:24'); + expect(useItemLine('clock', { label: '0:05' })).toBe('Время: 0:05'); + }); + + it('не-юзабельные предметы — null (сумка молчит)', () => { + expect(useItemLine('cloth', { label: '8:24' })).toBeNull(); + expect(useItemLine('flower', { label: '8:24' })).toBeNull(); + expect(useItemLine('', { label: '8:24' })).toBeNull(); + }); +}); + +describe('реестр предметов', () => { + it('часы описаны в реестре (валидатор диалогов держит giveItem clock)', () => { + expect(itemName('clock')).toBe('Медные часы'); + expect(ITEMS.clock.desc.length).toBeGreaterThan(0); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/data/__tests__/migrations.test.ts b/v1/apps/game/src/data/__tests__/migrations.test.ts new file mode 100644 index 0000000..5d20426 --- /dev/null +++ b/v1/apps/game/src/data/__tests__/migrations.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { GameState } from '@rpg/engine'; +import { migrateV1toV2, migrateV2toV3, registerStateMigrations, STATE_DATA_VERSION } from '../migrations'; + +describe('миграции GameStateData', () => { + it('v1→v2: знакомства переименованы, остальное перенесено, version=2', () => { + const out = migrateV1toV2({ + version: 1, + flags: ['metElder', 'quest_bells_taken', 'metTrader'], + vars: { flowers: 3 } + }); + expect(out.version).toBe(2); + expect(out.flags).toEqual(['met_elder', 'quest_bells_taken', 'met_mila']); + expect(out.vars).toEqual({ flowers: 3 }); + }); + + it('v2→v3: флаг evening удалён, остальное перенесено, version=3', () => { + const out = migrateV2toV3({ + version: 2, + flags: ['evening', 'quest_bells_done', 'hint_bells'], + vars: { flowers: 3 } + }); + expect(out.version).toBe(3); + expect(out.flags).toEqual(['quest_bells_done', 'hint_bells']); + expect(out.vars).toEqual({ flowers: 3 }); + }); + + it('старый сейв v1 читается state.load с миграцией до текущей версии', () => { + const s = new GameState(); + registerStateMigrations(s); + expect(s.dataVersion).toBe(STATE_DATA_VERSION); + s.load({ version: 1, flags: ['metElder', 'metTrader'], vars: {} }); + expect(s.hasFlag('met_elder')).toBe(true); + expect(s.hasFlag('met_mila')).toBe(true); + expect(s.hasFlag('metElder')).toBe(false); + expect(s.hasFlag('metTrader')).toBe(false); + }); + + it('сейв текущей версии миграций не получает', () => { + const s = new GameState(); + registerStateMigrations(s); + s.load({ version: STATE_DATA_VERSION, flags: ['quest_bells_done'], vars: {} }); + expect(s.hasFlag('quest_bells_done')).toBe(true); + expect(s.allFlags).toEqual(['quest_bells_done']); + // сериализация — в текущей версии + expect(s.serialize().version).toBe(STATE_DATA_VERSION); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/data/__tests__/quests.test.ts b/v1/apps/game/src/data/__tests__/quests.test.ts new file mode 100644 index 0000000..703eaa3 --- /dev/null +++ b/v1/apps/game/src/data/__tests__/quests.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { GameState, Inventory } from '@rpg/engine'; +import { QUESTS, QUEST_FLOWERS, activeStage, questDialogueFor, questLog, questTracker } from '../quests'; +import { applyDialogueEffect, type DialogueEffectSink } from '../../systems/DialogueEffects'; +import { DIALOGUE_CUSTOM } from '../effects'; + +const bells = QUESTS[0]!; + +describe('квест «Три цветка» — стадии', () => { + it('до знакомства квест не взят, стадий нет', () => { + const s = new GameState(); + expect(questLog(s)).toEqual([]); + }); + + it('после взятия активна стадия сбора; диалог сдачи не готов', () => { + const s = new GameState(); + s.setFlag('quest_bells_taken'); + expect(activeStage(s, bells)?.dialogue).toBe('elder_hand_in'); + expect(questDialogueFor(s, 'elder')).toBeNull(); // цветов мало + expect(questLog(s)).toEqual([ + { done: true, text: 'Пепел дышит у прудов. Поговорить с Ирвином' }, + { done: false, text: `Собрать три колокольчика — Серые пруды, северо-запад лугов (0/${QUEST_FLOWERS})` } + ]); + }); + + it('собрано достаточно — сдача готова', () => { + const s = new GameState(); + s.setFlag('quest_bells_taken'); + s.setVar('flowers', QUEST_FLOWERS); + expect(questDialogueFor(s, 'elder')).toBe('elder_hand_in'); + expect(questLog(s)).toEqual([ + { done: true, text: 'Пепел дышит у прудов. Поговорить с Ирвином' }, + { done: false, text: `Собрать три колокольчика — Серые пруды, северо-запад лугов (3/3)` } + ]); + }); + + it('после сдачи — эпилог с Милой, до разговора с ней активен', () => { + const s = new GameState(); + s.setFlag('quest_bells_taken'); + s.setVar('flowers', QUEST_FLOWERS); + s.setFlag('quest_bells_done'); + expect(questDialogueFor(s, 'trader')).toBe('trader_after'); + expect(questDialogueFor(s, 'elder')).toBeNull(); // Ирвин — обычный repeat + expect(questLog(s)).toEqual([ + { done: true, text: 'Пепел дышит у прудов. Поговорить с Ирвином' }, + { done: true, text: `Собрать три колокольчика — Серые пруды, северо-запад лугов (3/3)` }, + { done: false, text: 'Поговорить с Милой — поляна у тропы гудит' } + ]); + // Разговор с Милой (act2_hook) завершает и эпилог. + s.setFlag('act2_hook'); + expect(questLog(s).every((e) => e.done)).toBe(true); + }); +}); + +describe('трекер HUD (questTracker)', () => { + it('в свежем состоянии — цель взятия (поговорить с Ирвином)', () => { + const s = new GameState(); + expect(questTracker(s)).toBe('Пепел дышит у прудов. Поговорить с Ирвином'); + }); + + it('на стадии сбора показывает прогресс', () => { + const s = new GameState(); + s.setFlag('quest_bells_taken'); + s.setVar('flowers', 1); + expect(questTracker(s)).toBe(`Собрать три колокольчика — Серые пруды, северо-запад лугов (1/3)`); + }); + + it('после завершения квеста колокольчиков — цель следующего квеста', () => { + const s = new GameState(); + s.setFlag('quest_bells_taken'); + s.setFlag('quest_bells_done'); + s.setFlag('act2_hook'); + expect(questTracker(s)).toBe('Резонатор Клинта молчит. Поговорить с Клинтом'); + }); +}); + +describe('эффекты диалогов', () => { + it('applyDialogueEffect: give/take меняют сумку, unknown игнорируется', () => { + const inv = new Inventory(); + const sink: DialogueEffectSink = { + giveItem: (id, count) => void inv.add(id, count), + takeItem: (id, count) => void inv.remove(id, count), + playSound: () => {}, + showToast: () => {}, + custom: () => {} + }; + applyDialogueEffect({ kind: 'giveItem', id: 'cloth', count: 2 }, sink); + expect(inv.count('cloth')).toBe(2); + applyDialogueEffect({ kind: 'takeItem', id: 'cloth' }, sink); + expect(inv.count('cloth')).toBe(1); + // неизвестный kind — тихий игнор (валидатор ловит как error) + applyDialogueEffect({ kind: 'custom', id: 'nope' } as never, sink); + applyDialogueEffect({ kind: 'toast', text: '' }, sink); + expect(inv.count('cloth')).toBe(1); + }); + + it('custom доходит до приёмника с именем из реестра', () => { + const got: string[] = []; + const sink: DialogueEffectSink = { + giveItem: () => {}, + takeItem: () => {}, + playSound: () => {}, + showToast: () => {}, + custom: (name) => got.push(name) + }; + applyDialogueEffect({ kind: 'custom', id: DIALOGUE_CUSTOM.plant_flowers }, sink); + expect(got).toEqual(['plant_flowers']); + }); + + it('квест завершён, но эпилог (без doneFlag) остаётся активной стадией', () => { + const s = new GameState(); + s.setFlag('quest_bells_taken'); + s.setFlag('quest_bells_done'); + expect(activeStage(s, bells)?.npc).toBe('trader'); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/data/__tests__/saves.test.ts b/v1/apps/game/src/data/__tests__/saves.test.ts new file mode 100644 index 0000000..7be8159 --- /dev/null +++ b/v1/apps/game/src/data/__tests__/saves.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { SAVE_SLOTS, canDelete, slotTitle } from '../saves'; + +describe('слоты сейвов', () => { + it('autosave первый и неудаляемый вручную', () => { + expect(SAVE_SLOTS[0]).toBe('autosave'); + expect(canDelete('autosave')).toBe(false); + expect(canDelete('slot1')).toBe(true); + }); + + it('подписи слотов человекочитаемы', () => { + expect(slotTitle('autosave')).toBe('Автосохранение'); + expect(slotTitle('slot2')).toBe('Слот 2'); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/data/__tests__/sfxSpecs.test.ts b/v1/apps/game/src/data/__tests__/sfxSpecs.test.ts new file mode 100644 index 0000000..a611a5c --- /dev/null +++ b/v1/apps/game/src/data/__tests__/sfxSpecs.test.ts @@ -0,0 +1,87 @@ +/** + * Парити-тест спек-синтеза: renderSpec(SPEC_SFX[key]) должен совпадать с + * формулой бывшего файлового генератора (tools/audio/gen.mjs) — миграция + * на playSpec не изменила звучание. Агент без слуха, поэтому сверяем + * численно: bell_hit/whoosh бит-в-бит (формулы совпали дословно), остальные + * — с допуском 1e-5 (перегруппировка умножений даёт расхождение ~1e-15, + * допуск ловит любую реальную ошибку формулы: частоту, вес, огибающую). + */ +import { describe, expect, it } from 'vitest'; +import { renderSpec } from '@rpg/engine'; +import { bandNoise, decay, normalize, synth } from '@rpg/engine/tools/synth.mjs'; +import { + ashDie, + ashHiss, + bellHit, + bellLow, + chestOpen, + chime, + deerStartle, + hurt, + motePickup, + note, + pickup, + spit, + uiClick, + whoosh +} from '../../../tools/audio/gen.mjs'; +import { SPEC_SFX } from '../sfxSpecs'; + +/** Максимум модуля разности двух сэмпл-массивов. */ +function maxDiff(a: Float32Array, b: Float32Array): number { + expect(a.length).toBe(b.length); + let max = 0; + for (let i = 0; i < a.length; i++) max = Math.max(max, Math.abs(a[i]! - b[i]!)); + return max; +} + +describe('паритет SPEC_SFX с формулами gen.mjs', () => { + it('sfx/bell_hit — бит-в-бит равен bellHit()', () => { + const spec = renderSpec(SPEC_SFX['sfx/bell_hit']!); + const wav = bellHit(); + expect(spec.length).toBe(wav.length); + for (let i = 0; i < wav.length; i++) expect(spec[i]).toBe(wav[i]); + }); + + it('sfx/whoosh — бит-в-бит равен whoosh()', () => { + const spec = renderSpec(SPEC_SFX['sfx/whoosh']!); + const wav = whoosh(); + expect(spec.length).toBe(wav.length); + for (let i = 0; i < wav.length; i++) expect(spec[i]).toBe(wav[i]); + }); + + const EPS = 1e-5; + const CASES: [string, () => Float32Array][] = [ + ['sfx/bell_low', bellLow], + ['sfx/ash_hiss', ashHiss], + ['sfx/ash_die', ashDie], + ['sfx/spit', spit], + ['sfx/hurt', hurt], + ['sfx/mote_pickup', motePickup], + ['sfx/pickup', pickup], + ['sfx/chest_open', chestOpen], + ['sfx/note', note], + ['sfx/deer_startle', deerStartle], + ['sfx/ui_click', uiClick], + ['sfx/chime', chime] + ]; + for (const [key, gen] of CASES) { + it(`${key} — совпадает с формулой gen.mjs (eps ${EPS})`, () => { + expect(maxDiff(renderSpec(SPEC_SFX[key]!), gen())).toBeLessThan(EPS); + }); + } + + it('sfx/step_water — совпадает с формулой всплеска (eps)', () => { + // Формула бывшего sfx/step_water.wav (seed 255 в gen.mjs); у спека + // сид 255+... — нет, базовый спек шагов взял сиды StepVariants + // (253-256), поэтому здесь сверяем саму формулу с сидом 255. + const gen = (): Float32Array => { + const dur = 0.18; + const noise = bandNoise(155, 600, 3200, dur); + const s = synth(dur, (t) => (noise() * 0.6 + Math.sin(2 * Math.PI * 300 * t) * 0.2) * decay(t, dur, 2)); + return normalize(s, 0.3); + }; + const spec: typeof SPEC_SFX[string] = { ...SPEC_SFX['sfx/step_water']!, seed: 155 }; + expect(maxDiff(renderSpec(spec), gen())).toBeLessThan(EPS); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/data/__tests__/transitions.test.ts b/v1/apps/game/src/data/__tests__/transitions.test.ts new file mode 100644 index 0000000..4e75cab --- /dev/null +++ b/v1/apps/game/src/data/__tests__/transitions.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTransition, type TransitionCtx } from '../transitions'; +import type { TransitionDef } from '../locations'; + +const ctx: TransitionCtx = { + hasFlag: (f) => f === 'door_open', + hasItem: (i) => i === 'salt' +}; + +const at = (x: number, y: number) => ({ x, y }); + +describe('resolveTransition — выбор по тайлу/триггеру', () => { + const defs: TransitionDef[] = [ + { tile: at(2, 2), target: { kind: 'area', area: 'ponds', entry: at(3, 3) } }, + { tile: at(5, 5), label: 'Дверь', target: { kind: 'area', area: 'zvenets', entry: at(1, 1) }, trigger: 'click' }, + { tile: at(9, 6), target: { kind: 'return' }, trigger: 'click' } + ]; + + it('step-переход: наступил на тайл — сработал', () => { + const pick = resolveTransition(defs, at(2, 2), 'step', ctx); + expect(pick?.ok).toBe(true); + }); + + it('step не срабатывает на click-переходе того же тайла', () => { + expect(resolveTransition(defs, at(5, 5), 'step', ctx)).toBeNull(); + expect(resolveTransition(defs, at(5, 5), 'click', ctx)?.ok).toBe(true); + }); + + it('клик мимо переходов — null', () => { + expect(resolveTransition(defs, at(20, 20), 'click', ctx)).toBeNull(); + }); + + it('return-переход выбирается по kind цели, не по тайлу входа', () => { + const pick = resolveTransition(defs, at(9, 6), 'click', ctx); + expect(pick?.ok && pick.def.target.kind === 'return').toBe(true); + }); +}); + +describe('resolveTransition — условия и отказ', () => { + it('requiresFlag не выполнен — отказ с lockedText', () => { + const defs: TransitionDef[] = [ + { + tile: at(4, 4), + target: { kind: 'area', area: 'zvenets', entry: at(1, 1) }, + requiresFlag: 'gate_open', + lockedText: 'Заперто.' + } + ]; + const pick = resolveTransition(defs, at(4, 4), 'step', ctx); + expect(pick).toEqual({ ok: false, lockedText: 'Заперто.' }); + // Флаг поставлен — пускает. + expect(resolveTransition(defs, at(4, 4), 'step', { hasFlag: () => true, hasItem: () => false })?.ok).toBe(true); + }); + + it('requiresItem не выполнен — отказ; с предметом пускает', () => { + const defs: TransitionDef[] = [ + { + tile: at(4, 4), + target: { kind: 'area', area: 'zvenets', entry: at(1, 1) }, + requiresItem: 'salt', + lockedText: 'Нужна соль.' + } + ]; + expect(resolveTransition(defs, at(4, 4), 'step', { hasFlag: () => false, hasItem: () => false })).toEqual({ + ok: false, + lockedText: 'Нужна соль.' + }); + expect(resolveTransition(defs, at(4, 4), 'step', ctx)?.ok).toBe(true); + }); + + it('без lockedText — текст отказа по умолчанию', () => { + const pick = resolveTransition( + [{ tile: at(4, 4), target: { kind: 'area', area: 'zvenets', entry: at(1, 1) }, requiresFlag: 'x' }], + at(4, 4), + 'step', + ctx + ); + expect(pick?.ok).toBe(false); + expect(pick && !pick.ok && pick.lockedText.length > 0).toBe(true); + }); +}); + +describe('resolveTransition — дисарм входа', () => { + const defs: TransitionDef[] = [ + { tile: at(3, 3), target: { kind: 'area', area: 'meadows', entry: at(9, 9) } } + ]; + + it('step-переход на тайле входа игнорируется, пока герой не покинул его', () => { + expect(resolveTransition(defs, at(3, 3), 'step', ctx, at(3, 3))).toBeNull(); + }); + + it('после ухода с тайла входа переход снова работает', () => { + expect(resolveTransition(defs, at(3, 3), 'step', ctx, at(10, 10))?.ok).toBe(true); + expect(resolveTransition(defs, at(3, 3), 'step', ctx, null)?.ok).toBe(true); + }); + + it('дисарм не глушит click-переходы на том же тайле', () => { + const clickDefs: TransitionDef[] = [ + { tile: at(3, 3), target: { kind: 'return' }, trigger: 'click' } + ]; + expect(resolveTransition(clickDefs, at(3, 3), 'click', ctx, at(3, 3))?.ok).toBe(true); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/data/__tests__/validate.test.ts b/v1/apps/game/src/data/__tests__/validate.test.ts new file mode 100644 index 0000000..bc4115d --- /dev/null +++ b/v1/apps/game/src/data/__tests__/validate.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest'; +import type { DialogueGraph, TileMapData } from '@rpg/engine'; +import { + TILES, + buildMeadowsMap, + buildPondsMap, + buildZvenetsMap, + buildRustGroveMap, + buildOldSidingMap, + buildMachineHallMap +} from '../map'; +import { AREAS } from '../locations'; +import { ROOM_TONES } from '../sfxSpecs'; +import { NPCS } from '../npcs'; +import { DIALOGUES } from '../dialogues'; +import { FLAGS } from '../ids'; +import { validateContent, validateDialogue, validateAudio, validateLocations, validateNpcs, validateReferences } from '../validate'; + +/** Карта целиком из проходимой травы (или с одиночной стеной). */ +function flatMap(w: number, h: number, wall?: { x: number; y: number }): TileMapData { + const tiles = new Array(w * h).fill(TILES.GRASS); + if (wall) tiles[wall.y * w + wall.x] = TILES.HOUSE; + return { width: w, height: h, tiles, blocked: [TILES.WATER, TILES.TREE, TILES.TOWER, TILES.HOUSE] }; +} + +/** Карты всех локаций (как их грузит BootScene) — все области, не только акта 1. */ +function realMaps(): Map { + return new Map([ + ['meadows', buildMeadowsMap()], + ['ponds', buildPondsMap()], + ['zvenets', buildZvenetsMap()], + ['rust_grove', buildRustGroveMap()], + ['old_siding', buildOldSidingMap()], + ['machine_hall', buildMachineHallMap()] + ]); +} + +describe('validateContent — реальный контент чист', () => { + it('ошибок нет (warn допустим)', () => { + const errs = validateContent(realMaps()).filter((i) => i.severity === 'error'); + expect(errs).toEqual([]); + }); +}); + +describe('validateReferences — реестры флагов/варов', () => { + it('реальный контент: только флаг знакомств вне контента (dead), ошибок нет', () => { + const inv = validateReferences(); + expect(inv.filter((i) => i.severity === 'error')).toEqual([]); + // got_cloth ставится диалогом и нигде не читается, но ставится — упомянут. + // hint_bells ставится кодом сцены (подсказка прудов) — в контенте не упомянут. + const dead = inv.filter((i) => i.id === 'flag-dead').map((i) => i.message); + expect(dead).toEqual(['флаг «hint_bells» из реестра не упоминается в контенте']); + }); + + it('неизвестный флаг/вар в диалоге — error', () => { + const g: DialogueGraph = { start: 'a', nodes: { a: { text: 'a', setFlags: ['quest_bels_done'], whenVar: { key: 'moats', op: 'ge', value: 1 } } } }; + const backup = DIALOGUES['test_ref']; + (DIALOGUES as Record)['test_ref'] = g; + try { + const inv = validateReferences(); + expect(inv.some((i) => i.id === 'flag-unknown' && i.message.includes('quest_bels_done'))).toBe(true); + expect(inv.some((i) => i.id === 'var-unknown' && i.message.includes('moats'))).toBe(true); + } finally { + if (backup === undefined) delete (DIALOGUES as Record)['test_ref']; + else (DIALOGUES as Record)['test_ref'] = backup; + } + }); + + it('битая ссылка квеста на NPC/диалог — error', () => { + // Симулируем опечатку: подменяем NPCS/QUESTS нельзя (const), поэтому + // проверяем негатив через фейковый NPC-деф на уровне флага flagKey. + const broken = { ...NPCS[0]!, flagKey: 'metEllder' as keyof typeof FLAGS }; + const backup = NPCS[0]!; + (NPCS as unknown as { [0]: typeof broken })[0] = broken; + try { + const inv = validateReferences(); + expect(inv.some((i) => i.id === 'flag-unknown' && i.message.includes('metEllder'))).toBe(true); + } finally { + (NPCS as unknown as { [0]: typeof backup })[0] = backup; + } + }); +}); + +describe('validateDialogue', () => { + it('битый start и битый next — ошибки', () => { + const g: DialogueGraph = { + start: 'нет', + nodes: { + a: { text: 'a', next: 'нет2' }, + b: { text: 'b', choices: [{ text: 'x', next: 'нет3' }] } + } + }; + const ids = validateDialogue('test', g).map((i) => i.id); + expect(ids).toContain('dialogue-start'); + expect(ids).toContain('dialogue-next'); + }); + + it('узел-сирота — warn, связный граф чист', () => { + const ok: DialogueGraph = { start: 'a', nodes: { a: { text: 'a', next: 'b' }, b: { text: 'b', end: true } } }; + expect(validateDialogue('ok', ok)).toEqual([]); + const orphan: DialogueGraph = { start: 'a', nodes: { a: { text: 'a', end: true }, dead: { text: 'x', end: true } } }; + expect(validateDialogue('orphan', orphan).map((i) => i.id)).toEqual(['dialogue-orphan']); + }); +}); + +describe('validateNpcs', () => { + it('проходимая карта без NPC в стенах — чисто; стена под NPC ловится', () => { + const maps = new Map([['zvenets', flatMap(56, 40)]]); + expect(validateNpcs(maps)).toEqual([]); + // NPCS статичен — подменяем карту: стена на тайле Ирвина даст 'in-wall'. + const elder = AREAS.zvenets.npcs[0]!; + const walled = new Map([['zvenets', flatMap(28, 20, { x: elder.tile.x, y: elder.tile.y })]]); + const inv = validateNpcs(walled); + expect(inv.some((i) => i.id === 'in-wall' && i.message.includes(elder.id))).toBe(true); + }); +}); + +describe('validateLocations', () => { + it('переход в несуществующую область — ошибки target нет, guard на карту', () => { + const maps = new Map([['meadows', flatMap(28, 28)]]); + // AREAS статичен; проверяем чистую функцию через реальные данные: + // meadows -> ponds/zvenets существуют, но их карт в maps нет — + // guard на отсутствующую карту не роняет проверку. + const inv = validateLocations(maps); + expect(inv.every((i) => i.severity === 'warn' || i.where?.includes('meadows'))).toBe(true); + }); + + it('спавн в стене ловится', () => { + const spawn = AREAS.meadows.spawn; + const maps = new Map([['meadows', flatMap(28, 28, spawn)]]); + const inv = validateLocations(maps); + expect(inv.some((i) => i.id === 'in-wall' && i.message.includes('spawn'))).toBe(true); + }); +}); +describe('validateAudio — аудио-ключи контента', () => { + it('неизвестный ambience области — error', () => { + const backup = AREAS.meadows.ambience; + (AREAS.meadows as { ambience?: string }).ambience = 'ambience/tundra'; + try { + const inv = validateAudio(); + expect(inv.some((i) => i.id === 'audio-key' && i.message.includes('ambience/tundra'))).toBe(true); + } finally { + (AREAS.meadows as { ambience?: string }).ambience = backup; + } + }); + + it('sfx-ключ реакции вне спек-реестра — error', () => { + const area = AREAS.meadows; + const backup = area.interactables; + (area as { interactables?: unknown }).interactables = [ + { id: 'x', kind: 'signpost', tile: { x: 1, y: 1 }, responses: [{ sound: 'sfx/nats' }] } + ]; + try { + const inv = validateAudio(); + expect(inv.some((i) => i.id === 'audio-key' && i.message.includes('sfx/nats'))).toBe(true); + } finally { + (area as { interactables?: unknown }).interactables = backup; + } + }); + + it('roomtone с ключом-не-областью — error; реальные контент чист', () => { + (ROOM_TONES as Record).cave = { kind: 'hum', tone: 40, dur: 4, power: 2, peak: 0.2 }; + try { + const inv = validateAudio(); + expect(inv.some((i) => i.id === 'audio-key' && i.message.includes('cave'))).toBe(true); + } finally { + delete (ROOM_TONES as Record).cave; + } + expect(validateAudio().filter((i) => i.severity === 'error')).toEqual([]); + }); +}); + +describe('validateReferences — requiresItem и return-правило интерьеров', () => { + it('переход с неизвестным requiresItem — error', () => { + const t = AREAS.ponds.transitions[0]!; + const backup = t.requiresItem; + (t as { requiresItem?: string }).requiresItem = 'rope'; + try { + const inv = validateReferences(); + expect(inv.some((i) => i.id === 'item-unknown' && i.message.includes('rope'))).toBe(true); + } finally { + (t as { requiresItem?: string }).requiresItem = backup; + } + }); + + it('накат с неизвестным requiresItem — error', () => { + const h = AREAS.ponds.hazards![0]!; + const backup = h.requiresItem; + (h as { requiresItem?: string }).requiresItem = 'mask'; + try { + const inv = validateReferences(); + expect(inv.some((i) => i.id === 'item-unknown' && i.message.includes('mask'))).toBe(true); + } finally { + (h as { requiresItem?: string }).requiresItem = backup; + } + }); + + it('неизвестный whenFlag источника света — error', () => { + const s = AREAS.zvenets.lighting!.sources![0]!; + const backup = s.whenFlag; + (s as { whenFlag?: string }).whenFlag = 'evening_yes'; + try { + const inv = validateReferences(); + expect(inv.some((i) => i.id === 'flag-unknown' && i.message.includes('evening_yes'))).toBe(true); + } finally { + (s as { whenFlag?: string }).whenFlag = backup; + } + }); + + it('интерьер без return-перехода — error', () => { + const area = AREAS.shop; + const backup = area.transitions; + (area as { transitions: typeof backup }).transitions = []; + try { + const inv = validateReferences(); + expect(inv.some((i) => i.id === 'interior-return' && i.where?.includes('shop'))).toBe(true); + } finally { + (area as { transitions: typeof backup }).transitions = backup; + } + }); +}); + +describe('validateLocations — патрули и источники света', () => { + it('точка патруля вне карты — out-of-bounds', () => { + const e = AREAS.meadows.enemies[2]!; + const backup = e.patrol; + (e as { patrol?: typeof backup }).patrol = { points: [{ x: 19, y: 6 }, { x: 500, y: 0 }], pauseSec: 1 }; + try { + const inv = validateLocations(realMaps()); + expect(inv.some((i) => i.id === 'out-of-bounds' && i.message.includes('патруль#1'))).toBe(true); + } finally { + (e as { patrol?: typeof backup }).patrol = backup; + } + }); + + it('дубль id источника света и источник без at/tiles — error', () => { + const area = AREAS.shop; + const backup = area.lighting; + (area as { lighting?: typeof backup }).lighting = { + ambient: 0x5a5e70, + sources: [ + { id: 'a', at: { x: 1.5, y: 1.5 } }, + { id: 'a', at: { x: 2.5, y: 2.5 } }, + { id: 'b' } + ] + }; + try { + const inv = validateLocations(new Map([['shop', flatMap(10, 10)]])); + expect(inv.filter((i) => i.id === 'light-id').length).toBe(1); + expect(inv.some((i) => i.id === 'light-source-empty' && i.message.includes('b'))).toBe(true); + } finally { + (area as { lighting?: typeof backup }).lighting = backup; + } + }); +}); diff --git a/v1/apps/game/src/data/assetSets.ts b/v1/apps/game/src/data/assetSets.ts new file mode 100644 index 0000000..ae24885 --- /dev/null +++ b/v1/apps/game/src/data/assetSets.ts @@ -0,0 +1,70 @@ +import type { AssetLoader, Texture } from '@rpg/engine'; +import { DECOR, TILES } from './map'; +import { ENEMY_KINDS, type EnemyKindId } from './enemies'; +import type { HeroTextures } from '../systems/PlayerController'; + +/** + * Наборы текстур сцены из загруженных ассетов: соответствие «данные ↔ вьюхи» + * живёт здесь, а не в LocationScene. Имена файлов фиксированы арт-библией. + */ + +/** Текстуры тайлов из загруженных ассетов (id -> Texture) — для IsometricTileMap. */ +export function tileTextures(a: AssetLoader): Map { + return new Map([ + [TILES.GRASS, a.texture('tiles/grass')], + [TILES.GRASS_V1, a.texture('tiles/grass_2')], + [TILES.GRASS_V2, a.texture('tiles/grass_3')], + [TILES.PATH, a.texture('tiles/path')], + [TILES.PATH_V1, a.texture('tiles/path_2')], + [TILES.WATER, a.texture('tiles/water_1')], + [TILES.ASH, a.texture('tiles/ash')], + [TILES.ASH_V1, a.texture('tiles/ash_2')], + [DECOR.TUFT, a.texture('tiles/decor_tuft')], + [DECOR.STONE, a.texture('tiles/decor_stone')], + [DECOR.BALD, a.texture('tiles/decor_bald')], + [TILES.BELLFLOWER, a.texture('tiles/bellflower')], + [TILES.TREE, a.texture('tiles/tree')], + [TILES.TREE_V1, a.texture('tiles/tree_2')], + [TILES.TREE_V2, a.texture('tiles/tree_3')], + [TILES.TOWER, a.texture('tiles/tower')], + [TILES.HOUSE, a.texture('tiles/house')], + [TILES.HOUSE_V1, a.texture('tiles/house_2')], + [TILES.HOUSE_V2, a.texture('tiles/house_3')], + [TILES.HOUSE_V3, a.texture('tiles/house_4')], + [TILES.HOUSE_V4, a.texture('tiles/house_5')], + [TILES.HOUSE_BIG, a.texture('tiles/house_big')], + [TILES.BOULDER, a.texture('tiles/boulder')], + [TILES.BOULDER_2, a.texture('tiles/boulder_2')], + [TILES.BOULDER_3, a.texture('tiles/boulder_3')], + [TILES.BUSH, a.texture('tiles/bush')], + [TILES.BUSH_2, a.texture('tiles/bush_2')], + [TILES.BUSH_3, a.texture('tiles/bush_3')], + [TILES.FLOOR, a.texture('tiles/floor')], + [TILES.WALL, a.texture('tiles/wall')], + [TILES.WELL, a.texture('tiles/well')], + // Акт 2: роща и разъезд. + [TILES.WIRE, a.texture('tiles/wire')], + [TILES.RAIL, a.texture('tiles/rail')], + [TILES.STAIRS, a.texture('tiles/stairs')], + [TILES.MACHINE, a.texture('tiles/machine')] + ]); +} + +/** Кадры сгустков из атласа chars/clumps_sheet: префиксы — из EnemyKindDef.frames. */ +export function enemyTextures(a: AssetLoader): Map { + const out = new Map(); + for (const kind of Object.values(ENEMY_KINDS)) { + const prefix = kind.frames[0]!.replace(/_\d+$/, ''); // clump_crawler_1 -> clump_crawler + out.set(kind.id, a.frames('chars/clumps_sheet.json', prefix) as [Texture, Texture]); + } + return out; +} + +/** Кадры героя из атласа chars/hero_sheet (именованные анимации генератора). */ +export function heroTextures(a: AssetLoader): HeroTextures { + return { + down: a.animation('chars/hero_sheet.json', 'hero_walk_down') as [Texture, Texture], + up: a.animation('chars/hero_sheet.json', 'hero_walk_up') as [Texture, Texture], + side: a.animation('chars/hero_sheet.json', 'hero_walk_side') as [Texture, Texture] + }; +} \ No newline at end of file diff --git a/v1/apps/game/src/data/audio.ts b/v1/apps/game/src/data/audio.ts new file mode 100644 index 0000000..6bec064 --- /dev/null +++ b/v1/apps/game/src/data/audio.ts @@ -0,0 +1,45 @@ +/** + * Ключи аудио: спек-синтез sfx (sfxSpecs.ts, WAV не нужен) и файловые лупы + * (ambience из tools/audio/gen.mjs). Отдельный модуль (без `?url`-импортов + * Game) — чтобы валидатор мог сверять ключи контента с реестром в node-тестах. + */ +import { SPEC_SFX } from './sfxSpecs'; + +/** Спек-синтезированные sfx (без WAV, компилирует движок). */ +export const SPEC_SFX_KEYS: readonly string[] = Object.keys(SPEC_SFX); + +/** Файловые sfx (WAV) — пусто: все sfx переведены на спек-синтез. */ +export const SFX_KEYS: readonly string[] = []; + +/** Амбиент-лупы областей (день + ночь: кроссфейд по GameClock). Луга — без амбиента. */ +export const AMBIENCE_KEYS = [ + 'ambience/ponds', + 'ambience/zvenets', + 'ambience/ponds_night', + 'ambience/zvenets_night', + 'ambience/rust_grove', + 'ambience/old_siding' +] as const; + +/** Локальные амбиент-слои (вода, гул, дома) и глобальные (ветер). */ +export const AMBIENCE_LAYER_KEYS = ['ambience/ponds_water', 'ambience/tower_hum', 'ambience/houses', 'ambience/wind'] as const; + +/** Все аудио-ключи игры (валидация контента; префетч только файловых). */ +export const AUDIO_KEYS: readonly string[] = [ + ...SFX_KEYS, + ...SPEC_SFX_KEYS, + ...AMBIENCE_KEYS, + ...AMBIENCE_LAYER_KEYS +]; + +/** + * Файловые ключи аудио области (ambience + локальные слои) — для ленивого + * префетча: сцена загрузки декодирует только стартовую область, остальные + * декодируются при входе (playAmbience/playLoop умеют грузить на месте). + */ +export function areaAudioKeys(area: { + ambience?: string; + ambienceLayers?: readonly { key: string }[]; +}): string[] { + return [area.ambience ?? '', ...(area.ambienceLayers ?? []).map((l) => l.key)].filter((k) => k.length > 0); +} \ No newline at end of file diff --git a/v1/apps/game/src/data/dialogueRules.ts b/v1/apps/game/src/data/dialogueRules.ts new file mode 100644 index 0000000..b52caf6 --- /dev/null +++ b/v1/apps/game/src/data/dialogueRules.ts @@ -0,0 +1,178 @@ +import type { DialogueChoice, DialogueGraph, DialogueNode, Invariant } from '@rpg/engine'; + +/** + * Чистые правила диалоговых графов: реестры — параметры (не импорты), поэтому + * один и тот же checkGraph работает в validate.ts (юнит-тесты, agent:invariants), + * в CLI dry-run и в сервере визуального редактора. Истина одна. + */ + +export interface GraphRefs { + /** Допустимые id: флаги, вары, предметы, custom-эффекты, диалоги, строки. */ + flags: ReadonlySet; + vars: ReadonlySet; + items: ReadonlySet; + customs: ReadonlySet; + strings: ReadonlySet; + /** Куда записать упоминания (для «мёртвых» сущностей реестра) — опционально. */ + usedFlags?: Set; + usedVars?: Set; +} + +/** Результат обхода графа: кто достижим и где подозрительные места. */ +export interface Reachability { + /** Достижимые от start узлы (BFS по next/choices). */ + reachable: string[]; + /** Узлы, в которые нет ни одной ссылки. */ + orphans: string[]; + /** Циклы из узлов без текста и без выборов (раннер обрывает по MAX_STEPS). */ + textlessCycles: string[][]; + /** Достижимые концы без реплики (диалог закончится молча). */ + silentEnds: string[]; +} + +/** Ссылки узла/выбора, которые надо сверить с реестрами. */ +function refsOf(n: DialogueNode | DialogueChoice): { + flags: string[]; + vars: string[]; + items: string[]; + doOps: NonNullable; + textKey?: string; +} { + return { + flags: [...(n.when ?? []), ...(n.whenNot ?? []), ...(n.setFlags ?? []), ...(n.clearFlags ?? [])], + vars: [ + ...(n.whenVar ? [n.whenVar.key] : []), + ...(n.whenVars ?? []).map((c) => c.key), + ...Object.keys(n.setVars ?? {}) + ], + items: [...(n.hasItem ?? [])], + doOps: n.do ?? [], + textKey: n.textKey + }; +} + +function checkSpot( + out: Invariant[], + where: string, + refs: GraphRefs, + spot: string, + n: DialogueNode | DialogueChoice, + next: string | undefined, + graph: DialogueGraph +): void { + const r = refsOf(n); + for (const f of r.flags) { + if (!refs.flags.has(f)) out.push({ id: 'flag-unknown', severity: 'error', message: `${spot}: флаг «${f}» вне реестра FLAGS`, where }); + refs.usedFlags?.add(f); + } + for (const v of r.vars) { + if (!refs.vars.has(v)) out.push({ id: 'var-unknown', severity: 'error', message: `${spot}: вар «${v}» вне реестра VARS`, where }); + refs.usedVars?.add(v); + } + for (const it of r.items) { + if (!refs.items.has(it)) out.push({ id: 'item-unknown', severity: 'error', message: `${spot}: предмет «${it}» вне реестра ITEMS`, where }); + } + for (const op of r.doOps) { + if ((op.kind === 'giveItem' || op.kind === 'takeItem') && (op.id === undefined || !refs.items.has(op.id))) { + out.push({ id: 'do-item-unknown', severity: 'error', message: `${spot}: do[].${op.kind} — предмет «${op.id}» вне реестра ITEMS`, where }); + } + if (op.kind === 'custom' && (op.id === undefined || !refs.customs.has(op.id))) { + out.push({ id: 'do-custom-unknown', severity: 'error', message: `${spot}: do[].custom — имя «${op.id}» вне реестра DIALOGUE_CUSTOM`, where }); + } + } + if (r.textKey !== undefined && !refs.strings.has(r.textKey)) { + out.push({ id: 'string-unknown', severity: 'error', message: `${spot}: textKey «${r.textKey}» вне реестра строк`, where }); + } + if (next !== undefined && !graph.nodes[next]) { + out.push({ id: 'dialogue-next', severity: 'error', message: `${spot}: next «${next}» не существует`, where }); + } +} + +/** Все правила одного графа: ссылки, next, сироты, циклы, тихие концы. */ +export function checkGraph(id: string, graph: DialogueGraph, refs: GraphRefs): Invariant[] { + const out: Invariant[] = []; + const where = `data/dialogue/${id}`; + if (!graph.nodes[graph.start]) { + out.push({ id: 'dialogue-start', severity: 'error', message: `start «${graph.start}» не существует`, where }); + } + for (const [nid, node] of Object.entries(graph.nodes)) { + checkSpot(out, where, refs, `узел «${nid}»`, node, node.next, graph); + (node.choices ?? []).forEach((c, i) => { + checkSpot(out, where, refs, `узел «${nid}», выбор#${i}`, c, c.next, graph); + }); + } + const a = analyzeGraph(graph); + for (const o of a.orphans) { + out.push({ id: 'dialogue-orphan', severity: 'warn', message: `узел «${o}» недостижим`, where }); + } + for (const cycle of a.textlessCycles) { + out.push({ id: 'textless-cycle', severity: 'error', message: `цикл без текста: ${cycle.join(' → ')}`, where }); + } + for (const end of a.silentEnds) { + out.push({ id: 'silent-end', severity: 'warn', message: `диалог может кончиться молча в узле «${end}»`, where }); + } + return out; +} + +/** Исходящие рёбра узла (next + next выборов). */ +function edgesOf(graph: DialogueGraph, id: string): string[] { + const n = graph.nodes[id]; + if (!n) return []; + const out = n.next !== undefined ? [n.next] : []; + for (const c of n.choices ?? []) { + if (c.next !== undefined) out.push(c.next); + } + return out; +} + +/** Обход графа от start: достижимость, сироты, текстовые циклы, тихие концы. */ +export function analyzeGraph(graph: DialogueGraph): Reachability { + const reachable = new Set(); + if (graph.nodes[graph.start]) { + const queue = [graph.start]; + while (queue.length > 0) { + const id = queue.shift()!; + if (reachable.has(id)) continue; + reachable.add(id); + for (const next of edgesOf(graph, id)) { + if (graph.nodes[next] && !reachable.has(next)) queue.push(next); + } + } + } + const orphans = Object.keys(graph.nodes).filter((id) => !reachable.has(id)); + + // Циклы без текста: DFS с окраской; цикл собираем из стека. + const color = new Map(); // 1 — в стеке, 2 — готово + const stack: string[] = []; + const cycles: string[][] = []; + const visit = (id: string): void => { + color.set(id, 1); + stack.push(id); + for (const next of edgesOf(graph, id)) { + if (!graph.nodes[next]) continue; + const c = color.get(next); + if (c === 1) { + const cycle = stack.slice(stack.indexOf(next)); + const textless = cycle.every((cid) => { + const n = graph.nodes[cid]!; + return n.text === undefined && n.textKey === undefined && (n.choices?.length ?? 0) === 0; + }); + if (textless) cycles.push(cycle); + } else if (c === undefined) { + visit(next); + } + } + stack.pop(); + color.set(id, 2); + }; + for (const id of reachable) { + if (!color.has(id)) visit(id); + } + + const silentEnds = [...reachable].filter((id) => { + const n = graph.nodes[id]!; + return n.next === undefined && (n.choices?.length ?? 0) === 0 && n.text === undefined && n.textKey === undefined; + }); + + return { reachable: [...reachable], orphans, textlessCycles: cycles, silentEnds }; +} \ No newline at end of file diff --git a/v1/apps/game/src/data/dialogues.ts b/v1/apps/game/src/data/dialogues.ts new file mode 100644 index 0000000..c9860f8 --- /dev/null +++ b/v1/apps/game/src/data/dialogues.ts @@ -0,0 +1,29 @@ +import type { DialogueGraph } from '@rpg/engine'; +import elderFirst from './dialogues/elder_first.json'; +import elderRepeat from './dialogues/elder_repeat.json'; +import elderHandIn from './dialogues/elder_hand_in.json'; +import traderFirst from './dialogues/trader_first.json'; +import traderRepeat from './dialogues/trader_repeat.json'; +import traderAfter from './dialogues/trader_after.json'; +import clintFirst from './dialogues/clint_first.json'; +import clintRepeat from './dialogues/clint_repeat.json'; +import clintTune from './dialogues/clint_tune.json'; + +/** + * Диалоги NPC как графы (по docs/world.md, акты 1–2). Источник истины — JSON + * в data/dialogues/ (формат 4 пробела + \n): их правит и визуальный редактор, + * и человек руками. Флаги/вары/предметы — строками, сверяются с реестрами + * validate.ts (checkGraph). Мила — безгласная: шёпотом, коротко. + * Ирвин — экономит дыхание. Клинт — ворчит и чинит. + */ +export const DIALOGUES: Record = { + elder_first: elderFirst as unknown as DialogueGraph, + elder_repeat: elderRepeat as unknown as DialogueGraph, + elder_hand_in: elderHandIn as unknown as DialogueGraph, + trader_first: traderFirst as unknown as DialogueGraph, + trader_repeat: traderRepeat as unknown as DialogueGraph, + trader_after: traderAfter as unknown as DialogueGraph, + clint_first: clintFirst as unknown as DialogueGraph, + clint_repeat: clintRepeat as unknown as DialogueGraph, + clint_tune: clintTune as unknown as DialogueGraph +}; \ No newline at end of file diff --git a/v1/apps/game/src/data/dialogues/clint_first.json b/v1/apps/game/src/data/dialogues/clint_first.json new file mode 100644 index 0000000..0179533 --- /dev/null +++ b/v1/apps/game/src/data/dialogues/clint_first.json @@ -0,0 +1,36 @@ +{ + "start": "grunt", + "nodes": { + "grunt": { + "speaker": "Клинт", + "text": "*не поднимая головы* Не звенит. Ржавый весь. Уходи.", + "next": "squint" + }, + "squint": { + "speaker": "Клинт", + "text": "*прищуривается* Звонарь?... От Ирвина? Того, что книгу ведёт?", + "next": "half_tone" + }, + "half_tone": { + "speaker": "Клинт", + "text": "Скажи ему: колокол башни — на пол-тона ниже, чем в книге. Книге веры нет, если уши молчат.", + "next": "work" + }, + "work": { + "speaker": "Клинт", + "text": "А у меня тут своя беда: резонатор ржавый стоит. Молчит, скотина, третий месяц.", + "setFlags": ["met_clint"], + "next": "offer" + }, + "offer": { + "speaker": "Клинт", + "text": "Деревья в Ржавой роще — за восточной околицей, — как механизмы, кабелями оплетены. Прозвени их: кто отзовётся — тот и тон даст.", + "setFlags": ["quest_resonator_taken"], + "next": "reply" + }, + "reply": { + "speaker": "Звонарь", + "text": "*кивает*" + } + } +} \ No newline at end of file diff --git a/v1/apps/game/src/data/dialogues/clint_repeat.json b/v1/apps/game/src/data/dialogues/clint_repeat.json new file mode 100644 index 0000000..c2694d8 --- /dev/null +++ b/v1/apps/game/src/data/dialogues/clint_repeat.json @@ -0,0 +1,21 @@ +{ + "start": "grumble", + "nodes": { + "grumble": { + "speaker": "Клинт", + "text": "Опять ты. Хорошо. Слушай: у Машины есть голос — низкий, через пепел. Все её боятся, а зря.", + "setFlags": ["machine_hook"], + "next": "hint" + }, + "hint": { + "speaker": "Клинт", + "text": "Дышит она в такт. Кто найдёт верный звон — уложит спать, как ребёнка.", + "next": "half_tone" + }, + "half_tone": { + "speaker": "Клинт", + "text": "И про пол-тона не забудь. Книга врёт, уши — нет.", + "end": true + } + } +} \ No newline at end of file diff --git a/v1/apps/game/src/data/dialogues/clint_tune.json b/v1/apps/game/src/data/dialogues/clint_tune.json new file mode 100644 index 0000000..e0a64fc --- /dev/null +++ b/v1/apps/game/src/data/dialogues/clint_tune.json @@ -0,0 +1,19 @@ +{ + "start": "count", + "nodes": { + "count": { + "speaker": "Клинт", + "text": "Три тона, слышишь? Соберёшь — поддел резонатор под них. Мои руки уже не слышат.", + "next": "accept" + }, + "accept": { + "setFlags": ["quest_resonator_done"], + "do": [{ "kind": "custom", "id": "tune_resonator" }], + "next": "ring" + }, + "ring": { + "speaker": "Звонарь", + "text": "Пусть гудит. Пол-тона — тоже тон." + } + } +} \ No newline at end of file diff --git a/v1/apps/game/src/data/dialogues/elder_first.json b/v1/apps/game/src/data/dialogues/elder_first.json new file mode 100644 index 0000000..7fddc97 --- /dev/null +++ b/v1/apps/game/src/data/dialogues/elder_first.json @@ -0,0 +1,36 @@ +{ + "start": "greet", + "nodes": { + "greet": { + "speaker": "Старейшина Ирвин", + "text": "Вернулся. Хорошо. Слышал гул на закате? Это пепел дышит у прудов.", + "next": "ask" + }, + "ask": { + "speaker": "Старейшина Ирвин", + "text": "Три лунных колокольчика цветут у Серых прудов — за северо-западным краем лугов, у старого колодца. Если не собрать до выдоха — задохнутся.", + "next": "task" + }, + "task": { + "speaker": "Старейшина Ирвин", + "text": "Собери. Посади здесь, на лугу. Поляна без цветов — поляна без завтра.", + "choices": [ + { + "text": "Прозвоню дорогу до прудов и вернусь до темноты.", + "next": "give" + } + ] + }, + "give": { + "setFlags": ["met_elder", "quest_bells_taken"], + "do": [{ "kind": "giveItem", "id": "clock" }, { "kind": "giveItem", "id": "map_scroll" }], + "speaker": "Старейшина Ирвин", + "text": "Держи старые медные часы — стрелка не врёт, темнота по ней приходит. И набросок окрестностей: я чертил его для звонарей. Тропы помнит, дома помнит.", + "next": "ring" + }, + "ring": { + "speaker": "Звонарь", + "text": "Прозвоню дорогу до прудов и вернусь до темноты." + } + } +} \ No newline at end of file diff --git a/v1/apps/game/src/data/dialogues/elder_hand_in.json b/v1/apps/game/src/data/dialogues/elder_hand_in.json new file mode 100644 index 0000000..8b5a334 --- /dev/null +++ b/v1/apps/game/src/data/dialogues/elder_hand_in.json @@ -0,0 +1,19 @@ +{ + "start": "count", + "nodes": { + "count": { + "speaker": "Старейшина Ирвин", + "text": "Три цветка. Живые. Сажай у тропы здесь, в Звенце. Серая земля примет.", + "next": "accept" + }, + "accept": { + "setFlags": ["quest_bells_done"], + "do": [{ "kind": "custom", "id": "plant_flowers" }], + "next": "ring" + }, + "ring": { + "speaker": "Звонарь", + "text": "Пусть гудят. Это твой голос, Ирвин, — теперь в земле." + } + } +} \ No newline at end of file diff --git a/v1/apps/game/src/data/dialogues/elder_repeat.json b/v1/apps/game/src/data/dialogues/elder_repeat.json new file mode 100644 index 0000000..f0b962e --- /dev/null +++ b/v1/apps/game/src/data/dialogues/elder_repeat.json @@ -0,0 +1,9 @@ +{ + "start": "waiting", + "nodes": { + "waiting": { + "speaker": "Старейшина Ирвин", + "text": "Цветы ждут у прудов, звонарь. А пепел не ждёт." + } + } +} \ No newline at end of file diff --git a/v1/apps/game/src/data/dialogues/trader_after.json b/v1/apps/game/src/data/dialogues/trader_after.json new file mode 100644 index 0000000..9943f91 --- /dev/null +++ b/v1/apps/game/src/data/dialogues/trader_after.json @@ -0,0 +1,11 @@ +{ + "start": "listen", + "nodes": { + "listen": { + "speaker": "Торговка Мила", + "text": "*смотрит под ноги* ...Слышишь? Гул снизу. Это разъезд. Это Машина дышит.", + "setFlags": ["act2_hook"], + "end": true + } + } +} \ No newline at end of file diff --git a/v1/apps/game/src/data/dialogues/trader_first.json b/v1/apps/game/src/data/dialogues/trader_first.json new file mode 100644 index 0000000..fff3661 --- /dev/null +++ b/v1/apps/game/src/data/dialogues/trader_first.json @@ -0,0 +1,29 @@ +{ + "start": "stop", + "nodes": { + "stop": { + "speaker": "Торговка Мила", + "text": "*звонит ручным колокольчиком дважды* ...Стой.", + "next": "warn" + }, + "warn": { + "speaker": "Торговка Мила", + "text": "У прудов... свежий накат. Дышать поверх — голос отдашь. Как я отдала.", + "next": "gift" + }, + "gift": { + "speaker": "Торговка Мила", + "text": "Держи вощёное полотно. На губы. И звони тихо — пепел не буди.", + "next": "give_cloth" + }, + "give_cloth": { + "setFlags": ["met_mila", "got_cloth"], + "do": [{ "kind": "giveItem", "id": "cloth" }], + "next": "reply" + }, + "reply": { + "speaker": "Звонарь", + "text": "Спасибо, Мила. Верну и полотно, и голос — твой точно." + } + } +} \ No newline at end of file diff --git a/v1/apps/game/src/data/dialogues/trader_repeat.json b/v1/apps/game/src/data/dialogues/trader_repeat.json new file mode 100644 index 0000000..1e6ce5c --- /dev/null +++ b/v1/apps/game/src/data/dialogues/trader_repeat.json @@ -0,0 +1,15 @@ +{ + "start": "quiet", + "nodes": { + "quiet": { + "speaker": "Торговка Мила", + "text": "*колокольчик один раз* ...Звони тихо. Пепел просыпается от гулкого.", + "next": "motes_note" + }, + "motes_note": { + "speaker": "Торговка Мила", + "text": "*замечает искру в сумке* ...Моты? Серая земля с искрой — редкость. Принеси три — поменяю на соль.", + "whenVar": { "key": "motes", "op": "ge", "value": 1 } + } + } +} \ No newline at end of file diff --git a/v1/apps/game/src/data/effects.ts b/v1/apps/game/src/data/effects.ts new file mode 100644 index 0000000..08e7463 --- /dev/null +++ b/v1/apps/game/src/data/effects.ts @@ -0,0 +1,12 @@ +/** + * Реестр имён сюжетных эффектов диалогов (как FLAGS/VARS — валидатор ловит + * опечатки). Имена сцена реализует в своём EffectSink; give/take/sound/toast + * — общие и сюда не входят. + */ +export const DIALOGUE_CUSTOM = { + plant_flowers: 'plant_flowers', // сдача «Трёх цветков»: кат-сцена посадки + tune_resonator: 'tune_resonator', // сдача резонатора: кат-сцена настройки + lull_machine: 'lull_machine' // Старая Машина: кат-сцена правильного звона +} as const; + +export type DialogueCustomId = keyof typeof DIALOGUE_CUSTOM; \ No newline at end of file diff --git a/v1/apps/game/src/data/enemies.ts b/v1/apps/game/src/data/enemies.ts new file mode 100644 index 0000000..3563e50 --- /dev/null +++ b/v1/apps/game/src/data/enemies.ts @@ -0,0 +1,119 @@ +/** + * Враги демо-боя: «пепельные сгустки» — оживший пепел, просыпается от гулкого звука. + * Чистые данные (без Pixi) — используются боевой системой и тестами. + */ + +export type EnemyKindId = 'crawler' | 'spitter' | 'heavy'; + +export interface EnemyKindDef { + id: EnemyKindId; + /** Здоровье. */ + hp: number; + /** Скорость, мировых юнитов/сек (1 юнит = 1 тайл). */ + speed: number; + /** Радиус тела для попаданий, юнитов. */ + radius: number; + /** Дистанция начала атаки, юнитов. */ + attackRange: number; + /** Урон атаки. */ + attackDamage: number; + /** Пауза между атаками, сек. */ + attackCooldown: number; + /** Замах до удара, сек (у ползуна 0 — контактный урон). */ + windup: number; + /** Восстановление после удара, сек. */ + recover: number; + /** Плевун держит дистанцию (юнитов) — отходит при сближении. */ + keepDistance?: number; + /** Дроп «пепельной пыли». */ + motes: number; + /** Кадры из атласа chars/clumps_sheet.json. */ + frames: [string, string]; + // --- ИИ (необязательные: withDefaults подставляет разумные значения) --- + + /** Дистанция обнаружения героя (при прямой видимости), юнитов. */ + aggroRange?: number; + /** Секунд без прямой видимости, после которых враг отступает к дому. */ + deaggroSec?: number; + /** Ниже этой доли hp — отступает (0 — не отступает никогда). */ + fleeBelowHpFraction?: number; + /** При отступлении убегает, пока герой ближе этого радиуса, юнитов. */ + fleeRange?: number; + /** Радиус слуха шума (уровень >= 0.35), юнитов. */ + hearRadius?: number; + /** Секунд настороженности без новой информации, после которых враг возвращается. */ + warySec?: number; +} + +/** Патруль отдельного спавна: тайлы маршрута (проходится туда-обратно) и пауза на точке. */ +export interface EnemyPatrolDef { + points: { x: number; y: number }[]; + pauseSec: number; +} + +/** Дефолты полей ИИ (контент задаёт только отличия). */ +export function withDefaults(kind: EnemyKindDef): EnemyKindDef { + return { + ...kind, + aggroRange: kind.aggroRange ?? 4, + deaggroSec: kind.deaggroSec ?? 2.5, + fleeBelowHpFraction: kind.fleeBelowHpFraction ?? 0, + fleeRange: kind.fleeRange ?? 4, + hearRadius: kind.hearRadius ?? 5, + warySec: kind.warySec ?? 2.5 + }; +} + +export const ENEMY_KINDS: Record = { + crawler: { + id: 'crawler', + hp: 3, + speed: 0.7, + radius: 0.2, + attackRange: 0.4, + attackDamage: 1, + attackCooldown: 0.8, + windup: 0, + recover: 0.3, + motes: 1, + frames: ['clump_crawler_1', 'clump_crawler_2'], + // Сторожит пепел, шатаясь между кустов; слышит шаги вблизи. + hearRadius: 3.5 + }, + spitter: { + id: 'spitter', + hp: 2, + speed: 0.55, + radius: 0.2, + attackRange: 3.0, + attackDamage: 1, + attackCooldown: 2.2, + windup: 0.35, + recover: 0.4, + keepDistance: 2.25, + motes: 1, + frames: ['clump_spitter_1', 'clump_spitter_2'], + // Обстреливает издали и не отступает — стоит насмерть. + aggroRange: 5 + }, + heavy: { + id: 'heavy', + hp: 8, + speed: 0.45, + radius: 0.3, + attackRange: 0.8, + attackDamage: 2, + attackCooldown: 2, + windup: 0.6, + recover: 0.5, + motes: 3, + frames: ['clump_heavy_1', 'clump_heavy_2'], + // Крупный сгусток: глуховат, но с трудом убитый — отступает при малом hp. + hearRadius: 4, + fleeBelowHpFraction: 0.35, + fleeRange: 4.5, + warySec: 3 + } +}; + +/** Точки спавна и патрули врагов задаются в data/locations.ts (AREAS[*].enemies). */ diff --git a/v1/apps/game/src/data/ids.ts b/v1/apps/game/src/data/ids.ts new file mode 100644 index 0000000..5ce34dd --- /dev/null +++ b/v1/apps/game/src/data/ids.ts @@ -0,0 +1,76 @@ +/** + * Реестры строковых сущностей сюжета: флаги и вары GameState. + * Единственная точка правды: контент (диалоги, интерактивы, квесты, переходы) + * и код сцены ссылаются на них через константы, а data/validate.ts сверяет + * каждое строковое упоминание с реестром — опечатка падает в тестах и + * agent:invariants, а не молча создаёт новый флаг. + */ + +/** + * Флаги сюжета: ключ = значение = id (строка в GameState/сейве). + * Знакомства ставятся самими графами диалогов (dialogueFirst), а не кодом + * сцены; старые ключи metElder/metTrader мигрируют (data/migrations.ts). + */ +export const FLAGS = { + /** Познакомились со старейшиной (диалог elder_first). */ + met_elder: 'met_elder', + /** Ирвин дал задание о колокольчиках (диалог elder_first; doneFlag стадии 1). */ + quest_bells_taken: 'quest_bells_taken', + /** Колокольчики собраны и посажены (диалог elder_hand_in; doneFlag стадии 2). */ + quest_bells_done: 'quest_bells_done', + /** Познакомились с Милой (диалог trader_first). */ + met_mila: 'met_mila', + /** Мила дала вощёное полотно (диалог trader_first). */ + got_cloth: 'got_cloth', + /** Колокол башни звенел (интерактив bell_rope). */ + rang_tower: 'rang_tower', + /** Прочитана записка в доме Ирвина (интерактив note_elder). */ + read_note: 'read_note', + /** Подсказка «как собирать колокольчики» уже показана (вход в пруды). */ + hint_bells: 'hint_bells', + /** Акт 2 начался: Мила указала на разъезд («Машина дышит», trader_after). */ + act2_hook: 'act2_hook', + /** Познакомились с Клинтом (диалог clint_first; doneFlag стадии 1 резонатора). */ + met_clint: 'met_clint', + /** Клинт просит найти верный звон для резонатора (clint_first; стадия 2). */ + quest_resonator_taken: 'quest_resonator_taken', + /** Резонатор настроен (диалог clint_tune; doneFlag стадии 2). */ + quest_resonator_done: 'quest_resonator_done', + /** Клинт указал на Машину (clint_repeat; doneFlag эпилога резонатора). */ + machine_hook: 'machine_hook', + /** Дерево-механизм №1 прозвенело (интерактив tone_tree_1). */ + tone_tree_1: 'tone_tree_1', + /** Дерево-механизм №2 прозвенело (интерактив tone_tree_2). */ + tone_tree_2: 'tone_tree_2', + /** Дерево-механизм №3 прозвенело (интерактив tone_tree_3). */ + tone_tree_3: 'tone_tree_3', + /** Старая Машина уложена спать правильным звоном (интерактив old_machine). */ + machine_lulled: 'machine_lulled' +} as const; + +export type FlagId = keyof typeof FLAGS; + +/** Вары прогресса (числовые; значение живёт в GameState.vars): ключ = id. */ +export const VARS = { + /** Пепельные моты в сумке (подбор наростов, победы над врагами). */ + motes: 'motes', + /** Собранные лунные колокольчики (квест «Три цветка»). */ + flowers: 'flowers', + /** Здоровье героя между входами в локацию. */ + hp: 'hp', + /** Побеждённые враги (отладка/статистика). */ + kills: 'kills', + /** Смерти героя (статистика). */ + deaths: 'deaths', + /** Резонансы башни (кат-сцена, статистика). */ + resonances: 'resonances', + /** Собранные тоны деревьев-механизмов (квест резонатора). */ + tones: 'tones' +} as const; + +export type VarId = keyof typeof VARS; + +/** Синтезируемый флаг одноразового интерактива (once): used:. */ +export function usedFlag(id: string): string { + return `used:${id}`; +} \ No newline at end of file diff --git a/v1/apps/game/src/data/interactables.ts b/v1/apps/game/src/data/interactables.ts new file mode 100644 index 0000000..2275b2b --- /dev/null +++ b/v1/apps/game/src/data/interactables.ts @@ -0,0 +1,396 @@ +import type { Vec2 } from '@rpg/engine'; +import type { ItemId } from './items'; +import type { AreaId } from './locations'; +import { FLAGS, VARS } from './ids'; +import { QUEST_TONES, QUEST_TONE_TILES } from './quests'; + +/** + * Интерактивные объекты области: сундуки, очаги, записки, прилавки, столбы + * у троп, подборные сгустки, рычаги. Первая подходящая реакция (по `when`) + * срабатывает; эффекты применяет рантайм systems/Interactables.ts через sink. + */ + +/** Условие реакции: все указанные поля должны выполняться. */ +export interface InteractWhen { + /** Флаг поднят. */ + flag?: string; + /** Флаг не поднят. */ + notFlag?: string; + /** Вар не меньше значения. */ + varAtLeast?: { id: string; value: number }; +} + +/** Реакция объекта: текст (тост) + необязательные эффекты. */ +export interface InteractResponse { + /** Первая реакция с выполненным условием срабатывает (без when — всегда). */ + when?: InteractWhen; + text: string; + setFlags?: string[]; + clearFlags?: string[]; + setVar?: { id: string; value: number }; + /** Прибавить к вару (подбор мотов: +1 за каждый нарост). */ + addVar?: { id: string; by: number }; + /** Выдать предмет (в сумку). */ + gives?: ItemId; + /** Сколько штук gives (по умолчанию 1). */ + count?: number; + /** Ключ звука (sfx/...). */ + sound?: string; + /** Тост вместо text (после эффектов); с varKey — дописывает « (N/goal)». */ + toast?: { text: string; varKey?: string; goal?: number }; + /** Сюжетный эффект (DIALOGUE_CUSTOM): сцена запускает катсцену/событие. */ + custom?: string; +} + +/** Вьюха объекта из ассетов: если задана — рисуется вместо плейсхолдера Graphics. */ +export interface InteractableView { + /** Ключ текстуры в ассетах (tiles/…, chars/…). */ + tex: string; + /** Якорь по Y в пикселях кадра от низа (по умолчанию низ спрайта). */ + anchorY?: number; +} + +/** Объект мира, с которым герой взаимодействует кликом. */ +export interface InteractableDef { + id: string; + /** Позиция в мировых юнитах (центр тайла = координата + 0.5). */ + pos: Vec2; + /** Тайл привязки (floor от pos; вычисляется — в контенте не писать). */ + tile: { x: number; y: number }; + kind: 'examine' | 'pickup' | 'container' | 'lever' | 'signpost'; + /** Вьюха из ассетов (без неё — плейсхолдер Graphics по kind). */ + view?: InteractableView; + /** Подпись при близости (и в снапшоте). */ + label?: string; + /** Одноразовый: помечается флагом used: и больше не реагирует. */ + once?: boolean; + /** Без предмета — отказ с lockedText. */ + requiresItem?: ItemId; + lockedText?: string; + responses: InteractResponse[]; +} + +/** Что резолверу нужно знать о герое и мире (подставляется из GameState/сумки). */ +export interface InteractCtx { + hasFlag(flag: string): boolean; + hasItem(id: ItemId): boolean; + getVar(id: string): number; + /** Одноразовый объект уже использован (флаг used:). */ + used(id: string): boolean; +} + +/** Результат взаимодействия: реакция / отказ (заперто) / ничего (пусто). */ +export type InteractPick = + | { ok: true; response: InteractResponse } + | { ok: false; lockedText: string } + | null; + +/** Условие when выполнено (все указанные поля). */ +function matches(when: InteractWhen | undefined, ctx: InteractCtx): boolean { + if (!when) return true; + if (when.flag !== undefined && !ctx.hasFlag(when.flag)) return false; + if (when.notFlag !== undefined && ctx.hasFlag(when.notFlag)) return false; + if (when.varAtLeast && ctx.getVar(when.varAtLeast.id) < when.varAtLeast.value) return false; + return true; +} + +/** + * Чистый выбор реакции: первая подходящая по `when`; одноразовые объекты + * после использования молчат (null); requiresItem без предмета — отказ. + */ +export function resolveInteractable(def: InteractableDef, ctx: InteractCtx): InteractPick { + if (def.requiresItem && !ctx.hasItem(def.requiresItem)) { + return { ok: false, lockedText: def.lockedText ?? 'Ничего не выйдет.' }; + } + if (def.once && ctx.used(def.id)) return null; + const response = def.responses.find((r) => matches(r.when, ctx)); + if (!response) return null; + return { ok: true, response }; +} + +/** Контент по областям (см. docs/world.md): интерьеры и улицы (pos — первичен). */ +const INTERACTABLES_RAW: Partial[]>> = { + // --- луга: столбы у троп и подборные пепельные моты (зачаток экономики) --- + meadows: [ + { + id: 'sign_meadows_east', + pos: { x: 48.5, y: 27.5 }, + kind: 'signpost', + view: { tex: 'tiles/signpost' }, + label: 'Столб у тропы', + responses: [ + { + text: '«Звенец» — стёрто, но различимо. Дальше на восток: иди на звон.', + sound: 'sfx/note' + } + ] + }, + { + id: 'sign_meadows_west', + pos: { x: 2.5, y: 3.5 }, + kind: 'signpost', + view: { tex: 'tiles/signpost' }, + label: 'Столб у тропы', + responses: [ + { + text: '«Серые пруды» — стрелка на северо-запад. Ниже приписка: «туман лжёт».', + sound: 'sfx/note' + } + ] + }, + { + id: 'mote_1', + pos: { x: 13.5, y: 28.5 }, + kind: 'pickup', + view: { tex: 'tiles/mote' }, + label: 'Пепельный мот', + once: true, + responses: [ + { + text: 'Сгусток серой земли с тёплой искрой. Мот — то, ради чего сажают колокольчики.', + gives: 'mote', + addVar: { id: VARS.motes, by: 1 }, + sound: 'sfx/mote_pickup' + } + ] + }, + { + id: 'mote_2', + pos: { x: 16.5, y: 38.5 }, + kind: 'pickup', + view: { tex: 'tiles/mote' }, + label: 'Пепельный мот', + once: true, + responses: [ + { + text: 'Ещё один мот. Земля здесь отдаёт их неохотно.', + gives: 'mote', + addVar: { id: VARS.motes, by: 1 }, + sound: 'sfx/mote_pickup' + } + ] + }, + { + id: 'mote_3', + pos: { x: 16.5, y: 20.5 }, + kind: 'pickup', + view: { tex: 'tiles/mote' }, + label: 'Пепельный мот', + once: true, + responses: [ + { + text: 'Третий мот за день. Сумка тихо позвякивает.', + gives: 'mote', + addVar: { id: VARS.motes, by: 1 }, + sound: 'sfx/mote_pickup' + } + ] + } + ], + // --- роща (акт 2): деревья-механизмы для звона и резонатор Клинта --- + // Реакция дерева-механизма: до квеста молчит; с квестом — звон и тон + // (свой флаг на дерево — addVar без повтора); после настройки гудит в лад. + rust_grove: [ + ...(['tone_tree_1', 'tone_tree_2', 'tone_tree_3'] as const).map((id, i): Omit => { + const [tx, ty] = QUEST_TONE_TILES[i]; + return { + id, + pos: { x: tx + 0.5, y: ty + 0.5 }, + kind: 'examine', + view: { tex: 'tiles/tone_tree' }, + label: 'Дерево-механизм', + responses: [ + { + when: { flag: FLAGS.quest_resonator_done }, + text: 'Дерево гудит в лад с резонатором. Кабели тёплые.', + sound: 'sfx/bell_low' + }, + { + when: { notFlag: FLAGS.quest_resonator_taken }, + text: 'Кабели оплетают ствол, но дерево молчит. Не сейчас.', + sound: 'sfx/ash_hiss' + }, + { + when: { notFlag: FLAGS[id] }, + text: 'Звон колокольчика — и кабели отвечают: дерево отдаёт свой тон.', + toast: { text: 'Тон', varKey: VARS.tones, goal: QUEST_TONES }, + setFlags: [FLAGS[id]], + addVar: { id: VARS.tones, by: 1 }, + sound: 'sfx/bell_hit' + }, + { text: 'Это дерево уже прозвенело. Тон унесён.', sound: 'sfx/note' } + ] + }; + }), + { + id: 'clint_resonator', + pos: { x: 24.5, y: 16.5 }, + kind: 'lever', + view: { tex: 'tiles/resonator' }, + label: 'Резонатор Клинта', + responses: [ + { + when: { flag: FLAGS.quest_resonator_done }, + text: 'Резонатор держит тон ровно. Клинт поддел его под пол-тона.', + sound: 'sfx/bell_low' + }, + { + text: 'Медные колокола на стальной раме. Ржавый, молчит. Клинту нужен верный звон.', + sound: 'sfx/ash_hiss' + } + ] + } + ], + ponds: [ + { + id: 'sign_ponds', + pos: { x: 5.5, y: 3.5 }, + kind: 'signpost', + view: { tex: 'tiles/signpost' }, + label: 'Столб у берега', + responses: [ + { + text: '«Не дышать накатом» — буквы вросли в ржавчину. Кто-то дописал: «голос дороже соли».', + sound: 'sfx/note' + } + ] + } + ], + zvenets: [ + { + id: 'sign_zvenets', + pos: { x: 3.5, y: 19.5 }, + kind: 'signpost', + view: { tex: 'tiles/signpost' }, + label: 'Столб у входа', + responses: [ + { + text: '«Звенец. Мастерская мира». Ниже чужим почерком: «кузня чинит резонаторы, лавка меняет соль».', + sound: 'sfx/note' + } + ] + }, + { + id: 'bell_rope', + pos: { x: 27.5, y: 13.5 }, + kind: 'lever', + view: { tex: 'tiles/bell_rope' }, + label: 'Верёвка колокола', + responses: [ + { + when: { notFlag: FLAGS.rang_tower }, + text: 'Верёвка в ладони. Колокол качнулся — низкий тон ушёл в землю. Кто-то услышит.', + setFlags: [FLAGS.rang_tower], + sound: 'sfx/bell_low' + }, + { + text: 'Колокол качнулся снова. Звон тот же: пол-тона ниже, чем в звонной книге.', + sound: 'sfx/bell_low' + } + ] + } + ], + house_elder: [ + { + id: 'chest_elder', + pos: { x: 3.5, y: 3.5 }, + kind: 'container', + view: { tex: 'tiles/chest' }, + label: 'Сундук', + once: true, + responses: [ + { + text: 'В сундуке — запас вощёного полотна.', + gives: 'cloth', + sound: 'sfx/chest_open' + } + ] + }, + { + id: 'hearth_elder', + pos: { x: 8.5, y: 0.5 }, + kind: 'examine', + view: { tex: 'tiles/hearth' }, + label: 'Очаг', + responses: [ + { + when: { flag: FLAGS.quest_bells_done }, + text: 'Очаг горит ровнее. Пепел отступает — и тепло возвращается.', + sound: 'sfx/bell_low' + }, + { text: 'В очаге тлеют щепки. Тепло — редкость теперь.', sound: 'sfx/ash_hiss' } + ] + }, + { + id: 'note_elder', + pos: { x: 10.5, y: 4.5 }, + kind: 'examine', + view: { tex: 'tiles/note' }, + label: 'Записка', + once: true, + responses: [ + { + text: '«Тоны уходят вниз. Если колокол смолкнет — не буди Молчание».', + setFlags: [FLAGS.read_note], + sound: 'sfx/note' + } + ] + } + ], + shop: [ + { + id: 'counter_shop', + pos: { x: 5.5, y: 3.5 }, + kind: 'examine', + view: { tex: 'tiles/counter' }, + label: 'Прилавок', + responses: [ + { + when: { flag: FLAGS.quest_bells_done }, + text: 'На прилавке — соль и цветы в плошке. Поляна гудит — Мила звонит чаще.', + sound: 'sfx/bell_hit' + }, + { + text: 'Соль, вощёное полотно, вода в бутлях. Цен Мила не пишет — звонит.', + sound: 'sfx/bell_hit' + } + ] + } + ], + // --- зал Машины (финал акта 2): усыпить её правильным звоном --- + machine_hall: [ + { + id: 'old_machine', + pos: { x: 13.5, y: 1.5 }, + kind: 'lever', + view: { tex: 'tiles/machine' }, + label: 'Старая Машина', + responses: [ + { + when: { flag: FLAGS.machine_lulled }, + text: 'Машина спит. Угольки в устье погасли — впервые за годы.', + sound: 'sfx/bell_low' + }, + { + when: { notFlag: FLAGS.quest_resonator_done }, + text: 'Устье дышит жаром сквозь рёбра. Резонатор не настроен — Машине не спеть.', + sound: 'sfx/ash_hiss' + }, + { + // Правильный звон: настроенный резонатор + верный тон. + text: 'Резонатор ловит дыхание. Пол-тона вниз — и Машина отвечает сном.', + setFlags: [FLAGS.machine_lulled], + custom: 'lull_machine', + sound: 'sfx/bell_low' + } + ] + } + ] +}; +/** Тайл привязки вычисляется из pos при загрузке модуля. */ +export const INTERACTABLES: Partial> = Object.fromEntries( + Object.entries(INTERACTABLES_RAW).map(([area, defs]) => [ + area, + defs.map((d) => ({ ...d, tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } })) + ]) +); diff --git a/v1/apps/game/src/data/items.ts b/v1/apps/game/src/data/items.ts new file mode 100644 index 0000000..066ec08 --- /dev/null +++ b/v1/apps/game/src/data/items.ts @@ -0,0 +1,63 @@ +/** + * Реестр предметов (контент по docs/world.md). Id — стабильные ключи сейва. + * Описания — стиль сеттинга: кратко, меланхолично. + */ +export interface ItemDef { + id: ItemId; + name: string; + desc: string; +} + +export type ItemId = 'cloth' | 'bellflower' | 'salt' | 'water_flask' | 'mote' | 'clock' | 'map_scroll'; + +export const ITEMS: Record = { + clock: { + id: 'clock', + name: 'Медные часы', + desc: 'Старые, с одной стрелкой. Ирвин: «Стрелка не врёт — темнота по ней приходит».' + }, + cloth: { + id: 'cloth', + name: 'Вощёное полотно', + desc: 'Маска от наката. Дышать поверх свежего пепла — потерять голос.' + }, + bellflower: { + id: 'bellflower', + name: 'Лунный колокольчик', + desc: 'Трава-возвращенец. Вытягивает пепел из земли. Посаженный — надежда.' + }, + salt: { + id: 'salt', + name: 'Соль', + desc: 'Край держится на обмене. Соль здесь дороже золота.' + }, + water_flask: { + id: 'water_flask', + name: 'Вода в медном бутле', + desc: 'Мутная, но чистая. Пруды отдают воду неохотно.' + }, + mote: { + id: 'mote', + name: 'Пепельный мот', + desc: 'Сгусток серой земли с тёплой искрой — то, что колокольчик вытянул из пепла. Мила меняет моты на товар.' + }, + map_scroll: { + id: 'map_scroll', + name: 'Карта окрестностей', + desc: 'Набросок Ирвина: тропы, дома, дальние края. Местность тут меняется быстрее чернил.' + } +}; + +/** Безопасное имя по id (незнакомый id — сам id). */ +export function itemName(id: string): string { + return ITEMS[id as ItemId]?.name ?? id; +} + +/** + * Строка от «применения» предмета (юза́бельные — только часы). Чистая функция: + * null — предмет без эффекта (сумка молчит). label — текущее время сумки + * (в сумке время заморожено — LocationScene не тикается). + */ +export function useItemLine(id: string, ctx: { label: string }): string | null { + return id === 'clock' ? `Время: ${ctx.label}` : null; +} \ No newline at end of file diff --git a/v1/apps/game/src/data/locations.ts b/v1/apps/game/src/data/locations.ts new file mode 100644 index 0000000..d56d352 --- /dev/null +++ b/v1/apps/game/src/data/locations.ts @@ -0,0 +1,503 @@ +import type { NpcDef } from './npcs'; +import type { EnemyKindId, EnemyPatrolDef } from './enemies'; +import { NPCS } from './npcs'; +import type { ItemId } from './items'; +import { TILES } from './map'; +import { FLAGS } from './ids'; +import { INTERACTABLES, type InteractableDef } from './interactables'; + +/** + * Области демо-среза (улица и интерьер — общий контекст). Карты хранятся + * файлами assets/maps/.map (формат rpg-map, RLE) и загружаются через + * parseMap в BootScene. + */ +export type AreaId = + | 'meadows' + | 'ponds' + | 'zvenets' + | 'rust_grove' + | 'old_siding' + | 'machine_hall' + | 'house_elder' + | 'shop'; + +/** Куда ведёт переход: в другую область (с точкой входа) или назад, откуда вошли. */ +export type TransitionTarget = + | { kind: 'area'; area: AreaId; entry: { x: number; y: number } } + | { kind: 'return' }; + +/** + * Единый механизм переходов: выходы между локациями (step), двери/порталы/ + * колодцы (click). Первая подходящая по тайлу и триггеру — срабатывает. + */ +export interface TransitionDef { + /** Тайл-триггер. */ + tile: { x: number; y: number }; + /** Подпись для подсказки при близости (позже) и снапшота. */ + label?: string; + target: TransitionTarget; + /** step — наступил (по умолчанию), click — клик по тайлу (двери, порталы). */ + trigger?: 'step' | 'click'; + /** Условия: не выполнены — переход не срабатывает, герой получает lockedText. */ + requiresFlag?: string; + requiresItem?: ItemId; + lockedText?: string; + /** Длительность fade (сек), по умолчанию 0.4. */ + duration?: number; +} + +/** Зона опасности (накат): герой без нужного предмета еле идёт и рискует голосом. */ +export interface HazardDef { + name: string; + /** Тайлы низины (свежий пласт пепла). */ + tiles: { x: number; y: number }[]; + /** Без чего нельзя дышать здесь. */ + requiresItem: ItemId; +} + +/** Локальный амбиент-слой: громкость из дистанции до ближайшего тайла/точки. */ +export interface AmbienceLayerDef { + /** Ключ лупа (Game.AUDIO_KEYS). */ + key: string; + /** Тайлы-источники (иды из TILES); пусто/нет — точки positions или точка at. */ + tiles?: number[]; + /** Явные точки-источники в тайлах (например, тайлы зоны наката). */ + positions?: { x: number; y: number }[]; + /** Точка-источник в тайлах (центр башни и т.п.). */ + at?: { x: number; y: number }; + /** Радиус слышимости в юнитах (за ним слой молчит); нет at/tiles — глобальный слой. */ + radius?: number; + /** Громкость (для глобального — константа; по умолчанию 0.5). */ + volume?: number; +} + +/** Источник света: точка в тайлах или все тайлы заданных id; радиус в юнитах. */ +export interface LightSourceDef { + id: string; + /** Центр источника в тайлах (центр ромба: +0.5); для at/tiles — одно из двух. */ + at?: { x: number; y: number }; + /** Альтернатива: свет на каждом тайле этого id (позиции из карты). */ + tiles?: number[]; + /** Радиус в юнитах (1 юнит = тайл; по умолчанию 3). */ + radius?: number; + /** Цвет свечения (по умолчанию 0xf2b45a — тёплый F1). */ + color?: number; + /** Яркость (по умолчанию 0.9). */ + intensity?: number; + /** Амплитуда мерцания 0..1 (по умолчанию 0 — ровный). */ + flicker?: number; + /** Источник активен только при наличии (whenFlag) / отсутствии (notFlag) флага. */ + whenFlag?: string; + notFlag?: string; + /** Ночной источник: горит при факторе ночи ≥ 0.5 (вечер/ночь по часам). */ + whenNight?: boolean; +} + +/** Освещение области: ambient (multiply-цвет) + статические источники. */ +export interface LightingDef { + /** 0xffffff — не трогает сцену; тёмный/цветной — тон и затемнение сцены. */ + ambient?: number; + /** Ночной ambient (когда стоит флаг вечера); нет — ночь не меняет тон. */ + nightAmbient?: number; + /** Закатный тон (золотой час); нет — движковый DEFAULT_DUSK_COLOR. */ + duskColor?: number; + sources?: LightSourceDef[]; +} + +export interface AreaDef { + id: AreaId; + name: string; + /** Амбиент области (кроссфейд при переходе, ключ из Game.AUDIO_KEYS); нет — тишина. */ + ambience?: string; + /** Музыкальная тема области (ключ из data/music.ts, спек-музыка); нет — тишина. */ + theme?: string; + /** Стартовый тайл (новая игра / респаун / фолбэк return). */ + spawn: { x: number; y: number }; + npcs: NpcDef[]; + enemies: { kind: EnemyKindId; tile: { x: number; y: number }; patrol?: EnemyPatrolDef }[]; + transitions: TransitionDef[]; + /** Интерактивные объекты (сундуки, очаги, прилавки). */ + interactables?: InteractableDef[]; + /** Атмосферные частицы: 'ash' — пепел, 'fog' — туман прудов, 'none' — интерьер. */ + atmosphere: 'ash' | 'fog' | 'none'; + /** Освещение: ambient-тон + статические источники (очаг, окна). */ + lighting?: LightingDef; + /** Локальные амбиент-слои поверх областного (вода, гул башни, дома). */ + ambienceLayers?: AmbienceLayerDef[]; + /** Зоны наката (необязательно). */ + hazards?: HazardDef[]; + /** Сборный тайл (лунный колокольчик): клик по нему — сбор. Нет — в области не собирается. */ + harvestTile?: number; + /** Спавны пейзажных оленей (тайлы). */ + fauna?: { x: number; y: number }[]; +} + +/** Зона наката прудов (низины у берегов): тайлы — ещё и источники слоя ashflow. */ +const HAZARD_PONDS: HazardDef = { + name: 'Свежий накат', + requiresItem: 'cloth', + tiles: [ + { x: 10, y: 32 }, + { x: 12, y: 34 }, + { x: 14, y: 35 }, + { x: 16, y: 36 }, + { x: 18, y: 36 }, + { x: 20, y: 37 }, + { x: 22, y: 37 }, + { x: 24, y: 38 }, + { x: 26, y: 37 }, + { x: 28, y: 36 }, + { x: 30, y: 35 }, + { x: 32, y: 34 }, + { x: 8, y: 18 }, + { x: 6, y: 16 }, + { x: 30, y: 12 }, + { x: 32, y: 10 } + ] +}; + +/** Зона наката разъезда (наметало у рельсов, у люка). */ +const HAZARD_SIDING: HazardDef = { + name: 'Накат у рельсов', + requiresItem: 'cloth', + tiles: [ + { x: 26, y: 29 }, + { x: 27, y: 29 }, + { x: 29, y: 29 }, + { x: 28, y: 28 }, + { x: 29, y: 30 }, + { x: 30, y: 29 } + ] +}; + +export const AREAS: Record = { + meadows: { + id: 'meadows', + name: 'Выжженные луга', + // Амбиента нет (по фидбеку): тишина выжженных лугов — только музыка и шаги. + theme: 'meadows', + spawn: { x: 16, y: 28 }, + npcs: [], + enemies: [ + { kind: 'crawler', tile: { x: 16, y: 18 } }, + { kind: 'crawler', tile: { x: 20, y: 14 } }, + // Плевун кружит у северо-восточных бугров. + { + kind: 'spitter', + tile: { x: 38, y: 12 }, + patrol: { points: [{ x: 38, y: 12 }, { x: 44, y: 10 }], pauseSec: 1.2 } + }, + { kind: 'crawler', tile: { x: 44, y: 42 } }, + { kind: 'crawler', tile: { x: 36, y: 24 } }, + // Тяжеловес бродит вдоль юго-западной низины. + { + kind: 'heavy', + tile: { x: 12, y: 44 }, + patrol: { points: [{ x: 12, y: 44 }, { x: 8, y: 40 }], pauseSec: 2 } + } + ], + transitions: [ + // Северо-западный угол: тропа на Серые пруды. + { tile: { x: 2, y: 2 }, target: { kind: 'area', area: 'ponds', entry: { x: 4, y: 4 } } }, + // Тропа в Звенец (деревня-мастерская). + { tile: { x: 54, y: 28 }, target: { kind: 'area', area: 'zvenets', entry: { x: 3, y: 20 } } }, + // Встречный колодец: звон в Звенец (клик по тайлу колодца). + { + tile: { x: 44, y: 40 }, + label: 'Старый колодец', + target: { kind: 'area', area: 'zvenets', entry: { x: 16, y: 14 } }, + trigger: 'click', + duration: 0.2 + } + ], + atmosphere: 'ash', + // Солнечный выжженный день: тёплый почти-белый. Вечером — золотой час + // и синяя ночь: прорези колокольчиков несут свет. + lighting: { + ambient: 0xf7f4ea, + nightAmbient: 0x3e4658, + sources: [ + { + id: 'bells', + tiles: [TILES.BELLFLOWER], + color: 0xf2cd68, + radius: 1.0, + intensity: 0.65, + flicker: 0.15, + whenNight: true + } + ] + }, + interactables: INTERACTABLES.meadows, + // Амбиента нет (по фидбеку): на лугах тишина — только музыка и шаги. + // Безгласные олени у берегов прудов — подходят к звону. + fauna: [ + { x: 18, y: 36 }, + { x: 24, y: 40 } + ] + }, + ponds: { + id: 'ponds', + name: 'Серые пруды', + ambience: 'ambience/ponds', + spawn: { x: 4, y: 4 }, + npcs: [], + enemies: [ + { kind: 'crawler', tile: { x: 8, y: 22 } }, + { kind: 'crawler', tile: { x: 18, y: 36 } }, + { kind: 'spitter', tile: { x: 40, y: 20 } }, + { kind: 'spitter', tile: { x: 36, y: 36 } }, + { kind: 'heavy', tile: { x: 28, y: 36 } } + ], + transitions: [ + // Обратно на луга. + { tile: { x: 2, y: 2 }, target: { kind: 'area', area: 'meadows', entry: { x: 3, y: 3 } } } + ], + atmosphere: 'fog', + // Светлый день у прудов (туман даёт молочность сам); ночью — глухая + // синева, туман читается прорезями. + lighting: { ambient: 0xf2f0e8, nightAmbient: 0x444c60 }, + // Вода прудов слышна у берега — тихий плеск поверх ветрового амбиента. + // Глобальный ветер + вода у берегов (громкость — от дистанции). + ambienceLayers: [ + { key: 'ambience/wind', volume: 0.3 }, + // Вода — только вплотную к берегу и совсем тихо (у нас лужи, не море). + { key: 'ambience/ponds_water', tiles: [TILES.WATER], radius: 2.5, volume: 0.22 } + ], + // Свежий накат — низины у берегов: без вощёного полотна здесь не дышать. + hazards: [HAZARD_PONDS], + interactables: INTERACTABLES.ponds, + // Лунные колокольчики собираются только у прудов (квест «Три цветка»). + harvestTile: TILES.BELLFLOWER, + // Один олень на северо-западном берегу. + fauna: [{ x: 8, y: 12 }] + }, + zvenets: { + id: 'zvenets', + name: 'Звенец', + ambience: 'ambience/zvenets', + spawn: { x: 28, y: 20 }, + npcs: NPCS, + enemies: [], + transitions: [ + // Тропа обратно на луга. + { tile: { x: 2, y: 20 }, target: { kind: 'area', area: 'meadows', entry: { x: 53, y: 28 } } }, + // Колодец у дома Ирвина: телепорт на луга (клик; встречный лугам). + { + tile: { x: 16, y: 13 }, + label: 'Старый колодец', + target: { kind: 'area', area: 'meadows', entry: { x: 44, y: 41 } }, + trigger: 'click', + duration: 0.2 + }, + // Двери: клик по дому — внутрь (вход у проёма; выход — kind 'return'). + { + tile: { x: 13, y: 12 }, + label: 'Дом Ирвина', + target: { kind: 'area', area: 'house_elder', entry: { x: 5, y: 7 } }, + trigger: 'click', + duration: 0.2 + }, + { + tile: { x: 43, y: 19 }, + label: 'Лавка Милы', + target: { kind: 'area', area: 'shop', entry: { x: 5, y: 7 } }, + trigger: 'click', + duration: 0.2 + }, + // Восточный край: тропа в Ржавую рощу (акт 2). + { tile: { x: 54, y: 20 }, target: { kind: 'area', area: 'rust_grove', entry: { x: 2, y: 20 } } } + ], + interactables: INTERACTABLES.zvenets, + atmosphere: 'ash', + // Светлый тёплый день деревни; вечером — золотой час, синяя ночь и + // тёплые окна трёх домов; после звонка — холодный отсвет от башни. + lighting: { + ambient: 0xf5f2ea, + nightAmbient: 0x454c62, + sources: [ + // Окна — на теле спрайта дома (тело занимает тайл выше-левее базового); + // днём не горят, зажигаются вечером. + { id: 'window_elder', at: { x: 12.5, y: 11.5 }, radius: 1.2, intensity: 0.65, flicker: 0.1, whenNight: true }, + { id: 'window_north', at: { x: 36.5, y: 9.5 }, radius: 1.2, intensity: 0.65, flicker: 0.1, whenNight: true }, + { id: 'window_shop', at: { x: 42.5, y: 18.5 }, radius: 1.2, intensity: 0.65, flicker: 0.1, whenNight: true }, + { + id: 'tower_resonance', + at: { x: 28, y: 14 }, + color: 0x4a737f, + radius: 2.5, + intensity: 0.6, + flicker: 0.25, + whenFlag: FLAGS.rang_tower + } + ] + }, + // Гул колокола башни и скрипы вагонных щитов — слышны вблизи источников. + ambienceLayers: [ + { key: 'ambience/tower_hum', at: { x: 28, y: 14 }, radius: 8, volume: 0.5 }, + { + key: 'ambience/houses', + tiles: [TILES.HOUSE, TILES.HOUSE_V1, TILES.HOUSE_V2, TILES.HOUSE_V3, TILES.HOUSE_V4], + radius: 5, + volume: 0.4 + } + ] + }, + // --- акт 2: роща, разъезд, подземелье --- + rust_grove: { + id: 'rust_grove', + name: 'Ржавая роща', + ambience: 'ambience/rust_grove', + spawn: { x: 2, y: 20 }, + npcs: [], + enemies: [ + { kind: 'crawler', tile: { x: 12, y: 12 } }, + { kind: 'crawler', tile: { x: 34, y: 14 } }, + { + kind: 'spitter', + tile: { x: 36, y: 32 }, + patrol: { points: [{ x: 36, y: 32 }, { x: 40, y: 28 }], pauseSec: 1.4 } + }, + { kind: 'heavy', tile: { x: 14, y: 30 } } + ], + transitions: [ + // Тропа обратно в Звенец. + { tile: { x: 1, y: 20 }, target: { kind: 'area', area: 'zvenets', entry: { x: 53, y: 20 } } }, + // Тропа на восток: к Старому разъезду. + { tile: { x: 46, y: 20 }, target: { kind: 'area', area: 'old_siding', entry: { x: 2, y: 20 } } } + ], + atmosphere: 'ash', + interactables: INTERACTABLES.rust_grove, + // Роща чуть темнее лугов: мёртвый лес глушит свет; ночью — глухая синева. + lighting: { ambient: 0xe9e6da, nightAmbient: 0x39405a }, + // Ветер в проводах: гул башни не слышен, только кабельный звон. + ambienceLayers: [{ key: 'ambience/wind', volume: 0.4 }] + }, + old_siding: { + id: 'old_siding', + name: 'Старый разъезд', + ambience: 'ambience/old_siding', + spawn: { x: 2, y: 20 }, + npcs: [], + enemies: [ + { kind: 'crawler', tile: { x: 14, y: 18 } }, + { + kind: 'spitter', + tile: { x: 36, y: 16 }, + patrol: { points: [{ x: 36, y: 16 }, { x: 38, y: 12 }], pauseSec: 1.2 } + }, + { + kind: 'heavy', + tile: { x: 36, y: 32 }, + patrol: { points: [{ x: 36, y: 32 }, { x: 32, y: 33 }], pauseSec: 2 } + } + ], + transitions: [ + // Тропа обратно в рощу. + { tile: { x: 1, y: 20 }, target: { kind: 'area', area: 'rust_grove', entry: { x: 45, y: 20 } } }, + // Люк-ступени вниз, в зал Старой Машины (наступил — спустился). + { + tile: { x: 28, y: 31 }, + label: 'Люк в подземелье', + target: { kind: 'area', area: 'machine_hall', entry: { x: 13, y: 19 } }, + duration: 0.6 + } + ], + atmosphere: 'ash', + // Разъезд темнее рощи: рельсы тянут свет в землю; ночью — глубокая тьма. + lighting: { ambient: 0xe4e2d8, nightAmbient: 0x343a50 }, + // Гул снизу: Машина дышит у люка (та же нота, что башня Звенца). + ambienceLayers: [ + { key: 'ambience/wind', volume: 0.25 }, + { key: 'ambience/tower_hum', at: { x: 28, y: 31 }, radius: 7, volume: 0.45 } + ], + // Свежий накат наметало у рельсов: без полотна рядом с люком не дышать. + hazards: [HAZARD_SIDING] + }, + machine_hall: { + id: 'machine_hall', + name: 'Зал Старой Машины', + spawn: { x: 13, y: 19 }, + npcs: [], + enemies: [], + transitions: [ + // Проём в нижней стене: шаг — назад наверх, к люку. + { tile: { x: 13, y: 21 }, target: { kind: 'return' }, duration: 0.2 } + ], + atmosphere: 'none', + interactables: INTERACTABLES.machine_hall, + // Под землёй темно; единственный свет — холодное тление устья печи. + lighting: { + ambient: 0x3e4252, + sources: [ + { + id: 'machine_mouth', + at: { x: 12.5, y: 1.5 }, + color: 0x4a737f, + radius: 3.5, + intensity: 0.55, + flicker: 0.3 + } + ] + }, + // Гул здесь — везде: это её грудная клетка. + ambienceLayers: [{ key: 'ambience/tower_hum', volume: 0.6 }] + }, + // --- интерьеры: рамка WALL, пол FLOOR, дверной проём снизу --- + house_elder: { + id: 'house_elder', + name: 'Дом Ирвина', + spawn: { x: 5, y: 7 }, + npcs: [], + enemies: [], + transitions: [ + // Проём в нижней стене: шаг наружу — назад, откуда вошёл. + { tile: { x: 5, y: 8 }, target: { kind: 'return' }, duration: 0.2 } + ], + interactables: INTERACTABLES.house_elder, + atmosphere: 'none', + // Тёмный холодный интерьер (без света окна выглядят как улица); + // очаг: тлеет, пока квест не закрыт, после — горит ровнее и ярче. + lighting: { + ambient: 0x54586a, + sources: [ + { + id: 'hearth_low', + at: { x: 8.5, y: 0.5 }, + radius: 4, + intensity: 0.75, + flicker: 0.35, + notFlag: FLAGS.quest_bells_done + }, + { + id: 'hearth_high', + at: { x: 8.5, y: 0.5 }, + radius: 5, + intensity: 1.05, + flicker: 0.2, + whenFlag: FLAGS.quest_bells_done + } + ] + } + }, + shop: { + id: 'shop', + name: 'Лавка Милы', + spawn: { x: 5, y: 7 }, + npcs: [], + enemies: [], + transitions: [{ tile: { x: 5, y: 8 }, target: { kind: 'return' }, duration: 0.2 }], + interactables: INTERACTABLES.shop, + atmosphere: 'none', + // Полутёмная лавка: лампа над прилавком. + lighting: { + ambient: 0x5a5e70, + sources: [{ id: 'counter_lamp', at: { x: 5.5, y: 3.5 }, radius: 3, intensity: 0.7, flicker: 0.05 }] + } + } +}; + +/** Область по id (фолбэк 'meadows' — для старых сейвов и пустых id). */ +export function areaOf(id: string | null | undefined): AreaDef { + return AREAS[(id ?? 'meadows') as AreaId] ?? AREAS.meadows; +} \ No newline at end of file diff --git a/v1/apps/game/src/data/map.ts b/v1/apps/game/src/data/map.ts new file mode 100644 index 0000000..ae420dc --- /dev/null +++ b/v1/apps/game/src/data/map.ts @@ -0,0 +1,775 @@ +import { createRng, type TileMapData, type TallSpec, type DecorData, type PropData } from '@rpg/engine'; + +/** id тайлов луга «Пепельные луга». */ +export const TILES = { + GRASS: 0, + PATH: 1, + WATER: 2, + TREE: 3, + ASH: 4, + BELLFLOWER: 5, + TOWER: 6, + HOUSE: 7, + FLOOR: 8, + WALL: 9, + WELL: 10, + // --- акт 2 --- + /** Бухта кабеля/труб в Ржавой роще (низкий tall-объект). */ + WIRE: 18, + /** Рельсы Старого разъезда (ходовые: шпалы с нитями). */ + RAIL: 19, + /** Люк-ступени вниз в зал Машины (ходовой тайл, step-переход). */ + STAIRS: 20, + /** Сама Старая Машина (tall-объект зала, непроходима). */ + MACHINE: 21, + // Варианты текстур (микс по хэшу координат) — в tiles никогда не попадают. + GRASS_V1: 11, + GRASS_V2: 12, + ASH_V1: 13, + PATH_V1: 14, + /** Деревья других видов (варианты TREE распределителем ниже; в tiles не попадают). */ + TREE_V1: 22, + TREE_V2: 23, + /** + * Дома других фасадов. Обычно раздаются вариантами HOUSE по хэшу координат, + * но могут ставиться в tiles и явно — деревня Звенец показывает разные + * фасада намеренно (там немного домов, хэш-микс не гарантирует разнообразия). + * V1/V2 — низкие (времянка, сарай), V3/V4 — высокие 32x80 (амбар, двухэтажный). + */ + HOUSE_V1: 24, + HOUSE_V2: 25, + HOUSE_V3: 26, + HOUSE_V4: 27, + /** Валун — крупный непроходимый камень (луга, пруды, роща, разъезд). */ + BOULDER: 28, + /** Большой мёртвый куст — непроходимое облако ветвей. */ + BUSH: 29, + /** Валуны других форм (варианты BOULDER): скол-монолит и плоский камень. */ + BOULDER_2: 30, + BOULDER_3: 31, + /** Кусты других видов (варианты BUSH): раскидистый и высокий узкий. */ + BUSH_2: 32, + BUSH_3: 33, + /** Большой дом 2×2 (спрайт-проп 64×80, footprint блокируется движком). */ + HOUSE_BIG: 34 +} as const; + +const BLOCKED = [ + TILES.WATER, + TILES.TREE, + TILES.TOWER, + TILES.HOUSE, + TILES.HOUSE_V1, + TILES.HOUSE_V2, + TILES.HOUSE_V3, + TILES.HOUSE_V4, + TILES.WALL, + TILES.WELL, + TILES.WIRE, + TILES.MACHINE, + TILES.BOULDER, + TILES.BUSH, + TILES.BOULDER_2, + TILES.BOULDER_3, + TILES.BUSH_2, + TILES.BUSH_3 +]; + +/** Все id деревьев (вьюхи крон/частицы/соседство — общий случай). */ +export const TREE_TILES: readonly number[] = [TILES.TREE, TILES.TREE_V1, TILES.TREE_V2]; + +/** Дерево — высокий объект 1.5 мировых юнита (48 px), под ним трава. */ +export const TALL_OBJECTS: Record = { + [TILES.TREE]: { height: 1.5, ground: TILES.GRASS }, + [TILES.TREE_V1]: { height: 1.5, ground: TILES.GRASS }, + [TILES.TREE_V2]: { height: 1.5, ground: TILES.GRASS }, + [TILES.TOWER]: { height: 2.5, ground: TILES.PATH }, + [TILES.HOUSE]: { height: 1.75, ground: TILES.ASH, shadow: true }, + [TILES.HOUSE_V1]: { height: 1.75, ground: TILES.ASH, shadow: true }, + [TILES.HOUSE_V2]: { height: 1.75, ground: TILES.ASH, shadow: true }, + // Высокие дома 32x80 — ростом с башню (2.5 юнита). + [TILES.HOUSE_V3]: { height: 2.5, ground: TILES.ASH, shadow: true }, + [TILES.HOUSE_V4]: { height: 2.5, ground: TILES.ASH, shadow: true }, + // Валун и куст: приземистые препятствия (высота спрайта ≤ 1.5 — не резать куллингом). + [TILES.BOULDER]: { height: 1.05, ground: TILES.ASH }, + [TILES.BUSH]: { height: 1.25, ground: TILES.ASH }, + [TILES.BOULDER_2]: { height: 1.25, ground: TILES.ASH }, + [TILES.BOULDER_3]: { height: 0.75, ground: TILES.ASH }, + [TILES.BUSH_2]: { height: 1.0, ground: TILES.ASH }, + [TILES.BUSH_3]: { height: 1.45, ground: TILES.ASH }, + // Стены интерьеров — вертикальные объекты, под ними пол. + [TILES.WALL]: { height: 1, ground: TILES.FLOOR }, + // Бухта кабеля — низкая груда на пепле. + [TILES.WIRE]: { height: 0.6, ground: TILES.ASH }, + // Старая Машина — ростом с башню, стоит на полу зала. + [TILES.MACHINE]: { height: 2.5, ground: TILES.FLOOR } +}; + +/** + * Микс текстур наземных тайлов (движок выбирает по хэшу координат): + * трава и тропа — из трёх/двух вариантов, пепел — с редким вторым оттенком. + */ +const GROUND_VARIANTS: Record = { + [TILES.GRASS]: [TILES.GRASS, TILES.GRASS_V1, TILES.GRASS_V2], + [TILES.PATH]: [TILES.PATH, TILES.PATH_V1], + [TILES.ASH]: [TILES.ASH, TILES.ASH_V1] +}; + +/** Микс высоких объектов: деревья трёх видов, дома пяти фасадов, камни/кусты — по три. */ +const TALL_VARIANTS: Record = { + [TILES.TREE]: [TILES.TREE, TILES.TREE_V1, TILES.TREE_V2], + [TILES.HOUSE]: [TILES.HOUSE, TILES.HOUSE_V1, TILES.HOUSE_V2, TILES.HOUSE_V3, TILES.HOUSE_V4], + [TILES.BOULDER]: [TILES.BOULDER, TILES.BOULDER_2, TILES.BOULDER_3], + [TILES.BUSH]: [TILES.BUSH, TILES.BUSH_2, TILES.BUSH_3] +}; + +/** Полная таблица вариантов для карт (земля + высокие объекты). */ +const TILE_VARIANTS: Record = { ...GROUND_VARIANTS, ...TALL_VARIANTS }; + +/** id текстур мелкого декора (не тайлы — в tiles не попадают). */ +export const DECOR = { + /** Микро-кустик (сухая трава). */ + TUFT: 15, + /** Камешек. */ + STONE: 16, + /** Проплешина (тёмное пятно на траве). */ + BALD: 17 +} as const; + +/** + * Детерминированно набросать декор по готовой карте: кустики и проплешины + * на траве, камешки на пепле (~10% наземных тайлов). Декор не ставится + * на тропу/воду/высокие объекты — только GRASS и ASH. + */ +function buildDecor(tiles: number[], width: number, height: number, seed: number): DecorData[] { + const r = createRng(seed + 7919); + const out: DecorData[] = []; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (r.next() >= 0.1) continue; + const id = tiles[y * width + x]; + // лёгкое смещение от центра ромба — декор не по сетке + // (+0: Math.round может вернуть -0, который JSON теряет — fresh-тест разъезжается) + const dx = Math.round((r.next() - 0.5) * 8) + 0; + const dy = Math.round((r.next() - 0.5) * 4) + 0; + if (id === TILES.GRASS) { + out.push({ id: r.next() < 0.6 ? DECOR.TUFT : DECOR.BALD, x, y, dx, dy }); + } else if (id === TILES.ASH) { + out.push({ id: DECOR.STONE, x, y, dx, dy }); + } + } + } + return out; +} + +/** + * Разбросать валуны и кусты по свободной земле (GRASS/ASH), не затирая тропы, + * воду и высокие объекты. Ставится до расчисток переходов/полян — важные тайлы + * все равно перезаписываются после. Часть попыток может не найти место — это + * допустимо (мелкий разброс у плотных карт). + */ +function scatterRocksAndBushes( + tiles: number[], + width: number, + height: number, + rng: { next: () => number }, + boulders: number, + bushes: number +): void { + const free = (x: number, y: number) => + tiles[y * width + x] === TILES.GRASS || tiles[y * width + x] === TILES.ASH; + const place = (id: number, count: number) => { + for (let i = 0; i < count * 24 && count > 0; i++) { + const x = 1 + Math.floor(rng.next() * (width - 2)); + const y = 1 + Math.floor(rng.next() * (height - 2)); + if (!free(x, y)) continue; + tiles[y * width + x] = id; + count--; + } + }; + place(TILES.BOULDER, boulders); + place(TILES.BUSH, bushes); +} + +/** + * Локация «Выжженные луга»: поляны травы, тропа от северо-западного угла + * (пруды) через стартовую поляну на восток (Звенец), пара прудов и рощи + * деревьев по краям. + */ +export function buildMeadowsMap(width = 56, height = 56): TileMapData { + const seed = 20260905; + const rng = createRng(seed); + const tiles: number[] = new Array(width * height).fill(TILES.GRASS); + const at = (x: number, y: number) => y * width + x; + + // Пятна пепла (декоративные). + for (let i = 0; i < 110; i++) { + const x = Math.floor(rng.next() * width); + const y = Math.floor(rng.next() * height); + const r = 1 + Math.floor(rng.next() * 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.next() < 0.7) { + tiles[at(nx, ny)] = TILES.ASH; + } + } + } + } + + // Тропа: монотонная ломаная к цели (случайный выбор оси) — детерминированно. + const walkPath = (sx: number, sy: number, tx: number, ty: number) => { + let px = sx; + let py = sy; + while (px !== tx || py !== ty) { + tiles[at(px, py)] = TILES.PATH; + if (px !== tx && (py === ty || rng.next() < 0.5)) px += Math.sign(tx - px); + else py += Math.sign(ty - py); + } + tiles[at(tx, ty)] = TILES.PATH; + }; + // От северо-западного перехода к стартовой поляне и от неё на восток к Звенцу. + walkPath(2, 2, 16, 28); + walkPath(16, 28, 52, 28); + + // Пруды (крупнее, с рваными краями). + for (const [cx, cy] of [ + [12, 32], + [38, 14] + ]) { + for (let dy = 0; dy < 5; dy++) { + for (let dx = 0; dx < 7; dx++) { + if (rng.next() < 0.85) tiles[at(cx + dx, cy + dy)] = TILES.WATER; + } + } + } + + // Лунные колокольчики у берегов прудов (квестовый декор, проходимы). + for (const [fx, fy] of [ + [20, 34], + [11, 36], + [46, 16], + [35, 11] + ]) { + if (tiles[at(fx, fy)] === TILES.GRASS || tiles[at(fx, fy)] === TILES.ASH) { + tiles[at(fx, fy)] = TILES.BELLFLOWER; + } + } + + // Рощи по краям (в основном) и одинокие деревья на полянах. + for (let i = 0; i < 240; i++) { + const edge = rng.next() < 0.8; + const x = edge ? Math.floor(rng.next() * 8) : width - 1 - Math.floor(rng.next() * 8); + const y = Math.floor(rng.next() * height); + if (tiles[at(x, y)] === TILES.GRASS || tiles[at(x, y)] === TILES.ASH) { + tiles[at(x, y)] = TILES.TREE; + } + } + + // Валуны и кусты на полянах. + scatterRocksAndBushes(tiles, width, height, rng, 10, 14); + + // Стартовая поляна в центре-западе всегда свободна. + const cx = 16; + const cy = 28; + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + tiles[at(cx + dx, cy + dy)] = TILES.GRASS; + } + } + + // Пробуждение стартовой поляны: валун, кусты и деревья в первом кадре от + // спавна — новое сразу видно. Ставим с проверкой: не затирать тропу/воду. + const safePlace = (x: number, y: number, id: number) => { + if (tiles[at(x, y)] !== TILES.PATH && tiles[at(x, y)] !== TILES.WATER) tiles[at(x, y)] = id; + }; + safePlace(13, 25, TILES.BOULDER); // у северо-западной тропы + safePlace(20, 32, TILES.BOULDER); + safePlace(11, 30, TILES.BUSH); // южнее поляны + safePlace(19, 24, TILES.BUSH); // восточнее, не на тропе (x>16) + safePlace(12, 21, TILES.TREE); // деревца-ориентиры у поляны + safePlace(13, 22, TILES.TREE); + safePlace(21, 26, TILES.TREE); + + // Тропа к Серым прудам: северо-западный угол (переход между локациями). + // Вокруг тайлов перехода расчищаем поляну — рощи не должны запирать выход. + for (let dy = -1; dy <= 2; dy++) { + for (let dx = -1; dx <= 2; dx++) { + const nx = 2 + dx; + const ny = 2 + dy; + if (nx >= 0 && ny >= 0 && nx < width && ny < height) { + tiles[at(nx, ny)] = dx === dy || dx + 1 === dy ? TILES.PATH : TILES.GRASS; + } + } + } + tiles[at(2, 2)] = TILES.PATH; + tiles[at(3, 3)] = TILES.PATH; + + // Тропа в Звенец: восточный край, вокруг тайлов перехода тоже расчищаем. + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -2; dx <= 0; dx++) { + const nx = 54 + dx; + const ny = 28 + dy; + if (nx >= 0 && ny >= 0 && nx < width && ny < height) { + tiles[at(nx, ny)] = dx === -1 && dy === 0 ? TILES.PATH : TILES.GRASS; + } + } + } + tiles[at(54, 28)] = TILES.PATH; + + // Встречный колодец на лугах: телепорт обратно в Звенец (клик по тайлу). + tiles[at(44, 40)] = TILES.WELL; + + // Знак у восточной тропы (см. INTERACTABLES.meadows): scatter не должен + // ставить на его тайл дерево/куст. + tiles[at(48, 27)] = TILES.ASH; + + return { + width, + height, + tiles, + blocked: [...BLOCKED], + tall: TALL_OBJECTS, + variants: TILE_VARIANTS, + decor: buildDecor(tiles, width, height, seed) + }; +} + +/** + * Локация «Ржавая роща»: мёртвый лес, оплетённый кабелями и трубами. + * Деревья стоят как механизмы — плотная роща с бухтами кабеля (WIRE), + * тропа с запада (из Звенца) на восток (к разъезду) через поляну + * мастерской Клинта в центре. + */ +export function buildRustGroveMap(width = 48, height = 40): TileMapData { + const seed = 20260908; + const rng = createRng(seed); + const tiles: number[] = new Array(width * height).fill(TILES.ASH); + const at = (x: number, y: number) => y * width + x; + + // Редкие проплешины чахлой травы. + for (let i = 0; i < 24; i++) { + const x = Math.floor(rng.next() * width); + const y = Math.floor(rng.next() * height); + if (tiles[at(x, y)] === TILES.ASH) tiles[at(x, y)] = TILES.GRASS; + } + + // Плотная роща: деревья гуще к краям, реже к центру. + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const d = Math.hypot((x - width / 2) / (width / 2), (y - height / 2) / (height / 2)); + if (rng.next() < 0.16 + d * 0.5 && (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS)) { + tiles[at(x, y)] = TILES.TREE; + } + } + } + + // Бухты кабеля: короткие цепочки по 1–3 тайла (как протянутые шлейфы). + for (const [bx, by, dir] of [ + [10, 8, 1], + [32, 6, -1], + [16, 30, 1], + [36, 28, -1], + [24, 10, 0] + ] as [number, number, number][]) { + for (let i = 0; i < 3; i++) { + const nx = bx + i * dir; + const ny = by + (i % 2); + if (nx >= 0 && ny >= 0 && nx < width && ny < height && tiles[at(nx, ny)] !== TILES.TREE) { + tiles[at(nx, ny)] = TILES.WIRE; + } + } + } + // Тон-деревья квеста резонатора (см. QUEST_TONE_TILES в data/quests.ts): + // гарантируем бухту кабеля на их тайлах — квест не должен зависеть от шума. + for (const [tx, ty] of [ + [32, 6], + [12, 10], + [20, 30] + ]) { + tiles[at(tx, ty)] = TILES.WIRE; + } + + // Валуны и кусты в прогалинах. + scatterRocksAndBushes(tiles, width, height, rng, 6, 8); + + // Тропа: с запада (вход из Звенца) на восток (к разъезду) — до самого + // тайла перехода (46,20), чтобы A* всегда доводил. + for (let x = 1; x <= 46; x++) tiles[at(x, 20)] = TILES.PATH; + + // Спавны врагов расчищаем: роща плотная — дерево могло вырасти на тайле. + for (const [ex, ey] of [ + [12, 12], + [34, 14], + [36, 32], + [40, 28], + [14, 30] + ]) { + if (tiles[at(ex, ey)] === TILES.TREE || tiles[at(ex, ey)] === TILES.WIRE) { + tiles[at(ex, ey)] = TILES.ASH; + } + } + + // Поляна мастерской Клинта: расчистка и утоптанный пепел вокруг центра. + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -6; dx <= 6; dx++) { + const nx = 24 + dx; + const ny = 20 + dy; + if (nx >= 0 && ny >= 0 && nx < width && ny < height && Math.abs(dx) + Math.abs(dy) <= 8) { + tiles[at(nx, ny)] = TILES.ASH; + } + } + } + // Пятачок тропы у поляны (сюда встанет резонатор Клинта). + tiles[at(24, 16)] = TILES.ASH; + + // Входы/выходы: тайлы переходов всегда проходимы. + tiles[at(2, 20)] = TILES.PATH; + tiles[at(46, 20)] = TILES.PATH; + + return { + width, + height, + tiles, + blocked: [...BLOCKED], + tall: TALL_OBJECTS, + variants: TILE_VARIANTS, + decor: buildDecor(tiles, width, height, seed) + }; +} + +/** + * Локация «Старый разъезд»: руины узловой станции. Через карту идут рельсы, + * вдоль них — вагоны-времянки (HOUSE) и редкие сухие деревья. Южнее стрелки — + * люк-ступени (STAIRS) вниз в зал Старой Машины. + */ +export function buildOldSidingMap(width = 48, height = 40): TileMapData { + const seed = 20260909; + const rng = createRng(seed); + const tiles: number[] = new Array(width * height).fill(TILES.ASH); + const at = (x: number, y: number) => y * width + x; + + // Редкие проплешины травы. + for (let i = 0; i < 20; i++) { + const x = Math.floor(rng.next() * width); + const y = Math.floor(rng.next() * height); + if (tiles[at(x, y)] === TILES.ASH) tiles[at(x, y)] = TILES.GRASS; + } + + // Рельсы: главная линия поперёк карты (вертикальная нить в изометрии). + for (let y = 6; y <= 32; y++) { + tiles[at(22, y)] = TILES.RAIL; + tiles[at(23, y)] = TILES.RAIL; + } + // Стрелка: боковая ветка к северо-востоку. + for (let x = 24; x <= 38; x++) tiles[at(x, 6 + (x - 24))] = TILES.RAIL; + + // Руины станции: пара вагонов-времянок и останки стены. + tiles[at(16, 12)] = TILES.HOUSE; + tiles[at(32, 24)] = TILES.HOUSE; + tiles[at(10, 26)] = TILES.WALL; + + // Редкие сухие деревья по краям. + for (let i = 0; i < 44; i++) { + const edge = rng.next() < 0.5; + const x = edge ? Math.floor(rng.next() * 6) : width - 1 - Math.floor(rng.next() * 6); + const y = Math.floor(rng.next() * height); + if (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS) { + tiles[at(x, y)] = TILES.TREE; + } + } + + // Валуны и кусты среди руин. + scatterRocksAndBushes(tiles, width, height, rng, 6, 6); + + // Тропа от западного входа к рельсам, через них и к люку. + for (let x = 1; x <= 20; x++) tiles[at(x, 20)] = TILES.PATH; + for (let x = 24; x <= 26; x++) tiles[at(x, 20)] = TILES.PATH; + for (let y = 21; y <= 30; y++) tiles[at(26, y)] = TILES.PATH; + tiles[at(27, 30)] = TILES.PATH; + tiles[at(27, 31)] = TILES.PATH; + + // Люк-ступени вниз (step-переход в зал Машины), рядом тропа. + tiles[at(28, 31)] = TILES.STAIRS; + + // Вход всегда проходим; спавны врагов (см. AREAS.old_siding.enemies) + // scatter не должен закрывать валуном/кустом. + tiles[at(2, 20)] = TILES.PATH; + for (const [x, y] of [[14, 18], [36, 16], [36, 32]]) tiles[at(x, y)] = TILES.ASH; + + return { + width, + height, + tiles, + blocked: [...BLOCKED], + tall: TALL_OBJECTS, + variants: TILE_VARIANTS, + decor: buildDecor(tiles, width, height, seed) + }; +} + +/** + * Зал Старой Машины (подземелье разъезда): тесный интерьер с плитами пола, + * у северной стены — сама Машина (два тайла корпуса). Рельсы подходят к ней + * со стороны входа. Дверной проём снизу (kind 'return' наверх). + */ +export function buildMachineHallMap(): TileMapData { + const width = 26; + const height = 22; + const tiles: number[] = new Array(width * height).fill(TILES.WALL); + const at = (x: number, y: number) => y * width + x; + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + tiles[at(x, y)] = TILES.FLOOR; + } + } + // Машина: корпус в два тайла у северной стены. + tiles[at(12, 1)] = TILES.MACHINE; + tiles[at(13, 1)] = TILES.MACHINE; + // Рельсы подходят к Машине — её кормили составами. + for (let y = 3; y <= 19; y++) tiles[at(13, y)] = TILES.RAIL; + // Дверной проём в нижней стене (step-переход kind 'return'). + tiles[at(13, height - 1)] = TILES.FLOOR; + return { + width, + height, + tiles, + blocked: [...BLOCKED], + tall: TALL_OBJECTS + }; +} + +/** + * Локация «Серые пруды»: опасная зона — большая гладь воды, туман, + * много пепла и сгустков. Тропа начинается в северо-западном углу + * (обратный переход на луга). + */ +export function buildPondsMap(width = 48, height = 48): TileMapData { + const seed = 20260906; + const rng = createRng(seed); + const tiles: number[] = new Array(width * height).fill(TILES.ASH); + const at = (x: number, y: number) => y * width + x; + + // Пятна чахлой травы. + for (let i = 0; i < 50; i++) { + const x = Math.floor(rng.next() * width); + const y = Math.floor(rng.next() * height); + const r = 1 + Math.floor(rng.next() * 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.next() < 0.6) { + tiles[at(nx, ny)] = TILES.GRASS; + } + } + } + } + + // Большая гладь в центре с рваными берегами. + const cx = Math.floor(width / 2); + const cy = Math.floor(height / 2); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const d = Math.hypot((x - cx) / 15, (y - cy) / 11); + if (d + (rng.next() - 0.5) * 0.35 < 1) tiles[at(x, y)] = TILES.WATER; + } + } + + // Лунные колокольчики у берегов (квестовые цветы растут только здесь). + for (const [fx, fy] of [ + [8, 16], + [38, 12], + [36, 34], + [12, 34], + [24, 42] + ]) { + if (tiles[at(fx, fy)] !== TILES.WATER) tiles[at(fx, fy)] = TILES.BELLFLOWER; + } + + // Тропа от северо-западного входа к берегу. + let px = 4; + let py = 4; + while (py < 30 || px < 30) { + tiles[at(px, py)] = TILES.PATH; + if (rng.next() < 0.45 && px < 30) px++; + else if (py < 30) py++; + else px++; + } + + // Сухие деревья по краям. + for (let i = 0; i < 92; i++) { + const edge = rng.next() < 0.5; + const x = edge ? Math.floor(rng.next() * 6) : width - 1 - Math.floor(rng.next() * 6); + const y = Math.floor(rng.next() * height); + if (tiles[at(x, y)] !== TILES.WATER) tiles[at(x, y)] = TILES.TREE; + } + + // Валуны и кусты у берегов. + scatterRocksAndBushes(tiles, width, height, rng, 8, 10); + + // Поляна у входа свободна; тайлы перехода — тропа. + for (let dy = 0; dy <= 1; dy++) { + for (let dx = 0; dx <= 1; dx++) { + tiles[at(4 + dx, 4 + dy)] = dy + dx === 0 ? TILES.PATH : TILES.GRASS; + } + } + tiles[at(2, 2)] = TILES.PATH; + tiles[at(4, 4)] = TILES.PATH; + + return { + width, + height, + tiles, + blocked: [...BLOCKED], + tall: TALL_OBJECTS, + variants: TILE_VARIANTS, + decor: buildDecor(tiles, width, height, seed) + }; +} +/** + * Интерьер 11×9: рамка WALL, пол FLOOR, дверной проём снизу (5,8). + * Мебель (сундуки/прилавки) — интерактивные объекты на тайлах пола + * (см. data/interactables.ts), не отдельные тайлы. + */ +function buildInteriorMap(): TileMapData { + const width = 11; + const height = 9; + const tiles: number[] = new Array(width * height).fill(TILES.WALL); + const at = (x: number, y: number) => y * width + x; + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + tiles[at(x, y)] = TILES.FLOOR; + } + } + // Дверной проём в нижней стене (step-переход kind 'return' наружу). + tiles[at(5, height - 1)] = TILES.FLOOR; + return { + width, + height, + tiles, + blocked: [...BLOCKED], + tall: TALL_OBJECTS + }; +} + +/** Дом Ирвина: сундук, очаг в северной стене, записка у восточной стены. */ +export function buildHouseElderMap(): TileMapData { + return buildInteriorMap(); +} + +/** Лавка Милы: прилавок в центре, полки вдоль северной стены. */ +export function buildShopMap(): TileMapData { + return buildInteriorMap(); +} + +/** + * Локация «Звенец»: деревня-мастерская вокруг старой железнодорожной + * водонапорной башни с колоколом. Дома из вагонных щитов, крыши из + * платформ, посреди площади — звонное место. Безопасный хаб (без врагов). + */ +export function buildZvenetsMap(width = 56, height = 40): TileMapData { + const seed = 20260907; + const rng = createRng(seed); + const tiles: number[] = new Array(width * height).fill(TILES.ASH); + const at = (x: number, y: number) => y * width + x; + + // Пятна чахлой травы по краям. + for (let i = 0; i < 40; i++) { + const x = Math.floor(rng.next() * width); + const y = Math.floor(rng.next() * height); + const r = 1 + Math.floor(rng.next() * 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.next() < 0.6) { + tiles[at(nx, ny)] = TILES.GRASS; + } + } + } + } + + // Площадь: утоптанный пепел в центре карты. + for (let y = 12; y <= 27; y++) { + for (let x = 18; x <= 39; x++) { + if (rng.next() < 0.9) tiles[at(x, y)] = TILES.PATH; + } + } + + // Знак у входа (см. INTERACTABLES.zvenets): scatter не занял бы его деревом. + tiles[at(3, 19)] = TILES.ASH; + + // Тропа: от западного края (переход на луга) к площади. + let px = 2; + let py = 20; + while (px < 18 || py !== 20) { + tiles[at(px, py)] = TILES.PATH; + if (px < 18 && (py === 20 || rng.next() < 0.6)) px++; + else if (py !== 20) py += py < 20 ? 1 : -1; + else px++; + } + + // Сухие деревья по краям. + for (let i = 0; i < 60; i++) { + const edge = rng.next() < 0.5; + const x = edge ? Math.floor(rng.next() * 6) : width - 1 - Math.floor(rng.next() * 6); + const y = Math.floor(rng.next() * height); + if (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS) { + tiles[at(x, y)] = TILES.TREE; + } + } + + // Пара кустов по краю деревни (валун один — у рощи за площадью). + scatterRocksAndBushes(tiles, width, height, rng, 2, 4); + + // Башня с колоколом — север площади. + tiles[at(28, 14)] = TILES.TOWER; + + // Дома деревни: все фасады показаны намеренно (вариантные id прямо в + // тайлах — хэш-микс не гарантирует разнообразия). Дом Ирвина у колодца, + // двухэтажный на севере, амбар-лавка Милы на востоке, пара времянок югом. + tiles[at(13, 12)] = TILES.HOUSE; + tiles[at(37, 10)] = TILES.HOUSE_V4; + tiles[at(43, 19)] = TILES.HOUSE_V3; + tiles[at(23, 28)] = TILES.HOUSE_V1; + tiles[at(33, 28)] = TILES.HOUSE_V2; + + // Большой дом 2×2 на юго-востоке: первый проп-объект с footprint в игре + // (коллизия и глубина — по всему прямоугольнику, земля под ним — пепел). + const props: PropData[] = [ + { id: TILES.HOUSE_BIG, x: 46, y: 24, w: 2, h: 2, ground: TILES.ASH } + ]; + + // Старый колодец у дома Ирвина: телепорт на луга (см. AREAS, transitions). + tiles[at(16, 13)] = TILES.WELL; + + // Стартовая поляна и тайл перехода всегда свободны. + tiles[at(28, 20)] = TILES.GRASS; + tiles[at(2, 20)] = TILES.PATH; + tiles[at(3, 20)] = TILES.PATH; + + // Тропа в Ржавую рощу: восточный край (вокруг тоже расчищаем). + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -2; dx <= 0; dx++) { + const nx = 54 + dx; + const ny = 20 + dy; + if (nx >= 0 && ny >= 0 && nx < width && ny < height) { + tiles[at(nx, ny)] = dx === -1 && dy === 0 ? TILES.PATH : TILES.ASH; + } + } + } + tiles[at(54, 20)] = TILES.PATH; + + return { + width, + height, + tiles, + blocked: [...BLOCKED], + tall: TALL_OBJECTS, + variants: TILE_VARIANTS, + decor: buildDecor(tiles, width, height, seed), + props + }; +} diff --git a/v1/apps/game/src/data/migrations.ts b/v1/apps/game/src/data/migrations.ts new file mode 100644 index 0000000..200e756 --- /dev/null +++ b/v1/apps/game/src/data/migrations.ts @@ -0,0 +1,34 @@ +import { GameState, type GameStateData } from '@rpg/engine'; +import { FLAGS } from './ids'; + +/** + * Миграции GameStateData (ось state-версии, отдельная от SAVE_VERSION): + * регистрируются в Game один раз, применяются в state.load при чтении сейва. + * Контракт движка: миграция сама повышает version; от старых версий к новым. + */ + +/** Текущая версия формата данных состояния. */ +export const STATE_DATA_VERSION = 3; + +/** v1→v2: знакомства metElder/metTrader → единый snake_case реестра FLAGS. */ +export function migrateV1toV2(data: GameStateData): GameStateData { + const rename = (f: string): string => + f === 'metElder' ? FLAGS.met_elder : f === 'metTrader' ? FLAGS.met_mila : f; + return { version: 2, flags: data.flags.map(rename), vars: { ...data.vars } }; +} + +/** v2→v3: флаг evening удалён — вечер теперь наступает по внутриигровым часам. */ +export function migrateV2toV3(data: GameStateData): GameStateData { + return { + version: 3, + flags: data.flags.filter((f) => f !== 'evening'), + vars: { ...data.vars } + }; +} + +/** Зарегистрировать все миграции (зовёт Game при создании состояния). */ +export function registerStateMigrations(state: GameState): void { + state.dataVersion = STATE_DATA_VERSION; + state.addMigration(migrateV1toV2); + state.addMigration(migrateV2toV3); +} \ No newline at end of file diff --git a/v1/apps/game/src/data/music.ts b/v1/apps/game/src/data/music.ts new file mode 100644 index 0000000..922cbf0 --- /dev/null +++ b/v1/apps/game/src/data/music.ts @@ -0,0 +1,103 @@ +import { createRng, type MusicSpec, type NoteEvent } from '@rpg/engine'; + +/** + * Музыкальные темы: правило-базовый генератор (детерминированный, сиды) + + * компиляция движковым секвенсором (renderMusicStereo → playLoopBuffer) прямо + * в рантайме — WAV-файлов для музыки нет. Тема лугов — пепельная + * ля-минорная пентатоника: пэд-дрон + редкая плак-мелодия восьмыми. + */ + +/** Ля-минорная пентатоника от A3 (A C D E G + октава). */ +const PENTATONIC = [57, 60, 62, 64, 67, 69, 72] as const; + +/** Дрон-пэд: тоника/субмедианта по половинам лупа. */ +function drone(from: number, to: number, beats: number): NoteEvent[] { + return [ + { beat: 0, dur: beats / 2, midi: from, vel: 0.5 }, + { beat: beats / 2, dur: beats / 2, midi: to, vel: 0.5 } + ]; +} + +/** Мелодия восьмыми с паузами (вероятность ноты 0.55) — сид фиксирует рисунок. */ +function melody(seed: number, beats: number): NoteEvent[] { + const rng = createRng(seed); + const notes: NoteEvent[] = []; + for (let eighth = 0; eighth < beats * 2; eighth++) { + if (!rng.chance(0.55)) continue; + notes.push({ + beat: eighth * 0.5, + dur: 0.5, + midi: PENTATONIC[Math.floor(rng.next() * PENTATONIC.length)]!, + vel: 0.35 + rng.next() * 0.25 + }); + } + return notes; +} + +/** Тема лугов: 16 долей на 70 bpm, дрон A2→G2 + плак по пентатонике (стерео). */ +export const MEADOW_THEME: MusicSpec = { + bpm: 70, + beats: 16, + tracks: [ + { instrument: 'pad', notes: drone(45, 43, 16), gain: 0.9, pan: -0.3 }, + { instrument: 'pluck', notes: melody(20260907, 16), pan: 0.3 } + ] +}; + +/** Ритмичный рифф боя: восьмые с пропусками, низкая пентатоника. */ +function riff(seed: number, beats: number): NoteEvent[] { + const rng = createRng(seed); + const low = [45, 48, 50, 52, 55] as const; // A2-минорная низь + const notes: NoteEvent[] = []; + for (let eighth = 0; eighth < beats * 2; eighth++) { + if (!rng.chance(0.7)) continue; + notes.push({ + beat: eighth * 0.5, + dur: 0.5, + midi: low[Math.floor(rng.next() * low.length)]!, + vel: 0.5 + rng.next() * 0.3 + }); + } + return notes; +} + +/** Редкие высокие «тревожные» ноты колокола для tension-стема. */ +function sparseBells(seed: number, beats: number): NoteEvent[] { + const rng = createRng(seed); + const notes: NoteEvent[] = []; + for (let beat = 0; beat < beats; beat++) { + if (!rng.chance(0.15)) continue; + notes.push({ beat, dur: 2, midi: rng.chance(0.5) ? 81 : 84, vel: 0.4 + rng.next() * 0.2 }); + } + return notes; +} + +/** Тема боя: 112 bpm, глухой дрон A1 + настойчивый низкий рифф. */ +export const COMBAT_THEME: MusicSpec = { + bpm: 112, + beats: 8, + tracks: [ + { instrument: 'pad', notes: drone(33, 33, 8), gain: 1, pan: 0 }, + { instrument: 'pluck', notes: riff(20260908, 8), pan: -0.2 } + ] +}; + +/** + * Tension-стем: тихий луп «настороженности» (враг wary) — низкий дрон + * A1 + редкие высокие колокола. Играется на шине music ПОД темой области, + * громкость ведёт threat-тик (см. LocationScene.updateMusic). + */ +export const TENSION_STEM: MusicSpec = { + bpm: 70, + beats: 16, + tracks: [ + { instrument: 'pad', notes: [{ beat: 0, dur: 16, midi: 33, vel: 0.7 }], gain: 0.9, pan: 0.2 }, + { instrument: 'bell', notes: sparseBells(20260909, 16), gain: 0.5, pan: -0.4 } + ] +}; + +/** Реестр тем (ключ → спек); область ссылается на тему полем `theme`. */ +export const THEMES: Record = { + meadows: MEADOW_THEME, + combat: COMBAT_THEME +}; \ No newline at end of file diff --git a/v1/apps/game/src/data/npcs.ts b/v1/apps/game/src/data/npcs.ts new file mode 100644 index 0000000..ddb1042 --- /dev/null +++ b/v1/apps/game/src/data/npcs.ts @@ -0,0 +1,96 @@ +import type { Vec2 } from '@rpg/engine'; +import { FLAGS, type FlagId } from './ids'; + +/** + * NPC локации: позиция в юнитах, спрайт/атлас, диалоги и флаг сюжета + * после первого разговора. Тайл привязки вычисляется из pos при загрузке + * модуля — снапшот/валидатор/клики читают `tile`, реестр объектов — `pos`. + */ +export interface NpcDef { + id: string; + name: string; + /** Позиция ног в мировых юнитах (центр тайла = координата + 0.5). */ + pos: Vec2; + /** Тайл привязки (floor от pos; вычисляется — в контенте не писать). */ + tile: { x: number; y: number }; + /** Ключ спрайта в ассетах (chars/.png). */ + sprite: string; + /** Атлас idle-анимации (по умолчанию chars/npcs_sheet.json). */ + atlas?: string; + /** Первый диалог (когда флаг ещё не поднят). */ + dialogueFirst: string; + /** Повторный диалог. */ + dialogueRepeat: string; + /** Флаг, поднимаемый после первого диалога. */ + flagKey: FlagId; +} + +const NPCS_RAW: Omit[] = [ + { + id: 'elder', + name: 'Старейшина Ирвин', + // Не вплотную ни к двери дома (13,12), ни к колодцу (16,13): кнопка + // действия берёт ближайшую цель, E у двери/колодца не должен звать + // Ирвина — он стоит юго-западнее, вне радиуса (1.5) их тайлов подхода. + pos: { x: 12.5, y: 15.5 }, + sprite: 'elder_irwin', + dialogueFirst: 'elder_first', + dialogueRepeat: 'elder_repeat', + flagKey: FLAGS.met_elder + }, + { + id: 'trader', + name: 'Торговка Мила', + // Не вплотную к двери лавки (43,19): E у двери не должен звать Милу. + pos: { x: 40.5, y: 20.5 }, + sprite: 'trader_mila', + dialogueFirst: 'trader_first', + dialogueRepeat: 'trader_repeat', + flagKey: FLAGS.met_mila + } +]; + +export const NPCS: NpcDef[] = NPCS_RAW.map((d) => ({ + ...d, + tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } +})); + +/** + * Размещения NPC по флагу: персонаж появляется в области, когда флаг сюжета + * поднят (и пропадает, если сценарий его снимет). Так акт 2 заселяет рощу + * только после крючка Милы — контент не живёт вне своей главы. + */ +export interface NpcPlacement { + /** id области (из locations.ts — строкой, чтобы не зациклить импорт). */ + area: string; + /** Флаг, при котором NPC стоит на карте. */ + flag: FlagId; + def: NpcDef; +} + +/** Клинт из raw-определения (общий с NPCS тайловый мост). */ +function npcFrom(d: Omit): NpcDef { + return { ...d, tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } }; +} + +export const NPC_PLACEMENTS: NpcPlacement[] = [ + { + // Кузнец-починщик стоит у своего резонатора в роще после крючка Милы. + area: 'rust_grove', + flag: FLAGS.act2_hook, + def: npcFrom({ + id: 'clint', + name: 'Клинт', + pos: { x: 23.5, y: 17.5 }, + sprite: 'clint', + dialogueFirst: 'clint_first', + dialogueRepeat: 'clint_repeat', + flagKey: FLAGS.met_clint + }) + } +]; + +/** NPC области: статичные (area.npcs) + размещения по поднятым флагам. */ +export function npcsForArea(area: string, hasFlag: (f: FlagId) => boolean): NpcDef[] { + return NPC_PLACEMENTS.filter((p) => p.area === area && hasFlag(p.flag)).map((p) => p.def); +} \ No newline at end of file diff --git a/v1/apps/game/src/data/quests.ts b/v1/apps/game/src/data/quests.ts new file mode 100644 index 0000000..963ef3a --- /dev/null +++ b/v1/apps/game/src/data/quests.ts @@ -0,0 +1,196 @@ +import { evalConditions, type DialogueConditions, type DialogueWorld, type GameState } from '@rpg/engine'; +import { FLAGS, VARS, type FlagId, type VarId } from './ids'; + +/** + * Квестовая система (контент по docs/world.md). + * Квест — декларативный набор стадий; состояние живёт во флагах/варах + * GameState (ставятся графами диалогов), статус выводится из состояния. + * Готовность стадии — те же условия, что и в графах диалогов (данные, не + * замыкания — валидатор и dry-run их видят). Сцена спрашивает только + * «какой диалог у NPC». + */ + +/** Квест «Три цветка» (docs/world.md, акт 1): собрать и посадить колокольчики. */ +export const QUEST_FLOWERS = 3; + +/** Тайлы посадки поляны у тропы (Звенец): collectFlowers расползается от них. */ +export const QUEST_PLANT_TILES: readonly (readonly [number, number])[] = [ + [24, 22], + [25, 23], + [24, 24] +]; + +/** Башня Звенца: тайл (камера кат-сцены сдачи идёт сюда). */ +export const QUEST_TOWER_TILE = { x: 28, y: 14 }; + +/** Зелень у подножия башни: визуальный финал акта 1. */ +export const QUEST_TOWER_GREEN: readonly (readonly [number, number])[] = [ + [27, 15], + [29, 15], + [28, 16] +]; + +/** Квест резонатора (акт 2): сколько тонов собрать у деревьев-механизмов. */ +export const QUEST_TONES = 3; + +/** WIRE-деревья рощи для звона (тайлы интерактивов tone_tree_1..3; достижимы от входа). */ +export const QUEST_TONE_TILES: readonly (readonly [number, number])[] = [ + [32, 6], + [12, 10], + [20, 30] +]; + +/** Одна стадия квеста. */ +export interface QuestStage { + /** Чей диалог ведёт стадию (id из NpcDef). */ + npc: string; + /** Строка журнала, пока стадия активна. */ + text: string; + /** Прогресс N/M по var-ключу (если стадия считается в штуках). */ + progressKey?: VarId; + goal?: number; + /** Диалог стадии: взятие (первая) или сдача (промежуточная). */ + dialogue?: string; + /** Готовность диалога — условия как в графах (по умолчанию сразу). */ + ready?: DialogueConditions; + /** Флаг, которым граф диалога завершает стадию. Без него стадия — «эпилог». */ + doneFlag?: FlagId; +} + +export interface QuestDef { + id: string; + title: string; + /** Стадии по порядку; активная — первая незавершённая. */ + stages: QuestStage[]; +} + +export const QUESTS: QuestDef[] = [ + { + id: 'bells', + title: 'Три цветка', + stages: [ + // Взятие: Ирвин рассказывает про поляны у прудов. + { + npc: 'elder', + text: 'Пепел дышит у прудов. Поговорить с Ирвином', + dialogue: 'elder_first', + doneFlag: FLAGS.quest_bells_taken + }, + // Сбор: активная строка журнала с прогрессом; сдача — Ирвину. + { + npc: 'elder', + text: 'Собрать три колокольчика — Серые пруды, северо-запад лугов', + progressKey: VARS.flowers, + goal: QUEST_FLOWERS, + dialogue: 'elder_hand_in', + ready: { whenVars: [{ key: VARS.flowers, op: 'ge', value: QUEST_FLOWERS }] }, + doneFlag: FLAGS.quest_bells_done + }, + // Эпилог: крючок акта 1 — разговор с Милой после посадки. + { + npc: 'trader', + text: 'Поговорить с Милой — поляна у тропы гудит', + dialogue: 'trader_after', + doneFlag: FLAGS.act2_hook + } + ] + }, + { + // Акт 2: Клинт и его молчащий резонатор (docs/world.md, «Ржавая роща»). + id: 'resonator', + title: 'Голос для резонатора', + stages: [ + // Взятие: ворчливое знакомство — тот же граф ставит met_clint. + { + npc: 'clint', + text: 'Резонатор Клинта молчит. Поговорить с Клинтом', + dialogue: 'clint_first', + doneFlag: FLAGS.quest_resonator_taken + }, + // Сбор тонов: деревья-механизмы рощи (интерактивы), сдача Клинту. + { + npc: 'clint', + text: 'Прозвенеть у деревьев-механизмов — Ржавая роща, восток Звенца', + progressKey: VARS.tones, + goal: QUEST_TONES, + dialogue: 'clint_tune', + ready: { whenVars: [{ key: VARS.tones, op: 'ge', value: QUEST_TONES }] }, + doneFlag: FLAGS.quest_resonator_done + }, + // Эпилог: подсказка о Машине — мост к финалу акта. + { + npc: 'clint', + text: 'Зайти к Клинту — резонатор держит тон', + dialogue: 'clint_repeat', + doneFlag: FLAGS.machine_hook + } + ] + } +]; + +/** Активная стадия: первая незавершённая (эпилог без doneFlag — тоже стадия). */ +export function activeStage(state: GameState, quest: QuestDef): QuestStage | null { + for (const stage of quest.stages) { + if (stage.doneFlag !== undefined && state.hasFlag(stage.doneFlag)) continue; + return stage; + } + return null; +} + +/** + * Диалог NPC по квест-стадиям: активная стадия этого NPC с готовым диалогом. + * world — предикаты мира (сумка) для hasItem-условий. null — repeat-диалог. + */ +export function questDialogueFor(state: GameState, npcId: string, world?: DialogueWorld): string | null { + for (const quest of QUESTS) { + const stage = activeStage(state, quest); + if (!stage || stage.npc !== npcId || !stage.dialogue) continue; + if (stage.ready && !evalConditions(stage.ready, state, world)) continue; + return stage.dialogue; + } + return null; +} + +/** Одна строка журнала квестов. */ +export interface QuestEntry { + done: boolean; + text: string; +} + +/** Журнал: достигнутые стадии квестов. Взятия-стадия видна после принятия — + * журнал не «знает» о квесте раньше героя. */ +export function questLog(state: GameState): QuestEntry[] { + const entries: QuestEntry[] = []; + for (const quest of QUESTS) { + let reached = true; + quest.stages.forEach((stage, i) => { + const done = stage.doneFlag === undefined || state.hasFlag(stage.doneFlag); + // Взятия-стадия (первая) видна, только когда принята (done); + // остальные достигнутые стадии — всегда. + const show = i > 0 || done; + if (show && reached) { + const progress = + stage.progressKey !== undefined && stage.goal !== undefined + ? ` (${Math.min(state.getNumber(stage.progressKey), stage.goal)}/${stage.goal})` + : ''; + entries.push({ done, text: `${stage.text}${progress}` }); + } + if (!done) reached = false; + }); + } + return entries; +} + +/** Трекер HUD: текст первой активной стадии с прогрессом (null — активных нет). */ +export function questTracker(state: GameState): string | null { + for (const quest of QUESTS) { + const stage = activeStage(state, quest); + if (!stage) continue; + const progress = + stage.progressKey !== undefined && stage.goal !== undefined + ? ` (${Math.min(state.getNumber(stage.progressKey), stage.goal)}/${stage.goal})` + : ''; + return `${stage.text}${progress}`; + } + return null; +} \ No newline at end of file diff --git a/v1/apps/game/src/data/saves.ts b/v1/apps/game/src/data/saves.ts new file mode 100644 index 0000000..60005bf --- /dev/null +++ b/v1/apps/game/src/data/saves.ts @@ -0,0 +1,16 @@ +/** + * Слоты сохранений и их подписи: конфиг вместо хардкода в сцене. + * autosave — служебный слот автосейва (Esc в локации), только загрузка. + */ + +export const SAVE_SLOTS = ['autosave', 'slot1', 'slot2', 'slot3'] as const; + +/** Человекочитаемый заголовок слота (для экрана сейвов). */ +export function slotTitle(slot: string): string { + return slot === 'autosave' ? 'Автосохранение' : slot.replace('slot', 'Слот '); +} + +/** Автосейв перезаписывается игрой — удалять его вручную нельзя. */ +export function canDelete(slot: string): boolean { + return slot !== 'autosave'; +} \ No newline at end of file diff --git a/v1/apps/game/src/data/sfxSpecs.ts b/v1/apps/game/src/data/sfxSpecs.ts new file mode 100644 index 0000000..c13c70f --- /dev/null +++ b/v1/apps/game/src/data/sfxSpecs.ts @@ -0,0 +1,106 @@ +/** + * Sfx на спек-синтезе: ключ → SoundSpec (компилирует движок, WAV не нужен). + * Формулы — бывшие генераторы tools/audio/gen.mjs (функции экспортированы + * там же); парити-тест sfxSpecs.test.ts сверяет renderSpec с ними численно, + * поэтому миграция не изменила звучание. + */ +import type { AudioManager, PlayOptions, SoundSpec } from '@rpg/engine'; + +/** Реестр спек-звуков (ключи валидируются тем же validate-тестом, что и WAV). */ +export const SPEC_SFX: Record = { + // Бывший sfx/bell_hit.wav (bellHit): 4 партиала + клик, normalize 0.7. + 'sfx/bell_hit': { kind: 'chime', dur: 0.35, freq: 520, power: 5, seed: 1, peak: 0.7 }, + // Бывший sfx/whoosh.wav (whoosh): воздух «0 → 1 → 0», normalize 0.4. + 'sfx/whoosh': { kind: 'scrape', dur: 0.7, low: 200, high: 2400, seed: 160, swell: true, peak: 0.4 }, + // Бывший sfx/bell_low.wav (bellLow): три тона с тремоло 6 Гц. + 'sfx/bell_low': { + kind: 'tone', dur: 0.9, freq: 110, w: 0.5, power: 2, peak: 0.6, + wobbleHz: 6, wobbleDepth: 0.3, + layers: [{ freq: 165, w: 0.25 }, { freq: 220.5, w: 0.12 }] + }, + // Бывший sfx/ash_hiss.wav (ashHiss): шипение с нарастанием 0.08 с. + 'sfx/ash_hiss': { kind: 'scrape', dur: 0.4, low: 900, high: 4200, seed: 11, power: 2, attack: 0.08, peak: 0.55 }, + // Бывший sfx/ash_die.wav (ashDie): шум, громкость спадает вдвое к концу. + 'sfx/ash_die': { kind: 'scrape', dur: 0.6, low: 300, high: 2400, seed: 23, power: 3, fadeTo: 0.5, peak: 0.6 }, + // Бывший sfx/spit.wav (spit): тон со свипом 700→350 Гц + шипящий хвост. + 'sfx/spit': { + kind: 'tone', dur: 0.2, freq: 700, freqTo: 350, w: 0.6, power: 3, peak: 0.5, + layers: [{ low: 2000, high: 6000, seed: 37, w: 0.4 }] + }, + // Бывший sfx/hurt.wav (hurt): глухой удар 90 Гц + низкий шум. + 'sfx/hurt': { + kind: 'tone', dur: 0.25, freq: 90, w: 0.8, power: 3, peak: 0.65, + layers: [{ low: 80, high: 400, seed: 41, w: 0.3, dur: 0.125, power: 2 }] + }, + // Бывший sfx/mote_pickup.wav (motePickup): двойной звон 1240+1860 Гц + // (у верхнего партиала суммарная крутизна 4+2.5 — два затухания gen.mjs). + 'sfx/mote_pickup': { + kind: 'tone', dur: 0.45, freq: 1240, w: 0.4, power: 2.5, peak: 0.4, + layers: [{ freq: 1860, w: 0.2, power: 6.5 }] + }, + // Бывший sfx/pickup.wav (pickup): «пок» со взлётом 520→740 Гц. + 'sfx/pickup': { kind: 'tone', dur: 0.14, freq: 520, freqTo: 740, power: 2.5, peak: 0.4 }, + // Бывший sfx/chest_open.wav (chestOpen): скрип петель + удар крышки 110 Гц. + 'sfx/chest_open': { + kind: 'scrape', dur: 0.55, low: 300, high: 1400, seed: 157, power: 1.5, w: 0.35, attack: 0.1, peak: 0.45, + layers: [{ freq: 110, w: 0.7, dur: 0.12, power: 2 }] + }, + // Бывший sfx/note.wav (note): шелест с гулким «воблером» 3.2 Гц. + 'sfx/note': { + kind: 'scrape', dur: 0.35, low: 1800, high: 6000, seed: 159, power: 1.5, peak: 0.3, + wobbleHz: 3.2, wobbleDepth: 0.6, wobbleBase: 0.4, wobblePhase: 1 + }, + // Бывший sfx/deer_startle.wav (deerStartle): выдох + стук копыт (блип 90 Гц). + 'sfx/deer_startle': { + kind: 'scrape', dur: 0.4, low: 150, high: 900, seed: 161, power: 2, w: 0.7, attack: 0.05, peak: 0.4, + layers: [{ freq: 90, w: 0.6, dur: 0.08, delay: 0.2 }] + }, + // Бывший sfx/ui_click.wav (uiClick): короткий чистый тон 880 Гц. + 'sfx/ui_click': { kind: 'tone', dur: 0.06, freq: 880, power: 3, peak: 0.4 }, + // Бывший sfx/chime.wav (chime): колокольчик Милы, 1560 Гц + партиал 1.61×. + 'sfx/chime': { + kind: 'tone', dur: 0.5, freq: 1560, w: 0.5, power: 3, peak: 0.45, + layers: [{ freq: 1560 * 1.61, w: 0.25 }] + }, + // Шаги (бывшие sfx/step_*.wav): до unlock играет спек, варианты — + // renderSpec с сидами seed+i·10 (см. systems/StepVariants.ts). + 'sfx/step_grass': { kind: 'hit', dur: 0.12, low: 400, high: 2000, seed: 253, power: 2, peak: 0.3 }, + 'sfx/step_ash': { kind: 'hit', dur: 0.14, low: 200, high: 1100, seed: 254, power: 2.5, peak: 0.3 }, + 'sfx/step_water': { + kind: 'hit', dur: 0.18, low: 600, high: 3200, seed: 255, power: 2, w: 0.6, peak: 0.3, + layers: [{ freq: 300, w: 0.2 }] + }, + 'sfx/step_floor': { + kind: 'hit', dur: 0.1, low: 150, high: 800, seed: 256, power: 3, w: 0.4, peak: 0.3, + layers: [{ freq: 140, w: 0.5 }] + }, + // Дальний шорох пепла («мир живой»): медленный вздох шума, панорама/вариации + // задаёт тик сцены (LocationScene.updateAmbient). + 'sfx/rustle': { + kind: 'scrape', dur: 0.5, low: 400, high: 1800, seed: 263, power: 1.2, attack: 0.15, peak: 0.25 + } +}; + +/** + * Room tone интерьеров: тихий луп-гул «внутри помещения» (шина ambience). + * Ключи — строковые id областей (см. locations.ts), БЕЗ типа AreaId — иначе + * цикл импортов audio.ts → sfxSpecs → locations → interactables → audio.ts. + * Проверка «ключи ⊆ AREAS» — в validate-тестах. + */ +export const ROOM_TONES: Record = { + // Дом Ирвина: очаг + скрип половиц — низкий гул с медленным «дыханием». + house_elder: { kind: 'hum', tone: 52, dur: 8, power: 2, peak: 0.3 }, + // Лавка Милы: чуть выше и с лёгким тремоло (колокольчики на витрине). + shop: { kind: 'hum', tone: 66, dur: 8, power: 3, peak: 0.28, wobbleHz: 0.4, wobbleDepth: 0.15 } +}; + +/** + * Единая точка проигрывания sfx по ключу: спек-звук — через playSpec + * (движок компилирует сам), файловый — через play. Все play-сайты игры + * зовут этот хелпер, а не audio.play напрямую. + */ +export function playSfx(audio: AudioManager, key: string, opts?: number | PlayOptions): void { + const spec = SPEC_SFX[key]; + if (spec) void audio.playSpec(key, spec, opts); + else void audio.play(key, opts); +} \ No newline at end of file diff --git a/v1/apps/game/src/data/transitions.ts b/v1/apps/game/src/data/transitions.ts new file mode 100644 index 0000000..720e612 --- /dev/null +++ b/v1/apps/game/src/data/transitions.ts @@ -0,0 +1,47 @@ +import type { TransitionDef } from './locations'; +import type { ItemId } from './items'; + +/** + * Чистый выбор перехода: по тайлу и триггеру, с условиями и дисармом. + * Вынесен из сцены — тестируется в Vitest без браузера. + */ + +/** Что резолверу нужно знать о герое (подставляется из GameState/Inventory). */ +export interface TransitionCtx { + hasFlag(flag: string): boolean; + hasItem(id: ItemId): boolean; +} + +/** Результат выбора: переход доступен / отказ (заперт) / ничего на этом тайле. */ +export type TransitionPick = + | { ok: true; def: TransitionDef } + | { ok: false; lockedText: string } + | null; + +/** + * Первый переход на тайле с этим триггером. step-переходы на disarmTile + * игнорируются (герой только что вошёл через него — иначе немедленный вылет). + */ +export function resolveTransition( + defs: TransitionDef[], + tile: { x: number; y: number }, + trigger: 'step' | 'click', + ctx: TransitionCtx, + disarmTile?: { x: number; y: number } | null +): TransitionPick { + const def = defs.find( + (t) => + t.tile.x === tile.x && + t.tile.y === tile.y && + (t.trigger ?? 'step') === trigger && + !(trigger === 'step' && disarmTile && t.tile.x === disarmTile.x && t.tile.y === disarmTile.y) + ); + if (!def) return null; + if (def.requiresFlag && !ctx.hasFlag(def.requiresFlag)) { + return { ok: false, lockedText: def.lockedText ?? 'Не пускает.' }; + } + if (def.requiresItem && !ctx.hasItem(def.requiresItem)) { + return { ok: false, lockedText: def.lockedText ?? 'Нужен предмет.' }; + } + return { ok: true, def }; +} \ No newline at end of file diff --git a/v1/apps/game/src/data/validate.ts b/v1/apps/game/src/data/validate.ts new file mode 100644 index 0000000..7885e11 --- /dev/null +++ b/v1/apps/game/src/data/validate.ts @@ -0,0 +1,349 @@ +import { + checkBounds, + checkWalkable, + gridOf, + mergeInvariants, + type Invariant, + type TileMapData, + type DialogueGraph +} from '@rpg/engine'; +import { AREAS } from './locations'; +import { NPCS, NPC_PLACEMENTS } from './npcs'; +import { DIALOGUES } from './dialogues'; +import { ENEMY_KINDS } from './enemies'; +import { QUESTS } from './quests'; +import { FLAGS, VARS } from './ids'; +import { ITEMS } from './items'; +import { TILES } from './map'; +import { AMBIENCE_KEYS, AMBIENCE_LAYER_KEYS } from './audio'; +import { THEMES } from './music'; +import { ROOM_TONES, SPEC_SFX } from './sfxSpecs'; +import { DIALOGUE_CUSTOM } from './effects'; +import { checkGraph, type GraphRefs } from './dialogueRules'; + +/** + * Runtime-валидация контента → инварианты (замена JSON Schema: истина одна — + * в TS-типах; здесь ловим то, что типы не выражают: ссылки, границы, стены). + * Вызывается из GameAgent.invariants() и из юнит-теста — битый контент падает + * сразу в тестах, а не в рантайме игры. Правила графов диалогов — общие с CLI + * dry-run и редактором (data/dialogueRules.ts). + */ + +const WHERE = 'data'; + +/** Реестры для checkGraph (чистые правила знают только множества id). */ +export function graphRefs(usedFlags?: Set, usedVars?: Set): GraphRefs { + return { + flags: new Set(Object.keys(FLAGS)), + vars: new Set(Object.keys(VARS)), + items: new Set(Object.keys(ITEMS)), + customs: new Set(Object.keys(DIALOGUE_CUSTOM)), + strings: new Set(), + usedFlags, + usedVars + }; +} + +/** Граф диалога: все правила checkGraph (ссылки, next, сироты, циклы, концы). */ +export function validateDialogue(id: string, graph: DialogueGraph): Invariant[] { + return checkGraph(id, graph, graphRefs()); +} + +/** NPC: не в стенах, в границах своей области (включая размещения по флагам). */ +export function validateNpcs(maps: Map): Invariant[] { + const out: Invariant[] = []; + for (const npc of NPCS) { + for (const area of Object.values(AREAS)) { + if (!area.npcs.some((n) => n.id === npc.id)) continue; + const map = maps.get(area.id); + if (!map) continue; + const grid = gridOf(map); + const check = mergeInvariants( + checkBounds(`NPC ${npc.id}`, npc.tile, map.width, map.height, WHERE), + checkWalkable(`NPC ${npc.id}`, npc.tile, grid, WHERE) + ); + out.push(...check); + } + } + // Размещения: тайл своей области должен быть проходим всегда — флаг может + // подняться в любой момент, NPC не должен появиться в стене. + for (const p of NPC_PLACEMENTS) { + const map = maps.get(p.area); + if (!map) continue; + const grid = gridOf(map); + out.push(...mergeInvariants( + checkBounds(`NPC ${p.def.id} (placement)`, p.def.tile, map.width, map.height, WHERE), + checkWalkable(`NPC ${p.def.id} (placement)`, p.def.tile, grid, WHERE) + )); + } + return out; +} + +/** Области: спавн/враги проходимы, переходы ведут в существующие области и проходимые entry. */ +export function validateLocations(maps: Map): Invariant[] { + const out: Invariant[] = []; + for (const loc of Object.values(AREAS)) { + const map = maps.get(loc.id); + if (!map) continue; + const grid = gridOf(map); + const where = `${WHERE}/area/${loc.id}`; + out.push(...mergeInvariants( + checkBounds('spawn', loc.spawn, map.width, map.height, where), + checkWalkable('spawn', loc.spawn, grid, where) + )); + loc.enemies.forEach((e, i) => { + out.push(...mergeInvariants( + checkBounds(`враг#${i}`, e.tile, map.width, map.height, where), + checkWalkable(`враг#${i}`, e.tile, grid, where) + )); + // Патруль: точки в границах и проходимы (иначе враг врежется в стену). + (e.patrol?.points ?? []).forEach((p, j) => { + out.push(...mergeInvariants( + checkBounds(`враг#${i} патруль#${j}`, p, map.width, map.height, where), + checkWalkable(`враг#${i} патруль#${j}`, p, grid, where) + )); + }); + }); + // Зоны наката: тайлы в границах карты. + for (const [i, h] of (loc.hazards ?? []).entries()) { + h.tiles.forEach((t, j) => { + out.push(...mergeInvariants(checkBounds(`накат#${i} тайл#${j}`, t, map.width, map.height, where))); + }); + } + // Источники света: центр в границах (дробные at — содержащий тайл), + // id тайлов-светильников существуют; дубли id в одной области — ошибка. + const lightIds = new Set(); + for (const [i, s] of (loc.lighting?.sources ?? []).entries()) { + const lw = `${where}/light#${i}`; + if (lightIds.has(s.id)) { + out.push({ id: 'light-id', severity: 'error', message: `источник света «${s.id}» уже есть в области`, where: lw }); + } + lightIds.add(s.id); + if (s.at) out.push(...mergeInvariants(checkBounds(`light#${i} at`, { x: Math.floor(s.at.x), y: Math.floor(s.at.y) }, map.width, map.height, lw))); + for (const tileId of s.tiles ?? []) { + if (!(Object.values(TILES) as number[]).includes(tileId)) { + out.push({ id: 'light-tile', severity: 'error', message: `light#${i}: тайл ${tileId} не из TILES`, where: lw }); + } + } + if (!s.at && !(s.tiles && s.tiles.length > 0)) { + out.push({ id: 'light-source-empty', severity: 'error', message: `light#${i} «${s.id}»: ни at, ни tiles — источник никогда не включится`, where: lw }); + } + } + for (const obj of loc.interactables ?? []) { + out.push(...mergeInvariants(checkBounds(`объект ${obj.id}`, obj.tile, map.width, map.height, where))); + } + for (const [i, t] of loc.transitions.entries()) { + // Тайл-триггер в границах своей карты (может быть и непроходим — колодец). + out.push(...mergeInvariants(checkBounds(`переход#${i}`, t.tile, map.width, map.height, where))); + if (t.target.kind !== 'area') continue; + const to: string = t.target.area; + if (!(to in AREAS)) { + out.push({ id: 'transition-target', severity: 'error', message: `переход#${i} -> «${to}»: область не существует`, where }); + continue; + } + const targetMap = maps.get(to); + if (targetMap) { + out.push(...mergeInvariants( + checkBounds(`переход#${i} entry (${to})`, t.target.entry, targetMap.width, targetMap.height, where), + checkWalkable(`переход#${i} entry (${to})`, t.target.entry, gridOf(targetMap), where) + )); + } + } + } + return out; +} + +/** Виды врагов: базовая числовая согласованность. */ +export function validateEnemies(): Invariant[] { + const out: Invariant[] = []; + for (const kind of Object.values(ENEMY_KINDS)) { + const where = `${WHERE}/enemy/${kind.id}`; + if (kind.hp <= 0) out.push({ id: 'enemy-hp', severity: 'error', message: `hp = ${kind.hp}`, where }); + if (kind.speed < 0) out.push({ id: 'enemy-speed', severity: 'error', message: `speed = ${kind.speed}`, where }); + } + return out; +} + +/** + * Референциальная целостность строковых сущностей: каждое упоминание флага/ + * вара в контенте существует в реестрах ids.ts (error), каждый ключ реестра + * где-то упомянут (warn — «мёртвый» флаг); ссылки квестов на NPC/диалоги — + * error. Движковые типы — строки, поэтому реестр сверяется только здесь. + */ +export function validateReferences(): Invariant[] { + const out: Invariant[] = []; + const flagsUsed = new Set(); + const varsUsed = new Set(); + + const checkFlag = (flag: string, where: string, what: string): void => { + flagsUsed.add(flag); + if (!(flag in FLAGS)) { + out.push({ id: 'flag-unknown', severity: 'error', message: `${what}: флаг «${flag}» вне реестра FLAGS`, where }); + } + }; + const checkVar = (id: string, where: string, what: string): void => { + varsUsed.add(id); + if (!(id in VARS)) { + out.push({ id: 'var-unknown', severity: 'error', message: `${what}: вар «${id}» вне реестра VARS`, where }); + } + }; + + // Графы диалогов — общие правила checkGraph (и упоминания для dead-проверки). + for (const [id, graph] of Object.entries(DIALOGUES)) { + out.push(...checkGraph(id, graph, graphRefs(flagsUsed, varsUsed))); + } + for (const area of Object.values(AREAS)) { + const where = `${WHERE}/area/${area.id}`; + for (const [i, t] of area.transitions.entries()) { + const tw = `переход#${i} (${t.tile.x},${t.tile.y})`; + if (t.requiresFlag !== undefined) checkFlag(t.requiresFlag, where, tw); + if (t.requiresItem !== undefined && !(t.requiresItem in ITEMS)) { + out.push({ id: 'item-unknown', severity: 'error', message: `${tw}: requiresItem «${t.requiresItem}» вне реестра ITEMS`, where }); + } + } + // Условные источники света: опечатка во флаге = свет никогда не включится. + for (const [i, s] of (area.lighting?.sources ?? []).entries()) { + const lw = `${where}/light#${i}`; + if (s.whenFlag !== undefined) checkFlag(s.whenFlag, lw, 'whenFlag'); + if (s.notFlag !== undefined) checkFlag(s.notFlag, lw, 'notFlag'); + } + // Низины: полотно без реестра предметов = маска никогда не сработает. + for (const [i, h] of (area.hazards ?? []).entries()) { + if (!(h.requiresItem in ITEMS)) { + out.push({ id: 'item-unknown', severity: 'error', message: `накат#${i} «${h.name}»: requiresItem «${h.requiresItem}» вне реестра ITEMS`, where }); + } + } + // Интерьеры (atmosphere 'none') выходят только назад: ровно один return-переход. + if (area.atmosphere === 'none') { + const returns = area.transitions.filter((t) => t.target.kind === 'return').length; + if (returns !== 1) { + out.push({ id: 'interior-return', severity: 'error', message: `интерьер должен иметь ровно один kind:'return' переход (сейчас ${returns})`, where }); + } + } + for (const obj of area.interactables ?? []) { + const w = `${where}/interactable/${obj.id}`; + for (const r of obj.responses) { + if (r.when?.flag !== undefined) checkFlag(r.when.flag, w, 'when.flag'); + if (r.when?.notFlag !== undefined) checkFlag(r.when.notFlag, w, 'when.notFlag'); + for (const f of r.setFlags ?? []) checkFlag(f, w, 'setFlags'); + for (const f of r.clearFlags ?? []) checkFlag(f, w, 'clearFlags'); + if (r.setVar) checkVar(r.setVar.id, w, 'setVar.id'); + if (r.addVar) checkVar(r.addVar.id, w, 'addVar.id'); + if (r.custom !== undefined && !(r.custom in DIALOGUE_CUSTOM)) { + out.push({ id: 'custom-unknown', severity: 'error', message: `${obj.id}: custom «${r.custom}» вне реестра DIALOGUE_CUSTOM`, where: w }); + } + } + } + } + for (const quest of QUESTS) { + const where = `${WHERE}/quest/${quest.id}`; + for (const [i, stage] of quest.stages.entries()) { + if (stage.doneFlag !== undefined) checkFlag(stage.doneFlag, where, `стадия#${i}.doneFlag`); + if (stage.progressKey !== undefined) checkVar(stage.progressKey, where, `стадия#${i}.progressKey`); + // NPC квеста — из основной шпаргалки или размещений по флагам. + if (![...NPCS, ...NPC_PLACEMENTS.map((p) => p.def)].some((n) => n.id === stage.npc)) { + out.push({ id: 'npc-ref', severity: 'error', message: `стадия#${i}: NPC «${stage.npc}» не существует`, where }); + } + if (stage.dialogue !== undefined && !(stage.dialogue in DIALOGUES)) { + out.push({ id: 'dialogue-ref', severity: 'error', message: `стадия#${i}: диалог «${stage.dialogue}» не существует`, where }); + continue; + } + // Стадия с doneFlag должна выставляться setFlags в графе стадии — + // иначе диалог никогда её не завершит. + if (stage.doneFlag !== undefined && stage.dialogue !== undefined) { + const graph = DIALOGUES[stage.dialogue]; + const sets = new Set(); + for (const node of Object.values(graph.nodes)) { + for (const n of [node, ...(node.choices ?? [])]) { + for (const f of n.setFlags ?? []) sets.add(f); + } + } + if (!sets.has(stage.doneFlag)) { + out.push({ id: 'quest-stage-unreachable', severity: 'error', message: `стадия#${i}: doneFlag «${stage.doneFlag}» не выставляется в графе «${stage.dialogue}»`, where }); + } + } + } + } + for (const npc of [...NPCS, ...NPC_PLACEMENTS.map((p) => p.def)]) { + checkFlag(npc.flagKey, `${WHERE}/npc/${npc.id}`, 'flagKey'); + if (!(npc.dialogueFirst in DIALOGUES)) { + out.push({ id: 'dialogue-ref', severity: 'error', message: `NPC ${npc.id}: dialogueFirst «${npc.dialogueFirst}» не существует`, where: `${WHERE}/npc/${npc.id}` }); + } + if (!(npc.dialogueRepeat in DIALOGUES)) { + out.push({ id: 'dialogue-ref', severity: 'error', message: `npc ${npc.id}: dialogueRepeat «${npc.dialogueRepeat}» не существует`, where: `${WHERE}/npc/${npc.id}` }); + } + } + + // Мёртвые сущности реестра: ключ не упомянут нигде в контенте (warn). + for (const flag of Object.keys(FLAGS)) { + if (!flagsUsed.has(flag)) { + out.push({ id: 'flag-dead', severity: 'warn', message: `флаг «${flag}» из реестра не упоминается в контенте`, where: WHERE }); + } + } + for (const id of Object.keys(VARS)) { + if (!varsUsed.has(id)) { + out.push({ id: 'var-dead', severity: 'warn', message: `вар «${id}» из реестра не упоминается в контенте`, where: WHERE }); + } + } + return out; +} + +/** + * Аудио-ключи контента: опечатка в ключе = тишина в рантайме (никто не упадёт). + * Лупы/темы/слои — по реестрам audio.ts и music.ts; sfx реакций и реплик — по + * спек-реестру; roomtone/* — ключ обязан быть id существующей области. + */ +export function validateAudio(): Invariant[] { + const out: Invariant[] = []; + const sfx = new Set(Object.keys(SPEC_SFX)); + for (const area of Object.values(AREAS)) { + const where = `${WHERE}/area/${area.id}`; + if (area.ambience !== undefined && !AMBIENCE_KEYS.includes(area.ambience as never)) { + out.push({ id: 'audio-key', severity: 'error', message: `ambience «${area.ambience}» вне AMBIENCE_KEYS`, where }); + } + if (area.theme !== undefined && !(area.theme in THEMES)) { + out.push({ id: 'audio-key', severity: 'error', message: `theme «${area.theme}» вне THEMES`, where }); + } + (area.ambienceLayers ?? []).forEach((layer, i) => { + if (!AMBIENCE_LAYER_KEYS.includes(layer.key as never)) { + out.push({ id: 'audio-key', severity: 'error', message: `ambienceLayer#${i} «${layer.key}» вне AMBIENCE_LAYER_KEYS`, where }); + } + }); + for (const obj of area.interactables ?? []) { + for (const [i, r] of obj.responses.entries()) { + if (r.sound !== undefined && !sfx.has(r.sound)) { + out.push({ id: 'audio-key', severity: 'error', message: `${obj.id} response#${i}: sfx «${r.sound}» вне спек-реестра`, where: `${where}/${obj.id}` }); + } + } + } + } + for (const [id, graph] of Object.entries(DIALOGUES)) { + for (const [nodeId, node] of Object.entries(graph.nodes)) { + for (const n of [node, ...(node.choices ?? [])]) { + for (const op of n.do ?? []) { + if (op.kind === 'sound' && op.id !== undefined && !sfx.has(op.id)) { + out.push({ id: 'audio-key', severity: 'error', message: `узел «${nodeId}»: sfx «${op.id}» вне спек-реестра`, where: `${WHERE}/dialogue/${id}` }); + } + } + } + } + } + for (const key of Object.keys(ROOM_TONES)) { + if (!(key in AREAS)) { + out.push({ id: 'audio-key', severity: 'error', message: `roomtone «${key}»: области не существует`, where: `${WHERE}/roomtone` }); + } + } + return out; +} + +/** Весь контент разом (для снапшота моста и тестов). */ +export function validateContent(maps: Map): Invariant[] { + return mergeInvariants( + ...Object.entries(DIALOGUES).map(([id, g]) => validateDialogue(id, g)), + validateNpcs(maps), + validateLocations(maps), + validateEnemies(), + validateAudio(), + validateReferences() + ); +} \ No newline at end of file diff --git a/v1/apps/game/src/main.ts b/v1/apps/game/src/main.ts new file mode 100644 index 0000000..9d0070f --- /dev/null +++ b/v1/apps/game/src/main.ts @@ -0,0 +1,74 @@ +import { Engine, computeScale, ensurePixelFont, type EngineOptions } from '@rpg/engine'; +import { Game } from './Game'; +import { BootScene } from './scenes/BootScene'; + +async function main(): Promise { + const container = document.getElementById('game')!; + + const options: EngineOptions = { + virtualWidth: Game.VIRTUAL_W, + virtualHeight: Game.VIRTUAL_H, + scale: computeScale(Game.VIRTUAL_W, Game.VIRTUAL_H, window.innerWidth, window.innerHeight), + background: 0x0b0b12, + fixedFps: 60, + maxFps: 30, // лок отрисовки: шаги логики копятся и выполняются пачкой + parent: container + }; + + const engine = new Engine(options); + await engine.init(); + engine.input.bindActions({ + advance: ['Space', 'Enter'], + attack: ['Space'], // вне диалога: тап — удар, удержание — резонанс + interact: ['KeyE'], // действие вблизи: NPC/объекты/цветы/переходы (только в локации) + menu: ['Escape'], + inventory: ['KeyI'], + debug: ['F3'], + debugChar: ['KeyP'], + debugZoom: ['KeyZ'], // отладка зума: уровни 1 <-> 2 (целые — пиксель-точность) + up: ['KeyW', 'ArrowUp'], + down: ['KeyS', 'ArrowDown'], + left: ['KeyA', 'ArrowLeft'], + right: ['KeyD', 'ArrowRight'] + }); + engine.input.bindGamepad({ + advance: [0], // A + attack: [2], // X + interact: [0], // A — действие в локации (в диалоге та же кнопка = advance) + menu: [9], // Start + inventory: [3], // Y + up: [12], + down: [13], + left: [14], + right: [15] + }); + + // Пиксельный шрифт движка (VT323): до первого текста; фолбэк monospace безопасен. + void ensurePixelFont(Game.FONT_URL); + + const game = new Game(engine); + + // Аудио разрешено только после жеста пользователя: разблокируем на первом вводе. + const unlockAudio = (): void => { + void game.audio.unlock(); + window.removeEventListener('pointerdown', unlockAudio); + window.removeEventListener('keydown', unlockAudio); + }; + window.addEventListener('pointerdown', unlockAudio); + window.addEventListener('keydown', unlockAudio); + + // Автосейв при закрытии/скрытии вкладки: localStorage.write синхронен — + // успеваем до выгрузки (активная локация знает своё состояние). + const saveOnLeave = (): void => { + game.activeLocation?.autosave(); + }; + window.addEventListener('beforeunload', saveOnLeave); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') saveOnLeave(); + }); + + await engine.scenes.push(new BootScene(game)); + await engine.start(); +} + +void main(); \ No newline at end of file diff --git a/v1/apps/game/src/scenes/BootScene.ts b/v1/apps/game/src/scenes/BootScene.ts new file mode 100644 index 0000000..b772595 --- /dev/null +++ b/v1/apps/game/src/scenes/BootScene.ts @@ -0,0 +1,106 @@ +import { Container, Graphics, PixelText, ensurePixelFont, type Scene } from '@rpg/engine'; +import { Game } from '../Game'; +import { AREAS, areaOf } from '../data/locations'; +import { AMBIENCE_KEYS, AMBIENCE_LAYER_KEYS, areaAudioKeys } from '../data/audio'; +import { MenuScene } from './MenuScene'; + +/** Атласы персонажей (Spritesheet JSON + PNG). */ +const ATLAS_KEYS = ['chars/hero_sheet.json', 'chars/clumps_sheet.json', 'chars/fauna_sheet.json', 'chars/npcs_sheet.json']; + +/** + * Загрузочная сцена: грузит ассеты с прогресс-баром, затем меню. + */ +export class BootScene implements Scene { + private view = new Container(); + private bar: Graphics; + private started = false; + private progress = -1; + + constructor(private game: Game) { + const label = new PixelText({ + text: 'пепел оседает...', + size: 11, + color: 0x8a8a9a + }); + label.anchor.set(0.5); + label.position.set(240, 120); + this.bar = new Graphics(); + this.view.addChild(label, this.bar); + this.game.renderer.uiRoot.addChild(this.view); + } + + enter(): void {} + + exit(): void { + this.view.destroy({ children: true }); + } + + update(_dt: number): void { + if (!this.started) { + this.started = true; + void this.loadAll(); + } + if (this.progress >= 0) { + this.drawBar(this.progress); + this.progress = -1; // перерисовать один раз на изменение + } + } + + /** + * Параллельная загрузка всех групп с общим прогресс-баром: текстуры (0.6), + * атласы (0.15), карты (0.15), шрифт + амбиент стартовой области (0.1). + * Остальные лупы амбиента декодируются лениво при входе в область + * (LocationScene.enter -> game.preloadAudio) — до меню их ждать не нужно. + */ + private async loadAll(): Promise { + const areaIds = Object.keys(AREAS); + let texP = 0; + let atlasP = 0; + let mapP = 0; + const setProgress = () => { + this.progress = texP * 0.6 + atlasP * 0.15 + mapP * 0.15; + }; + try { + await Promise.all([ + this.game.assets.load(Game.ASSET_KEYS, (p) => { + texP = p; + setProgress(); + }), + ...ATLAS_KEYS.map((key) => + this.game.assets.loadAtlas(key).then(() => { + atlasP += 1 / ATLAS_KEYS.length; + setProgress(); + }) + ), + ...areaIds.map((id) => + this.game.loadMap(id).then(() => { + mapP += 1 / areaIds.length; + setProgress(); + }) + ), + ensurePixelFont(Game.FONT_URL), + (async () => { + // Регистрация ключей дешёвая (URL в карту); декодируем только + // лупы стартовой области, чтобы амбиент сразу не тормозил. + for (const key of [...AMBIENCE_KEYS, ...AMBIENCE_LAYER_KEYS]) { + this.game.audio.register(key); + } + await Promise.all(areaAudioKeys(areaOf(undefined)).map((k) => this.game.audio.preload(k))); + })() + ]); + this.progress = 1; + console.log('[boot] ассеты загружены, меню...'); // маяк для смоук-тестов + void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); + } catch (err) { + console.error('[boot] ошибка загрузки:', err); + } + } + + private drawBar(p: number): void { + this.bar.clear(); + this.bar.rect(180, 136, 120, 6).fill(0x23232b); + this.bar.rect(180, 136, Math.round(120 * p), 6).fill(0xd99a32); + } + + render(): void {} +} \ No newline at end of file diff --git a/v1/apps/game/src/scenes/InventoryScene.ts b/v1/apps/game/src/scenes/InventoryScene.ts new file mode 100644 index 0000000..fa29265 --- /dev/null +++ b/v1/apps/game/src/scenes/InventoryScene.ts @@ -0,0 +1,156 @@ +import { playSfx } from '../data/sfxSpecs'; +import { Container, MenuList, MenuSceneBase, Panel, PixelText, Sprite } from '@rpg/engine'; +import { Game } from '../Game'; +import { questLog } from '../data/quests'; +import { itemName, useItemLine, type ItemId } from '../data/items'; +import { MapScene } from './MapScene'; + +/** + * Сумка и журнал квестов (панель поверх локации, push/pop). + * Содержимое собирается из флагов/варов GameState при каждом открытии. + * Список сумки с курсором: Enter — применить предмет (часы показывают время, + * время в сумке заморожено — локация не тикается). Журнал — текст без фокуса. + */ + +/** Иконка предмета 8×8 (арт-библия §4): ui/icon_.png. */ +const ITEM_ICONS: Record = { + clock: 'ui/icon_clock', + cloth: 'ui/icon_cloth', + bellflower: 'ui/icon_bellflower', + salt: 'ui/icon_salt', + water_flask: 'ui/icon_flask', + mote: 'ui/icon_mote', + map_scroll: 'ui/icon_map' +}; +export class InventoryScene extends MenuSceneBase { + /** Иконки строк сумки (синхронизируются с окном скролла). */ + private icons: Container | null = null; + + constructor( + private game: Game, + private onBack: () => void + ) { + super( + { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, + { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', extra: ['inventory'] } + ); + } + + override update(dt: number): void { + super.update(dt); + this.syncIcons(); + } + + /** Иконки следуют за окном скролла: видимость и строка — как у кнопок. */ + private syncIcons(): void { + if (!this.icons || !this.menu) return; + const win = this.menu.window ?? { first: 0, last: this.icons.children.length }; + this.icons.children.forEach((icon, i) => { + icon.visible = i >= win.first && i < win.last; + icon.position.y = 7 + (i - win.first) * 15; + }); + } + + protected build(): void { + const panel = new Panel({ width: 300, height: 222 }); + panel.position.set((Game.VIRTUAL_W - 300) / 2, (Game.VIRTUAL_H - 222) / 2); + + const title = new PixelText({ text: 'ЗВОНАРЬ', size: 14, color: 0xd8c79a }); + title.anchor.set(0.5); + title.position.set(150, 14); + panel.addChild(title); + + const addLine = (text: string, color: number, x: number, y: number): void => { + const t = new PixelText({ text, size: 10, color }); + t.position.set(x, y); + panel.addChild(t); + }; + + // Статус-строка: подсказка до первого применения, результат — после. + const result = new PixelText({ text: 'Enter — применить, Esc — назад', size: 9, color: 0xf2d49a }); + result.position.set(16, 28); + panel.addChild(result); + + // Сумка: список с курсором и скроллом (5 строк), Enter — применить. + addLine('Сумка', 0x999988, 16, 40); + const slots = this.game.inventory.all; + if (slots.length > 0) { + this.menu = new MenuList({ width: 260, height: 14, gap: 1, size: 10, visibleRows: 5 }); + this.menu.position.set(20, 52); + this.menu.setItems( + slots.map((slot) => ({ + label: slot.count > 1 ? `${itemName(slot.id)} ×${slot.count}` : itemName(slot.id), + value: '', // метка прижата влево — освобождаем место под иконку + onSelect: () => this.useItem(slot.id, result) + })) + ); + panel.addChild(this.menu); + // Иконки 8×8 слева от строк: тикаются с окном скролла (syncIcons). + this.icons = new Container(); + this.icons.position.set(20, 52); + slots.forEach((slot, i) => { + const icon = new Sprite(this.game.assets.texture(ITEM_ICONS[slot.id as ItemId])); + icon.anchor.set(0.5, 0.5); + icon.position.set(-6, 7 + i * 15); + this.icons!.addChild(icon); + }); + panel.addChild(this.icons); + } else { + addLine('— пусто —', 0x777788, 24, 54); + } + + // Журнал квестов — текущие задачи (незавершённые достигнутые стадии), + // без фокуса; перенос по ширине панели, шаг — по фактической высоте. + addLine('Журнал', 0x999988, 16, 132); + let y = 146; + let shown = 0; + for (const entry of questLog(this.game.state)) { + if (entry.done) continue; + const line = new PixelText({ + text: `· ${entry.text}`, + size: 10, + color: 0xaaaaaa, + wordWrapWidth: 264 + }); + line.position.set(24, y); + panel.addChild(line); + y += Math.ceil(line.height) + 3; + shown++; + } + if (shown === 0) addLine('— текущих задач нет —', 0x777788, 24, 146); + + this.view.addChild(panel); + this.game.renderer.uiRoot.addChild(this.view); + } + + /** Применить предмет: юза́бельные дают строку-результат, остальные молчат. */ + private useItem(id: string, result: PixelText): void { + // Карта окрестностей — не строка, а оверлей поверх сумки. + if (id === 'map_scroll') { + playSfx(this.game.audio, 'sfx/ui_click'); + void this.game.scenes.push( + new MapScene(this.game, () => void this.game.scenes.pop()), + { duration: 0.2 } + ); + return; + } + const line = useItemLine(id, { label: this.game.clock.label }); + if (line === null) return; + playSfx(this.game.audio, 'sfx/ui_click'); + result.text = line; + } + + /** Esc или клавиша инвентаря — назад в локацию. */ + protected onCancel(): void { + this.close(); + } + + protected onAction(action: string): void { + if (action === 'inventory') this.close(); + } + + private close(): void { + playSfx(this.game.audio, 'sfx/ui_click'); + this.onBack(); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/scenes/LocationScene.ts b/v1/apps/game/src/scenes/LocationScene.ts new file mode 100644 index 0000000..a9e1043 --- /dev/null +++ b/v1/apps/game/src/scenes/LocationScene.ts @@ -0,0 +1,1026 @@ +import { + Container, + IsoDepthLayer, + Lighting, + Sprite, + SpriteMotion, + Texture, + IsometricTileMap, + FxLayer, + emberPreset, + readSeconds, + createRng, + worldToScreen, + tileToWorld, + DebugOverlay, + SpriteDebugView, + VirtualJoystick, + CutsceneRunner, + dayNightFactor, + PostFx, + unitsToPx, + sunShadow, + type Camera, + type GameState, + type Invariant, + type JsonValue, + type Scene, + type SnapshotLayer, + type ShimmerParams, + type ShimmerState, + type Vec2, + type DialogueWorld +} from '@rpg/engine'; +import { Game } from '../Game'; +import { MenuScene } from './MenuScene'; +import { SAVE_VERSION, type SaveData } from './saveData'; +import { InventoryScene } from './InventoryScene'; +import { TILES, TREE_TILES } from '../data/map'; +import { areaAudioKeys } from '../data/audio'; +import { playSfx } from '../data/sfxSpecs'; +import { areaOf, type AreaDef, type AreaId, type TransitionDef } from '../data/locations'; +import { AudioSystem } from '../systems/AudioSystem'; +import type { StepSurface } from '../systems/StepVariants'; +import { InteractionRouter } from '../systems/InteractRouting'; +import { SceneAgentView } from '../agent/SceneAgentView'; +import type { NpcDef } from '../data/npcs'; +import { npcsForArea } from '../data/npcs'; +import { ActorWalker } from '../systems/cutscene/CutsceneActors'; +import { DIALOGUES } from '../data/dialogues'; +import { questDialogueFor, questTracker } from '../data/quests'; +import { DIALOGUE_CUSTOM, type DialogueCustomId } from '../data/effects'; +import type { ItemId } from '../data/items'; +import { FLAGS, VARS } from '../data/ids'; +import { PlayerController } from '../systems/PlayerController'; +import { GameShadows } from '../systems/Shadows'; +import { FaunaSystem } from '../systems/fauna/FaunaSystem'; +import { DialogueSystem } from '../systems/DialogueSystem'; +import { CombatWorld } from '../systems/combat/CombatWorld'; +import { CombatViews } from '../systems/combat/CombatViews'; +import { CombatFlow } from '../systems/combat/CombatFlow'; +import { PLAYER_COMBAT } from '../systems/combat/stats'; +import { Interactables } from '../systems/Interactables'; +import { GameLighting, MAX_LIGHTS, type HeroLampDef } from '../systems/Lighting'; +import { SceneObjects } from '../systems/SceneObjects'; +import { InteractHint } from '../systems/InteractHint'; +import { LocationAudio } from '../systems/LocationAudio'; +import { InteractableViews, makeExitGlow } from '../systems/InteractableViews'; +import { LocationHud } from '../systems/LocationHud'; +import { Atmosphere } from '../systems/Atmosphere'; +import { FlowerQuest } from '../systems/quest/FlowerQuest'; +import { ResonatorQuest } from '../systems/quest/ResonatorQuest'; +import { enemyTextures, heroTextures, tileTextures } from '../data/assetSets'; + +/** Лампа героя: тёплый, компактный, слегка мерцает (прорезает ночную тьму). */ +const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.0, intensity: 1.0, flicker: 0.12 }; + +/** + * Источники марева (пост-эффект «горячий воздух») по id интерактива: + * усыпленная Машина не дымит; очаг горит всегда. radiusUnits — юниты. + */ +const SHIMMER_SOURCES: Record = { + old_machine: { + amp: 1.4, + wavelength: 26, + speed: 1.8, + radiusUnits: 2.5, + visible: (s) => !s.hasFlag(FLAGS.machine_lulled) + }, + hearth_elder: { amp: 0.9, wavelength: 22, speed: 1.3, radiusUnits: 2, visible: () => true } +}; + +/** Осыпание пепла с крон: период спавна порции (сек) на все видимые деревья. */ +const CROWN_ASH_PERIOD = 0.9; + +/** + * Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc. + * Сцена — оркестратор вьюх: аудио/атмосфера/квест/вьюхи объектов живут + * своими подсистемами (systems/), а здесь только их сборка и тик. + */ +export class LocationScene implements Scene { + private world = new Container(); + private map: IsometricTileMap; + private player: PlayerController; + private dialogue: DialogueSystem; + /** Сюжетные custom-эффекты, отложенные до конца диалога. */ + private pendingCustom = new Set(); + private npcs: { def: NpcDef; view: Container; body: Sprite }[] = []; + private actors = new IsoDepthLayer(); + /** Аудио локации: слои/roomtone/шорохи/музыка угрозы. */ + private locationAudio: LocationAudio; + /** Атмосфера: пепел/мотыли/дымка, зоны наката, виньетка. */ + private atmosphere: Atmosphere; + /** Квест «Три цветка»: сбор/посадка/кат-сцена сдачи. */ + private flowers: FlowerQuest; + /** Квест резонатора (акт 2): кат-сцена настройки у Клинта. */ + private resonator: ResonatorQuest; + /** Вьюхи интерактивных объектов и NPC (фабрики + растворение). */ + private interactViews: InteractableViews; + /** Мигание при неуязвимости — процедурный blink (duty живой). */ + private heroMotion: SpriteMotion; + /** Название локации, подсказка, счётчик мотов (одна вьюха в uiRoot). */ + private hud: LocationHud; + private camera: Camera; + + // --- бой --- + private combat: CombatWorld; + private combatViews: CombatViews; + /** Боевая обвязка героя: ввод/резонанс/урон/респаун/hp-бар/кольцо заряда. */ + private flow: CombatFlow; + /** Слой мировых эффектов (кольца, частицы, растворения) — тикается engine.fx. */ + private fx: FxLayer; + /** Слой UI-эффектов (тосты): clear() не гасит мировые эффекты. */ + private uiFx: FxLayer; + /** Маршрутизация клика/переходов/отложенных взаимодействий. */ + private router: InteractionRouter; + /** Подпись «[E] Имя» над ближайшей целью кнопки действия. */ + private hint: InteractHint; + /** Интерактивные объекты области (сундуки, очаги, прилавки). */ + private interactables: Interactables; + /** Объекты сцены на движковом реестре (кто на тайле — для кликов/коллизий). */ + private objects: SceneObjects; + /** Освещение: движковый слой (lightRoot) + игровая обвязка. */ + private lightView: Lighting; + /** Пост-эффекты мира: блюр резонанса, аберрация урона, марево источников. */ + private postfx: PostFx; + /** Тени мира: блобы актёров + направленные тени tall-объектов. */ + private shadows: GameShadows; + /** + * Ручная зона марева моста (scene:postfx shimmer): перекрывает авто-источники. + * undefined — авто-режим (SHIMMER_SOURCES), null — выключено насовсем. + */ + private manualShimmer: ShimmerState | null | undefined; + private lighting: GameLighting; + /** Пейзажная фауна (безгласные олени). */ + private fauna: FaunaSystem; + /** Позиционный звук мира + события боя (звук — подпиской). */ + private worldAudio: AudioSystem; + /** off() подписок worldAudio (снять в exit). */ + private audioOff: () => void; + /** off() подписок на шину событий боя (снять в exit). */ + private eventOffs: (() => void)[] = []; + /** Кат-сцены (раннер шагов; на время сцены геймплей на паузе). */ + private cutscene = new CutsceneRunner(); + /** Ходоки актёров кат-сцен, по def NPC (создаются лениво). */ + private walkers = new Map(); + /** Тач-джойстик (активен только для касаний). */ + private joystick: VirtualJoystick; + private debug: DebugOverlay; + private charDebug: SpriteDebugView; + /** Последний тост (текст + тик) — канал текста для агентного моста. */ + private lastToast: { text: string; tick: number } | null = null; + /** Агентный мост сцены (снапшот/инварианты/команды). */ + private agentView: SceneAgentView; + /** Позиции тайлов-источников амбиент-слоёв, по id (собираются один раз). */ + private layerTilePos = new Map(); + /** Тайлы деревьев области (все виды) — источники осыпающегося пепла. */ + private crownTiles: Vec2[] = []; + /** Осыпание пепла с крон: накопитель периода, rng и счётчик seed частиц. */ + private crownAshT = 0; + private readonly crownAshRng = createRng(20260908); + private crownAshSeed = 1; + + constructor( + private game: Game, + save: SaveData | null, + private area: AreaDef, + /** Точка входа при переходе из другой области (приоритетнее save). */ + entry?: { x: number; y: number }, + /** Откуда вошли (для target kind 'return'): область + тайл триггера. */ + private returnTo?: { area: AreaId; entry: { x: number; y: number } } + ) { + this.camera = game.engine.camera; + const data = this.game.mapFiles.get(area.id)!; + this.map = new IsometricTileMap(data, tileTextures(this.game.assets)); + // Живая вода: один общий таймлайн на все клетки с id WATER (2 кадра). + this.map.setTileAnimation( + TILES.WATER, + [this.game.assets.texture('tiles/water_1'), this.game.assets.texture('tiles/water_2')], + 2 + ); + // Качание крон: у каждого вида дерева свои 2 кадра и свой таймлайн + // (fps слегка разный — виды качаются не в такт; деревья одного вида — + // синхронно, как вода). + for (const [id, name, fps] of [ + [TILES.TREE, 'tree', 1.5], + [TILES.TREE_V1, 'tree_2', 1.7], + [TILES.TREE_V2, 'tree_3', 1.3] + ] as const) { + this.map.setTileAnimation( + id, + [this.game.assets.texture(`tiles/${name}`), this.game.assets.texture(`tiles/${name}_sway`)], + fps + ); + } + this.world.addChild(this.map.view, this.actors); + // Выход интерьера: бронзовый контур проёма + подпись (у kind 'return'). + const exit = area.transitions.find((t) => t.target.kind === 'return'); + if (exit) { + const glow = makeExitGlow(this.tileCenter(exit.tile.x, exit.tile.y)); + this.world.addChildAt(glow, 1); // поверх пола карты, под акторами + } + // Источники амбиент-слоёв: позиции тайлов по id — один проход по карте. + for (let y = 0; y < data.height; y++) { + for (let x = 0; x < data.width; x++) { + const id = data.tiles[y * data.width + x]!; + const list = this.layerTilePos.get(id) ?? []; + list.push({ x, y }); + this.layerTilePos.set(id, list); + } + } + // Тайлы деревьев (все виды) — для осыпания пепла с крон. + this.crownTiles = TREE_TILES.flatMap((id) => this.layerTilePos.get(id) ?? []); + // Tall-объекты (деревья, дома-тайлы, валуны) — в общий depth-слой: + // герой за деревом/домом действительно прячется (глубина tx+ty, как у героев). + this.map.setTallDepthLayer(this.actors); + // Крупные объекты карты (дома) — в общий depth-слой по footprint'у. + for (const p of this.map.propViews) this.actors.addRect(p.view, p.prop.x, p.prop.y, p.prop.w, p.prop.h); + // Тени: ground-слой между картой и актёрами + направленные тени tall-объектов. + // data.tall — таблица «id тайла → TallSpec»; tall-ячейки ищем в tiles. + this.shadows = new GameShadows(this.game.renderer); + this.world.addChildAt(this.shadows.ground, 1); // поверх пола, под акторами + for (let idx = 0; idx < data.tiles.length; idx++) { + const raw = data.tall?.[data.tiles[idx]!]; + if (!raw) continue; + const spec = typeof raw === 'number' ? { height: raw } : raw; + const tx = idx % data.width; + const ty = Math.floor(idx / data.width); + this.shadows.addStatic(this.tileCenter(tx, ty), 22, unitsToPx(spec.height)); + } + this.game.renderer.worldRoot.addChild(this.world); + // Слой мировых эффектов (раньше вьюх объектов: consume растворяет через него). + this.fx = new FxLayer(); + this.world.addChild(this.fx); + this.game.engine.fx.add(this.fx); + // Единый реестр объектов сцены: NPC, интерактивы, пропы (враги/фауна + // добавляются позже своими системами). + this.objects = new SceneObjects(area, this.map); + + const startTile = entry ?? save?.pos ?? area.spawn; + // Тайл входа: step-переходы на нём глушатся, пока герой с него не ушёл + // (иначе вошёл в дверь — и немедленно вылетел обратно). + const disarmTile = entry ? { ...startTile } : null; + if (save?.state) this.game.state.load(save.state); + // Позиционный звук: слушатель — герой. + this.worldAudio = new AudioSystem({ + audio: this.game.audio, + events: this.game.engine.events, + getListener: () => this.player.position + }); + this.audioOff = this.worldAudio.attach(); + this.player = new PlayerController(this.map, heroTextures(this.game.assets), startTile, () => { + this.worldAudio.playStep(this.stepSurface(this.player.currentTile()), this.player.position, 0.35); + // Шаги — тихий шум (0.35): бодрых сгустков настораживает, спящих не будит. + this.combat.noise(this.player.position, 0.35); + }); + // Тела: герой не проходит сквозь NPC (расталкивание через реестр). + this.player.setBodies(this.objects.registry); + // Блоб-тень героя (первый ребёнок вьюхи — рисуется под спрайтом). + this.shadows.attachActor(this.player.view, 9, 12); + + // Мигание героя при неуязвимости — процедурный blink (duty меняется на лету). + this.heroMotion = new SpriteMotion(this.player.view, { blink: { period: 0.25, duty: 1 } }); + this.game.engine.fx.add(this.heroMotion); + + // Видимый фонарь на поясе (правый бок): свет HERO_LAMP — «от фонаря». + const lampSprite = new Sprite(this.game.assets.texture('chars/hero_lamp')); + lampSprite.anchor.set(0.5); + lampSprite.position.set(4, -10); + this.player.view.addChild(lampSprite); + + // Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе). + this.actors.add(this.player.view, startTile.x, startTile.y); + this.interactViews = new InteractableViews({ + assets: this.game.assets, + addFx: (u) => this.game.engine.fx.add(u), + fx: this.fx + }); + // NPC области: статичные из контента + размещения по флагам (акт 2). + // Снапшот/клики читают this.npcs — размещения подхватываются сами. + const placedNpcs = [...area.npcs, ...npcsForArea(area.id, (f) => this.game.state.hasFlag(f))]; + for (const def of placedNpcs) { + const made = this.interactViews.createNpcView(def, (tx, ty) => this.tileCenter(tx, ty)); + this.actors.add(made.view, def.tile.x, def.tile.y); + this.shadows.attachActor(made.view, 9, 12); + this.npcs.push({ def, view: made.view, body: made.body }); + if (!area.npcs.includes(def)) this.objects.addNpc(def); + } + + // Интерактивные объекты: те же вьюхи в actors-слое, реакции — через Interactables. + this.interactables = new Interactables(area.interactables ?? [], this.game.state, { + hasItem: (id) => this.game.inventory.has(id), + give: (id, n) => this.game.inventory.add(id, n), + playSound: (k) => this.worldAudio.playAt(k, this.player.position, 0.6), + showToast: (t) => this.showToast(t), + onCustom: (name) => this.onSceneCustom(name), + onConsumed: (def) => this.interactViews.consume(def, this.tileCenter, (s) => this.combatViews.hitBurst(s)) + }); + for (const def of area.interactables ?? []) { + // Одноразовые использованные объекты (моты, сундуки) не рисуем: + // флаг used живёт в GameState, вьюха при повторном входе возвращаться не должна. + if (def.once && this.interactables.isUsed(def.id)) continue; + const made = this.interactViews.createInteractableView(def, (tx, ty) => this.tileCenter(tx, ty)); + this.actors.add(made.view, def.tile.x, def.tile.y); + } + + // --- боевая система: враги на ECS, звуки/события/урон героя — через колбэки --- + this.combat = new CombatWorld( + { + map: this.map, + events: this.game.engine.events, + getPlayerPos: () => this.player.position, + damagePlayer: (dmg, from) => this.flow.onPlayerDamaged(dmg, from), + registry: this.objects.registry + }, + (kind) => { + // счётчики прогресса — в vars GameState + this.game.state.setVar(VARS.kills, this.game.state.getNumber(VARS.kills) + 1); + this.game.state.setVar(VARS.motes, this.game.state.getNumber(VARS.motes) + kind.motes); + } + ); + for (const s of area.enemies) { + this.combat.spawnEnemy(s.kind, tileToWorld(s.tile.x, s.tile.y), s.patrol ? { patrol: s.patrol } : undefined); + } + // Слой UI-эффектов (тосты) поверх uiRoot. + this.uiFx = new FxLayer(); + this.game.renderer.uiRoot.addChild(this.uiFx); + this.game.engine.fx.add(this.uiFx); + this.combatViews = new CombatViews( + this.combat, + this.actors, + enemyTextures(this.game.assets), + this.world, + this.fx, + this.game.assets, + this.shadows + ); + + // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира (в CombatFlow). + + // Пейзажная фауна: безгласные олени у берегов (сама по себе, вне боя). + this.fauna = new FaunaSystem( + this.actors, + this.game.engine.events, + data, + (area.fauna ?? []).map((t) => tileToWorld(t.x, t.y)), + this.objects.registry + ); + this.fauna.setFrames( + this.game.assets.animation('chars/fauna_sheet.json', 'fauna_deer_walk') as [Texture, Texture] + ); + // Мотыли над потревоженным пеплом: серые, дрейф вверх от точки звона + // (через слой эффектов: тик и зачистка — сами; раньше эмиттер не тикался). + this.eventOffs.push( + this.game.engine.events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => { + const s = worldToScreen(origin.x, origin.y); + this.fx.burst(6, { x: s.x, y: s.y - 8 }, emberPreset(0x8a8a96, { + blend: 'normal', + fadeIn: 0, + lifetime: [1.2, 2.2], + velocity: { x: [-6, 6], y: [-14, -8] }, + seed: ((origin.x * 13 + origin.y) | 0) || 1 + })); + }) + ); + + this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, this.game.state, { + width: Game.VIRTUAL_W, + height: Game.VIRTUAL_H, + margin: 8, + typewriter: 40, + moodColors: { sad: 0x9aaad8, angry: 0xd89a9a, warm: 0xd8c79a } + }); + this.dialogue.onDialogueFinished = () => this.onDialogueFinished(); + this.dialogue.onLineShown = () => void this.game.audio.play('sfx/chime', 0.5); + this.dialogue.registerSink({ + giveItem: (id, count) => this.game.inventory.add(id, count), + takeItem: (id, count) => this.game.inventory.remove(id, count), + playSound: (key) => void this.game.audio.play(key, 0.8), + showToast: (text) => this.showToast(text), + custom: (name) => { + // Сюжетные эффекты — по завершении диалога (кат-сцена не рвёт реплику). + this.pendingCustom.add(name); + } + }); + + // Квест «Три цветка»: сбор/посадка/кат-сцена — свои изменения тайлов. + this.flowers = new FlowerQuest( + this.game, + this.map, + this.layerTilePos, + (tx, ty) => this.tileCenter(tx, ty), + (s) => this.combatViews.hitBurst(s), + (t) => this.showToast(t), + this.cutscene + ); + + // Квест резонатора (акт 2): сбор тона — интерактивы рощи, сдача — катсцена. + this.resonator = new ResonatorQuest( + this.game, + (tx, ty) => this.tileCenter(tx, ty), + (s) => this.combatViews.hitBurst(s), + (t) => this.showToast(t), + this.cutscene + ); + + // Маршрутизация взаимодействия (кнопка действия) и step-переходов. + this.router = new InteractionRouter({ + game: this.game, + map: this.map, + area, + player: this.player, + interactables: this.interactables, + objects: this.objects, + disarmTile, + callbacks: { + showToast: (t) => this.showToast(t), + playUiClick: () => playSfx(this.game.audio, 'sfx/ui_click', 0.4), + talkTo: (d) => this.talkTo(d), + collectFlower: (x, y) => this.flowers.collectFlower(x, y), + useTransition: (d) => this.useTransition(d) + } + }); + + // Атмосфера локации: пепел/мотыли/дымка, зоны наката, виньетка. + this.atmosphere = new Atmosphere({ + game: this.game, + area, + worldRoot: this.game.renderer.worldRoot, + uiRoot: this.game.renderer.uiRoot, + addFx: (u) => this.game.engine.fx.add(u), + player: this.player, + heroHp: () => this.flow.playerCombat.hp, + vignette: { + level: () => this.lighting.vignetteLevel, + set: (level, fade) => this.lighting.setVignette(level, fade) + }, + showToast: (t) => this.showToast(t) + }); + + // Аудио локации: roomtone, слои, шорохи, музыка угрозы, день/ночь. + this.locationAudio = new LocationAudio({ + audio: this.game.audio, + worldAudio: this.worldAudio, + area, + layerTilePos: this.layerTilePos, + heroPos: () => this.player.position, + playTheme: (key) => this.game.playTheme(key), + playAmbience: (key) => this.game.playAmbience(key), + nightFactor: () => dayNightFactor(this.game.clock.hours), + threat: () => this.flow.threat() + }); + + // Освещение: ambient области + статические источники (очаг, окна, лампа). + this.lightView = new Lighting({ + width: Game.VIRTUAL_W, + height: Game.VIRTUAL_H, + renderer: this.game.renderer, + maxLights: MAX_LIGHTS + }); + this.game.renderer.lightRoot.addChild(this.lightView); + this.lighting = new GameLighting({ + area, + lighting: this.lightView, + layerTilePos: this.layerTilePos, + camera: this.camera, + heroPos: () => this.player.position, + hasFlag: (f) => this.game.state.hasFlag(f), + timeHours: () => this.game.clock.hours, + lamp: HERO_LAMP + }); + + // Пост-эффекты: фильтр на корне мира — блюр/аберрация/марево (см. postSim). + this.postfx = new PostFx(this.game.renderer.worldRoot, Game.VIRTUAL_W, Game.VIRTUAL_H); + + // HUD: название локации, подсказка управления, счётчик мотов. + this.hud = new LocationHud(area.name); + this.game.renderer.uiRoot.addChild(this.hud.view); + + // Боевая обвязка героя: ввод атаки, резонанс, урон/респаун, hp-бар. + this.flow = new CombatFlow( + { + game: this.game, + combat: this.combat, + combatViews: this.combatViews, + player: this.player, + lighting: this.lighting, + camera: this.camera, + postfx: this.postfx, + fx: this.fx, + spawn: area.spawn, + inHazard: () => this.atmosphere.inHazard !== null, + snapCamera: () => this.updateCameraFollow(true) + }, + this.game.state.getNumber(VARS.hp) || PLAYER_COMBAT.maxHp + ); + this.game.renderer.uiRoot.addChild(this.flow.healthBar); + + // Подпись «[E] Имя» над ближайшей целью действия: поверх акторов мира. + this.hint = new InteractHint(); + this.world.addChild(this.hint.view); + + // Агентный мост сцены (снапшот/инварианты/команды). + this.agentView = new SceneAgentView({ + game: this.game, + map: this.map, + area, + player: this.player, + playerCombat: this.flow.playerCombat, + combat: this.combat, + npcs: this.npcs.map((n) => n.def), + interactables: this.interactables, + registry: this.objects.registry, + walkGrid: () => this.objects.walkGrid(), + dialogue: this.dialogue, + cutscene: this.cutscene, + lastToast: () => this.lastToast, + interactHint: () => this.router.interactTarget(), + inHazard: () => this.atmosphere.inHazard, + damagePlayer: (dmg, from) => this.flow.onPlayerDamaged(dmg, from ?? this.player.position), + lighting: () => this.lighting, + camera: this.camera, + postfx: this.postfx, + setShimmer: (z) => { + this.manualShimmer = z; + }, + followCamera: (snap) => this.updateCameraFollow(snap) + }); + + // Камера: следим за героем; границы — мировой прямоугольник карты (юниты). + this.camera.bounds = this.map.worldBounds; + // Камера-«окно»: герой ходит в центральной зоне свободно, у виртуальной + // границы экрана толкает камеру (движение остаётся плавным). + this.camera.deadZonePx = { width: 180, height: 120 }; + this.updateCameraFollow(true); + + // Тач-джойстик: перехватывает касания (мышь проходит насквозь). + this.joystick = new VirtualJoystick({ screen: { width: Game.VIRTUAL_W, height: Game.VIRTUAL_H } }); + this.joystick.eventMode = 'none'; // включается при первом касании + this.game.renderer.uiRoot.addChild(this.joystick); + + // Дебаг-оверлей (F3). + this.debug = new DebugOverlay(this.game.engine.fpsMeter, false); + this.game.renderer.uiRoot.addChild(this.debug.view); + + // Дебаг спрайта героя (P): текущий кадр, увеличенный с пиксельной сеткой. + this.charDebug = new SpriteDebugView({ zoom: 8 }); + this.charDebug.view.position.set(390, 120); + this.game.renderer.uiRoot.addChild(this.charDebug.view); + + // Сцена готова: регистрируемся как активная локация (автосейв при + // закрытии вкладки — даже когда поверх открыта сумка/другая сцена). + this.game.activeLocation = this; + } + + /** id области — для оверлея карты (Game.activeLocation). */ + get areaId(): string { + return this.area.id; + } + + /** Текущий тайл героя — для мигающего маркера на карте. */ + heroTile(): { x: number; y: number } { + return this.player.currentTile(); + } + + enter(): void { + console.log('[location]', this.area.id); // маяк для смоук-тестов + // Амбиент локации: день/ночь по текущим часам (кроссфейд; '' — тишина). + this.locationAudio.startAmbient(); + this.game.playTheme(this.area.theme ?? ''); + // Файловые лупы области — в фоне (сцена загрузки декодировала только стартовую). + this.game.preloadAudio(areaAudioKeys(this.area)); + this.locationAudio.startRoomTone(); + // Первая подсказка сбора: как брать лунные колокольчики (один раз). + if (this.area.id === 'ponds' && !this.game.state.hasFlag(FLAGS.hint_bells)) { + this.game.state.setFlag(FLAGS.hint_bells); + this.showToast('Лунные колокольчики в земле: подойди и нажми E.'); + } + } + + exit(): void { + // Сняться с регистрации (replace: новый LocationScene уже занял слот — + // чистим только свой). + if (this.game.activeLocation === this) this.game.activeLocation = null; + for (const off of this.eventOffs) off(); + this.eventOffs = []; + this.audioOff(); + this.worldAudio.setLayers([]); // лупы локальных слоёв: иначе звучат поверх следующей сцены + this.locationAudio.exit(); + this.dialogue.destroy(); + this.fauna.exit(); + this.combatViews.exit(); + this.flow.exit(); + this.fx.destroy({ children: true }); + this.uiFx.destroy({ children: true }); + this.world.destroy({ children: true }); + this.atmosphere.destroy(); + this.lighting.destroy(); + this.lightView.destroy({ children: true }); + this.postfx.dispose(); + this.shadows.destroy(); + this.hud.destroy(); + this.hint.destroy(); + this.joystick.destroy({ children: true }); + this.debug.view.destroy({ children: true }); + this.charDebug.view.destroy({ children: true }); + } + + update(dt: number): void { + this.map.update(dt); // анимированные тайлы (вода, кроны) — no-op без анимаций + this.updateCrownAsh(dt); + this.worldAudio.update(dt); + this.locationAudio.updateLayers(); + this.locationAudio.updateDayNightAmbient(); + this.game.clock.advance(dt); // время суток идёт и в диалогах/катсценах: свет меняется + this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов + this.lightView.update(dt); // время/лерп ambient/кадры к спрайтам + this.shadows.update(sunShadow(this.game.clock.hours)); // тени ползут за солнцем + this.postfx.update(dt); + this.updateShimmer(); + this.atmosphere.updateVignette(); + this.fauna.update(dt); + // Ходоки актёров: тикаются всегда (исполнители until в кат-сценах). + if (this.walkers.size > 0) for (const w of this.walkers.values()) w.update(dt); + // Кат-сцена и диалог живут в одном тике: сцена может вести диалог + // посреди кат-сцены (call(talkTo) + until(диалог закрыт)). Геймплей + // на паузе, пока активна хоть одна из них; камера при кат-сцене — + // под контролем раннера (follow не тикаем). + const input = this.game.engine.input; + // Гвард «было занято до тика»: клавиша, закрывшая диалог (advance/attack + // мульти-биндинг), не должна в тот же тик ударить/переоткрыть разговор. + const wasBusy = this.cutscene.active || this.dialogue.active; + if (this.cutscene.active) this.cutscene.update(dt); + if (this.dialogue.active) { + // Во время диалога up/down листают варианты, клик/пробел — дальше/выбор. + this.dialogue.update(dt); + if (input.isActionJustPressed('up')) this.dialogue.moveCursor(-1); + if (input.isActionJustPressed('down')) this.dialogue.moveCursor(1); + if (input.getPointer().justPressed || input.isActionJustPressed('advance')) { + this.dialogue.advance(); + } + } + if (this.cutscene.active || this.dialogue.active) return; + if (this.game.engine.scenes.transitioning) return; + + if (input.isActionJustPressed('menu')) { + this.saveAndExit(); + return; + } + + if (input.isActionJustPressed('inventory')) { + playSfx(this.game.audio, 'sfx/ui_click'); + void this.game.scenes.push( + new InventoryScene(this.game, () => void this.game.scenes.pop()), + { duration: 0.2 } + ); + return; + } + + if (input.isActionJustPressed('debug')) { + this.debug.view.visible = !this.debug.view.visible; + } + if (input.isActionJustPressed('debugChar')) { + this.charDebug.view.visible = !this.charDebug.view.visible; + } + if (input.isActionJustPressed('debugZoom')) { + // Отладка зума: целые уровни 1 <-> 2; snap перекрывает dead zone. + this.camera.setZoom(this.camera.zoomLevel > 1 ? 1 : 2); + this.updateCameraFollow(true); + } + + // --- ввод боя: тап = короткий удар, удержание = заряд резонанса --- + if (!wasBusy && input.isActionJustPressed('attack')) { + this.flow.onAttackPressed(); + } + if (!wasBusy && input.isActionJustReleased('attack')) { + this.flow.onAttackReleased(); + } + // --- кнопка действия: ближайшая цель в радиусе (E / геймпад A) --- + if (!wasBusy && input.isActionJustPressed('interact')) { + this.router.resolveInteract(); + } + + // Касание отдаём джойстику (тач-устройство: движение стиком). + if (input.getPointer().justPressed && input.getPointer().isTouch) { + this.joystick.eventMode = 'static'; + } + + // --- боевой мир --- + this.combat.update(dt); + this.combatViews.sync(dt); + this.locationAudio.updateMusic(dt); + this.flow.update(dt); + + // Мигание героя при неуязвимости: duty blink-оживителя живой. + this.heroMotion.params.blink!.duty = this.flow.playerCombat.invuln ? 0.5 : 1; + + const mv = this.moveVector(); + if (mv.x !== 0 || mv.y !== 0) { + this.player.moveFree(mv, dt); // нулевой вектор в moveFree = stop() — не вызываем + } else { + this.player.update(dt); + } + const tile = this.player.currentTile(); + this.hud.setMotes(this.game.state.getNumber(VARS.motes)); + this.hud.setQuest(questTracker(this.game.state)); + this.atmosphere.updateHazard(tile); + this.actors.setDepth(this.player.view, tile.x, tile.y); + this.updateCameraFollow(); + this.router.checkTransitions(tile); + // Подпись кнопки действия: одна вьюха над ближайшей целью (или скрыта). + this.hint.show(this.router.interactTarget()); + + // Отладочные строки собираются только при видимой панели (F3): иначе — + // три template-строки и массив каждый тик впустую. + if (this.debug.view.visible) { + this.debug.setLines([ + `tile ${tile.x},${tile.y}`, + `pos ${Math.round(this.player.position.x)},${Math.round(this.player.position.y)}`, + `hp ${this.flow.playerCombat.hp} kills ${this.game.state.getNumber(VARS.kills)}` + ]); + } + this.debug.update(dt); + if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture); + } + + render(): void {} + + /** Вектор прямого движения: тач-джойстик приоритетнее, иначе клавиши + стик геймпада. */ + private moveVector(): Vec2 { + if (this.joystick.active) return this.joystick.getVector(); + const input = this.game.engine.input; + const stick = input.getLeftStick(); + const x = (input.isActionActive('right') ? 1 : 0) - (input.isActionActive('left') ? 1 : 0); + const y = (input.isActionActive('down') ? 1 : 0) - (input.isActionActive('up') ? 1 : 0); + return { + x: Math.max(-1, Math.min(1, (stick?.x ?? 0) + x)), + y: Math.max(-1, Math.min(1, (stick?.y ?? 0) + y)) + }; + } + + // ---------- агентный мост (SceneAgent → SceneAgentView) ---------- + + /** Контентный слой снапшота — см. apps/game/src/agent/snapshot.ts. */ + agentSnapshot(): SnapshotLayer { + return this.agentView.agentSnapshot(); + } + + /** Инварианты сцены: валидность контента + целостность героя/врагов. */ + agentInvariants(): Invariant[] { + return this.agentView.agentInvariants(); + } + + /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */ + agentCommand(name: string, args?: JsonValue): JsonValue { + return this.agentView.agentCommand(name, args); + } + + // ---------- остальное ---------- + + /** Ходок актёра по def (лениво); depth-слой обновляется при смене тайла. */ + walkerFor(def: NpcDef): ActorWalker { + let walker = this.walkers.get(def); + if (!walker) { + const record = this.npcs.find((n) => n.def === def); + walker = new ActorWalker(this.map, record!.view, def.tile, (t) => + this.actors.setDepth(record!.view, t.x, t.y) + ); + this.walkers.set(def, walker); + } + return walker; + } + + /** + * Зоны марева (пост-эффект «горячий воздух»): первый источник на экране. + * Каждый тик — камера едет, точка должна следовать за объектом. + */ + private updateShimmer(): void { + if (this.manualShimmer !== undefined) { + this.postfx.setShimmer(this.manualShimmer); + return; + } + let zone: ShimmerState | null = null; + for (const def of this.area.interactables ?? []) { + const src = SHIMMER_SOURCES[def.id]; + if (!src || !src.visible(this.game.state)) continue; + const w = tileToWorld(def.tile.x + 0.5, def.tile.y + 0.5); + const s = this.camera.toScreen(w.x, w.y); + const radius = unitsToPx(src.radiusUnits) * this.camera.zoomLevel; + // Зона видна, пока её центр в пределах экрана с запасом радиуса. + if ( + s.x < -radius || s.x > Game.VIRTUAL_W + radius || + s.y < -radius || s.y > Game.VIRTUAL_H + radius + ) continue; + zone = { amp: src.amp, wavelength: src.wavelength, speed: src.speed, center: s, radius }; + break; + } + this.postfx.setShimmer(zone); + } + + private updateCameraFollow(snap = false): void { + // Непрерывное следование за ногами героя; snap — телепорты/спавн. + const p = this.player.position; + if (snap) this.camera.snap(p.x, p.y); + else this.camera.follow(p.x, p.y); + this.cullMap(); + } + + /** Culling тайлов: окно камеры в px слоя карты (экран − позиция мира, /зум). */ + private cullMap(): void { + const root = this.game.renderer.worldRoot; + const z = root.scale.x; + this.map.cullToRect({ + x: -root.position.x / z, + y: -root.position.y / z, + width: Game.VIRTUAL_W / z, + height: Game.VIRTUAL_H / z + }); + } + + /** Экранные координаты центра ромба тайла (с Origin карты). */ + private tileCenter(tx: number, ty: number): { x: number; y: number } { + const u = tileToWorld(tx, ty); + return worldToScreen(u.x, u.y); + } + + /** Поверхность под ногами (трава/пепел/вода/пол) — для вариативных шагов. */ + private stepSurface(tile: Vec2): StepSurface { + const d = this.map.data; + const id = d.tiles[tile.y * d.width + tile.x]!; + if (id === TILES.WATER) return 'water'; + if (id === TILES.ASH || id === TILES.HOUSE || id === TILES.TOWER) return 'ash'; + if (id === TILES.FLOOR || id === TILES.PATH) return 'floor'; + return 'grass'; + } + + /** Предикаты мира для условий (сумка героя). */ + private dialogueWorld(): DialogueWorld { + return { hasItem: (id) => this.game.inventory.count(id as ItemId) > 0 }; + } + + private talkTo(def: NpcDef): void { + // Флаг знакомства поднимает сам граф dialogueFirst; сцена только читает. + const met = this.game.state.hasFlag(def.flagKey); + // Сюжетная ветка — из квест-стадии; иначе обычный диалог. + const id = !met + ? def.dialogueFirst + : (questDialogueFor(this.game.state, def.id, this.dialogueWorld()) ?? def.dialogueRepeat); + this.dialogue.start(DIALOGUES[id], id); + } + + private onDialogueFinished(): void { + // Сюжетные custom-эффекты графа — только когда реплики доскажены. + if (this.pendingCustom.has(DIALOGUE_CUSTOM.plant_flowers)) { + this.pendingCustom.delete(DIALOGUE_CUSTOM.plant_flowers); + this.flowers.startHandInCutscene(); + } + if (this.pendingCustom.has(DIALOGUE_CUSTOM.tune_resonator)) { + this.pendingCustom.delete(DIALOGUE_CUSTOM.tune_resonator); + this.resonator.startTuneCutscene(); + } + } + + /** Сюжетные custom-эффекты интерактивов (вне диалогов). */ + private onSceneCustom(name: string): void { + if (name === DIALOGUE_CUSTOM.lull_machine) this.startMachineLullCutscene(); + } + + /** + * Финал акта 2 — «правильный звон»: камера спускается к устью, резонатор + * держит верный тон, Машина засыпает. Резонанс — тот же пульс CombatWorld, + * что и удержание атаки героя (догоняет бодрых сгустков зала). + */ + private startMachineLullCutscene(): void { + const at = tileToWorld(6, 2); // устье печи (свет machine_mouth) + const s = worldToScreen(at.x, at.y); + this.cutscene.play([ + // Спуск камеры к Машине, наезд на устье. + { kind: 'cameraMove', x: at.x, y: at.y, seconds: 1.2, camera: this.game.camera }, + { kind: 'cameraZoom', camera: this.game.camera, z: 2, seconds: 0.8 }, + // Правильный звон: бронза держит тон, тряска, кольцо резонанса. + { + kind: 'burst', + fn: () => { + void this.game.audio.play('sfx/bell_low', 1); + this.game.camera.addShake(2.5, 0.6); + this.combatViews.hitBurst(s); + }, + seconds: 1 + }, + // Тот же резонанс, что у героя: доспать всем, кто не спал. + { kind: 'call', fn: () => this.combat.resonancePulse(at) }, + // Дыхание обрывается: угольки гаснут. + { + kind: 'burst', + fn: () => { + void this.game.audio.play('sfx/ash_die', 0.8); + this.combatViews.hitBurst(s); + }, + seconds: 1.2 + }, + // Отъезд и тост-финал акта. + { kind: 'cameraZoom', camera: this.game.camera, z: 1, seconds: 0.8 }, + { kind: 'call', fn: () => this.showToast('Машина дышит тише. Впервые за годы — тише.') } + ]); + } + + /** + * Осыпающийся пепел с крон деревьев: редкий спавн 1–2 частиц с случайной + * кроны (детерминированный rng), падают вниз и тают — жизнь леса вне боя. + */ + private updateCrownAsh(dt: number): void { + if (this.crownTiles.length === 0) return; // интерьеры и пустые карты + this.crownAshT += dt; + if (this.crownAshT < CROWN_ASH_PERIOD) return; + this.crownAshT = 0; + const t = this.crownTiles[Math.floor(this.crownAshRng.next() * this.crownTiles.length)]!; + const c = this.tileCenter(t.x, t.y); + this.fx.burst( + 1 + Math.floor(this.crownAshRng.next() * 2), + { x: c.x, y: c.y - 34 }, // середина кроны (дерево 48px от низа ромба) + emberPreset(0x8a8a96, { + blend: 'normal', + fadeIn: 0.2, + lifetime: [1.5, 2.6], + velocity: { x: [-3, 3], y: [4, 9] }, + seed: this.crownAshSeed++ + }) + ); + } + + /** Всплывающая подсказка: появляется, держится, растворяется вверх (FxLayer). */ + private showToast(text: string): void { + this.lastToast = { text, tick: this.game.engine.tickCount }; + // Новый тост вытесняет старый (раньше наслаивались). + this.uiFx.clear(); + const hold = readSeconds(text); // базовые 1.4 с + ~45 мс на символ, не дольше 6 + // y ниже строки «Цель:» (LocationHud) — тост и трекер не накладываются. + this.uiFx.floatText(text, { x: 240, y: 56 }, { + duration: hold + 0.75, + hold, + fadeIn: 0.25, + fadeOut: 0.5, + rise: 8, + // Перенос по ширине экрана: длинный тост в 2 строки, не за края. + style: { size: 11, color: 0xd8c79a, wordWrapWidth: 440 } + }); + } + + /** Куда ведёт переход: область + точка входа ('return' — откуда вошли). */ + private targetEntry(def: TransitionDef): { area: AreaDef; entry: { x: number; y: number } } { + const t = def.target; + if (t.kind === 'area') return { area: areaOf(t.area), entry: t.entry }; + return { + area: areaOf(this.returnTo?.area), + entry: this.returnTo?.entry ?? this.area.spawn + }; + } + + /** Выполнить переход: новая сцена с fade (длительность — из определения). */ + private useTransition(def: TransitionDef): void { + const next = this.targetEntry(def); + // Автосейв до fade: закрыл игру на переходе — вернёшься на эту же тропу. + this.autosave(); + playSfx(this.game.audio, 'sfx/whoosh', 0.5); + void this.game.scenes.replace( + new LocationScene(this.game, null, next.area, next.entry, { + area: this.area.id, + entry: this.player.currentTile() + }), + { duration: def.duration ?? 0.4 } + ); + } + + /** + * Сериализовать прохождение без записи (общий источник autosave: + * Esc, переходы, закрытие вкладки). + */ + private buildSaveData(): SaveData { + this.game.state.setVar(VARS.hp, this.flow.playerCombat.hp); + return { + version: SAVE_VERSION, + area: this.area.id, + pos: this.player.currentTile(), + state: this.game.state.serialize(), + items: this.game.inventory.serialize().items, + clock: { minutes: this.game.clock.minutes }, + returnTo: this.returnTo ?? null, + savedAt: Date.now() + }; + } + + /** Записать autosave (localStorage синхронен — безопасно и из beforeunload). */ + autosave(): void { + const data = this.buildSaveData(); + this.game.saves.save( + 'autosave', + data, + { title: 'Автосохранение', savedAt: data.savedAt, version: SAVE_VERSION, extras: { area: this.area.id } } + ); + } + + private saveAndExit(): void { + this.autosave(); + void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/scenes/MapScene.ts b/v1/apps/game/src/scenes/MapScene.ts new file mode 100644 index 0000000..30df4d9 --- /dev/null +++ b/v1/apps/game/src/scenes/MapScene.ts @@ -0,0 +1,257 @@ +import { + Graphics, + MenuSceneBase, + Panel, + PixelText, + Sprite, + Texture, + renderMinimap, + type MinimapImage, + type TileMapData +} from '@rpg/engine'; +import { Game } from '../Game'; +import { TILES } from '../data/map'; +import { areaOf } from '../data/locations'; +import { playSfx } from '../data/sfxSpecs'; + +/** + * Палитра миникарты: id тайла → цвет (0xRRGGBB, из арт-палитры). Варианты + * тайлов красятся как базовый. Незаданный — тёмный fallback движка. + */ +const MAP_COLORS: Record = { + [TILES.GRASS]: 0x4a5340, // G1 — трава + [TILES.GRASS_V1]: 0x4a5340, + [TILES.GRASS_V2]: 0x4a5340, + [TILES.PATH]: 0x77634f, // T1 — протоптанная земля + [TILES.PATH_V1]: 0x77634f, + [TILES.WATER]: 0x3a4356, // C0 — вода + [TILES.TREE]: 0x343b2c, // G0 — тёмная крона + [TILES.TREE_V1]: 0x343b2c, + [TILES.TREE_V2]: 0x343b2c, + [TILES.ASH]: 0x3e3e48, // P3 — пепел + [TILES.ASH_V1]: 0x3e3e48, + [TILES.BELLFLOWER]: 0xf2b45a, // F1 — цветок (тёплый) + [TILES.TOWER]: 0x26262e, // M0 — высокая башня + [TILES.HOUSE]: 0x5a4a3c, // T0 — крыши + [TILES.HOUSE_V1]: 0x5a4a3c, + [TILES.HOUSE_V2]: 0x5a4a3c, + [TILES.HOUSE_V3]: 0x5a4a3c, + [TILES.HOUSE_V4]: 0x5a4a3c, + [TILES.HOUSE_BIG]: 0x5a4a3c, + [TILES.BOULDER]: 0x767681, // P5 + [TILES.BOULDER_2]: 0x767681, + [TILES.BOULDER_3]: 0x767681, + [TILES.BUSH]: 0x343b2c, // G0 + [TILES.BUSH_2]: 0x343b2c, + [TILES.BUSH_3]: 0x343b2c, + [TILES.FLOOR]: 0x44454e, // M1 — полы интерьеров + [TILES.WALL]: 0x26262e, // M0 — стены + [TILES.WELL]: 0x8a6a2e, // B0 — латунный колодец + [TILES.WIRE]: 0x5c3b2e, // R1 — бухты кабеля + [TILES.RAIL]: 0x767681, // P5 — металл рельсов + [TILES.STAIRS]: 0x77634f, + [TILES.MACHINE]: 0x26262e +}; + +/** Цвета маркеров: герой (мигает), переходы, NPC. */ +const HERO_COLOR = 0xf2d49a; +const TRANSITION_COLOR = 0xb2b2bb; +const NPC_COLOR = 0xf2b45a; +/** Цвет дорог-линий на листе мира (тропа). */ +const ROAD_COLOR = 0x77634f; // T1 + +/** Часть листа мира: область и её оффсет в тайлах листа. */ +interface WorldPart { + id: string; + name: string; + x: number; + y: number; + map: TileMapData; +} + +/** + * Раскладка листа мира «Низина Звенца» (оффсеты в тайлах, cell = 1): + * пруды на северо-западе, луга в центре, восточнее — деревня, роща, разъезд. + * Интерьеры домов и зал Машины на карте мира не показаны. + */ +function worldParts(game: Game): WorldPart[] { + const defs: [id: string, name: string, x: number, y: number][] = [ + ['ponds', 'Серые пруды', 0, 0], + ['meadows', 'Выжженные луга', 53, 10], + ['zvenets', 'Звенец', 114, 20], + ['rust_grove', 'Ржавая роща', 167, 20], + // Разъезд сдвинут вправо: подписи соседних областей не слипаются. + ['old_siding', 'Старый разъезд', 231, 20] + ]; + return defs.flatMap(([id, name, x, y]) => { + const map = game.mapFiles.get(id); + return map ? [{ id, name, x, y, map }] : []; + }); +} + +/** Дороги мира: пары тайлов областей (связки переходов, тропы и колодцы). */ +const WORLD_ROADS: { a: [string, number, number]; b: [string, number, number] }[] = [ + { a: ['ponds', 2, 2], b: ['meadows', 2, 2] }, // тропа на пруды + { a: ['meadows', 54, 28], b: ['zvenets', 2, 20] }, // тропа в Звенец + { a: ['meadows', 44, 40], b: ['zvenets', 16, 13] }, // колодцы (телепорт) + { a: ['zvenets', 54, 20], b: ['rust_grove', 1, 20] }, // дорога в рощу + { a: ['rust_grove', 46, 20], b: ['old_siding', 1, 20] } // дорога на разъезд +]; + +/** RGBA-картинка движка → пиксельная текстура (nearest, без размытия). */ +function textureFromMinimap(img: { width: number; height: number; rgba: Uint8Array }): Texture { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d')!; + ctx.putImageData(new ImageData(new Uint8ClampedArray(img.rgba), img.width, img.height), 0, 0); + const tex = Texture.from(canvas); + tex.source.scaleMode = 'nearest'; + return tex; +} + +/** + * Склеить миниатюры областей в лист мира: каждая область рендерится + * renderMinimap (cell = 1), строки копируются в общий RGBA по оффсету. + * Промежутки между областями остаются прозрачными (alpha 0). + */ +function composeWorldSheet(parts: WorldPart[]): MinimapImage { + const width = Math.max(...parts.map((p) => p.x + p.map.width)); + const height = Math.max(...parts.map((p) => p.y + p.map.height)); + const rgba = new Uint8Array(width * height * 4); // всё прозрачено + for (const p of parts) { + const img = renderMinimap(p.map, { colors: MAP_COLORS, cell: 1 }); + for (let row = 0; row < img.height; row++) { + const src = row * img.width * 4; + const dst = ((p.y + row) * width + p.x) * 4; + rgba.set(img.rgba.subarray(src, src + img.width * 4), dst); + } + } + return { width, height, rgba }; +} + +/** + * Карта мира «Низина Звенца» (предмет map_scroll): композит миниатюр всех + * внешних областей на одном листе, дороги между переходами, маркеры текущей + * области и мигающая точка героя. Интерьеры и зал Машины не показаны. + * Esc/E/клавиша сумки — назад. + */ +export class MapScene extends MenuSceneBase { + /** Мигающий маркер героя. */ + private hero: Graphics | null = null; + private blinkT = 0; + + constructor( + private game: Game, + private onBack: () => void + ) { + super( + { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, + { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', extra: ['inventory'] } + ); + } + + protected build(): void { + const parts = worldParts(this.game); + if (parts.length === 0) return; // карты ещё не загружены — не бывает + + const panel = new Panel({ width: 300, height: 222 }); + panel.position.set((Game.VIRTUAL_W - 300) / 2, (Game.VIRTUAL_H - 222) / 2); + + const title = new PixelText({ text: 'Низина Звенца', size: 14, color: 0xd8c79a }); + title.anchor.set(0.5); + title.position.set(150, 14); + panel.addChild(title); + + const sheet = composeWorldSheet(parts); + const sprite = new Sprite(textureFromMinimap(sheet)); + sprite.position.set(150 - sheet.width / 2, 46); + panel.addChild(sprite); + + // Дороги между областями: тропы и колодцы, по связкам переходов. + const roads = new Graphics(); + roads.lineStyle(1, ROAD_COLOR, 1); + for (const road of WORLD_ROADS) { + const a = parts.find((p) => p.id === road.a[0]); + const b = parts.find((p) => p.id === road.b[0]); + if (!a || !b) continue; + roads.moveTo(sprite.position.x + a.x + road.a[1] + 0.5, sprite.position.y + a.y + road.a[2] + 0.5); + roads.lineTo(sprite.position.x + b.x + road.b[1] + 0.5, sprite.position.y + b.y + road.b[2] + 0.5); + } + panel.addChild(roads); + + // Подписи областей под миниатюрами. + for (const p of parts) { + const label = new PixelText({ text: p.name, size: 8, color: 0xb2b2bb }); + label.anchor.set(0.5, 0); + label.position.set( + sprite.position.x + p.x + p.map.width / 2, + sprite.position.y + p.y + p.map.height + 2 + ); + panel.addChild(label); + } + + this.addMarkers(panel, sprite.position.x, sprite.position.y, parts); + + const hint = new PixelText({ text: 'Esc — назад', size: 9, color: 0xf2d49a }); + hint.position.set(16, 204); + panel.addChild(hint); + + this.view.addChild(panel); + this.game.renderer.uiRoot.addChild(this.view); + } + + /** Маркеры текущей области: переходы, NPC и мигающий герой. */ + private addMarkers(panel: Panel, sheetX: number, sheetY: number, parts: WorldPart[]): void { + const areaId = this.game.activeLocation?.areaId; + const part = areaId ? parts.find((p) => p.id === areaId) : undefined; + if (!part) return; // интерьер/подземелье: на карте мира маркеров нет + const area = areaOf(part.id); + const g = new Graphics(); + const at = (x: number, y: number): { x: number; y: number } => ({ + x: sheetX + part.x + x - 1, + y: sheetY + part.y + y - 1 + }); + // Переходы — светлые клетки, NPC — тёплые. + for (const t of area.transitions) { + const p = at(t.tile.x, t.tile.y); + g.rect(p.x, p.y, 3, 3).fill(TRANSITION_COLOR); + } + for (const npc of area.npcs) { + const p = at(Math.floor(npc.pos.x), Math.floor(npc.pos.y)); + g.rect(p.x, p.y, 3, 3).fill(NPC_COLOR); + } + panel.addChild(g); + + // Герой — мигающая клетка поверх (update). + const heroTile = this.game.activeLocation!.heroTile(); + this.hero = new Graphics(); + this.hero.rect(0, 0, 4, 4).fill(HERO_COLOR); + const hp = at(heroTile.x, heroTile.y); + this.hero.position.set(hp.x - 0.5, hp.y - 0.5); + panel.addChild(this.hero); + } + + update(dt: number): void { + super.update(dt); + // Мигание маркера героя (локация не тикается — мерцает сама карта). + if (this.hero) { + this.blinkT += dt; + this.hero.visible = Math.floor(this.blinkT / 0.4) % 2 === 0; + } + } + + /** Esc или клавиша сумки — назад (в сумку, если карта открыта из неё). */ + protected onCancel(): void { + this.close(); + } + + protected onAction(): void { + this.close(); + } + + private close(): void { + playSfx(this.game.audio, 'sfx/ui_click'); + this.onBack(); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/scenes/MenuScene.ts b/v1/apps/game/src/scenes/MenuScene.ts new file mode 100644 index 0000000..2e5ff13 --- /dev/null +++ b/v1/apps/game/src/scenes/MenuScene.ts @@ -0,0 +1,154 @@ +import { playSfx } from '../data/sfxSpecs'; +import { + MenuList, + MenuSceneBase, + PixelText, + type Invariant, + type JsonValue, + type SnapshotLayer +} from '@rpg/engine'; +import type { Game } from '../Game'; +import { areaOf } from '../data/locations'; +import { LocationScene } from './LocationScene'; +import { SaveSlotsScene } from './SaveSlotsScene'; +import { SettingsScene } from './SettingsScene'; +import { normalizeSave, type SaveData } from './saveData'; +import { START_MINUTES } from '../systems/Clock'; + +/** + * Главное меню: название, «новая игра», «продолжить» (если есть сейв). + * Список на движковом MenuList: мышь и клавиатура/геймпад. + */ +export class MenuScene extends MenuSceneBase { + constructor(private game: Game) { + super( + { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, + { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu' } + ); + } + + protected build(): void { + const title = new PixelText({ + text: 'ПЕПЕЛЬНЫЕ ЛУГА', + size: 28, + color: 0xd8c79a + }); + title.anchor.set(0.5); + title.position.set(240, 66); + this.view.addChild(title); + + const subtitle = new PixelText({ + text: 'пепел всё ещё дышит', + size: 11, + color: 0x8a8a9a + }); + subtitle.anchor.set(0.5); + subtitle.position.set(240, 94); + this.view.addChild(subtitle); + + const items = [ + { + label: 'Новая игра', + // Звук не блокирует навигацию: в suspended-контексте play может не дойти. + onSelect: () => { + playSfx(this.game.audio, 'sfx/ui_click'); + this.startNewGame(); + } + }, + { + label: 'Сейвы', + onSelect: () => { + playSfx(this.game.audio, 'sfx/ui_click'); + void this.game.scenes.push( + new SaveSlotsScene( + this.game, + (save) => void this.game.scenes.pop().then(() => this.start(save)), + () => void this.game.scenes.pop() + ) + ); + } + }, + { + label: 'Настройки', + onSelect: () => { + playSfx(this.game.audio, 'sfx/ui_click'); + void this.game.scenes.push( + new SettingsScene(this.game, () => void this.game.scenes.pop()) + ); + } + } + ]; + if (this.game.saves.has('autosave')) { + items.unshift({ + label: 'Продолжить', + onSelect: () => { + playSfx(this.game.audio, 'sfx/ui_click'); + const save = this.game.saves.load('autosave'); + this.start(save); + } + }); + } + + this.menu = new MenuList({ width: 120, height: 16, gap: 3 }); + this.menu.setItems(items); + this.menu.position.set(180, 130); + this.view.addChild(this.menu); + + const hint = new PixelText({ + text: 'клик по тайлу — идти · клик по NPC — говорить\nEsc в игре — меню с сохранением', + size: 9, + color: 0x666677, + align: 'center', + lineHeight: 13, + wordWrapWidth: 400 + }); + hint.anchor.set(0.5); + hint.position.set(240, 234); + this.view.addChild(hint); + + this.game.renderer.uiRoot.addChild(this.view); + } + + /** Новая игра: сброс прогресса, сумки и часов, старт на лугах. */ + private startNewGame(): void { + this.game.state.reset(); + this.game.inventory.clear(); + this.game.clock.reset(); + this.start(null); + } + + // --- агентный мост: сцена отдаёт снапшот и принимает команды --- + + agentSnapshot(): SnapshotLayer { + return { scene: 'menu' }; + } + + agentInvariants(): Invariant[] { + return []; + } + + agentCommand(name: string): JsonValue { + if (name === 'menu:newGame') { + this.startNewGame(); + return true; + } + return null; + } + + private start(save: SaveData | null): void { + if (save) { + const norm = normalizeSave(save); + this.game.inventory.load({ items: norm.items }); + this.game.clock.set(norm.clock?.minutes ?? START_MINUTES); + void this.game.scenes.replace( + new LocationScene(this.game, norm, areaOf(norm.area), undefined, norm.returnTo ?? undefined), + { duration: 0.4 } + ); + } else { + this.game.inventory.clear(); + void this.game.scenes.replace(new LocationScene(this.game, null, areaOf(undefined)), { + duration: 0.4 + }); + } + } +} \ No newline at end of file diff --git a/v1/apps/game/src/scenes/SaveSlotsScene.ts b/v1/apps/game/src/scenes/SaveSlotsScene.ts new file mode 100644 index 0000000..d153d74 --- /dev/null +++ b/v1/apps/game/src/scenes/SaveSlotsScene.ts @@ -0,0 +1,119 @@ +import { playSfx } from '../data/sfxSpecs'; +import { MenuList, MenuSceneBase, Panel, PixelText } from '@rpg/engine'; +import { Game } from '../Game'; +import { areaOf } from '../data/locations'; +import { SAVE_SLOTS, canDelete, slotTitle } from '../data/saves'; +import type { SaveData } from './saveData'; + +/** + * Меню сейвов: autosave (только загрузка) + 3 именованных слота + * (загрузка/удаление). Панель поверх меню (push/pop). Подписи — из меты + * слота; у старых сейвов без меты — полная загрузка (как раньше). + */ +export class SaveSlotsScene extends MenuSceneBase { + constructor( + private game: Game, + private onPick: (save: SaveData) => void, + private onBack: () => void + ) { + super( + { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, + { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', extra: ['attack'] } + ); + } + + protected build(): void { + const panel = new Panel({ width: 240, height: 150 }); + panel.position.set((Game.VIRTUAL_W - 240) / 2, (Game.VIRTUAL_H - 150) / 2); + + const title = new PixelText({ text: 'СЕЙВЫ', size: 14, color: 0xd8c79a }); + title.anchor.set(0.5); + title.position.set(120, 14); + panel.addChild(title); + + this.menu = new MenuList({ width: 200, height: 14, gap: 3, size: 10 }); + this.menu.position.set(20, 36); + panel.addChild(this.menu); + + this.view.addChild(panel); + this.game.renderer.uiRoot.addChild(this.view); + this.rebuild(); + } + + /** Delete на выбранном именованном слоте — стереть. */ + protected onAction(action: string, index: number): void { + if (action === 'attack') this.deleteAt(index); + } + + protected onCancel(): void { + this.back(); + } + + /** Пересобрать список слотов из SaveManager. */ + private rebuild(): void { + const items: { label: string; disabled?: boolean; onSelect?: () => void }[] = []; + const slots = this.game.saves.listSlots(); + for (const slot of SAVE_SLOTS) { + const meta = this.game.saves.slotMeta(slot); + const save = meta + ? null + : slots.includes(slot) + ? this.game.saves.load(slot)! + : null; + if (!meta && !save) { + items.push({ label: `${slotTitle(slot)} — пусто —`, disabled: true }); + continue; + } + const label = meta + ? this.metaLabel(slot, meta) + : this.saveLabel(slot, save!); + items.push({ + label, + onSelect: () => { + playSfx(this.game.audio, 'sfx/ui_click'); + const picked = save ?? this.game.saves.load(slot)!; + this.onPick(picked); + } + }); + } + items.push({ label: 'Назад', onSelect: () => this.back() }); + this.menu!.setItems(items); + } + + /** Подпись из меты: локация из extras, время из savedAt. */ + private metaLabel(slot: string, meta: { savedAt?: number; extras?: Record }): string { + const area = typeof meta.extras?.area === 'string' ? meta.extras.area : undefined; + const when = meta.savedAt ? this.formatTime(meta.savedAt) : '--:--'; + return `${slotTitle(slot)} ${this.areaName(area)} ${when}`; + } + + /** Подпись из полного сейва (старые сейвы без меты). */ + private saveLabel(slot: string, save: SaveData): string { + const areaId = save.area ?? (save as { location?: string }).location; + return `${slotTitle(slot)} ${this.areaName(areaId)} ${this.formatTime(save.savedAt)}`; + } + + private formatTime(ts: number): string { + const date = new Date(ts); + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + } + + private areaName(areaId: string | undefined): string { + // Имя из реестра областей: интерьеры раньше показывались как «луга». + return areaId ? areaOf(areaId).name : 'луга'; + } + + /** Удалить сейв в выбранной строке (именованные слоты; autosave защищён). */ + private deleteAt(index: number): void { + const slot = SAVE_SLOTS[index]; + if (!slot || !canDelete(slot) || !this.game.saves.listSlots().includes(slot)) return; + this.game.saves.delete(slot); + playSfx(this.game.audio, 'sfx/ui_click'); + this.rebuild(); + } + + private back(): void { + playSfx(this.game.audio, 'sfx/ui_click'); + this.onBack(); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/scenes/SettingsScene.ts b/v1/apps/game/src/scenes/SettingsScene.ts new file mode 100644 index 0000000..e6cc3fe --- /dev/null +++ b/v1/apps/game/src/scenes/SettingsScene.ts @@ -0,0 +1,85 @@ +import { playSfx } from '../data/sfxSpecs'; +import { MenuList, MenuSceneBase, Panel, PixelText } from '@rpg/engine'; +import { Game } from '../Game'; + +/** + * Настройки: громкости шин аудио. Панель поверх вызывающей сцены (push/pop), + * изменения сразу применяются через settings.onChange -> AudioManager. + * Громкости рисуются текстовыми барами, left/right/Enter — подстройка. + */ +export class SettingsScene extends MenuSceneBase { + constructor( + private game: Game, + private onBack: () => void + ) { + super( + { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, + { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', left: 'left', right: 'right' } + ); + } + + protected build(): void { + const panel = new Panel({ width: 220, height: 130 }); + panel.position.set((Game.VIRTUAL_W - 220) / 2, (Game.VIRTUAL_H - 130) / 2); + + const title = new PixelText({ text: 'НАСТРОЙКИ', size: 14, color: 0xd8c79a }); + title.anchor.set(0.5); + title.position.set(110, 14); + panel.addChild(title); + + this.menu = new MenuList({ width: 180, height: 16, gap: 4, size: 10 }); + this.menu.position.set(20, 36); + panel.addChild(this.menu); + + this.view.addChild(panel); + this.game.renderer.uiRoot.addChild(this.view); + this.rebuild(); + } + + /** Enter — на 10% вверх. */ + protected onConfirm(index: number): void { + this.adjust(index, 0.1); + } + + /** Влево/вправо — подстройка громкости выбранной строки. */ + protected onAdjust(delta: number, index: number): void { + this.adjust(index, delta * 0.1); + } + + protected onCancel(): void { + this.back(); + } + + /** Список собирается один раз; громкости обновляются value-слотом. */ + private rebuild(): void { + this.menu!.setItems([ + { label: 'Общая', value: this.volumeBars(this.game.settings.data.master), onSelect: () => this.adjust(0, 0.1) }, + { label: 'Амбиент', value: this.volumeBars(this.game.settings.data.ambience), onSelect: () => this.adjust(1, 0.1) }, + { label: 'Звуки', value: this.volumeBars(this.game.settings.data.sfx), onSelect: () => this.adjust(2, 0.1) }, + { label: 'Назад', onSelect: () => this.back() } + ]); + } + + private volumeBars(v: number): string { + const bars = Math.round(v * 10); + return `${'▮'.repeat(bars)}${'▯'.repeat(10 - bars)}`; + } + + private adjust(index: number, delta: number): void { + if (index < 0 || index > 2) { + this.back(); + return; + } + const s = this.game.settings.data; + const key = (['master', 'ambience', 'sfx'] as const)[index]; + const value = Math.round(Math.min(1, Math.max(0, s[key] + delta)) * 10) / 10; + this.game.settings.update({ [key]: value }); + if (delta > 0) playSfx(this.game.audio, 'sfx/ui_click'); + this.menu!.setValue(index, this.volumeBars(value)); + } + + private back(): void { + playSfx(this.game.audio, 'sfx/ui_click'); + this.onBack(); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/scenes/__tests__/saveData.test.ts b/v1/apps/game/src/scenes/__tests__/saveData.test.ts new file mode 100644 index 0000000..d01898d --- /dev/null +++ b/v1/apps/game/src/scenes/__tests__/saveData.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { SAVE_VERSION, normalizeSave, type SaveData } from '../saveData'; + +/** Сейв заданной версии с минимальным содержимым. */ +function saveOf(version: number): SaveData { + return { + version, + area: 'meadows', + pos: { x: 1, y: 2 }, + state: { version: 2, flags: ['evening'], vars: {} }, + items: { flower: 1 }, + savedAt: 0 + } as SaveData; +} + +describe('normalizeSave', () => { + it('v3 (без clock): время дефолтится в старт 8:00, версия поднимается', () => { + const out = normalizeSave(saveOf(3)); + expect(out.version).toBe(SAVE_VERSION); + expect(out.clock).toEqual({ minutes: 480 }); + expect(out.items).toEqual({ flower: 1 }); + }); + + it('v4: проходит без изменений (тот же объект)', () => { + const s = saveOf(SAVE_VERSION); + s.clock = { minutes: 1230 }; + expect(normalizeSave(s)).toBe(s); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/scenes/saveData.ts b/v1/apps/game/src/scenes/saveData.ts new file mode 100644 index 0000000..43cf9b6 --- /dev/null +++ b/v1/apps/game/src/scenes/saveData.ts @@ -0,0 +1,46 @@ +import type { GameStateData } from '@rpg/engine'; +import type { AreaId } from '../data/locations'; +import { START_MINUTES } from '../systems/Clock'; + +/** + * Формат сейва: область, позиция героя, сериализованное состояние прохождения, + * сумка и время суток. У старых сейвов нет `version`/`items` — normalizeSave + * дорастает их. + */ +export const SAVE_VERSION = 4; + +/** Откуда герой вошёл в область (для переходов kind 'return'). */ +export interface ReturnToData { + area: AreaId; + entry: { x: number; y: number }; +} + +export interface SaveData { + version: number; + /** В какой области герой (фолбэк 'meadows' для старых сейвов). */ + area: string; + pos: { x: number; y: number }; + state: GameStateData; + /** Содержимое сумки: itemId -> количество. */ + items: Record; + /** Время суток в минутах (v4; нет — старт 8:00). */ + clock?: { minutes: number } | null; + /** Откуда вошли в область (v3; null/undefined — вход не через переход). */ + returnTo?: ReturnToData | null; + savedAt: number; +} + +/** Дополнить сейв старого формата до текущего (без записи). */ +export function normalizeSave(save: SaveData): SaveData { + if (save.version === SAVE_VERSION) return save; + // v1/v2: area зовётся location, returnTo ещё нет; v3: clock ещё нет. + const legacy = save as SaveData & { location?: string }; + return { + ...save, + version: SAVE_VERSION, + area: save.area ?? legacy.location ?? 'meadows', + returnTo: save.returnTo ?? null, + items: save.items ?? {}, + clock: save.clock ?? { minutes: START_MINUTES } + }; +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/Atmosphere.ts b/v1/apps/game/src/systems/Atmosphere.ts new file mode 100644 index 0000000..9f9ca7d --- /dev/null +++ b/v1/apps/game/src/systems/Atmosphere.ts @@ -0,0 +1,154 @@ +import { Container, Graphics, ParticleEmitter, type Updatable } from '@rpg/engine'; +import { Game } from '../Game'; +import type { AreaDef, HazardDef } from '../data/locations'; +import type { PlayerController } from './PlayerController'; + +/** + * Атмосфера локации: пепел/мотыли (частицы мира), дымка зон наката (UI-слой), + * механика низин (замедление без маски, тост при входе) и виньетка опасности. + */ + +/** Виньетка: цель при низком hp / в накате без маски и фейд перехода. */ +const VIGNETTE_LOW_HP = 0.3; +const VIGNETTE_HAZARD = 0.35; +const VIGNETTE_FADE = 0.5; + +export interface AtmosphereDeps { + game: Game; + area: AreaDef; + /** Слои: частицы — в мир, дымка — под UI (index 0 uiRoot). */ + worldRoot: Container; + uiRoot: Container; + /** Реестр тикающихся (engine.fx). */ + addFx(u: Updatable): void; + player: PlayerController; + /** Текущий hp героя (виньетка при низком). */ + heroHp: () => number; + /** Виньетка движкового света: уровень + цель с фейдом. */ + vignette: { level(): number; set(intensity: number, fadeSec: number): void }; + showToast(text: string): void; +} + +export class Atmosphere { + /** Пепел на лугах/деревне (в интерьерах null). */ + readonly ash: ParticleEmitter | null; + /** Тёплые мотыльки над лугами (светящиеся, дрейф по синусу). */ + readonly moths: ParticleEmitter | null; + /** Оверлей дымки (зоны наката). Виден только в низинах. */ + readonly fog: Graphics; + /** Зона наката, в которой герой был в прошлом кадре (тост при входе). */ + inHazard: HazardDef | null = null; + /** Текущая зона наката перекрыта полотном (для виньетки). */ + hazardMasked = false; + /** Тайл прошлого поиска зоны и его результат — поиск только при смене тайла. */ + private lastTile: { x: number; y: number } | null = null; + private lastHazard: HazardDef | null = null; + + constructor(private deps: AtmosphereDeps) { + const area = deps.area; + // Пепел на лугах, туман над прудами, в интерьере — ничего. + this.ash = + area.atmosphere === 'none' + ? null + : area.atmosphere === 'fog' + ? new ParticleEmitter({ + color: 0x7a7a88, + rate: 6, + lifetime: [5, 10], + velocity: { x: [-3, 3], y: [-1, 1] }, + size: 2, + spawnArea: { width: 560, height: 340 }, + seed: 20260906 + }) + : new ParticleEmitter({ + color: 0x666677, + rate: 9, + lifetime: [4, 9], + velocity: { x: [-7, -2], y: [1, 4] }, // ветер + медленное падение + size: 1, + spawnArea: { width: 520, height: 300 }, + seed: 20260905 + }); + if (this.ash) { + this.ash.position.set(240, 120); + deps.worldRoot.addChild(this.ash); + // Пепел тикается реестром эффектов (engine.fx) — без ручного update. + deps.addFx(this.ash); + } + + // Тёплые мотыльки над лугами: светятся (add), дышат по синусу, в интерьере их нет. + this.moths = + area.atmosphere === 'ash' + ? new ParticleEmitter({ + colors: [0xf0d878, 0xd8b050], + blend: 'add', + rate: 2, + lifetime: [3, 6], + velocity: { x: [-4, 4], y: [-2, 2] }, + size: 1, + wobble: 8, + fadeIn: 0.6, + spawnArea: { width: 520, height: 300 }, + seed: 20260907 + }) + : null; + if (this.moths) { + this.moths.position.set(240, 120); + deps.worldRoot.addChild(this.moths); + deps.addFx(this.moths); + } + + // Дымка наката: полупрозрачная пелена на весь экран (в низинах без маски — гуще). + this.fog = new Graphics().rect(0, 0, Game.VIRTUAL_W, Game.VIRTUAL_H).fill(0x5a6a78); + this.fog.alpha = 0; + deps.uiRoot.addChildAt(this.fog, 0); + } + + /** Зона наката под ногами: без маски герой еле идёт (тост при входе). */ + updateHazard(tile: { x: number; y: number }): void { + const { game, area, player } = this.deps; + // Зона зависит только от тайла: ищем заново лишь при его смене + // (поиск — замыкание + some по всем зонам, иначе он в каждом тике). + if (!this.lastTile || this.lastTile.x !== tile.x || this.lastTile.y !== tile.y) { + this.lastTile = { x: tile.x, y: tile.y }; + this.lastHazard = + (area.hazards ?? []).find((h) => + h.tiles.some((t) => t.x === tile.x && t.y === tile.y) + ) ?? null; + } + const hazard = this.lastHazard; + const masked = hazard !== null && game.inventory.has(hazard.requiresItem); + player.speedMul = hazard !== null && !masked ? 0.5 : 1; + + // Вход в низину: предупреждение один раз за вход. + if (hazard !== null && this.inHazard === null) { + this.deps.showToast( + masked ? `${hazard.name}: полотно держит — но пепел у самых губ.` : `${hazard.name}! Дышать поверх — потерять голос.` + ); + void game.audio.play('sfx/ash_hiss', 0.5); + } + this.inHazard = hazard; + this.hazardMasked = masked; + + // Дымка: плотная без маски, лёгкая с ней, вне низин её нет. + this.fog.alpha = hazard === null ? 0 : masked ? 0.12 : 0.28; + } + + /** Виньетка: низкий hp или накат без маски — края экрана темнеют. */ + updateVignette(): void { + const target = Math.max( + this.deps.heroHp() <= 2 ? VIGNETTE_LOW_HP : 0, + this.inHazard !== null && !this.hazardMasked ? VIGNETTE_HAZARD : 0 + ); + if (this.deps.vignette.level() !== target) this.deps.vignette.set(target, VIGNETTE_FADE); + } + + /** Погасить/уничтожить свои вьюхи (exit сцены; engine.fx дочистит сам). */ + destroy(): void { + this.ash?.clear(); + this.moths?.clear(); + this.ash?.destroy(); + this.moths?.destroy(); + this.fog.destroy(); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/AudioSystem.ts b/v1/apps/game/src/systems/AudioSystem.ts new file mode 100644 index 0000000..54a107b --- /dev/null +++ b/v1/apps/game/src/systems/AudioSystem.ts @@ -0,0 +1,165 @@ +import { + worldToScreen, + EventBus, + type AudioManager, + type MusicHandle, + type Vec2 +} from '@rpg/engine'; +import { Game } from '../Game'; +import { playSfx } from '../data/sfxSpecs'; +import { StepVariants, type StepSurface } from './StepVariants'; + +/** + * Позиционный звук мира: громкость и панорама от расстояния до героя, + * звуки боя — подпиской на события шины (симуляция чиста), локальные + * амбиент-слои — лупами в шине ambience с тик-фейдом. + */ + +/** Минимальный интервал между запусками одного ключа (полифония). */ +const MIN_INTERVAL = 0.08; + +export interface AudioSystemDeps { + audio: AudioManager; + events: EventBus; + /** Позиция слушателя (герой), мировые юниты. */ + getListener: () => Vec2; +} + +export interface AudioLayerDef { + key: string; + /** Целевая громкость (по умолчанию 0.5). */ + volume?: number; + /** Сек до целевой громкости. */ + fade?: number; +} + +export class AudioSystem { + private clock = 0; + private lastAt = new Map(); + private offs: (() => void)[] = []; + private layers = new Map(); + /** Ключи запрошенных слоёв (и до асинхронного старта лупов). */ + private wanted = new Set(); + /** Вариативные шаги (процедурные буферы; до unlock — файловый шаг). */ + private steps: StepVariants; + + constructor(private deps: AudioSystemDeps) { + this.steps = new StepVariants(deps.audio); + } + + /** Тик: часы полифонии + доводка громкости слоёв к целям (fade сек до target). */ + update(dt: number): void { + this.clock += dt; + for (const layer of this.layers.values()) { + if (!layer.handle) continue; // луп ещё грузится + const target = layer.def.volume ?? 0.5; + const rate = Math.abs(target) / Math.max(0.001, layer.def.fade ?? 1); + layer.volume = + layer.volume < target + ? Math.min(target, layer.volume + rate * dt) + : Math.max(target, layer.volume - rate * dt); + layer.handle.setVolume(layer.volume); + } + } + + /** + * Звук в точке мира: затухание по дистанции (степень 1.5) и панорама + * по экранному смещению от слушателя. За радиусом — тишина. + */ + playAt(key: string, pos: Vec2, base = 1, radius = 12): void { + const listener = this.deps.getListener(); + const d = Math.hypot(pos.x - listener.x, pos.y - listener.y); + const falloff = Math.max(0, 1 - d / radius); + const volume = base * Math.pow(falloff, 1.5); + if (volume < 0.02) return; + // Панорама — по разности экранных X источника и слушателя (камера не нужна). + const dx = worldToScreen(pos.x, pos.y).x - worldToScreen(listener.x, listener.y).x; + const pan = Math.max(-0.8, Math.min(0.8, dx / (Game.VIRTUAL_W / 2))); + if (Math.abs(pan) >= 0.01) this.play(key, { volume, pan }); + else this.play(key, { volume }); + } + + /** + * Шаг по поверхности ('grass'|'ash'|'water'|'floor'): процедурный вариант + * из StepVariants (до unlock — файловый шаг), дедуп по поверхности. + */ + playStep(surface: StepSurface, pos: Vec2, base = 1): void { + const listener = this.deps.getListener(); + const d = Math.hypot(pos.x - listener.x, pos.y - listener.y); + const volume = base * Math.max(0, 1 - d / 4); + if (volume < 0.02) return; + if ((this.lastAt.get(`step:${surface}`) ?? -1) + MIN_INTERVAL > this.clock) return; + this.lastAt.set(`step:${surface}`, this.clock); + this.steps.play(surface, volume); + } + + /** Подписки на события боя и фауны; возвращает off() для exit() сцены. */ + attach(): () => void { + const on = this.deps.events; + this.offs = [ + on.on<{ mode: string; origin: Vec2 }>('combat:attack', ({ mode, origin }) => { + if (mode === 'cone') this.playAt('sfx/bell_hit', origin, 0.8); + else this.playAt('sfx/bell_low', origin, 0.9); + }), + on.on<{ pos: Vec2 }>('combat:spit', ({ pos }) => this.playAt('sfx/spit', pos, 0.6)), + on.on<{ pos: Vec2 }>('combat:awake', ({ pos }) => this.playAt('sfx/ash_hiss', pos, 0.5)), + on.on<{ tile: Vec2 }>('combat:kill', ({ tile }) => this.playAt('sfx/ash_die', tile, 0.8)), + on.on('combat:playerHit', () => this.play('sfx/hurt', { volume: 0.8 })), + on.on<{ pos: Vec2 }>('fauna:startle', ({ pos }) => this.playAt('sfx/deer_startle', pos, 0.7)) + ]; + return () => this.off(); + } + + /** Отписаться от событий (сцена сменяется). */ + off(): void { + for (const off of this.offs) off(); + this.offs = []; + } + + /** + * Локальные амбиент-слои (вода, гул, дома) — лупы в шине ambience поверх + * областного. Вызывать каждый тик с текущим набором: лишние стопятся, + * громкости доводятся в update. + */ + setLayers(defs: AudioLayerDef[]): void { + this.wanted = new Set(defs.map((d) => d.key)); + for (const [key, layer] of this.layers) { + if (!this.wanted.has(key)) { + layer.handle?.stop(1); // pending-луп остановит .then (слой уже не нужен) + this.layers.delete(key); + } + } + for (const def of defs) { + const existing = this.layers.get(def.key); + if (existing) { + existing.def = def; + continue; + } + // Синхронная пометка «слой занят»: повторный вызов до резолва лупа + // не перезапускает его (playLoop асинхронен). + const pending: { def: AudioLayerDef; handle: MusicHandle | null; volume: number } = { + def, + handle: null, + volume: 0.001 + }; + this.layers.set(def.key, pending); + void this.deps.audio + .playLoop(def.key, { bus: 'ambience', fade: def.fade ?? 1, volume: 0.001 }) + .then((handle) => { + // Слой уже не нужен или луп не декодировался — без хвостов. + if (!handle || !this.wanted.has(def.key)) { + if (this.layers.get(def.key) === pending) this.layers.delete(def.key); + handle?.stop(0.5); + return; + } + pending.handle = handle; + }); + } + } + + private play(key: string, opts: { volume: number; rate?: number; pan?: number }): void { + if ((this.lastAt.get(key) ?? -1) + MIN_INTERVAL > this.clock) return; + this.lastAt.set(key, this.clock); + playSfx(this.deps.audio, key, opts); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/Clock.ts b/v1/apps/game/src/systems/Clock.ts new file mode 100644 index 0000000..ba651da --- /dev/null +++ b/v1/apps/game/src/systems/Clock.ts @@ -0,0 +1,50 @@ +/** + * Внутреигровое время суток. Темп: 1 секунда реального времени = 1 игровая + * минута (сутки = 24 минуты реального). Чистый класс без Pixi — тестируется + * в Vitest; живёт на Game (LocationScene пересоздаётся при переходах). + */ + +/** Игровых минут за секунду реального времени. */ +export const GAME_MIN_PER_SEC = 1; +/** Минут в сутках. */ +export const DAY_MINUTES = 1440; +/** Время старта новой игры (8:00). */ +export const START_MINUTES = 8 * 60; + +export class GameClock { + /** Сырые минуты суток (float, 0..1440). */ + private m = START_MINUTES; + + /** Целые минуты суток 0..1439. */ + get minutes(): number { + return Math.floor(this.m) % DAY_MINUTES; + } + + /** Часы суток float 0..24 — вход кривой dayNightFactor. */ + get hours(): number { + return this.m / 60; + } + + /** Строка для часов предмета: «8:00», «13:05» (часы без ведущего нуля). */ + get label(): string { + const m = this.minutes; + const h = Math.floor(m / 60); + const mm = m % 60; + return `${h}:${mm < 10 ? '0' : ''}${mm}`; + } + + /** Продвинуть время на dtSec секунд реального времени. */ + advance(dtSec: number): void { + this.m = (this.m + dtSec * GAME_MIN_PER_SEC) % DAY_MINUTES; + } + + /** Перемотка (мост/загрузка): mod 1440, отрицательные — в диапазон. */ + set(minutes: number): void { + this.m = ((minutes % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES; + } + + /** Новая игра: 8:00. */ + reset(): void { + this.set(START_MINUTES); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/DialogueEffects.ts b/v1/apps/game/src/systems/DialogueEffects.ts new file mode 100644 index 0000000..8042470 --- /dev/null +++ b/v1/apps/game/src/systems/DialogueEffects.ts @@ -0,0 +1,42 @@ +import type { DialogueEffectOp } from '@rpg/engine'; +import type { ItemId } from '../data/items'; +import type { DialogueCustomId } from '../data/effects'; + +/** + * Приёмник диалоговых эффектов: игра реализует его (сцена/системы), + * движок только эмитит операции из do[]. Сюда стекается giveItem/takeItem, + * звук, всплывашка и сюжетные custom-эффекты. + */ +export interface DialogueEffectSink { + giveItem(id: ItemId, count: number): void; + takeItem(id: ItemId, count: number): void; + playSound(key: string): void; + showToast(text: string): void; + custom(name: DialogueCustomId, payload: Record | undefined): void; +} + +/** + * Применить одну операцию эффекта к приёмнику. Неизвестный kind — ошибка + * валидатора контента, рантайм его молча игнорирует. + */ +export function applyDialogueEffect(op: DialogueEffectOp, sink: DialogueEffectSink): void { + switch (op.kind) { + case 'giveItem': + if (op.id) sink.giveItem(op.id as ItemId, op.count ?? 1); + return; + case 'takeItem': + if (op.id) sink.takeItem(op.id as ItemId, op.count ?? 1); + return; + case 'sound': + if (op.id) sink.playSound(op.id); + return; + case 'toast': + if (op.text) sink.showToast(op.text); + return; + case 'custom': + sink.custom((op.id ?? '') as DialogueCustomId, op.payload); + return; + default: + return; + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/DialogueSystem.ts b/v1/apps/game/src/systems/DialogueSystem.ts new file mode 100644 index 0000000..52f5003 --- /dev/null +++ b/v1/apps/game/src/systems/DialogueSystem.ts @@ -0,0 +1,135 @@ +import { + Container, + DialogueBox, + DialogueRunner, + GameState, + type DialogueBoxOptions, + type DialogueGraph +} from '@rpg/engine'; +import { applyDialogueEffect, type DialogueEffectSink } from './DialogueEffects'; + +/** + * Диалоги игры: DialogueRunner движка ходит по графам и применяет флаги + * к GameState, DialogueBox рисует реплики и варианты, do[]-эффекты идут + * в зарегистрированный EffectSink. + */ +export class DialogueSystem { + private box: DialogueBox; + private runner: DialogueRunner; + private sink: DialogueEffectSink | null = null; + + /** Сюжетное событие «диалог завершён» — для триггеров/квестов. */ + onDialogueFinished: ((id: string) => void) | null = null; + /** Звук на каждую новую реплику (колокольчик). */ + onLineShown: (() => void) | null = null; + /** id стартованного диалога (для onFinish). */ + private currentId: string | null = null; + + constructor(uiRoot: Container, state: GameState, options: DialogueBoxOptions) { + this.box = new DialogueBox({ + ...options, + onChoice: (index) => this.runner.pick(index) + }); + uiRoot.addChild(this.box.view); + + this.runner = new DialogueRunner(state); + this.runner.setView({ + show: ({ speaker, text, mood, choices }) => { + this.box.show({ speaker, text, mood, choices }); + this.onLineShown?.(); + }, + hide: () => this.box.hide() + }); + this.runner.onEffect = (op) => { + if (this.sink) applyDialogueEffect(op, this.sink); + }; + } + + /** Зарегистрировать приёмник do[]-эффектов (сцена с геймплеем). */ + registerSink(sink: DialogueEffectSink): void { + this.sink = sink; + } + + /** Уничтожить панель диалога (exit сцены — иначе копится в uiRoot). */ + destroy(): void { + this.box.view.destroy({ children: true }); + } + + get active(): boolean { + return this.runner.active; + } + + /** Начать диалог по ключу из data/dialogues.ts. */ + start(graph: DialogueGraph, id: string): void { + this.currentId = id; + this.runner.onFinish = () => { + const id = this.currentId; + this.currentId = null; + if (id) this.onDialogueFinished?.(id); + }; + this.runner.start(graph); + } + + /** Клик/пробел: догнать печать → активировать вариант под курсором → дальше. */ + advance(): void { + if (this.box.revealing) { + this.box.skipReveal(); + return; + } + if (this.runner.waitingForChoice) { + this.box.activate(); + return; + } + this.runner.advance(); + } + + /** Листать варианты up/down (клавиатура/геймпад). */ + moveCursor(delta: number): void { + this.box.moveCursor(delta); + } + + /** Печать текста; звать из update сцены. */ + update(dt: number): void { + this.box.update(dt); + } + + /** Выбрать показанный вариант (агентный мост; индексы — из agentState.choices). */ + pickChoice(index: number): void { + this.runner.pick(index); + } + + /** Выбрать вариант по тексту (агентный мост). false — такого варианта нет. */ + pickChoiceByText(text: string): boolean { + const index = this.runner.choices.findIndex((c) => c.text === text); + if (index < 0) return false; + this.runner.pick(index); + return true; + } + + /** Состояние диалога для агентного моста (снапшот; не для логики). */ + get agentState(): { + id: string | null; + nodeId: string | null; + speaker: string | null; + text: string | null; + mood: string | null; + tags: string[]; + choices: string[]; + path: string[]; + waitingForChoice: boolean; + } | null { + if (!this.runner.active) return null; + const node = this.runner.node; + return { + id: this.currentId, + nodeId: this.runner.nodeId, + speaker: node?.speaker ?? null, + text: node?.text ?? null, + mood: node?.mood ?? null, + tags: node?.tags ?? [], + choices: this.runner.choices.map((c) => c.text), + path: this.runner.path, + waitingForChoice: this.runner.waitingForChoice + }; + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/InteractHint.ts b/v1/apps/game/src/systems/InteractHint.ts new file mode 100644 index 0000000..500047b --- /dev/null +++ b/v1/apps/game/src/systems/InteractHint.ts @@ -0,0 +1,42 @@ +import { Container, PixelText, worldToScreen } from '@rpg/engine'; + +/** Цель кнопки действия (от роутера): kind — для будущих иконок. */ +export interface InteractTargetInfo { + kind: string; + label: string; + tile: { x: number; y: number }; +} + +/** + * Подпись «[E] Имя» над ближайшей целью кнопки действия. Один вью на сцену + * (в worldRoot — масштабируется зумом камеры): позиция над тайлом цели, + * видна только когда цель есть. Заменяет прежние подписи per-object — + * NPC впервые получают подписи тоже. + */ +export class InteractHint { + readonly view = new Container(); + private text: PixelText; + + constructor() { + this.text = new PixelText({ text: '', size: 9, color: 0xd8c79a }); + this.text.anchor.set(0.5, 1); + this.view.addChild(this.text); + this.view.visible = false; + } + + /** Показать цель (null — скрыть). Центр тайла переводится в px внутри worldRoot. */ + show(target: InteractTargetInfo | null): void { + if (!target) { + this.view.visible = false; + return; + } + const c = worldToScreen(target.tile.x + 0.5, target.tile.y + 0.5); + this.text.text = `[E] ${target.label}`; + this.view.position.set(Math.round(c.x), Math.round(c.y) - 20); + this.view.visible = true; + } + + destroy(): void { + this.view.destroy({ children: true }); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/InteractRouting.ts b/v1/apps/game/src/systems/InteractRouting.ts new file mode 100644 index 0000000..be4b549 --- /dev/null +++ b/v1/apps/game/src/systems/InteractRouting.ts @@ -0,0 +1,258 @@ +import { tileToWorld, type IsometricTileMap, type TileMapData, type Vec2 } from '@rpg/engine'; +import type { AreaDef, TransitionDef } from '../data/locations'; +import { resolveTransition, type TransitionCtx } from '../data/transitions'; +import type { NpcDef } from '../data/npcs'; +import type { InteractableDef } from '../data/interactables'; +import type { Interactables } from './Interactables'; +import type { SceneObjects } from './SceneObjects'; +import type { PlayerController } from './PlayerController'; +import type { Game } from '../Game'; + +/** + * Маршрутизация взаимодействия: кнопка действия (ближайшая цель в радиусе) + * и step-переходы с дисармом тайла входа. Чистый пикер (pickInteract) + * тестируется в Vitest без Pixi; InteractionRouter — рантайм поверх него. + * Сцена остаётся оркестратором вьюх. (Клик-роутинг удалён: движение — + * WASD/стик/джойстик, взаимодействие — кнопкой действия.) + */ + +/** Колбэки роутера в сцену: всё, что трогает вьюхи/катсцены/замену сцен. */ +export interface RouterCallbacks { + showToast(text: string): void; + playUiClick(): void; + talkTo(def: NpcDef): void; + collectFlower(x: number, y: number): void; + useTransition(def: TransitionDef): void; +} + +/** Зависимости рантайма роутера (всё уже собрано сценой). */ +export interface RouterDeps { + game: Game; + map: IsometricTileMap; + area: AreaDef; + player: PlayerController; + interactables: Interactables; + /** Реестр объектов сцены: «кто на тайле» (NPC/интерактивы, grid путей). */ + objects: SceneObjects; + /** Тайл входа в область (step-переходы на нём дисармованы). */ + disarmTile: { x: number; y: number } | null; + callbacks: RouterCallbacks; +} + +/** Радиус взаимодействия (юнитов): от ног героя до центра тайла цели. */ +const INTERACT_RANGE = 1.5; + +/** Дистанция до центра тайла цели: истинная евклидова линейка (юнит = 1 тайл). */ +function tileDist(playerPos: Vec2, tx: number, ty: number): number { + const c = tileToWorld(tx, ty); + return Math.hypot(c.x - playerPos.x, c.y - playerPos.y); +} + +/** + * Радиус взаимодействия: истинная евклидова линейка (юнит = 1 тайл), не + * проекционная. Проекция анизотропна (2:1) — «юниты» в ней вытянуты вдоль + * мировой диагонали, и радиус в inCircleW давал зону в 2-3 тайла: объект + * срабатывал издалека, до подхода героя. Орто-сосед 1.0 ✓, диагональный + * сосед 1.414 ✓ (findPathToNeighbor приводит в него), 2 тайла ✗. + */ +export function inInteractRangePos(playerPos: Vec2, tx: number, ty: number): boolean { + return tileDist(playerPos, tx, ty) <= INTERACT_RANGE; +} + +/** Кандидат кнопки действия: то, с чем можно взаимодействовать в радиусе. */ +export interface InteractCandidate { + kind: 'npc' | 'transition' | 'locked' | 'interact' | 'flower'; + tile: { x: number; y: number }; + /** Дистанция от героя до центра тайла (юниты) — решает выбор. */ + dist: number; + /** Человекочитаемое имя для подписи «[E] …». */ + label: string; + npc?: NpcDef; + inter?: InteractableDef; + trans?: TransitionDef; + /** Текст «заперто» для kind 'locked'. */ + text?: string; + /** Тайл цветка (kind 'flower'). */ + x?: number; + y?: number; +} + +/** Приоритет при ничьей по дистанции: NPC важнее перехода, тот — объекта, объект — цветка. */ +const PICK_PRIORITY: Record = { + npc: 0, + transition: 1, + locked: 1, + interact: 2, + flower: 3 +}; + +/** Эпсилон ничьей по дистанции (юниты): ближе — выигрывает дистанция, иначе приоритет. */ +const PICK_EPS = 0.05; + +/** + * Выбор цели кнопки действия: ближайший кандидат в радиусе; при ничьей + * (~эпсилон) — по приоритету kind. Чистая функция — тестируется в Vitest. + */ +export function pickInteract(candidates: InteractCandidate[]): InteractCandidate | null { + let best: InteractCandidate | null = null; + for (const c of candidates) { + if (c.dist > INTERACT_RANGE) continue; + if ( + best === null || + c.dist < best.dist - PICK_EPS || + (c.dist <= best.dist + PICK_EPS && PICK_PRIORITY[c.kind] < PICK_PRIORITY[best.kind]) + ) { + best = c; + } + } + return best; +} + +/** + * Рантайм маршрутизации: кнопка действия (ближайшая цель в радиусе) и + * step-переходы с дисармом тайла входа. + */ +export class InteractionRouter { + private disarmTile: { x: number; y: number } | null; + private prevStepTile: { x: number; y: number } | null = null; + + constructor(private deps: RouterDeps) { + this.disarmTile = deps.disarmTile; + } + + /** Радиус взаимодействия (для подписей объектов в сцене). */ + inInteractRange(tx: number, ty: number): boolean { + return inInteractRangePos(this.deps.player.position, tx, ty); + } + + /** + * Единый механизм переходов (step-триггер): герой наступил на тайл. + * На тайле входа step-переходы дисармованы, пока герой с него не ушёл. + */ + checkTransitions(tile: { x: number; y: number }): void { + const fresh = + this.prevStepTile === null || + this.prevStepTile.x !== tile.x || + this.prevStepTile.y !== tile.y; + this.prevStepTile = tile; + // Ушёл с тайла входа — дисарм снят, переходы снова работают. + if (this.disarmTile && (tile.x !== this.disarmTile.x || tile.y !== this.disarmTile.y)) { + this.disarmTile = null; + } + const pick = resolveTransition( + this.deps.area.transitions, + tile, + 'step', + this.transitionCtx(), + this.disarmTile + ); + if (!pick) return; + if (!pick.ok) { + // «Заперто» — не спамим: только при приходе на тайл. + if (fresh) { + this.deps.callbacks.showToast(pick.lockedText); + this.deps.callbacks.playUiClick(); + } + return; + } + this.deps.callbacks.useTransition(pick.def); + } + + // ---------- кнопка действия: ближайшая цель в радиусе ---------- + + /** Кандидаты кнопки действия вокруг героя: NPC, click-переходы, интерактивы, цветы. */ + interactCandidates(): InteractCandidate[] { + const player = this.deps.player.position; + const heroTile = this.deps.player.currentTile(); + const out: InteractCandidate[] = []; + for (const def of this.deps.objects.npcs()) { + out.push({ + kind: 'npc', + npc: def, + tile: def.tile, + dist: tileDist(player, def.tile.x, def.tile.y), + label: def.name + }); + } + for (const t of this.deps.area.transitions) { + if ((t.trigger ?? 'step') !== 'click') continue; + const pick = resolveTransition(this.deps.area.transitions, t.tile, 'click', this.transitionCtx(), null); + if (!pick) continue; + out.push({ + kind: pick.ok ? 'transition' : 'locked', + trans: pick.ok ? pick.def : undefined, + text: pick.ok ? undefined : pick.lockedText, + tile: t.tile, + dist: tileDist(player, t.tile.x, t.tile.y), + label: t.label ?? 'проход' + }); + } + for (const def of this.deps.area.interactables ?? []) { + if (def.once && this.deps.interactables.isUsed(def.id)) continue; + out.push({ + kind: 'interact', + inter: def, + tile: def.tile, + dist: tileDist(player, def.tile.x, def.tile.y), + label: def.label ?? def.id + }); + } + // Цветы: скан окна ±2 тайла вокруг героя по сборному тайлу области. + if (this.deps.area.harvestTile !== undefined) { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + const x = heroTile.x + dx; + const y = heroTile.y + dy; + if (!this.isFlower(x, y)) continue; + out.push({ kind: 'flower', x, y, tile: { x, y }, dist: tileDist(player, x, y), label: 'лунный колокольчик' }); + } + } + } + return out; + } + + /** Ближайшая цель кнопки действия — для подписи «[E] имя» и снапшота моста. */ + interactTarget(): { kind: InteractCandidate['kind']; label: string; tile: { x: number; y: number } } | null { + const c = pickInteract(this.interactCandidates()); + return c && { kind: c.kind, label: c.label, tile: c.tile }; + } + + /** Кнопка действия: сработать ближайшей целью в радиусе (вне радиуса — ничего). */ + resolveInteract(): void { + const c = pickInteract(this.interactCandidates()); + if (!c) return; + switch (c.kind) { + case 'npc': + this.deps.callbacks.talkTo(c.npc!); + return; + case 'transition': + this.deps.callbacks.useTransition(c.trans!); + return; + case 'locked': + this.deps.callbacks.showToast(c.text ?? 'Не пускает.'); + this.deps.callbacks.playUiClick(); + return; + case 'interact': + this.deps.interactables.tryInteract(c.inter!); + return; + case 'flower': + this.deps.callbacks.collectFlower(c.x!, c.y!); + } + } + + /** Сборный тайл области (лунный колокольчик); нет harvestTile — не собирается. */ + private isFlower(x: number, y: number): boolean { + const harvest = this.deps.area.harvestTile; + if (harvest === undefined) return false; + const d: TileMapData = this.deps.map.data; + return d.tiles[y * d.width + x] === harvest; + } + + /** Контекст условий перехода: флаги GameState, сумка героя. */ + private transitionCtx(): TransitionCtx { + return { + hasFlag: (f) => this.deps.game.state.hasFlag(f), + hasItem: (id) => this.deps.game.inventory.has(id) + }; + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/InteractableViews.ts b/v1/apps/game/src/systems/InteractableViews.ts new file mode 100644 index 0000000..d09fb23 --- /dev/null +++ b/v1/apps/game/src/systems/InteractableViews.ts @@ -0,0 +1,159 @@ +import { Container, FxLayer, Graphics, PixelText, Sprite, SpriteAnimator, SpriteMotion, type AssetLoader, type Updatable } from '@rpg/engine'; +import type { InteractableDef } from '../data/interactables'; +import type { NpcDef } from '../data/npcs'; + +/** + * Вьюхи NPC и интерактивных объектов: фабрики спрайтов/плейсхолдеров с + * подписями + растворение использованных объектов. Сцена только вызывает + * фабрики и держит слои глубины. + */ + +/** Вьюха интерактивного объекта + её части (тело — для кликов, подпись — близость). */ +export interface InteractableView { + def: InteractableDef; + view: Container; + body: Graphics | Sprite; + label: PixelText; +} + +export interface InteractableViewDeps { + assets: AssetLoader; + /** Реестр тикающихся (engine.fx): пульс мота и idle-анимация NPC. */ + addFx(u: Updatable): void; + /** Слой мировых эффектов: растворение использованной вьюхи. */ + fx: FxLayer; +} + +/** Экранный центр ромба тайла (worldToScreen ∘ tileToWorld). */ +export type TileCenter = (tx: number, ty: number) => { x: number; y: number }; + +export class InteractableViews { + /** Созданные вьюхи интерактивов (подписи, клики по телу, растворение). */ + readonly items: InteractableView[] = []; + + constructor(private deps: InteractableViewDeps) {} + + /** Вьюха интерактивного объекта: спрайт из def.view или плейсхолдер + подпись. */ + createInteractableView(def: InteractableDef, center: TileCenter): InteractableView { + const view = new Container(); + const p = center(def.tile.x, def.tile.y); + view.position.set(p.x, p.y); + let g: Graphics | Sprite; + if (def.view?.tex) { + // Настоящий арт из def.view: якорь низом в центр ромба (или задан). + const sprite = new Sprite(this.deps.assets.texture(def.view.tex)); + sprite.anchor.set(0.5, def.view.anchorY ?? 1); + g = sprite; + } else { + // Плейсхолдер-ящик по роду (пока нет арта конкретного объекта). + g = new Graphics(); + switch (def.kind) { + case 'signpost': // столбик с доской + g.rect(-1, -10, 2, 10).fill(0x4a3b2a); + g.rect(-6, -14, 12, 5).fill(0x5c4a33); + g.rect(-6, -14, 12, 1).fill(0x8a6d3a); + break; + case 'lever': // рычаг: основание и наклонная рукоять + g.rect(-3, -2, 6, 2).fill(0x4a3b2a); + g.rect(0, -8, 2, 7).fill(0x8a6d3a); + g.rect(-2, -10, 4, 2).fill(0x8a6d3a); + break; + case 'pickup': // пепельная насыпь, из которой теплится мот + g.rect(-6, -2, 12, 3).fill(0x3a3a3a); + g.rect(-4, -4, 8, 2).fill(0x4a4a4a); + g.rect(-2, -5, 4, 1).fill(0x5a5a5a); + break; + default: // контейнер/предмет осмотра: ящик с бронзовой кромкой + g.rect(-5, -12, 10, 12).fill(0x4a3b2a); + g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо + } + } + view.addChild(g); + // У мота — тёплая искра поверх насыпи: заметный пульс видно издалека. + if (def.kind === 'pickup') { + const spark = new Graphics(); + spark.rect(-1, -1, 3, 3).fill(0xf0d878); + spark.position.set(0, -5); + view.addChild(spark); + this.deps.addFx( + new SpriteMotion(spark, { pulse: { min: 0.3, max: 1, period: 1.1 } }, { + seed: def.tile.x * 31 + def.tile.y + }) + ); + } + const label = new PixelText({ text: def.label ?? def.id, size: 9, color: 0xd8c79a }); + label.anchor.set(0.5, 1); + label.position.set(0, -16); + label.visible = false; + view.addChild(label); + const made = { def, view, body: g, label }; + this.items.push(made); + return made; + } + + /** + * Одноразовый объект поднят (sink.onConsumed): вьюха растворяется, из + * тайла вылетают пеплинки — состояние мира меняется на глазах. + */ + consume(def: InteractableDef, center: TileCenter, burst: (s: { x: number; y: number }) => void): void { + const iv = this.items.find((v) => v.def.id === def.id); + if (!iv) return; + this.items.splice(this.items.indexOf(iv), 1); + const w = center(def.tile.x, def.tile.y); + burst(w); + // Вьюха тает и удаляется сама (пресет; removeChild+destroy внутри). + this.deps.fx.fade(iv.view, { duration: 0.5 }); + } + + /** NPC: спрайт с idle-дыханием кадрами атласа + маркер «с ним можно говорить». */ + createNpcView(def: NpcDef, center: TileCenter): { view: Container; body: Sprite } { + const view = new Container(); + const p = center(def.tile.x, def.tile.y); + view.position.set(p.x, p.y); + + const sprite = new Sprite(); + sprite.anchor.set(0.5, 1); // ноги в центре ромба + // idle-дыхание кадрами из атласа (по умолчанию — общий лист NPC). + const idle = this.deps.assets.animation( + def.atlas ?? 'chars/npcs_sheet.json', + `${def.sprite}_idle` + ); + sprite.texture = idle[0]!; + const animator = new SpriteAnimator( + sprite, + { idle: { frames: idle, fps: 1.2 } }, + 'idle', + { randomPhase: def.tile.y * 64 + def.tile.x } + ); + this.deps.addFx(animator); + view.addChild(sprite); + + // Маркер «с ним можно говорить»: восклицательный штрих над плечом + // (высота — от реального кадра атласа; сбоку, чтобы не закрывать лицо). + const frameH = idle[0]!.height; + const marker = new Graphics(); + marker.rect(5, -(frameH + 3), 2, 4).fill(0xf0d878); + marker.rect(5, -(frameH - 3), 2, 2).fill(0xf0d878); + view.addChild(marker); + return { view, body: sprite }; + } +} + +/** Бронзовый контур проёма + подпись «выход» — маркер интерьеров (kind 'return'). */ +export function makeExitGlow(p: { x: number; y: number }): Container { + const view = new Container(); + const glow = new Graphics(); + glow + .moveTo(0, -8) + .lineTo(16, 0) + .lineTo(0, 8) + .closePath() + .stroke({ color: 0x8a6d3a, width: 1 }); + glow.position.set(p.x, p.y); + view.addChild(glow); + const label = new PixelText({ text: 'выход', size: 9, color: 0xd8c79a }); + label.anchor.set(0.5, 1); + label.position.set(p.x, p.y - 12); + view.addChild(label); + return view; +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/Interactables.ts b/v1/apps/game/src/systems/Interactables.ts new file mode 100644 index 0000000..9f0adc5 --- /dev/null +++ b/v1/apps/game/src/systems/Interactables.ts @@ -0,0 +1,85 @@ +import type { GameState } from '@rpg/engine'; +import type { ItemId } from '../data/items'; +import { usedFlag } from '../data/ids'; +import { + resolveInteractable, + type InteractableDef, + type InteractCtx, + type InteractPick, + type InteractResponse +} from '../data/interactables'; + +/** + * Рантайм интерактивных объектов области: поиск по тайлу, флаги used:, + * применение эффектов реакции через sink (сцена подставляет сумку/звук/тост). + */ + +/** Эффекты реакции — мост между чистым резолвером и игрой. */ +export interface InteractSink { + hasItem(id: ItemId): boolean; + give(id: ItemId, count: number): void; + playSound(key: string): void; + showToast(text: string): void; + /** Сюжетный эффект реакции (DIALOGUE_CUSTOM): сцена запускает катсцену. */ + onCustom?(name: string): void; + /** + * Одноразовый объект «поднят» (once, реакция ok): сцена убирает вьюху с + * поля (fade) и даёт визуальный отклик. Вызывается после apply. + */ + onConsumed?(def: InteractableDef, response: InteractResponse): void; +} + +export class Interactables { + constructor( + private defs: InteractableDef[], + private state: GameState, + private sink: InteractSink + ) {} + + /** Объект на тайле (или null). */ + defAt(x: number, y: number): InteractableDef | null { + return this.defs.find((d) => d.tile.x === x && d.tile.y === y) ?? null; + } + + /** Одноразовый объект уже использован (флаг used: в GameState). */ + isUsed(id: string): boolean { + return this.state.hasFlag(usedFlag(id)); + } + + /** Взаимодействие: выбор реакции + применение эффектов. */ + tryInteract(def: InteractableDef): InteractPick { + const ctx: InteractCtx = { + hasFlag: (f) => this.state.hasFlag(f), + hasItem: (i) => this.sink.hasItem(i), + getVar: (id) => this.state.getNumber(id), + used: (id) => this.isUsed(id) + }; + const pick = resolveInteractable(def, ctx); + if (pick?.ok) { + this.apply(def, pick.response); + if (def.once) this.sink.onConsumed?.(def, pick.response); + } + return pick; + } + + /** Применить эффекты реакции (порядок: флаги -> вар -> предмет -> звук -> тост). */ + private apply(def: InteractableDef, r: InteractResponse): void { + for (const f of r.setFlags ?? []) this.state.setFlag(f); + for (const f of r.clearFlags ?? []) this.state.clearFlag(f); + if (r.setVar) this.state.setVar(r.setVar.id, r.setVar.value); + if (r.addVar) this.state.setVar(r.addVar.id, this.state.getNumber(r.addVar.id) + r.addVar.by); + if (r.gives) this.sink.give(r.gives, r.count ?? 1); + if (def.once) this.state.setFlag(usedFlag(def.id)); + if (r.sound) this.sink.playSound(r.sound); + if (r.custom) this.sink.onCustom?.(r.custom); + if (r.toast) { + // Тост с прогрессом (как у цветов): «Тон (2/3)». + const n = r.toast.varKey !== undefined ? this.state.getNumber(r.toast.varKey) : null; + const progress = + n !== null && r.toast.goal !== undefined ? ` (${Math.min(n, r.toast.goal)}/${r.toast.goal})` : ''; + this.sink.showToast(r.toast.text + progress); + } else { + this.sink.showToast(r.text); + } + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/Lighting.ts b/v1/apps/game/src/systems/Lighting.ts new file mode 100644 index 0000000..3068406 --- /dev/null +++ b/v1/apps/game/src/systems/Lighting.ts @@ -0,0 +1,295 @@ +/** + * Игровая обвязка освещения: статика из LightingDef области + лампа героя, + * проекция в экранные px каждый тик (camera.toScreen), условные источники + * по флагам и времени суток. Runtime API (setAmbient) — база day/night. + */ + +import { + Camera, + Lighting, + dayNightAmbient, + dayNightFactor, + tileToWorld, + unitsToPx, + type PulseSpec, + type Vec2 +} from '@rpg/engine'; +import type { AreaDef, LightSourceDef } from '../data/locations'; + +/** Параметры лампы героя (следует за героем в тёмных областях). */ +export interface HeroLampDef { + color: number; + radius: number; // юниты + intensity: number; + flicker: number; +} + +/** Кап активных источников — защита от опечатки в данных (tiles одного id). */ +export const MAX_LIGHTS = 24; + +export interface GameLightingDeps { + area: AreaDef; + /** Движковый адаптер (уже добавлен в renderer.lightRoot). */ + lighting: Lighting; + /** Позиции тайлов по id (один проход по карте, см. LocationScene). */ + layerTilePos: Map; + camera: Camera; + /** Позиция героя в мировых юнитах. */ + heroPos: () => Vec2; + hasFlag: (flag: string) => boolean; + /** Текущее время суток в часах (GameClock.hours) — вход кривой дня. */ + timeHours: () => number; + lamp?: HeroLampDef | null; +} + +export interface LightingSnapshot { + ambient: number; + /** Целевая интенсивность виньетки 0..1 (текущая — пост-лерп движка). */ + vignette: number; + sources: { id: string; x: number; y: number; color: number; intensity: number }[]; +} + +/** Порог ночи для ночных источников и лампы на светлой улице (≈ 19:00–07:00). */ +const NIGHT_SOURCE_FACTOR = 0.5; + +/** Тёмная аура зоны наката: радиус в юнитах и сила затемнения. */ +const HAZARD_SPOT_RADIUS = 1.6; +const HAZARD_SPOT_ALPHA = 0.22; + +/** + * Подъём центра лампы героя от ног к телу, в юнитах по обеим мировым осям + * (в изометрии это «вверх по экрану»: 0.5+0.5 юнита ≈ 8 px при tileH 16). + */ +const LAMP_LIFT = 0.5; + +/** Кап тёмных пятен — под пул движка (maxDarkSpots, по умолчанию 16). */ +const MAX_DARK_SPOTS = 16; + +/** Считается ли область тёмной для лампы (тот же порог, что darkAreaLamp сцены). */ +function isDarkAmbient(ambient: number | undefined): boolean { + if (ambient === undefined) return false; + const r = (ambient >> 16) & 0xff; + const g = (ambient >> 8) & 0xff; + const b = ambient & 0xff; + return (r * 0.3 + g * 0.6 + b * 0.1) / 255 < 0.75; +} + +/** Детерминированная фаза мерцания по id: снапшот воспроизводим между запусками. */ +function seedOf(id: string): number { + let h = 2166136261; + for (let i = 0; i < id.length; i++) { + h ^= id.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return ((h >>> 0) % 1000) / 1000; +} + +export class GameLighting { + private readonly deps: GameLightingDeps; + /** Активные id на прошлом тике — чтобы снимать погасшие (условия по флагам). */ + private activeIds = new Set(); + /** Активные id тёмных пятен на прошлом тике (ауры хазардов). */ + private spotIds = new Set(); + /** + * Дифф-буферы (каждый тик, без аллокаций): пара Set'ов на слой, своп после + * тика — clear() не должен задевать активный набор. + */ + private idsB = new Set(); + private spotsB = new Set(); + /** Переиспользуемые списки активных def/позиций (каждый тик). */ + private readonly defsBuf: LightSourceDef[] = []; + private readonly posBuf: { x: number; y: number }[] = []; + /** Мутируемый def лампы героя (пушится в defsBuf без пересоздания). */ + private readonly lampAt = { x: 0, y: 0 }; + private readonly lampDef: LightSourceDef = { id: 'lamp', at: this.lampAt }; + /** Последняя заданная цель ambient — guard от повторного setAmbient. */ + private lastAmbientTarget: number | null = null; + private destroyed = false; + + constructor(deps: GameLightingDeps) { + this.deps = deps; + if (deps.area.lighting?.ambient !== undefined) { + deps.lighting.setAmbient(deps.area.lighting.ambient); + this.lastAmbientTarget = deps.area.lighting.ambient; + this.updateNightAmbient(); // ночной вход в область — без мелькания дня + } + } + + // --- runtime поверх статики области (фундамент day/night и эффектов) --- + + setAmbient(color: number, fadeSec = 0): void { + this.deps.lighting.setAmbient(color, fadeSec); + this.lastAmbientTarget = color; + } + + /** Виньетка 0..1 (низкий hp, опасная зона); guard по изменению — у сцены. */ + setVignette(intensity: number, fadeSec = 0): void { + this.deps.lighting.setVignette(intensity, fadeSec); + } + + /** Текущая целевая виньетка. */ + get vignetteLevel(): number { + return this.deps.lighting.vignetteLevel; + } + + /** Импульс света в точке (позиция уже в экранных px, радиус — в px). */ + pulseLight(args: { x: number; y: number; color: number; intensity?: number; radius?: number; spec: PulseSpec }): void { + this.deps.lighting.pulseLight(args); + } + + /** Аддитивная вспышка на весь экран (color, peak 0..1, огибающая). */ + pulseAmbient(args: { color: number; peak: number; spec: PulseSpec }): void { + this.deps.lighting.pulseAmbient(args); + } + + /** Тик: ночная цель ambient, источники, тёмные пятна хазардов. */ + update(): void { + if (this.destroyed) return; + this.updateNightAmbient(); + const next = this.idsB; + next.clear(); + for (const def of this.activeDefs()) { + for (const pos of this.sourcePositions(def)) { + if (next.size >= MAX_LIGHTS) break; + const id = def.tiles ? `${def.id}@${pos.x},${pos.y}` : def.id; + next.add(id); + const world = tileToWorld(pos.x + 0.5, pos.y + 0.5); + const s = this.deps.camera.toScreen(world.x, world.y); + // Свет живёт в lightRoot (вне worldRoot): радиус масштабируем зумом. + this.deps.lighting.upsertLight({ + id, + x: s.x, + y: s.y, + color: def.color ?? 0xf2b45a, + intensity: def.intensity ?? 0.9, + radius: unitsToPx(def.radius ?? 3) * this.deps.camera.zoomLevel, + flicker: def.flicker ?? 0, + seed: seedOf(id) + }); + } + } + for (const id of this.activeIds) { + if (!next.has(id)) this.deps.lighting.removeLight(id); + } + this.idsB = this.activeIds; // старый набор — на следующей итерации буфер + this.activeIds = next; + this.updateHazardSpots(); + } + + /** Текущий фактор ночи 0..1 по часам (округлён — для снапшота и фильтров). */ + get nightFactor(): number { + return Math.round(dayNightFactor(this.deps.timeHours()) * 1000) / 1000; + } + + /** Цель ambient: микс дневного и ночного по кривой суток (плавный закат — сама кривая). */ + private updateNightAmbient(): void { + const light = this.deps.area.lighting; + const night = light?.nightAmbient; + const base = light?.ambient; + if (night === undefined || base === undefined) return; + // Золотой час: день → закатный тон → ночь (рассвет тем же тёплым цветом). + const target = dayNightAmbient(base, night, this.nightFactor, light?.duskColor); + if (target !== this.lastAmbientTarget) this.setAmbient(target, 0); + } + + /** Тёмные ауры зон наката: по тайлу HazardDef, снятие погасших через diff. */ + private updateHazardSpots(): void { + const next = this.spotsB; + next.clear(); + for (const hazard of this.deps.area.hazards ?? []) { + for (const t of hazard.tiles) { + if (next.size >= MAX_DARK_SPOTS) break; + const id = `hazard@${t.x},${t.y}`; + next.add(id); + const world = tileToWorld(t.x + 0.5, t.y + 0.5); + const s = this.deps.camera.toScreen(world.x, world.y); + this.deps.lighting.upsertDarkSpot({ + id, + x: s.x, + y: s.y, + radius: unitsToPx(HAZARD_SPOT_RADIUS) * this.deps.camera.zoomLevel, + alpha: HAZARD_SPOT_ALPHA + }); + } + } + for (const id of this.spotIds) { + if (!next.has(id)) this.deps.lighting.removeDarkSpot(id); + } + this.spotsB = this.spotIds; + this.spotIds = next; + } + + /** Состояние для агентного снапшота (интенсивность — пост-мерцание). */ + snapshot(): LightingSnapshot { + const frames = this.deps.lighting.frames(); + return { + ambient: this.deps.lighting.ambientColor, + vignette: Math.round(this.deps.lighting.vignetteLevel * 1000) / 1000, + sources: frames.map((f) => ({ + id: f.id, + x: Math.round(f.x * 10) / 10, + y: Math.round(f.y * 10) / 10, + color: f.tint, + intensity: Math.round(f.alpha * 1000) / 1000 + })) + }; + } + + destroy(): void { + for (const id of this.activeIds) this.deps.lighting.removeLight(id); + this.activeIds.clear(); + for (const id of this.spotIds) this.deps.lighting.removeDarkSpot(id); + this.spotIds.clear(); + this.destroyed = true; + } + + // --- внутреннее --- + + /** Активные def области + лампа героя (в defsBuf, переиспользуется каждый тик). */ + private activeDefs(): LightSourceDef[] { + const out = this.defsBuf; + out.length = 0; + const all = this.deps.area.lighting?.sources ?? []; + const night = this.nightFactor; + for (const def of all) { + if (def.whenFlag && !this.deps.hasFlag(def.whenFlag)) continue; + if (def.notFlag && this.deps.hasFlag(def.notFlag)) continue; + if (def.whenNight && night < NIGHT_SOURCE_FACTOR) continue; + out.push(def); + } + const lamp = this.deps.lamp; + // Лампа в тёмной области или ночью (вечерняя улица тоже темна). + if (lamp && (isDarkAmbient(this.deps.area.lighting?.ambient) || night >= NIGHT_SOURCE_FACTOR)) { + // Позиция героя уже в мировых юнитах: центры тайлов = tileToWorld(x+0.5), + // поэтому отнимаем 0.5 — источник встаёт на якорь героя (ноги). Спрайт + // героя рисуется выше якоря (16×24, низ = якорь), поэтому поднимаем + // центр лампы к телу: в изометрии «вверх по экрану» = минус по обеим + // мировым осям поровну (экранный Y = (x+y)·tileH/2). + const hero = this.deps.heroPos(); + this.lampAt.x = hero.x - 0.5 - LAMP_LIFT; + this.lampAt.y = hero.y - 0.5 - LAMP_LIFT; + const d = this.lampDef; + d.radius = lamp.radius; + d.color = lamp.color; + d.intensity = lamp.intensity; + d.flicker = lamp.flicker; + out.push(d); + } + return out; + } + + /** Позиции источника в тайлах (в posBuf): точка at или все тайлы заданных id. */ + private sourcePositions(def: LightSourceDef): { x: number; y: number }[] { + const out = this.posBuf; + out.length = 0; + if (def.at) { + out.push(def.at); + return out; + } + for (const id of def.tiles ?? []) { + const positions = this.deps.layerTilePos.get(id); + if (positions) for (const pos of positions) out.push(pos); + } + return out; + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/LocationAudio.ts b/v1/apps/game/src/systems/LocationAudio.ts new file mode 100644 index 0000000..ef907d8 --- /dev/null +++ b/v1/apps/game/src/systems/LocationAudio.ts @@ -0,0 +1,172 @@ +import { RATE, renderMusicStereo, renderSpec, type AudioManager, type MusicHandle, type Vec2 } from '@rpg/engine'; +import { AudioSystem, type AudioLayerDef } from './AudioSystem'; +import { ROOM_TONES } from '../data/sfxSpecs'; +import { TENSION_STEM } from '../data/music'; +import type { AmbienceLayerDef, AreaDef } from '../data/locations'; + +/** + * Аудио-подсистема локации: позиционные слои (громкость от дистанции до + * источника), room tone интерьеров и динамическая музыка угрозы + * (tension-стем под темой области). Сцена кормит её тиком + * и уровнем угрозы боевого мира; свои лупы гасит сама в exit(). + */ + +export interface LocationAudioDeps { + audio: AudioManager; + /** Позиционный звук мира (шаги, звуки боя) — тикается сценой отдельно. */ + worldAudio: AudioSystem; + area: AreaDef; + /** Позиции тайлов-источников амбиент-слоёв по id (собирает сцена). */ + layerTilePos: Map; + /** Слушатель — герой (мировые юниты). */ + heroPos: () => Vec2; + /** Тема области/боя (Game.playTheme: кроссфейд, '' — тишина). */ + playTheme: (key: string) => void; + /** Амбиент области (Game.playAmbience: кроссфейд 1.5 с, тот же ключ не перезапускает). */ + playAmbience: (key: string) => void; + /** Фактор ночи 0..1 (кривая суток, как у света). */ + nightFactor: () => number; + /** Уровень угрозы боевого мира 0..1 (погоня — 1, wary — 0.4). */ + threat: () => number; +} + +export class LocationAudio { + /** Tension-стем (враги wary): буфер + хендл + сглаженная громкость. */ + private stemHandle: MusicHandle | null = null; + private stemBuf: AudioBuffer | null = null; + private stemLevel = 0; + /** Стем заводится (промис не вернулся) — не заводить второй. */ + private stemPending = false; + /** Room tone интерьера (тихий гул на шине ambience). */ + private roomtoneHandle: MusicHandle | null = null; + /** exit() вызван: гасим хвосты асинхронных заводок (roomtone/стем). */ + private exited = false; + /** Текущий выбор амбиента день/ночь (для гистерезиса порога). */ + private nightAmbient = false; + + constructor(private deps: LocationAudioDeps) {} + + /** Room tone интерьера (см. ROOM_TONES): тихий луп на шине ambience. */ + startRoomTone(): void { + const spec = ROOM_TONES[this.deps.area.id]; + if (!spec) return; + const buf = this.deps.audio.createBuffer(renderSpec(spec)); + if (!buf) return; // до unlock контекста нет — тон появится при следующем входе + const key = this.deps.area.id; + void this.deps.audio + .playLoopBuffer(`roomtone/${key}`, buf, { bus: 'ambience', fade: 2, volume: 0.6 }) + .then((h) => { + // Сцена уже вышла, пока луп заводился, — гасим без хвостов. + if (this.exited) { + h?.stop(1); + return; + } + this.roomtoneHandle = h; + }); + } + + /** Погасить свои лупы (exit сцены) — без хвостов поверх следующей сцены. */ + exit(): void { + this.exited = true; + this.roomtoneHandle?.stop(1); + this.roomtoneHandle = null; + this.stemHandle?.stop(1); + this.stemHandle = null; + this.stemBuf = null; + this.stemPending = false; + this.stemLevel = 0; + } + + /** + * Тик амбиент-слоёв: громкость каждого — от дистанции героя до ближайшего + * источника (тайлы или точка); глобальный слой (без источников) — константа. + * Пустой набор — setLayers сам снимет лишние. + */ + updateLayers(): void { + const p = this.deps.heroPos(); + const defs: AudioLayerDef[] = []; + for (const layer of this.deps.area.ambienceLayers ?? []) { + const d = this.layerDistance(layer, p); + const k = layer.radius === undefined ? 1 : Math.max(0, 1 - d / layer.radius); + const volume = (layer.volume ?? 0.5) * k; + if (volume > 0.01) defs.push({ key: layer.key, volume, fade: 0.5 }); + } + this.deps.worldAudio.setLayers(defs); + } + + /** + * Дистанция до ближайшего источника слоя (юниты; тайл — центр ромба). + * Слой без at, positions и tiles — глобальный (ветер): источников нет. + */ + private layerDistance(layer: AmbienceLayerDef, p: Vec2): number { + if (!layer.at && !(layer.positions && layer.positions.length > 0) && !(layer.tiles && layer.tiles.length > 0)) + return 0; + if (layer.at) return Math.hypot(p.x - (layer.at.x + 0.5), p.y - (layer.at.y + 0.5)); + let best = Infinity; + for (const tile of layer.positions ?? []) { + const d = Math.hypot(p.x - (tile.x + 0.5), p.y - (tile.y + 0.5)); + if (d < best) best = d; + } + for (const id of layer.tiles ?? []) { + for (const tile of this.deps.layerTilePos.get(id) ?? []) { + const d = Math.hypot(p.x - (tile.x + 0.5), p.y - (tile.y + 0.5)); + if (d < best) best = d; + } + } + return best; + } + + /** + * Амбиент дня/ночи: за порогом кривой суток (с гистерезисом ±0.05, чтобы + * не трещало на закате) играем ночной луп области — кроссфейд даёт сам + * Game.playAmbience (1.5 с). Ночной ключ — суффикс `_night` у дневного. + */ + updateDayNightAmbient(): void { + const base = this.deps.area.ambience; + if (!base) return; // интерьеры без амбиента — нечего переключать + const night = this.deps.nightFactor() >= (this.nightAmbient ? 0.45 : 0.55); + if (night === this.nightAmbient) return; + this.nightAmbient = night; + this.deps.playAmbience(night ? `${base}_night` : base); + } + + /** Начальный амбиент (enter сцены): день или ночь по текущим часам. */ + startAmbient(): void { + this.nightAmbient = this.deps.nightFactor() >= 0.5; + const base = this.deps.area.ambience; + if (base) this.deps.playAmbience(this.nightAmbient ? `${base}_night` : base); + } + + /** + * Динамическая музыка по уровню угрозы: погоня — боевая тема вместо темы + * области; настороженность — тихий tension-стем под темой (громкость + * по угрозе, сглаженная); спокойствие — тема области. Сцена зовёт после + * combat.update — угроза свежая. + */ + updateMusic(dt: number): void { + const threat = this.deps.threat(); + // Боевая тема вытесняет тему области; обратно — как только погоня кончилась. + this.deps.playTheme(threat >= 1 ? 'combat' : (this.deps.area.theme ?? '')); + + // Стем: тихо в бою (тема боя сама громкая), громче при wary, ноль в тишине. + const target = threat >= 1 ? 0.15 : threat > 0 ? 0.6 * threat : 0; + if (!this.stemHandle && !this.stemPending && target > 0) { + this.stemBuf ??= this.deps.audio.createBuffer(renderMusicStereo(TENSION_STEM), RATE, 2); + if (this.stemBuf) { + this.stemPending = true; + void this.deps.audio + .playLoopBuffer('music/tension', this.stemBuf, { fade: 1, volume: 0 }) + .then((h) => { + this.stemPending = false; + if (!h) return; + // Сцена сменилась, пока луп заводился, — гасим без хвостов. + if (!this.stemBuf) h.stop(1); + else this.stemHandle = h; + }); + } + } + // Громкость ведём плавно (dt-подход), stop в exit. + this.stemLevel += (target - this.stemLevel) * Math.min(1, dt * 2); + this.stemHandle?.setVolume(this.stemLevel * 0.8); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/LocationHud.ts b/v1/apps/game/src/systems/LocationHud.ts new file mode 100644 index 0000000..c092c36 --- /dev/null +++ b/v1/apps/game/src/systems/LocationHud.ts @@ -0,0 +1,55 @@ +import { Container, PixelText } from '@rpg/engine'; + +/** + * HUD локации: название области (правый верх), подсказка управления (левый + * верх), счётчик мотов (под названием), трекер цели квеста (под подсказкой). + * Одна вьюха в uiRoot — сцена только добавляет её, тикает setMotes/setQuest + * и гасит в exit. + */ +export class LocationHud { + readonly view = new Container(); + private motesLabel: PixelText; + private questLabel: PixelText; + private lastMotes = -1; + private lastQuest: string | null = null; + + constructor(areaName: string) { + const label = new PixelText({ text: areaName, size: 11, color: 0x999988 }); + label.anchor.set(1, 0); + label.position.set(474, 4); + this.view.addChild(label); + this.motesLabel = new PixelText({ text: '', size: 10, color: 0xd8c79a }); + this.motesLabel.anchor.set(1, 0); + this.motesLabel.position.set(474, 17); // под названием локации + this.view.addChild(this.motesLabel); + const hint = new PixelText({ + text: 'WASD — идти · E — действие · Space — удар (удержать — резонанс) · I — сумка · Esc — меню', + size: 10, + color: 0x999988 + }); + // Под сердечками (HealthBar занимает y≈13..18). + hint.position.set(6, 23); + this.view.addChild(hint); + this.questLabel = new PixelText({ text: '', size: 10, color: 0xd8c79a }); + this.questLabel.position.set(6, 35); // под подсказкой управления + this.view.addChild(this.questLabel); + } + + /** Счётчик мотов: текст пишем только при изменении (сбор — редкое событие). */ + setMotes(count: number): void { + if (count === this.lastMotes) return; + this.lastMotes = count; + this.motesLabel.text = count > 0 ? `Пепельные моты: ${count}` : ''; + } + + /** Трекер цели: «Цель: <текст стадии>»; null — активных квестов нет. */ + setQuest(text: string | null): void { + if (text === this.lastQuest) return; + this.lastQuest = text; + this.questLabel.text = text === null ? '' : `Цель: ${text}`; + } + + destroy(): void { + this.view.destroy({ children: true }); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/PlayerController.ts b/v1/apps/game/src/systems/PlayerController.ts new file mode 100644 index 0000000..abb62fe --- /dev/null +++ b/v1/apps/game/src/systems/PlayerController.ts @@ -0,0 +1,268 @@ +import { + IsometricTileMap, + worldToScreen, + screenToWorld, + worldNorm, + worldToTile, + tileToWorld, + moveTowardsW, + moveCircle, + circleFits, + separateCircles, + SpriteAnimator, + Container, + Sprite, + Texture, + type SceneRegistry, + type Vec2 +} from '@rpg/engine'; +import { PLAYER_COMBAT } from './combat/stats'; + +/** Максимальный радиус тел, из которых выталкивается герой (NPC — 0.35, запас). */ +const BODY_PROBE = 0.5; + +/** Порог прибытия в waypoint (юниты): меньше шага любого кадра — на глаз незаметно. */ +const ARRIVE_EPS = 1e-4; + +/** Кадры героя по направлениям. side смотрит вправо; влево — флип. */ +export interface HeroTextures { + down: [Texture, Texture]; + up: [Texture, Texture]; + side: [Texture, Texture]; +} + +type Facing = 'down' | 'up' | 'left' | 'right'; + +/** + * Управление героем: путь по клику (A*), плавное движение по маршруту, + * покадровая анимация ходьбы по направлению движения. + * Позиция — в мировых юнитах (1 юнит = 1 тайл, ноги героя). + */ +export class PlayerController { + readonly view: Container; + /** Именованные клипы: walk_* на ходу, idle_* — стоп-кадр направления. */ + readonly animator: SpriteAnimator; + + /** Спрайт тела (публичный: хит-флэши урона через FxLayer.flash). */ + readonly sprite: Sprite; + private facing: Facing = 'down'; + /** Кэш имени клипа ходьбы (меняется только с facing — setFacing). */ + private facingClip = 'walk_down'; + + /** Позиция в мировых юнитах (источник истины, ноги героя). */ + private pos: Vec2; + private path: { x: number; y: number }[] = []; + private waypoint: Vec2 | null = null; + /** Скорость в мировых юнитах/сек. */ + private speed = 1.75; + /** Множитель скорости (зоны наката без маски — 0.5). Меняется сценой каждый кадр. */ + speedMul = 1; + /** Реестр объектов сцены (тела NPC) — задаётся сценой после создания. */ + private bodies: SceneRegistry | null = null; + + constructor( + private map: IsometricTileMap, + textures: HeroTextures, + startTile: { x: number; y: number }, + private onStep?: () => void + ) { + this.view = new Container(); + this.sprite = new Sprite(textures.down[0]); // публичный: флэши урона (FxLayer.flash) + this.sprite.anchor.set(0.5, 1); // ноги в точке позиции + this.view.addChild(this.sprite); + this.animator = new SpriteAnimator(this.sprite, { + walk_down: { frames: textures.down, fps: 6 }, + walk_up: { frames: textures.up, fps: 6 }, + walk_side: { frames: textures.side, fps: 6 }, + idle_down: { frames: [textures.down[0]], loop: 'once' }, + idle_up: { frames: [textures.up[0]], loop: 'once' }, + idle_side: { frames: [textures.side[0]], loop: 'once' } + }, 'idle_down'); + + this.pos = tileToWorld(startTile.x, startTile.y); + this.syncView(); + } + + /** Текущее направление взгляда (для боя: куда бьёт конус). */ + get dir(): 'down' | 'up' | 'left' | 'right' { + return this.facing; + } + + /** Подключить реестр тел — герой не проходит сквозь NPC. */ + setBodies(registry: SceneRegistry | null): void { + this.bodies = registry; + } + + /** Вытолкнуть себя из тел (каждый актор двигает только себя). */ + private separateBodies(): void { + if (!this.bodies) return; + for (const hit of this.bodies.near(this.pos, PLAYER_COMBAT.radius + BODY_PROBE, 'npc')) { + separateCircles(this.pos, PLAYER_COMBAT.radius, hit.pos, hit.radius); + } + } + + /** Вектор направления взгляда в мировых юнитах (экранные оси — мировые диагонали). */ + get dirVector(): Vec2 { + switch (this.facing) { + case 'up': + return { x: -1, y: -1 }; + case 'left': + return { x: -1, y: 1 }; + case 'right': + return { x: 1, y: -1 }; + default: + return { x: 1, y: 1 }; + } + } + + /** Пойти по готовому пути тайлов (мост: scene:walk/approach, кат-сцены). */ + followPath(path: { x: number; y: number }[]): void { + if (path.length === 0) return; + this.path = path; + this.advanceWaypoint(); + } + + update(dt: number): void { + // Тела: даже стоячий герой выталкивается (враг мог втолкнуть его в NPC). + this.separateBodies(); + if (!this.waypoint) { + this.playIdle(); + return; + } + this.pos = moveTowardsW(this.pos, this.waypoint, this.speed * this.speedMul * dt); + if (this.arrivedAt(this.waypoint)) { + this.pos = { x: this.waypoint.x, y: this.waypoint.y }; // снап: вью и тайлы без дрейфа + this.onStep?.(); // ступили на новый тайл + this.advanceWaypoint(); + this.syncView(); // вью на точном прибытии (иначе кадр отстаёт) + return; + } + this.faceMovement(); + this.animator.resume(); + this.animator.play(this.walkClip()); + this.animator.update(dt); + this.syncView(); + } + + get moving(): boolean { + return this.waypoint !== null; + } + + /** Текущий тайл героя. */ + currentTile(): { x: number; y: number } { + return ( + worldToTile(this.pos.x, this.pos.y, this.map.data.width, this.map.data.height) ?? { x: 0, y: 0 } + ); + } + + /** Прямое движение (тач-джойстик/стик): экранный вектор -1..1, движение с проверкой стен. */ + moveFree(dir: Vec2, dt: number): void { + const len = Math.hypot(dir.x, dir.y); + if (len < 0.01) { + this.stop(); + return; + } + // Экранная ось джойстика -> мировое направление (экранные оси — мировые диагонали). + const w = screenToWorld(dir.x, dir.y); + const n = worldNorm(w.x, w.y); + const step = this.speed * this.speedMul * dt; + // Круг тела скользит вдоль стен (оси раздельно). + const moved = moveCircle(this.map, this.pos, { x: n.x * step, y: n.y * step }, PLAYER_COMBAT.radius); + this.separateBodies(); + if (moved) { + // Поворот — по экранным компонентам смещения (кадры down/up/side). + const proj = worldToScreen(n.x, n.y); + this.setFacing( + Math.abs(proj.x) >= Math.abs(proj.y) ? (proj.x >= 0 ? 'right' : 'left') : proj.y >= 0 ? 'down' : 'up' + ); + this.animator.resume(); + this.animator.play(this.walkClip()); + this.animator.update(dt); + this.syncView(); + } else { + this.stop(); + } + } + + /** Позиция героя (ноги) в мировых юнитах. */ + get position(): Vec2 { + return { x: this.pos.x, y: this.pos.y }; + } + + /** Текущий кадр спрайта (для дебага анимации). */ + get currentTexture(): Texture { + return this.sprite.texture; + } + + /** Прервать текущий путь (например, при уроне). */ + stop(): void { + this.path = []; + this.waypoint = null; + } + + /** Имя клипа ходьбы по текущему направлению — кэш из setFacing. */ + private walkClip(): string { + return this.facingClip; + } + + /** Стоп-кадр текущего направления (без «застыла на произвольном кадре ходьбы»). */ + private playIdle(): void { + this.animator.play(`idle_${this.facing === 'left' || this.facing === 'right' ? 'side' : this.facing}`); + } + + /** Мгновенно переместить героя в тайл (респаун, переходы между локациями). */ + teleportTo(tile: { x: number; y: number }): void { + this.stop(); + this.pos = tileToWorld(tile.x, tile.y); + this.syncView(); + } + + /** Отброс: сдвиг позиции, только если тело помещается (для боя), юниты. */ + applyKnockback(dir: Vec2, dist: number): void { + const nx = this.pos.x + dir.x * dist; + const ny = this.pos.y + dir.y * dist; + if (circleFits(this.map, { x: nx, y: ny }, PLAYER_COMBAT.radius)) { + this.pos.x = nx; + this.pos.y = ny; + this.syncView(); + } + this.stop(); + } + + /** Направление — по доминирующей экранной оси движения к следующей точке пути. */ + private faceMovement(): void { + if (!this.waypoint) return; + const dx = this.waypoint.x - this.pos.x; + const dy = this.waypoint.y - this.pos.y; + const proj = worldToScreen(dx, dy); + this.setFacing( + Math.abs(proj.x) >= Math.abs(proj.y) ? (proj.x >= 0 ? 'right' : 'left') : proj.y >= 0 ? 'down' : 'up' + ); + } + + /** Прибытие с eps: moveTowardsW обещает точность, но расталкивание тел сдвигает. */ + private arrivedAt(t: Vec2): boolean { + return Math.abs(this.pos.x - t.x) < ARRIVE_EPS && Math.abs(this.pos.y - t.y) < ARRIVE_EPS; + } + + /** Сменить направление взгляда (кадры + флип спрайта + кэш имени клипа). */ + private setFacing(facing: Facing): void { + if (facing === this.facing) return; + this.facing = facing; + this.sprite.scale.x = facing === 'left' ? -1 : 1; + // Имя клипа ходьбы зависит только от facing: кэш вместо строки каждый тик. + this.facingClip = `walk_${facing === 'left' || facing === 'right' ? 'side' : facing}`; + } + + private advanceWaypoint(): void { + const next = this.path.shift(); + this.waypoint = next ? tileToWorld(next.x, next.y) : null; + } + + /** Вью-позиция: единственная точка перевода мир→экран (округление до px). */ + private syncView(): void { + const s = worldToScreen(this.pos.x, this.pos.y); + this.view.position.set(Math.round(s.x), Math.round(s.y)); + } +} + diff --git a/v1/apps/game/src/systems/SceneObjects.ts b/v1/apps/game/src/systems/SceneObjects.ts new file mode 100644 index 0000000..707e079 --- /dev/null +++ b/v1/apps/game/src/systems/SceneObjects.ts @@ -0,0 +1,107 @@ +import { SceneRegistry, tileToWorld, type Grid, type IsometricTileMap } from '@rpg/engine'; +import type { AreaDef } from '../data/locations'; +import type { NpcDef } from '../data/npcs'; +import type { InteractableDef } from '../data/interactables'; + +/** + * Объекты сцены на движковом SceneRegistry: единый список «кто на карте» + * (NPC, интерактивы, пропы, фауна, враги) с индексом по тайлам. + * Клик-роутинг, подписи и телесные коллизии спрашивают здесь, а не перебирают + * параллельные списки. Роды задаёт игра: 'npc' | 'interactable' | 'prop' | + * 'fauna' | 'enemy'. + */ + +/** Радиус тела NPC (юниты) — для будущих телесных коллизий. */ +const NPC_RADIUS = 0.35; + +export class SceneObjects { + readonly registry = new SceneRegistry(); + + constructor(area: AreaDef, private map: IsometricTileMap) { + for (const def of area.npcs) { + this.registry.add({ + id: `npc:${def.id}`, + kind: 'npc', + pos: tileToWorld(def.tile.x, def.tile.y), + radius: NPC_RADIUS, + ref: def + }); + } + for (const def of area.interactables ?? []) { + this.registry.add({ + id: `int:${def.id}`, + kind: 'interactable', + pos: tileToWorld(def.tile.x, def.tile.y), + ref: def + }); + } + // Пропы карты: footprint-объекты, занимают все свои тайлы. + for (const prop of map.props) { + this.registry.add({ + id: `prop:${prop.x},${prop.y}`, + kind: 'prop', + pos: { x: prop.x + prop.w / 2, y: prop.y + prop.h / 2 }, + footprint: { w: prop.w, h: prop.h }, + ref: prop + }); + } + } + + /** NPC на тайле (клик-роутинг). */ + npcAt(x: number, y: number): NpcDef | null { + return (this.registry.at(x, y, 'npc')?.ref as NpcDef | undefined) ?? null; + } + + /** Все NPC сцены (кандидаты кнопки действия): статичные + размещения по флагам. */ + npcs(): NpcDef[] { + const out: NpcDef[] = []; + for (const obj of this.registry.byKind('npc')) { + const def = obj.ref as NpcDef | undefined; + if (def) out.push(def); + } + return out; + } + + /** + * Дополнительный NPC после создания сцены — размещения по флагам (акт 2): + * попадает и в клик-роутинг, и в кэш занятых тайлов (если тот уже построен). + */ + addNpc(def: NpcDef): void { + this.registry.add({ + id: `npc:${def.id}`, + kind: 'npc', + pos: tileToWorld(def.tile.x, def.tile.y), + radius: NPC_RADIUS, + ref: def + }); + this.npcTiles?.add(def.tile.y * 4096 + def.tile.x); + } + + /** Интерактивный объект на тайле (клик-роутинг). */ + interactableAt(x: number, y: number): InteractableDef | null { + return (this.registry.at(x, y, 'interactable')?.ref as InteractableDef | undefined) ?? null; + } + + /** Ключ тайла (как в реестре; NPC не двигаются — кэш навсегда). */ + private npcTiles: Set | null = null; + + /** + * Grid для построения путей: тайлы NPC считаются занятыми — A* не ведёт + * героя сквозь тело, где его остановит расталкивание. Остальная карта — + * живая (IsometricTileMap учитывает стены и пропы). + */ + walkGrid(): Grid { + if (!this.npcTiles) { + this.npcTiles = new Set(); + for (const npc of this.registry.byKind('npc')) { + this.npcTiles.add(npc.tile.y * 4096 + npc.tile.x); + } + } + const npcTiles = this.npcTiles; + return { + width: this.map.width, + height: this.map.height, + isWalkable: (x, y) => this.map.isWalkable(x, y) && !npcTiles.has(y * 4096 + x) + }; + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/Shadows.ts b/v1/apps/game/src/systems/Shadows.ts new file mode 100644 index 0000000..348b6e5 --- /dev/null +++ b/v1/apps/game/src/systems/Shadows.ts @@ -0,0 +1,90 @@ +/** + * Тени мира: блобы под актёрами (герой, NPC, враги) и направленные тени + * tall-объектов карты. Положение считает чистая математика sunShadow/ + * blobPlacement по часам суток (сцена передаёт SunShadow каждый тик — + * тени ползут за солнцем плавно). Актёрский блоб — первый ребёнок вьюхи + * (ноги в (0,0)), статические тени — отдельный ground-слой под актёрами. + */ +import { Container, Sprite, Texture, blobPlacement, applyBlobShadow, makeShadowTexture, type Renderer, type SunShadow } from '@rpg/engine'; + +/** Актёрский блоб: вьюха + «толщина» объекта в px виртуального экрана. */ +interface ActorShadow { + view: Container; + radiusPx: number; + heightPx: number; + sprite: Sprite; +} + +/** Статическая тень (tall-тайл): база уже в координатах мира-экрана. */ +interface StaticShadow { + sprite: Sprite; + base: { x: number; y: number }; + radiusPx: number; + heightPx: number; +} + +/** Узкий интерфейс для боевых вьюх — без связности с полной системой. */ +export interface ShadowAttach { + attachActor(view: Container, radiusPx: number, heightPx: number): void; +} + +export class GameShadows implements ShadowAttach { + private readonly tex: Texture; + /** Ground-слой: добавить в world между картой и актёрами. */ + readonly ground = new Container(); + private readonly actorShadows: ActorShadow[] = []; + private readonly statics: StaticShadow[] = []; + + constructor(renderer: Renderer) { + this.tex = makeShadowTexture(renderer); + this.ground.eventMode = 'none'; + } + + /** Блоб под актёром: первым ребёнком — спрайты рисуются поверх тени. */ + attachActor(view: Container, radiusPx: number, heightPx: number): void { + const sprite = new Sprite(this.tex); + sprite.anchor.set(0.5, 0.5); + view.addChildAt(sprite, 0); + this.actorShadows.push({ view, radiusPx, heightPx, sprite }); + } + + /** Направленная тень tall-объекта: база — центр основания (центр ромба). */ + addStatic(base: { x: number; y: number }, radiusPx: number, heightPx: number): void { + const sprite = new Sprite(this.tex); + sprite.anchor.set(0.5, 0.5); + this.ground.addChild(sprite); + this.statics.push({ sprite, base, radiusPx, heightPx }); + } + + /** Пересчёт всех теней по солнцу (null — ночь: погасить). */ + update(sun: SunShadow | null): void { + // Вьюхи погибших врагов уничтожаются CombatViews — блобы вычищаем сами. + for (let i = this.actorShadows.length - 1; i >= 0; i--) { + if (this.actorShadows[i]!.sprite.destroyed) { + this.actorShadows.splice(i, 1); + continue; + } + const rec = this.actorShadows[i]!; + this.place(rec.sprite, { x: 0, y: 0 }, rec, sun); + } + for (const rec of this.statics) this.place(rec.sprite, rec.base, rec, sun); + } + + private place( + sprite: Sprite, + base: { x: number; y: number }, + rec: { radiusPx: number; heightPx: number }, + sun: SunShadow | null + ): void { + if (!sun) { + sprite.alpha = 0; + return; + } + applyBlobShadow(sprite, base, blobPlacement(rec.radiusPx, rec.heightPx, sun)); + } + + destroy(): void { + this.ground.destroy({ children: true }); + this.tex.destroy(); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/StepVariants.ts b/v1/apps/game/src/systems/StepVariants.ts new file mode 100644 index 0000000..911dc18 --- /dev/null +++ b/v1/apps/game/src/systems/StepVariants.ts @@ -0,0 +1,52 @@ +import { renderSpec } from '@rpg/engine'; +import type { AudioManager } from '@rpg/engine'; +import { SPEC_SFX, playSfx } from '../data/sfxSpecs'; + +/** + * Вариативные шаги: 3 варианта на поверхность, компилируются в рантайме + * из спеков sfxSpecs.ts (сид варианта = базовый + i·10 — звучат похоже, + * но не идентично). Выбор варианта — по кругу. До unlock буферы не создать + * (createBuffer → null) — играем базовый спек, варианты синтезируются + * заново при следующих шагах, пока не получится (обычно вторая попытка, + * после первого play). + */ + +export type StepSurface = 'grass' | 'ash' | 'water' | 'floor'; + +const VARIANTS = 3; + +export class StepVariants { + /** Буферы по поверхности (синтезируются при первом успешном шаге). */ + private cache = new Map(); + private seq = 0; + + constructor(private audio: AudioManager) {} + + /** Играть очередной вариант шага (до готовности буферов — базовый спек). */ + play(surface: StepSurface, volume: number): void { + const key = `sfx/step_${surface}`; + const list = this.ensure(surface); + if (list.length > 0) { + const i = this.seq++ % list.length; + void this.audio.playBuffer(`${key}#${i}`, list[i]!, { volume }); + } else { + playSfx(this.audio, key, { volume }); + } + } + + /** Синтезировать варианты поверхности (если контекст ещё не готов — пустой список). */ + private ensure(surface: StepSurface): AudioBuffer[] { + const existing = this.cache.get(surface); + if (existing) return existing; + const base = SPEC_SFX[`sfx/step_${surface}`]; + if (!base) return []; + const list: AudioBuffer[] = []; + for (let i = 0; i < VARIANTS; i++) { + const buf = this.audio.createBuffer(renderSpec({ ...base, seed: (base.seed ?? 1) + i * 10 }), 22050); + if (!buf) return []; // контекста нет — попробуем на следующем шаге + list.push(buf); + } + this.cache.set(surface, list); + return list; + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/__tests__/AudioSystem.test.ts b/v1/apps/game/src/systems/__tests__/AudioSystem.test.ts new file mode 100644 index 0000000..abc902c --- /dev/null +++ b/v1/apps/game/src/systems/__tests__/AudioSystem.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EventBus, type AudioManager, type MusicHandle } from '@rpg/engine'; +import { AudioSystem, type AudioSystemDeps } from '../AudioSystem'; + +/** Фейковый AudioManager: записывает вызовы play/playBuffer/playLoop. */ +function fakeAudio() { + const played: { key: string; opts?: { volume?: number; rate?: number; pan?: number } }[] = []; + const loops: { key: string; opts?: { bus?: string; volume?: number; fade?: number } }[] = []; + const audio = { + play: vi.fn(async (key: string, opts?: { volume?: number; rate?: number; pan?: number }) => { + played.push({ key, opts }); + }), + playBuffer: vi.fn(async (key: string, _buf: AudioBuffer, opts?: { volume?: number; rate?: number; pan?: number }) => { + played.push({ key, opts }); + }), + playSpec: vi.fn(async (key: string, _spec: unknown, opts?: { volume?: number; rate?: number; pan?: number }) => { + played.push({ key, opts }); + }), + // По умолчанию контекст «не разблокирован» — буферы не создать. + createBuffer: vi.fn((): AudioBuffer | null => null), + playLoop: vi.fn(async (key: string, opts?: { bus?: string; volume?: number; fade?: number }) => { + loops.push({ key, opts }); + return { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle; + }) + } as unknown as AudioManager; + return { audio, played, loops }; +} + +function makeDeps(over: Partial = {}) { + const calls = fakeAudio(); + const events = new EventBus(); + const deps: AudioSystemDeps = { + audio: calls.audio, + events, + getListener: () => ({ x: 10, y: 10 }), + ...over + }; + return { deps, events, calls }; +} + +describe('AudioSystem.playAt — затухание и панорама', () => { + it('рядом с героем — полная громкость, без pan (герой в центре)', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playAt('sfx/bell_hit', { x: 10, y: 10 }, 0.8); + + expect(calls.played).toHaveLength(1); + expect(calls.played[0]!.key).toBe('sfx/bell_hit'); + expect(calls.played[0]!.opts!.volume).toBeCloseTo(0.8); + expect(calls.played[0]!.opts!.pan).toBeUndefined(); // |pan| < 0.01 — без узла + }); + + it('за радиусом — тишина (вызова нет)', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playAt('sfx/spit', { x: 30, y: 30 }, 0.6, 12); + + expect(calls.played).toHaveLength(0); + }); + + it('сбоку от слушателя — панорама со знаком, клэмп ±0.8', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playAt('sfx/bell_low', { x: 12, y: 10 }, 0.9); + + const pan = calls.played[0]!.opts!.pan!; + expect(pan).toBeGreaterThan(0); + expect(pan).toBeLessThanOrEqual(0.8); + }); + + it('далеко сбоку — панорама клэмпится к 0.8', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playAt('sfx/bell_low', { x: 18, y: 2 }, 2); // далеко и сбоку, громкий + + expect(calls.played[0]!.opts!.pan).toBe(0.8); + }); +}); + +describe('AudioSystem.playStep — поверхности и варианты', () => { + it('без буферов (контекст не разблокирован) — файловый шаг по поверхности', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playStep('grass', { x: 10, y: 10 }, 0.35); + + expect(calls.played).toHaveLength(1); + expect(calls.played[0]!.key).toBe('sfx/step_grass'); + expect(calls.played[0]!.opts!.volume).toBeCloseTo(0.35); + }); + + it('буферы готовы — playBuffer вариантов по кругу', () => { + const { deps, calls } = makeDeps(); + vi.mocked(calls.audio.createBuffer).mockImplementation(() => ({ duration: 0.12 }) as AudioBuffer); + const sys = new AudioSystem(deps); + + sys.playStep('ash', { x: 10, y: 10 }, 0.35); + sys.update(0.1); + sys.playStep('ash', { x: 10, y: 10 }, 0.35); + sys.update(0.1); + sys.playStep('ash', { x: 10, y: 10 }, 0.35); + + expect(calls.played.map((p) => p.key)).toEqual(['sfx/step_ash#0', 'sfx/step_ash#1', 'sfx/step_ash#2']); + }); + + it('дедуп: одна поверхность чаще 0.08 с не звучит', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playStep('grass', { x: 10, y: 10 }, 0.35); + sys.playStep('grass', { x: 10, y: 10 }, 0.35); // слишком рано — глушится + sys.update(0.05); + sys.playStep('grass', { x: 10, y: 10 }, 0.35); // всё ещё рано + sys.update(0.05); + sys.playStep('water', { x: 10, y: 10 }, 0.35); // другая поверхность — ок + + expect(calls.played.map((p) => p.key)).toEqual(['sfx/step_grass', 'sfx/step_water']); + }); + + it('далеко от слушателя — тишина', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playStep('floor', { x: 20, y: 20 }, 0.35); + + expect(calls.played).toHaveLength(0); + }); +}); + +describe('AudioSystem.attach — звук мира подпиской', () => { + it('combat:attack cone → bell_hit, resonance → bell_low; spit/awake/kill/playerHit', () => { + const { deps, events, calls } = makeDeps(); + const sys = new AudioSystem(deps); + sys.attach(); + + events.emit('combat:attack', { mode: 'cone', origin: { x: 10, y: 10 } }); + events.emit('combat:attack', { mode: 'resonance', origin: { x: 10, y: 10 } }); + events.emit('combat:spit', { pos: { x: 10, y: 10 } }); + events.emit('combat:awake', { kind: 'crawler', pos: { x: 10, y: 10 } }); + events.emit('combat:kill', { kind: 'crawler', tile: { x: 10, y: 10 } }); + events.emit('combat:playerHit', { hp: 5 }); + + expect(calls.played.map((p) => p.key)).toEqual([ + 'sfx/bell_hit', + 'sfx/bell_low', + 'sfx/spit', + 'sfx/ash_hiss', + 'sfx/ash_die', + 'sfx/hurt' + ]); + // playerHit — урон герою, без панорамы + expect(calls.played[5]!.opts!.pan).toBeUndefined(); + }); + + it('off() снимает подписки', () => { + const { deps, events, calls } = makeDeps(); + const sys = new AudioSystem(deps); + const off = sys.attach(); + off(); + + events.emit('combat:spit', { pos: { x: 10, y: 10 } }); + expect(calls.played).toHaveLength(0); + }); +}); + +describe('AudioSystem.setLayers — локальные амбиенты', () => { + it('слой — playLoop в шине ambience; повтор с тем же ключом не перезапускает', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.setLayers([{ key: 'ambience/ponds_water' }]); + sys.setLayers([{ key: 'ambience/ponds_water' }]); // тот же ключ — no-op + + expect(calls.loops).toHaveLength(1); + expect(calls.loops[0]!.key).toBe('ambience/ponds_water'); + expect(calls.loops[0]!.opts!.bus).toBe('ambience'); + }); + + it('два слоя — два независимых лупа', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.setLayers([{ key: 'ambience/tower_hum' }, { key: 'ambience/houses' }]); + + expect(calls.loops).toHaveLength(2); + expect(calls.loops[0]!.key).toBe('ambience/tower_hum'); + expect(calls.loops[1]!.key).toBe('ambience/houses'); + }); + + it('пустой набор останавливает слои; повторный старт — новый луп', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + const handles: MusicHandle[] = []; + (deps.audio.playLoop as ReturnType).mockImplementation( + async (key: string, opts?: { bus?: string; volume?: number; fade?: number }) => { + calls.loops.push({ key, opts }); + const h = { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle; + handles.push(h); + return h; + } + ); + + sys.setLayers([{ key: 'ambience/tower_hum' }]); + sys.setLayers([]); + sys.setLayers([{ key: 'ambience/tower_hum' }]); + + expect(handles).toHaveLength(2); // второй старт после снятия + expect(calls.loops).toHaveLength(2); + }); + + it('update доводит громкость слоя к цели', async () => { + const { deps } = makeDeps(); + const sys = new AudioSystem(deps); + let handle: MusicHandle | null = null; + (deps.audio.playLoop as ReturnType).mockImplementation(async () => { + handle = { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle; + return handle; + }); + + sys.setLayers([{ key: 'ambience/tower_hum', volume: 0.5, fade: 1 }]); + await Promise.resolve(); // луп стартовал + sys.update(0.5); + expect(handle!.setVolume).toHaveBeenCalledWith(expect.closeTo(0.25)); + sys.update(0.5); + expect(handle!.setVolume).toHaveBeenCalledWith(0.5); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/systems/__tests__/InteractRouting.test.ts b/v1/apps/game/src/systems/__tests__/InteractRouting.test.ts new file mode 100644 index 0000000..a0947e4 --- /dev/null +++ b/v1/apps/game/src/systems/__tests__/InteractRouting.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { inInteractRangePos, pickInteract, type InteractCandidate } from '../InteractRouting'; + +describe('inInteractRangePos — истинная евклидова линейка (юнит = тайл)', () => { + // Центр тайла (5,5) = мир (5.5, 5.5): NPC/объект стоят в центре своего тайла. + + it('соседний тайл по оси — в радиусе (дистанция 1.0)', () => { + expect(inInteractRangePos({ x: 6.5, y: 5.5 }, 5, 5)).toBe(true); + }); + + it('диагональный сосед — в радиусе (дистанция 1.414 < 1.5)', () => { + // findPathToNeighbor может привести героя в диагонального соседа цели. + expect(inInteractRangePos({ x: 6.5, y: 6.5 }, 5, 5)).toBe(true); + }); + + it('два тайла — вне радиуса (дистанция 2.0): дальний клик не срабатывает', () => { + // Регресс бага: проекционная линейка давала «радиус» в 2-3 тайла. + expect(inInteractRangePos({ x: 7.5, y: 5.5 }, 5, 5)).toBe(false); + }); + + it('2 тайла вдоль мировой диагонали (проекция была бы «ближе») — вне радиуса', () => { + expect(inInteractRangePos({ x: 6.5, y: 6.5 }, 4, 4)).toBe(false); + }); +}); + +describe('pickInteract — выбор цели кнопки действия', () => { + /** Кандидат-заглушка: тайл задаёт дистанцию (герой в (0,0)). */ + function cand( + kind: InteractCandidate['kind'], + tile: { x: number; y: number }, + label = 'цель' + ): InteractCandidate { + return { kind, tile, dist: Math.hypot(tile.x, tile.y), label }; + } + + it('ближайший кандидат в радиусе — он', () => { + const c = pickInteract([cand('npc', { x: 2, y: 0 }), cand('interact', { x: 1, y: 0 })]); + expect(c?.label).toBe('цель'); + expect(c?.kind).toBe('interact'); + expect(c?.tile).toEqual({ x: 1, y: 0 }); + }); + + it('вне радиуса (два тайла и дальше) — не выбирается', () => { + expect(pickInteract([cand('npc', { x: 2, y: 0 })])).toBeNull(); + expect(pickInteract([])).toBeNull(); + }); + + it('ничья по дистанции — приоритет NPC выше интерактива', () => { + const c = pickInteract([cand('interact', { x: 1, y: 0 }), cand('npc', { x: 0, y: 1 })]); + expect(c?.kind).toBe('npc'); + }); + + it('почти-ничья (в эпсилоне) — приоритет: переход выше цветка', () => { + const c = pickInteract([cand('flower', { x: 1, y: 1 }), cand('transition', { x: 1.04, y: 0 })]); + expect(c?.kind).toBe('transition'); + }); + + it('заметно ближе — дистанция побеждает приоритет', () => { + const c = pickInteract([cand('npc', { x: 1, y: 0 }), cand('flower', { x: 0.5, y: 0 })]); + expect(c?.kind).toBe('flower'); + }); + + it('заперто — кандидат locked с текстом', () => { + const c: InteractCandidate = { + kind: 'locked', + tile: { x: 1, y: 0 }, + dist: 1, + label: 'дверь', + text: 'Заперто.' + }; + expect(pickInteract([c])?.text).toBe('Заперто.'); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/systems/__tests__/Interactables.test.ts b/v1/apps/game/src/systems/__tests__/Interactables.test.ts new file mode 100644 index 0000000..b043c56 --- /dev/null +++ b/v1/apps/game/src/systems/__tests__/Interactables.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import { Interactables, type InteractSink } from '../Interactables'; +import { fakeState } from '../../testing/gameState'; +import type { InteractableDef } from '../../data/interactables'; + +const def = (over: Partial = {}): InteractableDef => ({ + id: 'chest', + pos: { x: 3.5, y: 3.5 }, + tile: { x: 3, y: 3 }, + kind: 'container', + once: true, + responses: [{ text: 'в сундуке', gives: 'cloth' }], + ...over +}); + +function make(defs: InteractableDef[], sinkOver: Partial = {}) { + const calls: { toast: string[]; gave: [string, number][]; sound: string[] } = { + toast: [], + gave: [], + sound: [] + }; + const sink: InteractSink = { + hasItem: () => false, + give: (id: string, n: number) => calls.gave.push([id, n]), + playSound: (k: string) => calls.sound.push(k), + showToast: (t: string) => calls.toast.push(t), + ...sinkOver + }; + const st = fakeState(); + return { world: new Interactables(defs, st, sink), calls, st }; +} + +describe('Interactables', () => { + it('defAt — поиск по тайлу', () => { + const { world } = make([def()]); + expect(world.defAt(3, 3)?.id).toBe('chest'); + expect(world.defAt(0, 0)).toBeNull(); + }); + + it('once: реакция + used:; повтор — пусто', () => { + const { world, calls } = make([def()]); + expect(world.isUsed('chest')).toBe(false); + const pick = world.tryInteract(world.defAt(3, 3)!); + expect(pick?.ok).toBe(true); + expect(calls.toast).toEqual(['в сундуке']); + expect(calls.gave).toEqual([['cloth', 1]]); + expect(world.isUsed('chest')).toBe(true); + // Повтор: реакции нет, тост не дублируется. + expect(world.tryInteract(world.defAt(3, 3)!)).toBeNull(); + expect(calls.toast).toHaveLength(1); + }); + + it('не-once объект реагирует повторно', () => { + const { world, calls } = make([ + def({ id: 'hearth', once: false, responses: [{ text: 'тепло' }] }) + ]); + world.tryInteract(world.defAt(3, 3)!); + world.tryInteract(world.defAt(3, 3)!); + expect(calls.toast).toEqual(['тепло', 'тепло']); + }); + + it('эффекты: флаги/вар/count/звук', () => { + const { world, calls, st } = make([ + def({ + once: false, + responses: [ + { + text: 'рычаг', + setFlags: ['a'], + clearFlags: ['b'], + setVar: { id: 'motes', value: 7 }, + gives: 'salt', + count: 2, + sound: 'sfx/bell_hit' + } + ] + }) + ]); + st.setFlag('b'); + world.tryInteract(world.defAt(3, 3)!); + expect(st.hasFlag('a')).toBe(true); + expect(st.hasFlag('b')).toBe(false); + expect(st.getNumber('motes')).toBe(7); + expect(calls.gave).toEqual([['salt', 2]]); + expect(calls.sound).toEqual(['sfx/bell_hit']); + }); + + it('addVar: подбор мотов копит вар, а не затирает', () => { + const { world, st, calls } = make([ + def({ + id: 'mote', + kind: 'pickup', + once: true, + responses: [{ text: 'мот', gives: 'mote', addVar: { id: 'motes', by: 1 } }] + }) + ]); + world.tryInteract(world.defAt(3, 3)!); + expect(st.getNumber('motes')).toBe(1); + // Второй такой же объект (другой тайл): вар растёт от прежнего значения. + const { world: w2, st: st2 } = make([ + def({ + id: 'mote_2', + kind: 'pickup', + once: true, + responses: [{ text: 'мот', gives: 'mote', addVar: { id: 'motes', by: 1 } }] + }) + ]); + st2.setVar('motes', 2); + w2.tryInteract(w2.defAt(3, 3)!); + expect(st2.getNumber('motes')).toBe(3); + expect(calls.toast).toEqual(['мот']); + }); + + it('toast с varKey: тост прогресса вместо text, после addVar', () => { + const { world, calls, st } = make([ + def({ + id: 'tone_tree_1', + kind: 'examine', + once: false, + responses: [ + { + text: 'Дерево отдаёт свой тон.', + toast: { text: 'Тон', varKey: 'tones', goal: 3 }, + addVar: { id: 'tones', by: 1 } + } + ] + }) + ]); + world.tryInteract(world.defAt(3, 3)!); + expect(st.getNumber('tones')).toBe(1); + expect(calls.toast).toEqual(['Тон (1/3)']); + }); + + it('when от GameState: реакция меняется с флагом', () => { + const { world, calls, st } = make([ + def({ + once: false, + responses: [ + { when: { flag: 'quest_bells_done' }, text: 'после' }, + { text: 'до' } + ] + }) + ]); + world.tryInteract(world.defAt(3, 3)!); + expect(calls.toast[0]).toBe('до'); + st.setFlag('quest_bells_done'); + world.tryInteract(world.defAt(3, 3)!); + expect(calls.toast[1]).toBe('после'); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/systems/__tests__/clock.test.ts b/v1/apps/game/src/systems/__tests__/clock.test.ts new file mode 100644 index 0000000..92a53f4 --- /dev/null +++ b/v1/apps/game/src/systems/__tests__/clock.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { DAY_MINUTES, GameClock, START_MINUTES } from '../Clock'; + +describe('GameClock', () => { + it('старт: 8:00, night-вход hours = 8', () => { + const c = new GameClock(); + expect(c.minutes).toBe(480); + expect(c.hours).toBe(8); + expect(c.label).toBe('8:00'); + }); + + it('advance: dtSec секунды = столько же игровых минут', () => { + const c = new GameClock(); + c.advance(60); // 1 реальная минута = 1 игровой час + expect(c.minutes).toBe(540); + expect(c.label).toBe('9:00'); + }); + + it('advance: переход через полночь', () => { + const c = new GameClock(); + c.set(23 * 60 + 50); + c.advance(20); + expect(c.minutes).toBe(10); + expect(c.label).toBe('0:10'); + }); + + it('label: минуты с ведущим нулём, часы без', () => { + const c = new GameClock(); + c.set(13 * 60 + 5); + expect(c.label).toBe('13:05'); + c.set(45); + expect(c.label).toBe('0:45'); + }); + + it('set: mod 1440, отрицательные в диапазон', () => { + const c = new GameClock(); + c.set(1500); + expect(c.minutes).toBe(60); + c.set(-60); + expect(c.minutes).toBe(23 * 60); + }); + + it('reset возвращает к 8:00', () => { + const c = new GameClock(); + c.set(0); + c.reset(); + expect(c.minutes).toBe(START_MINUTES); + expect(START_MINUTES).toBe(480); + expect(DAY_MINUTES).toBe(1440); + }); + + it('hours — float для кривой света', () => { + const c = new GameClock(); + c.set(19 * 60 + 30); + expect(c.hours).toBeCloseTo(19.5, 10); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/systems/__tests__/lighting.test.ts b/v1/apps/game/src/systems/__tests__/lighting.test.ts new file mode 100644 index 0000000..dba6e46 --- /dev/null +++ b/v1/apps/game/src/systems/__tests__/lighting.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest'; +import { Camera, DUSK_PEAK, Lighting, tileToWorld } from '@rpg/engine'; +import { GameLighting, type HeroLampDef } from '../Lighting'; +import type { AreaDef } from '../../data/locations'; + +const LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.5, intensity: 0.5, flicker: 0.12 }; + +function fakeArea(sources?: AreaDef['lighting'], hazards?: AreaDef['hazards']): AreaDef { + return { + id: 'meadows', // AreaId — юнион; значение не важно, свет приходит из fakeArea-аргумента + name: 'Тест', + spawn: { x: 1, y: 1 }, + npcs: [], + enemies: [], + transitions: [], + atmosphere: 'none', + lighting: sources, + hazards + }; +} + +function makeGameLighting( + area: AreaDef, + opts?: { flags?: Set; hero?: { x: number; y: number }; lamp?: HeroLampDef | null; time?: number } +) { + const engineLighting = new Lighting({ width: 480, height: 270 }); + const camera = new Camera(480, 270); + camera.snap(10, 10); + const flags = opts?.flags ?? new Set(); + let hero = opts?.hero ?? { x: 5, y: 5 }; + const clock = { hours: opts?.time ?? 12 }; + const gl = new GameLighting({ + area, + lighting: engineLighting, + layerTilePos: new Map([[11, [{ x: 2, y: 3 }, { x: 8, y: 3 }]]]), // TILES.HOUSE = 11 + camera, + heroPos: () => hero, + hasFlag: (f) => flags.has(f), + timeHours: () => clock.hours, + lamp: opts?.lamp === undefined ? null : opts.lamp + }); + return { gl, engineLighting, camera, setHero: (p: { x: number; y: number }) => (hero = p), setTime: (h: number) => (clock.hours = h) }; +} + +describe('GameLighting: статика области', () => { + it('ambient из данных применяется к движковому слою', () => { + const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0x54586a })); + expect(engineLighting.ambientColor).toBe(0x54586a); + gl.destroy(); + }); + + it('деф с at — один источник в центре ромба', () => { + const { gl, engineLighting, camera } = makeGameLighting( + fakeArea({ sources: [{ id: 'hearth', at: { x: 8, y: 0 }, intensity: 1, flicker: 0 }] }) + ); + gl.update(); + const frames = engineLighting.frames(); + expect(frames.length).toBe(1); + expect(frames[0].id).toBe('hearth'); + // Позиция — ровно проекция центра ромба тайла (8.5, 0.5) + const w = tileToWorld(8.5, 0.5); + const s = camera.toScreen(w.x, w.y); + expect(frames[0].x).toBeCloseTo(s.x, 3); + expect(frames[0].y).toBeCloseTo(s.y, 3); + gl.destroy(); + }); + + it('деф с tiles — источник на каждый тайл id', () => { + const { gl, engineLighting } = makeGameLighting( + fakeArea({ sources: [{ id: 'window', tiles: [11], intensity: 0.4, flicker: 0 }] }) + ); + gl.update(); + const ids = engineLighting.frames().map((f) => f.id).sort(); + expect(ids).toEqual(['window@2,3', 'window@8,3']); + gl.destroy(); + }); + + it('whenFlag/notFlag управляют активностью', () => { + const { gl, engineLighting } = makeGameLighting( + fakeArea({ + sources: [ + { id: 'off', at: { x: 1, y: 1 }, whenFlag: 'quest_bells_done', flicker: 0 }, + { id: 'on', at: { x: 1, y: 1 }, notFlag: 'quest_bells_done', flicker: 0 } + ] + }) + ); + gl.update(); + expect(engineLighting.frames().map((f) => f.id)).toEqual(['on']); + gl.destroy(); + }); + + it('погасшие источники снимаются с движкового слоя (флаг-условие)', () => { + const flags = new Set(); + const { gl, engineLighting } = makeGameLighting( + fakeArea({ sources: [{ id: 'a', at: { x: 1, y: 1 }, flicker: 0, notFlag: 'lit' }] }), + { flags } + ); + gl.update(); + expect(engineLighting.hasLight('a')).toBe(true); + flags.add('lit'); + gl.update(); + expect(engineLighting.hasLight('a')).toBe(false); + gl.destroy(); + }); +}); + +describe('GameLighting: лампа героя', () => { + it('следует за героем, id стабилен (ночь на светлой улице)', () => { + const { gl, engineLighting, setHero } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { + time: 21, + lamp: LAMP + }); + gl.update(); + const first = engineLighting.frames()[0]; + setHero({ x: 7.5, y: 9.5 }); + gl.update(); + const second = engineLighting.frames()[0]; + expect(first.id).toBe('lamp'); + expect(second.id).toBe('lamp'); + expect(second.x).not.toBe(first.x); // лампа переехала вместе с героем + gl.destroy(); + }); + + it('радиус в юнитах переводится в px', () => { + const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0x54586a }), { lamp: LAMP }); + gl.update(); + // unitsToPx(2.5) = 2.5 * 32 = 80; scale = 80 / GLOW_BASE_PX(32) = 2.5 + expect(engineLighting.frames()[0].scale).toBeCloseTo(2.5, 6); + gl.destroy(); + }); + + it('на светлой улице днём лампы нет, ночью появляется', () => { + const { gl, engineLighting, setTime } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { + time: 12, + lamp: LAMP + }); + gl.update(); + expect(engineLighting.hasLight('lamp')).toBe(false); + setTime(21); + gl.update(); + expect(engineLighting.hasLight('lamp')).toBe(true); + gl.destroy(); + }); +}); + +describe('GameLighting: день/ночь по времени', () => { + it('цель ambient — микс день/ночь по кривой суток', () => { + const { gl, engineLighting, setTime } = makeGameLighting( + fakeArea({ ambient: 0xd8dade, nightAmbient: 0x6a7288 }), + { time: 12 } + ); + gl.update(); + expect(engineLighting.ambientColor).toBe(0xd8dade); // день + setTime(19); // k = 0.5 — середина заката + gl.update(); + expect(engineLighting.ambientColor).not.toBe(0xd8dade); // микс, не день + expect(engineLighting.ambientColor).not.toBe(0x6a7288); // и не ночь + setTime(21); + gl.update(); + expect(engineLighting.ambientColor).toBe(0x6a7288); // ночь + setTime(10); + gl.update(); + expect(engineLighting.ambientColor).toBe(0xd8dade); // и обратно к дню + gl.destroy(); + }); + + it('без nightAmbient в данных время не трогает ambient', () => { + const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { time: 21 }); + gl.update(); + expect(engineLighting.ambientColor).toBe(0xd8dade); + gl.destroy(); + }); + + it('золотой час: на пике заката ambient — закатный тон (свой в данных)', () => { + const { gl, engineLighting, setTime } = makeGameLighting( + fakeArea({ ambient: 0xf7f4ea, nightAmbient: 0x3e4658, duskColor: 0xe8a060 }), + { time: 18 + 2 * DUSK_PEAK } // k = DUSK_PEAK + ); + gl.update(); + expect(engineLighting.ambientColor).toBe(0xe8a060); + setTime(12); // день — обратно из заката + gl.update(); + expect(engineLighting.ambientColor).toBe(0xf7f4ea); + gl.destroy(); + }); + + it('конструктор сразу применяет ночной микс (без мелькания дня)', () => { + const { gl, engineLighting } = makeGameLighting( + fakeArea({ ambient: 0xd8dade, nightAmbient: 0x6a7288 }), + { time: 23 } + ); + expect(engineLighting.ambientColor).toBe(0x6a7288); + gl.destroy(); + }); + + it('whenNight: ночные источники горят ночью, гаснут днём', () => { + const { gl, engineLighting, setTime } = makeGameLighting( + fakeArea({ sources: [{ id: 'win', at: { x: 1, y: 1 }, flicker: 0, whenNight: true }] }), + { time: 21 } + ); + gl.update(); + expect(engineLighting.hasLight('win')).toBe(true); + setTime(12); + gl.update(); + expect(engineLighting.hasLight('win')).toBe(false); + gl.destroy(); + }); +}); + +describe('GameLighting: тёмные ауры хазардов', () => { + it('пятно на каждый тайл зоны, снятие при исчезновении зоны', () => { + const hazards = [{ name: 'накат', tiles: [{ x: 3, y: 4 }, { x: 4, y: 4 }], requiresItem: 'cloth' as const }]; + const { gl, engineLighting } = makeGameLighting(fakeArea(undefined, hazards)); + gl.update(); + expect(engineLighting.hasDarkSpot('hazard@3,4')).toBe(true); + expect(engineLighting.hasDarkSpot('hazard@4,4')).toBe(true); + gl.destroy(); + // Пятна сняты destroy + expect(engineLighting.hasDarkSpot('hazard@3,4')).toBe(false); + }); +}); + +describe('GameLighting: runtime API и снапшот', () => { + it('setAmbient пробрасывается в движковый слой с лерпом', () => { + const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xffffff })); + gl.setAmbient(0x000000, 1); + engineLighting.update(0.5); + expect(engineLighting.ambientColor).toBe(0x000000); + expect(engineLighting.children[0].tint).not.toBe(0xffffff); + expect(engineLighting.children[0].tint).not.toBe(0x000000); + gl.destroy(); + }); + + it('snapshot: ambient + виньетка + источники с пост-мерцанием', () => { + const { gl } = makeGameLighting( + fakeArea({ ambient: 0x334455, sources: [{ id: 'h', at: { x: 1, y: 1 }, intensity: 0.8, flicker: 0 }] }) + ); + gl.update(); + gl.setVignette(0.35); + const s = gl.snapshot(); + expect(s.ambient).toBe(0x334455); + expect(s.vignette).toBe(0.35); + expect(s.sources.length).toBe(1); + expect(s.sources[0].id).toBe('h'); + expect(s.sources[0].intensity).toBeCloseTo(0.8, 3); + expect(Number.isFinite(s.sources[0].x)).toBe(true); + gl.destroy(); + }); + + it('destroy снимает все источники', () => { + const { gl, engineLighting } = makeGameLighting(fakeArea(), { lamp: LAMP }); + gl.update(); + gl.destroy(); + expect(engineLighting.frames().length).toBe(0); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/CombatFlow.ts b/v1/apps/game/src/systems/combat/CombatFlow.ts new file mode 100644 index 0000000..6ba9780 --- /dev/null +++ b/v1/apps/game/src/systems/combat/CombatFlow.ts @@ -0,0 +1,278 @@ +import { + Graphics, + worldNorm, + worldToScreen, + unitsToPx, + conePath, + type Camera, + type Entity, + type FxLayer, + type PostFx, + type Vec2 +} from '@rpg/engine'; +import type { Game } from '../../Game'; +import type { VarId } from '../../data/ids'; +import { VARS } from '../../data/ids'; +import { GameLighting } from '../Lighting'; +import type { PlayerController } from '../PlayerController'; +import { CombatWorld } from './CombatWorld'; +import { CombatViews } from './CombatViews'; +import { HealthBar } from './HealthBar'; +import { PlayerCombat } from './PlayerCombat'; +import { PLAYER_COMBAT } from './stats'; + +/** Боевые счётчики GameState (инкрементируются здесь). */ +const BUMP: Record<'kills' | 'motes' | 'deaths' | 'resonances', VarId> = { + kills: VARS.kills, + motes: VARS.motes, + deaths: VARS.deaths, + resonances: VARS.resonances +}; + +/** + * Боевая обвязка героя поверх ECS-мира CombatWorld: ввод атаки (тап — короткий + * удар, удержание — резонанс), реакции на урон (вспышка, шейк, отброс, + * смерть-респаун), hp-бар, индикатор заряда, уровень угрозы для музыки. + * Сцена только собирает систему, тикает и передаёт ей ввод. + */ +export interface CombatFlowDeps { + game: Game; + combat: CombatWorld; + combatViews: CombatViews; + player: PlayerController; + lighting: GameLighting; + camera: Camera; + /** Пост-эффекты: блюр-волна резонанса, аберрация урона. */ + postfx: PostFx; + /** Слой мировых эффектов (флэш урона на герое). */ + fx: FxLayer; + /** Стартовый тайл локации (респаун после смерти). */ + spawn: Vec2; + /** Зона пепельного наката (резонанс вспыхивает пепел). */ + inHazard(): boolean; + /** Респаун/телепорт: камера мгновенно на героя. */ + snapCamera(): void; +} + +export class CombatFlow { + /** Боевое состояние героя (читают агентный мост и мигание неуязвимости). */ + readonly playerCombat: PlayerCombat; + /** Полоска сердец (сцена добавляет в uiRoot). */ + readonly healthBar: HealthBar; + private ring: Graphics; + /** Кольцо сейчас нарисовано: clear/перерисовка только на переходах. */ + private ringVisible = false; + /** Сектор замаха (удержание атаки): полигон по conePath, двигается без перерисовки. */ + private swing: Graphics; + /** Ключ нарисованного сектора (направление + заряжен) — null сектор скрыт. */ + private swingKey: string | null = null; + private offs: (() => void)[] = []; + + constructor(private deps: CombatFlowDeps, savedHp: number) { + this.playerCombat = new PlayerCombat(savedHp); + this.healthBar = new HealthBar(); + this.healthBar.setHp(this.playerCombat.hp); + this.ring = new Graphics(); + deps.game.renderer.worldRoot.addChild(this.ring); + this.swing = new Graphics(); + deps.game.renderer.worldRoot.addChild(this.swing); + // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира. + this.offs.push( + deps.game.engine.events.on<{ entity: Entity }>('combat:hurt', ({ entity }) => { + const en = deps.combat.enemies.get(entity); + if (!en) return; + deps.combatViews.flashEnemy(entity); + const s = worldToScreen(en.pos.x, en.pos.y); + if (en.brain.dead) deps.combatViews.deathBurst(s); + else deps.combatViews.hitBurst(s); + // Световой импульс в точке попадания (lightRoot — экранное пространство). + const ls = deps.camera.toScreen(en.pos.x, en.pos.y); + deps.lighting.pulseLight({ + x: ls.x, + y: ls.y, + color: 0xf2b45a, + intensity: 0.45, + radius: unitsToPx(1.5) * deps.camera.zoomLevel, + spec: { attack: 0.02, decay: 0.2 } + }); + }) + ); + } + + /** Нажатие атаки: начало зарядки резонанса. */ + onAttackPressed(): void { + this.playerCombat.startCharge(); + } + + /** Отпускание атаки: резонанс (долгое) или короткий удар. */ + onAttackReleased(): void { + if (!this.playerCombat.isCharging()) return; + const action = this.playerCombat.release(); + const from = this.deps.player.position; + if (action === 'resonance') { + this.doResonance(from); + } else if (action === 'attack') { + this.deps.combat.playerConeAttack(from, this.deps.player.dirVector); + this.swingFlash(from, this.deps.player.dirVector); + } + } + + /** Уровень угрозы 0..1 по состояниям врагов (музыка: бой/настороженность). */ + threat(): number { + let threat = 0; + for (const en of this.deps.combat.enemies.values()) { + const st = en.brain.state; + if (st === 'chase' || st === 'windup' || st === 'attack' || st === 'hurt') threat = 1; + else if (st === 'wary') threat = Math.max(threat, 0.4); + } + return threat; + } + + /** Тик: таймеры боя, hp-бар, кольцо заряда под героем. */ + update(dt: number): void { + this.playerCombat.update(dt); + this.updateRing(); + this.updateSwing(); + this.healthBar.setHp(this.playerCombat.hp); + } + + /** Урон герою: неуязвимость внутри PlayerCombat; здесь отброс, шейк, звук, смерть. */ + onPlayerDamaged(damage: number, from: Vec2): void { + const d = this.deps; + const applied = this.playerCombat.takeDamage(damage); + if (applied <= 0) return; + d.game.engine.events.emit('combat:playerHit', { hp: this.playerCombat.hp }); + d.game.camera.addShake(2.5, 0.3); + // Красная вспышка на весь экран — экранная реакция на боль. + d.lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18, spec: { attack: 0.02, decay: 0.35 } }); + // Хроматическая аберрация: экран «ведёт» от боли (импульс с огибающей). + d.postfx.pulseAberration(3, { attack: 0.02, hold: 0.06, decay: 0.35 }); + // Симметричный врагам флэш урона на герое. + d.fx.flash(d.player.sprite, 0xd05a5a, 0.18); + // Отброс от источника урона (направление — по метрике проекции) + const dx = d.player.position.x - from.x; + const dy = d.player.position.y - from.y; + d.player.applyKnockback(worldNorm(dx, dy), PLAYER_COMBAT.knockback); + + if (this.playerCombat.dead) { + this.bump('deaths', 1); + this.playerCombat.revive(); + d.player.teleportTo(d.spawn); + d.snapCamera(); + } + } + + exit(): void { + for (const off of this.offs) off(); + this.offs = []; + this.ring.destroy(); // в worldRoot — не вычищается с this.world + this.swing.destroy(); + } + + /** Резонанс: волна сна, кольцо, звон света, реакция наката. */ + private doResonance(from: Vec2): void { + const d = this.deps; + const slept = d.combat.resonancePulse(from); + const s = worldToScreen(from.x, from.y); + d.combatViews.resonanceRing(s); + d.game.camera.addShake(1.5, 0.25); + // Звон вспыхивает: тёплый импульс у героя и лёгкий подъём ambient. + const ls = d.camera.toScreen(from.x, from.y); + d.lighting.pulseLight({ + x: ls.x, + y: ls.y, + color: 0xd99a32, + intensity: 0.6, + radius: unitsToPx(2.5) * d.camera.zoomLevel, + spec: { attack: 0.05, decay: 0.45 } + }); + d.lighting.pulseAmbient({ color: 0xd99a32, peak: 0.12, spec: { attack: 0.05, decay: 0.5 } }); + // Блюр-волна вокруг героя: воздух плавится от резонанса (точка + радиус). + d.postfx.pulseBlur( + { strength: 5, center: { x: ls.x, y: ls.y }, radius: unitsToPx(3) * d.camera.zoomLevel }, + { attack: 0.06, hold: 0.08, decay: 0.5 } + ); + this.bump('resonances', slept > 0 ? 1 : 0); + // Звон как «проверка воздуха»: в накате волна на миг подсвечивает пепел. + if (d.inHazard()) { + d.combatViews.hitBurst({ x: s.x - 14, y: s.y + 6 }); + d.combatViews.hitBurst({ x: s.x + 14, y: s.y + 6 }); + void d.game.audio.play('sfx/ash_hiss', 0.7); + } + } + + /** Индикатор заряда резонанса под героем (Graphics трогаем только на переходах). */ + private updateRing(): void { + const charging = this.playerCombat.isCharging(); + if (!charging) { + if (this.ringVisible) { + this.ringVisible = false; + this.ring.clear(); + } + return; + } + const u = this.deps.player.position; + const p = worldToScreen(u.x, u.y); + const k = Math.min(1, this.playerCombat.chargeTime / PLAYER_COMBAT.resonanceCharge); + const color = k >= 1 ? 0xd99a32 : 0x888899; + if (!this.ringVisible) { + this.ringVisible = true; + this.ring + .circle(p.x, p.y - 2, 6 + 4 * k) + .stroke({ color, width: 1, alpha: 0.9 }); + } + } + + /** + * Замах при удержании атаки: полупрозрачный сектор конуса по направлению + * взгляда (заряжен резонанс — круг волны). Полигон перерисовывается только + * на смене направления/состояния заряда; позиция следует за героем каждый + * тик (сдвиг вьюхи, без перестройки полигона). + */ + private updateSwing(): void { + const charging = this.playerCombat.isCharging(); + if (!charging) { + if (this.swingKey) { + this.swingKey = null; + this.swing.clear(); + } + return; + } + const ready = this.playerCombat.chargeTime >= PLAYER_COMBAT.resonanceCharge; + const dir = this.deps.player.dirVector; + const key = `${dir.x},${dir.y}|${ready ? 'r' : 'c'}`; + if (key !== this.swingKey) { + this.swingKey = key; + this.swing.clear(); + const d = worldToScreen(dir.x, dir.y); // угол сектора — в экранных осях (как inConeW) + if (ready) { + this.swing + .circle(0, 0, unitsToPx(PLAYER_COMBAT.resonanceRange) - 2) + .fill({ color: 0xd99a32, alpha: 0.12 }); + } else { + const pts = conePath({ x: 0, y: 0 }, d, unitsToPx(PLAYER_COMBAT.coneRange), PLAYER_COMBAT.coneHalfAngle); + this.swing.poly(pts.flatMap((p) => [p.x, p.y])).fill({ color: 0xf2b45a, alpha: 0.15 }); + } + } + const p = worldToScreen(this.deps.player.position.x, this.deps.player.position.y); + this.swing.position.set(p.x, p.y); + } + + /** След удара: яркая вспышка-сектор по конусу, тает за ~0.18 с (FxLayer.destroy). */ + private swingFlash(from: Vec2, dir: Vec2): void { + const p = worldToScreen(from.x, from.y); + const d = worldToScreen(dir.x, dir.y); + const pts = conePath({ x: 0, y: 0 }, d, unitsToPx(PLAYER_COMBAT.coneRange), PLAYER_COMBAT.coneHalfAngle); + const flash = new Graphics(); + flash.poly(pts.flatMap((q) => [q.x, q.y])).fill({ color: 0xf2cd68, alpha: 0.4 }); + flash.position.set(p.x, p.y); + this.deps.game.renderer.worldRoot.addChild(flash); + this.deps.fx.fade(flash, { duration: 0.18 }); + } + + /** Инкремент боевого счётчика в vars GameState. */ + private bump(name: keyof typeof BUMP, by: number): void { + const key = BUMP[name]; + this.deps.game.state.setVar(key, this.deps.game.state.getNumber(key) + by); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/CombatViews.ts b/v1/apps/game/src/systems/combat/CombatViews.ts new file mode 100644 index 0000000..570b7d5 --- /dev/null +++ b/v1/apps/game/src/systems/combat/CombatViews.ts @@ -0,0 +1,188 @@ +import { + Container, + IsoDepthLayer, + Sprite, + SpriteAnimator, + Texture, + worldToTile, + worldToScreen, + type AssetLoader, + type Entity, + type FxLayer, + type Vec2 +} from '@rpg/engine'; +import type { CombatWorld } from './CombatWorld'; +import type { EnemyKindId } from '../../data/enemies'; +import type { ShadowAttach } from '../Shadows'; + +/** + * Пиксельные вьюхи боя: спрайты сгустков, снаряды, вспышки урона, растворение + * трупов, частицы ударов. Данные живут в CombatWorld (ECS) — здесь только отображение. + */ + +interface EnemyView { + root: Container; + sprite: Sprite; + animator: SpriteAnimator; + /** Растворение запущено (труп тает через FxFade, не вручную). */ + dissolving?: boolean; +} + +export class CombatViews { + private views = new Map(); + private projectileViews = new Map(); + + constructor( + private combat: CombatWorld, + private actors: IsoDepthLayer, + private enemyTextures: Map, + private fxRoot: Container, + /** Слой эффектов (FxLayer сцены): тик и зачистка — сами. */ + private fx: FxLayer, + /** Ассеты: спрайт снаряда плевка (tiles/spit). */ + private assets: AssetLoader, + /** Тени: блоб-тень под каждым сгустком (attach на спавне). */ + private shadows: ShadowAttach + ) {} + + /** Спавн-вспышка пепла (взрыв частиц). */ + hitBurst(pos: Vec2): void { + this.fx.burst(10, { x: pos.x, y: pos.y - 6 }, { + color: 0x52525c, + rate: 0, + lifetime: [0.2, 0.45], + radialSpeed: [30, 70], + size: 2, + drag: 2.5, + scaleOverLife: 'shrink', + seed: (pos.x * 31 + pos.y) | 0 + }, this.fxRoot); + } + + /** Смерть: заметный выброс пепла. */ + deathBurst(pos: Vec2): void { + this.fx.burst(16, { x: pos.x, y: pos.y - 8 }, { + colors: [0x6a6a74, 0x8a8a96], + rate: 0, + lifetime: [0.3, 0.6], + radialSpeed: [40, 90], + size: 2, + drag: 2, + spin: [-4, 4], + scaleOverLife: 'shrink', + seed: (pos.x * 17 + pos.y) | 0 + }, this.fxRoot); + } + + /** Кольцо резонанса — расходящаяся окружность (пресет, тикается слоем). */ + resonanceRing(pos: Vec2): void { + this.fx.ring( + { color: 0xd99a32, from: 8, to: 96, duration: 0.4, width: 1 }, + { x: pos.x, y: pos.y - 4 }, + { parent: this.fxRoot } + ); + } + + /** Синхронизация вьюх с ECS-миром. Вызывать каждый тик после combat.update. */ + sync(dt: number): void { + const map = this.combat.deps.map; + const tileOf = (p: Vec2): { x: number; y: number } => + worldToTile(p.x, p.y, map.data.width, map.data.height) ?? { x: 0, y: 0 }; + + // Спавн новых + for (const [e, en] of this.combat.enemies) { + if (!this.views.has(e)) { + const frames = this.enemyTextures.get(en.kind.id)!; + const root = new Container(); + const sprite = new Sprite(frames[0]); + sprite.anchor.set(0.5, 1); + root.addChild(sprite); + this.shadows.attachActor(root, 8, 10); + const sp = worldToScreen(en.pos.x, en.pos.y); + root.position.set(sp.x, sp.y); + const t = tileOf(en.pos); + this.actors.add(root, t.x, t.y); + this.views.set(e, { + root, + sprite, + animator: new SpriteAnimator(sprite, { walk: { frames, fps: 4 } }, 'walk') + }); + } + } + + // Обновление/смерть + for (const [e, en] of this.combat.enemies) { + const view = this.views.get(e)!; + const sp = worldToScreen(en.pos.x, en.pos.y); + view.root.position.set(sp.x, sp.y); + const t = tileOf(en.pos); + this.actors.setDepth(view.root, t.x, t.y); + + if (en.brain.dead) { + // Растворение трупа: один fade на переход alive→dead (keep — + // вьюху уничтожает сцена по !isAlive, без двойного destroy). + if (!view.dissolving) { + view.dissolving = true; + this.fx.fade(view.root, { duration: 0.5, keep: true }); + } + view.sprite.tint = 0x555560; + continue; + } + + // Мигание неактивных: спящий сгусток почти невидим в пепле + view.root.alpha = en.brain.asleep ? 0.45 : 1; + + // Покадровая анимация (4 Гц) — только когда активен; спящим pause. + if (en.brain.asleep) view.animator.pause(); + else { + view.animator.resume(); + view.animator.play('walk'); + } + view.animator.update(dt); + } + + // Удаление уничтоженных сущностей + for (const [e, view] of this.views) { + if (!this.combat.world.isAlive(e)) { + this.actors.removeChild(view.root); + view.root.destroy({ children: true }); + this.views.delete(e); + } + } + + // Снаряды: спрайт пепельного плевка (tiles/spit) + for (const [e, pr] of this.combat.projectiles) { + let pv = this.projectileViews.get(e); + if (!pv) { + pv = new Sprite(this.assets.texture('tiles/spit')); + pv.anchor.set(0.5); + this.fxRoot.addChild(pv); + this.projectileViews.set(e, pv); + } + const sp = worldToScreen(pr.pos.x, pr.pos.y); + pv.position.set(sp.x, sp.y); + } + for (const [e, pv] of this.projectileViews) { + if (!this.combat.world.isAlive(e)) { + pv.destroy(); + this.projectileViews.delete(e); + } + } + } + + /** Вспышка конкретного врага (вызывает CombatWorld при попадании). */ + flashEnemy(e: Entity): void { + const view = this.views.get(e); + if (view) this.fx.flash(view.sprite, 0xf2b45a, 0.15); + } + + exit(): void { + for (const [, view] of this.views) { + this.actors.removeChild(view.root); + view.root.destroy({ children: true }); + } + this.views.clear(); + for (const [, pv] of this.projectileViews) pv.destroy(); + this.projectileViews.clear(); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/CombatWorld.ts b/v1/apps/game/src/systems/combat/CombatWorld.ts new file mode 100644 index 0000000..9bd9362 --- /dev/null +++ b/v1/apps/game/src/systems/combat/CombatWorld.ts @@ -0,0 +1,410 @@ +import { + EventBus, + IsometricTileMap, + World, + worldToTile, + worldDist, + worldNorm, + inCircleW, + inConeW, + tileToWorld, + moveCircle, + circleFits, + separateCircles, + type Entity, + type SceneRegistry, + type System, + type Vec2, + type Invariant +} from '@rpg/engine'; +import { + ENEMY_KINDS, + withDefaults, + type EnemyKindDef, + type EnemyKindId, + type EnemyPatrolDef +} from '../../data/enemies'; +import { EnemyBrain, ENEMY_STATES, NOISE_HEAR_LEVEL, NOISE_WAKE_LEVEL, type EnemyIntent } from './EnemyBrain'; +import { hasLineOfSight, tileOpaque } from './los'; +import { PLAYER_COMBAT } from './stats'; + +/** + * Боевой мир: сущности (враги, снаряды) в движковом ECS, поведение — в системах. + * Пиксельные вьюхи держатся отдельно (CombatViews) — в ECS только данные. + */ + +export interface EnemyComp { + kind: EnemyKindDef; + brain: EnemyBrain; + pos: Vec2; + hp: number; + /** Точка спавна — «дом» для возврата после погони. */ + home: Vec2; + /** Точки патруля, мировые юниты (пусто — враг спит в пепле). */ + patrolPoints: Vec2[]; + /** Секунды до исчезновения трупа (после смерти). */ + corpseTimer: number; + /** Кэш прямой видимости героя: true/false и сек до пересчёта. */ + los: boolean; + losTimer: number; +} + +export interface ProjectileComp { + pos: Vec2; + vel: Vec2; + damage: number; + life: number; +} + +export interface CombatDeps { + map: IsometricTileMap; + events: EventBus; + /** Позиция героя (ноги), мировые юниты (1 юнит = 1 тайл). */ + getPlayerPos: () => Vec2; + /** Герой получил урон (сцена отбросит/шейкнет/проиграет звук). */ + damagePlayer: (damage: number, from: Vec2) => void; + /** + * Реестр объектов сцены (опция): враги регистрируются в нём для кликов + * и телесных коллизий — боевая система остаётся на ECS. + */ + registry?: SceneRegistry; +} + +/** Окно слышимости шума: событие живёт столько секунд для бодрых ушей. */ +const NOISE_WINDOW = 0.5; +/** Пересчёт LOS раз в 3 тика (60 Гц) — видимость меняется медленнее. */ +const LOS_INTERVAL = 0.05; +/** Бег (отступление) быстрее шага. */ +const RUN_MULT = 1.3; + +/** Движение врага: круг тела скользит вдоль стен (движковые коллизии). */ +function stepWithCollision( + map: IsometricTileMap, + pos: Vec2, + dir: Vec2, + speed: number, + radius: number, + dt: number +): void { + moveCircle(map, pos, { x: dir.x * speed * dt, y: dir.y * speed * dt }, radius); +} + +/** Максимальный радиус тел в реестре (NPC 0.35, враги до 0.3 — с запасом). */ +const BODY_PROBE = 0.5; + +// ---------- системы ECS ---------- + +/** ИИ и движение сгустков. */ +class EnemySystem implements System { + /** Колбэк непрозрачности тайлов (карта не меняется за жизнь мира). */ + private opaque: (tx: number, ty: number) => boolean; + + constructor(private combat: CombatWorld) { + // Дома (пропы) тоже перекрывают взгляд — сгустки не видят сквозь них. + this.opaque = tileOpaque(combat.deps.map); + } + + update(_world: World, dt: number): void { + const playerPos = this.combat.deps.getPlayerPos(); + for (const [e, en] of this.combat.enemies) { + const world = this.combat.world; + if (en.brain.dead) { + en.corpseTimer -= dt; + if (en.corpseTimer <= 0) { + world.destroyEntity(e); + this.combat.enemies.delete(e); + this.combat.deps.registry?.remove(`enemy:${e}`); + } + continue; + } + + // LOS с кэшем: пересчёт раз в LOS_INTERVAL. + en.losTimer -= dt; + if (en.losTimer <= 0) { + en.los = hasLineOfSight(en.pos, playerPos, this.opaque); + en.losTimer = LOS_INTERVAL; + } + + const dx = playerPos.x - en.pos.x; + const dy = playerPos.y - en.pos.y; + const dist = worldDist(en.pos, playerPos) || 1e-4; + const noise = this.combat.noiseFor(en); + const intent: EnemyIntent = en.brain.update(dt, { + dist, + dirToPlayer: worldNorm(dx, dy), + pos: en.pos, + canSee: en.los, + noise, + patrolPoints: en.patrolPoints, + home: en.home, + hpFraction: en.hp / en.kind.hp + }); + + // Позиция до шага и толкучки: если сепарация втолкнет тело в стену + // (separateCircles стены не проверяет), откатываемся сюда. + const beforeCrowd = { ...en.pos }; + + switch (intent.type) { + case 'move': + stepWithCollision( + this.combat.deps.map, + en.pos, + intent.dir, + en.kind.speed * (intent.run ? RUN_MULT : 1), + en.kind.radius, + dt + ); + this.combat.deps.registry?.move(`enemy:${e}`, en.pos); + break; + case 'strike': + // Удар доходит, если герой в радиусе тела + замах + if (dist <= en.kind.attackRange + 0.25) { + this.combat.deps.damagePlayer(en.kind.attackDamage, en.pos); + } + break; + case 'shoot': + this.combat.spawnProjectile(en.pos, intent.dir, en.kind.attackDamage); + // Плевок слышно (звук — подпиской на combat:spit), но спящих не будит (< 0.7) + this.combat.deps.events.emit('combat:spit', { pos: { ...en.pos } }); + this.combat.noise(en.pos, 0.6); + break; + default: + break; + } + + // Тела: каждый враг выталкивает себя из героя, NPC и других врагов + // (сепарация после шага — толкучка не отменяет намерение ИИ). + separateCircles(en.pos, en.kind.radius, playerPos, PLAYER_COMBAT.radius); + const reg = this.combat.deps.registry; + if (reg) { + for (const hit of reg.near(en.pos, en.kind.radius + BODY_PROBE)) { + if (hit.id === `enemy:${e}` || (hit.kind !== 'enemy' && hit.kind !== 'npc')) continue; + separateCircles(en.pos, en.kind.radius, hit.pos, hit.radius); + } + } + // Стена сильнее толкучки: втолкнули в непроходимый тайл — откат. + if (!circleFits(this.combat.deps.map, en.pos, en.kind.radius)) { + en.pos.x = beforeCrowd.x; + en.pos.y = beforeCrowd.y; + } + } + } +} + +/** Снаряды: полёт, попадание в героя, срок жизни. */ +class ProjectileSystem implements System { + constructor(private combat: CombatWorld) {} + + update(_world: World, dt: number): void { + const playerPos = this.combat.deps.getPlayerPos(); + for (const [e, pr] of this.combat.projectiles) { + pr.life -= dt; + pr.pos.x += pr.vel.x * dt; + pr.pos.y += pr.vel.y * dt; + + // Столкновение с героем + if (inCircleW(playerPos, 0.2, pr.pos)) { + this.combat.deps.damagePlayer(pr.damage, pr.pos); + this.combat.killProjectile(e); + continue; + } + // Вне карты, срок истёк или плевок в стену/дом (вода прозрачна — + // летит над прудом; правило игры, не движка). + const tile = worldToTile(pr.pos.x, pr.pos.y, this.combat.deps.map.data.width, this.combat.deps.map.data.height); + if (pr.life <= 0 || !tile || this.combat.opaque(tile.x, tile.y)) { + this.combat.killProjectile(e); + } + } + } +} + +// ---------- фасад ---------- + +export class CombatWorld { + readonly world = new World(); + readonly enemies = new Map(); + readonly projectiles = new Map(); + /** Последний шум мира (шаги, удары, плевки); живёт NOISE_WINDOW секунд. */ + private lastNoise: { origin: Vec2; level: number; age: number } | null = null; + /** Переиспользуемый view шума для noiseFor — без копии origin на врага/тик. */ + private readonly noiseView: { origin: Vec2; level: number } = { origin: { x: 0, y: 0 }, level: 0 }; + /** Непрозрачность тайлов (стены/дома) — для конуса удара и снарядов. */ + readonly opaque: (tx: number, ty: number) => boolean; + + constructor( + readonly deps: CombatDeps, + private onEnemyKilled?: (kind: EnemyKindDef) => void + ) { + this.opaque = tileOpaque(deps.map); + this.world.addSystem(new EnemySystem(this)); + this.world.addSystem(new ProjectileSystem(this)); + } + + /** + * Заспавнить сгусток. Без patrol спит в пепле (dormant), пока не разбудит + * гулкий звук; с патрулём сразу бродит по маршруту. + */ + spawnEnemy(kindId: EnemyKindId, pos: Vec2, opts?: { patrol?: EnemyPatrolDef }): Entity { + const kind = withDefaults(ENEMY_KINDS[kindId]); + const e = this.world.createEntity(); + this.enemies.set(e, { + kind, + brain: new EnemyBrain( + kind, + opts?.patrol + ? { points: opts.patrol.points.map((p) => tileToWorld(p.x, p.y)), pauseSec: opts.patrol.pauseSec } + : undefined + ), + pos: { ...pos }, + hp: kind.hp, + home: { ...pos }, + patrolPoints: opts?.patrol ? opts.patrol.points.map((p) => tileToWorld(p.x, p.y)) : [], + corpseTimer: 0.5, + los: false, + losTimer: 0 + }); + // В реестре сцены — та же ссылка pos: move() после шага переиндексирует. + this.deps.registry?.add({ + id: `enemy:${e}`, + kind: 'enemy', + pos: this.enemies.get(e)!.pos, + radius: kind.radius, + ref: e + }); + return e; + } + + spawnProjectile(from: Vec2, dir: Vec2, damage: number): Entity { + const e = this.world.createEntity(); + this.projectiles.set(e, { + pos: { x: from.x + dir.x * 0.3, y: from.y + dir.y * 0.3 }, + vel: { x: dir.x * 2.2, y: dir.y * 2.2 }, + damage, + life: 1.5 + }); + return e; + } + + killProjectile(e: Entity): void { + if (!this.world.isAlive(e)) return; + this.world.destroyEntity(e); + this.projectiles.delete(e); + } + + /** + * Короткий удар конусом. Возвращает число поражённых сгустков. + * Побочный эффект: гулкий звон (уровень 1.0) будит спящих в wakeRadius. + */ + playerConeAttack(from: Vec2, dir: Vec2): number { + this.noise(from, 1.0); + this.deps.events.emit('combat:attack', { mode: 'cone', origin: { ...from } }); + + let hits = 0; + for (const [e, en] of this.enemies) { + if (en.brain.dead) continue; + if (!inConeW(from, dir, PLAYER_COMBAT.coneRange, PLAYER_COMBAT.coneHalfAngle, en.pos)) continue; + // Звон не огибает дома: цель должна быть в прямой видимости. + if (!hasLineOfSight(from, en.pos, this.opaque)) continue; + this.damageEnemy(e, en, PLAYER_COMBAT.coneDamage, from); + hits++; + } + return hits; + } + + /** Низкий резонанс: усыпляет сгустков в радиусе. Возвращает число усыпленных. */ + resonancePulse(from: Vec2): number { + this.deps.events.emit('combat:attack', { mode: 'resonance', origin: { ...from } }); + + let slept = 0; + for (const [, en] of this.enemies) { + if (en.brain.dead) continue; + if (inCircleW(from, PLAYER_COMBAT.resonanceRange, en.pos)) { + en.brain.putToSleep(PLAYER_COMBAT.resonanceSleep); + slept++; + } + } + return slept; + } + + /** + * Шум в точке мира. level 0.35 — слышат бодрые в hearRadius (патруль + * идёт проверять); 0.7+ — гулкий удар, будит и спящих в wakeRadius; + * 0.6 — плевок/смерть сгустка (слышат, но не просыпаются). + */ + noise(pos: Vec2, level: number): void { + this.lastNoise = { origin: { ...pos }, level, age: 0 }; + if (level >= NOISE_WAKE_LEVEL) { + for (const [, en] of this.enemies) { + if (inCircleW(pos, PLAYER_COMBAT.wakeRadius, en.pos) && en.brain.asleep) { + en.brain.hearLoud(); + this.deps.events.emit('combat:awake', { kind: en.kind.id, pos: { ...en.pos } }); + } + } + } + } + + /** Шум, слышимый конкретному врагу (фильтр по уровню, радиусу слуха и окну). */ + noiseFor(en: EnemyComp): { origin: Vec2; level: number } | null { + const n = this.lastNoise; + if (!n || n.age > NOISE_WINDOW || n.level < NOISE_HEAR_LEVEL) return null; + if (en.brain.asleep) return null; // спящего будит только noise() от 0.7 + if (worldDist(en.pos, n.origin) > (en.kind.hearRadius ?? 5)) return null; + // Общий view (потребитель копирует origin перед хранением — см. EnemyBrain). + this.noiseView.origin = n.origin; + this.noiseView.level = n.level; + return this.noiseView; + } + + /** Урон сгустку; смерть — дроп, событие и приглушённый шум. */ + damageEnemy(e: Entity, en: EnemyComp, damage: number, from: Vec2): void { + en.hp -= damage; + en.brain.hurt(); + this.deps.events.emit('combat:hurt', { entity: e, died: en.hp <= 0 }); + if (en.hp <= 0) { + if (en.brain.die()) { + this.noise(en.pos, 0.6); + this.deps.events.emit('combat:kill', { kind: en.kind.id, tile: { ...en.pos } }); + this.onEnemyKilled?.(en.kind); + // Отброс трупа от героя — лёгкий визуальный эффект делает CombatViews. + en.pos.x += Math.sign(en.pos.x - from.x) * 0.0625; + en.pos.y += Math.sign(en.pos.y - from.y) * 0.0625; + } + } + } + + /** Инварианты боевого мира: легальность состояний и позиций врагов. */ + agentInvariants(): Invariant[] { + const where = 'systems/combat'; + const { width, height } = this.deps.map.data; + const out: Invariant[] = []; + for (const [, en] of this.enemies) { + if (!ENEMY_STATES.includes(en.brain.state as (typeof ENEMY_STATES)[number])) { + out.push({ + id: 'enemy-state-legal', + severity: 'error', + message: `${en.kind.id}: неизвестное состояние "${en.brain.state}"`, + where + }); + } + const tx = Math.floor(en.pos.x); + const ty = Math.floor(en.pos.y); + if (tx < 0 || ty < 0 || tx >= width || ty >= height) { + out.push({ + id: 'enemy-in-bounds', + severity: 'error', + message: `${en.kind.id} вне карты (${en.pos.x.toFixed(2)},${en.pos.y.toFixed(2)})`, + where + }); + } + } + return out; + } + + update(dt: number): void { + if (this.lastNoise) { + this.lastNoise.age += dt; + if (this.lastNoise.age > NOISE_WINDOW) this.lastNoise = null; + } + this.world.update(dt); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/EnemyBrain.ts b/v1/apps/game/src/systems/combat/EnemyBrain.ts new file mode 100644 index 0000000..154c0b6 --- /dev/null +++ b/v1/apps/game/src/systems/combat/EnemyBrain.ts @@ -0,0 +1,406 @@ +import { StateMachine, Cooldown, type Vec2 } from '@rpg/engine'; +import type { EnemyKindDef } from '../../data/enemies'; + +/** + * Чистый ИИ «пепельного сгустка» поверх движкового StateMachine. + * Сенсоры входят (дистанция/направление до героя, видимость, шум, патруль), + * намерения выходят (куда идти, когда бить/плевать). Без Pixi — тестируется + * в Vitest. + * + * Состояния: спящий враг (dormant, старт без патруля) -> rise -> chase -> + * windup -> attack -> recover -> chase; патрульный враг: patrol <-> wary -> + * chase; отступление: chase -> flee (низкое hp) / -> return (потерял героя) -> + * patrol | dormant; sleeping (от низкого резонанса), dead. + * + * Шум слышен бодрому врагу от уровня NOISE_HEAR_LEVEL (в радиусе hearRadius — + * фильтрует CombatWorld); спящих будит только уровень >= 0.7 (гулкий удар). + */ +export type EnemyIntent = + | { type: 'idle' } + | { type: 'move'; dir: Vec2; run?: boolean } + | { type: 'strike' } + | { type: 'shoot'; dir: Vec2 }; + +/** Варианты намерений с направлением — для типизации переиспользуемых констант. */ +type MoveIntent = Extract; +type ShootIntent = Extract; + +export interface EnemySenses { + /** Дистанция до героя, мировых юнитов (1 юнит = 1 тайл). */ + dist: number; + /** Нормализованное направление к герою. */ + dirToPlayer: Vec2; + /** Позиция врага (для хождения по патрульным точкам и к дому). */ + pos?: Vec2; + /** Герой в прямой видимости (LOS; по умолчанию true — старые сенсоры). */ + canSee?: boolean; + /** Последний шум в радиусе слуха (уже отфильтрован по hearRadius). */ + noise?: { origin: Vec2; level: number } | null; + /** Точки патруля, мировые юниты (пусто — враг без патруля). */ + patrolPoints?: Vec2[]; + /** Точка спавна — «дом», к которому враг отступает, юниты. */ + home?: Vec2; + /** Доля hp (0..1; по умолчанию 1). */ + hpFraction?: number; +} + +/** Шум от этого уровня слышит бодрый враг (спящих будит CombatWorld от 0.7). */ +export const NOISE_HEAR_LEVEL = 0.35; + +/** Шум от этого уровня будит спящих (гулкий удар; см. CombatWorld.noise). */ +export const NOISE_WAKE_LEVEL = 0.7; + +/** Легальные состояния автомата (инвариант enemy-state-legal). */ +export const ENEMY_STATES = [ + 'dormant', + 'rise', + 'patrol', + 'wary', + 'chase', + 'windup', + 'attack', + 'recover', + 'hurt', + 'flee', + 'return', + 'sleeping', + 'dead' +] as const; + +/** Дальность, на которой считается «дошёл до точки» (юниты). */ +const ARRIVE_DIST = 0.35; + +/** + * Переиспользуемые намерения и вектор направления: update() вызывается на + * каждый тик каждого врага — новые объекты там не создаются. Потребитель + * (CombatWorld) читает intent сразу, между тиками не хранит. + */ +const INTENT_IDLE: EnemyIntent = { type: 'idle' }; +const INTENT_STRIKE: EnemyIntent = { type: 'strike' }; +const INTENT_MOVE: MoveIntent = { type: 'move', dir: { x: 0, y: 0 } }; +const INTENT_MOVE_RUN: MoveIntent = { type: 'move', dir: { x: 0, y: 0 }, run: true }; +const INTENT_SHOOT: ShootIntent = { type: 'shoot', dir: { x: 0, y: 0 } }; +const MOVE_DIR: Vec2 = { x: 0, y: 0 }; + +export class EnemyBrain { + readonly sm = new StateMachine(); + private attackCd: Cooldown; + private sleepTimer = 0; + private hurtTimer = 0; + /** Секунд без прямой видимости в погоне. */ + private lostTimer = 0; + /** Таймер настороженности и точка, к которой идём проверять. */ + private waryTimer = 0; + private waryTarget: Vec2 | null = null; + /** Патруль: индекс точки (ping-pong) и пауза на точке, сек. */ + private patrolIndex = 0; + private patrolDir = 1; + private patrolPause = 0; + + constructor( + readonly kind: EnemyKindDef, + private patrol?: { points: Vec2[]; pauseSec: number } + ) { + this.attackCd = new Cooldown(kind.attackCooldown); + + for (const s of ENEMY_STATES) this.sm.add(s, {}); + + // Обычные переходы + this.sm.transition('dormant', 'wake', 'rise'); + this.sm.transition('rise', 'done', 'chase'); + // Патруль: увидел — погоня, услышал шум — насторожился. + this.sm.transition('patrol', 'see', 'chase'); + this.sm.transition('patrol', 'noise', 'wary'); + this.sm.transition('wary', 'see', 'chase'); + this.sm.transition('wary', 'lost', 'return'); + this.sm.transition('chase', 'inRange', 'windup'); + this.sm.transition('chase', 'exhausted', 'sleeping'); + this.sm.transition('chase', 'lost', 'return'); + this.sm.transition('chase', 'flee', 'flee'); + this.sm.transition('flee', 'calm', 'wary'); + this.sm.transition('return', 'home', this.patrol ? 'patrol' : 'dormant'); + this.sm.transition('return', 'see', 'chase'); + this.sm.transition('windup', 'strike', 'attack'); + this.sm.transition('attack', 'done', 'recover'); + this.sm.transition('recover', 'done', 'chase'); + this.sm.transition('hurt', 'done', 'chase'); + this.sm.transition('sleeping', 'wake', 'rise'); + + // Из любого состояния: урон и смерть (кроме dead — повторный вход игнорируется) + this.sm.transitionAny('hurt', 'hurt'); + this.sm.transitionAny('die', 'dead'); + + this.sm.change(this.patrol ? 'patrol' : 'dormant'); + } + + /** Гулкий звук рядом — проснуться (только для спящих; уровень фильтрует CombatWorld). */ + hearLoud(): void { + if (this.sm.current === 'dormant' || this.sm.current === 'sleeping') { + this.sm.handleEvent('wake'); + } + } + + /** Низкий резонанс: усыпить на seconds (мёртвых не трогает). */ + putToSleep(seconds: number): void { + if (this.sm.current === 'dead') return; + this.sm.change('sleeping'); + this.sleepTimer = seconds; + } + + /** Получил урон: короткий стан (не выбивает из dead). */ + hurt(): void { + if (this.sm.current === 'dead') return; + this.hurtTimer = 0.25; + this.sm.handleEvent('hurt'); + } + + /** Умер. Возвращает true, если умер именно сейчас. */ + die(): boolean { + if (this.sm.current === 'dead') return false; + this.sm.handleEvent('die'); + return true; + } + + get state(): string | null { + return this.sm.current; + } + + get asleep(): boolean { + return this.sm.current === 'sleeping' || this.sm.current === 'dormant'; + } + + get dead(): boolean { + return this.sm.current === 'dead'; + } + + /** Тик ИИ: сенсоры внутрь, намерение наружу. */ + update(dt: number, senses: EnemySenses): EnemyIntent { + this.sm.update(dt); + this.attackCd.update(dt); + + const canSee = senses.canSee ?? true; + const aggro = this.kind.aggroRange ?? 4; + + // Таймеры поверх автомата + if (this.hurtTimer > 0) { + this.hurtTimer -= dt; + if (this.hurtTimer <= 0) this.sm.handleEvent('done'); + return INTENT_IDLE; + } + if (this.sm.current === 'sleeping') { + this.sleepTimer -= dt; + if (this.sleepTimer <= 0) this.sm.handleEvent('wake'); + return INTENT_IDLE; + } + if (this.sm.current === 'rise') { + // Подъём из пепла: 0.6 с, потом в погоню + if (this.sm.time >= 0.6) this.sm.handleEvent('done'); + return INTENT_IDLE; + } + + switch (this.sm.current) { + case 'patrol': + return this.updatePatrol(dt, senses, canSee, aggro); + case 'wary': + return this.updateWary(dt, senses, canSee, aggro); + case 'chase': + return this.updateChase(dt, senses, canSee, aggro); + case 'flee': + return this.updateFlee(senses); + case 'return': + return this.updateReturn(senses, canSee, aggro); + case 'windup': { + if (this.sm.time >= this.kind.windup) { + this.sm.handleEvent('strike'); + this.attackCd.triggerForce(); + if (this.kind.keepDistance !== undefined) { + INTENT_SHOOT.dir.x = senses.dirToPlayer.x; + INTENT_SHOOT.dir.y = senses.dirToPlayer.y; + return INTENT_SHOOT; + } + return INTENT_STRIKE; + } + return INTENT_IDLE; + } + case 'attack': { + if (this.sm.time >= Math.max(this.kind.recover, 0.3)) { + this.sm.handleEvent('done'); + } + return INTENT_IDLE; + } + case 'recover': { + if (this.sm.time >= this.kind.recover) this.sm.handleEvent('done'); + return INTENT_IDLE; + } + default: + return INTENT_IDLE; + } + } + + /** Патруль: ход по точкам туда-обратно; герой/шум вырывают из круга. */ + private updatePatrol(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { + if (this.checkSee(senses, canSee, aggro)) return INTENT_IDLE; + if (this.checkNoise(senses)) return INTENT_IDLE; + + const points = this.patrol?.points ?? senses.patrolPoints ?? []; + if (points.length === 0) return INTENT_IDLE; + if (this.patrolPause > 0) { + this.patrolPause -= dt; + return INTENT_IDLE; + } + if (!this.dirInto(MOVE_DIR, senses.pos, points[this.patrolIndex]!)) { + // Точка достигнута: пауза и разворот на следующую (ping-pong). + this.patrolPause = this.patrol?.pauseSec ?? 0; + this.advancePatrolIndex(points.length); + return INTENT_IDLE; + } + return this.moveIntentFrom(MOVE_DIR, false); + } + + /** Настороженность: идём к источнику шума, осматриваемся warySec — и домой. */ + private updateWary(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { + if (this.checkSee(senses, canSee, aggro)) return INTENT_IDLE; + if (this.checkNoise(senses)) return INTENT_IDLE; // свежий шум обновляет цель и таймер + + this.waryTimer -= dt; + if (this.waryTimer <= 0) { + this.sm.handleEvent('lost'); + return INTENT_IDLE; + } + if (!this.waryTarget || !this.dirInto(MOVE_DIR, senses.pos, this.waryTarget)) return INTENT_IDLE; + return this.moveIntentFrom(MOVE_DIR, false); + } + + /** Погоня: потерял героя дольше deaggroSec — домой; мало hp — прочь. */ + private updateChase(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { + if (canSee) { + this.lostTimer = 0; + } else { + this.lostTimer += dt; + if (this.lostTimer >= (this.kind.deaggroSec ?? 2.5)) { + this.sm.handleEvent('lost'); + return INTENT_IDLE; + } + } + const fleeAt = this.kind.fleeBelowHpFraction ?? 0; + if (fleeAt > 0 && (senses.hpFraction ?? 1) <= fleeAt) { + this.sm.handleEvent('flee'); + return INTENT_IDLE; + } + if (senses.dist > aggro * 1.5) { + // Убежал слишком далеко даже с LOS — не гоняемся по всей карте. + this.sm.handleEvent('lost'); + return INTENT_IDLE; + } + + const inRange = senses.dist <= this.kind.attackRange; + if (inRange && this.kind.keepDistance === undefined && this.attackCd.ready) { + // контактный (ползун): без замаха + if (this.kind.windup === 0) { + this.attackCd.triggerForce(); + return INTENT_STRIKE; + } + this.sm.handleEvent('inRange'); + return INTENT_IDLE; + } + // Плевун держит дистанцию + if (this.kind.keepDistance !== undefined) { + if (senses.dist < this.kind.keepDistance - 0.4) { + return this.moveIntentFrom(negate(MOVE_DIR, senses.dirToPlayer), false); + } + if (senses.dist <= this.kind.attackRange && this.attackCd.ready) { + this.sm.handleEvent('inRange'); + return INTENT_IDLE; + } + if (senses.dist > this.kind.keepDistance + 0.4) { + return this.moveIntentFrom(senses.dirToPlayer, false); + } + return INTENT_IDLE; + } + return this.moveIntentFrom(senses.dirToPlayer, false); + } + + /** Отступление: прочь от героя бегом, пока он не отстал на fleeRange. */ + private updateFlee(senses: EnemySenses): EnemyIntent { + if (senses.dist >= (this.kind.fleeRange ?? 4)) { + // Оторвался: стоит и осматривается warySec, потом домой. + this.waryTarget = null; + this.waryTimer = this.kind.warySec ?? 2.5; + this.sm.handleEvent('calm'); + return INTENT_IDLE; + } + return this.moveIntentFrom(negate(MOVE_DIR, senses.dirToPlayer), true); + } + + /** Возврат к дому; на месте — снова в патруль или обратно в пепел. */ + private updateReturn(senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { + if (this.checkSee(senses, canSee, aggro)) return INTENT_IDLE; + if (this.checkNoise(senses)) return INTENT_IDLE; + if (senses.home) { + if (!this.dirInto(MOVE_DIR, senses.pos, senses.home)) { + this.lostTimer = 0; + this.sm.handleEvent('home'); + } else { + return this.moveIntentFrom(MOVE_DIR, false); + } + } + return INTENT_IDLE; + } + + /** Переход «увидел героя» (LOS + aggroRange); true — событие отправлено. */ + private checkSee(senses: EnemySenses, canSee: boolean, aggro: number): boolean { + if (canSee && senses.dist <= aggro) { + this.lostTimer = 0; + this.sm.handleEvent('see'); + return true; + } + return false; + } + + /** Переход «услышал шум» (уровень >= порога); true — событие отправлено. */ + private checkNoise(senses: EnemySenses): boolean { + const noise = senses.noise; + if (noise && noise.level >= NOISE_HEAR_LEVEL) { + this.waryTarget = { ...noise.origin }; + this.waryTimer = this.kind.warySec ?? 2.5; + this.sm.handleEvent('noise'); + return true; + } + return false; + } + + /** Нормализованное направление к точке в out; false — точка достигнута (ARRIVE_DIST). */ + private dirInto(out: Vec2, from: Vec2 | undefined, target: Vec2): boolean { + if (!from) return false; + const dx = target.x - from.x; + const dy = target.y - from.y; + const d = Math.hypot(dx, dy); + if (d < ARRIVE_DIST) return false; + out.x = dx / d; + out.y = dy / d; + return true; + } + + /** move-намерение с копией направления из dir (общий intent.dir мутируется). */ + private moveIntentFrom(dir: Vec2, run: boolean): EnemyIntent { + const intent = run ? INTENT_MOVE_RUN : INTENT_MOVE; + intent.dir.x = dir.x; + intent.dir.y = dir.y; + return intent; + } + + private advancePatrolIndex(count: number): void { + if (count < 2) return; + if (this.patrolIndex + this.patrolDir >= count || this.patrolIndex + this.patrolDir < 0) { + this.patrolDir = -this.patrolDir; + } + this.patrolIndex += this.patrolDir; + } +} + +/** Обратный вектор в out (без аллокации — вызывается в тике). */ +function negate(out: Vec2, v: Vec2): Vec2 { + out.x = -v.x; + out.y = -v.y; + return out; +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/HealthBar.ts b/v1/apps/game/src/systems/combat/HealthBar.ts new file mode 100644 index 0000000..58bf33e --- /dev/null +++ b/v1/apps/game/src/systems/combat/HealthBar.ts @@ -0,0 +1,50 @@ +import { Container, Graphics } from '@rpg/engine'; +import { PLAYER_COMBAT } from './stats'; + +/** + * Полоса здоровья героя: сердечки в левом верхнем углу (uiRoot). + */ +export class HealthBar extends Container { + private hearts: Graphics[] = []; + /** Прошлое значение hp — не перерисовываем при том же (тик идёт каждый кадр). */ + private lastHp: number = PLAYER_COMBAT.maxHp; + + constructor() { + super(); + for (let i = 0; i < PLAYER_COMBAT.maxHp; i++) { + const h = new Graphics(); + h.position.set(8 + i * 11, 16); + this.addChild(h); + this.hearts.push(h); + } + this.draw(PLAYER_COMBAT.maxHp); + } + + /** Обновить по текущему HP. */ + setHp(hp: number): void { + if (hp === this.lastHp) return; + this.lastHp = hp; + this.draw(hp); + } + + private draw(hp: number): void { + this.hearts.forEach((h, i) => { + h.clear(); + // Пиксельное сердечко 7x6 из прямоугольников + const color = i < hp ? 0xb0453f : 0x2a2a33; + const border = i < hp ? 0x7a2f2b : 0x1c1c24; + const px = (x: number, y: number, w: number, hh: number, c: number): void => { + h.rect(x, y, w, hh).fill(c); + }; + px(-3, -2, 6, 4, border); // тело + px(-2, 2, 4, 1, border); + px(-3, -2, 2, 2, color); + px(1, -2, 2, 2, color); + px(-2, -1, 4, 2, color); + px(-2, 1, 2, 1, color); + px(0, 1, 1, 1, color); + px(-3, -3, 2, 1, color); // горбики + px(1, -3, 2, 1, color); + }); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/PlayerCombat.ts b/v1/apps/game/src/systems/combat/PlayerCombat.ts new file mode 100644 index 0000000..741bf79 --- /dev/null +++ b/v1/apps/game/src/systems/combat/PlayerCombat.ts @@ -0,0 +1,80 @@ +import { Cooldown } from '@rpg/engine'; +import { PLAYER_COMBAT } from './stats'; + +/** + * Боевое состояние героя: HP, неуязвимость, кулдауны, заряд резонанса. + * Чистая логика (без Pixi) — тестируется в Vitest. + */ +export class PlayerCombat { + hp: number; + /** Секунды удержания атаки (заряд низкого резонанса). */ + chargeTime = 0; + private invulnTimer = 0; + /** Кулдаун короткого удара. */ + readonly attackCd = new Cooldown(PLAYER_COMBAT.coneCooldown); + /** Кулдаун низкого резонанса. */ + readonly resonanceCd = new Cooldown(PLAYER_COMBAT.resonanceCooldown); + + constructor(hp: number = PLAYER_COMBAT.maxHp) { + this.hp = Math.min(hp, PLAYER_COMBAT.maxHp); + } + + update(dt: number): void { + if (this.invulnTimer > 0) this.invulnTimer = Math.max(0, this.invulnTimer - dt); + this.attackCd.update(dt); + this.resonanceCd.update(dt); + if (this.isCharging()) this.chargeTime += dt; + } + + /** Атака удерживается (Space зажат) — копим заряд резонанса. */ + startCharge(): void { + if (this.chargeTime === 0) this.chargeTime = 0.0001; // старт отсчёта + } + + isCharging(): boolean { + return this.chargeTime > 0; + } + + /** + * Отпустил: если зарядился до резонанса и кулдаун готов — вернуть 'resonance', + * иначе — обычный удар (если готов). Кулдауны триггерятся здесь. + */ + release(): 'resonance' | 'attack' | null { + const charged = this.chargeTime >= PLAYER_COMBAT.resonanceCharge; + this.chargeTime = 0; + if (charged) { + if (this.resonanceCd.trigger()) return 'resonance'; + return null; // резонанс ещё перезаряжается — молча + } + return this.attackCd.trigger() ? 'attack' : null; + } + + /** + * Получить урон. Возвращает фактически применённый урон (0 — неуязвим). + */ + takeDamage(damage: number): number { + if (this.invulnTimer > 0 || this.hp <= 0) return 0; + const applied = Math.min(damage, this.hp); + this.hp -= applied; + this.invulnTimer = PLAYER_COMBAT.invulnTime; + return applied; + } + + /** Неуязвим (мигание спрайта в этот период). */ + get invuln(): boolean { + return this.invulnTimer > 0; + } + + get dead(): boolean { + return this.hp <= 0; + } + + /** Восстановление после смерти (респаун). */ + revive(): void { + this.hp = PLAYER_COMBAT.maxHp; + this.invulnTimer = PLAYER_COMBAT.invulnTime; + this.chargeTime = 0; + this.attackCd.reset(); + this.resonanceCd.reset(); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts b/v1/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts new file mode 100644 index 0000000..d0aa8c5 --- /dev/null +++ b/v1/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest'; +import { ENEMY_KINDS, withDefaults } from '../../../data/enemies'; +import { EnemyBrain, NOISE_HEAR_LEVEL, NOISE_WAKE_LEVEL, type EnemySenses } from '../EnemyBrain'; +import { far, fakeWorld, run } from '../../../testing/combatWorld'; + +/** Патрульные точки в юнитах. */ +const points = [ + { x: 10.5, y: 7.5 }, + { x: 12.5, y: 9.5 } +]; + +describe('EnemyBrain: патруль', () => { + it('с патрулём стартует бодрым в patrol, без — спит в пепле', () => { + const patrolled = new EnemyBrain(withDefaults(ENEMY_KINDS.crawler), { points, pauseSec: 1 }); + expect(patrolled.state).toBe('patrol'); + expect(patrolled.asleep).toBe(false); + const sleeper = new EnemyBrain(ENEMY_KINDS.crawler); + expect(sleeper.state).toBe('dormant'); + expect(sleeper.asleep).toBe(true); + }); + + it('дошёл до точки — пауза, потом идёт к следующей (ping-pong)', () => { + const b = new EnemyBrain(ENEMY_KINDS.heavy, { points, pauseSec: 1 }); + // Стоим на точке 0 — сразу пауза + expect(b.update(1 / 60, far(points[0]!)).type).toBe('idle'); + run(b, 1.1, far(points[0]!)); + // Пауза истекла — движение к точке 1 (вниз-вправо, диагональ изометрии) + const move = run(b, 0.05, far(points[0]!)); + expect(move.type).toBe('move'); + if (move.type === 'move') { + expect(move.dir.x).toBeCloseTo(0.707, 1); + expect(move.dir.y).toBeCloseTo(0.707, 1); + expect(move.run).toBeUndefined(); // патруль шагом, не бегом + } + // Дошли до точки 1 — снова пауза и разворот назад + run(b, 3.2, far(points[1]!)); + const back = run(b, 0.05, far(points[1]!)); + expect(back.type).toBe('move'); + if (back.type === 'move') expect(back.dir.x).toBeLessThan(0); + }); + + it('шум уровня 0.35 вырывает из патруля в настороженность', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 }); + const pos = { x: 10.5, y: 7.5 }; + b.update(1 / 60, { ...far(pos), noise: { origin: { x: 11.5, y: 8.5 }, level: NOISE_HEAR_LEVEL } }); + expect(b.state).toBe('wary'); + // Идёт к источнику шума + const move = b.update(1 / 60, { ...far(pos), noise: null }); + expect(move.type).toBe('move'); + }); + + it('тихий шум (ниже порога) не настораживает', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 }); + b.update(1 / 60, { ...far(points[0]!), noise: { origin: { x: 11, y: 8 }, level: 0.2 } }); + expect(b.state).toBe('patrol'); + }); +}); + +describe('EnemyBrain: настороженность', () => { + it('увидел героя в aggroRange — погоня', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 }); + const pos = { x: 10.5, y: 7.5 }; + b.update(1 / 60, { ...far(pos), noise: { origin: pos, level: 0.5 } }); + expect(b.state).toBe('wary'); + b.update(1 / 60, { dist: 3, dirToPlayer: { x: 1, y: 0 }, pos, canSee: true }); + expect(b.state).toBe('chase'); + }); + + it('герой не показался за warySec — возврат к дому', () => { + const b = new EnemyBrain(ENEMY_KINDS.heavy, { points, pauseSec: 1 }); // warySec 3 + const pos = { x: 10.5, y: 7.5 }; + b.update(1 / 60, { ...far(pos), noise: { origin: { x: 11, y: 8 }, level: 0.5 } }); + run(b, 1, { ...far({ x: 11, y: 8 }), noise: null }); + expect(b.state).toBe('wary'); + run(b, 2.2, { ...far({ x: 11, y: 8 }), noise: null }); + expect(b.state).toBe('return'); + // Идёт к дому и на месте — снова в патруль + run(b, 3, far({ x: 10.7, y: 7.7 })); + expect(b.state).toBe('patrol'); + }); +}); + +describe('EnemyBrain: погоня и отступление', () => { + it('потерял героя из виду дольше deaggroSec — возврат', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + b.hearLoud(); + run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, y: 0 } }); + expect(b.state).toBe('chase'); + // Герой скрылся: 2.5 с без LOS — и враг уходит домой (позицию двигаем руками) + const pos = { x: 10, y: 10 }; + for (let i = 0; i < Math.round(2.6 * 60); i++) { + b.update(1 / 60, { dist: 3, dirToPlayer: { x: 1, y: 0 }, canSee: false, pos, home: { x: 5, y: 5 } }); + pos.x -= 0.007; + } + expect(b.state).toBe('return'); + }); + + it('герой дальше 1.5 aggro — не гоняется по всей карте', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + b.hearLoud(); + run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, y: 0 } }); + run(b, 0.1, { dist: 7, dirToPlayer: { x: 1, y: 0 } }); // 1.5 * aggro 4 = 6 + expect(b.state).toBe('return'); + }); + + it('мало hp — бежит прочь бегом, оторвался — успокоился', () => { + const b = new EnemyBrain(withDefaults(ENEMY_KINDS.heavy)); // fleeBelowHpFraction 0.35 + const chase: EnemySenses = { dist: 1, dirToPlayer: { x: 1, y: 0 } }; + b.hearLoud(); + run(b, 0.7, chase); + run(b, 0.1, { ...chase, hpFraction: 0.3 }); + expect(b.state).toBe('flee'); + const flee = b.update(1 / 60, { ...chase, hpFraction: 0.3 }); + expect(flee.type).toBe('move'); + if (flee.type === 'move') { + expect(flee.dir.x).toBe(-1); // прочь от героя + expect(flee.run).toBe(true); + } + // Герой отстал на fleeRange (4.5) — отбой в настороженность + run(b, 0.1, { dist: 4.6, dirToPlayer: { x: 1, y: 0 }, hpFraction: 0.3 }); + expect(b.state).toBe('wary'); + }); + + it('возврат без патруля — дошёл до дома и снова спит', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + b.hearLoud(); + run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, y: 0 } }); + run(b, 2.6, { dist: 3, dirToPlayer: { x: 1, y: 0 }, canSee: false, home: { x: 5, y: 5 } }); + run(b, 5, { dist: 30, dirToPlayer: { x: 1, y: 0 }, canSee: false, pos: { x: 4.8, y: 4.8 }, home: { x: 5, y: 5 } }); + expect(b.state).toBe('dormant'); + expect(b.asleep).toBe(true); + }); +}); + +// ---------- CombatWorld: пороги шума ---------- + +describe('CombatWorld: шум будит только от гулкого', () => { + it('шаги героя (0.35) не будят спящего, гулкий звон (1.0) будит', () => { + const hero = { x: 20, y: 20 }; + const w = fakeWorld(hero); + const e = w.spawnEnemy('crawler', { x: 10.5, y: 10.5 }); + const en = w.enemies.get(e)!; + expect(en.brain.asleep).toBe(true); + + w.noise({ x: 10.5, y: 10.5 }, 0.35); // шаги прямо рядом + w.update(1 / 60); + expect(en.brain.asleep).toBe(true); + + w.noise({ x: 10.5, y: 10.5 }, NOISE_WAKE_LEVEL); // гулкий удар + expect(en.brain.asleep).toBe(false); + expect(en.brain.state).toBe('rise'); + }); + + it('бодрый патрульный слышит тихий шум в hearRadius и настораживается', () => { + const hero = { x: 20, y: 20 }; + const w = fakeWorld(hero); + const e = w.spawnEnemy('heavy', { x: 10.5, y: 10.5 }, { + patrol: { points: [{ x: 10, y: 10 }, { x: 12, y: 12 }], pauseSec: 1 } + }); + const en = w.enemies.get(e)!; + expect(en.brain.state).toBe('patrol'); + // Шаги героя в 1 юните — в hearRadius 4 + w.noise({ x: 11.5, y: 10.5 }, 0.35); + w.update(1 / 60); + expect(en.brain.state).toBe('wary'); + }); + + it('шум вне радиуса слуха не слышен', () => { + const hero = { x: 20, y: 20 }; + const w = fakeWorld(hero); + const e = w.spawnEnemy('crawler', { x: 10.5, y: 10.5 }, { + patrol: { points: [{ x: 10, y: 10 }, { x: 12, y: 12 }], pauseSec: 1 } + }); + const en = w.enemies.get(e)!; + // hearRadius ползуна 3.5, шум в 20 юнитах + w.noise({ x: 18.5, y: 10.5 }, 0.5); + w.update(1 / 60); + expect(en.brain.state).toBe('patrol'); + }); + + it('инварианты боевого мира пусты на честном ИИ', () => { + const hero = { x: 20, y: 20 }; + const w = fakeWorld(hero); + w.spawnEnemy('crawler', { x: 10.5, y: 10.5 }, { + patrol: { points: [{ x: 10, y: 10 }, { x: 12, y: 12 }], pauseSec: 1 } + }); + for (let i = 0; i < 600; i++) w.update(1 / 60); + expect(w.agentInvariants()).toEqual([]); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts b/v1/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts new file mode 100644 index 0000000..8d448e1 --- /dev/null +++ b/v1/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { ENEMY_KINDS } from '../../../data/enemies'; +import { EnemyBrain, type EnemySenses } from '../EnemyBrain'; + +/** Тик ИИ на 1/60 с; возвращает последнее намерение. */ +const run = (b: EnemyBrain, seconds: number, senses: EnemySenses): ReturnType => { + let intent: ReturnType = { type: 'idle' }; + for (let i = 0; i < Math.round(seconds * 60); i++) intent = b.update(1 / 60, senses); + return intent; +}; + +/** Прогон с ловлей конкретного намерения (strike/shoot выдаются одним тиком). */ +const sawIntent = (b: EnemyBrain, seconds: number, senses: EnemySenses, type: string): boolean => { + for (let i = 0; i < Math.round(seconds * 60); i++) { + if (b.update(1 / 60, senses).type === type) return true; + } + return false; +}; + +const away: EnemySenses = { dist: 3.1, dirToPlayer: { x: 0, y: 1 } }; +const close: EnemySenses = { dist: 0.25, dirToPlayer: { x: 1, y: 0 } }; + +/** Разбудить и дождаться погони (спит -> rise 0.6 с -> chase). */ +const wakeUp = (b: EnemyBrain, senses: EnemySenses): void => { + b.hearLoud(); + run(b, 0.7, senses); +}; + +describe('EnemyBrain (ползун)', () => { + it('стартует спящим в пепле и не двигается', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + expect(b.asleep).toBe(true); + expect(b.update(1 / 60, close).type).toBe('idle'); + }); + + it('гулкий звук поднимает: rise -> chase', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + b.hearLoud(); + expect(b.state).toBe('rise'); + run(b, 0.7, away); + expect(b.state).toBe('chase'); + // Вдали от героя — движение к нему + expect(run(b, 0.05, away)).toEqual({ type: 'move', dir: { x: 0, y: 1 } }); + }); + + it('в радиусе атаки бьёт контактом по кулдауну', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + wakeUp(b, away); + expect(sawIntent(b, 0.1, close, 'strike')).toBe(true); + // Кулдаун: в следующее окно удара нет, после — снова есть + expect(sawIntent(b, 0.1, close, 'strike')).toBe(false); + run(b, ENEMY_KINDS.crawler.attackCooldown, away); + expect(sawIntent(b, 0.1, close, 'strike')).toBe(true); + }); + + it('урон станит и возвращает в погоню', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + wakeUp(b, away); + b.hurt(); + expect(b.state).toBe('hurt'); + expect(b.update(1 / 60, away).type).toBe('idle'); + run(b, 0.3, away); + expect(b.state).toBe('chase'); + }); + + it('резонанс усыпляет на заданное время, потом просыпается', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + wakeUp(b, away); + b.putToSleep(4); + expect(b.asleep).toBe(true); + expect(run(b, 2, away).type).toBe('idle'); + expect(b.asleep).toBe(true); + run(b, 2.2, away); + expect(b.state).toBe('rise'); // проснулся — сначала подъём + run(b, 0.7, away); + expect(b.state).toBe('chase'); + expect(b.asleep).toBe(false); + }); + + it('смерть окончательна: повторный die возвращает false', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + expect(b.die()).toBe(true); + expect(b.dead).toBe(true); + expect(b.die()).toBe(false); + // Урон и сон мёртвого не трогают + b.hurt(); + b.putToSleep(4); + expect(b.dead).toBe(true); + }); +}); + +describe('EnemyBrain (наст)', () => { + it('перед ударом есть замах 0.6 с', () => { + const b = new EnemyBrain(ENEMY_KINDS.heavy); + wakeUp(b, away); + // Сразу — только замах, урона нет + run(b, 0.05, close); + expect(b.state).toBe('windup'); + run(b, 0.3, close); + expect(b.state).toBe('windup'); + // Через 0.6 с замаха — удар и фаза атаки + run(b, 0.35, close); + expect(b.state).toBe('attack'); + expect(sawIntent(b, 0.05, close, 'strike')).toBe(false); // уже прошёл + }); +}); + +describe('EnemyBrain (плевун)', () => { + it('сближение — отходит, издали — идёт ближе', () => { + const b = new EnemyBrain(ENEMY_KINDS.spitter); + wakeUp(b, { dist: 3.1, dirToPlayer: { x: 1, y: 0 } }); + expect(b.state).toBe('chase'); + // Слишком близко к keepDistance — пятится + const back = run(b, 0.05, { dist: 1.3, dirToPlayer: { x: 1, y: 0 } }); + expect(back.type).toBe('move'); + if (back.type === 'move') expect(back.dir.x).toBeLessThan(0); + // Слишком далеко — подходит + const toward = run(b, 0.05, { dist: 3.8, dirToPlayer: { x: 0, y: 1 } }); + expect(toward.type).toBe('move'); + if (toward.type === 'move') expect(toward.dir.y).toBeGreaterThan(0); + }); + + it('на дистанции атаки — замах и плевок', () => { + const b = new EnemyBrain(ENEMY_KINDS.spitter); + wakeUp(b, { dist: 100, dirToPlayer: { x: 1, y: 0 } }); + const mid: EnemySenses = { dist: 2.5, dirToPlayer: { x: 1, y: 0 } }; + // Входим в радиус атаки — замах + run(b, 0.05, mid); + expect(b.state).toBe('windup'); + // Через windup — плевок (ловим намерение в тик срабатывания) + expect(sawIntent(b, 0.45, mid, 'shoot')).toBe(true); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/__tests__/PlayerCombat.test.ts b/v1/apps/game/src/systems/combat/__tests__/PlayerCombat.test.ts new file mode 100644 index 0000000..bdf693b --- /dev/null +++ b/v1/apps/game/src/systems/combat/__tests__/PlayerCombat.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { PLAYER_COMBAT } from '../stats'; +import { PlayerCombat } from '../PlayerCombat'; + +/** Тик на 1/60 с — как в игровом цикле. */ +const step = (c: PlayerCombat, seconds: number): void => { + for (let i = 0; i < Math.round(seconds * 60); i++) c.update(1 / 60); +}; + +describe('PlayerCombat', () => { + it('стартует с полным здоровьем', () => { + const c = new PlayerCombat(); + expect(c.hp).toBe(PLAYER_COMBAT.maxHp); + expect(c.dead).toBe(false); + expect(c.invuln).toBe(false); + }); + + it('урон не превышает остаток HP, после него неуязвимость', () => { + const c = new PlayerCombat(); + expect(c.takeDamage(3)).toBe(3); + expect(c.hp).toBe(2); + expect(c.invuln).toBe(true); + // В окно неуязвимости урон не проходит + expect(c.takeDamage(1)).toBe(0); + expect(c.hp).toBe(2); + }); + + it('смерть при HP 0; revive возвращает полные сердечки', () => { + const c = new PlayerCombat(); + c.takeDamage(PLAYER_COMBAT.maxHp); + expect(c.dead).toBe(true); + c.revive(); + expect(c.hp).toBe(PLAYER_COMBAT.maxHp); + expect(c.dead).toBe(false); + expect(c.invuln).toBe(true); + }); + + it('короткий тап — удар по кулдауну', () => { + const c = new PlayerCombat(); + c.startCharge(); + step(c, 0.1); + expect(c.isCharging()).toBe(true); + expect(c.release()).toBe('attack'); + // Кулдаун ещё не истёк — второй удар не проходит + c.startCharge(); + step(c, 0.1); + expect(c.release()).toBeNull(); + // После кулдауна снова готов + step(c, PLAYER_COMBAT.coneCooldown); + c.startCharge(); + step(c, 0.1); + expect(c.release()).toBe('attack'); + }); + + it('удержание дольше resonanceCharge — резонанс по своему кулдауну', () => { + const c = new PlayerCombat(); + c.startCharge(); + step(c, PLAYER_COMBAT.resonanceCharge + 0.1); + expect(c.release()).toBe('resonance'); + // Резонанс перезаряжается — повторный заряд молча отклоняется + c.startCharge(); + step(c, PLAYER_COMBAT.resonanceCharge + 0.1); + expect(c.release()).toBeNull(); + step(c, PLAYER_COMBAT.resonanceCooldown); + c.startCharge(); + step(c, PLAYER_COMBAT.resonanceCharge + 0.1); + expect(c.release()).toBe('resonance'); + }); + + it('неуязвимость спадает со временем', () => { + const c = new PlayerCombat(); + c.takeDamage(1); + step(c, PLAYER_COMBAT.invulnTime + 0.05); + expect(c.invuln).toBe(false); + expect(c.takeDamage(1)).toBe(1); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/__tests__/los.test.ts b/v1/apps/game/src/systems/combat/__tests__/los.test.ts new file mode 100644 index 0000000..0502d96 --- /dev/null +++ b/v1/apps/game/src/systems/combat/__tests__/los.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { hasLineOfSight, tileOpaque } from '../los'; +import { buildMeadowsMap } from '../../../data/map'; +import { mapWithWall } from '../../../testing/maps'; + +describe('hasLineOfSight', () => { + const opaque = tileOpaque({ data: mapWithWall() }); + + it('свободная линия видна', () => { + expect(hasLineOfSight({ x: 0.5, y: 0.5 }, { x: 4.5, y: 0.5 }, opaque)).toBe(true); + }); + + it('стена в середине перекрывает обзор', () => { + expect(hasLineOfSight({ x: 0.5, y: 5.5 }, { x: 9.5, y: 5.5 }, opaque)).toBe(false); + }); + + it('линия мимо стены видит', () => { + expect(hasLineOfSight({ x: 0.5, y: 0.5 }, { x: 9.5, y: 4.5 }, opaque)).toBe(true); + }); + + it('короткие отрезки и нулевая длина видимы', () => { + expect(hasLineOfSight({ x: 3, y: 3 }, { x: 3.2, y: 3 }, opaque)).toBe(true); + expect(hasLineOfSight({ x: 3, y: 3 }, { x: 3, y: 3 }, opaque)).toBe(true); + }); + + it('вне карты — непрозрачно', () => { + expect(hasLineOfSight({ x: 9.5, y: 5.5 }, { x: 15.5, y: 5.5 }, opaque)).toBe(false); + }); +}); + +describe('tileOpaque на реальной карте', () => { + it('дерево/дом непрозрачны, трава/тропа/вода прозрачны', () => { + const data = buildMeadowsMap(); + const map = { data }; + const is = tileOpaque(map); + // Дерево и воду ищем на карте: раскладка seeded, но не привязываем тест к координате. + let tree: { x: number; y: number } | null = null; + let water: { x: number; y: number } | null = null; + for (let y = 0; y < data.height; y++) { + for (let x = 0; x < data.width; x++) { + const t = data.tiles[y * data.width + x]; + if (t === 3 && !tree) tree = { x, y }; + if (t === 2 && !water) water = { x, y }; + } + } + expect(tree).not.toBeNull(); + expect(water).not.toBeNull(); + expect(is(tree!.x, tree!.y)).toBe(true); // дерево + expect(is(16, 28)).toBe(false); // трава у спавна + expect(is(water!.x, water!.y)).toBe(false); // вода прозрачна + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/__tests__/stats.test.ts b/v1/apps/game/src/systems/combat/__tests__/stats.test.ts new file mode 100644 index 0000000..fa4c4b3 --- /dev/null +++ b/v1/apps/game/src/systems/combat/__tests__/stats.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { PLAYER_COMBAT } from '../stats'; +import { ENEMY_KINDS } from '../../../data/enemies'; + +/** + * Таблица баланса боевой модели демо-среза (согласована с планом и docs/world.md): + * удар — «гулкий звон» (будит пепел), резонанс — «низкий звон» (усыпляет). + */ +describe('PLAYER_COMBAT — таблица баланса', () => { + it('герой: 5 сердечек, окно неуязвимости 0.8 с', () => { + expect(PLAYER_COMBAT.maxHp).toBe(5); + expect(PLAYER_COMBAT.invulnTime).toBeCloseTo(0.8); + expect(PLAYER_COMBAT.knockback).toBe(0.75); + }); + + it('короткий удар: конус 1.0 юнита, 55°, урон 2, кулдаун 0.45 с', () => { + expect(PLAYER_COMBAT.coneRange).toBe(1.0); + expect((PLAYER_COMBAT.coneHalfAngle * 180) / Math.PI).toBeCloseTo(55); + expect(PLAYER_COMBAT.coneDamage).toBe(2); + expect(PLAYER_COMBAT.coneCooldown).toBeCloseTo(0.45); + }); + + it('резонанс: заряд 0.6 с, радиус 3.0, сон 4 с, кулдаун 3 с', () => { + expect(PLAYER_COMBAT.resonanceCharge).toBeCloseTo(0.6); + expect(PLAYER_COMBAT.resonanceRange).toBe(3.0); + expect(PLAYER_COMBAT.resonanceSleep).toBe(4); + expect(PLAYER_COMBAT.resonanceCooldown).toBe(3); + }); + + it('гулкий удар будит спящих в радиусе 4.5 юнитов', () => { + expect(PLAYER_COMBAT.wakeRadius).toBe(4.5); + }); + + it('ползун: слабый, но быстрый, контактный', () => { + const c = ENEMY_KINDS.crawler; + expect(c.hp).toBe(3); + expect(c.attackDamage).toBe(1); + expect(c.windup).toBe(0); + // Убивается одним коротким ударом (урон 2) не должен: нужно 2 удара + expect(Math.ceil(c.hp / PLAYER_COMBAT.coneDamage)).toBe(2); + }); + + it('плевун: хрупкий стрелок, держит дистанцию', () => { + const s = ENEMY_KINDS.spitter; + expect(s.hp).toBe(2); + expect(s.keepDistance).toBeGreaterThan(PLAYER_COMBAT.coneRange); + expect(s.attackRange).toBeGreaterThan(s.keepDistance ?? 0); + expect(s.windup).toBeGreaterThan(0); + }); + + it('наст: танк с тяжёлым замахом и уроном 2', () => { + const h = ENEMY_KINDS.heavy; + expect(h.hp).toBe(8); + expect(h.attackDamage).toBe(2); + expect(h.windup).toBeGreaterThanOrEqual(0.6); + // Четыре коротких удара (урон 2) убивают + expect(Math.ceil(h.hp / PLAYER_COMBAT.coneDamage)).toBe(4); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/los.ts b/v1/apps/game/src/systems/combat/los.ts new file mode 100644 index 0000000..cf7b99a --- /dev/null +++ b/v1/apps/game/src/systems/combat/los.ts @@ -0,0 +1,54 @@ +import type { Vec2 } from '@rpg/engine'; + +/** + * Линия видимости по сетке (чистая математика, без Pixi). + * Отрезок дискретизируется шагом 0.25 юнита; тайл с непрозрачным объектом + * (стена, дом, башня, дерево) перекрывает обзор. Не в движке: правила + * прозрачности зависят от игры, второй пользователь не виден. + */ + +/** Шаг дискретизации отрезка, юнитов. */ +const STEP = 0.25; + +/** Виден ли `to` из `from` с учётом непрозрачных тайлов. */ +export function hasLineOfSight( + from: Vec2, + to: Vec2, + isOpaque: (tx: number, ty: number) => boolean +): boolean { + const dx = to.x - from.x; + const dy = to.y - from.y; + const len = Math.hypot(dx, dy); + if (len < 1e-6) return true; + const n = Math.ceil(len / STEP); + const ux = dx / n; + const uy = dy / n; + // Концевые точки не проверяем: сами враг/герой стоят в своих тайлах. + for (let i = 1; i < n; i++) { + const x = from.x + ux * i; + const y = from.y + uy * i; + if (isOpaque(Math.floor(x), Math.floor(y))) return false; + } + return true; +} + +/** + * Непрозрачен ли тайл карты: высокий объект (TallSpec) или footprint пропа + * (дом) перекрывает взгляд. Вода и ровный пепел прозрачны — сгустки видят + * через низину. extra — дополнительные непрозрачные тайлы (правила игры). + */ +export function tileOpaque( + map: { + isPropBlocked?: (tx: number, ty: number) => boolean; + data: { width: number; height: number; tiles: number[]; tall?: Record }; + }, + extra?: (tx: number, ty: number) => boolean +): (tx: number, ty: number) => boolean { + return (tx: number, ty: number): boolean => { + const { width, height, tiles, tall } = map.data; + if (tx < 0 || ty < 0 || tx >= width || ty >= height) return true; + if (map.isPropBlocked?.(tx, ty)) return true; + if (extra?.(tx, ty)) return true; + return tall?.[tiles[ty * width + tx]] !== undefined; + }; +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/combat/stats.ts b/v1/apps/game/src/systems/combat/stats.ts new file mode 100644 index 0000000..0a1b63f --- /dev/null +++ b/v1/apps/game/src/systems/combat/stats.ts @@ -0,0 +1,31 @@ +/** + * Числовые константы боя героя — чистые данные без Pixi (тестируются). + * Позиции, дистанции и скорости — в мировых юнитах (1 юнит = 1 тайл; + * на экране 1 юнит = tileW px по линейке проекции). + * Согласованы с боевой моделью демо-среза и сеттингом: + * удар — «гулкий звон» (будит пепел), резонанс — «низкий звон» (усыпляет). + */ + +export const PLAYER_COMBAT = { + /** Здоровье героя (сердечки). */ + maxHp: 5, + /** Радиус тела героя (юниты; < 0.5 — путь A* по центрам остаётся проходим). */ + radius: 0.35, + /** Конус удара: дальность, полуугол (рад), урон, кулдаун. */ + coneRange: 1.0, + coneHalfAngle: (55 * Math.PI) / 180, + coneDamage: 2, + coneCooldown: 0.45, + /** В зоне удара стоим ближе, чем coneRange (гистерезис авто-подхода). */ + attackStop: 0.85, + /** Низкий резонанс: сколько держать заряд, радиус, сон врага, кулдаун. */ + resonanceCharge: 0.6, + resonanceRange: 3.0, + resonanceSleep: 4, + resonanceCooldown: 3, + /** Неуязвимость после урона, сек; отброс, юнитов. */ + invulnTime: 0.8, + knockback: 0.75, + /** Гулкий удар излучает звук: будит сгустков в этом радиусе. */ + wakeRadius: 4.5 +} as const; diff --git a/v1/apps/game/src/systems/cutscene/CutsceneActors.ts b/v1/apps/game/src/systems/cutscene/CutsceneActors.ts new file mode 100644 index 0000000..e6b51e1 --- /dev/null +++ b/v1/apps/game/src/systems/cutscene/CutsceneActors.ts @@ -0,0 +1,110 @@ +import { + findPath, + tileToWorld, + worldToScreen, + worldToTile, + type Container, + type CutsceneStep, + type IsometricTileMap, + type Vec2 +} from '@rpg/engine'; + +/** + * Актёры кат-сцен: ходок по A*-пути для вьюхи NPC и фабрики шагов. + * Мир на время сцены на паузе — ходок без боя/расталкивания, тикается + * сценой независимо от раннера (исполнитель шага until). + */ + +/** Скорость ходьбы актёра по умолчанию (юниты/сек) — чуть медленнее героя. */ +const WALKER_SPEED = 1.4; + +/** Таймаут ожидания ходьбы по умолчанию (сек) — страховка скипа/зависания. */ +const WALK_TIMEOUT = 30; + +/** + * Ходок: ведёт вьюху по пути A* в мировых юнитах, синхронизирует вью + * (округление до px — единственная точка мир→экран, как у героя). + */ +export class ActorWalker { + private pos: Vec2; + private waypoints: Vec2[] = []; + + constructor( + private map: IsometricTileMap, + private view: Container, + startTile: { x: number; y: number }, + /** Смена тайла (для depth-слоя сцены). */ + private onTile?: (tile: Vec2) => void, + private speed = WALKER_SPEED + ) { + this.pos = tileToWorld(startTile.x, startTile.y); + } + + /** Идёт ли сейчас (шаг until должен ждать). */ + get done(): boolean { + return this.waypoints.length === 0; + } + + get position(): Vec2 { + return { x: this.pos.x, y: this.pos.y }; + } + + /** Построить путь к тайлу и пойти. Непроходимый тайл — остаётся на месте. */ + walkTo(tile: { x: number; y: number }): void { + const from = worldToTileClamped(this.pos, this.map); + const path = findPath(this.map, from, tile, false); + this.waypoints = path ? path.map((t) => tileToWorld(t.x, t.y)) : []; + } + + update(dt: number): void { + if (this.waypoints.length === 0) return; + const target = this.waypoints[0]!; + const dx = target.x - this.pos.x; + const dy = target.y - this.pos.y; + const dist = Math.hypot(dx, dy); + const step = this.speed * dt; + let crossedTile = false; + if (dist <= step) { + this.pos = { x: target.x, y: target.y }; + this.waypoints.shift(); + crossedTile = true; + } else { + this.pos = { x: this.pos.x + (dx / dist) * step, y: this.pos.y + (dy / dist) * step }; + } + this.syncView(); + if (crossedTile) this.onTile?.(worldToTileClamped(this.pos, this.map)); + } + + /** Вью-позиция: округление до px (вьюха лежит в worldRoot — мировые px). */ + private syncView(): void { + const s = worldToScreen(this.pos.x, this.pos.y); + this.view.position.set(Math.round(s.x), Math.round(s.y)); + } +} + +/** Тайл под позицией (вне карты — ближайший краевой, как у героя). */ +function worldToTileClamped(pos: Vec2, map: IsometricTileMap): Vec2 { + const w = map.data.width; + const h = map.data.height; + const t = worldToTile(pos.x, pos.y, w, h); + return ( + t ?? { + x: Math.min(w - 1, Math.max(0, Math.floor(pos.x))), + y: Math.min(h - 1, Math.max(0, Math.floor(pos.y))) + } + ); +} + +/** Шаги «актёр идёт к тайлу»: запуск + ожидание прихода (со страховкой). */ +export function npcWalkSteps(walker: ActorWalker, tile: Vec2, timeout = WALK_TIMEOUT): CutsceneStep[] { + return [ + { kind: 'call', fn: () => walker.walkTo(tile) }, + { kind: 'until', predicate: () => walker.done, label: `актёр к ${tile.x},${tile.y}`, timeout } + ]; +} + +/** Шаг «вьюха плавно уходит в мировую точку» (глайд без пути — по прямой). */ +export function npcMoveView(view: Container, toWorld: Vec2, seconds: number): CutsceneStep { + const p = worldToScreen(toWorld.x, toWorld.y); + return { kind: 'moveView', view, x: p.x, y: p.y, seconds }; +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/fauna/FaunaSystem.ts b/v1/apps/game/src/systems/fauna/FaunaSystem.ts new file mode 100644 index 0000000..bfe0a3c --- /dev/null +++ b/v1/apps/game/src/systems/fauna/FaunaSystem.ts @@ -0,0 +1,205 @@ +import { + Container, + Sprite, + SpriteAnimator, + StateMachine, + Texture, + moveTowardsW, + worldDist, + worldToScreen, + gridOf, + type EventBus, + type Grid, + type IsoDepthLayer, + type SceneRegistry, + type TileMapData, + type Vec2 +} from '@rpg/engine'; + +/** + * Пейзажная фауна (витрина StateMachine вне боя): безгласный олень. + * Стоит, медленно бродит по соседним тайлам; на громкий звон рядом + * подходит к источнику и стоит у колокольчиков. Не боевой. + */ + +/** Радиус слуха на звон (мировые юниты). */ +const HEAR_RADIUS = 7; +/** Скорость брожения / подхода (юниты/сек). */ +const WANDER_SPEED = 0.35; +const APPROACH_SPEED = 0.7; +/** Радиус тела оленя (юниты) — для телесных коллизий. */ +const FAUNA_RADIUS = 0.4; + +interface FaunaEntity { + /** Позиция (ноги) в мировых юнитах. */ + pos: Vec2; + machine: StateMachine; + /** Куда идём (если идём). */ + dest: Vec2 | null; + view: Container; + sprite: Sprite; + animator: SpriteAnimator; + /** Пауза перед следующим шагом (idle). */ + pause: number; +} + +export class FaunaSystem { + private entities: FaunaEntity[] = []; + private rng: () => number; + private off: () => void; + /** Проходимость карты (blocked-иды + пропы) — один адаптер на систему. */ + private grid: Grid; + + constructor( + private actors: IsoDepthLayer, + events: EventBus, + data: TileMapData, + spawns: Vec2[], + /** Реестр сцены (опция): олени регистрируются для «кто рядом». */ + private registry?: SceneRegistry, + seed = 20260908 + ) { + this.grid = gridOf(data); + // Детерминированный ГПСЧ — брожение одинаково между запусками. + let s = seed; + this.rng = () => { + s = (s + 0x6d2b79f5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + for (const p of spawns) this.spawn(p); + // Громкий звон рядом — олень подходит к источнику и стоит у колокольчиков. + this.off = events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => { + for (const e of this.entities) { + if (worldDist(e.pos, origin) <= HEAR_RADIUS) { + e.dest = { ...origin }; + e.machine.change('approach'); + // Олень, услышав звон, вскрикивает — звук вешает AudioSystem. + events.emit('fauna:startle', { pos: { ...e.pos } }); + } + } + }); + } + + /** Привязать кадры (вызывается сценой после конструктора). */ + setFrames(frames: [Texture, Texture]): void { + for (const e of this.entities) { + e.animator.setClipFrames('walk', frames); + e.animator.showStep(0); + } + } + + /** Заспавнить оленя в точке (юниты). */ + private spawn(pos: Vec2): void { + const view = new Container(); + const sprite = new Sprite(); + sprite.anchor.set(0.5, 1); + view.addChild(sprite); + const sp = worldToScreen(pos.x, pos.y); + view.position.set(sp.x, sp.y); + this.actors.add(view, Math.floor(pos.x), Math.floor(pos.y)); + + const e: FaunaEntity = { + pos: { ...pos }, + machine: new StateMachine(), + dest: null, + view, + sprite, + animator: new SpriteAnimator(sprite, { walk: { frames: [sprite.texture, sprite.texture], fps: 3 } }), + pause: 0 + }; + + // Стоит: через паузу делает шаг к соседнему тайлу. + e.machine.add('idle', { + update: () => { + if (e.machine.time >= e.pause) this.startWander(e); + } + }); + // Бродит: медленно к случайной соседней точке. + e.machine.add('wander', { + update: (dt) => this.stepTo(e, dt, WANDER_SPEED, 'idle') + }); + // Подходит на звон: быстрее, к источнику звука. + e.machine.add('approach', { + update: (dt) => this.stepTo(e, dt, APPROACH_SPEED, 'idle') + }); + e.machine.change('idle'); + e.pause = 1.5 + this.rng() * 3; + this.entities.push(e); + this.registry?.add({ + id: `fauna:${this.entities.length - 1}`, + kind: 'fauna', + pos: e.pos, + radius: FAUNA_RADIUS + }); + } + + /** Начать шаг к случайной соседней проходимой точке. */ + private startWander(e: FaunaEntity): void { + const dirs = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1] + ]; + const options = dirs + .map(([dx, dy]) => ({ x: e.pos.x + dx, y: e.pos.y + dy })) + .filter((p) => this.walkable(p)); + if (options.length === 0) { + e.pause = 2; + return; + } + e.dest = options[Math.floor(this.rng() * options.length)]!; + e.pause = 1.5 + this.rng() * 3; + e.machine.change('wander'); + } + + /** Движение к dest; по приходе — переход в idle. */ + private stepTo(e: FaunaEntity, dt: number, speed: number, back: string): void { + if (!e.dest) { + e.machine.change(back); + return; + } + e.pos = moveTowardsW(e.pos, e.dest, speed * dt); + // Прибытие с eps: расталкивание тел могло сместить существо. + if (Math.abs(e.pos.x - e.dest.x) < 1e-4 && Math.abs(e.pos.y - e.dest.y) < 1e-4) { + e.pos = { x: e.dest.x, y: e.dest.y }; // снап — тайл без дрейфа + e.dest = null; + e.machine.change(back); + } + } + + private walkable(p: Vec2): boolean { + const tx = Math.floor(p.x); + const ty = Math.floor(p.y); + return this.grid.isWalkable(tx, ty); + } + + update(dt: number): void { + for (const [i, e] of this.entities.entries()) { + e.machine.update(dt); + // Покадровая анимация: на ходу play, в покое — стоп-кадр 0. + if (e.dest) { + e.animator.resume(); + e.animator.play('walk'); + } else { + e.animator.showStep(0); + } + e.animator.update(dt); + this.registry?.move(`fauna:${i}`, e.pos); + const sp = worldToScreen(e.pos.x, e.pos.y); + e.view.position.set(sp.x, sp.y); + this.actors.setDepth(e.view, Math.floor(e.pos.x), Math.floor(e.pos.y)); + } + } + + exit(): void { + this.off?.(); + for (const e of this.entities) { + this.actors.removeChild(e.view); + e.view.destroy({ children: true }); + } + this.entities = []; + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/quest/FlowerQuest.ts b/v1/apps/game/src/systems/quest/FlowerQuest.ts new file mode 100644 index 0000000..19b0666 --- /dev/null +++ b/v1/apps/game/src/systems/quest/FlowerQuest.ts @@ -0,0 +1,122 @@ +import { CutsceneRunner, IsometricTileMap, tileToWorld, worldToScreen, type Vec2 } from '@rpg/engine'; +import { Game } from '../../Game'; +import { VARS } from '../../data/ids'; +import { TILES } from '../../data/map'; +import { QUEST_FLOWERS, QUEST_PLANT_TILES, QUEST_TOWER_GREEN, QUEST_TOWER_TILE } from '../../data/quests'; +import { playSfx } from '../../data/sfxSpecs'; + +/** + * Квест «Три цветка» (docs/world.md, акт 1): сбор лунных колокольчиков, + * посадка поляны у тропы и кат-сцена сдачи (колокол башни, зелень). + * Изменения тайлов идут через map.setTile с поддержанием индекса + * layerTilePos — свет по тайлам-источникам остаётся живым. + */ +export class FlowerQuest { + constructor( + private game: Game, + private map: IsometricTileMap, + /** Позиции тайлов-источников по id (свет/слои): меняем при setTile. */ + private layerTilePos: Map, + /** Экранный центр ромба тайла (с Origin карты). */ + private tileCenter: (tx: number, ty: number) => { x: number; y: number }, + /** Всплеск пеплинок в точке (CombatViews.hitBurst). */ + private burst: (s: { x: number; y: number }) => void, + private showToast: (text: string) => void, + private cutscene: CutsceneRunner + ) {} + + /** id тайла карты. */ + tileId(x: number, y: number): number { + return this.map.data.tiles[y * this.map.data.width + x]; + } + + /** Смена тайла с поддержанием индекса layerTilePos (свет по тайлам живой). */ + setTileTracked(x: number, y: number, id: number): void { + const prev = this.tileId(x, y); + this.map.setTile(x, y, id); + const from = this.layerTilePos.get(prev); + if (from) { + const i = from.findIndex((p) => p.x === x && p.y === y); + if (i >= 0) from.splice(i, 1); + if (from.length === 0) this.layerTilePos.delete(prev); + } + const list = this.layerTilePos.get(id) ?? []; + list.push({ x, y }); + this.layerTilePos.set(id, list); + } + + /** Сбор лунного колокольчика: тайл зеленеет, цветок — в сумку, прогресс — в vars. */ + collectFlower(x: number, y: number): void { + this.setTileTracked(x, y, TILES.GRASS); + this.game.inventory.add('bellflower'); + const n = this.game.state.getNumber(VARS.flowers) + 1; + this.game.state.setVar(VARS.flowers, n); + this.game.engine.events.emit('quest:flower', { n }); + playSfx(this.game.audio, 'sfx/bell_hit', 0.6); + this.burst(this.tileCenter(x, y)); + this.showToast(`Лунный колокольчик (${Math.min(n, QUEST_FLOWERS)}/${QUEST_FLOWERS})`); + } + + /** Посадка цветов у тропы: поляна гудит колокольчиками и разрастается. */ + plantFlowers(): void { + const left = this.game.state.getNumber(VARS.flowers) - QUEST_FLOWERS; + this.game.state.setVar(VARS.flowers, Math.max(0, left)); + for (const [tx, ty] of QUEST_PLANT_TILES) { + this.setTileTracked(tx, ty, TILES.BELLFLOWER); + this.burst(this.tileCenter(tx, ty)); + } + // Пересев: поляна расползается на соседние тайлы (якорь «надежда растёт»). + for (const [tx, ty] of QUEST_PLANT_TILES) { + for (const [dx, dy] of [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1] + ]) { + const id = this.tileId(tx + dx, ty + dy); + if (id === TILES.GRASS || id === TILES.ASH) this.setTileTracked(tx + dx, ty + dy, TILES.BELLFLOWER); + } + } + void this.game.audio.play('sfx/bell_low', 0.8); + this.showToast('Цветы в земле. Поляна гудит.'); + } + + /** Кат-сцена сдачи: посадка, камера к башне, удар колокола, зелень — финал акта 1. */ + startHandInCutscene(): void { + // Башня Звенца: тайл в данных, камере нужны юниты. + const tower = tileToWorld(QUEST_TOWER_TILE.x, QUEST_TOWER_TILE.y); + const s = worldToScreen(tower.x, tower.y); + this.cutscene.play([ + // Пересев: поляна гудит, герой стоит. + { kind: 'call', fn: () => this.plantFlowers(), seconds: 0.8 }, + // Камера уходит к башне. + { kind: 'cameraMove', x: tower.x, y: tower.y, seconds: 1.2, camera: this.game.camera }, + // Удар колокола: низкий звон, тряска, кольцо резонанса. + { + kind: 'burst', + fn: () => { + void this.game.audio.play('sfx/bell_low', 1); + this.game.camera.addShake(2.5, 0.4); + this.burst(s); + }, + seconds: 1 + }, + // Зелень поднимается у подножия башни (визуальный финал акта). + { + kind: 'burst', + fn: () => { + for (const [tx, ty] of QUEST_TOWER_GREEN) { + const id = this.tileId(tx, ty); + if (id === TILES.GRASS || id === TILES.ASH) { + this.setTileTracked(tx, ty, TILES.BELLFLOWER); + this.burst(this.tileCenter(tx, ty)); + } + } + }, + seconds: 1.2 + }, + // Тост-финал акта 1. + { kind: 'call', fn: () => this.showToast('Пепел отступил у тропы. Машина дышит тише... но дышит.') } + ]); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/systems/quest/ResonatorQuest.ts b/v1/apps/game/src/systems/quest/ResonatorQuest.ts new file mode 100644 index 0000000..82f2477 --- /dev/null +++ b/v1/apps/game/src/systems/quest/ResonatorQuest.ts @@ -0,0 +1,57 @@ +import { CutsceneRunner, tileToWorld, worldToScreen } from '@rpg/engine'; +import { Game } from '../../Game'; +import { FLAGS, type FlagId } from '../../data/ids'; +import { QUEST_TONE_TILES } from '../../data/quests'; + +/** + * Квест «Голос для резонатора» (docs/world.md, акт 2): звон у деревьев- + * механизмов рощи (интерактивы, тон — варом через реакции) и кат-сцена + * настройки — резонатор ловит собранные тоны, роща отвечает. + */ +export class ResonatorQuest { + constructor( + private game: Game, + /** Экранный центр ромба тайла (с Origin карты). */ + private tileCenter: (tx: number, ty: number) => { x: number; y: number }, + /** Всплеск пеплинок в точке (CombatViews.hitBurst). */ + private burst: (s: { x: number; y: number }) => void, + private showToast: (text: string) => void, + private cutscene: CutsceneRunner + ) {} + + /** Кат-сцена настройки: камера к резонатору Клинта, звон, ответ рощи. */ + startTuneCutscene(): void { + // Резонатор у поляны Клинта (интерактив clint_resonator). + const at = tileToWorld(12, 8); + const s = worldToScreen(at.x, at.y); + this.cutscene.play([ + // Камера подходит и наезжает: настройка — крупный план. + { kind: 'cameraMove', x: at.x, y: at.y, seconds: 1, camera: this.game.camera }, + { kind: 'cameraZoom', camera: this.game.camera, z: 2, seconds: 0.8 }, + // Резонатор ловит тон: звон, тряска, всплеск. + { + kind: 'burst', + fn: () => { + void this.game.audio.play('sfx/bell_low', 1); + this.game.camera.addShake(2, 0.4); + this.burst(s); + }, + seconds: 1 + }, + // Роща отвечает: прозвеневшие деревья вспыхивают в такт. + { + kind: 'burst', + fn: () => { + QUEST_TONE_TILES.forEach(([tx, ty], i) => { + if (!this.game.state.hasFlag(FLAGS[`tone_tree_${i + 1}` as FlagId])) return; + this.burst(this.tileCenter(tx, ty)); + }); + }, + seconds: 1.2 + }, + // Отъезд и тост-финал квеста. + { kind: 'cameraZoom', camera: this.game.camera, z: 1, seconds: 0.6 }, + { kind: 'call', fn: () => this.showToast('Резонатор держит тон. Роща слышит.') } + ]); + } +} \ No newline at end of file diff --git a/v1/apps/game/src/testing/combatWorld.ts b/v1/apps/game/src/testing/combatWorld.ts new file mode 100644 index 0000000..d5ec8d1 --- /dev/null +++ b/v1/apps/game/src/testing/combatWorld.ts @@ -0,0 +1,54 @@ +import type { EventBus, IsometricTileMap } from '@rpg/engine'; +import { CombatWorld } from '../systems/combat/CombatWorld'; +import { type EnemyBrain, type EnemySenses } from '../systems/combat/EnemyBrain'; + +/** + * Тестовые фикстуры боевого мира (без Pixi и браузера). Импортировать только + * из *.test.ts — в прод-бандл эти файлы не попадают. + */ + +/** Фейковая карта w×h травы, проходима всюду (по умолчанию 20×20). */ +export function fakeMap(w = 20, h = 20): IsometricTileMap { + const data = { + width: w, + height: h, + tiles: new Array(w * h).fill(0), + blocked: [], + tall: undefined + }; + // width/height наверху — контракт Grid для движковых коллизий. + return { width: w, height: h, data, isWalkable: () => true } as unknown as IsometricTileMap; +} + +/** Боевой мир над фейковой картой: события глушатся, герой в заданной точке. */ +export function fakeWorld(playerPos: { x: number; y: number }): CombatWorld { + return new CombatWorld({ + map: fakeMap(), + events: { emit: () => {} } as unknown as EventBus, + getPlayerPos: () => playerPos, + damagePlayer: () => {} + }); +} + +/** Тик ИИ на 1/60 с; возвращает последнее намерение. */ +export function run( + b: EnemyBrain, + seconds: number, + senses: EnemySenses +): ReturnType { + let intent: ReturnType = { type: 'idle' }; + for (let i = 0; i < Math.round(seconds * 60); i++) intent = b.update(1 / 60, senses); + return intent; +} + +/** Сенсоры «герой далеко и не виден» (для патрульных сценариев). */ +export function far(pos: { x: number; y: number }): EnemySenses { + return { + dist: 30, + dirToPlayer: { x: 1, y: 0 }, + pos, + canSee: false, + home: { x: 10.5, y: 7.5 }, + hpFraction: 1 + }; +} \ No newline at end of file diff --git a/v1/apps/game/src/testing/gameState.ts b/v1/apps/game/src/testing/gameState.ts new file mode 100644 index 0000000..e548941 --- /dev/null +++ b/v1/apps/game/src/testing/gameState.ts @@ -0,0 +1,17 @@ +import type { GameState } from '@rpg/engine'; + +/** + * Фейковый GameState: флаги и вар-числа в памяти (без DOM и сейвов). + * Импортировать только из *.test.ts. + */ +export function fakeState(): GameState { + const flags = new Set(); + const vars = new Map(); + return { + setFlag: (f: string) => void flags.add(f), + hasFlag: (f: string) => flags.has(f), + clearFlag: (f: string) => void flags.delete(f), + setVar: (k: string, v: number) => void vars.set(k, Number(v)), + getNumber: (k: string) => vars.get(k) ?? 0 + } as unknown as GameState; +} \ No newline at end of file diff --git a/v1/apps/game/src/testing/maps.ts b/v1/apps/game/src/testing/maps.ts new file mode 100644 index 0000000..1adb0f9 --- /dev/null +++ b/v1/apps/game/src/testing/maps.ts @@ -0,0 +1,13 @@ +import { TILES } from '../data/map'; + +/** Карта 10×10 травы с одной стеной (tall-объект) в центре — для LOS. */ +export function mapWithWall(): { + width: number; + height: number; + tiles: number[]; + tall: Record; +} { + const tiles = new Array(100).fill(TILES.GRASS); + tiles[5 * 10 + 5] = TILES.HOUSE; + return { width: 10, height: 10, tiles, tall: { [TILES.HOUSE]: { height: 1, ground: 0 } } }; +} \ No newline at end of file diff --git a/v1/apps/game/tools/agent.mjs b/v1/apps/game/tools/agent.mjs new file mode 100644 index 0000000..34f28ca --- /dev/null +++ b/v1/apps/game/tools/agent.mjs @@ -0,0 +1,164 @@ +/** + * CLI-диспетчер агентных инструментов. Вывод по умолчанию — JSON + * (машинночитаемый), --pretty — для человека. + * + * node apps/game/tools/agent.mjs check [--only a,b] [--skip a,b] [--pretty] + * node apps/game/tools/agent.mjs run [--pretty] + * node apps/game/tools/agent.mjs snapshot [--new-game] [--out файл] [--pretty] + * node apps/game/tools/agent.mjs screenshot [--out файл] [--steps N] [--pretty] + * node apps/game/tools/agent.mjs dev [--port N] + */ +import { spawnSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parseArgs, startDevServer, openGame } from './lib.mjs'; + +/** Корень репозитория (тулзы живут в apps/game/tools, скрипты npm — в корне). */ +const ROOT = new URL('../../..', import.meta.url); + +const args = parseArgs(); +const cmd = args._[0] ?? 'check'; + +/** Один «серверный» шаг проверки: результат + ms + детали (fn может быть async). */ +async function stepResult(name, fn) { + const t0 = Date.now(); + try { + const details = await fn(); + return { name, ok: true, ms: Date.now() - t0, details: details ?? null }; + } catch (err) { + return { name, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) }; + } +} + +/** Существующие .map равны генераторам (gen.test.ts сравнивает сиды с файлами). */ +const mapsFresh = () => stepResult('maps:fresh', () => { + const gen = spawnSync('npx', ['vitest', 'run', 'apps/game/tools/maps/gen.test.ts'], { encoding: 'utf8', cwd: ROOT.pathname }); + if (gen.status !== 0) throw new Error(gen.stdout.split('\n').filter((l) => l.includes('FAIL')).join('; ') || 'gen.test.ts не зелёный'); + return 'файлы карт равны генераторам'; +}); + +/** Браузерная проверка invariants — сценарий checks/agent-invariants.mjs (единый источник). */ +const agentInvariants = () => runScenario('agent:invariants', 'apps/game/tools/checks/agent-invariants.mjs'); + +/** Сценарий из apps/game/tools/checks/ как шаг check: exit code -> результат. */ +async function runScenario(name, file) { + return stepResult(name, async () => { + const mod = await import(pathToFileURL(resolve(ROOT.pathname, file))); + const code = await mod.default({ args, pretty: args.pretty }); + if (code !== 0) throw new Error(`сценарий ${file} не зелёный (exit ${code})`); + return null; + }); +} + +const commands = { + /** Полный прогон проверок. */ + async check() { + const all = [ + { name: 'typecheck', run: () => stepResult('typecheck', () => { + const r = spawnSync('npm', ['run', 'typecheck'], { encoding: 'utf8', cwd: ROOT.pathname }); + if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('error TS')).join('\n')); + return null; + }) }, + { name: 'guard:boundary', run: () => stepResult('guard:boundary', () => { + const r = spawnSync('npm', ['run', 'guard'], { encoding: 'utf8', cwd: ROOT.pathname }); + if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('"rule"') || l.includes('"ok": false')).join('\n') || 'граница движок/игра нарушена'); + return null; + }) }, + { name: 'tests', run: () => stepResult('tests', () => { + const r = spawnSync('npm', ['test'], { encoding: 'utf8', cwd: ROOT.pathname }); + if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('FAIL')).join('; ') || 'тесты не зелёные'); + return null; + }) }, + { name: 'maps:fresh', run: mapsFresh }, + { name: 'agent:invariants', run: agentInvariants }, + { name: 'transitions', run: () => runScenario('transitions', 'apps/game/tools/checks/transitions.mjs') }, + { name: 'interact', run: () => runScenario('interact', 'apps/game/tools/checks/interact.mjs') }, + { name: 'interact-world', run: () => runScenario('interact-world', 'apps/game/tools/checks/interact-world.mjs') }, + { name: 'quest-bells', run: () => runScenario('quest-bells', 'apps/game/tools/checks/quest-bells.mjs') }, + { name: 'lighting', run: () => runScenario('lighting', 'apps/game/tools/checks/lighting.mjs') }, + { name: 'time', run: () => runScenario('time', 'apps/game/tools/checks/time.mjs') }, + { name: 'collision', run: () => runScenario('collision', 'apps/game/tools/checks/collision.mjs') }, + { name: 'ai', run: () => runScenario('ai', 'apps/game/tools/checks/ai.mjs') }, + { name: 'audio', run: () => runScenario('audio', 'apps/game/tools/checks/audio.mjs') }, + { name: 'synth', run: () => runScenario('synth', 'apps/game/tools/checks/synth.mjs') }, + { name: 'music', run: () => runScenario('music', 'apps/game/tools/checks/music.mjs') } + ]; + const only = args.only ?? all.map((p) => p.name); + const skip = args.skip ?? []; + const report = { name: 'check', ok: true, ms: 0, results: [] }; + const t0 = Date.now(); + for (const probe of all) { + if (!only.includes(probe.name) || skip.includes(probe.name)) continue; + let res; + try { + res = await probe.run(); + } catch (err) { + res = { name: probe.name, ok: false, ms: 0, details: String(err?.message ?? err) }; + } + report.results.push(res); + report.ok = report.ok && res.ok; + } + report.ms = Date.now() - t0; + console.log(args.pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report)); + return report.ok ? 0 : 1; + }, + + /** Один сценарий из apps/game/tools/checks/. */ + async run() { + const file = args._[1]; + if (!file) throw new Error('укажи путь к сценарию: node apps/game/tools/agent.mjs run apps/game/tools/checks/xxx.mjs'); + const mod = await import(pathToFileURL(file).href); + if (typeof mod.default !== 'function') throw new Error(`${file}: нет export default (async ({args}) => exitCode)`); + return mod.default({ args, pretty: args.pretty }); + }, + + /** Бенчмарк производительности (не входит в check — замер, а не гейт). */ + async perf() { + await runScenario('perf', 'apps/game/tools/checks/perf.mjs'); + return 0; + }, + + /** Снимок снапшота в файл/stdout. */ + async snapshot() { + const server = await startDevServer({ port: args.port ?? 5199 }); + try { + const ctx = await openGame({ url: server.url, newGame: args.newGame ?? false }); + const snap = await ctx.agent.snapshot(); + if (args.steps) await ctx.agent.step(args.steps); + if (args.out) writeFileSync(args.out, JSON.stringify(snap, null, 2)); + console.log(args.out ? `снапшот: ${args.out}` : JSON.stringify(snap, null, 2)); + await ctx.browser.close(); + return 0; + } finally { server.stop(); } + }, + + /** Скриншот + консоль (для визуальных проверок, когда снапшота мало). */ + async screenshot() { + const server = await startDevServer({ port: args.port ?? 5199 }); + try { + const ctx = await openGame({ url: server.url, newGame: args.newGame ?? true }); + if (args.steps) await ctx.agent.step(args.steps); + const out = args.out ?? '/tmp/rpg_agent.png'; + await ctx.agent.screenshot(out); + console.log(JSON.stringify({ screenshot: out, logs: ctx.consoleLogs.slice(-10) })); + await ctx.browser.close(); + return 0; + } finally { server.stop(); } + }, + + /** Dev-сервер + URL для ручной работы (не гасит сервер до Ctrl+C). */ + async dev() { + const server = await startDevServer({ port: args.port ?? 5199 }); + console.log(`dev: ${server.url}`); + process.on('SIGINT', () => { server.stop(); process.exit(0); }); + await new Promise(() => {}); + } +}; + +const fn = commands[cmd]; +if (!fn) { + console.error(`Неизвестная команда «${cmd}». Доступны: ${Object.keys(commands).join(', ')}`); + process.exit(2); +} +process.exit(await fn()); \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/__tests__/recolor.test.mjs b/v1/apps/game/tools/aiart/__tests__/recolor.test.mjs new file mode 100644 index 0000000..d67eb52 --- /dev/null +++ b/v1/apps/game/tools/aiart/__tests__/recolor.test.mjs @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { remap, parseColor } from '../recolor.mjs'; + +/** Спрайт 2×2: два пикселя цвета from (alpha 255), один прозрачный, один другой. */ +const sprite = (from) => { + const rgba = new Uint8Array(2 * 2 * 4); + rgba.set([...from, 255], 0); + rgba.set([1, 2, 3, 255], 8); + return rgba; +}; + +describe('remap', () => { + it('точная замена RGB, alpha и прочие цвета не тронуты', () => { + const out = remap(sprite([58, 40, 34]), [[[58, 40, 34], [58, 67, 86]]]); + expect([...out.slice(0, 4)]).toEqual([58, 67, 86, 255]); + expect([...out.slice(8, 12)]).toEqual([1, 2, 3, 255]); // чужой цвет не тронут + expect(out[4 + 3]).toBe(0); // прозрачный остался прозрачным + }); +}); + +describe('parseColor', () => { + it('ключ палитры и #hex дают один и тот же RGB', () => { + expect(parseColor('C0')).toEqual([0x3a, 0x43, 0x56]); + expect(parseColor('#3a4356')).toEqual([0x3a, 0x43, 0x56]); + }); + it('неизвестный ключ — ошибка', () => { + expect(() => parseColor('Z9')).toThrow(); + }); +}); \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/atlas-manifest.json b/v1/apps/game/tools/aiart/atlas-manifest.json new file mode 100644 index 0000000..6fff155 --- /dev/null +++ b/v1/apps/game/tools/aiart/atlas-manifest.json @@ -0,0 +1,33 @@ +{ + "characters": [ + { + "id": "bellringer", + "prompt": "a bell ringer wearing a dark blue cloak and a wide brimmed hat, holding a small golden hand bell, pixel art character sprite, plain white background", + "seed": 42, + "views": ["FSS", "BSS", "LSS"], + "frameSets": { + "down": { "view": "FSS", "poses": [0, 1], "bob": true }, + "up": { "view": "BSS", "poses": [0, 1], "bob": true }, + "side": { "view": "LSS", "poses": [0, 1], "bob": true } + }, + "frameSize": [16, 24], + "palette": "game", + "remap": { + "R0": "C0", + "R1": "C0", + "R2": "C1", + "G1": "C0", + "G2": "C1", + "G2H": "C1", + "F0": "B0" + }, + "crop": { + "FSS": [[8, 150, 122, 230], [136, 150, 122, 230], [264, 150, 122, 230], [392, 150, 122, 230]], + "BSS": [[8, 150, 122, 230], [136, 150, 122, 230], [264, 150, 122, 230], [392, 150, 122, 230]], + "LSS": [[8, 150, 122, 230], [136, 150, 122, 230], [264, 150, 122, 230], [392, 150, 122, 230]] + }, + "target": "chars/hero_sheet.png", + "status": "draft" + } + ] +} \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/atlas.mjs b/v1/apps/game/tools/aiart/atlas.mjs new file mode 100644 index 0000000..61e67f4 --- /dev/null +++ b/v1/apps/game/tools/aiart/atlas.mjs @@ -0,0 +1,172 @@ +/** + * Пайплайн генерации атласов персонажей из AI-листов (работа B, см. docs/plan.md): + * gen — AI-листы ракурсов (sd_sheet.py, возобновляемо, ~6.6 мин/вид) + * build — листы → кадры 16×24 → атлас + обзорный лист (build//) + * promote — копия атласа в assets/ (только при status: approved) + * + * Всё манифест-драйвенно: atlas-manifest.json (промпт, seed, ракурсы, палитра). + * AI-вывод в assets/ напрямую не попадает — только через promote. + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'; +import { join, dirname, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; +import { findFigures, resizeRect, quantize } from '@rpg/engine/tools/imaging.mjs'; +import { buildAtlas } from '@rpg/engine/tools/atlas.mjs'; +import { squashFrame } from '@rpg/engine/tools/frames.mjs'; +import { remap, parseColor } from './recolor.mjs'; +import { PALETTE } from '../pixelart/palette.mjs'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const SHEETS = join(HERE, 'sheets'); +const FRAMES = join(HERE, 'frames'); +const BUILD = join(HERE, 'build'); +const MANIFEST = join(HERE, 'atlas-manifest.json'); + +// ---------- контекст персонажа ---------- + +function loadChar(id) { + const { characters } = JSON.parse(readFileSync(MANIFEST, 'utf8')); + const entry = characters.find((c) => c.id === id); + if (!entry) throw new Error(`персонаж «${id}» не найден в atlas-manifest.json`); + return entry; +} + +/** Палитра из манифеста: "game" — 32 цвета игры; иначе palettes/.json (hex). */ +function loadPalette(entry) { + const hexes = entry.palette === 'game' + ? Object.values(PALETTE) + : JSON.parse(readFileSync(join(HERE, 'palettes', `${entry.palette}.json`), 'utf8')); + return hexes.map((hex) => { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + }); +} + +// ---------- gen: AI-листы ---------- + +function cmdGen(char, { force = false } = {}) { + const py = process.env.RPG_SD_PYTHON ?? `${process.env.HOME}/.cache/rpg-ai/venv-pxgpt/bin/python`; + const dir = join(SHEETS, char.id); + mkdirSync(dir, { recursive: true }); + for (const view of char.views) { + const out = join(dir, `${view}_${char.seed}.png`); + if (existsSync(out) && !force) { + console.log(`[skip] ${out} уже есть (—force для перегенерации)`); + continue; + } + console.log(`[gen] ${view} (seed ${char.seed}) — это долго, ~6.6 мин`); + const r = spawnSync(py, [join(HERE, 'sd_sheet.py'), view, String(char.seed), dir, char.prompt], { stdio: 'inherit' }); + if (r.status !== 0) throw new Error(`sd_sheet.py упал на ${view} (код ${r.status})`); + } +} + +// ---------- build: кадры + атлас ---------- + +/** Лист ракурса: фигуры — crop-оверрайды из манифеста (rect[]) или автодетект. */ +function sheetFigures(char, view) { + const sheet = decodePng(readFileSync(join(SHEETS, char.id, `${view}_${char.seed}.png`))); + const override = char.crop?.[view]; + if (override) { + return { sheet, figures: override.map(([x, y, w, h]) => ({ x, y, w, h })) }; + } + return { sheet, figures: findFigures(sheet, { maxCount: 4 }) }; +} + +/** Обзорный лист: кадры ×scale в сетке (для апрува человеком). */ +function frameSheet(frames, fw, fh, scale = 4) { + const pad = 6, cellW = fw * scale, cellH = fh * scale; + const cols = 4, rows = Math.ceil(frames.length / cols); + const W = cols * (cellW + pad) + pad, H = rows * (cellH + pad) + pad; + const out = new Uint8Array(W * H * 4); + for (let p = 0; p < out.length; p += 4) { out[p] = 30; out[p + 1] = 32; out[p + 2] = 38; out[p + 3] = 255; } + frames.forEach((rgba, i) => { + const ox = pad + (i % cols) * (cellW + pad), oy = pad + ((i / cols) | 0) * (cellH + pad); + for (let y = 0; y < cellH; y++) for (let x = 0; x < cellW; x++) { + const si = (((y / scale) | 0) * fw + ((x / scale) | 0)) * 4; + if (rgba[si + 3] === 0) continue; + out.set(rgba.subarray(si, si + 4), ((oy + y) * W + ox + x) * 4); + } + }); + return encodePng(W, H, Buffer.from(out)); +} + +function cmdBuild(char) { + const pal = loadPalette(char); + const [fw, fh] = char.frameSize; + // Ремап «поплавших» цветов генерации — после квантизации (точные цвета палитры). + const pairs = Object.entries(char.remap ?? {}).map(([from, to]) => [parseColor(from), parseColor(to)]); + const frames = [], names = [], animations = {}; + mkdirSync(join(FRAMES, char.id), { recursive: true }); + for (const [dirName, set] of Object.entries(char.frameSets)) { + if (!existsSync(join(SHEETS, char.id, `${set.view}_${char.seed}.png`))) { + console.log(`[skip] ${set.view}: лист ещё не сгенерирован (atlas.mjs gen ${char.id})`); + continue; + } + const { sheet, figures } = sheetFigures(char, set.view); + if (Math.max(...set.poses) >= figures.length) { + throw new Error(`${set.view}: найдено фигур ${figures.length}, поза ${Math.max(...set.poses)} не выбирается (см. crop в манифесте)`); + } + // Приседание: 2-й кадр — производный от первого (сдвиг вниз на 1px, + // верх прозрачный) — различимая ходьба из одинаковых стоячих поз; + // вторая поза листа тогда не используется. + const usedPoses = set.bob ? [set.poses[0]] : set.poses; + const setFirst = frames.length; + for (const pose of usedPoses) { + let rgba = quantize(resizeRect(sheet, figures[pose], fw, fh), pal); + if (pairs.length) rgba = remap(rgba, pairs); + const n = frames.length + 1; + frames.push(rgba); + names.push(`${char.id}_${dirName}_${n}`); + writeFileSync(join(FRAMES, char.id, `${names[names.length - 1]}.png`), encodePng(fw, fh, Buffer.from(rgba))); + if (set.bob) { + const bob = squashFrame(rgba, fw, fh); + frames.push(bob); + names.push(`${char.id}_${dirName}_${frames.length}`); + writeFileSync(join(FRAMES, char.id, `${names[names.length - 1]}.png`), encodePng(fw, fh, Buffer.from(bob))); + } + } + animations[`${char.id}_${dirName}`] = frames + .slice(setFirst) + .map((_, i) => setFirst + i); + } + if (!frames.length) throw new Error('ни одного ракурса нет — сначала atlas.mjs gen'); + const outDir = join(BUILD, char.id); + mkdirSync(dirname(join(outDir, char.target)), { recursive: true }); + // meta.image — имя PNG рядом с JSON (Pixi резолвит его относительно URL + // атласа; путь с каталогом даст /chars/chars/… → 404 в игре). + const { png, json } = buildAtlas(frames, names, { + frameW: fw, frameH: fh, image: basename(char.target), animations + }); + writeFileSync(join(outDir, char.target), png); + writeFileSync(join(outDir, char.target.replace(/\.png$/, '.json')), JSON.stringify(json, null, 2)); + writeFileSync(join(outDir, '_review.png'), frameSheet(frames, fw, fh)); + console.log(`[build] ${frames.length} кадров -> ${outDir} (обзорный лист _review.png, статус: ${char.status})`); +} + +// ---------- promote: в assets после апрува ---------- + +function cmdPromote(char) { + if (char.status !== 'approved') { + throw new Error(`статус «${char.status}» — promote только при approved (правь atlas-manifest.json после апрува обзорного листа)`); + } + const outDir = join(BUILD, char.id); + const dst = join(HERE, '..', '..', 'assets'); + const jsonTarget = char.target.replace(/\.png$/, '.json'); + copyFileSync(join(outDir, char.target), join(dst, char.target)); + copyFileSync(join(outDir, jsonTarget), join(dst, jsonTarget)); + console.log(`[promote] ${char.target} (+ JSON) -> assets. Внимание: npm run art перегенерирует кодовый атлас поверх.`); +} + +// ---------- CLI ---------- + +const [, , cmd, id, ...rest] = process.argv; +if (!cmd || !id) { + console.log('Использование: node atlas.mjs [--force]'); + process.exit(1); +} +const char = loadChar(id); +const handlers = { gen: cmdGen, build: cmdBuild, promote: cmdPromote }; +if (!handlers[cmd]) throw new Error(`неизвестная команда «${cmd}»`); +handlers[cmd](char, { force: rest.includes('--force') }); \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/build/bellringer/_review.png b/v1/apps/game/tools/aiart/build/bellringer/_review.png new file mode 100644 index 0000000..85e5f43 --- /dev/null +++ b/v1/apps/game/tools/aiart/build/bellringer/_review.png Binary files differ diff --git a/v1/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.json b/v1/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.json new file mode 100644 index 0000000..0c1cad6 --- /dev/null +++ b/v1/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.json @@ -0,0 +1,96 @@ +{ + "frames": { + "bellringer_down_1": { + "frame": { + "x": 0, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "bellringer_down_2": { + "frame": { + "x": 16, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "bellringer_up_3": { + "frame": { + "x": 32, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "bellringer_up_4": { + "frame": { + "x": 48, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "bellringer_side_5": { + "frame": { + "x": 64, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "bellringer_side_6": { + "frame": { + "x": 80, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + } + }, + "meta": { + "image": "hero_sheet.png", + "size": { + "w": 96, + "h": 24 + }, + "scale": 1 + } +} \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.png b/v1/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.png new file mode 100644 index 0000000..7d4ddb3 --- /dev/null +++ b/v1/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.png Binary files differ diff --git a/v1/apps/game/tools/aiart/frames/bellringer/bellringer_down_1.png b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_down_1.png new file mode 100644 index 0000000..31a1ebe --- /dev/null +++ b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_down_1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/frames/bellringer/bellringer_down_2.png b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_down_2.png new file mode 100644 index 0000000..44320ac --- /dev/null +++ b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_down_2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/frames/bellringer/bellringer_side_1.png b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_side_1.png new file mode 100644 index 0000000..a353c32 --- /dev/null +++ b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_side_1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/frames/bellringer/bellringer_side_2.png b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_side_2.png new file mode 100644 index 0000000..7473f83 --- /dev/null +++ b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_side_2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/frames/bellringer/bellringer_side_5.png b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_side_5.png new file mode 100644 index 0000000..a353c32 --- /dev/null +++ b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_side_5.png Binary files differ diff --git a/v1/apps/game/tools/aiart/frames/bellringer/bellringer_side_6.png b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_side_6.png new file mode 100644 index 0000000..300f2fe --- /dev/null +++ b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_side_6.png Binary files differ diff --git a/v1/apps/game/tools/aiart/frames/bellringer/bellringer_up_1.png b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_up_1.png new file mode 100644 index 0000000..14af22e --- /dev/null +++ b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_up_1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/frames/bellringer/bellringer_up_2.png b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_up_2.png new file mode 100644 index 0000000..afb427d --- /dev/null +++ b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_up_2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/frames/bellringer/bellringer_up_3.png b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_up_3.png new file mode 100644 index 0000000..14af22e --- /dev/null +++ b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_up_3.png Binary files differ diff --git a/v1/apps/game/tools/aiart/frames/bellringer/bellringer_up_4.png b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_up_4.png new file mode 100644 index 0000000..9426cda --- /dev/null +++ b/v1/apps/game/tools/aiart/frames/bellringer/bellringer_up_4.png Binary files differ diff --git a/v1/apps/game/tools/aiart/gen_b4.py b/v1/apps/game/tools/aiart/gen_b4.py new file mode 100644 index 0000000..ce17662 --- /dev/null +++ b/v1/apps/game/tools/aiart/gen_b4.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""B4: батч AI-генерации кандидатов-плейсхолдеров (SD1.5, GPU fp16). + +Модель: Retro Diffusion (LostMedia/RetroDiffusion, Public Domain Mark 1.0) — +SD1.5-файнтюн пиксель-арта, слушает промпты одиночных объектов (проба 2026-09-09: +очаг/часы — отличные, мот/свиток читаются). Листовая модель B3 +(SD_PixelArt_SpriteSheet_Generator) рисует только 4 фигурки персонажей — +для объектов непригодна. RTX 3090 fp16: ~1.3 с/вид, семена бесплатны. + +Список объектов — LIST (id, промпт): интерактивы-плейсхолдеры (InteractableViews), +снаряд плевка и иконки сумки 8×8 (арт-библия §4). Слоты назначает post_b4.mjs. + +Запуск (интерпретатор — venv-aiaudio, там torch cu130): + ~/.cache/rpg-ai/venv-aiaudio/bin/python gen_b4.py # все объекты, SEEDS семян + ... gen_b4.py signpost hearth:7 # конкретные id[:seed] +Вывод: aiart/review-b4/raw/_.png (512×512 сырьё). +""" +import sys +import time +from pathlib import Path + +import torch +from diffusers import StableDiffusionPipeline + +MODEL_ID = 'LostMedia/RetroDiffusion' +STEPS = 20 +CFG = 7.0 +SEEDS = (1, 2, 3, 4) +NEG = ('blurry, photo, realistic, smooth gradients, text, watermark, ' + 'multiple objects, cropped, character, person, ' + 'warm background, brown background, orange background, sunset lighting') + +LIST = { + # --- интерактивы-плейсхолдеры (InteractableViews) --- + 'signpost': 'pixel art of a weathered wooden road signpost with an arrow board, dark wood', + 'mote': 'pixel art of a small mound of gray ash with a glowing warm ember inside', + 'tone_tree': 'pixel art of a dead tree trunk wrapped in black cables with small copper bells, on a plain pale gray background', + 'resonator': 'pixel art of a copper bell resonator machine on a rusty steel frame, on a plain pale gray background', + 'bell_rope': 'pixel art of a hanging bell pull rope with a wooden handle grip', + 'hearth': 'pixel art of a stone hearth fireplace with a warm fire inside', + 'note': 'pixel art of a small crumpled paper note lying flat on the ground', + 'counter': 'pixel art of a wooden market stall counter with crates and goods on top, side view', + 'spit': 'pixel art of a small blob of gray ash, a projectile', + # --- иконки сумки 8×8 (items.ts) --- + 'icon_clock': 'pixel art icon of a small copper pocket watch', + 'icon_cloth': 'pixel art icon of a folded waxed cloth mask', + 'icon_bellflower': 'pixel art icon of a small blue bellflower', + 'icon_salt': 'pixel art icon of a small pouch of salt', + 'icon_flask': 'pixel art icon of a small copper water flask', + 'icon_mote': 'pixel art icon of a small warm glowing ash mote', + 'icon_map': 'pixel art icon of a rolled paper map scroll', +} + + +def main() -> None: + args = sys.argv[1:] + wanted = {a.split(':')[0] for a in args} if args else set(LIST) + seeds_of = {} + for a in args: + if ':' in a: + k, s = a.split(':', 1) + seeds_of.setdefault(k, []).extend(int(x) for x in s.split(':')) + out_dir = Path(__file__).resolve().parent / 'review-b4' / 'raw' + out_dir.mkdir(parents=True, exist_ok=True) + + t0 = time.time() + pipe = StableDiffusionPipeline.from_pretrained( + MODEL_ID, torch_dtype=torch.float16, safety_checker=None, + ).to('cuda') + pipe.set_progress_bar_config(disable=True) + print(f'[load] {time.time() - t0:.1f}s', flush=True) + + for oid in [k for k in LIST if k in wanted]: + prompt = LIST[oid] + for seed in seeds_of.get(oid, list(SEEDS)): + gen = torch.Generator('cuda').manual_seed(seed) + img = pipe( + prompt, negative_prompt=NEG, num_inference_steps=STEPS, + guidance_scale=CFG, width=512, height=512, generator=gen, + ).images[0] + out = out_dir / f'{oid}_{seed}.png' + img.convert('RGBA').save(out) + print(f'[ok] {out.name}', flush=True) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/gen_pxgpt.py b/v1/apps/game/tools/aiart/gen_pxgpt.py new file mode 100644 index 0000000..237babb --- /dev/null +++ b/v1/apps/game/tools/aiart/gen_pxgpt.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""AI-шаг пробы B1: доменные интерактивы через PixelGPT-24×24 (CPU). + +Модель: unstonio/pixelgpt-24x24 (68M декодер, ~22 с/спрайт). Репо модели — +вне репо игры: ~/.cache/rpg-ai/pixelgpt-24x24 (env RPG_PXGPT_REPO). Веса — +models/pixelar_fp16.pt (LFS) + models/minilm/; лицензии на модель нет — +результаты только PoC/review, в игру не идут. + +Палитра обусловливания — 5 ключей из apps/game/tools/pixelart/palette.mjs +(первый — самый тёмный, токен 0 = прозрачный). Токены 1–4 маппятся на +палитру по индексу (как во фронтенде модели: fillStyle = palette[role]). + +Запуск: $RPG_SD_PYTHON gen_pxgpt.py + строки промптов: seed|имя|промпт|5 ключей палитры через запятую + (формат apps/game/tools/aiart/prompts-b1.txt); строки с # — комментарии. +""" +import os +import re +import sys +import time +from pathlib import Path + +import numpy as np +import torch +from PIL import Image + +REPO = Path(os.environ.get('RPG_PXGPT_REPO', f'{Path.home()}/.cache/rpg-ai/pixelgpt-24x24')) +GAME_ROOT = Path(__file__).resolve().parents[4] +CFG = 3.0 +TOP_P = 0.95 +TEMP = 1.0 + + +def load_game_palette(path: Path) -> dict[str, tuple[int, int, int]]: + """Ключи палитры игры → RGB (парсинг palette.mjs).""" + out = {} + for m in re.finditer(r"(?:^|\s)([A-Z]\w*):\s*'(#[0-9a-fA-F]{6})'", path.read_text()): + h = m.group(2) + out[m.group(1)] = tuple(int(h[i:i + 2], 16) for i in (1, 3, 5)) + return out + + +def render(tokens: list[int], palette: list[tuple[int, int, int]], out: Path) -> None: + """24×24 RGBA: токен 0 — прозрачный, 1–5 — палитра со сдвигом (t-1). + + Сдвиг восстановлен сверкой с review-b1: токен 1 = первый ключ палитры + (самый тёмный), токен 5 — последний (в пробы B1 не попадал). + """ + im = Image.new('RGBA', (24, 24), (0, 0, 0, 0)) + px = im.load() + for i, t in enumerate(tokens): + if t: + px[i % 24, i // 24] = (*palette[t - 1], 255) + out.parent.mkdir(parents=True, exist_ok=True) + im.save(out) + + +def main() -> None: + prompts_file, out_dir = Path(sys.argv[1]), Path(sys.argv[2]) + keys = load_game_palette(GAME_ROOT / 'apps/game/tools/pixelart/palette.mjs') + + sys.path.insert(0, str(REPO)) + torch.set_num_threads(4) + from model_runtime import SEQ_LEN, SOS_TOKEN, load_model, sample_next, seed_everything, sequence_to_tokens + from sentence_transformers import SentenceTransformer + + device = torch.device('cpu') + t0 = time.time() + model, config, flat = load_model(str(REPO / 'models/pixelar_fp16.pt'), device) + encoder = SentenceTransformer(str(REPO / 'models/minilm'), device='cpu', local_files_only=True) + print(f'[load] {time.time() - t0:.0f}s', flush=True) + + for line in prompts_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + seed, name, prompt, palette_keys = line.split('|') + palette = [keys[k.strip()] for k in palette_keys.split(',')] + seed = int(seed) + seed_everything(seed) + dtype = next(model.parameters()).dtype + embedding = torch.from_numpy(encoder.encode( + [prompt], normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=False, + )).to(device=device, dtype=dtype) + pal = torch.from_numpy(np.asarray(palette, dtype=np.uint8)[None]).to(device=device, dtype=dtype) / 255.0 + captions = torch.cat([embedding, model.null_caption.unsqueeze(0).to(embedding.dtype)], dim=0) + model_palettes = pal.repeat(2, 1, 1) + with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=False): + condition = model.cond_vector(captions, model_palettes) + caches = model.allocate_caches(2, device, condition.dtype) + current = torch.full((2, 1), SOS_TOKEN, dtype=torch.long, device=device) + sequence = torch.zeros(1, SEQ_LEN, dtype=torch.long, device=device) + for position in range(SEQ_LEN): + logits = model(current, model_palettes, condition, caches=caches, start_pos=position)[:, -1].float() + conditional, unconditional = logits[:1], logits[1:] + token = sample_next(unconditional + CFG * (conditional - unconditional), TEMP, TOP_P) + sequence[:, position] = token + current = torch.cat([token, token], dim=0).unsqueeze(1) + tokens = sequence_to_tokens(sequence, flat).clamp(0, 4)[0].cpu().to(torch.uint8).reshape(-1).tolist() + render(tokens, palette, out_dir / f'{name}.png') + print(f'[ok] {name} (seed {seed}, {time.time() - t0:.0f}s)', flush=True) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/lint-art.mjs b/v1/apps/game/tools/aiart/lint-art.mjs new file mode 100644 index 0000000..b835554 --- /dev/null +++ b/v1/apps/game/tools/aiart/lint-art.mjs @@ -0,0 +1,91 @@ +/** + * Линтер арта — авто-чеклист арт-библии (docs/art-style.md) перед попаданием + * в assets/: все непрозрачные пиксели из палитры, размер в известном слоте, + * детект «мыла» (чужая плотность пикселя — градиентные переходы). + * + * node lint-art.mjs [--palette game|<файл.json>] [--size 16x24,...] + * + * Выход: построчные вердикты, код 1 при нарушениях. Гейт апрува — человек, + * линтер ловит только механику. + */ +import { readFileSync } from 'node:fs'; +import { decodePng } from '@rpg/engine/tools/png.mjs'; +import { PALETTE } from '../pixelart/palette.mjs'; +import { SLOTS } from '../pixelart/sizes.mjs'; + +/** Палитра по имени: "game" — 32 цвета игры; иначе JSON-файл с массивом hex. */ +function loadPalette(name) { + if (name === 'game') { + return Object.values(PALETTE).map((hex) => hexToRgb(hex)); + } + return JSON.parse(readFileSync(name, 'utf8')).map(hexToRgb); +} + +function hexToRgb(hex) { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +/** Все непрозрачные пиксели — из палитры? Возвращает список нарушений. */ +function checkPalette(img, pal) { + const set = new Set(pal.map((c) => c.join(','))); + const bad = new Map(); + for (let p = 0; p < img.rgba.length; p += 4) { + if (img.rgba[p + 3] === 0) continue; + const key = `${img.rgba[p]},${img.rgba[p + 1]},${img.rgba[p + 2]}`; + if (!set.has(key) && !bad.has(key)) bad.set(key, 1); + } + return [...bad.keys()]; +} + +/** + * Детект «мыла»: доля горизонтальных пар соседних непрозрачных пикселей с + * маленьким (1..10 на канал), но ненулевым расхождением — градиент внутри + * «квадрата». У честного пиксель-арта соседи либо совпадают, либо различны. + */ +function checkSoap(img) { + let pairs = 0, gradient = 0; + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width - 1; x++) { + const a = (y * img.width + x) * 4, b = a + 4; + if (img.rgba[a + 3] === 0 || img.rgba[b + 3] === 0) continue; + pairs++; + const d = [0, 1, 2].map((c) => Math.abs(img.rgba[a + c] - img.rgba[b + c])); + if (d.some((v) => v >= 1 && v <= 10) && d.every((v) => v <= 10)) gradient++; + } + } + return pairs ? gradient / pairs : 0; +} + +// ---------- CLI ---------- + +const args = process.argv.slice(2); +const files = args.filter((a) => !a.startsWith('--')); +const opt = (name, def) => { + const i = args.indexOf(`--${name}`); + return i >= 0 ? args[i + 1] : def; +}; +if (!files.length) { + console.log('Использование: node lint-art.mjs [--palette game|<файл.json>] [--size 16x24,16x16]'); + process.exit(1); +} +const pal = loadPalette(opt('palette', 'game')); +const sizes = opt('size', null); // переопределение слотов (через запятую) +const slots = sizes ? new Set(sizes.split(',')) : SLOTS; + +let failed = 0; +for (const file of files) { + const img = decodePng(readFileSync(file)); + const problems = []; + const dim = `${img.width}x${img.height}`; + const atlasRow = [12, 14, 16, 20, 24, 32, 48].includes(img.height) && img.width > img.height + && (img.width % 16 === 0 || img.width % img.height === 0); + if (!slots.has(dim) && !atlasRow) problems.push(`размер ${dim} вне слотов`); + const badColors = checkPalette(img, pal); + if (badColors.length) problems.push(`вне палитры: ${badColors.length} цв. (${badColors.slice(0, 3).join(' ')})`); + const soap = checkSoap(img); + if (soap > 0.1) problems.push(`мыло: ${(soap * 100).toFixed(0)}% градиентных пар`); + if (problems.length) failed++; + console.log(`${problems.length ? 'FAIL' : ' ok '} ${file}${problems.length ? ' — ' + problems.join('; ') : ''}`); +} +process.exit(failed ? 1 : 0); \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/manifest-b1.json b/v1/apps/game/tools/aiart/manifest-b1.json new file mode 100644 index 0000000..19cb54e --- /dev/null +++ b/v1/apps/game/tools/aiart/manifest-b1.json @@ -0,0 +1,42 @@ +{ + "run": "b1-domain", + "date": "2026-09-06", + "model": "PixelGPT-24×24 (github.com/unstonio/pixelgpt-24x24), 68M декодер, CPU ~22 с/спрайт", + "license": "кода и весов НЕТ (в репо не указана) — в игру нельзя; датасет 20K — CC BY 4.0", + "pipeline": "prompts-b1.txt (seed|имя|промпт|5 ключей палитры) -> gen_pxgpt.py (обусловливание палитрой игры) -> review-b1/*.png (24x24) -> post.mjs (кроп, nearest 16x16, квантизация palette.mjs) -> processed-b1/", + "criterion": ">= 30% годных на доменных интерактивах, где PXG-Tiny отказывала", + "notes": [ + "Отказов нет вообще: модель пробуется рисовать любой конкретный объект (у PXG-Tiny домен давал gate=clarify).", + "Кастомная палитра из 5 цветов игры на запрос работает — стиль совпал на 100%.", + "Главный фактор брака — даунскейл 24→16: колодец/рычаг/прилавок на 24×24 читаются, на 16 разваливаются.", + "Фон часто залит непрозрачным цветом (сундуки) — кроп по альфе не спасает." + ], + "items": [ + { "file": "b1_bell_s1.png", "prompt": "a small bronze bell with a handle", "seed": 227, "match": "good", "note": "колокол читается чётко, бронза" }, + { "file": "b1_bell_s2.png", "prompt": "a small bronze bell with a handle", "seed": 228, "match": "good", "note": "тоже читаемый колокол" }, + { "file": "b1_chest_s1.png", "prompt": "a wooden chest with bronze trim", "seed": 237, "match": "partial", "note": "форма ок, но фон залит жёлтым" }, + { "file": "b1_chest_s2.png", "prompt": "a wooden chest with bronze trim", "seed": 238, "match": "partial", "note": "фон залит; у PXG-Tiny сундук был чище" }, + { "file": "b1_counter_s1.png", "prompt": "a wooden shop counter with goods", "seed": 231, "match": "partial", "note": "на 24×24 прилавок читается, на 16 разваливается" }, + { "file": "b1_counter_s2.png", "prompt": "a wooden shop counter with goods", "seed": 232, "match": "bad", "note": "тёмная каша" }, + { "file": "b1_door_s1.png", "prompt": "an old wooden door with iron hinges", "seed": 233, "match": "good", "note": "дверь с панелями и ручкой" }, + { "file": "b1_door_s2.png", "prompt": "an old wooden door with iron hinges", "seed": 234, "match": "good", "note": "дверь с перекладинами" }, + { "file": "b1_hearth_s1.png", "prompt": "a stone fireplace with burning fire", "seed": 229, "match": "good", "note": "камин с огнём, тёплый F0 уместен" }, + { "file": "b1_hearth_s2.png", "prompt": "a stone fireplace with burning fire", "seed": 230, "match": "partial", "note": "силуэт есть, огонь слабый" }, + { "file": "b1_lever_s1.png", "prompt": "a rusty metal lever on a stone base", "seed": 225, "match": "partial", "note": "на 24×24 рычаг на подставке, на 16 — палка" }, + { "file": "b1_lever_s2.png", "prompt": "a rusty metal lever on a stone base", "seed": 226, "match": "bad", "note": "похоже на лимон" }, + { "file": "b1_mote_s1.png", "prompt": "a glowing wisp of light", "seed": 235, "match": "good", "note": "светящийся ореол с ядром — как моты" }, + { "file": "b1_mote_s2.png", "prompt": "a glowing wisp of light", "seed": 236, "match": "partial", "note": "ядро тёмное, свечение по краю" }, + { "file": "b1_signpost_s1.png", "prompt": "a wooden signpost with a small sign board", "seed": 223, "match": "good", "note": "столб с доской-указателем" }, + { "file": "b1_signpost_s2.png", "prompt": "a wooden signpost with a small sign board", "seed": 224, "match": "good", "note": "столб с двумя досками" }, + { "file": "b1_well_s1.png", "prompt": "a stone well with a wooden roof", "seed": 221, "match": "partial", "note": "на 24×24 good (кладка, проём) — убил даунскейл 24→16" }, + { "file": "b1_well_s2.png", "prompt": "a stone well with a wooden roof", "seed": 222, "match": "bad", "note": "серая клякса" } + ], + "verdict": { + "good": 8, + "partial": 7, + "bad": 3, + "total": 18, + "goodShare": "44%", + "conclusion": "Критерий 30% выполнен; главное — отказов нет, доменные интерактивы модель рисует (PXG-Tiny отказывала). Но: качество неровное, даунскейл 24→16 губит деталь (колодец good на 24×24), фон часто залит, ровно 5 цветов на спрайт, ~22 с/спрайт на CPU. Решающее ограничение — лицензии на код/веса нет, в игру нельзя. Итог: локальные GPT-пиксель-модели домен тянут частично (PXG-Tiny — инвентарь/декор, PixelGPT — простые объекты на 24×24); для критичных интерактивов — RD API (платный) или ручной арт. Дообучение под игру решением не считается (отвергнуто)." + } +} \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/manifest.json b/v1/apps/game/tools/aiart/manifest.json new file mode 100644 index 0000000..89aba36 --- /dev/null +++ b/v1/apps/game/tools/aiart/manifest.json @@ -0,0 +1,43 @@ +{ + "run": "b0-poc", + "date": "2026-09-06", + "model": "PXG-Tiny (Tarul/pxg-tiny), NumPy CPU, 16x16, код+веса MIT/CC0", + "pipeline": "cli.py -> review/*.png -> post.mjs (кроп альфы, nearest, квантизация palette.mjs) -> processed/", + "criterion": ">= 30% спрайтов годных без ручной правки", + "items": [ + { "file": "a_chest_s1.png", "prompt": "a chest", "seed": 1, "gate": "accept", "match": "good", "note": "золотой сундук, годен" }, + { "file": "a_chest_s2.png", "prompt": "a chest", "seed": 2, "gate": "accept", "match": "good", "note": "серебряный сундук, годен" }, + { "file": "a_rock_s1.png", "prompt": "a rock", "seed": 1, "gate": "accept", "match": "good", "note": "валун, годен" }, + { "file": "a_rock_s2.png", "prompt": "a rock", "seed": 2, "gate": "accept", "match": "good", "note": "валун, годен" }, + { "file": "a_table_s1.png", "prompt": "a table", "seed": 1, "gate": "accept", "match": "good", "note": "стол, годен" }, + { "file": "a_table_s2.png", "prompt": "a table", "seed": 2, "gate": "accept", "match": "good", "note": "стол, годен" }, + { "file": "102_a_golden_key.png", "prompt": "a golden key", "seed": 102, "gate": "accept", "match": "good", "note": "ключ, годен" }, + { "file": "111_an_iron_sword.png", "prompt": "an iron sword", "seed": 111, "gate": "accept", "match": "good", "note": "меч, годен" }, + { "file": "109_a_healing_potion_in_a_round_.png", "prompt": "a healing potion in a round bottle", "seed": 109, "gate": "accept", "match": "partial", "note": "форма ок, цвет ушёл в W3 при квантизации (зелёного в палитре нет)" }, + { "file": "107_a_wooden_chair.png", "prompt": "a wooden chair", "seed": 107, "gate": "accept", "match": "partial", "note": "похоже на табурет, читаемо" }, + { "file": "101_an_iron_chest_with_bronze_tr.png", "prompt": "an iron chest with bronze trim", "seed": 101, "gate": "accept", "match": "bad", "note": "сгенерировал рыцаря вместо сундука" }, + { "file": "a_coin_s1.png", "prompt": "a coin", "seed": 1, "gate": "accept", "match": "bad", "note": "табурет вместо монеты" }, + { "file": "a_coin_s2.png", "prompt": "a coin", "seed": 2, "gate": "accept", "match": "bad", "note": "табурет вместо монеты" }, + { "file": "an_oak_s1.png", "prompt": "an oak", "seed": 1, "gate": "accept", "match": "bad", "note": "нечитаемая каша" }, + { "file": "an_oak_s2.png", "prompt": "an oak", "seed": 2, "gate": "accept", "match": "bad", "note": "котелок вместо дерева" }, + { "file": "105_an_old_oak_tree.png", "prompt": "an old oak tree", "seed": 105, "gate": "accept", "match": "bad", "note": "рыжая каша" }, + { "file": "106_a_wooden_table.png", "prompt": "a wooden table", "seed": 106, "gate": "accept", "match": "partial", "note": "стол-табурет" }, + { "file": "108_a_small_grey_stone_house.png", "prompt": "a small grey stone house", "seed": 108, "gate": "accept", "match": "bad", "note": "наковальня-кувшин" }, + { "file": "110_a_wooden_staff.png", "prompt": "a wooden staff", "seed": 110, "gate": "accept", "match": "bad", "note": "доска вместо посоха" }, + { "file": "112_a_bronze_coin.png", "prompt": "a bronze coin", "seed": 112, "gate": "accept", "match": "bad", "note": "кирпич" } + ], + "refused": [ + { "prompt": "a grey crystal shard", "gate": "clarify", "note": "shard вне лексикона" }, + { "prompt": "a grey stone", "gate": "clarify", "note": "stone вне лексикона (rock — есть)" }, + { "prompt": "a bronze bell", "gate": "clarify", "note": "колокол нет в классах — ожидаемо" }, + { "prompt": "a yellow flower", "gate": "clarify", "note": "цветов нет в классах — критично для игры" } + ], + "verdict": { + "good": 8, + "partial": 3, + "bad": 9, + "total": 20, + "goodShare": "40%", + "conclusion": "Критерий 30% выполнен, стиль совпадает с игрой на 100% (чанки-пиксель, палитра). Но домен модели узкий: критичные для игры ассеты (колодец, столб, рычаг, колокольчик, моты, очаг, прилавок) вне 38 классов. Вывод: PXG-Tiny годна для инвентаря/декора из её классов; для доменных ассетов нужен дообученный генератор, платный RD API или ручной арт." + } +} \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/post.mjs b/v1/apps/game/tools/aiart/post.mjs new file mode 100644 index 0000000..60086f7 --- /dev/null +++ b/v1/apps/game/tools/aiart/post.mjs @@ -0,0 +1,38 @@ +/** + * Пост-обработка AI-спрайтов «Пепельных лугов» — сценарий над жанронезависимым + * @rpg/engine/tools/imaging.mjs (декодер/кроп/квантизация — там). + * Запуск: node apps/game/tools/aiart/post.mjs <каталог с PNG> [каталог вывода=processed] + * → <вывод>/*.png (кроп по альфе, квантизация в палитру) + * → <вывод>/_sheet.png (обзорный лист для апрува) + * + * AI-вывод — только сырьё: в игру попадает после ручного апрува (манифест). + */ +import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; +import { normalizeToSize, quantize, reviewSheet } from '@rpg/engine/tools/imaging.mjs'; +import { PALETTE } from '../pixelart/palette.mjs'; + +const REVIEW = process.argv[2] ?? 'review'; +const OUT_NAME = process.argv[3] ?? 'processed'; +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const OUT = join(HERE, OUT_NAME); +const SIZE = 16; // целевой размер спрайта (арт-библия) + +/** Палитра игры как массив [r,g,b] для квантизации. */ +const PAL = Object.entries(PALETTE).map(([, hex]) => { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +}); + +const names = readdirSync(join(HERE, REVIEW)).filter((f) => f.endsWith('.png')); +mkdirSync(OUT, { recursive: true }); +const processed = names.map((name) => { + const img = decodePng(readFileSync(join(HERE, REVIEW, name))); + const rgba = quantize(normalizeToSize(img, SIZE), PAL); + writeFileSync(join(OUT, name), encodePng(SIZE, SIZE, Buffer.from(rgba))); + return { name, rgba }; +}); +reviewSheet(processed, join(OUT, '_sheet.png'), { size: SIZE }); +console.log(`обработано ${processed.length} -> ${OUT}`); \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/post_b4.mjs b/v1/apps/game/tools/aiart/post_b4.mjs new file mode 100644 index 0000000..534111b --- /dev/null +++ b/v1/apps/game/tools/aiart/post_b4.mjs @@ -0,0 +1,191 @@ +/** + * B4: пост-обработка кандидатов gen_b4.py в слоты арт-библии. + * Запуск: node apps/game/tools/aiart/post_b4.mjs + * raw/_.png → кроп фигуры (findFigures) → nearest в слот B4_SLOTS + * → квантизация в палитру игры → processed-b4/_.png + * → обзорный лист processed-b4/_sheet.png (кандидаты по объектам, для апрува) + * + * AI-вывод — только сырьё: в игру (assets/) попадает после ручного апрува. + */ +import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; +import { findFigures, quantize } from '@rpg/engine/tools/imaging.mjs'; +import { PALETTE } from '../pixelart/palette.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const RAW = join(HERE, 'review-b4', 'raw'); +const OUT = join(HERE, 'review-b4', 'processed'); + +/** Слоты кандидатов (id → w×h): интерактивы — по родам плейсхолдеров, иконки — 8×8. */ +const SLOTS = { + signpost: [16, 24], + mote: [16, 16], + tone_tree: [32, 48], + resonator: [32, 40], + bell_rope: [16, 24], + hearth: [32, 32], + note: [16, 16], + counter: [32, 16], + spit: [8, 8], + icon_clock: [8, 8], + icon_cloth: [8, 8], + icon_bellflower: [8, 8], + icon_salt: [8, 8], + icon_flask: [8, 8], + icon_mote: [8, 8], + icon_map: [8, 8] +}; + +/** Палитра игры как массив [r,g,b]. */ +const PAL = Object.entries(PALETTE).map(([, hex]) => { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +}); + +/** Фигура листа: bbox крупнейшей связной компоненты (фон — угловой пиксель). */ +function figureRect(img) { + const boxes = findFigures(img, { maxCount: 8, minArea: 2000 }); + if (boxes.length) { + return boxes.reduce((a, b) => (a.w * a.h >= b.w * b.h ? a : b)); + } + return { x: 0, y: 0, w: img.width, h: img.height }; +} + +/** + * Маска фона наращиванием от краёв (BFS): фон RD-вывода — гладкий градиент, + * угловой пиксель его не описывает. Шаг с малым допуском (фон плавный), у + * границы объекта скачок — рост останавливается. + */ +function bgMask(img) { + const W = img.width; + const H = img.height; + const mask = new Uint8Array(W * H); + const queue = []; + const seed = (x, y) => { + const p = y * W + x; + if (!mask[p] && img.rgba[p * 4 + 3] >= 128) { + mask[p] = 1; + queue.push(x, y); + } + }; + for (let x = 0; x < W; x++) { + seed(x, 0); + seed(x, H - 1); + } + for (let y = 0; y < H; y++) { + seed(0, y); + seed(W - 1, y); + } + const close = (a, b) => + Math.abs(img.rgba[a] - img.rgba[b]) + Math.abs(img.rgba[a + 1] - img.rgba[b + 1]) + + Math.abs(img.rgba[a + 2] - img.rgba[b + 2]) < 40; + for (let q = 0; q < queue.length; q += 2) { + const x = queue[q]; + const y = queue[q + 1]; + const p = y * W + x; + const nb = [ + [x - 1, y], + [x + 1, y], + [x, y - 1], + [x, y + 1] + ]; + for (const [nx, ny] of nb) { + if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; + const n = ny * W + nx; + if (!mask[n] && close(p, n)) { + mask[n] = 1; + queue.push(nx, ny); + } + } + } + return mask; +} + +/** + * Даунскейл прямоугольника усреднением ячеек (RD-вывод гладкий — nearest + * сэмплирует шум, усреднение даёт ровные цветовые области). Пиксели фоновой + * маски в среднее не входят; ячейка без фигуры — прозрачная. + */ +function meanRect(img, rect, w, h) { + const bg = bgMask(img); + const out = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sx0 = rect.x + Math.floor((x * rect.w) / w); + const sx1 = rect.x + Math.max(sx0 + 1, Math.floor(((x + 1) * rect.w) / w)); + const sy0 = rect.y + Math.floor((y * rect.h) / h); + const sy1 = rect.y + Math.max(sy0 + 1, Math.floor(((y + 1) * rect.h) / h)); + let r = 0, g = 0, b = 0, n = 0; + for (let sy = sy0; sy < sy1 && sy < img.height; sy++) { + for (let sx = sx0; sx < sx1 && sx < img.width; sx++) { + const i = (sy * img.width + sx) * 4; + if (bg[sy * img.width + sx] || img.rgba[i + 3] < 128) continue; + r += img.rgba[i]; g += img.rgba[i + 1]; b += img.rgba[i + 2]; n++; + } + } + if (n > 0) { + const i = (y * w + x) * 4; + out[i] = r / n; out[i + 1] = g / n; out[i + 2] = b / n; out[i + 3] = 255; + } + } + } + return out; +} + +/** Обзорный лист: кандидаты группами по объекту (имя — подпись группы). */ +function groupSheet(groups, path) { + const scale = 3; + const pad = 8; + const cellW = 40 * scale; + const cellH = 52 * scale; + const cols = Math.max(...groups.map((g) => g.items.length)); + const W = cols * (cellW + pad) + pad; + const H = groups.length * (cellH + pad + 14) + pad; + const bg = Buffer.alloc(W * H * 4); + for (let p = 0; p < bg.length; p += 4) { + bg[p] = 30; bg[p + 1] = 32; bg[p + 2] = 38; bg[p + 3] = 255; + } + groups.forEach((g, gy) => { + const y0 = pad + gy * (cellH + pad + 14); + g.items.forEach(({ rgba, w, h }, i) => { + const x0 = pad + i * (cellW + pad); + for (let y = 0; y < h * scale; y++) { + for (let x = 0; x < w * scale; x++) { + const sp = ((y / scale) | 0) * w + ((x / scale) | 0); + if (rgba[sp * 4 + 3] === 0) continue; + bg.set( + rgba.subarray(sp * 4, sp * 4 + 4), + ((y0 + y) * W + x0 + x) * 4 + ); + } + } + }); + }); + writeFileSync(path, encodePng(W, H, Buffer.from(bg))); + return { W, H }; +} + +const files = readdirSync(RAW).filter((f) => f.endsWith('.png')); +mkdirSync(OUT, { recursive: true }); +const byId = new Map(); +for (const name of files) { + const id = name.replace(/_\d+\.png$/, ''); + const [w, h] = SLOTS[id]; + if (!w) throw new Error(`нет слота для ${id}`); + const img = decodePng(readFileSync(join(RAW, name))); + const rgba = quantize(meanRect(img, figureRect(img), w, h), PAL); + writeFileSync(join(OUT, name), encodePng(w, h, Buffer.from(rgba))); + if (!byId.has(id)) byId.set(id, []); + byId.get(id).push({ name, rgba, w, h }); +} + +const groups = [...byId.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([id, items]) => ({ id, items })); +const info = groupSheet(groups, join(OUT, '_sheet.png')); +console.log( + `обработано ${files.length} (${groups.length} объектов) -> ${OUT}\n` + + `лист: ${info.W}x${info.H}` +); \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/probe-b3/frame_16x24.png b/v1/apps/game/tools/aiart/probe-b3/frame_16x24.png new file mode 100644 index 0000000..9b14c45 --- /dev/null +++ b/v1/apps/game/tools/aiart/probe-b3/frame_16x24.png Binary files differ diff --git a/v1/apps/game/tools/aiart/probe-b3/frame_16x24_x8.png b/v1/apps/game/tools/aiart/probe-b3/frame_16x24_x8.png new file mode 100644 index 0000000..91ced5a --- /dev/null +++ b/v1/apps/game/tools/aiart/probe-b3/frame_16x24_x8.png Binary files differ diff --git a/v1/apps/game/tools/aiart/probe-b3/sd_fss_42_15steps.png b/v1/apps/game/tools/aiart/probe-b3/sd_fss_42_15steps.png new file mode 100644 index 0000000..82663d6 --- /dev/null +++ b/v1/apps/game/tools/aiart/probe-b3/sd_fss_42_15steps.png Binary files differ diff --git a/v1/apps/game/tools/aiart/probe-b3/sd_fss_42_raw.png b/v1/apps/game/tools/aiart/probe-b3/sd_fss_42_raw.png new file mode 100644 index 0000000..630c7a0 --- /dev/null +++ b/v1/apps/game/tools/aiart/probe-b3/sd_fss_42_raw.png Binary files differ diff --git a/v1/apps/game/tools/aiart/probe-b3/sd_q8_fss_42_broken.png b/v1/apps/game/tools/aiart/probe-b3/sd_q8_fss_42_broken.png new file mode 100644 index 0000000..2364feb --- /dev/null +++ b/v1/apps/game/tools/aiart/probe-b3/sd_q8_fss_42_broken.png Binary files differ diff --git a/v1/apps/game/tools/aiart/probe-c2.png b/v1/apps/game/tools/aiart/probe-c2.png new file mode 100644 index 0000000..fce1a3d --- /dev/null +++ b/v1/apps/game/tools/aiart/probe-c2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/_sheet.png b/v1/apps/game/tools/aiart/processed-b1/_sheet.png new file mode 100644 index 0000000..0b93f7e --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/_sheet.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_bell_s1.png b/v1/apps/game/tools/aiart/processed-b1/b1_bell_s1.png new file mode 100644 index 0000000..53994eb --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_bell_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_bell_s2.png b/v1/apps/game/tools/aiart/processed-b1/b1_bell_s2.png new file mode 100644 index 0000000..d10130a --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_bell_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_chest_s1.png b/v1/apps/game/tools/aiart/processed-b1/b1_chest_s1.png new file mode 100644 index 0000000..b91e10c --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_chest_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_chest_s2.png b/v1/apps/game/tools/aiart/processed-b1/b1_chest_s2.png new file mode 100644 index 0000000..dba9c40 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_chest_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_counter_s1.png b/v1/apps/game/tools/aiart/processed-b1/b1_counter_s1.png new file mode 100644 index 0000000..dee7604 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_counter_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_counter_s2.png b/v1/apps/game/tools/aiart/processed-b1/b1_counter_s2.png new file mode 100644 index 0000000..2f51d9e --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_counter_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_door_s1.png b/v1/apps/game/tools/aiart/processed-b1/b1_door_s1.png new file mode 100644 index 0000000..3fe9340 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_door_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_door_s2.png b/v1/apps/game/tools/aiart/processed-b1/b1_door_s2.png new file mode 100644 index 0000000..1befd14 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_door_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_hearth_s1.png b/v1/apps/game/tools/aiart/processed-b1/b1_hearth_s1.png new file mode 100644 index 0000000..69c3284 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_hearth_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_hearth_s2.png b/v1/apps/game/tools/aiart/processed-b1/b1_hearth_s2.png new file mode 100644 index 0000000..b9ac7e3 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_hearth_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_lever_s1.png b/v1/apps/game/tools/aiart/processed-b1/b1_lever_s1.png new file mode 100644 index 0000000..30b49c5 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_lever_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_lever_s2.png b/v1/apps/game/tools/aiart/processed-b1/b1_lever_s2.png new file mode 100644 index 0000000..b61b9d7 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_lever_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_mote_s1.png b/v1/apps/game/tools/aiart/processed-b1/b1_mote_s1.png new file mode 100644 index 0000000..9f42c2a --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_mote_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_mote_s2.png b/v1/apps/game/tools/aiart/processed-b1/b1_mote_s2.png new file mode 100644 index 0000000..6250da8 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_mote_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_signpost_s1.png b/v1/apps/game/tools/aiart/processed-b1/b1_signpost_s1.png new file mode 100644 index 0000000..efb564c --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_signpost_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_signpost_s2.png b/v1/apps/game/tools/aiart/processed-b1/b1_signpost_s2.png new file mode 100644 index 0000000..7de5fbf --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_signpost_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_well_s1.png b/v1/apps/game/tools/aiart/processed-b1/b1_well_s1.png new file mode 100644 index 0000000..8126474 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_well_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed-b1/b1_well_s2.png b/v1/apps/game/tools/aiart/processed-b1/b1_well_s2.png new file mode 100644 index 0000000..822baf3 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed-b1/b1_well_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/101_an_iron_chest_with_bronze_tr.png b/v1/apps/game/tools/aiart/processed/101_an_iron_chest_with_bronze_tr.png new file mode 100644 index 0000000..1631a09 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/101_an_iron_chest_with_bronze_tr.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/102_a_golden_key.png b/v1/apps/game/tools/aiart/processed/102_a_golden_key.png new file mode 100644 index 0000000..b21b1b1 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/102_a_golden_key.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/105_an_old_oak_tree.png b/v1/apps/game/tools/aiart/processed/105_an_old_oak_tree.png new file mode 100644 index 0000000..89b38fe --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/105_an_old_oak_tree.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/106_a_wooden_table.png b/v1/apps/game/tools/aiart/processed/106_a_wooden_table.png new file mode 100644 index 0000000..1ecb360 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/106_a_wooden_table.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/107_a_wooden_chair.png b/v1/apps/game/tools/aiart/processed/107_a_wooden_chair.png new file mode 100644 index 0000000..72a854f --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/107_a_wooden_chair.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/108_a_small_grey_stone_house.png b/v1/apps/game/tools/aiart/processed/108_a_small_grey_stone_house.png new file mode 100644 index 0000000..133ecfb --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/108_a_small_grey_stone_house.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/109_a_healing_potion_in_a_round_.png b/v1/apps/game/tools/aiart/processed/109_a_healing_potion_in_a_round_.png new file mode 100644 index 0000000..148c00b --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/109_a_healing_potion_in_a_round_.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/110_a_wooden_staff.png b/v1/apps/game/tools/aiart/processed/110_a_wooden_staff.png new file mode 100644 index 0000000..7cb38f2 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/110_a_wooden_staff.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/111_an_iron_sword.png b/v1/apps/game/tools/aiart/processed/111_an_iron_sword.png new file mode 100644 index 0000000..f1fe824 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/111_an_iron_sword.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/112_a_bronze_coin.png b/v1/apps/game/tools/aiart/processed/112_a_bronze_coin.png new file mode 100644 index 0000000..65531fb --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/112_a_bronze_coin.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/_sheet.png b/v1/apps/game/tools/aiart/processed/_sheet.png new file mode 100644 index 0000000..9f0ecf4 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/_sheet.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/a_chest_s1.png b/v1/apps/game/tools/aiart/processed/a_chest_s1.png new file mode 100644 index 0000000..c5205ef --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/a_chest_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/a_chest_s2.png b/v1/apps/game/tools/aiart/processed/a_chest_s2.png new file mode 100644 index 0000000..4dd6669 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/a_chest_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/a_coin_s1.png b/v1/apps/game/tools/aiart/processed/a_coin_s1.png new file mode 100644 index 0000000..a22534f --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/a_coin_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/a_coin_s2.png b/v1/apps/game/tools/aiart/processed/a_coin_s2.png new file mode 100644 index 0000000..a22534f --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/a_coin_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/a_rock_s1.png b/v1/apps/game/tools/aiart/processed/a_rock_s1.png new file mode 100644 index 0000000..68e7e8a --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/a_rock_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/a_rock_s2.png b/v1/apps/game/tools/aiart/processed/a_rock_s2.png new file mode 100644 index 0000000..4e219f6 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/a_rock_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/a_table_s1.png b/v1/apps/game/tools/aiart/processed/a_table_s1.png new file mode 100644 index 0000000..924a5e4 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/a_table_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/a_table_s2.png b/v1/apps/game/tools/aiart/processed/a_table_s2.png new file mode 100644 index 0000000..924a5e4 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/a_table_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/an_oak_s1.png b/v1/apps/game/tools/aiart/processed/an_oak_s1.png new file mode 100644 index 0000000..e707411 --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/an_oak_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/processed/an_oak_s2.png b/v1/apps/game/tools/aiart/processed/an_oak_s2.png new file mode 100644 index 0000000..b984d7b --- /dev/null +++ b/v1/apps/game/tools/aiart/processed/an_oak_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/prompts-b0.txt b/v1/apps/game/tools/aiart/prompts-b0.txt new file mode 100644 index 0000000..d039504 --- /dev/null +++ b/v1/apps/game/tools/aiart/prompts-b0.txt @@ -0,0 +1,17 @@ +# B0 PoC: промпты под домен «Пепельных лугов» (классы PXG-Tiny, 16×16). +# Формат: seed|промпт. Последние два — намеренно вне домена классов +# (колокол, цветок) для проверки поведения отказа. +101|an iron chest with bronze trim +102|a golden key +103|a grey crystal shard +104|a grey stone +105|an old oak tree +106|a wooden table +107|a wooden chair +108|a small grey stone house +109|a healing potion in a round bottle +110|a wooden staff +111|an iron sword +112|a bronze coin +113|a bronze bell +114|a yellow flower \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/prompts-b1.txt b/v1/apps/game/tools/aiart/prompts-b1.txt new file mode 100644 index 0000000..a984fd7 --- /dev/null +++ b/v1/apps/game/tools/aiart/prompts-b1.txt @@ -0,0 +1,22 @@ +# B1: доменные интерактивы «Пепельных лугов» через PixelGPT-24×24 (5 цветов из палитры). +# Формат: seed|имя|промпт|5 ключей палитры (первый — самый тёмный). +# С 220 по 227 — доменные объекты (вне 38 классов PXG-Tiny, отказ в B0); +# 228-229 — контрольные (PXG-Tiny их уже умела — для сравнения качества). +221|b1_well_s1|a stone well with a wooden roof|P2,P4,P6,P5H,R1 +222|b1_well_s2|a stone well with a wooden roof|P2,P4,P6,P5H,R1 +223|b1_signpost_s1|a wooden signpost with a small sign board|R1,R2,P3,P5,P5H +224|b1_signpost_s2|a wooden signpost with a small sign board|R1,R2,P3,P5,P5H +225|b1_lever_s1|a rusty metal lever on a stone base|R0,R1,R2,B0,P3 +226|b1_lever_s2|a rusty metal lever on a stone base|R0,R1,R2,B0,P3 +227|b1_bell_s1|a small bronze bell with a handle|B0,B1,B2,R1,P2 +228|b1_bell_s2|a small bronze bell with a handle|B0,B1,B2,R1,P2 +229|b1_hearth_s1|a stone fireplace with burning fire|P2,P3,R0,R1,F0 +230|b1_hearth_s2|a stone fireplace with burning fire|P2,P3,R0,R1,F0 +231|b1_counter_s1|a wooden shop counter with goods|R1,R2,P2,P4,B0 +232|b1_counter_s2|a wooden shop counter with goods|R1,R2,P2,P4,B0 +233|b1_door_s1|an old wooden door with iron hinges|R0,R1,R2,P3,B0 +234|b1_door_s2|an old wooden door with iron hinges|R0,R1,R2,P3,B0 +235|b1_mote_s1|a glowing wisp of light|P6,P5H,B1,B2,W2 +236|b1_mote_s2|a glowing wisp of light|P6,P5H,B1,B2,W2 +237|b1_chest_s1|a wooden chest with bronze trim|R1,B0,B1,B2,P3 +238|b1_chest_s2|a wooden chest with bronze trim|R1,B0,B1,B2,P3 \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/recolor.mjs b/v1/apps/game/tools/aiart/recolor.mjs new file mode 100644 index 0000000..955577a --- /dev/null +++ b/v1/apps/game/tools/aiart/recolor.mjs @@ -0,0 +1,64 @@ +/** + * Палитровый ремапер: перекраска квантованного спрайта маппингом цветов. + * Две потребности (docs/plan.md, работа B): привести «поплавшие» промпт-цвета + * AI-генерации к каноническим и дешёвые цветовые вариации NPC из одного спрайта. + * + * CLI: node recolor.mjs <из=в ...> + * цвета — ключи палитры (C1) или #hex; вход должен быть квантован + * в палитру (иначе «из» не найдётся — замена по точному совпадению). + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; +import { PALETTE } from '../pixelart/palette.mjs'; + +/** Цвет из аргумента: ключ палитры (C1) или #hex → [r,g,b]. */ +export function parseColor(token) { + if (!token) throw new Error('пустой цвет'); + if (token.startsWith('#')) { + const n = parseInt(token.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + } + if (!(token in PALETTE)) throw new Error(`«${token}» — нет такого ключа палитры`); + return hexToRgb(PALETTE[token]); +} + +function hexToRgb(hex) { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +/** + * Замена цветов по точному совпадению RGB (alpha не трогаем). + * @param pairs массив [[fromRGB, toRGB], ...] + */ +export function remap(rgba, pairs) { + const lookup = new Map(pairs.map(([from, to]) => [from.join(','), to])); + const out = new Uint8Array(rgba); + for (let p = 0; p < out.length; p += 4) { + const to = lookup.get(`${out[p]},${out[p + 1]},${out[p + 2]}`); + if (to) { + out[p] = to[0]; out[p + 1] = to[1]; out[p + 2] = to[2]; + } + } + return out; +} + +// ---------- CLI (только при прямом запуске) ---------- + +const invoked = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invoked) { + const [, , src, dst, ...tokens] = process.argv; + if (!src || !dst || !tokens.length) { + console.log('Использование: node recolor.mjs <из=в ...> (цвета: ключ палитры или #hex)'); + process.exit(1); + } + const pairs = tokens.map((t) => { + const [from, to] = t.split('='); + return [parseColor(from), parseColor(to)]; + }); + const img = decodePng(readFileSync(src)); + writeFileSync(dst, encodePng(img.width, img.height, Buffer.from(remap(img.rgba, pairs)))); + console.log(`[recolor] ${src} -> ${dst} (замены: ${pairs.map(([f, t]) => `#${f.map((v) => v.toString(16).padStart(2, '0')).join('')}→#${t.map((v) => v.toString(16).padStart(2, '0')).join('')}`).join(', ')})`); +} \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/review-b1/b1_bell_s1.png b/v1/apps/game/tools/aiart/review-b1/b1_bell_s1.png new file mode 100644 index 0000000..4fc73ae --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_bell_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_bell_s2.png b/v1/apps/game/tools/aiart/review-b1/b1_bell_s2.png new file mode 100644 index 0000000..7d8fd4f --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_bell_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_chest_s1.png b/v1/apps/game/tools/aiart/review-b1/b1_chest_s1.png new file mode 100644 index 0000000..236eedc --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_chest_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_chest_s2.png b/v1/apps/game/tools/aiart/review-b1/b1_chest_s2.png new file mode 100644 index 0000000..cb69c03 --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_chest_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_counter_s1.png b/v1/apps/game/tools/aiart/review-b1/b1_counter_s1.png new file mode 100644 index 0000000..480011f --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_counter_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_counter_s2.png b/v1/apps/game/tools/aiart/review-b1/b1_counter_s2.png new file mode 100644 index 0000000..49e68ff --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_counter_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_door_s1.png b/v1/apps/game/tools/aiart/review-b1/b1_door_s1.png new file mode 100644 index 0000000..01718c0 --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_door_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_door_s2.png b/v1/apps/game/tools/aiart/review-b1/b1_door_s2.png new file mode 100644 index 0000000..5d00fbd --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_door_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_hearth_s1.png b/v1/apps/game/tools/aiart/review-b1/b1_hearth_s1.png new file mode 100644 index 0000000..8ebeeeb --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_hearth_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_hearth_s2.png b/v1/apps/game/tools/aiart/review-b1/b1_hearth_s2.png new file mode 100644 index 0000000..160376b --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_hearth_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_lever_s1.png b/v1/apps/game/tools/aiart/review-b1/b1_lever_s1.png new file mode 100644 index 0000000..c50bd15 --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_lever_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_lever_s2.png b/v1/apps/game/tools/aiart/review-b1/b1_lever_s2.png new file mode 100644 index 0000000..8a34473 --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_lever_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_mote_s1.png b/v1/apps/game/tools/aiart/review-b1/b1_mote_s1.png new file mode 100644 index 0000000..805d925 --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_mote_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_mote_s2.png b/v1/apps/game/tools/aiart/review-b1/b1_mote_s2.png new file mode 100644 index 0000000..3aeb83c --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_mote_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_signpost_s1.png b/v1/apps/game/tools/aiart/review-b1/b1_signpost_s1.png new file mode 100644 index 0000000..1dab36e --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_signpost_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_signpost_s2.png b/v1/apps/game/tools/aiart/review-b1/b1_signpost_s2.png new file mode 100644 index 0000000..75cbaa1 --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_signpost_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_well_s1.png b/v1/apps/game/tools/aiart/review-b1/b1_well_s1.png new file mode 100644 index 0000000..9e5d5d0 --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_well_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review-b1/b1_well_s2.png b/v1/apps/game/tools/aiart/review-b1/b1_well_s2.png new file mode 100644 index 0000000..2244504 --- /dev/null +++ b/v1/apps/game/tools/aiart/review-b1/b1_well_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/101_an_iron_chest_with_bronze_tr.png b/v1/apps/game/tools/aiart/review/101_an_iron_chest_with_bronze_tr.png new file mode 100644 index 0000000..8203cdc --- /dev/null +++ b/v1/apps/game/tools/aiart/review/101_an_iron_chest_with_bronze_tr.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/102_a_golden_key.png b/v1/apps/game/tools/aiart/review/102_a_golden_key.png new file mode 100644 index 0000000..4d3c489 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/102_a_golden_key.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/105_an_old_oak_tree.png b/v1/apps/game/tools/aiart/review/105_an_old_oak_tree.png new file mode 100644 index 0000000..8304d58 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/105_an_old_oak_tree.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/106_a_wooden_table.png b/v1/apps/game/tools/aiart/review/106_a_wooden_table.png new file mode 100644 index 0000000..db6e07b --- /dev/null +++ b/v1/apps/game/tools/aiart/review/106_a_wooden_table.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/107_a_wooden_chair.png b/v1/apps/game/tools/aiart/review/107_a_wooden_chair.png new file mode 100644 index 0000000..bdb1333 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/107_a_wooden_chair.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/108_a_small_grey_stone_house.png b/v1/apps/game/tools/aiart/review/108_a_small_grey_stone_house.png new file mode 100644 index 0000000..f2a2377 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/108_a_small_grey_stone_house.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/109_a_healing_potion_in_a_round_.png b/v1/apps/game/tools/aiart/review/109_a_healing_potion_in_a_round_.png new file mode 100644 index 0000000..0209e11 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/109_a_healing_potion_in_a_round_.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/110_a_wooden_staff.png b/v1/apps/game/tools/aiart/review/110_a_wooden_staff.png new file mode 100644 index 0000000..ddf7d04 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/110_a_wooden_staff.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/111_an_iron_sword.png b/v1/apps/game/tools/aiart/review/111_an_iron_sword.png new file mode 100644 index 0000000..a92391d --- /dev/null +++ b/v1/apps/game/tools/aiart/review/111_an_iron_sword.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/112_a_bronze_coin.png b/v1/apps/game/tools/aiart/review/112_a_bronze_coin.png new file mode 100644 index 0000000..a6106a4 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/112_a_bronze_coin.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/a_chest_s1.png b/v1/apps/game/tools/aiart/review/a_chest_s1.png new file mode 100644 index 0000000..4657c47 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/a_chest_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/a_chest_s2.png b/v1/apps/game/tools/aiart/review/a_chest_s2.png new file mode 100644 index 0000000..0061bbb --- /dev/null +++ b/v1/apps/game/tools/aiart/review/a_chest_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/a_coin_s1.png b/v1/apps/game/tools/aiart/review/a_coin_s1.png new file mode 100644 index 0000000..2561e82 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/a_coin_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/a_coin_s2.png b/v1/apps/game/tools/aiart/review/a_coin_s2.png new file mode 100644 index 0000000..2561e82 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/a_coin_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/a_rock_s1.png b/v1/apps/game/tools/aiart/review/a_rock_s1.png new file mode 100644 index 0000000..6adfdeb --- /dev/null +++ b/v1/apps/game/tools/aiart/review/a_rock_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/a_rock_s2.png b/v1/apps/game/tools/aiart/review/a_rock_s2.png new file mode 100644 index 0000000..99dd8f8 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/a_rock_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/a_table_s1.png b/v1/apps/game/tools/aiart/review/a_table_s1.png new file mode 100644 index 0000000..a0e22df --- /dev/null +++ b/v1/apps/game/tools/aiart/review/a_table_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/a_table_s2.png b/v1/apps/game/tools/aiart/review/a_table_s2.png new file mode 100644 index 0000000..a0e22df --- /dev/null +++ b/v1/apps/game/tools/aiart/review/a_table_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/an_oak_s1.png b/v1/apps/game/tools/aiart/review/an_oak_s1.png new file mode 100644 index 0000000..59c2a00 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/an_oak_s1.png Binary files differ diff --git a/v1/apps/game/tools/aiart/review/an_oak_s2.png b/v1/apps/game/tools/aiart/review/an_oak_s2.png new file mode 100644 index 0000000..c372350 --- /dev/null +++ b/v1/apps/game/tools/aiart/review/an_oak_s2.png Binary files differ diff --git a/v1/apps/game/tools/aiart/sd_sheet.py b/v1/apps/game/tools/aiart/sd_sheet.py new file mode 100644 index 0000000..4654c86 --- /dev/null +++ b/v1/apps/game/tools/aiart/sd_sheet.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""AI-шаг пайплайна атласов: генерация листа одного ракурса (SD1.5, CPU). + +Модель: SD_PixelArt_SpriteSheet_Generator (Onodofthenorth, Apache-2.0). +Токены ракурсов: PixelartFSS (фронт), PixelartRSS (право), PixelartBSS (зад), +PixelartLSS (лево). 15 шагов / cfg 7.0 — рабочий режим пробы B3 (~6.6 мин/вид +на 4 потоках fp32). Промпт персонажа приходит из манифеста пайплайна. + +Запуск (интерпретатор — env RPG_SD_PYTHON, дефолт venv-pxgpt): + $RPG_SD_PYTHON apps/game/tools/aiart/sd_sheet.py +""" +import sys +import time +from pathlib import Path + +import torch +from diffusers import StableDiffusionPipeline +from huggingface_hub import snapshot_download + +MODEL_ID = 'Onodofthenorth/SD_PixelArt_SpriteSheet_Generator' +STEPS = 15 +CFG = 7.0 +NEG = ('blurry, photo, realistic, 3d render, smooth gradients, text, watermark, ' + 'multiple characters, cropped') + + +def main() -> None: + view, seed, out_dir = sys.argv[1], int(sys.argv[2]), Path(sys.argv[3]) + prompt = sys.argv[4] + out_dir.mkdir(parents=True, exist_ok=True) + + torch.set_num_threads(4) + base = snapshot_download(MODEL_ID, local_files_only=True) + t0 = time.time() + pipe = StableDiffusionPipeline.from_pretrained(str(base), safety_checker=None) + pipe.set_progress_bar_config(disable=True) + print(f'[load] {time.time() - t0:.0f}s', flush=True) + + t0 = time.time() + img = pipe( + f'Pixelart{view} of {prompt}', + negative_prompt=NEG, + num_inference_steps=STEPS, + guidance_scale=CFG, + generator=torch.Generator('cpu').manual_seed(seed), + ).images[0] + print(f'[gen] {time.time() - t0:.0f}s', flush=True) + + out = out_dir / f'{view}_{seed}.png' + img.convert('RGBA').save(out) + print(f'[ok] {out}', flush=True) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/v1/apps/game/tools/aiart/tryon.mjs b/v1/apps/game/tools/aiart/tryon.mjs new file mode 100644 index 0000000..728ddab --- /dev/null +++ b/v1/apps/game/tools/aiart/tryon.mjs @@ -0,0 +1,61 @@ +/** + * Примерка AI-атласа в игре ДО promote: временно подменяет целевой атлас + * сборкой из build//, снимает скриншот героя в локации, возвращает файлы. + * Апрув по обзорному листу — пиксели на клетке; примерка показывает якорь, + * масштаб и палитру в контексте мира. + * + * node tryon.mjs [файл-скриншота=/tmp/rpg_tryon.png] + * + * Файлы assets восстанавливаются в finally (даже при падении). + */ +import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { startDevServer, openGame } from '../lib.mjs'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const id = process.argv[2] ?? 'bellringer'; +const shot = process.argv[3] ?? '/tmp/rpg_tryon.png'; + +// Цель берём из манифеста (target), как её видит promote. +const manifest = JSON.parse(readFileSync(join(HERE, 'atlas-manifest.json'), 'utf8')); +const char = manifest.characters.find((c) => c.id === id); +if (!char) throw new Error(`персонаж «${id}» не найден в atlas-manifest.json`); +const buildPng = join(HERE, 'build', id, char.target); +if (!existsSync(buildPng)) throw new Error(`нет сборки ${buildPng} — сначала atlas.mjs build ${id}`); + +const dst = join(HERE, '..', '..', 'assets'); +const dstPng = join(dst, char.target); +const dstJson = dstPng.replace(/\.png$/, '.json'); +const backup = { + png: existsSync(dstPng) ? readFileSync(dstPng) : null, + json: existsSync(dstJson) ? readFileSync(dstJson) : null +}; + +let server = null, browser = null; +try { + copyFileSync(buildPng, dstPng); + // Игра ждёт кадры с префиксом hero_ (LocationScene: hero_down/up/side) — + // переписываем имена кадров сборки в JSON-копии. + const atlasJson = JSON.parse(readFileSync(join(HERE, 'build', id, char.target.replace(/\.png$/, '.json')), 'utf8')); + atlasJson.frames = Object.fromEntries( + Object.entries(atlasJson.frames).map(([k, v]) => [k.replace(new RegExp(`^${id}_`), 'hero_'), v]) + ); + writeFileSync(dstJson, JSON.stringify(atlasJson)); + console.log(`[tryon] ${char.target} подменён сборкой «${id}» (status: ${char.status}, кадры → hero_*)`); + + server = await startDevServer({ port: 5199 }); + const { browser: b, page, agent } = await openGame({ url: server.url, newGame: true }); + browser = b; + await agent.step(60); // пара шагов, чтобы герой попал в кадр ходьбы + const snap = await agent.snapshot(); + console.log('снапшот:', JSON.stringify({ area: snap.area, hero: snap.hero?.tile })); + await page.screenshot({ path: shot }); + console.log(`Скриншот: ${shot}`); +} finally { + if (backup.png) writeFileSync(dstPng, backup.png); + if (backup.json) writeFileSync(dstJson, backup.json); + if (backup.png === null) console.log('[tryon] внимание: исходного атласа не было — проверь assets вручную'); + if (browser) await browser.close(); + if (server) server.stop(); +} \ No newline at end of file diff --git a/v1/apps/game/tools/aiaudio/analyze.py b/v1/apps/game/tools/aiaudio/analyze.py new file mode 100644 index 0000000..d35e3bc --- /dev/null +++ b/v1/apps/game/tools/aiaudio/analyze.py @@ -0,0 +1,68 @@ +"""Численный отбор AI-клипов (работа A1): метрики вместо слушания. + +Для review/__loop.wav считает: RMS, диапазон огибающей (дыхание +лупа — std скользящего RMS), долю высокочастотной энергии (резкость/жёсткость), +центроид спектра, число клиппингов. Кандидаты против эталона сравниваются +по этим цифрам; финальный отбор — таблицей в консоль. + +Запуск: ~/.cache/rpg-ai/venv-aiaudio/bin/python tools/aiaudio/analyze.py +""" +import sys +import wave +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent + + +def read_wav(path: Path) -> np.ndarray: + with wave.open(str(path), "rb") as w: + data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) + return data.astype(np.float64) / 32768.0 + + +def metrics(s: np.ndarray, rate: int) -> dict: + win = rate // 10 # окно огибающей 100 мс + n = len(s) // win + env = np.sqrt((s[: n * win].reshape(n, win) ** 2).mean(axis=1)) + spec = np.abs(np.fft.rfft(s * np.hanning(len(s)))) + freqs = np.fft.rfftfreq(len(s), 1 / rate) + total = spec.sum() + 1e-12 + hi = spec[freqs > 4000].sum() / total # резкость: доля >4 кГц + centroid = (spec * freqs).sum() / total + clips = int((np.abs(s) > 0.99).sum()) + return { + "rms": float(np.sqrt((s**2).mean())), + "dyn": float(env.std() / (env.mean() + 1e-12)), # дыхание: неровность огибающей + "hi": float(hi), + "cent": float(centroid), + "clip": clips, + } + + +def main() -> None: + import glob + + files: list[Path] = [] + for pat in sys.argv[1:]: + # Шаблон относительный: сначала от cwd, при пустом результате — от tools/aiaudio. + for base in (Path.cwd(), HERE): + found = sorted(glob.glob(str(base / pat))) + if found: + files.extend(Path(p) for p in found) + break + if not files: + print("нет файлов под шаблонами") + sys.exit(1) + print(f"{'файл':44} {'rms':>6} {'dyn':>6} {'hi':>6} {'cent':>6} {'clip':>5}") + for f in files: + m = metrics(*((lambda r, rate: (r, rate))(read_wav(f), 22050))) + print( + f"{f.name:44} {m['rms']:6.3f} {m['dyn']:6.3f} {m['hi']:6.3f} " + f"{m['cent']:6.0f} {m['clip']:5d}" + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/v1/apps/game/tools/aiaudio/gen_audioldm2.py b/v1/apps/game/tools/aiaudio/gen_audioldm2.py new file mode 100644 index 0000000..90dd70e --- /dev/null +++ b/v1/apps/game/tools/aiaudio/gen_audioldm2.py @@ -0,0 +1,100 @@ +"""Генерация AI-аудио (работа A, этап A1): text-to-audio через AudioLDM2 по манифесту. + +Манифест — apps/game/tools/aiaudio/manifest.json: промпты, сиды, длительности, +gate — единственный источник истины пайплайна (аналог atlas-manifest.json в +aiart). Модель — AudioLDM2 (cvssp/audioldm2, CC-BY-NC-SA; решение A1 2026-09-09: +игра некоммерческая. Смена модели = новый gen-скрипт с тем же CLI). + +Запуск (обычно через runner: npm run aiaudio gen [id[:seed,seed]...]): + ~/.cache/rpg-ai/venv-aiaudio/bin/python gen_audioldm2.py [id[:seed]...] +Без аргументов — все сиды всех позиций манифеста. Выход — review/_.wav +(16000 Гц, моно); луп/нормализация — post.py (runner запускает сам). + +Детерминизм: seed + шаги фиксированы, промпт в манифесте; детерминизм +по-устройству (fp16 GPU ≠ fp32 CPU). GPU (RTX 3090) при наличии: fp16. +""" +import json +import sys +from pathlib import Path + +import numpy as np +import torch + +HERE = Path(__file__).resolve().parent +REVIEW = HERE / "review" +MANIFEST = HERE / "manifest.json" + +NEG_NOISE = ( + "music, melody, rhythm, drums, speech, voices, sudden loud noises, " + "harsh noise, distortion" +) + +DEFAULT_SEED = 1 + + +def load_items() -> dict[str, dict]: + """Позиции манифеста по id (промпт/длительность/сиды — источник истины).""" + data = json.loads(MANIFEST.read_text(encoding="utf-8")) + return {it["id"]: it for it in data["items"]} + + +def load_pipeline(): + from diffusers import AudioLDM2Pipeline + + use_gpu = torch.cuda.is_available() + dtype = torch.float16 if use_gpu else torch.float32 + pipe = AudioLDM2Pipeline.from_pretrained("cvssp/audioldm2", torch_dtype=dtype) + pipe.to("cuda" if use_gpu else "cpu") + if not use_gpu: + torch.set_num_threads(12) + return pipe, use_gpu + + +def main() -> None: + items = load_items() + + # Аргументы — id или id:seed (один сид на аргумент, runner разворачивает сам). + jobs: list[tuple[str, int]] = [] + for arg in sys.argv[1:]: + ident, _, seed = arg.partition(":") + if ident not in items: + print(f"неизвестный id {ident!r}; доступно: {', '.join(items)}") + sys.exit(1) + jobs.append((ident, int(seed) if seed else DEFAULT_SEED)) + if not jobs: + for ident, it in items.items(): + jobs.extend((ident, s) for s in it.get("seeds", [DEFAULT_SEED])) + + import diffusers # noqa: F401 (после разбора аргументов — чтобы --help был быстрым) + + pipe, use_gpu = load_pipeline() + dev = "cuda fp16" if use_gpu else "cpu fp32" + REVIEW.mkdir(parents=True, exist_ok=True) + + for ident, seed in jobs: + spec = items[ident] + torch.manual_seed(seed) + np.random.seed(seed) + print(f"генерация {ident} (seed={seed}, {spec['duration']} с, {dev})...") + audio = pipe( + spec["positive"], + negative_prompt=spec.get("negative", NEG_NOISE), + num_inference_steps=100, + guidance_scale=3.0, + audio_length_in_s=spec["duration"], + ).audios[0] + audio = np.clip(audio, -1.0, 1.0) + pcm = (audio * 32767.0).astype(np.int16) + import wave + + out = REVIEW / f"{ident}_{seed}.wav" + with wave.open(str(out), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(pcm.tobytes()) + print(f"готово: {out.relative_to(HERE)} ({pcm.shape[0] / 16000:.1f} с)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/v1/apps/game/tools/aiaudio/index.mjs b/v1/apps/game/tools/aiaudio/index.mjs new file mode 100644 index 0000000..4934dc5 --- /dev/null +++ b/v1/apps/game/tools/aiaudio/index.mjs @@ -0,0 +1,128 @@ +/** + * Пайплайн AI-аудио (работа A1) — манифест-driven, аналог `npm run aiart`. + * + * Команды: + * npm run aiaudio list — таблица позиций манифеста (gate/сиды/promote) + * npm run aiaudio gen [id[:seed,seed]...] — генерация AudioLDM2 + пост-обработка + * (без аргументов — все сиды манифеста; sfx — без лупа, остальное — луп + --check) + * npm run aiaudio post [...] — только пост-обработка (pass-through post.py) + * npm run aiaudio promote — апрув: review/__loop.wav → + * assets/audio/.wav + gate=approved в манифесте (sfx не promote-ится — + * SFX_KEYS пуст, спек-синтез; нужен файловый SFX-путь в движке) + * npm run aiaudio view [seed...] — спектро-лист кандидата в /tmp + * (движковая тулза packages/engine/tools/spectro.mjs: взгляд агента на звук) + * + * Модель/окружение — см. шапку manifest.json; python — venv ~/.cache/rpg-ai/venv-aiaudio. + */ +import { spawnSync } from 'node:child_process'; +import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const HERE = path.dirname(require.resolve('./manifest.json')); +const ASSETS = path.resolve(HERE, '../../assets'); // apps/game/assets +const PY = path.join(process.env.HOME ?? '', '.cache/rpg-ai/venv-aiaudio/bin/python'); +const USAGE = 'npm run aiaudio ...|promote >'; + +function fail(msg) { + console.error(msg); + process.exit(1); +} + +function readManifest() { + return JSON.parse(readFileSync(path.join(HERE, 'manifest.json'), 'utf8')); +} + +/** Цели генерации: без аргументов — все сиды манифеста, иначе id[:seed,seed]. */ +function parseTargets(args, manifest) { + if (args.length === 0) { + return manifest.items.flatMap((it) => (it.seeds ?? [1]).map((seed) => ({ it, seed }))); + } + const out = []; + for (const arg of args) { + const [id, seeds] = arg.split(':'); + const it = manifest.items.find((x) => x.id === id); + if (!it) fail(`неизвестный id ${id}; доступно: ${manifest.items.map((x) => x.id).join(', ')}`); + for (const seed of (seeds ? seeds.split(',') : it.seeds ?? [1]).map(Number)) { + out.push({ it, seed }); + } + } + return out; +} + +function run(cmd, pyArgs) { + if (!existsSync(PY)) fail(`нет venv-питона: ${PY} (см. шапку manifest.json)`); + const r = spawnSync(PY, [cmd, ...pyArgs], { cwd: HERE, stdio: 'inherit' }); + if (r.status !== 0) fail(`${cmd} завершился с ошибкой`); +} + +function cmdGen(args) { + const manifest = readManifest(); + const targets = parseTargets(args, manifest); + console.log(`целей: ${targets.length} (${targets.map((t) => `${t.it.id}_${t.seed}`).join(', ')})`); + run('gen_audioldm2.py', targets.map((t) => `${t.it.id}:${t.seed}`)); + // Пост-обработка сразу: sfx — без лупа (_fx), остальное — луп + проверка шва. + for (const t of targets) { + const flags = t.it.kind === 'sfx' ? ['--noloop'] : ['--check']; + if (t.it.peak) flags.push('--peak', String(t.it.peak)); + run('post.py', [`review/${t.it.id}_${t.seed}.wav`, ...flags]); + } +} + +function cmdPromote(args) { + const [id, seedStr] = args; + if (!id || !seedStr) fail(USAGE); + const manifest = readManifest(); + const it = manifest.items.find((x) => x.id === id); + if (!it) fail(`неизвестный id ${id}`); + if (!it.promote) fail(`${id}: позиция без promote (SFX_KEYS пуст — файловых sfx пока нет)`); + const src = path.join(HERE, 'review', `${id}_${seedStr}_loop.wav`); + if (!existsSync(src)) fail(`нет файла ${src} (сначала npm run aiaudio gen ${id}:${seedStr})`); + // Промотируем только ключ, известный реестрам игры (audio.ts). + const audioTs = readFileSync(path.resolve(HERE, '../../src/data/audio.ts'), 'utf8'); + if (!audioTs.includes(`'${it.promote}'`)) fail(`ключ '${it.promote}' не объявлен в data/audio.ts — добавь сначала`); + const dst = path.join(ASSETS, 'audio', `${it.promote}.wav`); + copyFileSync(src, dst); + it.gate = 'approved'; + it.approved_seed = Number(seedStr); + it.date = new Date().toISOString().slice(0, 10); + writeFileSync(path.join(HERE, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + console.log(`промотировано: ${it.promote}.wav (seed ${seedStr}, gate=approved)`); +} + +/** Лист спектрограмм кандидата (взгляд ИИ-агента): view [seed...] → /tmp/*.png. */ +function cmdView(args) { + const [id, ...seeds] = args; + if (!id) fail('использование: npm run aiaudio view [seed,seed...]'); + const manifest = readManifest(); + const it = manifest.items.find((x) => x.id === id); + if (!it) fail(`неизвестный id ${id}`); + const files = (seeds.length ? seeds.map(Number) : it.seeds ?? [1]).map((s) => { + const loop = it.kind !== 'sfx'; + const suffix = loop ? '_loop.wav' : '_fx.wav'; + const p = path.join(HERE, 'review', `${id}_${s}${suffix}`); + if (!existsSync(p)) fail(`нет файла ${p} (сначала npm run aiaudio gen ${id}:${s})`); + return `${p}${loop ? ':loop' : ''}`; + }); + const script = path.resolve(HERE, '../../../../packages/engine/tools/spectro.mjs'); + const out = `/tmp/aiaudio_${id}_spec.png`; + const r = spawnSync('node', [script, out, ...files], { stdio: 'inherit' }); + if (r.status !== 0) fail('spectro завершился с ошибкой'); +} + +function cmdList() { + const manifest = readManifest(); + for (const it of manifest.items) { + const gate = it.gate === 'approved' ? `approved(seed ${it.approved_seed})` : it.gate ?? '—'; + console.log(`${it.id.padEnd(20)} ${it.kind.padEnd(8)} сиды ${(it.seeds ?? [1]).join(',').padEnd(8)} ${gate.padEnd(20)} ${it.promote ?? ''}`); + } +} + +const [cmd, ...args] = process.argv.slice(2); +if (cmd === 'list') cmdList(); +else if (cmd === 'gen') cmdGen(args); +else if (cmd === 'post') run('post.py', args); +else if (cmd === 'promote') cmdPromote(args); +else if (cmd === 'view') cmdView(args); +else fail(`использование: ${USAGE}`); \ No newline at end of file diff --git a/v1/apps/game/tools/aiaudio/manifest.json b/v1/apps/game/tools/aiaudio/manifest.json new file mode 100644 index 0000000..a4e25c4 --- /dev/null +++ b/v1/apps/game/tools/aiaudio/manifest.json @@ -0,0 +1,289 @@ +{ + "run": "a1-pipeline", + "date": "2026-09-09", + "model": "AudioLDM2 (cvssp/audioldm2), RTX 3090 fp16 (torch 2.14.0+cu130), diffusers 0.35.2 + transformers 4.49.0", + "env": "~/.cache/rpg-ai/venv-aiaudio (pinned; diffusers 0.40 несовместим с transformers 4.x — _update_model_kwargs_for_generation удалён в 5.x)", + "license_note": "веса CC-BY-NC-SA 4.0 — решение A1 (2026-09-09, пользователь): строим пайплайн на AudioLDM2, игра некоммерческая; главный путь плана (Stable Audio Open Small) остаётся опцией — пайплайн манифест-driven, смена модели = новый gen-скрипт с тем же CLI", + "pipeline": "npm run aiaudio gen [id[:seed]...] -> post.py (ресэмпл 22050, нормализация 0.85, loopify 1 с) -> npm run aiaudio promote -> assets/audio/.wav; sfx — без лупа (_fx) и без promote (SFX_KEYS пуст, спек-синтез E4)", + "timing": "10 с аудио, 100 шагов: GPU ~15 с; CPU fp32 12 ядер ~3.5 мин", + "loop_check": "post.py --check: скачок на шве против максимума межсэмплового diff в середине записи", + "criterion": "ambience/layer — лучше процедурного эталона (tools/audio/gen.mjs) на слух; sfx — лучше спек-синтеза (data/sfxSpecs.ts); апрув — пользователь на слух", + "items": [ + { + "id": "amb_ponds", + "kind": "ambience", + "promote": "ambience/ponds", + "seeds": [ + 1, + 2 + ], + "duration": 10, + "gate": "approved", + "approved_seed": 1, + "date": "2026-09-09", + "positive": "field recording, calm misty pond at dusk, gentle water lapping, soft wind through reeds, occasional distant croaking, eerie quiet melancholic atmosphere, seamless ambience", + "note": "эталон ambiencePonds; луга молчат по дизайну, луп прудов апрувнут A0" + }, + { + "id": "amb_zvenets", + "kind": "ambience", + "promote": "ambience/zvenets", + "seeds": [ + 1, + 2 + ], + "duration": 10, + "gate": "approved", + "approved_seed": 1, + "date": "2026-09-09", + "positive": "field recording, quiet rural settlement at dusk, low distant metallic bell toll, soft wind, faint rustling, somber melancholic atmosphere, seamless ambience", + "note": "эталон ambienceZvenets; апрувнут A0" + }, + { + "id": "amb_meadows", + "kind": "ambience", + "seeds": [ + 1, + 2 + ], + "duration": 10, + "gate": "hold", + "date": "2026-09-09", + "positive": "field recording, desolate burned field, cold dry wind blowing over dead grass, occasional faint debris rattling, bleak post-fire emptiness, seamless ambience", + "note": "в игре луга без амбиента (тишина пепла) — клипы держим как запас" + }, + { + "id": "amb_ponds_night", + "kind": "ambience", + "promote": "ambience/ponds_night", + "seeds": [ + 1, + 2 + ], + "duration": 10, + "gate": "approved", + "approved_seed": 1, + "date": "2026-09-09", + "positive": "field recording, pond at night, deeper water lapping, night insects, distant eerie echoes, dark oppressive quiet, seamless ambience", + "note": "эталон ambiencePondsNight; апрувнут A0" + }, + { + "id": "amb_tower_hum", + "kind": "layer", + "promote": "ambience/tower_hum", + "seeds": [ + 1 + ], + "duration": 10, + "gate": "approved", + "approved_seed": 1, + "date": "2026-09-09", + "positive": "deep metallic resonance hum, giant bell tower vibrating, low sustained drone with slow shimmer, ominous and ancient", + "note": "слой layerTowerHum; апрувнут A0" + }, + { + "id": "amb_rust_grove", + "kind": "ambience", + "promote": "ambience/rust_grove", + "seeds": [ + 1, + 2, + 3 + ], + "duration": 10, + "gate": "approved", + "positive": "field recording, dead forest of rusted metal trees wrapped in black cables, wind through cable strands causing faint creaks and rare distant metallic chimes, low rustle of ash, somber mysterious atmosphere, seamless ambience", + "note": "новое A1: роще свой луп (seed 2 — тихий, редкие яркие скрипы); заменил подстановку прудов", + "approved_seed": 2, + "date": "2026-09-09" + }, + { + "id": "amb_old_siding", + "kind": "ambience", + "promote": "ambience/old_siding", + "seeds": [ + 1, + 2, + 3 + ], + "duration": 10, + "gate": "approved", + "positive": "field recording, abandoned railway siding in ash wastes at dusk, faint metallic squeak of old rails, cold wind over ash ground, very distant low machinery pulse, desolate quiet, seamless ambience", + "note": "новое A1: разъезду свой луп (seed 3 — событийная огибающая, скрипы рельсов); заменил подстановку прудов", + "approved_seed": 3, + "date": "2026-09-09" + }, + { + "id": "amb_zvenets_night", + "kind": "ambience", + "promote": "ambience/zvenets_night", + "seeds": [ + 1, + 2, + 3 + ], + "duration": 10, + "gate": "hold", + "positive": "field recording, quiet rural settlement at night, very low distant bell resonance, cold night wind, sparse faint insect chirps, deep somber quiet, seamless ambience", + "note": "оставляем процедурный эталон: у него огибающая живее (dyn 0.46 против 0.09–0.18) — отбор по метрикам, пользователь делегировал решение" + }, + { + "id": "layer_houses", + "kind": "layer", + "promote": "ambience/houses", + "seeds": [ + 1, + 2 + ], + "duration": 10, + "gate": "hold", + "positive": "quiet interior murmur of old wooden settlement houses, faint wood creaks, muffled domestic sounds behind walls, hearth crackling far away, close perspective, seamless ambience", + "note": "оставляем процедурный эталон: тихий с событиями треска (dyn 2.28, rms 0.03) против ровных AI-клипов" + }, + { + "id": "layer_wind", + "kind": "layer", + "promote": "ambience/wind", + "seeds": [ + 1, + 2, + 3, + 4 + ], + "duration": 10, + "peak": 0.6, + "gate": "approved", + "positive": "field recording, cold wind howling across a vast burned field, long slow gusts that swell and fade away, faint low mournful whistle, occasional light debris rattling far away, quiet distant perspective, seamless ambience", + "note": "новое A1 (фидбек: завывание, периодическое затухание, тихий): пик 0.6 поверх игрового гейна слоя 0.25–0.4", + "approved_seed": 3, + "date": "2026-09-09" + }, + { + "id": "layer_ponds_water", + "kind": "layer", + "promote": "ambience/ponds_water", + "seeds": [ + 1, + 2 + ], + "duration": 10, + "gate": "hold", + "positive": "gentle pond water lapping softly against a shore, calm small ripples, quiet close-up foley, seamless ambience", + "note": "оставляем процедурный эталон: dyn 0.67 против 0.15–0.26 у AI" + }, + { + "id": "sfx_step", + "kind": "sfx", + "seeds": [ + 1 + ], + "duration": 2.5, + "gate": "candidate", + "positive": "single soft footstep on dry crumbly ash and dead grass, short subtle crunch, close up, dry foley", + "note": "SFX-кандидаты A0/A1: в игре спек-синтез E4; promote после сравнения на слух (нужен файловый SFX-путь)" + }, + { + "id": "sfx_click", + "kind": "sfx", + "seeds": [ + 1 + ], + "duration": 1.5, + "gate": "candidate", + "positive": "single short UI click, soft wooden tap, clean and dry, interface sound", + "note": "SFX-кандидат" + }, + { + "id": "sfx_bell", + "kind": "sfx", + "seeds": [ + 1, + 2 + ], + "duration": 5, + "gate": "candidate", + "positive": "single bronze bell strike, resonant metallic ring with long natural decay, solemn, temple bell, one hit only", + "note": "SFX-кандидат; эталон bellHit" + }, + { + "id": "sfx_whoosh", + "kind": "sfx", + "seeds": [ + 1 + ], + "duration": 2, + "gate": "candidate", + "positive": "single quick air whoosh, fast swing passing by, short swoosh transition", + "note": "SFX-кандидат; эталон whoosh" + }, + { + "id": "sfx_hit", + "kind": "sfx", + "seeds": [ + 1 + ], + "duration": 2, + "gate": "candidate", + "positive": "single dull impact hit, heavy blunt thud with brief echo, combat punch sound, one hit only", + "note": "SFX-кандидат" + }, + { + "id": "sfx_pickup", + "kind": "sfx", + "seeds": [ + 1 + ], + "duration": 2, + "gate": "candidate", + "positive": "single gentle collect chime, soft bright pling, short delicate pickup sound, one note only", + "note": "SFX-кандидат; эталон motepick" + }, + { + "id": "sfx_drop", + "kind": "sfx", + "seeds": [ + 1 + ], + "duration": 2, + "gate": "candidate", + "positive": "single water drop into calm pond, soft plop with tiny ripple, close up, one drop only", + "note": "SFX-кандидат; эталон капли ambiencePonds" + }, + { + "id": "music_meadows", + "kind": "music", + "seeds": [ + 1, + 2 + ], + "duration": 10.0, + "gate": "hold", + "positive": "slow melancholic ambient music loop, sparse plucked melody over a low warm drone pad, minor pentatonic, desolate ash-gray atmosphere, no drums, no vocals, seamless loop", + "note": "новое A2: кандидат против спек-темы MEADOW_THEME (70 bpm, пэд+плак); promote пока некуда — музыка играет из MusicSpec в рантайме, нужен файловый путь на шине music; решение A2 (2026-09-09, делегировано): спек-темы остаются — AI-музыка не слушалась, AudioLDM2 музыкально слаб; лупы держим до Small-Music/слушания" + }, + { + "id": "music_combat", + "kind": "music", + "seeds": [ + 1, + 2 + ], + "duration": 10.0, + "gate": "hold", + "positive": "tense driving music loop, persistent low bass riff over a dark pulsing drone, urgent but muffled, minor key, no drums, no vocals, seamless loop", + "note": "новое A2: кандидат против COMBAT_THEME (112 bpm рифф); решение A2 (2026-09-09, делегировано): спек-темы остаются — AI-музыка не слушалась, AudioLDM2 музыкально слаб; лупы держим до Small-Music/слушания" + }, + { + "id": "music_tension", + "kind": "music", + "seeds": [ + 1, + 2 + ], + "duration": 10.0, + "gate": "hold", + "positive": "ominous quiet suspense music loop, deep sustained drone with rare distant high bell tolls, sparse and eerie, no drums, no vocals, seamless loop", + "note": "новое A2: кандидат против TENSION_STEM (дрон + редкие колокола); решение A2 (2026-09-09, делегировано): спек-темы остаются — AI-музыка не слушалась, AudioLDM2 музыкально слаб; лупы держим до Small-Music/слушания" + } + ] +} diff --git a/v1/apps/game/tools/aiaudio/post.py b/v1/apps/game/tools/aiaudio/post.py new file mode 100644 index 0000000..e1de11d --- /dev/null +++ b/v1/apps/game/tools/aiaudio/post.py @@ -0,0 +1,98 @@ +"""PoC (работа A, этап A0): пост-обработка сгенерированного WAV под формат игры. + +Вход: review/_.wav — AudioLDM2, 16000 Гц, моно, 16-бит. +Выход: review/__loop.wav — 22050 Гц, моно 16-бит, нормализованный, + бесшовный луп (хвост кроссфейдом вплетается в начало — стык без щелчка). + +Запуск: ~/.cache/rpg-ai/venv-aiaudio/bin/python tools/aiaudio/post.py review/amb_ponds_1.wav +Флаги: --check — сверить стык с фоном записи; --noloop — SFX без лупа + (ресэмпл + нормализация, хвост не вплетается). +""" +import sys +import wave +from pathlib import Path + +import numpy as np +from scipy.signal import resample_poly + +HERE = Path(__file__).resolve().parent +TARGET_RATE = 22050 # формат игры: моно, 22050 Гц, 16-бит (см. audio/gen.mjs) +FADE = 1.0 # длительность кроссфейда лупа, с +PEAK = 0.85 # целевой пик нормализации + + +def read_wav(path: Path) -> tuple[np.ndarray, int]: + with wave.open(str(path), "rb") as w: + rate = w.getframerate() + data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) + return data.astype(np.float64) / 32768.0, rate + + +def write_wav(path: Path, samples: np.ndarray) -> None: + pcm = np.clip(samples * 32767.0, -32768, 32767).astype(np.int16) + with wave.open(str(path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(TARGET_RATE) + w.writeframes(pcm.tobytes()) + + +def loopify(s: np.ndarray, rate: int, fade: float) -> np.ndarray: + """Хвост (последние fade с) кроссфейдом вплетается в начало — луп без шва. + + B[0..fade] = tail·(1-w) + head·w (хвост затухает, голова нарастает), + дальше — средина записи; шов (конец -> начало) соседние сэмплы A. + Длина выхода = len - fade с. + """ + n = int(fade * rate) + w = np.linspace(0.0, 1.0, n) + tail, head, body = s[-n:], s[:n], s[n:-n] + return np.concatenate([tail * (1 - w) + head * w, body]) + + +def main() -> None: + # Разбор аргументов вручную: --peak <значение> — цель нормализации + # (манифест может задавать тише для слоёв; по умолчанию PEAK). + peak_target = PEAK + args: list[str] = [] + raw = sys.argv[1:] + i = 0 + while i < len(raw): + if raw[i] == "--peak": + peak_target = float(raw[i + 1]) + i += 2 + elif not raw[i].startswith("--"): + args.append(raw[i]) + i += 1 + else: + i += 1 + check = "--check" in raw + noloop = "--noloop" in raw + if len(args) != 1: + print("использование: post.py [--check] [--noloop] [--peak 0.85]") + sys.exit(1) + src = Path(args[0]).resolve() + s, rate = read_wav(src) + s = resample_poly(s, TARGET_RATE, rate) # 16000 -> 22050 (целые множители: 441/320) + s = s if noloop else loopify(s, TARGET_RATE, FADE) + # Нормализация ПОСЛЕ loopify: пик модели часто в фейдах головы/хвоста, + # которые луп вплетает/выбрасывает — иначе фактический пик ниже цели. + peak = np.abs(s).max() + if peak > 1e-9: + s = s * (peak_target / peak) + out = src.with_name(src.stem + ("_fx.wav" if noloop else "_loop.wav")) + write_wav(out, s) + dur = s.shape[0] / TARGET_RATE + print(f"готово: {out.relative_to(HERE)} ({dur:.1f} с, {TARGET_RATE} Гц, пик {peak_target})") + + if check: + # Стык без щелчка: максимальный межсэмпловый скачок на шве против + # такого же скачка в середине записи — шов не должен выделяться. + mid = np.abs(np.diff(s[len(s) // 4 : len(s) // 4 + TARGET_RATE])).max() + seam = np.abs(s[-1] - s[0]) + print(f"стык: скачок на шве {seam:.4f}, максимум в середине {mid:.4f}") + assert seam <= max(mid, 0.01), "шов лупа подозрительно громкий" + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/v1/apps/game/tools/audio/gen.d.mts b/v1/apps/game/tools/audio/gen.d.mts new file mode 100644 index 0000000..4315d44 --- /dev/null +++ b/v1/apps/game/tools/audio/gen.d.mts @@ -0,0 +1,15 @@ +/** Типы для парити-теста sfxSpecs: экспортируемые формулы gen.mjs. */ +export declare function bellHit(): Float32Array; +export declare function bellLow(): Float32Array; +export declare function ashHiss(): Float32Array; +export declare function ashDie(): Float32Array; +export declare function spit(): Float32Array; +export declare function hurt(): Float32Array; +export declare function motePickup(): Float32Array; +export declare function pickup(): Float32Array; +export declare function chestOpen(): Float32Array; +export declare function note(): Float32Array; +export declare function whoosh(): Float32Array; +export declare function deerStartle(): Float32Array; +export declare function uiClick(): Float32Array; +export declare function chime(): Float32Array; diff --git a/v1/apps/game/tools/audio/gen.mjs b/v1/apps/game/tools/audio/gen.mjs new file mode 100644 index 0000000..fe6a4a3 --- /dev/null +++ b/v1/apps/game/tools/audio/gen.mjs @@ -0,0 +1,371 @@ +/** + * Генератор амбиента «Пепельных лугов» — WAV из кода, без зависимостей. + * Запуск: node tools/audio/gen.mjs + * Выход: apps/game/assets/audio/ambience/*.wav + * + * Все sfx переехали на спек-синтез (data/sfxSpecs.ts, компилирует движок) — + * здесь остались только многослойные амбиент-лупы. Формулы бывших sfx + * экспортированы ниже: их сверяет с рендером спеков парити-тест + * data/__tests__/sfxSpecs.test.ts (звучание мигрированных звуков не менялось). + * + * Формат: моно, 22050 Гц, 16-бит PCM. Меланхоличный пепельный саунд. + * WAV-энкодер и примитивы синтеза — жанронезависимые, в @rpg/engine/tools/wav.mjs. + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { encodeWav, makeRng, synth, decay, bellPartial, bandNoise, normalize, loopify } from '@rpg/engine/tools/wav.mjs'; + +const OUT = fileURLToPath(new URL('../../assets/audio/', import.meta.url)); + +// ---------- звуки ---------- + +/** Гулкий удар колокольчиком: 4 негармоничных партиала + шумовой клик. + * Экспортируется для парити-теста: renderSpec(SPEC_SFX['sfx/bell_hit']) бит-в-бит. */ +export function bellHit() { + const dur = 0.35; + const s = synth(dur, (t) => { + const body = + bellPartial(520, 1.0, dur, 5)(t) * 0.5 + + bellPartial(520, 1.52, dur, 6)(t) * 0.3 + + bellPartial(520, 2.38, dur * 0.6, 7)(t) * 0.2 + + bellPartial(520, 3.91, dur * 0.4, 8)(t) * 0.12; + const click = t < 0.008 ? (makeRng(1)() * 2 - 1) * (1 - t / 0.008) * 0.4 : 0; + return body * 0.8 + click; + }); + return normalize(s, 0.7); +} + +/** Низкий резонанс: медленный гул с тремоло — «усыпляет пепел». */ +export function bellLow() { + const dur = 0.9; + const s = synth(dur, (t) => { + const trem = 1 + 0.3 * Math.sin(2 * Math.PI * 6 * t); + const body = + Math.sin(2 * Math.PI * 110 * t) * 0.5 + + Math.sin(2 * Math.PI * 165 * t) * 0.25 + + Math.sin(2 * Math.PI * 220.5 * t) * 0.12; + return body * decay(t, dur, 2) * trem; + }); + return normalize(s, 0.6); +} + +/** Шипение: сгусток просыпается. */ +export function ashHiss() { + const dur = 0.4; + const noise = bandNoise(11, 900, 4200, dur); + const s = synth(dur, (t) => noise() * Math.min(1, t / 0.08) * decay(t, dur, 2)); + return normalize(s, 0.55); +} + +/** Смерть сгустка: шум с падающим «выдохом». */ +export function ashDie() { + const dur = 0.6; + const noise = bandNoise(23, 300, 2400, dur); + const s = synth(dur, (t) => { + const pitch = 1 - 0.7 * (t / dur); // темнеет к концу + return noise() * decay(t, dur, 3) * (0.4 + pitch); + }); + return normalize(s, 0.6); +} + +/** Плевок: синус с падающей высотой + шипящий хвост. */ +export function spit() { + const dur = 0.2; + const noise = bandNoise(37, 2000, 6000, dur); + const s = synth(dur, (t) => { + const f = 700 - 350 * (t / dur); + return (Math.sin(2 * Math.PI * f * t) * 0.6 + noise() * 0.4) * decay(t, dur, 3); + }); + return normalize(s, 0.5); +} + +/** Урон герою: глухой низкий удар. */ +export function hurt() { + const dur = 0.25; + const noise = bandNoise(41, 80, 400, dur); + const s = synth(dur, (t) => { + const thump = Math.sin(2 * Math.PI * 90 * t) * decay(t, dur, 3); + return thump * 0.8 + noise() * 0.3 * decay(t, dur * 0.5, 2); + }); + return normalize(s, 0.65); +} + +/** Мот подобран: мерцающий двойной звон (тёплая искра в серой земле). */ +export function motePickup() { + const dur = 0.45; + const s = synth(dur, (t) => { + const body = + Math.sin(2 * Math.PI * 1240 * t) * 0.4 + + Math.sin(2 * Math.PI * 1860 * t) * 0.2 * decay(t, dur, 4); + return body * decay(t, dur, 2.5); + }); + return normalize(s, 0.4); +} + +/** Предмет в сумку: мягкий «пок». */ +export function pickup() { + const dur = 0.14; + const s = synth(dur, (t) => { + const f = 520 + 220 * (t / dur); // чуть взлетает + return Math.sin(2 * Math.PI * f * t) * decay(t, dur, 2.5); + }); + return normalize(s, 0.4); +} + +/** Сундук открылся: низкий удар крышки + скрип петель. */ +export function chestOpen() { + const dur = 0.55; + const noise = bandNoise(157, 300, 1400, dur); + const s = synth(dur, (t) => { + const thump = Math.sin(2 * Math.PI * 110 * t) * decay(t, 0.12, 2) * 0.7; + const creak = noise() * Math.min(1, t / 0.1) * decay(t, dur, 1.5) * 0.35; + return thump + creak; + }); + return normalize(s, 0.45); +} + +/** Записка: шелест бумаги. */ +export function note() { + const dur = 0.35; + const noise = bandNoise(159, 1800, 6000, dur); + const s = synth(dur, (t) => noise() * (0.4 + 0.6 * Math.sin(2 * Math.PI * 3.2 * t + 1)) * decay(t, dur, 1.5)); + return normalize(s, 0.3); +} + +/** Переход локаций: воздух уносит — шум с подъёмом и спадом. + * Экспортируется для парити-теста (спек sfx/whoosh). */ +export function whoosh() { + const dur = 0.7; + const noise = bandNoise(160, 200, 2400, dur); + const s = synth(dur, (t) => { + const swell = Math.sin(Math.PI * Math.min(1, t / dur)); // 0 → 1 → 0 + return noise() * swell; + }); + return normalize(s, 0.4); +} + +/** Олень вспугнут: глухой выдох + стук копыт. */ +export function deerStartle() { + const dur = 0.4; + const noise = bandNoise(161, 150, 900, dur); + const s = synth(dur, (t) => { + const breath = noise() * Math.min(1, t / 0.05) * decay(t, dur, 2); + const hoof = t > 0.2 && t < 0.28 ? Math.sin(2 * Math.PI * 90 * (t - 0.2)) * decay(t - 0.2, 0.08, 2) * 0.6 : 0; + return breath * 0.7 + hoof; + }); + return normalize(s, 0.4); +} + +/** Клик UI. */ +export function uiClick() { + const dur = 0.06; + const s = synth(dur, (t) => Math.sin(2 * Math.PI * 880 * t) * decay(t, dur, 3) * 0.5); + return normalize(s, 0.4); +} + +/** Колокольчик Милы: высокий, почти хрустальный. */ +export function chime() { + const dur = 0.5; + const s = synth(dur, (t) => { + const body = + Math.sin(2 * Math.PI * 1560 * t) * 0.5 + + Math.sin(2 * Math.PI * 1560 * 1.61 * t) * 0.25; + return body * decay(t, dur, 3); + }); + return normalize(s, 0.45); +} + +function ambiencePonds() { + const dur = 8; + const wind = bandNoise(71, 60, 400, dur); + const r = makeRng(13); + const drops = []; + for (let i = 0; i < 4; i++) drops.push({ at: r() * dur, f: 900 + r() * 600 }); + const s = synth(dur, (t) => { + let v = wind() * 0.3 * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur + 3)); + for (const d of drops) { + const dt = t - d.at + (t < d.at ? dur : 0); + if (dt >= 0 && dt < 0.15) v += Math.sin(2 * Math.PI * d.f * dt * (1 - dt * 3)) * 0.08 * decay(dt, 0.15, 2); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + +/** + * Амбиент Звенца: низкий колокольный тон (два удара за цикл) над тихим + * ветром. Колокол башни отлит из рельсов — голос ниже лугового гула. + */ +function ambienceZvenets() { + const dur = 8; + const wind = bandNoise(81, 80, 500, dur); + const strikes = [ + { at: 1.0, f: 148 }, + { at: 5.0, f: 111 } + ]; + const s = synth(dur, (t) => { + let v = wind() * 0.2 * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur + 0.5)); + for (const s of strikes) { + const dt = t - s.at + (t < s.at ? dur : 0); // зациклить удары + if (dt >= 0 && dt < 2.5) { + v += + (Math.sin(2 * Math.PI * s.f * dt) * 0.22 + + Math.sin(2 * Math.PI * s.f * 2.76 * dt) * 0.08 + + Math.sin(2 * Math.PI * s.f * 5.4 * dt) * 0.04) * + decay(dt, 2.5, 3); + } + } + return v; + }); + return loopify(normalize(s, 0.55)); +} + +/** + * Ночь на лугах: та же фактура ветра, что днём, но глубже, тише и с меньшими + * порывами; вдалеке гудит машина (Ржавая роща не спит) и изредка доходит + * колокол Звенца. + */ +function ambiencePondsNight() { + const dur = 8; + const wind = bandNoise(83, 50, 250, dur); + const lap = bandNoise(84, 200, 900, dur); + const r = makeRng(19); + const drops = []; + for (let i = 0; i < 7; i++) drops.push({ at: r() * dur, f: 700 + r() * 500 }); + const s = synth(dur, (t) => { + let v = wind() * 0.22 * (0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur + 1)); + // Ночью вода «дышит» ровнее и ближе. + v += lap() * 0.14 * (0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur + 3)); + for (const d of drops) { + const dt = t - d.at + (t < d.at ? dur : 0); + if (dt >= 0 && dt < 0.15) v += Math.sin(2 * Math.PI * d.f * dt * (1 - dt * 3)) * 0.09 * decay(dt, 0.15, 2); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + +/** + * Ночь в Звенце: ветер ниже, колокол бьёт реже и глуше, под ним — низкий + * гул башни, не смолкающий и ночью. + */ +function ambienceZvenetsNight() { + const dur = 8; + const wind = bandNoise(93, 60, 350, dur); + const strikes = [ + { at: 2.0, f: 111 }, + { at: 6.0, f: 98 } + ]; + const s = synth(dur, (t) => { + let v = wind() * 0.24 * (0.65 + 0.35 * Math.sin(2 * Math.PI * t / dur + 2.5)); + // Башня не спит: тихий двойной гул под всем прочим. + v += (Math.sin(2 * Math.PI * 55 * t) * 0.08 + Math.sin(2 * Math.PI * 82.5 * t) * 0.05) * (0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur * 2)); + for (const s of strikes) { + const dt = t - s.at + (t < s.at ? dur : 0); + if (dt >= 0 && dt < 2.5) + v += + (Math.sin(2 * Math.PI * s.f * dt) * 0.2 + + Math.sin(2 * Math.PI * s.f * 2.76 * dt) * 0.07 + + Math.sin(2 * Math.PI * s.f * 5.4 * dt) * 0.03) * + decay(dt, 2.5, 3); + } + return v; + }); + return loopify(normalize(s, 0.55)); +} + +// ---------- локальные амбиент-слои ---------- + +function layerPondsWater() { + const dur = 8; + const lap = bandNoise(172, 300, 1500, dur); + const r = makeRng(27); + const drops = []; + for (let i = 0; i < 6; i++) drops.push({ at: r() * dur, f: 800 + r() * 700 }); + const s = synth(dur, (t) => { + // Плебск «дышит» медленной волной. + let v = lap() * 0.12 * (0.5 + 0.5 * Math.sin(2 * Math.PI * t / dur + 2)); + for (const d of drops) { + const dt = t - d.at + (t < d.at ? dur : 0); // зациклить капли + if (dt >= 0 && dt < 0.12) v += Math.sin(2 * Math.PI * d.f * dt) * 0.1 * decay(dt, 0.12, 2); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + +/** Слой «башня Звенца»: низкий гул колокола (55+82.5 Гц) с биением. */ +function layerTowerHum() { + const dur = 8; + const s = synth(dur, (t) => { + const breath = 0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur * 2 + 0.5); + const body = + Math.sin(2 * Math.PI * 55 * t) * 0.5 + + Math.sin(2 * Math.PI * 82.5 * t) * 0.3; // биение 27.5 Гц + return body * breath * 0.35; + }); + return loopify(normalize(s, 0.5)); +} + +/** Слой «дома Звенца»: редкие скрипы вагонных щитов. */ +function layerHouses() { + const dur = 8; + const noise = bandNoise(174, 200, 1000, dur); + const r = makeRng(41); + const creaks = []; + for (let i = 0; i < 4; i++) creaks.push({ at: r() * dur, len: 0.5 + r() * 0.6 }); + const s = synth(dur, (t) => { + let v = 0; + for (const c of creaks) { + const dt = t - c.at + (t < c.at ? dur : 0); // зациклить скрипы + if (dt >= 0 && dt < c.len) { + const wob = 1 + 0.5 * Math.sin(2 * Math.PI * 5 * dt); + v += noise() * wob * decay(dt, c.len, 1.5) * 0.25; + } + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + +/** Слой «ветер» (глобальный): свистящая высокая полоса с редкими порывами. */ +function layerWind() { + const dur = 8; + const air = bandNoise(175, 300, 1400, dur); + const r = makeRng(47); + const gusts = []; + for (let i = 0; i < 2; i++) gusts.push({ at: r() * dur, len: 2 + r() }); + const s = synth(dur, (t) => { + let v = air() * 0.18 * (0.55 + 0.45 * Math.sin(2 * Math.PI * t / dur + 1.5)); + for (const g of gusts) { + const dt = t - g.at + (t < g.at ? dur : 0); // зациклить порывы + if (dt >= 0 && dt < g.len) v += air() * 0.22 * decay(dt, g.len, 1.5); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + +// ---------- запись ---------- + +const AMBIENCE = { + 'ambience/ponds': ambiencePonds, + 'ambience/zvenets': ambienceZvenets, + // Ночные варианты: ниже, плотнее, с дальними механизмами (кроссфейд по часам). + 'ambience/ponds_night': ambiencePondsNight, + 'ambience/zvenets_night': ambienceZvenetsNight, + 'ambience/ponds_water': layerPondsWater, + 'ambience/tower_hum': layerTowerHum, + 'ambience/houses': layerHouses, + 'ambience/wind': layerWind +}; +// Записываем WAV только при запуске как CLI, а не при импорте (парити-тест +// импортирует формулы bellHit/whoosh, чтобы сверить их со спек-синтезом). +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + mkdirSync(`${OUT}ambience`, { recursive: true }); + for (const [name, gen] of Object.entries(AMBIENCE)) { + const buf = encodeWav(gen()); + writeFileSync(`${OUT}${name}.wav`, buf); + console.log(`${name}.wav ${(buf.length / 1024).toFixed(1)} КБ`); + } +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/agent-invariants.mjs b/v1/apps/game/tools/checks/agent-invariants.mjs new file mode 100644 index 0000000..cf8451f --- /dev/null +++ b/v1/apps/game/tools/checks/agent-invariants.mjs @@ -0,0 +1,33 @@ +/** + * Сценарий agent:invariants — мост жив, мир стабилен. + * Новая игра -> 300 фиксированных шагов -> движковые/сценические/контентные + * инварианты не содержат ошибок. + * Запуск: node tools/agent.mjs run tools/checks/agent-invariants.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'agent-invariants', + async (t) => { + await t.c.run('новая игра через мост', async () => { + await t.boot(); + const s = await t.ctx.agent.snapshot(); + t.c.expect(s.scene === 'location', 'сцена не location', { scene: s.scene, err: s.error }); + return null; + }); + await t.c.run('300 шагов без ошибок инвариантов', async () => { + await t.ctx.agent.step(300); + await t.expectInvariantsClean('после 300 шагов'); + return null; + }); + await t.c.run('снапшот содержит героя и флаги', async () => { + const s = await t.ctx.agent.snapshot(); + t.c.expect(!!s.hero?.tile, 'нет s.hero.tile', s); + t.c.expect(Array.isArray(s.flags), 'нет s.flags'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/ai.mjs b/v1/apps/game/tools/checks/ai.mjs new file mode 100644 index 0000000..622c545 --- /dev/null +++ b/v1/apps/game/tools/checks/ai.mjs @@ -0,0 +1,105 @@ +/** + * Сценарий ai — ИИ врагов (батч 4). + * 1) тяжеловес с патрулём стартует бодрым в patrol и реально ходит; + * 2) тихий шум (0.35) при далёком герое — настороженность (wary); + * 3) не увидел героя — wary гаснет, возврат к дому и снова patrol; + * 4) герой рядом с LOS — погоня (chase); + * 5) герой далеко — потеря погони (return -> patrol); + * 6) мало hp — отступление бегом (flee), дистанция растёт; + * 7) инварианты (enemy-state-legal, enemy-in-bounds) чисты. + * Запуск: node tools/agent.mjs run tools/checks/ai.mjs + */ +import { withChecks } from '../lib.mjs'; + +const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); +/** Тяжеловес из снапшота. */ +const heavy = (s) => (s.enemies ?? []).find((e) => e.kind === 'heavy'); + +export default async function ({ pretty }) { + return withChecks( + 'ai', + async (t) => { + const { c } = t; + await c.run('патруль: тяжеловес бодр и ходит по маршруту', async () => { + await t.boot(); + const s0 = await t.ctx.agent.snapshot(); + const h0 = heavy(s0); + c.expect(h0?.state === 'patrol', 'тяжеловес не в patrol на старте', h0); + // Спавн (12,44), патруль к (8,40): позиция меняется без участия + // героя; путь диагональный — с запасом по времени. + const w = await t.ctx.agent.waitFor( + '(s.enemies.find((e) => e.kind === "heavy")?.pos.x ?? 9) < 10', + { timeoutTicks: 1800 } + ); + c.expect(w.ok, 'тяжеловес не сдвинулся с места в патруле', w.snapshot.enemies); + return null; + }); + + await c.run('тихий шум настораживает патрульного', async () => { + // Герой далеко (спавн 16,28), шум у маршрута тяжеловеса. + await t.ctx.agent.command('scene:noise', { x: 10, y: 42, level: 0.35 }); + const w = await t.ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "wary"', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'тяжеловес не насторожился от шума', w.snapshot.enemies); + return null; + }); + + await c.run('настороженность гаснет: wary -> return -> patrol', async () => { + const w = await t.ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', + { timeoutTicks: 900 } + ); + c.expect(w.ok, 'тяжеловес не вернулся к патрулю', w.snapshot.enemies); + return null; + }); + + await c.run('герой рядом — погоня', async () => { + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); + await t.ctx.agent.command('scene:teleport', { x: 11, y: 44 }); + const w = await t.ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "chase"', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'тяжеловес не погнался за героем', w.snapshot.enemies); + return null; + }); + + await c.run('герой далеко — погоня гаснет и враг уходит домой', async () => { + await t.ctx.agent.command('scene:teleport', { x: 16, y: 28 }); + const w = await t.ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', + { timeoutTicks: 900 } + ); + c.expect(w.ok, 'тяжеловес не отступил к дому', w.snapshot.enemies); + return null; + }); + + await c.run('мало hp — отступление бегом', async () => { + await t.ctx.agent.command('scene:teleport', { x: 11, y: 43 }); + const dmg = await t.ctx.agent.command('scene:damageEnemy', { id: 'heavy', value: 6 }); + c.expect(dmg === true, 'scene:damageEnemy не нашла тяжеловеса'); + const w = await t.ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "flee"', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'тяжеловес не отступает при низком hp', w.snapshot.enemies); + // Держится подальше: дистанция до неподвижного героя растёт. + const s1 = await t.ctx.agent.snapshot(); + const d1 = dist(heavy(s1).pos, s1.hero.pos); + await t.ctx.agent.waitFor('false', { timeoutTicks: 60 }); // 1 сек наблюдения + const s2 = await t.ctx.agent.snapshot(); + const d2 = dist(heavy(s2).pos, s2.hero.pos); + c.expect(d2 > d1, 'отступающий не отдаляется', { d1, d2 }); + return null; + }); + + await c.run('инварианты ИИ чисты', async () => { + await t.expectInvariantsClean('в ИИ-сценарии'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/art_b4.mjs b/v1/apps/game/tools/checks/art_b4.mjs new file mode 100644 index 0000000..42a3718 --- /dev/null +++ b/v1/apps/game/tools/checks/art_b4.mjs @@ -0,0 +1,108 @@ +/** + * Сценарий art_b4 — визуальная проверка AI-арта B4 (задача 5): + * столбы, моты, верёвка колокола, очаг, записка, прилавок, резонатор, + * деревья-механизмы, Старая Машина (view.tex) и иконки сумки 8×8. + * Скриншоты — /tmp/rpg_b4_*.png; численных assert'ов минимум: смена области, + * открытая сумка, инварианты. Запуск: node tools/agent.mjs run tools/checks/art_b4.mjs + */ +import { withChecks } from '../lib.mjs'; + +const shot = async (t, name) => { + await t.ctx.agent.step(2, { render: true }); + await t.ctx.page.screenshot({ path: `/tmp/rpg_b4_${name}.png` }); +}; + +export default async function ({ pretty }) { + return withChecks( + 'art_b4', + async (t) => { + const { c } = t; + let a; // агентный мост появляется после boot + await c.run('луга: столб и мот', async () => { + await t.boot(); + a = t.ctx.agent; + await t.sleepEnemies(); + await a.command('scene:teleport', { x: 47, y: 27 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'sign_meadows'); + await a.command('scene:teleport', { x: 15, y: 28 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'mote'); + return null; + }); + await c.run('сумка: иконки предметов', async () => { + for (const id of ['clock', 'cloth', 'bellflower', 'salt', 'water_flask', 'mote', 'map_scroll']) { + await a.command('scene:give', { id }); + } + await a.press('inventory'); + // Открытие — push с fade: сцена сменится, потом fade отыграть. + await a.waitFor('s.scene !== "location"', { timeoutTicks: 300 }); + await a.step(14, { render: true }); + await t.ctx.page.screenshot({ path: '/tmp/rpg_b4_inventory.png' }); + await a.press('menu'); + await a.waitFor('s.scene === "location"', { timeoutTicks: 300 }); + return null; + }); + await c.run('Звенец: столб и верёвка колокола', async () => { + await t.goToArea('zvenets', { x: 54, y: 28 }); + await a.command('scene:teleport', { x: 5, y: 19 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'sign_zvenets'); + await a.command('scene:teleport', { x: 27, y: 15 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'bell_rope'); + return null; + }); + await c.run('дом Ирвина: очаг и записка', async () => { + c.expect(await t.interactAt(13, 12), 'дверь дома Ирвина не открылась'); + await a.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await a.command('scene:teleport', { x: 8, y: 2 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'hearth_note'); + // Шаг в проём (5,8) — назад в Звенец. + c.expect(await a.walkTo(5, 8, { timeoutTicks: 3000 }), 'не вышли из дома'); + await a.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 600 }); + return null; + }); + await c.run('лавка Милы: прилавок', async () => { + c.expect(await t.interactAt(43, 19), 'дверь лавки не открылась'); + await a.waitFor('s.area === "shop"', { timeoutTicks: 1200 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await a.command('scene:teleport', { x: 5, y: 5 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'counter_shop'); + c.expect(await a.walkTo(5, 8, { timeoutTicks: 3000 }), 'не вышли из лавки'); + await a.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 600 }); + return null; + }); + await c.run('роща: дерево-механизм и резонатор', async () => { + await t.goToArea('rust_grove', { x: 54, y: 20 }); + const s = await a.snapshot(); + const tree = (s.interactables ?? []).find((i) => i.id === 'tone_tree_1'); + c.expect(tree, 'tone_tree_1 не найден', s.interactables?.map((i) => i.id)); + await a.command('scene:teleport', { x: tree.tile.x, y: tree.tile.y + 2 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'tone_tree'); + await a.command('scene:teleport', { x: 24, y: 18 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'resonator'); + return null; + }); + await c.run('зал Машины: Старая Машина', async () => { + await t.goToArea('old_siding', { x: 46, y: 20 }); + await t.goToArea('machine_hall', { x: 28, y: 31 }); + await a.command('scene:teleport', { x: 13, y: 4 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'machine_hall'); + return null; + }); + const inv = await a.invariants(); + const errs = inv.filter((i) => i.severity === 'error'); + c.expect(errs.length === 0, 'инварианты не чисты', errs); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/audio.mjs b/v1/apps/game/tools/checks/audio.mjs new file mode 100644 index 0000000..f8696bd --- /dev/null +++ b/v1/apps/game/tools/checks/audio.mjs @@ -0,0 +1,75 @@ +/** + * Сценарий audio — звук мира через DEV-шпион window.__gameAudioLog + * (на слух в headless не проверить: читаем фактические запуски sfx/амбиента). + * 1) новая игра: на лугах тишина — амбиент области не запускается; + * 2) шаги: step_* в логе, боевых звуков нет; + * 3) атака: bell_hit; + * 4) guard амбиента: повторный playAmbience того же ключа не перезапускает; + * 5) лог ограничен 24 записями. + * Запуск: node tools/agent.mjs run tools/checks/audio.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'audio', + async (t) => { + const { c } = t; + await c.run('новая игра: на лугах тишина — амбиент не запускается', async () => { + await t.boot(); + const log = await t.readAudioLog(); + c.expect(Array.isArray(log), 'DEV-лог аудио недоступен (прод-сборка?)', log); + const amb = log.filter((e) => e.key.startsWith('ambience/')); + c.expect(amb.length === 0, 'на лугах не должно быть амбиента (тишина)', amb); + return null; + }); + await c.run('шаги: step_* в логе, боевых звуков нет', async () => { + await t.sleepEnemies(); + const before = (await t.readAudioLog()).filter((e) => e.key.startsWith('sfx/step_')).length; + await t.ctx.agent.walkTo(18, 28, { timeoutTicks: 1500 }); + const log = await t.waitAudioLog( + (l) => l.filter((e) => e.key.startsWith('sfx/step_')).length > before + ); + const steps = log.filter((e) => e.key.startsWith('sfx/step_')); + c.expect(steps.length > before, 'шагов в логе нет', { steps: steps.length }); + const combat = log.filter((e) => ['sfx/hurt', 'sfx/ash_die', 'sfx/spit'].includes(e.key)); + c.expect(combat.length === 0, 'боевые звуки без боя', combat); + return null; + }); + await c.run('атака: bell_hit в логе', async () => { + await t.ctx.agent.press('attack'); + const log = await t.waitAudioLog((l) => l.some((e) => e.key === 'sfx/bell_hit')); + c.expect(log.some((e) => e.key === 'sfx/bell_hit'), 'атака не сыграла bell_hit', log); + return null; + }); + await c.run('guard амбиента: тот же ключ не перезапускается', async () => { + // Луга без амбиента — гвард проверяем ключом прудов. + const count = async () => + (await t.readAudioLog()).filter((e) => e.key === 'ambience/ponds').length; + await t.ctx.page.evaluate(() => window.__game.playAmbience('ambience/ponds')); + // Лог пишется при фактическом старте (после декода) — ждём его. + await t.waitAudioLog((l) => l.some((e) => e.key === 'ambience/ponds')); + const before = await count(); + await t.ctx.page.evaluate(() => window.__game.playAmbience('ambience/ponds')); + await t.ctx.agent.step(60); + const after = await count(); + c.expect(after === before, 'повторный playAmbience перезапустил трек', { + before, + after + }); + return null; + }); + await c.run('лог ограничен 24 записями', async () => { + // Прогулка туда-обратно — шагов больше, чем вмещает кольцевой буфер. + for (const [x, y] of [[18, 28], [14, 28], [18, 28], [14, 28], [18, 28], [14, 28]]) { + await t.ctx.agent.walkTo(x, y, { timeoutTicks: 1500 }); + } + const log = await t.readAudioLog(); + c.expect(log.length <= 24, 'лог не обрезается до 24', log.length); + c.expect(log.length > 0, 'лог пуст после прогулки'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/big_house.mjs b/v1/apps/game/tools/checks/big_house.mjs new file mode 100644 index 0000000..19c0b33 --- /dev/null +++ b/v1/apps/game/tools/checks/big_house.mjs @@ -0,0 +1,75 @@ +/** + * Сценарий big_house — большой дом 2×2 в Звенце (первый проп с footprint): + * 1) коллизия: footprint непроходим, соседний тайл проходим; + * 2) scene:walk в дом — false, approach — к соседу; + * 3) глубина: герой с юга (перед домом) и с севера (за домом) — скриншоты. + * Запуск: node tools/agent.mjs run tools/checks/big_house.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function () { + return withChecks( + 'big_house', + async (t) => { + const { c } = t; + await c.run('снапшот: проп дома в collision-слое', async () => { + await t.boot(); + await t.sleepEnemies(); + await t.goToArea('zvenets', { x: 54, y: 28 }); + const s = await t.ctx.agent.snapshot(); + // collision-слой снапшота отдаёт пропы без id — ищем по footprint. + const prop = (s.collision.props ?? []).find((p) => p.x === 46 && p.y === 24); + c.expect(!!prop, 'проп (46,24) 2×2 не в collision-слое', s.collision.props); + c.expect(prop && prop.w === 2 && prop.h === 2, 'footprint пропа не 2×2', prop); + const idx = (x, y) => y * s.collision.width + x; + c.expect( + s.collision.blocked[idx(46, 24)] === 1 && s.collision.blocked[idx(47, 25)] === 1, + 'клетки footprint пропа не заблокированы' + ); + return null; + }); + await c.run('scene:walkable: дом непроходим, сосед свободен', async () => { + const walk = (x, y) => t.ctx.agent.command('scene:walkable', { x, y }); + c.expect((await walk(46, 24)) === false, 'северо-запад дома проходим'); + c.expect((await walk(47, 25)) === false, 'юго-восток дома проходим'); + c.expect((await walk(48, 24)) === true, 'тайл восточнее дома заблокирован'); + return null; + }); + await c.run('scene:walk в дом — false, approach — к соседу', async () => { + await t.ctx.agent.command('scene:teleport', { x: 48, y: 24 }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); + const walked = await t.ctx.agent.command('scene:walk', { x: 46, y: 24 }); + c.expect(walked === false, 'scene:walk провёл героя в дом', walked); + const dest = await t.ctx.agent.approach(46, 24); + c.expect(dest !== null, 'approach к дому не нашёл соседа'); + return null; + }); + await c.run('глубина: герой с юга и с севера дома', async () => { + const shot = async (name) => { + await t.ctx.agent.step(2, { render: true }); + await t.ctx.page.screenshot({ path: `/tmp/rpg_big_house_${name}.png` }); + }; + const teleport = async (x, y) => { + await t.ctx.agent.command('scene:teleport', { x, y }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); + const s = await t.ctx.agent.snapshot(); + c.expect( + s.hero.tile.x === x && s.hero.tile.y === y, + `телепорт в (${x},${y}) не удался`, + s.hero.tile + ); + }; + // Юг: герой вплотную перед домом — перекрывает его низ. + await teleport(46, 26); + await shot('south'); + // Север: дом ближе к камере — перекрывает героя целиком. + await teleport(46, 23); + await shot('north'); + return null; + }); + const inv = await t.ctx.agent.invariants(); + const errs = inv.filter((i) => i.severity === 'error'); + c.expect(errs.length === 0, 'инварианты с пропом не чисты', errs); + } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/collision.mjs b/v1/apps/game/tools/checks/collision.mjs new file mode 100644 index 0000000..73152de --- /dev/null +++ b/v1/apps/game/tools/checks/collision.mjs @@ -0,0 +1,98 @@ +/** + * Сценарий collision — коллизии и карта коллизий агенту. + * 1) слой collision в снапшоте: стены/вода/пропы, тайл героя свободен. + * 2) scene:walkable: трава проходима, вода и дерево — нет. + * 3) scene:raycast: дерево перекрывает луч, вода прозрачна (плевок летит над прудом). + * 4) scene:walk в непроходимый — false; approach подводит к соседу. + * 5) расталкивание тел: враг выталкивает себя из героя. + * Запуск: node tools/agent.mjs run tools/checks/collision.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'collision', + async (t) => { + const { c } = t; + await c.run('снапшот: слой collision валиден', async () => { + await t.boot(); + const s = await t.ctx.agent.snapshot(); + const col = s.collision; + c.expect(col && col.width > 0 && col.height > 0, 'нет карты коллизий', col); + c.expect( + col.blocked.length === col.width * col.height, + 'blocked не накрывает карту', + { len: col.blocked.length, w: col.width, h: col.height } + ); + c.expect( + col.blocked.every((v) => v === 0 || v === 1), + 'blocked не бинарный', + col.blocked.filter((v) => v !== 0 && v !== 1) + ); + c.expect( + col.props.every((p) => p.w >= 1 && p.h >= 1), + 'проп без footprint', + col.props + ); + const idx = s.hero.tile.y * col.width + s.hero.tile.x; + c.expect(col.blocked[idx] === 0, 'герой заспавнился в блоке', s.hero.tile); + return null; + }); + await c.run('scene:walkable: вода и дерево непроходимы', async () => { + const walk = (x, y) => t.ctx.agent.command('scene:walkable', { x, y }); + const s = await t.ctx.agent.snapshot(); + const heroWalk = await walk(s.hero.tile.x, s.hero.tile.y); + c.expect(heroWalk === true, 'тайл героя непроходим', s.hero.tile); + c.expect((await walk(40, 16)) === false, 'вода проходима?'); + c.expect((await walk(7, 1)) === false, 'дерево проходимо?'); + return null; + }); + await c.run('scene:raycast: дерево перекрывает, вода прозрачна', async () => { + const ray = (from, to) => t.ctx.agent.command('scene:raycast', { from, to }); + // Вертикальный луч (7,0)->(7,2) задевает дерево (7,1). + c.expect((await ray({ x: 7, y: 0 }, { x: 7, y: 2 })) === false, 'дерево не перекрыло луч'); + // Берег-берег через пруд: вода прозрачна, луч чист. + c.expect((await ray({ x: 37, y: 16 }, { x: 45, y: 16 })) === true, 'вода перекрыла луч'); + return null; + }); + await c.run('scene:walk в непроходимый — false, approach — к соседу', async () => { + await t.sleepEnemies(); + // Ближе к дереву: маршрут от (2,3) не задевает спящих ползунов у тропы + // (сам (2,2) — тайл перехода на пруды, туда телепортировать нельзя). + await t.ctx.agent.command('scene:teleport', { x: 2, y: 3 }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); + // Путь в дерево (7,1) не строится: scene:walk — false. + const walked = await t.ctx.agent.command('scene:walk', { x: 7, y: 1 }); + c.expect(walked === false, 'scene:walk провёл героя в дерево', walked); + // approach подводит к проходимому соседу дерева. + const dest = await t.ctx.agent.approach(7, 1); + c.expect(dest !== null, 'approach к дереву не нашёл соседа'); + const s = await t.ctx.agent.snapshot(); + c.expect( + s.hero.tile.x === dest.x && s.hero.tile.y === dest.y, + 'герой не на тайле прибытия approach', + { dest, after: s.hero.tile } + ); + const col = s.collision; + const idx = s.hero.tile.y * col.width + s.hero.tile.x; + c.expect(col.blocked[idx] === 0, 'герой стоит в блоке', s.hero.tile); + return null; + }); + await c.run('расталкивание: враг выталкивает себя из героя', async () => { + await t.sleepEnemies(); + // Ползун спит в (20.5,14.5); телепортируемся внутрь его тела. + await t.ctx.agent.command('scene:teleport', { x: 20, y: 14 }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); + const s = await t.ctx.agent.snapshot(); + const en = (s.enemies ?? []).find( + (e) => !e.dead && Math.hypot(e.pos.x - 20.5, e.pos.y - 14.5) < 1.2 + ); + c.expect(!!en, 'ползун у тропы не найден', s.enemies); + const gap = Math.hypot(en.pos.x - s.hero.pos.x, en.pos.y - s.hero.pos.y); + c.expect(gap >= 0.4, 'враг остался внутри тела героя', { gap, en: en.pos, hero: s.hero.pos }); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/final.mjs b/v1/apps/game/tools/checks/final.mjs new file mode 100644 index 0000000..49ce0ff --- /dev/null +++ b/v1/apps/game/tools/checks/final.mjs @@ -0,0 +1,74 @@ +/** + * Сценарий final — финал акта 2: правильный звон Старой Машины. + * 1) путь акта 2: Звенец -> роща -> разъезд -> люк в зал Машины; + * 2) без настроенного резонатора Машина отказывает (locked-текст); + * 3) чит-настройка (quest_resonator_done): действие E у Машины -> реакция ставит + * machine_lulled и запускает катсцену (тост «дышит тише», конец); + * 4) повторное действие: Машина спит; + * 5) инварианты чисты после финала. + * Запуск: node tools/agent.mjs run tools/checks/final.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'final', + async (t) => { + const { c } = t; + await c.run('путь акта 2: спуск через люк в зал Машины', async () => { + await t.boot(); + await t.sleepEnemies(); + await t.goToArea('zvenets', { x: 54, y: 28 }); + await t.sleepEnemies(); + await t.ctx.agent.command('scene:teleport', { x: 53, y: 20 }); + await t.goToArea('rust_grove', { x: 54, y: 20 }); + await t.sleepEnemies(); + await t.goToArea('old_siding', { x: 46, y: 20 }); + await t.sleepEnemies(); + await t.goToArea('machine_hall', { x: 28, y: 31 }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.area === 'machine_hall', 'герой не в зале Машины', s.area); + return null; + }); + await c.run('без резонатора Машина отказывает', async () => { + const used = await t.useInteractable('old_machine'); + c.expect(used, 'не подошли к Машине'); + const w = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("не настроен")', { + timeoutTicks: 1200 + }); + c.expect(w.ok, 'Машина не отказала без резонатора', w.snapshot.lastToast); + c.expect(!w.snapshot.flags.includes('machine_lulled'), 'флаг machine_lulled без настройки'); + return null; + }); + await c.run('правильный звон: катсцена, machine_lulled', async () => { + await t.ctx.agent.command('scene:setFlag', { flag: 'quest_resonator_done' }); + const used = await t.useInteractable('old_machine'); + c.expect(used, 'не подошли к Машине'); + const f = await t.ctx.agent.waitFor('s.flags.includes("machine_lulled")', { + timeoutTicks: 1200 + }); + c.expect(f.ok, 'реакция не подняла machine_lulled', f.snapshot.flags); + const toast = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("дышит тише")', { + timeoutTicks: 1200 + }); + c.expect(toast.ok, 'нет тоста финала', toast.snapshot.lastToast); + await t.ctx.agent.waitFor('!s.cutscene?.active', { timeoutTicks: 1200 }); + return null; + }); + await c.run('повторное действие: Машина спит', async () => { + const used = await t.useInteractable('old_machine'); + c.expect(used, 'не подошли к Машине'); + const w = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("спит")', { + timeoutTicks: 1200 + }); + c.expect(w.ok, 'повторная реакция не про «сон»', w.snapshot.lastToast); + return null; + }); + await c.run('инварианты чисты после финала', async () => { + await t.expectInvariantsClean('после финала акта 2'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/hero_depth.mjs b/v1/apps/game/tools/checks/hero_depth.mjs new file mode 100644 index 0000000..ad20780 --- /dev/null +++ b/v1/apps/game/tools/checks/hero_depth.mjs @@ -0,0 +1,85 @@ +/** + * Сценарий hero_depth — герой за tall-объектами (задача 19): + * деревья/дома-тайлы/валуны живут в одном depth-слое с героем (глубина tx+ty), + * поэтому с юга герой поверх дерева, с севера — дерево поверх героя. + * Дерево ищется по коллизии: blocked-клетка с проходимым севером и югом, + * не покрытая footprint'ом пропов. + * Запуск: node tools/agent.mjs run tools/checks/hero_depth.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function () { + return withChecks( + 'hero_depth', + async (t) => { + const { c } = t; + await c.run('снапшот: ищем дерево (blocked с проходимым севером/югом)', async () => { + await t.boot(); + await t.sleepEnemies(); + // Новая игра стартует в лугах — переход не нужен. + const s = await t.ctx.agent.snapshot(); + const w = s.collision.width; + const blocked = (x, y) => s.collision.blocked[y * w + x] === 1; + const covered = (x, y) => + (s.collision.props ?? []).some( + (p) => x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h + ); + const cand = []; + for (let y = 1; y < s.collision.height - 1; y++) { + for (let x = 1; x < w - 1; x++) { + if (blocked(x, y) && !covered(x, y) && !blocked(x, y - 1) && !blocked(x, y + 1)) { + cand.push({ x, y }); + } + } + } + c.expect(cand.length > 0, 'в лугах не нашлось дерева с проходимым севером/югом'); + t.tree = cand[0]; + return null; + }); + await c.run('глубина: с юга герой поверх, с севера — за деревом', async () => { + const { x, y } = t.tree; + const teleport = async (tx, ty, name) => { + await t.ctx.agent.command('scene:teleport', { x: tx, y: ty }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); + const s = await t.ctx.agent.snapshot(); + c.expect( + s.hero.tile.x === tx && s.hero.tile.y === ty, + `телепорт в (${tx},${ty}) не удался`, + s.hero.tile + ); + await t.ctx.agent.step(2, { render: true }); + await t.ctx.page.screenshot({ path: `/tmp/rpg_hero_depth_${name}.png` }); + // Численно: zIndex дерева (tx+ty) против героя — кто «ближе» + // к камере, тот и рисуется позже (sortableChildren). + const z = await t.ctx.page.evaluate(() => { + const scene = window.__game.scenes.current; + return { + heroZ: scene.player.view.zIndex, + // zIndex всех прочих детей actors: среди них — дерево. + treeZs: scene.actors.children + .filter((ch) => ch !== scene.player.view) + .map((ch) => ch.zIndex) + }; + }); + c.expect( + z.treeZs.includes(x + y), + `tall-вьюха дерева (z=${x + y}) не в actors-слое`, + z.treeZs?.slice(0, 12) + ); + return z; + }; + const south = await teleport(x, y + 1, 'south'); // герой перед деревом + const north = await teleport(x, y - 1, 'north'); // герой за деревом + c.expect( + south.heroZ > x + y && north.heroZ < x + y, + 'глубина героя относительно дерева не меняется при телепорте', + { south: south.heroZ, north: north.heroZ, tree: x + y } + ); + return null; + }); + const inv = await t.ctx.agent.invariants(); + const errs = inv.filter((i) => i.severity === 'error'); + c.expect(errs.length === 0, 'инварианты не чисты', errs); + } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/interact-world.mjs b/v1/apps/game/tools/checks/interact-world.mjs new file mode 100644 index 0000000..0230a77 --- /dev/null +++ b/v1/apps/game/tools/checks/interact-world.mjs @@ -0,0 +1,122 @@ +/** + * Сценарий interact-world — взаимодействие с миром шире (батч 3). + * 1) столб у тропы: тост с текстом знака; + * 2) подбор мота: предмет в сумке, вар motes растёт, used:; + * 3) второй и третий моты: вар копится (addVar, порядок не важен); + * 4) знак наката у прудов; + * 5) верёвка колокола: флаг rang_tower, повтор — другой текст; + * 6) Мила: знакомство -> повторный диалог с веткой по мотам; + * 7) инвариант interact-used-consistent чист. + * Запуск: node tools/agent.mjs run tools/checks/interact-world.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'interact-world', + async (t) => { + const { c } = t; + await c.run('столб у тропы: тост с текстом знака', async () => { + await t.boot(); + await t.sleepEnemies(); + // Столб у тропы в Звенец (луга, восток) — подходим и жмём E. + const opened = await t.useInteractable('sign_meadows_east'); + c.expect(opened, 'не подошли к столбу'); + const w = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("Звенец")', { + timeoutTicks: 1500 + }); + c.expect(w.ok, 'знак не показал текст', w.snapshot.lastToast); + return null; + }); + await c.run('мот 1: предмет + вар + used', async () => { + const opened = await t.useInteractable('mote_1'); + c.expect(opened, 'не подошли к моту'); + const w = await t.ctx.agent.waitFor( + '(s.inventory.find((i) => i.id === "mote")?.count ?? 0) === 1 && s.vars.motes === 1', + { timeoutTicks: 1500 } + ); + c.expect(w.ok, 'подбор мота не дал предмет и вар', { + inventory: w.snapshot.inventory, + vars: w.snapshot.vars + }); + const s = await t.ctx.agent.snapshot(); + const mote = (s.interactables ?? []).find((o) => o.id === 'mote_1'); + c.expect(mote?.used === true, 'мот не помечен used', mote); + return null; + }); + await c.run('моты 2 и 3: вар копится (addVar)', async () => { + let opened = await t.useInteractable('mote_2'); + c.expect(opened, 'не подошли ко второму моту'); + const w2 = await t.ctx.agent.waitFor('s.vars.motes === 2', { timeoutTicks: 1500 }); + c.expect(w2.ok, 'второй мот не поднял вар до 2', w2.snapshot.vars); + opened = await t.useInteractable('mote_3'); + c.expect(opened, 'не подошли к третьему моту'); + const w3 = await t.ctx.agent.waitFor('s.vars.motes === 3', { timeoutTicks: 1500 }); + c.expect(w3.ok, 'третий мот не поднял вар до 3', w3.snapshot.vars); + return null; + }); + await c.run('знак наката у прудов', async () => { + // Тропа на пруды (2,2): step-переход; у берега — столб. + await t.goToArea('ponds', { x: 2, y: 2 }); + const opened = await t.useInteractable('sign_ponds'); + c.expect(opened, 'не подошли к знаку наката'); + const ws = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("накатом")', { + timeoutTicks: 900 + }); + c.expect(ws.ok, 'знак наката не показал текст', ws.snapshot.lastToast); + // Обратно на луга (2,2) прудов. + await t.goToArea('meadows', { x: 2, y: 2 }); + return null; + }); + await c.run('верёвка колокола: флаг + повтор с другим текстом', async () => { + // Звенец через тропу (54,28); верёвка у башни — подходим и звоним. + await t.goToArea('zvenets', { x: 54, y: 28 }); + const opened = await t.useInteractable('bell_rope'); + c.expect(opened, 'не подошли к верёвке колокола'); + const wr = await t.ctx.agent.waitFor('s.flags.includes("rang_tower")', { + timeoutTicks: 1500 + }); + c.expect(wr.ok, 'звон не поднял rang_tower', wr.snapshot.flags); + const s1 = await t.ctx.agent.snapshot(); + const text1 = s1.lastToast?.text ?? ''; + c.expect(text1.includes('ушёл в землю'), 'первый звон — не тот текст', text1); + await t.useInteractable('bell_rope'); + await t.ctx.agent.step(150); + const s2 = await t.ctx.agent.snapshot(); + const text2 = s2.lastToast?.text ?? ''; + c.expect( + text2.includes('снова') && text2 !== text1, + 'повторный звон не сменил текст', + text2 + ); + return null; + }); + await c.run('Мила: знакомство -> повтор с веткой по мотам', async () => { + // Первое знакомство (trader_first): полотно. + const talked = await t.talkTo('trader'); + c.expect(talked, 'первый диалог с Милой не открылся'); + const d1 = await t.ctx.agent.runDialogue(900); + c.expect(d1, 'первый диалог с Милой не завершился'); + // Повтор (trader_repeat): при моты >= 1 после первой реплики идёт + // ветка о мота́х — листаем вручную и ловим её текст. + const talked2 = await t.talkTo('trader'); + c.expect(talked2, 'повторный диалог не открылся'); + await t.ctx.agent.press('advance'); // догнать печать (typewriter) + await t.ctx.agent.press('advance'); // следующая реплика + const w = await t.ctx.agent.waitFor( + 's.dialogue != null && (s.dialogue.text ?? "").includes("Моты")', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'в повторном диалоге нет ветки о мота́х', w.snapshot.dialogue); + const d2 = await t.ctx.agent.runDialogue(600); + c.expect(d2, 'повторный диалог не завершился'); + return null; + }); + await c.run('инвариант: used-флаги и снапшот согласованы', async () => { + await t.expectInvariantsClean('после обхода мира'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/interact.mjs b/v1/apps/game/tools/checks/interact.mjs new file mode 100644 index 0000000..9d680de --- /dev/null +++ b/v1/apps/game/tools/checks/interact.mjs @@ -0,0 +1,156 @@ +/** + * Сценарий interact — интерьеры и интерактивные объекты. + * 1) дверь: действие E у дома Ирвина -> интерьер house_elder; + * 2) сундук (once): предмет в сумке, used:, повтор молчит; + * 3) очаг: реакция зависит от флага quest_bells_done; + * 4) записка (once): флаг read_note; + * 5) выход через проём: step kind 'return' -> Звенец, герой на тайле входа; + * 6) лавка Милы: прилавок меняет реакцию с флагом; + * 7) размещение NPC по флагу: act2_hook -> Клинт появляется в Ржавой роще, + * действие E у него открывает диалог. + * Запуск: node tools/agent.mjs run tools/checks/interact.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + // Тайл, с которого герой нажал E у двери (цель kind 'return'). + let entryTile = null; + return withChecks( + 'interact', + async (t) => { + const { c } = t; + await c.run('дверь: дом Ирвина -> интерьер', async () => { + await t.boot(); + await t.sleepEnemies(); + // Добираемся до Звенца пешком (луга -> тропа (54,28)). + await t.goToArea('zvenets', { x: 54, y: 28 }); + // Подходим к дому (дверь (13,12)) и жмём действие. + const opened = await t.interactAt(13, 12); + c.expect(opened, 'не подошли к дому Ирвина'); + entryTile = (await t.ctx.agent.snapshot()).hero.tile; + const w = await t.ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'действие у двери не открыло интерьер', { area: w.snapshot.area }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.hero.tile.x === 5 && s.hero.tile.y === 7, 'герой не у проёма (5,7)', s.hero.tile); + return null; + }); + await c.run('сундук: предмет + used, повтор молчит', async () => { + const before = await t.ctx.agent.snapshot(); + const had = before.inventory.find((i) => i.id === 'cloth')?.count ?? 0; + // Подход к сундуку и действие — герой открывает. + const opened = await t.useInteractable('chest_elder'); + c.expect(opened, 'не подошли к сундуку'); + const w = await t.ctx.agent.waitFor( + `(s.inventory.find((i) => i.id === 'cloth')?.count ?? 0) === ${had + 1}`, + { timeoutTicks: 1200 } + ); + c.expect(w.ok, 'сундук не дал полотно', { inventory: w.snapshot.inventory }); + const s = await t.ctx.agent.snapshot(); + const chest = (s.interactables ?? []).find((o) => o.id === 'chest_elder'); + c.expect(chest?.used === true, 'сундук не помечен used', chest); + c.expect((s.flags ?? []).includes('used:chest_elder'), 'нет флага used:chest_elder'); + // Повтор: тик последнего тоста не меняется. + const t0 = s.lastToast?.tick ?? -1; + await t.useInteractable('chest_elder'); + await t.ctx.agent.step(120); + const s2 = await t.ctx.agent.snapshot(); + c.expect((s2.lastToast?.tick ?? -1) === t0, 'повторное взаимодействие не молчит', s2.lastToast); + return null; + }); + await c.run('очаг: реакция меняется с флагом', async () => { + await t.useInteractable('hearth_elder'); + await t.ctx.agent.step(120); + const s1 = await t.ctx.agent.snapshot(); + const text1 = s1.lastToast?.text ?? ''; + c.expect(text1.includes('щепки'), 'реакция очага до флага не та', text1); + await t.ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); + await t.useInteractable('hearth_elder'); + await t.ctx.agent.step(120); + const s2 = await t.ctx.agent.snapshot(); + c.expect( + (s2.lastToast?.text ?? '').includes('ровнее'), + 'реакция очага после флага не та', + s2.lastToast + ); + return null; + }); + await c.run('записка: флаг read_note', async () => { + await t.useInteractable('note_elder'); + await t.ctx.agent.step(120); + const s = await t.ctx.agent.snapshot(); + c.expect((s.flags ?? []).includes('read_note'), 'записка не подняла read_note', s.flags); + return null; + }); + await c.run('выход через проём: возврат в Звенец', async () => { + // Пешком в проём (5,8) — step-переход kind 'return' срабатывает на шаге. + await t.ctx.agent.walkTo(5, 8, { timeoutTicks: 300 }); + const w = await t.ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'проём не вернул в Звенец', { area: w.snapshot.area }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await t.ctx.agent.snapshot(); + c.expect( + s.hero.tile.x === entryTile.x && s.hero.tile.y === entryTile.y, + 'герой не на тайле, откуда нажал E у двери', + { entry: entryTile, after: s.hero.tile } + ); + return null; + }); + await c.run('лавка Милы: прилавок меняет реакцию', async () => { + // Действие у двери лавки (43,19) — интерьер; прилавок внутри. + const opened = await t.interactAt(43, 19); + c.expect(opened, 'не подошли к двери лавки'); + const w = await t.ctx.agent.waitFor('s.area === "shop"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'лавка не открылась', { area: w.snapshot.area }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await t.useInteractable('counter_shop'); + await t.ctx.agent.step(120); + const s1 = await t.ctx.agent.snapshot(); + const text1 = s1.lastToast?.text ?? ''; + c.expect(text1.includes('соль'), 'реакция прилавка до флага не та', text1); + await t.useInteractable('counter_shop'); + await t.ctx.agent.step(120); + const s2 = await t.ctx.agent.snapshot(); + c.expect( + (s2.lastToast?.text ?? '').includes('цветы'), + 'прилавок не изменил реакцию после флага', + s2.lastToast + ); + return null; + }); + await c.run('снапшот: интерактивные объекты области', async () => { + const s = await t.ctx.agent.snapshot(); + const list = s.interactables ?? []; + c.expect(list.some((o) => o.id === 'counter_shop'), 'прилавка нет в снапшоте', list); + c.expect(list.every((o) => typeof o.used === 'boolean'), 'объект без поля used', list); + return null; + }); + await c.run('размещение NPC по флагу: Клинт в роще', async () => { + // Выходим из лавки обратно в Звенец (проём (5,8)). + await t.ctx.agent.walkTo(5, 8, { timeoutTicks: 300 }); + const back = await t.ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + c.expect(back.ok, 'не вернулись из лавки в Звенец', { area: back.snapshot.area }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + // Флаг-крючок акта 2: после него Клинт стоит в Ржавой роще. + await t.ctx.agent.command('scene:setFlag', { flag: 'act2_hook' }); + // Роща: Звенец (54,20) -> роща (2,20). + await t.goToArea('rust_grove', { x: 54, y: 20 }); + await t.sleepEnemies(); + const s = await t.ctx.agent.snapshot(); + const clint = (s.npcs ?? []).find((n) => n.id === 'clint'); + c.expect(clint !== undefined, 'Клинта нет в роще после act2_hook', s.npcs); + if (!clint) return null; + // Действие у Клинта — сцена ждёт подхода героя (E работает только + // в радиусе); путь через рощу небыстрый — таймаут с запасом. + const talked = await t.talkTo('clint'); + c.expect(talked, 'диалог Клинта не открылся'); + const d = await t.ctx.agent.runDialogue(2400); + c.expect(d, 'диалог Клинта не завершился'); + const s2 = await t.ctx.agent.snapshot(); + c.expect((s2.flags ?? []).includes('met_clint'), 'диалог не поднял met_clint', s2.flags); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/lighting.mjs b/v1/apps/game/tools/checks/lighting.mjs new file mode 100644 index 0000000..685a183 --- /dev/null +++ b/v1/apps/game/tools/checks/lighting.mjs @@ -0,0 +1,131 @@ +/** + * Сценарий lighting — освещение сцены через мост: ambient областей, день/ночь + * по внутриигровому времени (scene:setTime), окна Звенца, очаг дома Ирвина + * (флаг квеста), лампа героя, мерцание, виньетка при низком hp. + * Перед каждым ambient-ассертом время пинуется — реальный темп (1 с = 1 мин) + * не должен влиять на результат. + * 1) Луга, полдень: светлый дневной ambient, источников нет; + * 2) 21:00: ночная цель ambient лугов, лампа героя на светлой днём улице; + * 3) Звенец ночью: ночная цель деревни, три окна горят, башня молчит; + * 4) Дом Ирвина: тёмный интерьер, hearth_low, лампа; мерцание очага гуляет; + * 5) телепорт внутри дома — лампа следует за героем (разность векторов); + * 6) quest_bells_done: hearth_high вместо hearth_low и строго ярче; + * 7) урон герою до hp 2 — виньетка темнеет (полный боевой путь); + * 8) инварианты чисты. + * Запуск: node tools/agent.mjs run tools/checks/lighting.mjs + */ +import { withChecks } from '../lib.mjs'; + +/** Ambient областей (те же константы, что в data/locations.ts). */ +const AMB = { + meadows: 0xf7f4ea, + meadowsNight: 0x3e4658, + zvenets: 0xf5f2ea, + zvenetsNight: 0x454c62, + house_elder: 0x54586a +}; + +/** Экранное смещение источника b относительно a (px). */ +function lightVec(s, a, b) { + const x = s.lighting.sources.find((l) => l.id === a); + const y = s.lighting.sources.find((l) => l.id === b); + return x && y ? { x: y.x - x.x, y: y.y - x.y } : null; +} + +export default async function ({ pretty }) { + return withChecks( + 'lighting', + async (t) => { + const { c } = t; + await c.run('луга в полдень: дневной ambient, источников нет', async () => { + await t.boot(); + await t.ctx.agent.command('scene:setTime', { hours: 12 }); + const w = await t.ctx.agent.waitFor( + `s.lighting.ambient === ${AMB.meadows} && s.lighting.sources.length === 0`, + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'днём луга не светлы или есть источники', w.snapshot?.lighting); + c.expect(w.snapshot.time.night === 0, 'в полдень фактор ночи не нулевой', w.snapshot.time); + return null; + }); + await c.run('ночь на лугах (21:00): ночная цель ambient, лампа героя', async () => { + await t.ctx.agent.command('scene:setTime', { hours: 21 }); + const w = await t.ctx.agent.waitFor( + `s.lighting.ambient === ${AMB.meadowsNight} && s.lighting.sources.some((l) => l.id === "lamp")`, + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'ночью луга не потемнели или лампа не зажглась', w.snapshot?.lighting); + c.expect(w.snapshot.time.night === 1, 'в 21:00 фактор ночи не единичный', w.snapshot.time); + return null; + }); + await c.run('Звенец ночью: ночная цель, три окна, башня молчит', async () => { + await t.goToArea('zvenets', { x: 54, y: 28 }); + await t.ctx.agent.command('scene:setTime', { hours: 21 }); // пин: ходьба ест время + const s = await t.ctx.agent.snapshot(); + c.expect(s.lighting.ambient === AMB.zvenetsNight, 'ambient Звенца не ночной', s.lighting); + const windows = s.lighting.sources.filter((l) => l.id.startsWith('window_')); + c.expect(windows.length === 3, 'ночью должно гореть три окна домов', s.lighting.sources); + c.expect(!s.lighting.sources.some((l) => l.id === 'tower_resonance'), 'башня светит без звонка', s.lighting.sources); + c.expect(s.lighting.sources.some((l) => l.id === 'lamp'), 'ночью на улице нет лампы героя', s.lighting.sources); + return null; + }); + await c.run('дом Ирвина: тёмный интерьер, hearth_low, лампа героя', async () => { + const opened = await t.interactAt(13, 12); // дверь дома — действие E + c.expect(opened, 'не подошли к двери дома Ирвина'); + const w = await t.ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'не вошли в дом Ирвина'); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.lighting.ambient === AMB.house_elder, 'ambient интерьера не тёмный', s.lighting); + c.expect(s.lighting.sources.some((l) => l.id === 'hearth_low'), 'нет тлеющего очага', s.lighting.sources); + c.expect(!s.lighting.sources.some((l) => l.id === 'hearth_high'), 'очаг горит ровно до сдачи квеста', s.lighting.sources); + c.expect(s.lighting.sources.some((l) => l.id === 'lamp'), 'в тёмном доме нет лампы героя', s.lighting.sources); + // Мерцание: серия снапшотов — интенсивность очага гуляет (с допуском). + const seen = new Set(); + for (let i = 0; i < 8; i++) { + const shot = await t.ctx.agent.snapshot(); + const h = shot.lighting.sources.find((l) => l.id === 'hearth_low'); + if (h) seen.add(Math.round(h.intensity * 100)); + await t.ctx.agent.step(6); + } + c.expect(seen.size >= 3, `мерцание очага не видно (вариантов ${seen.size})`, [...seen]); + return null; + }); + await c.run('лампа героя: следует за телепортом', async () => { + const v1 = lightVec(await t.ctx.agent.snapshot(), 'hearth_low', 'lamp'); + await t.ctx.agent.command('scene:teleport', { x: 2, y: 2 }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); + const v2 = lightVec(await t.ctx.agent.snapshot(), 'hearth_low', 'lamp'); + c.expect(v1 !== null && v2 !== null, 'нет лампы или очага в снапшоте', { v1, v2 }); + // Очаг статичен: v2 - v1 — чистое смещение лампы (камера двигает оба поровну). + const shift = Math.hypot(v2.x - v1.x, v2.y - v1.y); + c.expect(shift > 60, `лампа не переехала за героем (смещение ${shift.toFixed(0)} px)`, { v1, v2 }); + return null; + }); + await c.run('сдача квеста: hearth_high вместо hearth_low и ярче', async () => { + await t.ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); + const w = await t.ctx.agent.waitFor('s.lighting.sources.some((l) => l.id === "hearth_high")', { + timeoutTicks: 300 + }); + c.expect(w.ok, 'после сдачи квеста очаг не разгорелся', w.snapshot.lighting.sources); + const s = await t.ctx.agent.snapshot(); + c.expect(!s.lighting.sources.some((l) => l.id === 'hearth_low'), 'тлеющий очаг не погас', s.lighting.sources); + const high = s.lighting.sources.find((l) => l.id === 'hearth_high'); + // Минимум hearth_high (1.05·0.8) выше максимума hearth_low (0.75). + c.expect(high.intensity > 0.84 - 0.02, 'hearth_high не ярче тлеющего', high); + return null; + }); + await c.run('виньетка: урон до hp 2 — края экрана темнеют', async () => { + await t.ctx.agent.command('scene:damagePlayer', { value: 3 }); + const w = await t.ctx.agent.waitFor('s.lighting.vignette > 0.29', { timeoutTicks: 300 }); + c.expect(w.ok, 'виньетка не загорелась при hp 2', w.snapshot?.lighting); + return null; + }); + await c.run('инварианты чисты при работающем свете', async () => { + await t.expectInvariantsClean('при работающем свете'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/music.mjs b/v1/apps/game/tools/checks/music.mjs new file mode 100644 index 0000000..2e1f86c --- /dev/null +++ b/v1/apps/game/tools/checks/music.mjs @@ -0,0 +1,82 @@ +/** + * Сценарий music — музыкальный слой: тема лугов генерируется правилами + * (data/music.ts, сид) и компилируется движковым секвенсором в рантайме + * (playTheme → playLoopBuffer), факт запуска — в window.__gameAudioLog. + * 1) новая игра в лугах: music/meadows в логе (тема играет) + амбиент; + * 2) тема не перезапускается при возврате в ту же область (guard); + * 3) уход в Звенец (темы нет): слот темы снят (themeKey '') и тема лугов + * не перезапускается. Динамическая музыка разрешена: стем (настороженность) + * и тема боя (погоня тяжеловеса у тропы — его аггро-радиус на пути). + * Запуск: node tools/agent.mjs run tools/checks/music.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'music', + async (t) => { + const { c } = t; + /** Сколько раз ключ встречается в логе (null-лог → 0). */ + const countKey = async (key) => + ((await t.readAudioLog()) ?? []).filter((e) => e.key === key).length; + /** Слот темы страницы (window.__game.themeKey) или null. */ + const themeKey = () => + t.ctx.page.evaluate(() => { + const g = window.__game; + return g ? g.themeKey : null; + }); + await c.run('новая игра в лугах: тема скомпилирована и играет', async () => { + await t.boot(); + // Луга без амбиента (тишина по фидбеку) — тема играет одна. + const log = await t.waitAudioLog((l) => l.some((e) => e.key === 'music/meadows')); + c.expect( + log.some((e) => e.key === 'music/meadows'), + 'темы лугов нет в аудио-логе (компиляция не удалась?)', + log + ); + c.expect( + !log.some((e) => e.key.startsWith('ambience/')), + 'на лугах не должно быть амбиента (тишина)', + log.filter((e) => e.key.startsWith('ambience/')) + ); + return null; + }); + await c.run('возврат в ту же область: тема не перезапускается (guard)', async () => { + const before = await countKey('music/meadows'); + c.expect(before > 0, 'тема лугов не играла перед проверкой guard', before); + // Шагнуть туда-обратно мимо переходов, сцена не меняется — guard ключа. + await t.ctx.agent.walkTo(15, 28, { timeoutTicks: 1500 }); + await t.ctx.agent.walkTo(17, 28, { timeoutTicks: 1500 }); + c.expect((await countKey('music/meadows')) === before, 'тема перезапустилась без смены ключа', { + before, + after: await countKey('music/meadows') + }); + return null; + }); + await c.run('уход в Звенец: слот темы снят, тема лугов не перезапускается', async () => { + // Лог аудио — кольцевой на 24 записи: долгая пешая прогулка на + // картах ×2 вытесняет старые записи шагами, поэтому перед + // переходом лог чистится, и дальше смотрим только новые записи. + await t.ctx.page.evaluate(() => { + (window.__gameAudioLog ?? []).splice(0); + }); + // Пешком к тропе в Звенец (54,28) — честный step-переход. + const walked = await t.ctx.agent.walkTo(54, 28, { timeoutTicks: 6000 }); + c.expect(walked, 'walkTo(54,28) не дошёл до перехода'); + // Слот темы освобождается (тема Звенца пуста; тема боя погони — + // временный ключ, ждём его снятия после деаггро). + for (let i = 0; i < 12 && (await themeKey()) !== ''; i++) { + await t.ctx.agent.step(30); + } + c.expect((await themeKey()) === '', 'слот темы не снят при уходе из лугов'); + c.expect( + (await countKey('music/meadows')) === 0, + 'тема лугов перезапустилась (guard ключа сломан)', + { after: await countKey('music/meadows') } + ); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/perf.mjs b/v1/apps/game/tools/checks/perf.mjs new file mode 100644 index 0000000..f44c73e --- /dev/null +++ b/v1/apps/game/tools/checks/perf.mjs @@ -0,0 +1,92 @@ +/** + * Сценарий perf — базовый замер производительности (baseline оптимизаций). + * Не гейтовский чек: числа фиксируются в коммитах до/после, ассерты только + * на «всё живо» (fps > 0, путь найден, инварианты чисты). + * Важно: launchBrowser гоняет рендер на swiftshader (программный) — fps здесь + * уровень слабого железа, а не обычного десктопа; сравнивать можно только + * числа одинаковых прогонов (до/после), абсолютные значения — не про-player. + * 1) старт: страница+ассеты до маяка (включая фиксированные 1,5 с сна моста — + * вычитать при сравнении), затем newGame() → location; + * 2) путь: время scene:route (один A*) и walkTo через луга (один scene:walk); + * 3) бой: погоня + урон + эффекты — fps/heap после боя; + * 4) fps меряется в реальном времени (rAF), не в ручных шагах моста: + * окно ~0,9 с реального времени, затем снапшот + performance.memory. + * Запуск: node tools/agent.mjs run tools/checks/perf.mjs [--pretty] + */ +import { withChecks } from '../lib.mjs'; + +/** Пауза в реальном времени (rAF-цикл живёт, fpsMeter копит кадры). */ +const idle = (page, ms) => page.evaluate((m) => new Promise((r) => setTimeout(r, m)), ms); + +export default async function ({ pretty }) { + return withChecks( + 'perf', + async (t) => { + const { c } = t; + + await c.run('старт: страница+ассеты, затем newGame → location', async () => { + const t0 = Date.now(); + await t.open({ newGame: false }); // до маяка «ассеты загружены» + 1,5 c сна + const bootMs = Date.now() - t0; + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const t1 = Date.now(); + await t.ctx.agent.newGame(); // сам ждёт s.scene === 'location' + const newGameMs = Date.now() - t1; + const s = await t.ctx.agent.snapshot(); + c.expect(s.scene === 'location', 'после newGame не в локации', s.scene); + return { bootMs: bootMs - 1500, bootMsRaw: bootMs, newGameMs, fps: s.fps }; + }); + + await c.run('путь: один A* (scene:route) и walkTo через луга', async () => { + const t0 = Date.now(); + const route = await t.ctx.agent.command('scene:route', { x: 48, y: 28 }); + const routeMs = Date.now() - t0; + c.expect(Array.isArray(route) && route.length > 0, 'маршрут на восток лугов не найден', route); + const t1 = Date.now(); + const walked = await t.ctx.agent.walkTo(48, 28, { timeoutTicks: 6000 }); + const walkMs = Date.now() - t1; + c.expect(walked, 'walkTo(48,28) не дошёл', null); + return { routeMs, routeLen: route.length, walkMs }; + }); + + await c.run('fps/heap в покое после прогулки', async () => { + await idle(t.ctx.page, 900); + const s = await t.ctx.agent.snapshot(); + const heap = await t.ctx.page.evaluate( + () => (performance.memory ? Math.round(performance.memory.usedJSHeapSize / 1048576) : null) + ); + c.expect(s.fps > 0, 'fps-метр молчит (нет кадров?)', s.fps); + return { fps: s.fps, heapMB: heap }; + }); + + await c.run('бой: погоня, урон, эффекты — fps/heap под нагрузкой', async () => { + await t.ctx.agent.command('scene:sleepAll'); // чистый старт сценария боя + await t.ctx.agent.command('scene:teleport', { x: 15, y: 18 }); // у сталкера (16,18) + await t.ctx.agent.command('scene:noise', { x: 16, y: 18, level: 1 }); // будим + const w = await t.ctx.agent.waitFor( + 's.enemies.some((e) => e.state === "chase")', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'после шума никто не погнался', w.snapshot?.enemies); + // Полноценный бой: сгусток догоняет и бьёт, эффекты/частицы живут. + await t.ctx.agent.step(360); + for (let i = 0; i < 3; i++) { + await t.ctx.agent.command('scene:damageEnemy', { value: 3 }); + await t.ctx.agent.step(60); + } + await idle(t.ctx.page, 900); + const s = await t.ctx.agent.snapshot(); + const heap = await t.ctx.page.evaluate( + () => (performance.memory ? Math.round(performance.memory.usedJSHeapSize / 1048576) : null) + ); + return { fps: s.fps, heapMB: heap, heroHp: s.hero?.hp, enemies: s.enemies?.length }; + }); + + await c.run('инварианты чисты под нагрузкой', async () => { + await t.expectInvariantsClean('в perf-сценарии'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/postfx.mjs b/v1/apps/game/tools/checks/postfx.mjs new file mode 100644 index 0000000..eb2b77f --- /dev/null +++ b/v1/apps/game/tools/checks/postfx.mjs @@ -0,0 +1,76 @@ +/** + * Сценарий postfx — пост-эффекты мира (PostFx: блюр/аберрация/марево). + * На слух/глаз в headless не проверить силу эффекта — проверяем, что + * команды моста применяются (pulse/set/clear), и снимаем скриншоты в пике + * эффекта для визуальной сверки агентом. + * 1) новая игра: baseline без эффектов; + * 2) blur: точечный импульс вокруг героя — скриншот в пике; + * 3) aberration: импульс сдвига каналов — скриншот в пике; + * 4) shimmer: зона марева у героя — скриншот (держится до clear); + * 5) clear: после сброса и шага — команда отвечает, сцена жива. + * Запуск: node tools/agent.mjs run tools/checks/postfx.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'postfx', + async (t) => { + const { c } = t; + await c.run('новая игра: baseline без эффектов', async () => { + await t.boot(); + await t.ctx.agent.screenshot('/tmp/rpg_postfx_baseline.png'); + return null; + }); + await c.run('blur: точечный импульс вокруг героя', async () => { + const s = await t.ctx.agent.snapshot(); + const ok = await t.ctx.agent.command('scene:postfx', { + effect: 'blur', + x: s.hero.tile.x, + y: s.hero.tile.y, + level: 3, + value: 10 + }); + c.expect(ok === true, 'scene:postfx blur не применился', ok); + await t.ctx.agent.step(12); // пик огибающей (attack 0.05 + hold 0.1) + await t.ctx.page.screenshot({ path: '/tmp/rpg_postfx_blur.png' }); + return null; + }); + await c.run('aberration: импульс сдвига каналов', async () => { + const ok = await t.ctx.agent.command('scene:postfx', { + effect: 'aberration', + value: 5 + }); + c.expect(ok === true, 'scene:postfx aberration не применился', ok); + await t.ctx.agent.step(10); + await t.ctx.page.screenshot({ path: '/tmp/rpg_postfx_aberration.png' }); + return null; + }); + await c.run('shimmer: зона марева у героя', async () => { + const s = await t.ctx.agent.snapshot(); + const ok = await t.ctx.agent.command('scene:postfx', { + effect: 'shimmer', + x: s.hero.tile.x, + y: s.hero.tile.y, + level: 3, + value: 2.5 + }); + c.expect(ok === true, 'scene:postfx shimmer не применился', ok); + await t.ctx.agent.step(6); + await t.ctx.page.screenshot({ path: '/tmp/rpg_postfx_shimmer.png' }); + return null; + }); + await c.run('clear: сброс эффектов, сцена жива', async () => { + const ok = await t.ctx.agent.command('scene:postfx', { effect: 'clear' }); + c.expect(ok === true, 'scene:postfx clear не сработал', ok); + await t.ctx.agent.step(30); + await t.ctx.page.screenshot({ path: '/tmp/rpg_postfx_clear.png' }); + // Сцена отвечает на мост после сброса. + const s = await t.ctx.agent.snapshot(); + c.expect(s.scene === 'location', 'сцена не жива после clear', s.scene); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/quest-bells.mjs b/v1/apps/game/tools/checks/quest-bells.mjs new file mode 100644 index 0000000..56fe163 --- /dev/null +++ b/v1/apps/game/tools/checks/quest-bells.mjs @@ -0,0 +1,104 @@ +/** + * Сценарий quest-bells — полный цикл квеста «Три цветка» (акт 1) через мост. + * 1) Ирвин: elder_first до выбора, вариант по ТЕКСТУ (pickChoiceByText) -> + * флаги met_elder + quest_bells_taken; + * 2) чит-цветы (scene:setVar flowers=3) -> повторный разговор: elder_hand_in + * (квест-стадия ready), флаг quest_bells_done, кат-сцена посадки: + * vars.flowers -> 0, тост «Поляна гудит», поляна зеленеет; + * 3) Мила: trader_first (полотно в сумке, met_mila), затем trader_after + * (эпилог из квест-стадии); + * 4) инвариант interact-used-consistent и контент чисты. + * Запуск: node tools/agent.mjs run tools/checks/quest-bells.mjs + */ +import { withChecks } from '../lib.mjs'; + +/** Реплика героя в elder_first (выбор по тексту — не по индексу). */ +const RING_REPLY = 'Прозвоню дорогу до прудов и вернусь до темноты.'; + +export default async function ({ pretty }) { + return withChecks( + 'quest-bells', + async (t) => { + const { c } = t; + await c.run('Ирвин: elder_first, выбор по тексту, флаги квеста', async () => { + await t.boot(); + await t.sleepEnemies(); + // NPC в Звенце: тропа на востоке (54,28), затем Ирвин. + await t.goToArea('zvenets', { x: 54, y: 28 }); + const talked = await t.talkTo('elder'); // подойти и нажать E + c.expect(talked, 'не подошли к Ирвину'); + const o = await t.ctx.agent.waitFor('s.dialogue?.id === "elder_first"', { timeoutTicks: 900 }); + c.expect(o.ok, 'диалог elder_first не открылся', o.snapshot.dialogue); + // Листаем до выбора: advance при выборе активирует курсор — проверяем снапшот. + for (let i = 0; i < 12; i++) { + const s = await t.ctx.agent.snapshot(); + if (s.dialogue?.waitingForChoice) break; + if (!s.dialogue) break; + await t.ctx.agent.press('advance'); // догнать печать или следующая реплика + } + const picked = await t.ctx.agent.pickChoiceByText(RING_REPLY); + c.expect(picked, 'вариант по тексту не найден', (await t.ctx.agent.snapshot()).dialogue); + const d = await t.ctx.agent.runDialogue(900); + c.expect(d, 'диалог не завершился после выбора'); + const w = await t.ctx.agent.waitFor( + 's.flags.includes("met_elder") && s.flags.includes("quest_bells_taken")', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'граф не поднял флаги знакомства и взятия квеста', w.snapshot.flags); + return null; + }); + await c.run('сдача: elder_hand_in, кат-сцена посадки, поляна зеленеет', async () => { + // Чит-цветы: сам сбор (пруды) покрыт сценарием quest-bells/пруды, + // здесь — сдача. + await t.ctx.agent.command('scene:setVar', { id: 'flowers', value: 3 }); + const talked = await t.talkTo('elder'); + c.expect(talked, 'не подошли к Ирвину для сдачи'); + const o = await t.ctx.agent.waitFor('s.dialogue?.id === "elder_hand_in"', { timeoutTicks: 900 }); + c.expect(o.ok, 'квест-стадия не дала elder_hand_in', o.snapshot.dialogue); + const d = await t.ctx.agent.runDialogue(900); + c.expect(d, 'диалог сдачи не завершился'); + const w = await t.ctx.agent.waitFor( + 's.flags.includes("quest_bells_done") && s.vars.flowers === 0', + { timeoutTicks: 900 } + ); + c.expect(w.ok, 'нет флага сдачи или цветы не списаны', { + flags: w.snapshot.flags, + vars: w.snapshot.vars + }); + // Кат-сцена посадки: тост и конец (действие блокируется до её конца). + const toast = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("гудит")', { + timeoutTicks: 900 + }); + c.expect(toast.ok, 'нет тоста посадки', toast.snapshot.lastToast); + await t.ctx.agent.waitFor('!s.cutscene?.active', { timeoutTicks: 900 }); + return null; + }); + await c.run('Мила: trader_first (полотно) -> trader_after (эпилог)', async () => { + const talked = await t.talkTo('trader'); + c.expect(talked, 'не подошли к Миле'); + const o = await t.ctx.agent.waitFor('s.dialogue?.id === "trader_first"', { timeoutTicks: 900 }); + c.expect(o.ok, 'первый диалог Милы не открылся', o.snapshot.dialogue); + const d1 = await t.ctx.agent.runDialogue(900); + c.expect(d1, 'первый диалог Милы не завершился'); + const inv = await t.ctx.agent.waitFor( + '(s.inventory.find((i) => i.id === "cloth")?.count ?? 0) === 1', + { timeoutTicks: 300 } + ); + c.expect(inv.ok, 'полотно (do[] giveItem) не в сумке', inv.snapshot.inventory); + // Повторный разговор: квест-стадия-эпилог -> trader_after. + const talked2 = await t.talkTo('trader'); + c.expect(talked2, 'не подошли к Миле для эпилога'); + const o2 = await t.ctx.agent.waitFor('s.dialogue?.id === "trader_after"', { timeoutTicks: 900 }); + c.expect(o2.ok, 'эпилог trader_after не открылся', o2.snapshot.dialogue); + const d2 = await t.ctx.agent.runDialogue(600); + c.expect(d2, 'эпилог не завершился'); + return null; + }); + await c.run('инварианты чисты после полного квеста', async () => { + await t.expectInvariantsClean('после полного квеста'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/resonator.mjs b/v1/apps/game/tools/checks/resonator.mjs new file mode 100644 index 0000000..38621ac --- /dev/null +++ b/v1/apps/game/tools/checks/resonator.mjs @@ -0,0 +1,92 @@ +/** + * Сценарий resonator — полный цикл квеста «Голос для резонатора» (акт 2). + * 1) крючок акта 2: флаг act2_hook -> Клинт в Ржавой роще; + * 2) Клинт: clint_first -> флаги met_clint + quest_resonator_taken; + * 3) звон у трёх деревьев-механизмов (интерактивы tone_tree_N): + * vars.tones растёт до 3, деревья помечены флагами; + * 4) сдача: clint_tune (квест-стадия ready) -> quest_resonator_done, + * кат-сцена настройки: тост «Резонатор держит тон», конец; + * 5) инварианты чисты после полного квеста. + * Запуск: node tools/agent.mjs run tools/checks/resonator.mjs + */ +import { withChecks } from '../lib.mjs'; + +/** Деревья-механизмы (data/quests.ts QUEST_TONE_TILES → интерактивы tone_tree_N). */ +const TREES = ['tone_tree_1', 'tone_tree_2', 'tone_tree_3']; + +export default async function ({ pretty }) { + return withChecks( + 'resonator', + async (t) => { + const { c } = t; + await c.run('крючок акта 2: Клинт появляется в роще', async () => { + await t.boot(); + await t.sleepEnemies(); + await t.goToArea('zvenets', { x: 54, y: 28 }); + await t.sleepEnemies(); + await t.ctx.agent.command('scene:setFlag', { flag: 'act2_hook' }); + // К восточному переходу — телепортом в Звенце: пешком из центра + // маршрут уводит через город и мост не доживает. + await t.ctx.agent.command('scene:teleport', { x: 53, y: 20 }); + await t.goToArea('rust_grove', { x: 54, y: 20 }); + await t.sleepEnemies(); + const s = await t.ctx.agent.snapshot(); + c.expect((s.npcs ?? []).some((n) => n.id === 'clint'), 'Клинта нет в роще после act2_hook', s.npcs); + return null; + }); + await c.run('Клинт: clint_first, флаги знакомства и взятия квеста', async () => { + const talked = await t.talkTo('clint'); // подойти и нажать E + c.expect(talked, 'не подошли к Клинту'); + const o = await t.ctx.agent.waitFor('s.dialogue?.id === "clint_first"', { timeoutTicks: 2400 }); + c.expect(o.ok, 'диалог clint_first не открылся', o.snapshot.dialogue); + const d = await t.ctx.agent.runDialogue(900); + c.expect(d, 'диалог clint_first не завершился'); + const w = await t.ctx.agent.waitFor( + 's.flags.includes("met_clint") && s.flags.includes("quest_resonator_taken")', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'граф не поднял флаги знакомства и взятия квеста', w.snapshot.flags); + return null; + }); + await c.run('звон у деревьев-механизмов: tones 0 -> 3', async () => { + for (const [i, id] of TREES.entries()) { + const used = await t.useInteractable(id); + c.expect(used, `не подошли к ${id}`); + const w = await t.ctx.agent.waitFor( + `s.vars.tones === ${i + 1} && s.flags.includes("tone_tree_${i + 1}")`, + { timeoutTicks: 2400 } + ); + c.expect(w.ok, `${id} не отдало тон`, { vars: w.snapshot.vars }); + } + return null; + }); + await c.run('сдача: clint_tune, кат-сцена настройки, флаг done', async () => { + // Звон у деревьев шумит: краулер у (12,10) просыпается и может + // убить героя по дороге к Клинту — усыпляем перед сдачей. + await t.sleepEnemies(); + const talked = await t.talkTo('clint'); + c.expect(talked, 'не подошли к Клинту для сдачи'); + const o = await t.ctx.agent.waitFor('s.dialogue?.id === "clint_tune"', { timeoutTicks: 2400 }); + c.expect(o.ok, 'квест-стадия не дала clint_tune', o.snapshot.dialogue); + const d = await t.ctx.agent.runDialogue(900); + c.expect(d, 'диалог clint_tune не завершился'); + const w = await t.ctx.agent.waitFor('s.flags.includes("quest_resonator_done")', { + timeoutTicks: 300 + }); + c.expect(w.ok, 'нет флага сдачи резонатора', w.snapshot.flags); + // Кат-сцена настройки: тост и конец (действие блокируется до её конца). + const toast = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("держит тон")', { + timeoutTicks: 900 + }); + c.expect(toast.ok, 'нет тоста настройки', toast.snapshot.lastToast); + await t.ctx.agent.waitFor('!s.cutscene?.active', { timeoutTicks: 900 }); + return null; + }); + await c.run('инварианты чисты после полного квеста', async () => { + await t.expectInvariantsClean('после квеста резонатора'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/shadows.mjs b/v1/apps/game/tools/checks/shadows.mjs new file mode 100644 index 0000000..047c619 --- /dev/null +++ b/v1/apps/game/tools/checks/shadows.mjs @@ -0,0 +1,64 @@ +/** + * Сценарий shadows — тени мира (блобы актёров + направленные тени tall-объектов). + * Математика sunShadow чистая и покрыта юнит-тестами; здесь проверяем, что + * тени в игре ползут за солнцем и гаснут ночью — визуально, по скриншотам + * в ключевые часы (утро/полдень/вечер/ночь). + * 1) новая игра (8:00): baseline — утренние тени влево; + * 2) setTime 13: полдень — короткие тени под объектами; + * 3) setTime 19: вечер — тени вправо; + * 4) setTime 23: ночь — теней нет; + * 5) setTime 8 обратно + шаг: сцена жива, время применилось. + * Запуск: node tools/agent.mjs run tools/checks/shadows.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'shadows', + async (t) => { + const { c } = t; + const setTime = async (hours) => { + const ok = await t.ctx.agent.command('scene:setTime', { hours }); + c.expect(ok === true, `scene:setTime ${hours}h не применился`, ok); + return ok; + }; + await c.run('новая игра 8:00: baseline, утренние тени влево', async () => { + await t.boot(); + // пережидаем fade-переход меню→локация (скриншот в fade тёмен) + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + // ручные шаги без рендера не обновляют канвас: рисуем кадры явно + await t.ctx.agent.step(10, { render: true }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.scene === 'location', 'не в локации после boot', s.scene); + await t.ctx.page.screenshot({ path: '/tmp/rpg_shadow_morning.png' }); + return null; + }); + await c.run('13:00: полдень, короткие тени', async () => { + await setTime(13); + await t.ctx.agent.step(2); + await t.ctx.page.screenshot({ path: '/tmp/rpg_shadow_noon.png' }); + return null; + }); + await c.run('19:00: вечер, тени вправо', async () => { + await setTime(19); + await t.ctx.agent.step(2); + await t.ctx.page.screenshot({ path: '/tmp/rpg_shadow_evening.png' }); + return null; + }); + await c.run('23:00: ночь, теней нет', async () => { + await setTime(23); + await t.ctx.agent.step(2); + await t.ctx.page.screenshot({ path: '/tmp/rpg_shadow_night.png' }); + return null; + }); + await c.run('возврат к 8:00: время применилось, сцена жива', async () => { + await setTime(8); + await t.ctx.agent.step(5); + const s = await t.ctx.agent.snapshot(); + c.expect(s.scene === 'location', 'сцена не жива после setTime', s.scene); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/synth.mjs b/v1/apps/game/tools/checks/synth.mjs new file mode 100644 index 0000000..2d6192c --- /dev/null +++ b/v1/apps/game/tools/checks/synth.mjs @@ -0,0 +1,96 @@ +/** + * Сценарий synth — спек-синтез: агент без слуха описывает звуки параметрами + * (scene:synthesize → playSpec), факты запуска читаем из window.__gameAudioLog. + * 1) удар (hit): запуск в логе с заданным ключом и громкостью; + * 2) звон (chime): другой ключ, кэш — компиляция не мешает повтору; + * 3) детерминизм: тот же спек дважды — оба запуска в логе; + * 4) негодный спек (нет dur / неизвестный kind) — команда возвращает null; + * 5) гул (hum) не глушит амбиент области (разные голоса). + * Запуск: node tools/agent.mjs run tools/checks/synth.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'synth', + async (t) => { + const { c } = t; + /** + * Команда моста с ожиданием появления ключа в логе + * (синтез асинхронный): считаем запуски ключа до и после. + */ + const synthAndWait = async (spec, { key = 'agent/synth', volume, tries = 12 } = {}) => { + const count = async () => + (await t.readAudioLog()).filter((e) => e.key === key).length; + const before = await count(); + const res = await t.ctx.agent.command('scene:synthesize', { + spec, + key, + ...(volume !== undefined ? { volume } : {}) + }); + const log = await t.waitAudioLog((l) => l.filter((e) => e.key === key).length > before, tries); + return { res, log }; + }; + await c.run('новая игра: спек-синтез недоступен в прод-сборке с внятной ошибкой', async () => { + await t.boot(); + c.expect(Array.isArray(await t.readAudioLog()), 'DEV-лог аудио недоступен (прод-сборка?)'); + return null; + }); + await c.run('удар по спеку: запуск в логе с заданным ключом и громкостью', async () => { + const { res, log } = await synthAndWait( + { kind: 'hit', dur: 0.3, low: 600, high: 3000, seed: 7 }, + { key: 'agent/hit-test', volume: 0.5 } + ); + c.expect(res === true, 'scene:synthesize не принят мостом', res); + const hits = log.filter((e) => e.key === 'agent/hit-test'); + c.expect(hits.length > 0, 'удар по спеку не сыграл', log); + c.expect(Math.abs(hits.at(-1).volume - 0.5) < 0.01, 'громкость не дошла до голоса', hits.at(-1)); + return null; + }); + await c.run('звон по спеку: другой ключ играет параллельно', async () => { + const { res, log } = await synthAndWait( + { kind: 'chime', dur: 0.6, freq: 880 }, + { key: 'agent/chime-test' } + ); + c.expect(res === true, 'scene:synthesize не принят мостом', res); + c.expect(log.some((e) => e.key === 'agent/chime-test'), 'звон по спеку не сыграл', log); + c.expect( + log.some((e) => e.key === 'agent/hit-test'), + 'предыдущий голос пропал из лога (лог мал?)', + log + ); + return null; + }); + await c.run('детерминизм: тот же спек дважды — оба запуска в логе', async () => { + const spec = { kind: 'scrape', dur: 0.5, low: 80, high: 400, tone: 60, seed: 3 }; + const first = await synthAndWait(spec, { key: 'agent/scrape-test' }); + const second = await synthAndWait(spec, { key: 'agent/scrape-test' }); + c.expect(first.res === true && second.res === true, 'команда не принята', [first.res, second.res]); + const plays = second.log.filter((e) => e.key === 'agent/scrape-test'); + c.expect(plays.length >= 2, 'повторный синтез не сыграл', plays); + return null; + }); + await c.run('негодный спек: без dur и с неизвестным kind — null', async () => { + const noDur = await t.ctx.agent.command('scene:synthesize', { spec: { kind: 'hit' } }); + const badKind = await t.ctx.agent.command('scene:synthesize', { spec: { kind: 'thunder', dur: 1 } }); + const noSpec = await t.ctx.agent.command('scene:synthesize', {}); + c.expect(noDur === null, 'спек без dur должен вернуть null', noDur); + c.expect(badKind === null, 'неизвестный kind должен вернуть null', badKind); + c.expect(noSpec === null, 'вызов без спека должен вернуть null', noSpec); + return null; + }); + await c.run('гул (hum) не глушит музыку области', async () => { + const { res, log } = await synthAndWait( + { kind: 'hum', dur: 2, tone: 55, loop: true }, + { key: 'agent/hum-test' } + ); + c.expect(res === true, 'scene:synthesize не принят мостом', res); + c.expect(log.some((e) => e.key === 'agent/hum-test'), 'гул по спеку не сыграл', log); + // Луга без амбиента — проверяем, что гул не глушит тему лугов. + c.expect(log.some((e) => e.key === 'music/meadows'), 'тема лугов пропала', log); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/time.mjs b/v1/apps/game/tools/checks/time.mjs new file mode 100644 index 0000000..3e957f5 --- /dev/null +++ b/v1/apps/game/tools/checks/time.mjs @@ -0,0 +1,100 @@ +/** + * Сценарий time — внутриигровое время суток через мост: старт, ход по тикам, + * кривая суток (закат), ночные источники/лампа, инварианты. + * Темп: 1 реальная секунда = 1 игровая минута, тик фиксированный 1/60 с — + * 600 шагов моста = ровно 10 игровых минут (детерминированно, без ожидания). + * 1) Новая игра: старт 8:00, фактор ночи 0; + * 2) перемотка 8:00 + 600 шагов → +10 минут (±1, float-хвост-floor); + * 3) 19:00 — середина заката: night ≈ 0.5, ambient — микс по фактическому + * фактору (время идёт и после пина, точное равенство неустойчиво); + * 4) 21:00 — ночь: night 1, лампа героя зажглась на светлой днём улице; + * 5) часы (scene:give) кладутся в сумку и видны в снапшоте; + * 6) сумка открыта — время заморожено (локация не тикается), закрыта — идёт; + * 7) инварианты чисты (time-bounded и прочие). + * Запуск: node tools/agent.mjs run tools/checks/time.mjs + */ +import { withChecks } from '../lib.mjs'; + +/** Ambient лугов и его ночной микс на середине заката (19:00). */ +const AMB = { meadows: 0xf7f4ea, night: 0x3e4658 }; +/** Золотой час (те же константы, что в движке lightSim). */ +const DUSK = { peak: 0.45, color: 0xe8c090 }; + +export default async function ({ pretty }) { + return withChecks( + 'time', + async (t) => { + const { c } = t; + await c.run('новая игра: старт 8:00, день', async () => { + await t.boot(); + const s = await t.ctx.agent.snapshot(); + c.expect(s.time.minutes >= 480 && s.time.minutes < 485, `старт не 8:00 (${s.time.label})`, s.time); + c.expect(s.time.night === 0, 'утром фактор ночи не нулевой', s.time); + c.expect(/^\d{1,2}:\d{2}$/.test(s.time.label), `label не часы (${s.time.label})`, s.time); + return null; + }); + await c.run('ход времени: 600 шагов = +10 минут (±1)', async () => { + await t.ctx.agent.command('scene:setTime', { hours: 8 }); // пин: точка отсчёта 480 + await t.ctx.agent.step(600); // 600 тиков × (1/60 с) × 1 мин/с = 10 мин + const s = await t.ctx.agent.snapshot(); + const dt = s.time.minutes - 480; + c.expect(dt >= 9 && dt <= 11, `после 600 шагов прошло ${dt} мин (не ~10)`, s.time); + return null; + }); + await c.run('закат 19:00: night ≈ 0.5, ambient — микс по фактору', async () => { + await t.ctx.agent.command('scene:setTime', { hours: 19 }); + const w = await t.ctx.agent.waitFor('Math.abs(s.time.night - 0.5) < 0.02', { timeoutTicks: 300 }); + c.expect(w.ok, 'на середине заката фактор ночи не ~0.5', w.snapshot?.time); + // Время продолжает идти (1 тик ≈ +1/60 мин) — сверяем ambient с миксом + // по фактическому фактору из того же снапшота (округление по каналам). + const s = await t.ctx.agent.snapshot(); + const mix = (a, b, k) => + [16, 8, 0].map((sh) => { + const ca = (a >> sh) & 255, cb = (b >> sh) & 255; + return Math.round(ca * (1 - k) + cb * k) << sh; + }).reduce((x, y) => x | y); + // Трёхопорный микс суток (день -> золотой час -> ночь), как dayNightAmbient. + const k = s.time.night; + const linear = k < DUSK.peak + ? mix(AMB.meadows, DUSK.color, k / DUSK.peak) + : mix(DUSK.color, AMB.night, (k - DUSK.peak) / (1 - DUSK.peak)); + const expected = k <= 0 ? AMB.meadows : k >= 1 ? AMB.night : linear; + c.expect(s.lighting.ambient === expected, `ambient ${s.lighting.ambient.toString(16)} не микс ${expected.toString(16)}`, { ambient: s.lighting.ambient, night: s.time.night }); + return null; + }); + await c.run('ночь 21:00: night 1, ночная цель и лампа героя', async () => { + await t.ctx.agent.command('scene:setTime', { hours: 21 }); + const w = await t.ctx.agent.waitFor( + `s.time.night === 1 && s.lighting.ambient === ${AMB.night} && s.lighting.sources.some((l) => l.id === "lamp")`, + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'в 21:00 луга не ночные или лампа не зажглась', w.snapshot?.lighting); + return null; + }); + await c.run('часы в сумке: scene:give кладёт предмет', async () => { + await t.ctx.agent.command('scene:give', { id: 'clock' }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.inventory.some((i) => i.id === 'clock' && i.count === 1), 'часов нет в снапшоте сумки', s.inventory); + return null; + }); + await c.run('сумка: время заморожено, после закрытия — идёт', async () => { + await t.ctx.agent.command('scene:setTime', { hours: 10 }); // пин перед открытием + await t.ctx.agent.press('inventory'); // открыть сумку (атомарно) + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 120 }); + await t.ctx.agent.press('advance'); // применить часы (первый пункт) — строка в панели + await t.ctx.agent.step(120); // 2 с: в сумке локация не тикается + const s = await t.ctx.agent.snapshot(); + c.expect(s.time.minutes === 600, `в сумке время уехало (${s.time.label})`, s.time); + await t.ctx.agent.press('inventory'); // закрыть сумку + const w = await t.ctx.agent.waitFor('s.time.minutes > 600', { timeoutTicks: 300 }); + c.expect(w.ok, 'после закрытия сумки время не пошло', w.snapshot?.time); + return null; + }); + await c.run('инварианты чисты при идущем времени', async () => { + await t.expectInvariantsClean('при идущем времени'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/transitions.mjs b/v1/apps/game/tools/checks/transitions.mjs new file mode 100644 index 0000000..a2dc12c --- /dev/null +++ b/v1/apps/game/tools/checks/transitions.mjs @@ -0,0 +1,75 @@ +/** + * Сценарий transitions — единый механизм переходов. + * 1) step-переход: пешком в Звенец -> area==='zvenets'. + * 2) клик-переход: клик по колодцу у дома Ирвина -> телепорт на луга (entry (22,21)). + * 3) обратный колодец: клик по луговому колодцу -> возврат в Звенец. + * 4) акт 2: Звенец -> Ржавая роща -> Старый разъезд -> зал Машины (step). + * 5) снапшот перечисляет переходы области с триггерами. + * Запуск: node tools/agent.mjs run tools/checks/transitions.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'transitions', + async (t) => { + const { c } = t; + await c.run('step-переход: луга -> Звенец пешком', async () => { + await t.boot(); + // Спим врагов, чтобы путь был безопасным; идём к тропе (54,28). + await t.sleepEnemies(); + await t.goToArea('zvenets', { x: 54, y: 28 }); + return null; + }); + await c.run('клик-переход: колодец Звенца -> луга', async () => { + // Колодец (16,13) у дома Ирвина; entry на лугах — (44,41). + // Подходим к колодцу и жмём действие E. + const used = await t.interactAt(16, 13); + c.expect(used, 'не подошли к колодцу Звенца'); + const w = await t.ctx.agent.waitFor('s.area === "meadows"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'колодец не телепортировал на луга', { area: w.snapshot.area }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.hero.tile.x === 44 && s.hero.tile.y === 41, 'герой не на entry (44,41)', s.hero.tile); + return null; + }); + await c.run('обратный колодец: луга -> Звенец', async () => { + // Луговой колодец (44,40) стоит рядом с entry — подходим и жмём E. + const used = await t.interactAt(44, 40); + c.expect(used, 'не подошли к луговому колодцу'); + const w = await t.ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'луговой колодец не вернул в Звенец', { area: w.snapshot.area }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.hero.tile.x === 16 && s.hero.tile.y === 14, 'герой не на entry (16,14) Звенца', s.hero.tile); + return null; + }); + await c.run('снапшот: переходы области с триггерами', async () => { + const s = await t.ctx.agent.snapshot(); + const wells = (s.transitions ?? []).filter((tr) => tr.trigger === 'click'); + c.expect(wells.length >= 1, 'нет click-переходов в снапшоте', s.transitions); + c.expect( + (s.transitions ?? []).every((tr) => typeof tr.to === 'string' && tr.to.length > 0), + 'переход без цели', + s.transitions + ); + return null; + }); + await c.run('акт 2: Звенец -> роща -> разъезд -> зал Машины', async () => { + // Цепочка step-переходов акта 2: восток Звенца -> роща -> разъезд, + // затем люк (28,31) вниз в зал Машины. Врагов спим на каждой + // области: бой сбивает маршрут (как в smoke-act1). + await t.sleepEnemies(); + await t.goToArea('rust_grove', { x: 54, y: 20 }); + await t.sleepEnemies(); + await t.goToArea('old_siding', { x: 46, y: 20 }); + await t.sleepEnemies(); + await t.goToArea('machine_hall', { x: 28, y: 31 }); + // Обратно наверх: проём в нижней стене зала. + await t.goToArea('old_siding', { x: 13, y: 21 }); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/checks/zoom.mjs b/v1/apps/game/tools/checks/zoom.mjs new file mode 100644 index 0000000..c81a83d --- /dev/null +++ b/v1/apps/game/tools/checks/zoom.mjs @@ -0,0 +1,58 @@ +/** + * Сценарий zoom — зум камеры: действие, ходьба и диалог не ломаются при z=2. + * Проверки в Звенце (там NPC): 1) клавиша debugZoom: worldRoot.scale = 2; + * 2) действие E у NPC при зуме открывает диалог; + * 3) walkTo при зуме доходит (culling /z); + * 4) возврат на z=1: масштаб как до зума. + * Запуск: node tools/agent.mjs run tools/checks/zoom.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'zoom', + async (t) => { + const { c } = t; + const worldRoot = () => t.ctx.page.evaluate(() => window.__agent.worldRootView()); + await c.run('включение зума: scale=2', async () => { + await t.boot(); + await t.sleepEnemies(); + await t.goToArea('zvenets', { x: 54, y: 28 }); + const before = await worldRoot(); + c.expect(before.scale === 1, 'стартовый зум не 1', before.scale); + await t.ctx.agent.press('debugZoom'); + // scale ставится в Camera.apply на рендер-кадре — даём кадры. + await t.ctx.agent.step(3, { render: true }); + const after = await worldRoot(); + c.expect(after.scale === 2, 'после debugZoom scale не 2', after.scale); + return null; + }); + await c.run('действие E у NPC при зуме открывает диалог', async () => { + const s = await t.ctx.agent.snapshot(); + const npc = s.npcs?.[0]; + c.expect(!!npc, 'в области нет NPC', s.npcs); + // Герой подходит сам (interactAt) и открывает диалог. + const talked = await t.talkTo(npc.id); + c.expect(talked, 'не подошли к NPC при зуме'); + const d = await t.ctx.agent.runDialogue(900); + c.expect(d, 'диалог с NPC при зуме не открылся'); + return null; + }); + await c.run('walkTo при зуме доходит до цели', async () => { + const s = await t.ctx.agent.snapshot(); + const target = { x: s.hero.tile.x + 3, y: s.hero.tile.y + 3 }; + const walked = await t.ctx.agent.walkTo(target.x, target.y, { timeoutTicks: 1500 }); + c.expect(walked, `walkTo(${target.x},${target.y}) при зуме не дошёл`); + return null; + }); + await c.run('возврат на z=1: масштаб как до зума', async () => { + await t.ctx.agent.press('debugZoom'); + await t.ctx.agent.step(3, { render: true }); + const after = await worldRoot(); + c.expect(after.scale === 1, 'повторный debugZoom не вернул scale=1', after.scale); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/v1/apps/game/tools/dialogue-editor/editor.css b/v1/apps/game/tools/dialogue-editor/editor.css new file mode 100644 index 0000000..50498b9 --- /dev/null +++ b/v1/apps/game/tools/dialogue-editor/editor.css @@ -0,0 +1,137 @@ +/* Редактор диалогов: тёмная тема, без внешних ресурсов. */ + +:root { + --bg: #14161c; + --panel: #1c1f27; + --panel2: #232733; + --border: #3a3f4f; + --text: #d6d9e0; + --muted: #8a8fa0; + --accent: #f0d878; + --edge: #6a7185; + --edge-choice: #d8b04a; + --danger: #d88a8a; + --ok: #8ad88f; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.45 system-ui, sans-serif; + height: 100vh; + display: flex; + flex-direction: column; +} + +.muted { color: var(--muted); } +.spacer { flex: 1; } +.accent { border-color: var(--accent); color: var(--accent); } +.danger { border-color: var(--danger); color: var(--danger); } + +button, select, input, textarea { + background: var(--panel2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + font: inherit; +} +button { cursor: pointer; } +button:hover { border-color: var(--accent); } +input, textarea { width: 100%; } + +#topbar { + display: flex; + gap: 10px; + align-items: center; + padding: 8px 12px; + background: var(--panel); + border-bottom: 1px solid var(--border); +} +#topbar h1 { font-size: 15px; margin: 0; } + +#layout { + flex: 1; + display: grid; + grid-template-columns: 170px 1fr 340px; + min-height: 0; +} + +#graph-list { + background: var(--panel); + border-right: 1px solid var(--border); + padding: 8px; + overflow-y: auto; +} +#graph-list button { + display: block; + width: 100%; + text-align: left; + margin-bottom: 4px; + border: 1px solid transparent; + background: none; +} +#graph-list button:hover { border-color: var(--border); } +#graph-list button.active { border-color: var(--accent); color: var(--accent); } + +#canvas-wrap { position: relative; overflow: auto; min-width: 0; } +#canvas { width: 100%; height: 100%; min-height: 400px; } + +/* Узел-карточка. */ +.node-card rect { + fill: var(--panel); + stroke: var(--border); + rx: 6; +} +.node-card.selected rect { stroke: var(--accent); stroke-width: 2; } +.node-card.ghost rect { stroke-dasharray: 4 3; stroke: var(--danger); } +.node-card text { fill: var(--text); font: 12px system-ui, sans-serif; } +.node-card .speaker { fill: var(--accent); font-weight: 600; } +.node-card .body { fill: var(--muted); } +.node-card .badge { fill: #4a5164; font: 10px monospace; } + +#run-output, #problems { + position: absolute; + left: 12px; + right: 12px; + bottom: 12px; + max-height: 40%; + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 12px; + white-space: pre-wrap; +} +#problems .err { color: var(--danger); } +#problems .warn { color: var(--accent); } + +#inspector { + background: var(--panel); + border-left: 1px solid var(--border); + padding: 10px; + overflow-y: auto; +} +#inspector .row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; } +#inspector label { display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: var(--muted); flex: 1; } +#inspector label.grow { flex: 2; } +#inspector details { margin-bottom: 8px; } +#inspector summary { cursor: pointer; color: var(--muted); margin-bottom: 6px; } +#inspector .buttons { justify-content: space-between; } +#inspector .buttons button { flex: 1; white-space: nowrap; } + +.choice-edit { + border: 1px solid var(--border); + border-radius: 4px; + padding: 6px; + margin-bottom: 6px; + background: var(--panel2); +} +.choice-edit .row { margin-bottom: 4px; } +.choice-edit button { margin-top: 4px; } + +#status.ok { color: var(--ok); } +#status.err { color: var(--danger); } \ No newline at end of file diff --git a/v1/apps/game/tools/dialogue-editor/editor.js b/v1/apps/game/tools/dialogue-editor/editor.js new file mode 100644 index 0000000..2a89708 --- /dev/null +++ b/v1/apps/game/tools/dialogue-editor/editor.js @@ -0,0 +1,578 @@ +/** + * Клиент редактора диалогов (D8): список графов, SVG-канва с карточками и + * рёбрами, инспектор узла. Без фреймворков и CDN; авторитет валидации — + * сервер (probe.ts), клиент лишь мгновенно подсвечивает сирот и неизвестные id. + */ + +const $ = (id) => document.getElementById(id); +const SVGNS = 'http://www.w3.org/2000/svg'; +const CARD_W = 190; +const CARD_H = 74; +const GAP_X = 90; +const GAP_Y = 24; + +let graphIds = []; +let currentId = null; +let mtime = null; +let graph = null; +let selected = null; +let dirty = false; + +/** Позиции узлов последнего рендера (для рёбер). */ +let posMap = new Map(); + +async function jget(url) { + const r = await fetch(url); + const body = await r.json(); + if (!r.ok) throw new Error(body.message ?? `${r.status}`); + return body; +} + +async function jsend(url, method, body) { + const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + return { ok: r.ok, status: r.status, body: await r.json() }; +} + +function setStatus(text, cls = '') { + $('status').textContent = text; + $('status').className = `muted ${cls}`; +} + +function markDirty() { + dirty = true; + setStatus('не сохранено'); +} + +/** Все исходящие рёбра узла: [{to, kind}] — next и next выборов. */ +function edgesOf(id) { + const n = graph.nodes[id]; + if (!n) return []; + const out = []; + if (n.next) out.push({ to: n.next, kind: 'next' }); + (n.choices ?? []).forEach((c) => { + if (c.next) out.push({ to: c.next, kind: 'choice' }); + }); + return out; +} + +/** Слоёная раскладка: BFS от start (уровень → колонка), сироты — в последнюю. */ +function computeLayout() { + const pos = new Map(); + const depth = new Map(); + if (!graph.nodes[graph.start]) return pos; + depth.set(graph.start, 0); + const queue = [graph.start]; + while (queue.length > 0) { + const id = queue.shift(); + for (const e of edgesOf(id)) { + if (graph.nodes[e.to] && !depth.has(e.to)) { + depth.set(e.to, depth.get(id) + 1); + queue.push(e.to); + } + } + } + const rows = new Map(); + const lastCol = Math.max(-1, ...depth.values()) + 1; + for (const id of Object.keys(graph.nodes)) { + const d = depth.get(id) ?? lastCol; + const row = rows.get(d) ?? 0; + rows.set(d, row + 1); + pos.set(id, { x: d * (CARD_W + GAP_X) + 16, y: row * (CARD_H + GAP_Y) + 16, orphan: !depth.has(id) }); + } + return pos; +} + +/** Эллипсис для карточки. */ +function preview(s, n) { + const s2 = (s ?? '').replace(/\s+/g, ' ').trim(); + return s2.length > n ? `${s2.slice(0, n - 1)}…` : s2; +} + +function svgText(x, y, str, cls) { + const t = document.createElementNS(SVGNS, 'text'); + t.setAttribute('x', x); + t.setAttribute('y', y); + if (cls) t.setAttribute('class', cls); + t.textContent = str; + return t; +} + +/** Реплика ли узел: есть текст (inline или ключ). */ +function isReplica(n) { + return n.text !== undefined || n.textKey !== undefined; +} + +/** Краткие бейджи условий и эффектов (внизу карточки). */ +function badgesOf(n) { + const parts = []; + if (n.mood) parts.push(`mood:${n.mood}`); + if (n.when?.length) parts.push(`when:${n.when.join(',')}`); + if (n.whenNot?.length) parts.push(`!${n.whenNot.join(',')}`); + if (n.whenVar) parts.push(`${n.whenVar.key}${n.whenVar.op}${n.whenVar.value}`); + if (n.whenVars?.length) parts.push(`${n.whenVars.length}×var`); + if (n.hasItem?.length) parts.push(`item:${n.hasItem.join(',')}`); + if (n.setFlags?.length) parts.push(`+${n.setFlags.join(',')}`); + if (n.setVars && Object.keys(n.setVars).length) parts.push(`v=${Object.keys(n.setVars).join(',')}`); + (n.do ?? []).forEach((o) => parts.push(`${o.kind}${o.id ? ':' + o.id : ''}`)); + return parts.join(' '); +} + +/** Карточка узла (rect + 4 строки текста). */ +function drawNodeCard(id) { + const n = graph.nodes[id]; + const p = posMap.get(id); + const g = document.createElementNS(SVGNS, 'g'); + g.setAttribute('class', `node-card${selected === id ? ' selected' : ''}${p.orphan ? ' ghost' : ''}`); + g.setAttribute('transform', `translate(${p.x},${p.y})`); + const rect = document.createElementNS(SVGNS, 'rect'); + rect.setAttribute('width', CARD_W); + rect.setAttribute('height', CARD_H); + rect.setAttribute('rx', 6); + g.appendChild(rect); + g.appendChild(svgText(10, 16, n.speaker ?? (isReplica(n) ? '—' : '(действие)'), 'speaker')); + g.appendChild(svgText(10, 30, id, '')); + g.appendChild(svgText(10, 46, preview(n.text ?? n.textKey ?? '', 27), 'body')); + const badges = badgesOf(n); + if (badges) g.appendChild(svgText(10, 62, preview(badges, 30), 'badge')); + g.addEventListener('click', () => select(id)); + $('nodes').appendChild(g); +} + +/** Ребро между карточками: кубическая кривая от правой грани к левой. */ +function drawEdge(from, to, kind) { + const a = posMap.get(from); + const b = posMap.get(to); + if (!a || !b) return; + const x1 = a.x + CARD_W; + const y1 = a.y + CARD_H / 2; + const x2 = b.x; + const y2 = b.y + CARD_H / 2; + const mx = (x1 + x2) / 2; + const path = document.createElementNS(SVGNS, 'path'); + path.setAttribute('d', `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', kind === 'choice' ? 'var(--edge-choice)' : 'var(--edge)'); + if (kind === 'choice') path.setAttribute('stroke-dasharray', '5 4'); + path.setAttribute('stroke-width', '1.5'); + $('edges').appendChild(path); +} + +function renderCanvas() { + posMap = computeLayout(); + $('edges').replaceChildren(); + $('nodes').replaceChildren(); + for (const id of Object.keys(graph.nodes)) { + for (const e of edgesOf(id)) { + if (graph.nodes[e.to]) drawEdge(id, e.to, e.kind); + } + } + for (const id of Object.keys(graph.nodes)) { + drawNodeCard(id); + } + const xs = [...posMap.values()].map((p) => p.x + CARD_W + GAP_X); + const ys = [...posMap.values()].map((p) => p.y + CARD_H + GAP_Y); + $('canvas').setAttribute('viewBox', `0 0 ${Math.max(480, ...xs)} ${Math.max(400, ...ys)}`); +} + +function select(id) { + selected = id; + renderCanvas(); + renderInspector(); +} + +/* ---------- инспектор ---------- */ + +function setField(el, value) { + el.value = value ?? ''; +} + +/** Собрать whenVar из трёх полей (пустой ключ — условия нет). */ +function whenVarFromInputs() { + const key = $('f-whenvar-key').value.trim(); + if (!key) return undefined; + return { key, op: $('f-whenvar-op').value, value: Number($('f-whenvar-value').value || 0) }; +} + +/** do[] из строки «kind:id:count, kind:id». */ +function doFromString(s) { + const ops = []; + for (const part of s.split(',')) { + const [kind, id, count] = part.trim().split(':').map((x) => x.trim()); + if (!kind) continue; + const op = { kind }; + if (id) op.id = id; + if (count) op.count = Number(count); + ops.push(op); + } + return ops.length > 0 ? ops : undefined; +} + +function doToString(ops) { + return (ops ?? []).map((o) => [o.kind, o.id, o.count].filter((x) => x !== undefined).join(':')).join(', '); +} + +/** setVars из строки «ключ=число, ...». */ +function setVarsFromString(s) { + const out = {}; + for (const part of s.split(',')) { + const [k, v] = part.split('=').map((x) => x.trim()); + if (k && v !== undefined && v !== '') out[k] = Number(v); + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function setVarsToString(vars) { + return Object.entries(vars ?? {}).map(([k, v]) => `${k}=${v}`).join(', '); +} + +/** Заполнить поля инспектора из узла. */ +function fillInspector(id) { + const n = graph.nodes[id]; + setField($('f-id'), id); + setField($('f-speaker'), n.speaker); + setField($('f-mood'), n.mood); + setField($('f-text'), n.text); + setField($('f-textkey'), n.textKey); + setField($('f-tags'), (n.tags ?? []).join(', ')); + setField($('f-next'), n.next ?? ''); + setField($('f-when'), (n.when ?? []).join(', ')); + setField($('f-whennot'), (n.whenNot ?? []).join(', ')); + const wv = n.whenVar ?? n.whenVars?.[0]; + setField($('f-whenvar-key'), wv?.key); + setField($('f-whenvar-op'), wv?.op ?? 'ge'); + setField($('f-whenvar-value'), wv?.value); + setField($('f-setflags'), (n.setFlags ?? []).join(', ')); + setField($('f-setvars'), setVarsToString(n.setVars)); + setField($('f-do'), doToString(n.do)); + fillNextOptions(n.next ?? ''); + renderChoicesEditor(id); +} + +/** Варианты next: все узлы + пустой конец. */ +function fillNextOptions(current) { + const sel = $('f-next'); + sel.replaceChildren(new Option('— конец —', '')); + for (const id of Object.keys(graph.nodes)) sel.appendChild(new Option(id, id)); + sel.value = current; +} + +/** Обёртка label>control (строки инспектора). */ +function wrapLabel(text, control, grow) { + const label = document.createElement('label'); + if (grow) label.className = 'grow'; + label.append(text, control); + return label; +} + +/** Редактор одного выбора: text / next / when / удалить. */ +function choiceRow(id, index, ch) { + const div = document.createElement('div'); + div.className = 'choice-edit'; + const text = document.createElement('input'); + text.value = ch.text ?? ''; + text.placeholder = 'текст варианта'; + text.addEventListener('input', () => { + ch.text = text.value; + markDirty(); + renderCanvas(); + }); + const next = document.createElement('select'); + next.appendChild(new Option('— конец —', '')); + for (const nid of Object.keys(graph.nodes)) next.appendChild(new Option(nid, nid)); + next.value = ch.next ?? ''; + next.addEventListener('change', () => { + if (next.value) ch.next = next.value; + else delete ch.next; + markDirty(); + renderCanvas(); + }); + const when = document.createElement('input'); + when.value = (ch.when ?? []).join(', '); + when.placeholder = 'when (флаги, через запятую)'; + when.addEventListener('input', () => { + const flags = listOf(when.value); + if (flags.length > 0) ch.when = flags; + else delete ch.when; + markDirty(); + }); + const del = document.createElement('button'); + del.textContent = '× удалить'; + del.className = 'danger'; + del.addEventListener('click', () => { + graph.nodes[id].choices.splice(index, 1); + if (graph.nodes[id].choices.length === 0) delete graph.nodes[id].choices; + markDirty(); + renderAll(); + }); + const row1 = document.createElement('div'); + row1.className = 'row'; + row1.appendChild(wrapLabel('text', text, true)); + const row2 = document.createElement('div'); + row2.className = 'row'; + row2.appendChild(wrapLabel('next', next, true)); + const row3 = document.createElement('div'); + row3.className = 'row'; + row3.appendChild(wrapLabel('when', when, true)); + div.append(row1, row2, row3, del); + return div; +} + +function renderChoicesEditor(id) { + const n = graph.nodes[id]; + const wrap = $('choices'); + wrap.replaceChildren(); + (n.choices ?? []).forEach((ch, i) => { + wrap.appendChild(choiceRow(id, i, ch)); + }); +} + +/* ---------- запись полей обратно в узел ---------- */ + +/** Поля с comma-списками: пустое поле → пустой список. */ +function listOf(s) { + return s.split(',').map((x) => x.trim()).filter(Boolean); +} + +function bindField(elId, apply) { + $(elId).addEventListener('input', () => { + if (!selected) return; + apply(graph.nodes[selected]); + markDirty(); + renderCanvas(); + }); +} + +/** whenVar-поле: единая запись (редактируем одно условие; whenVars[1+] не трогаем). */ +function bindWhenVar() { + const apply = (n) => { + const wv = whenVarFromInputs(); + if (wv) { + n.whenVar = wv; + delete n.whenVars; + } else { + delete n.whenVar; + delete n.whenVars; + } + }; + for (const id of ['f-whenvar-key', 'f-whenvar-op', 'f-whenvar-value']) bindField(id, apply); +} + +function bindInspector() { + bindField('f-speaker', (n) => { + if ($('f-speaker').value) n.speaker = $('f-speaker').value; + else delete n.speaker; + }); + bindField('f-mood', (n) => { + if ($('f-mood').value) n.mood = $('f-mood').value; + else delete n.mood; + }); + bindField('f-text', (n) => { + if ($('f-text').value) n.text = $('f-text').value; + else delete n.text; + }); + bindField('f-textkey', (n) => { + if ($('f-textkey').value) n.textKey = $('f-textkey').value; + else delete n.textKey; + }); + bindField('f-tags', (n) => { + const tags = listOf($('f-tags').value); + if (tags.length > 0) n.tags = tags; + else delete n.tags; + }); + bindField('f-when', (n) => { + const flags = listOf($('f-when').value); + if (flags.length > 0) n.when = flags; + else delete n.when; + }); + bindField('f-whennot', (n) => { + const flags = listOf($('f-whennot').value); + if (flags.length > 0) n.whenNot = flags; + else delete n.whenNot; + }); + bindWhenVar(); + bindField('f-setflags', (n) => { + const flags = listOf($('f-setflags').value); + if (flags.length > 0) n.setFlags = flags; + else delete n.setFlags; + }); + bindField('f-setvars', (n) => { + const vars = setVarsFromString($('f-setvars').value); + if (vars) n.setVars = vars; + else delete n.setVars; + }); + bindField('f-do', (n) => { + const ops = doFromString($('f-do').value); + if (ops) n.do = ops; + else delete n.do; + }); + $('f-next').addEventListener('change', () => { + if (!selected) return; + if ($('f-next').value) graph.nodes[selected].next = $('f-next').value; + else delete graph.nodes[selected].next; + markDirty(); + renderCanvas(); + }); + $('btn-add-choice').addEventListener('click', () => { + if (!selected) return; + (graph.nodes[selected].choices ??= []).push({ text: 'Новый вариант' }); + markDirty(); + renderAll(); + }); + $('btn-add-node').addEventListener('click', addNode); + $('btn-del-node').addEventListener('click', deleteNode); +} + +/** Свободный id узла: node_N. */ +function freeNodeId() { + let i = 1; + while (graph.nodes[`node_${i}`]) i++; + return `node_${i}`; +} + +function addNode() { + const id = freeNodeId(); + graph.nodes[id] = { text: '' }; + markDirty(); + select(id); +} + +function deleteNode() { + if (!selected) return; + if (selected === graph.start) { + setStatus('start удалять нельзя — выбери другой узел', 'err'); + return; + } + delete graph.nodes[selected]; + selected = null; + markDirty(); + renderAll(); +} + +/* ---------- список графов, открытие, сохранение, dry-run ---------- */ + +async function loadGraphList() { + graphIds = await jget('/api/graphs'); + const nav = $('graph-list'); + nav.replaceChildren(); + for (const g of graphIds) { + const b = document.createElement('button'); + b.textContent = g.id; + b.className = g.id === currentId ? 'active' : ''; + b.addEventListener('click', () => switchGraph(g.id)); + nav.appendChild(b); + } +} + +async function switchGraph(id) { + if (dirty && !confirm('Есть несохранённые правки. Перейти и потерять их?')) return; + await openGraph(id); +} + +async function openGraph(id) { + const data = await jget(`/api/graph/${id}`); + currentId = id; + mtime = data.mtime; + graph = data.graph; + selected = null; + dirty = false; + $('graph-title').textContent = `— ${id}`; + $('run-output').hidden = true; + $('problems').hidden = true; + setStatus('загружено с диска'); + await loadGraphList(); + renderAll(); +} + +function renderInspector() { + if (selected && graph.nodes[selected]) { + $('inspector-empty').hidden = true; + $('inspector-form').hidden = false; + fillInspector(selected); + } else { + $('inspector-empty').hidden = false; + $('inspector-form').hidden = true; + } +} + +function renderAll() { + renderCanvas(); + renderInspector(); +} + +async function save() { + if (!graph) return; + setStatus('валидация…'); + const r = await jsend(`/api/graph/${currentId}`, 'PUT', { mtime, graph }); + if (r.status === 409) { + if (confirm(`${r.body.message}. Перечитать с диска и потерять локальные правки?`)) { + await openGraph(currentId); + } + return; + } + if (r.status === 422) { + showProblems(r.body); + setStatus('ошибки валидации — не сохранено', 'err'); + return; + } + if (!r.ok) { + setStatus(`ошибка: ${r.body.message ?? r.status}`, 'err'); + return; + } + mtime = r.body.mtime; + dirty = false; + $('problems').hidden = true; + setStatus('сохранено', 'ok'); +} + +/** Проблемы (валидация/анализ) в панель над канвой. */ +function showProblems(out) { + const div = $('problems'); + div.replaceChildren(); + const line = (cls, text) => { + const p = document.createElement('div'); + p.className = cls; + p.textContent = text; + div.appendChild(p); + }; + for (const inv of out.errors ?? []) line('err', `[ошибка] ${inv.message}`); + for (const inv of out.warnings ?? []) line('warn', `[предупреждение] ${inv.message}`); + for (const o of out.orphans ?? []) line('warn', `[сирота] узел «${o}» недостижим`); + for (const cy of out.cycles ?? []) line('err', `[цикл без текста] ${cy.join(' → ')}`); + for (const e of out.silentEnds ?? []) line('warn', `[тихий конец] «${e}»`); + div.hidden = div.childElementCount === 0; +} + +async function dryRun() { + if (!graph) return; + setStatus('прогон…'); + const r = await jsend('/api/dry-run', 'POST', { graph, preset: $('preset').value }); + if (!r.ok) { + setStatus(`ошибка: ${r.body.message ?? r.status}`, 'err'); + return; + } + const div = $('run-output'); + div.textContent = r.body.run ?? ''; + div.hidden = false; + showProblems(r.body); + setStatus('прогон готов', 'ok'); +} + +async function init() { + const list = await jget('/api/graphs'); + if (list.length === 0) { + setStatus('в data/dialogues нет графов', 'err'); + return; + } + bindInspector(); + $('btn-save').addEventListener('click', save); + $('btn-reload').addEventListener('click', async () => { + if (!dirty || confirm('Потерять локальные правки?')) await openGraph(currentId); + }); + $('btn-dry').addEventListener('click', dryRun); + window.addEventListener('beforeunload', (e) => { + if (dirty) e.preventDefault(); + }); + await openGraph(list[0].id); +} + +init().catch((e) => setStatus(String(e), 'err')); \ No newline at end of file diff --git a/v1/apps/game/tools/dialogue-editor/index.html b/v1/apps/game/tools/dialogue-editor/index.html new file mode 100644 index 0000000..f2d3c19 --- /dev/null +++ b/v1/apps/game/tools/dialogue-editor/index.html @@ -0,0 +1,104 @@ + + + + + + Редактор диалогов — Пепельные луга + + + +
+

Редактор диалогов

+ + + + + + + +
+ +
+ + +
+ + + + + + +
+ + +
+ + + + \ No newline at end of file diff --git a/v1/apps/game/tools/dialogue-editor/server.mjs b/v1/apps/game/tools/dialogue-editor/server.mjs new file mode 100644 index 0000000..4963f37 --- /dev/null +++ b/v1/apps/game/tools/dialogue-editor/server.mjs @@ -0,0 +1,173 @@ +/** + * Сервер визуального редактора диалогов (D8): node http без зависимостей. + * Отдаёт статику (index.html/editor.js/editor.css), читает и пишет графы + * в data/dialogues/*.json (атомарно, с mtime-гардом от конкурентной правки), + * валидацию и dry-run делает спавном vite-node tools/dialogues/probe.ts — + * правила одни (dialogueRules.ts), дублирования нет. + * + * Запуск: npm run dialogues [-- --port 5299] + */ +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import { readFile, writeFile, rename, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Корень монорепо (server.mjs — на 4 уровня ниже корня). */ +const ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); +const DIALOGUES_DIR = path.join(ROOT, 'apps/game/src/data/dialogues'); +const STATIC_DIR = path.join(ROOT, 'apps/game/tools/dialogue-editor'); +const VITE_NODE = path.join(ROOT, 'node_modules/.bin/vite-node'); +/** Порт редактора: --port N (иначе 5299; строка `??` после indexOf(-1) дала бы NaN). */ +const portIdx = process.argv.indexOf('--port'); +const PORT = + portIdx !== -1 && Number.isFinite(Number(process.argv[portIdx + 1])) + ? Number(process.argv[portIdx + 1]) + : 5299; + + +/** Спавн vite-node (TS-импорты из src): stdout — последняя строка (JSON). */ +function viteNode(args, input) { + return new Promise((resolve, reject) => { + const p = spawn(VITE_NODE, args, { cwd: ROOT }); + let out = ''; + let err = ''; + if (input !== undefined) { + p.stdin.write(input); + } + p.stdin.end(); + p.stdout.on('data', (c) => (out += c)); + p.stderr.on('data', (c) => (err += c)); + p.on('error', reject); + p.on('close', (code) => { + const line = out.trim().split('\n').filter(Boolean).pop() ?? ''; + try { + resolve(JSON.parse(line)); + } catch { + reject(new Error(`probe упал (code ${code}): ${err.slice(-500) || line.slice(-200)}`)); + } + }); + }); +} + +/** Валидация графа (probe.ts): Invariants + анализ достижимости. */ +function probe(graph) { + return viteNode(['apps/game/tools/dialogues/probe.ts'], JSON.stringify({ op: 'validate', graph })); +} + +/** Dry-run графа на пресете состояния. */ +function dryRun(graph, preset) { + return viteNode(['apps/game/tools/dialogues/probe.ts'], JSON.stringify({ op: 'dry-run', graph, preset })); +} + +/** Список графов: id + mtime (для гарандов конкурентной правки). */ +async function listGraphs() { + const files = (await readdir(DIALOGUES_DIR)).filter((f) => f.endsWith('.json')).sort(); + const out = []; + for (const f of files) { + const id = f.replace(/\.json$/, ''); + out.push({ id, mtime: (await stat(path.join(DIALOGUES_DIR, f))).mtimeMs }); + } + return out; +} + +/** Канонический формат файла: 4 пробела + \\n в конце. */ +function serialize(graph) { + return JSON.stringify(graph, null, 4) + '\n'; +} + +const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }; + +/** Ответ хелпером: JSON / текст / буфер статики / ошибка. */ +function send(res, code, body, type = 'application/json; charset=utf-8') { + res.writeHead(code, { 'Content-Type': type }); + if (Buffer.isBuffer(body) || typeof body === 'string') res.end(body); + else res.end(JSON.stringify(body)); +} + +/** Чтение тела запроса (JSON, лимит 2 МБ — графы маленькие). */ +function readBody(req) { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', (c) => { + data += c; + if (data.length > 2 * 1024 * 1024) req.destroy(); + }); + req.on('end', () => { + try { + resolve(JSON.parse(data || '{}')); + } catch (e) { + reject(new Error(`тело не JSON: ${e.message}`)); + } + }); + }); +} + +/** PUT графа: mtime-гард -> валидация -> атомарная запись. */ +async function putGraph(id, body) { + const file = path.join(DIALOGUES_DIR, `${id}.json`); + let current; + try { + current = (await stat(file)).mtimeMs; + } catch { + return { code: 404, body: { message: `граф «${id}» не найден` } }; + } + if (typeof body.mtime !== 'number' || body.mtime !== current) { + // Конкурентная правка: отдаём свежий mtime — клиент предложит перечитать. + return { code: 409, body: { message: 'файл изменён на диске', mtime: current } }; + } + const probeOut = await probe(body.graph); + if (probeOut.errors.length > 0) { + return { code: 422, body: probeOut }; + } + const tmp = `${file}.tmp`; + await writeFile(tmp, serialize(body.graph)); + await rename(tmp, file); + return { code: 200, body: { ok: true, mtime: (await stat(file)).mtimeMs } }; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${PORT}`); + try { + if (url.pathname === '/api/graphs' && req.method === 'GET') { + return send(res, 200, await listGraphs()); + } + const graphMatch = url.pathname.match(/^\/api\/graph\/(\w+)$/); + if (graphMatch) { + const id = graphMatch[1]; + const file = path.join(DIALOGUES_DIR, `${id}.json`); + if (req.method === 'GET') { + const data = await readFile(file, 'utf8'); + return send(res, 200, { id, mtime: (await stat(file)).mtimeMs, graph: JSON.parse(data) }); + } + if (req.method === 'PUT') { + const { code, body } = await putGraph(id, await readBody(req)); + return send(res, code, body); + } + } + if (url.pathname === '/api/dry-run' && req.method === 'POST') { + const { graph, preset } = await readBody(req); + return send(res, 200, await dryRun(graph, preset)); + } + if (url.pathname === '/api/registries' && req.method === 'GET') { + return send(res, 200, await viteNode(['apps/game/tools/dialogues/registries.ts'], '')); + } + // Статика редактора (нет файла — 404, не 500). + const name = url.pathname === '/' ? 'index.html' : url.pathname.slice(1); + if (/^[\w.-]+$/.test(name)) { + try { + const data = await readFile(path.join(STATIC_DIR, name)); + return send(res, 200, data, MIME[path.extname(name)] ?? 'application/octet-stream'); + } catch { + return send(res, 404, { message: `нет файла «${name}»` }); + } + } + send(res, 404, { message: 'нет такого пути' }); + } catch (e) { + send(res, 500, { message: String(e?.message ?? e) }); + } +}); + +server.listen(PORT, () => { + console.log(`редактор диалогов: http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/v1/apps/game/tools/dialogues/dry-run.ts b/v1/apps/game/tools/dialogues/dry-run.ts new file mode 100644 index 0000000..4e75f46 --- /dev/null +++ b/v1/apps/game/tools/dialogues/dry-run.ts @@ -0,0 +1,83 @@ +/** + * CLI dry-run диалогов: прогон графов через DialogueRunner на реальном + * GameState (без Pixi) + структурный анализ. Печатает дерево «узел → + * выборы → куда», сироты, циклы, тихие концы и битые ссылки. + * + * Запуск: npm run dialogues:dry [-- ] + * (vite-node — чтобы работал TS/JSON-импорт из src.) + */ +import { GameState, DialogueRunner } from '@rpg/engine'; +import { DIALOGUES } from '../../src/data/dialogues'; +import { analyzeGraph, checkGraph, type GraphRefs } from '../../src/data/dialogueRules'; +import { FLAGS, VARS } from '../../src/data/ids'; +import { ITEMS } from '../../src/data/items'; +import { DIALOGUE_CUSTOM } from '../../src/data/effects'; + +/** Реестры игры для checkGraph (те же множества, что в validate.ts). */ +function refs(): GraphRefs { + return { + flags: new Set(Object.keys(FLAGS)), + vars: new Set(Object.keys(VARS)), + items: new Set(Object.keys(ITEMS)), + customs: new Set(Object.keys(DIALOGUE_CUSTOM)), + strings: new Set() + }; +} + +/** Пресеты состояния: в каких условиях гоняем каждый граф. */ +const PRESETS: { name: string; setup: (s: GameState) => void }[] = [ + { name: 'fresh', setup: () => {} }, + { + name: 'quest-taken+flowers', + setup: (s) => { + s.setFlag('quest_bells_taken'); + s.setFlag('met_elder'); + s.setVar('flowers', 3); + } + }, + { + name: 'quest-done', + setup: (s) => { + s.setFlag('quest_bells_taken'); + s.setFlag('met_elder'); + s.setFlag('quest_bells_done'); + } + } +]; + +/** Дерево реплик: что покажет раннер и куда можно пойти — см. main(). */ + +const main = (): void => { + const only = process.argv[2]; + let errors = 0; + for (const [id, graph] of Object.entries(DIALOGUES)) { + if (only && id !== only) continue; + console.log(`\n=== ${id} (start: ${graph.start}) ===`); + const a = analyzeGraph(graph); + const bad = checkGraph(id, graph, refs()).filter((i) => i.severity === 'error'); + errors += bad.length; + for (const inv of bad) console.log(` [${inv.severity}] ${inv.id}: ${inv.message}`); + for (const o of a.orphans) console.log(` [warn] сирота: «${o}»`); + for (const cycle of a.textlessCycles) console.log(` [error] цикл без текста: ${cycle.join(' → ')}`); + for (const end of a.silentEnds) console.log(` [warn] тихий конец: «${end}»`); + + // Прогон на каждом пресете: какие реплики реально покажутся. + for (const preset of PRESETS) { + const state = new GameState(); + preset.setup(state); + const runner = new DialogueRunner(state); + const shown: string[] = []; + runner.setView({ + show: (n) => shown.push(`${n.speaker ? n.speaker + ': ' : ''}${n.text}` + + (n.choices.length > 0 ? ` [${n.choices.map((c) => c.text).join(' | ')}]` : '')), + hide: () => {} + }); + runner.start(graph); + while (runner.active && !runner.waitingForChoice) runner.advance(); + console.log(` [${preset.name}] ${shown.join(' → ') || '(молча)'}`); + } + } + if (errors > 0) process.exitCode = 1; +}; + +main(); \ No newline at end of file diff --git a/v1/apps/game/tools/dialogues/probe.ts b/v1/apps/game/tools/dialogues/probe.ts new file mode 100644 index 0000000..396eaf0 --- /dev/null +++ b/v1/apps/game/tools/dialogues/probe.ts @@ -0,0 +1,93 @@ +/** + * Проверка произвольного графа (для сервера редактора, D8). Читает stdin: + * { "op": "validate", "graph": {...} } — Invariants + анализ + * { "op": "dry-run", "graph": {...}, "preset": "fresh" } — ещё и прогон раннера + * Печатает JSON: + * { errors, warnings, orphans, cycles, silentEnds, run? } + * Запуск: vite-node apps/game/tools/dialogues/probe.ts + */ +import { GameState, DialogueRunner } from '@rpg/engine'; +import type { DialogueGraph } from '@rpg/engine'; +import { analyzeGraph, checkGraph, type GraphRefs } from '../../src/data/dialogueRules'; +import { FLAGS, VARS } from '../../src/data/ids'; +import { ITEMS } from '../../src/data/items'; +import { DIALOGUE_CUSTOM } from '../../src/data/effects'; + +/** Реестры игры (те же множества, что в validate.ts и dry-run.ts). */ +function refs(): GraphRefs { + return { + flags: new Set(Object.keys(FLAGS)), + vars: new Set(Object.keys(VARS)), + items: new Set(Object.keys(ITEMS)), + customs: new Set(Object.keys(DIALOGUE_CUSTOM)), + strings: new Set() + }; +} + +/** Пресеты состояния — как в dry-run.ts (общая семантика с CLI). */ +const PRESETS: { name: string; setup: (s: GameState) => void }[] = [ + { name: 'fresh', setup: () => {} }, + { + name: 'quest-taken+flowers', + setup: (s) => { + s.setFlag('quest_bells_taken'); + s.setFlag('met_elder'); + s.setVar('flowers', 3); + } + }, + { + name: 'quest-done', + setup: (s) => { + s.setFlag('quest_bells_taken'); + s.setFlag('met_elder'); + s.setFlag('quest_bells_done'); + } + } +]; + +/** Один прогон раннера: реплики до первого выбора (или до конца). */ +function walk(graph: DialogueGraph, preset: string): string { + const state = new GameState(); + (PRESETS.find((p) => p.name === preset) ?? PRESETS[0]!).setup(state); + const runner = new DialogueRunner(state); + const shown: string[] = []; + runner.setView({ + show: (n) => + void shown.push( + `${n.speaker ? n.speaker + ': ' : ''}${n.text}` + + (n.choices.length > 0 ? ` [${n.choices.map((c) => c.text).join(' | ')}]` : '') + ), + hide: () => {} + }); + runner.start(graph); + while (runner.active && !runner.waitingForChoice) runner.advance(); + return shown.join(' → ') || '(молча)'; +} + +const main = (): void => { + const chunks: string[] = []; + process.stdin.on('data', (c) => chunks.push(c)); + process.stdin.on('end', () => { + const req = JSON.parse(chunks.join('')) as { + op?: string; + graph: DialogueGraph; + preset?: string; + }; + const graph = req.graph; + const a = analyzeGraph(graph); + const inv = checkGraph('editor', graph, refs()); + const out: Record = { + errors: inv.filter((i) => i.severity === 'error'), + warnings: inv.filter((i) => i.severity === 'warn'), + orphans: a.orphans, + cycles: a.textlessCycles, + silentEnds: a.silentEnds + }; + if (req.op === 'dry-run') { + out.run = walk(graph, req.preset ?? 'fresh'); + } + console.log(JSON.stringify(out)); + }); +}; + +main(); \ No newline at end of file diff --git a/v1/apps/game/tools/dialogues/registries.ts b/v1/apps/game/tools/dialogues/registries.ts new file mode 100644 index 0000000..ba8f005 --- /dev/null +++ b/v1/apps/game/tools/dialogues/registries.ts @@ -0,0 +1,24 @@ +/** + * Реестры игры для редактора диалогов (D8): печатает JSON с допустимыми + * id — сервер отдаёт их клиенту для выпадающих списков и подсказок. + * + * Запуск: vite-node apps/game/tools/dialogues/registries.ts + */ +import { FLAGS, VARS } from '../../src/data/ids'; +import { ITEMS } from '../../src/data/items'; +import { DIALOGUE_CUSTOM } from '../../src/data/effects'; +import { DIALOGUES } from '../../src/data/dialogues'; + +const main = (): void => { + const out = { + flags: Object.keys(FLAGS), + vars: Object.keys(VARS), + items: Object.keys(ITEMS), + customs: Object.keys(DIALOGUE_CUSTOM), + dialogues: Object.keys(DIALOGUES), + strings: [] as string[] + }; + console.log(JSON.stringify(out)); +}; + +main(); \ No newline at end of file diff --git a/v1/apps/game/tools/final-smoke.mjs b/v1/apps/game/tools/final-smoke.mjs new file mode 100644 index 0000000..8eaa968 --- /dev/null +++ b/v1/apps/game/tools/final-smoke.mjs @@ -0,0 +1,80 @@ +/** + * Финальный визуальный смоук (финал ревью): переходы луга<->Звенец x3, + * бой с врагом, тост знака — проверка отсутствия наслоения вьюх/звука + * после повторных переходов. Скриншоты в /tmp/rpg_final_*.png. + * Запуск: node apps/game/tools/final-smoke.mjs [url] + */ +import { fightNearest, openGame, startDevServer } from './lib.mjs'; + +const server = await startDevServer(); +let ctx; +try { + ctx = await openGame({ url: server.url, newGame: true }); + const { page, agent } = ctx; + const fail = []; + + /** Туда-обратно: пешком к тропе (54,28) -> Звенец -> колодец (16,13) -> луга. */ + const roundTrip = async (n) => { + await agent.command('scene:sleepAll'); + const w1 = await agent.walkTo(54, 28, { timeoutTicks: 6000 }); + if (!w1) fail.push(`поездка ${n}: не дошёл до тропы`); + const z = await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + if (!z.ok) fail.push(`поездка ${n}: не в Звенце`); + await agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + if (await agent.approach(16, 13)) agent.press('interact'); // колодец у дома Ирвина + else fail.push(`поездка ${n}: не подошли к колодцу`); + const m = await agent.waitFor('s.area === "meadows"', { timeoutTicks: 1200 }); + if (!m.ok) fail.push(`поездка ${n}: колодец не вернул на луга`); + await agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await page.screenshot({ path: `/tmp/rpg_final_trip${n}.png` }); + }; + + for (let n = 1; n <= 3; n++) await roundTrip(n); + console.log('3 поездки туда-обратно: ок'); + + // Тост знака у тропы (48,27): подходим и жмём действие. + if (await agent.approach(48, 27)) agent.press('interact'); + else fail.push('не подошли к знаку у тропы'); + const t = await agent.waitFor('(s.lastToast?.text ?? "").includes("Звенец")', { timeoutTicks: 1500 }); + console.log('тост знака:', t.ok ? t.snapshot.lastToast?.text : 'НЕ ПОКАЗАН'); + if (!t.ok) fail.push('тост знака не показан'); + + // Бой: подойти к ближайшему живому врагу, доворот facing, конус + // (fightNearest — см. lib.mjs; позиция врага плавает, до 3 попыток). + const hit = await fightNearest(agent); + console.log('бой:', hit ? 'враг получил урон' : 'УРОНА НЕТ'); + if (!hit) fail.push('враг не получил урон'); + await page.screenshot({ path: '/tmp/rpg_final_combat.png' }); + + // Наслоение звука: после 3-го возврата амбиент не должен перезапуститься + // (guard ключа). Лог — кольцевой на 24 записи, старт с бута давно вытеснен, + // поэтому следим за хвостом лога после перехода. + let ambRestarted = false; + for (let i = 0; i < 4 && !ambRestarted; i++) { + await agent.step(30); + const tail = await page.evaluate(() => + (window.__gameAudioLog ?? []).slice(-8).map((e) => e.key) + ); + ambRestarted = tail.includes('ambience/meadows'); + } + console.log('перезапуск амбиента после 3-го возврата:', ambRestarted ? 'ЕСТЬ' : 'нет'); + if (ambRestarted) fail.push('амбиент перезапустился после возврата на луга'); + // Объекты в снапшоте по одному на id; враги — без id в снапшоте, поэтому + // проверяем, что их число не растёт от поездок (наслоения вьюх нет). + const snap = await agent.snapshot(); + const dupIds = (arr) => arr.length - new Set(arr.map((x) => x.id)).size; + const dup = dupIds(snap.interactables ?? []); + console.log('дубли id объектов:', dup); + if (dup > 0) fail.push(`дубли в снапшоте: ${dup}`); + + const errs = ((await agent.invariants()) ?? []).filter((i) => i.severity === 'error'); + if (errs.length) fail.push(`инварианты: ${JSON.stringify(errs)}`); + + console.log(fail.length ? 'ПРОВАЛЫ: ' + fail.join('; ') : 'ИТОГ: чисто'); + process.exitCode = fail.length ? 1 : 0; +} finally { + try { + await ctx?.browser?.close(); + } catch {} + server.stop(); +} \ No newline at end of file diff --git a/v1/apps/game/tools/guards/boundary.mjs b/v1/apps/game/tools/guards/boundary.mjs new file mode 100644 index 0000000..ebaa21f --- /dev/null +++ b/v1/apps/game/tools/guards/boundary.mjs @@ -0,0 +1,10 @@ +/** + * Гвард границы «движок / игра» — сценарий запуска (npm run guard). + * Правила и механизм проверки — жанронезависимые, в @rpg/engine/tools/boundary.mjs. + * Нарушения печатаются JSON-ом, exit 1 при наличии error-нарушений. + */ +import { checkBoundary, collectSources } from '@rpg/engine/tools/boundary.mjs'; + +const violations = checkBoundary(collectSources()); +console.log(JSON.stringify({ ok: violations.length === 0, violations }, null, 2)); +process.exit(violations.length ? 1 : 0); \ No newline at end of file diff --git a/v1/apps/game/tools/lib.mjs b/v1/apps/game/tools/lib.mjs new file mode 100644 index 0000000..8252230 --- /dev/null +++ b/v1/apps/game/tools/lib.mjs @@ -0,0 +1,185 @@ +/** + * Сценарный слой тулз «Пепельных лугов» поверх жанронезависимого каркаса + * @rpg/engine/tools/agent-lib.mjs: маяк загрузки игры и re-export общих + * примитивов. Смоуки и checks/*.mjs импортируют отсюда. + */ +import { fileURLToPath } from 'node:url'; +import { + Checks, + openGame as engineOpenGame, + startDevServer as engineStartDevServer +} from '@rpg/engine/tools/agent-lib.mjs'; + +export { CHROMIUM, launchBrowser, AgentClient, Checks, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; + +/** Каталог игры (index.html + vite-конфиг) — дефолтный cwd dev-сервера. */ +const GAME_ROOT = fileURLToPath(new URL('..', import.meta.url)); + +/** startDevServer по умолчанию поднимает vite из каталога игры, а не из корня монорепо. */ +export const startDevServer = (opts = {}) => engineStartDevServer({ cwd: GAME_ROOT, ...opts }); + +/** Консольный маяк BootScene: ассеты загружены, мост готов. */ +export const BEACON = '[boot] ассеты загружены'; + +/** openGame с игровым маяком загрузки. */ +export const openGame = (opts = {}) => engineOpenGame({ beacon: BEACON, ...opts }); + +/** + * Удар по ближайшему живому врагу через агентный мост. Возврат — урон прошёл. + * Подход задаёт facing только последним отрезком пути — он может прийти + * перпендикулярно врагу, поэтому после подхода герой делает короткий шаг в + * его сторону (клавиши движения — экранный вектор: facing совпадёт с + * направлением на врага) и бьёт конусом. Враг подвижен — до 3 попыток со + * свежей позицией. + */ +export const fightNearest = async (agent) => { + const snap = await agent.snapshot(); + const hero = snap.hero; + const foes = (snap.enemies ?? []).filter((e) => !e.dead && e.hp > 0); + if (foes.length === 0) return true; // бить некого — не провал + const d2 = (e) => (e.pos.x - hero.pos.x) ** 2 + (e.pos.y - hero.pos.y) ** 2; + const foe = foes.reduce((b, e) => (d2(e) < d2(b) ? e : b)); + const hurt = + `(s.enemies.filter((e) => !e.dead).length) < ${foes.length} || ` + + `s.enemies.some((e) => !e.dead && e.hp < ${Math.min(...foes.map((e) => e.hp))})`; + for (let i = 0; i < 3; i++) { + const cur = await agent.snapshot(); + // Свежая позиция цели: враг сам идёт на героя, пока мы подходили. + const live = (cur.enemies ?? []).find( + (e) => !e.dead && e.hp > 0 && Math.hypot(e.pos.x - foe.pos.x, e.pos.y - foe.pos.y) < 3 + ); + if (!live) return true; // добита (нашим прошлым тапом или сплэшем) + const dest = await agent.approach(Math.floor(live.pos.x), Math.floor(live.pos.y)); + if (dest) { + const dx = live.pos.x - cur.hero.pos.x; + const dy = live.pos.y - cur.hero.pos.y; + // Экранные оси — мировые диагонали: sx = dx − dy, sy = dx + dy. + const sx = dx - dy; + const sy = dx + dy; + const dir = Math.abs(sx) >= Math.abs(sy) ? (sx >= 0 ? 'right' : 'left') : sy >= 0 ? 'down' : 'up'; + agent.press(dir, 5); // шаг к врагу задаёт facing + agent.press('attack'); + } + const w = await agent.waitFor(hurt, { timeoutTicks: 120 }); + if (w.ok) return true; + } + return false; +}; + +/** + * Каркас чек-сценария: поднимает dev-сервер, даёт помощники (boot, goToArea, + * sleepEnemies, readAudioLog, waitAudioLog, expectInvariantsClean) и в любом + * исходе гасит браузер и сервер. Сценарий оставляет только шаги проверки. + * + * export default async function ({ pretty }) { + * return withChecks('имя', async (t) => { ... }, { pretty }); + * } + */ +export async function withChecks(name, fn, { pretty } = {}) { + const c = new Checks(name); + const server = await startDevServer(); + const h = { ctx: null }; + const t = { + c, + /** Контекст игры (заполняется boot). */ + get ctx() { + return h.ctx; + }, + /** Новая игра + выход из стартового fade-перехода. */ + boot: async () => { + h.ctx = await openGame({ url: server.url, newGame: true }); + await h.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + return h.ctx; + }, + /** + * Открыть игру с сырыми опциями openGame (boot = open({newGame: true}) + + * ожидание fade). Для сценариев, которым нужен свой тайминг старта. + */ + open: async (opts = {}) => { + h.ctx = await openGame({ url: server.url, ...opts }); + return h.ctx; + }, + /** Спим всех врагов (безопасная навигация по миру). */ + sleepEnemies: () => h.ctx.agent.command('scene:sleepAll'), + /** + * Пешком к тайлу-триггеру step-перехода (via) и дождаться области + * (двери-переходы — явным interactAt в сценарии). + */ + goToArea: async (area, via) => { + const walked = await h.ctx.agent.walkTo(via.x, via.y, { timeoutTicks: 6000 }); + c.expect(walked, `walkTo(${via.x},${via.y}) не дошёл до перехода`); + const w = await h.ctx.agent.waitFor(`s.area === "${area}"`, { timeoutTicks: 1200 }); + c.expect(w.ok, `после перехода не в «${area}»`, w.snapshot?.area); + await h.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + return w; + }, + /** + * Подойти к тайлу и нажать действие (E): walkTo на проходимый (цветок, + * мот), approach на непроходимый (стены, объекты, NPC-тайлы — путь в + * занятый тайл не строится). Таймаут с запасом: карты ×2, пешком + * бывает далеко. Возврат — дошли ли. + */ + interactAt: async (tx, ty) => { + const a = h.ctx.agent; + const ok = + (await a.walkTo(tx, ty, { timeoutTicks: 6000 })) || + (await a.approach(tx, ty, { timeoutTicks: 6000 })) !== null; + if (ok) a.press('interact'); + return ok; + }, + /** Подойти к NPC по id (координаты — из снапшота) и заговорить. */ + talkTo: async (npcId) => { + const snap = await h.ctx.agent.snapshot(); + const npc = (snap.npcs ?? []).find((n) => n.id === npcId); + c.expect(npc, `NPC «${npcId}» не найден в снапшоте`, null); + if (!(await t.interactAt(npc.tile.x, npc.tile.y))) return false; + const w = await h.ctx.agent.waitFor('s.dialogue != null', { timeoutTicks: 300 }); + return w.ok; + }, + /** Подойти к интерактивному объекту по id и нажать действие. */ + useInteractable: async (id) => { + const snap = await h.ctx.agent.snapshot(); + const it = (snap.interactables ?? []).find((o) => o.id === id); + c.expect(it, `интерактив «${id}» не найден в снапшоте`, null); + return t.interactAt(it.tile.x, it.tile.y); + }, + /** Удар по ближайшему живому врагу: см. fightNearest выше. */ + fightNearest: async () => { + const ok = await fightNearest(h.ctx.agent); + c.expect(ok, 'враг не получил урон', null); + return ok; + }, + /** DEV-лог аудио страницы (window.__gameAudioLog) или null вне DEV. */ + readAudioLog: () => h.ctx.page.evaluate(() => window.__gameAudioLog ?? null), + /** + * Ждать условие на аудио-логе: декод WAV и компиляция спеков в headless + * занимают заметное время — шагаем и опрашиваем, а не читаем сразу. + */ + waitAudioLog: async (pred, tries = 12) => { + let log = await t.readAudioLog(); + for (let i = 0; i < tries && !pred(log); i++) { + await h.ctx.agent.step(30); + log = await t.readAudioLog(); + } + return log; + }, + /** Ошибки инвариантов моста: пусто — чисто (warn допустим). */ + expectInvariantsClean: async (what) => { + const inv = await h.ctx.agent.invariants(); + const errs = (inv ?? []).filter((i) => i.severity === 'error'); + c.expect(errs.length === 0, `инварианты нарушены${what ? `: ${what}` : ''}`, errs); + return errs; + } + }; + try { + await fn(t); + } finally { + try { + await h.ctx?.browser?.close(); + } catch { + // браузер мог умереть раньше — сервер всё равно гасим + } + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/v1/apps/game/tools/maps/gen.test.ts b/v1/apps/game/tools/maps/gen.test.ts new file mode 100644 index 0000000..450213c --- /dev/null +++ b/v1/apps/game/tools/maps/gen.test.ts @@ -0,0 +1,84 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { encodeMap, parseMap } from '@rpg/engine'; +import { + buildMeadowsMap, + buildPondsMap, + buildZvenetsMap, + buildRustGroveMap, + buildOldSidingMap, + buildMachineHallMap, + buildHouseElderMap, + buildShopMap +} from '../../src/data/map'; + +/** + * Карты-файлы: fresh-тест (диск == генератор) и round-trip RLE — read-only + * и идут в каждом `npm test`. Запись файлов — только при RUN_MAPS_GEN=1 + * (`npm run maps`): обычный прогон тестов не трогает рабочее дерево. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(here, '../../assets/maps'); +const gen = !!process.env.RUN_MAPS_GEN; +const d = gen ? describe : describe.skip; + +const MAPS: Record> = { + meadows: buildMeadowsMap(), + ponds: buildPondsMap(), + zvenets: buildZvenetsMap(), + rust_grove: buildRustGroveMap(), + old_siding: buildOldSidingMap(), + machine_hall: buildMachineHallMap(), + house_elder: buildHouseElderMap(), + shop: buildShopMap() +}; + +describe('карты-файлы на диске равны генераторам (fresh)', () => { + for (const [id, data] of Object.entries(MAPS)) { + it(`${id}.map не разъехался с генератором (npm run maps лечит)`, () => { + const path = resolve(outDir, `${id}.map`); + const onDisk = JSON.parse(readFileSync(path, 'utf8')); + expect(onDisk).toEqual(encodeMap(data)); + }); + } +}); + +describe('rpg-map, RLE (round-trip, read-only)', () => { + for (const [id, data] of Object.entries(MAPS)) { + it(`${id}: encode -> parse восстанавливает карту`, () => { + const back = parseMap(JSON.parse(JSON.stringify(encodeMap(data)))); + + expect(back.width).toBe(data.width); + expect(back.height).toBe(data.height); + expect(back.blocked).toEqual(data.blocked); + expect(back.tiles).toEqual(data.tiles); + expect(back.tall).toEqual(data.tall); + expect(back.props).toEqual(data.props); + }); + } + + it('RLE реально сжимает карты', () => { + for (const [id, data] of Object.entries(MAPS)) { + const file = encodeMap(data); + const pairs = file.tiles as [number, number][]; + // Серий меньше, чем тайлов: RLE что-то ужал, несмотря на пятнистость + expect(pairs.length).toBeLessThan(data.tiles.length); + expect(file.encoding).toBe('rle'); + expect(id.length).toBeGreaterThan(0); + } + }); +}); + +d('запись карт-файлов (RUN_MAPS_GEN=1 / npm run maps)', () => { + for (const [id, data] of Object.entries(MAPS)) { + it(`${id}: пишет assets/maps/${id}.map`, () => { + const file = encodeMap(data); + mkdirSync(outDir, { recursive: true }); + writeFileSync(resolve(outDir, `${id}.map`), JSON.stringify(file)); + expect(true).toBe(true); + }); + } +}); \ No newline at end of file diff --git a/v1/apps/game/tools/pixelart/__tests__/sizes.test.ts b/v1/apps/game/tools/pixelart/__tests__/sizes.test.ts new file mode 100644 index 0000000..2c180e7 --- /dev/null +++ b/v1/apps/game/tools/pixelart/__tests__/sizes.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { SIZES, SLOTS } from '../sizes.mjs'; + +/** + * Реестр размеров (sizes.mjs) — единственный источник размеров спрайтов + * для gen.mjs и lint-art.mjs. Здесь проверяется сам реестр и его + * согласованность с фактическими ассетами (манифест AI-атласов). + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const dim = (s: { w: number } | number[]) => (Array.isArray(s) ? `${s[0]}x${s[1]}` : `${s.w}x${s.h}`); + +describe('реестр размеров спрайтов', () => { + it('все группы дают валидные слоты «ширина x высота»', () => { + for (const group of Object.values(SIZES)) { + const sizes = 'w' in group ? [group] : Object.values(group); + for (const s of sizes) { + expect(dim(s as never)).toMatch(/^\d+x\d+$/); + } + } + }); + + it('базовые слоты арт-библии на месте', () => { + for (const slot of ['32x16', '16x24', '20x20', '32x48', '32x56', '32x80', '64x80']) { + expect(SLOTS.has(slot), slot).toBe(true); + } + }); + + it('frameSize манифеста AI-атласов входит в слоты', () => { + const manifest = JSON.parse( + readFileSync(resolve(here, '../../aiart/atlas-manifest.json'), 'utf8') + ) as Record; + for (const [id, entry] of Object.entries(manifest)) { + const fs = entry.frameSize; + if (!fs) continue; + expect(SLOTS.has(`${fs[0]}x${fs[1]}`), id).toBe(true); + } + }); +}); \ No newline at end of file diff --git a/v1/apps/game/tools/pixelart/gen.mjs b/v1/apps/game/tools/pixelart/gen.mjs new file mode 100644 index 0000000..ccc568a --- /dev/null +++ b/v1/apps/game/tools/pixelart/gen.mjs @@ -0,0 +1,1667 @@ +/** + * Генератор пиксель-арта «Пепельных лугов». + * Запуск: node tools/pixelart/gen.mjs + * Выход: apps/game/assets/tiles/*.png, apps/game/assets/chars/*.png + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { Canvas as EngineCanvas, fillDiamond, fromAscii as fromAsciiRaw, rng } from '@rpg/engine/tools/canvas.mjs'; +import { buildAtlas } from '@rpg/engine/tools/atlas.mjs'; +import { bobFrame } from '@rpg/engine/tools/frames.mjs'; +import { RGBA } from './palette.mjs'; +import { SIZES } from './sizes.mjs'; + +/** Канвас с палитрой игры (ключи цветов — из palette.mjs). */ +class Canvas extends EngineCanvas { + constructor(w, h) { super(w, h, RGBA); } +} + +/** ASCII-карта -> спрайт в палитре игры (extra — дополнительные символы). */ +const fromAscii = (rows, extra) => fromAsciiRaw(rows, RGBA, extra); + +const OUT = fileURLToPath(new URL('../../assets/', import.meta.url)); + +// ---------- тайлы 32x16 ---------- + +/** Ромб с зерном пепла/травы. Без обводки граней: смежные тайлы сливаются в поле. */ +function tileBase(base, light, dark, seed, specks) { + const c = new Canvas(SIZES.tile.w, SIZES.tile.h); + fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, base); + const r = rng(seed); + for (let i = 0; i < specks; i++) { + const x = Math.floor(r() * 32); + const y = Math.floor(r() * 16); + if (c.get(x, y)) { + c.set(x, y, r() < 0.5 ? dark : base); + } + } + return c; +} + +function grassTile(seed = 77) { + const c = tileBase('G1', 'G2', 'G0', 11, 26); + // Сухие стебли: 1-2 пикселя G2H. seed — вариант текстуры (микс по хэшу тайла). + const r = rng(seed); + for (let i = 0; i < 5; i++) { + const x = 3 + Math.floor(r() * 26); + const y = 3 + Math.floor(r() * 10); + if (c.get(x, y)) { + c.set(x, y, 'G2H'); + } + } + return c; +} + +function pathTile(seed = 31) { + const c = tileBase('P4', 'P5', 'P3', 23, 30); + // Утоптанный камешек и след колеи. seed — вариант текстуры. + const r = rng(seed); + for (let i = 0; i < 4; i++) { + const x = 2 + Math.floor(r() * 28); + const y = 2 + Math.floor(r() * 12); + if (c.get(x, y)) c.set(x, y, 'P2'); + } + return c; +} + +/** Вода: кадр 1 и кадр 2 (блики-штрихи W2 сдвинуты на 1px — живая рябь). */ +function waterTile(rippleShift = 0) { + const c = tileBase('W1', 'W2', 'W0', 31, 0); + // Рябь: короткие горизонтальные штрихи W2 и глубина W0. + const r = rng(5); + for (let i = 0; i < 5; i++) { + const x = 4 + Math.floor(r() * 22); + const y = 2 + Math.floor(r() * 12); + if (c.get(x + rippleShift, y)) c.set(x + rippleShift, y, 'W2'); + if (c.get(x + 1 + rippleShift, y)) c.set(x + 1 + rippleShift, y, 'W2'); + } + for (let i = 0; i < 3; i++) { + const x = 6 + Math.floor(r() * 20); + const y = 5 + Math.floor(r() * 9); + c.set(x, y, 'W0'); + c.set(x + 1, y, 'W0'); + } + return c; +} + +function ashTile(seed = 9, stems = 10) { + // Пепельный бугор: чуть светлее травы, тоже без обводки. + const c = new Canvas(SIZES.tile.w, SIZES.tile.h); + fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, 'P3'); + const r = rng(seed); + for (let i = 0; i < 6; i++) { + const x = 3 + Math.floor(r() * 26); + const y = 4 + Math.floor(r() * 9); + if (c.get(x, y)) c.set(x, y, 'P5'); + } + // Проглядывающая трава: стебли 2–3 px и пучки, жизнь заметно пробивается, + // но пепел остаётся пеплом (растительность ~треть площади ромба). + for (let i = 0; i < stems; i++) { + const x = 3 + Math.floor(r() * 26); + const y = 5 + Math.floor(r() * 8); + if (!c.get(x, y)) continue; + // Пучок: с шансом 1/3 из точки растут 2–3 стебля вразброс. + const extra = r() < 0.33 ? 2 : 1; + for (let j = 0; j < extra; j++) { + const sx = Math.min(28, Math.max(3, x + (extra > 1 ? Math.floor(r() * 5) - 2 : 0))); + const sy = Math.min(12, Math.max(3, y + (extra > 1 ? Math.floor(r() * 3) - 1 : 0))); + if (!c.get(sx, sy)) continue; + const tip = r() < 0.5 ? 'G2H' : 'G2'; // сухая верхушка + const h = 1 + Math.floor(r() * 2); // высота стебля 2–3 px + for (let k = 0; k <= h; k++) { + if (!c.get(sx, sy - k)) break; + c.set(sx, sy - k, k === h ? tip : k === 0 ? 'G0' : r() < 0.5 ? 'G1' : 'G2'); + } + } + } + return c; +} + +// ---------- декор 16x16 (прозрачное поле, рисунок в ромб 16x8) ---------- + +/** Микро-кустик сухой травы: подушка G2 с сухими стеблями G2H. */ +function decorTuft() { + const c = new Canvas(SIZES.decor.w, SIZES.decor.h); + fillDiamond(c, 8, 9, 14, 7, 'G2'); + fillDiamond(c, 8, 9, 10, 5, 'G1'); + const r = rng(151); + for (let i = 0; i < 6; i++) { + const x = 4 + Math.floor(r() * 9); + const y = 5 + Math.floor(r() * 6); + if (c.get(x, y)) c.set(x, y, 'G2H'); + } + // пара торчащих стеблей выше подушки + c.set(6, 4, 'G2H'); + c.set(10, 5, 'G2H'); + return c; +} + +/** Камешек: основание P3, верхняя грань P5. */ +function decorStone() { + const c = new Canvas(SIZES.decor.w, SIZES.decor.h); + fillDiamond(c, 8, 10, 8, 4, 'P3'); + // светлая грань — верхний склон + fillDiamond(c, 8, 9, 6, 2, 'P5'); + return c; +} + +/** Проплешина: тёмное пятно на траве (только тень, без рисунка сверху). */ +function decorBald() { + const c = new Canvas(SIZES.decor.w, SIZES.decor.h); + fillDiamond(c, 8, 9, 12, 6, 'G0'); + fillDiamond(c, 8, 9, 8, 4, 'G1'); + return c; +} + +function bellflowerTile() { + const c = grassTile(); + // Три цветка: золото 3px + хайлайт. Тёплый цвет — только жизнь. + const flowers = [ + [10, 7], + [21, 9], + [16, 5] + ]; + for (const [x, y] of flowers) { + c.set(x, y - 1, 'B2'); + c.set(x - 1, y, 'B1'); + c.set(x, y, 'B1'); + c.set(x + 1, y, 'B1'); + c.set(x, y + 1, 'G2H'); // стебель + } + return c; +} + +/** + * Дерево 32x48, три вида кроны + кадр качания. + * kind: 'wide' (три облака, кабель — Ржавая роща), 'sparse' (редкая высокая), + * 'young' (молодое с малой кроной). sway — сдвиг кроны по X (±1px, ветер): + * кадры одного вида отличаются только кроной, ствол неподвижен. + */ +function treeTile({ kind = 'wide', seed = 2026, cable = true, sway = 0 } = {}) { + const c = new Canvas(...SIZES.tall.tree); + const r = rng(seed); + + const ellipse = (cx, cy, rx, ry, color) => { + for (let y = -ry; y <= ry; y++) { + for (let x = -rx; x <= rx; x++) { + if ((x * x) / (rx * rx) + (y * y) / (ry * ry) <= 1) { + c.set(cx + x, cy + y, color); + } + } + } + }; + + // Крона: перекрывающиеся «облака» мёртвой листвы (сдвигаются ветром). + const clouds = + kind === 'wide' + ? [ + [16, 9, 10, 7], + [9, 14, 6, 5], + [23, 13, 7, 5] + ] + : kind === 'sparse' + ? [ + [16, 8, 8, 7], + [10, 13, 5, 4], + [22, 12, 5, 4] + ] + : [ + [16, 12, 7, 5], + [12, 16, 4, 3], + [21, 15, 4, 3] + ]; + for (const [cx, cy, rx, ry] of clouds) ellipse(cx + sway, cy, rx, ry, 'G0'); + // Внутренний тон. + for (const [cx, cy, rx, ry] of clouds) ellipse(cx + sway, cy + 1, rx - 3, ry - 2, 'G1'); + for (let i = 0; i < 26; i++) { + const x = 6 + Math.floor(r() * 20); + const y = 3 + Math.floor(r() * 12); + if (c.get(x + sway, y) && r() < 0.5) c.set(x + sway, y, 'G2'); + } + // Прорехи (мёртвое дерево): прозрачные проколы в кроне. + for (const [x, y] of kind === 'wide' + ? [ + [13, 6], + [19, 12], + [8, 13], + [22, 8] + ] + : kind === 'sparse' + ? [ + [15, 5], + [20, 10], + [12, 11], + [17, 8], + [9, 9] + ] + : [ + [14, 11], + [19, 13] + ]) { + c.set(x + sway, y, null); + c.set(x + 1 + sway, y, null); + } + + // Ствол: металл с ржавчиной внизу, лёгкий изгиб, корни в стороны. + for (let y = 18; y <= 47; y++) { + const bend = Math.round(Math.sin((y - 18) * 0.09) * 1.5); + const half = y > 40 ? 2 : 1; + for (let dx = -half; dx <= half; dx++) { + c.set(16 + bend + dx, y, y > 38 ? 'R1' : 'M1'); + } + c.set(16 + bend - half - 1, y, y > 38 ? 'R0' : 'M0'); + } + // Корни. + for (let i = 0; i < 4; i++) { + c.set(12 - i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); + c.set(20 + i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); + } + // Пепел на корнях. + c.set(13, 46, 'P5'); + c.set(19, 46, 'P5'); + + // Кабель через крону и вокруг ствола (только «роща»-вид). + if (cable) { + for (let x = 4; x <= 27; x++) { + const y = 16 - Math.round(Math.sin((x - 4) * 0.2) * 1); + c.set(x, y, 'M2'); + } + for (let y = 22; y <= 44; y += 7) { + c.set(14, y, 'M0'); + c.set(19, y, 'M0'); + } + } + + return c; +} + +/** Линия по Брезенхэму (для кромок и трещин). */ +function line(canvas, x0, y0, x1, y1, color) { + const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1); + for (let i = 0; i <= n; i++) { + canvas.set(Math.round(x0 + ((x1 - x0) * i) / n), Math.round(y0 + ((y1 - y0) * i) / n), color); + } +} + +/** Построчная заливка полигона: fn(y, x0, x1) рисует строку внутри контура. */ +function fillPolyRows(canvas, pts, fn) { + const ys = pts.map((p) => p[1]); + const y0 = Math.max(0, Math.ceil(Math.min(...ys))); + const y1 = Math.min(canvas.height - 1, Math.floor(Math.max(...ys))); + for (let y = y0; y <= y1; y++) { + const xs = []; + for (let i = 0; i < pts.length; i++) { + const [xa, ya] = pts[i]; + const [xb, yb] = pts[(i + 1) % pts.length]; + if ((ya <= y && yb > y) || (yb <= y && ya > y)) { + xs.push(xa + ((y - ya) / (yb - ya)) * (xb - xa)); + } + } + if (xs.length < 2) continue; + const lx = Math.max(0, Math.ceil(Math.min(...xs))); + const rx = Math.min(canvas.width - 1, Math.floor(Math.max(...xs))); + if (lx <= rx) fn(y, lx, rx); + } +} + +/** + * Объёмный изометрический дом: две видимые стены сходятся ближним углом + * к зрителю (юго-западная светлая, юго-восточная тёмная — свет сверху-слева), + * шатровая или двускатная крыша, шпальный фундамент. Геометрия — по + * изо-боксу плейсхолдеров движка: основание — ромб тайла, центр на 8 px + * выше низа канваса. kind: 'shack' (времянка, шатровая metal-крыша), + * 'cottage' (двускатная шпальная с трубой), 'shed' (низкий сарай, + * широкое окно), 'barn' (высокий амбар с воротами, 32x80), + * 'two' (двухэтажный, два ряда окон, 32x80). + */ +function isoHouseTile({ kind = 'cottage', seed = 777 } = {}) { + const tall = kind === 'barn' || kind === 'two'; + const c = new Canvas(...(tall ? SIZES.tall.houseTall : SIZES.tall.house)); + const r = rng(seed); + const H = c.height; + const by = H - 8; // центр основания-ромба (ближняя вершина у кромки) + const wh = { shack: 13, cottage: 17, shed: 11, barn: 28, two: 24 }[kind]; + const gh = { shack: 9, cottage: 7, shed: 4, barn: 11, two: 9 }[kind]; // высота крыши + const wt = by - wh; // верх стен по боковым вершинам ромба + const mat = kind === 'shack' || kind === 'two' ? 'metal' : 'ties'; + + const fillPoly = (pts, color) => + fillPolyRows(c, pts, (y, lx, rx) => { + for (let x = lx; x <= rx; x++) c.set(x, y, color); + }); + // Стена: доски base со швами seam каждые 4 px по экранному Y. + const wallFill = (pts, base, seam) => + fillPolyRows(c, pts, (y, lx, rx) => { + const seamRow = (y - (H - wh)) % 4 === 0; + for (let x = lx; x <= rx; x++) c.set(x, y, seamRow ? seam : r() < 0.85 ? base : seam); + }); + // Точка на стене: t — доля вдоль стены (0..1), h — высота от земли. + const wallPt = (side, t, h) => + side === 'sw' + ? [Math.round((15 * t)), Math.round(H - 8 + 8 * t - h)] + : [Math.round(16 + 15 * t), Math.round(H - 8 * t - h)]; + // Проём: рама M0, тёмный проём (дверь) или стекло P2 с переплётом. + const opening = (side, t0, t1, h0, hh, glass) => { + const quad = (a0, a1, b0, b1) => [wallPt(side, a0, b0), wallPt(side, a1, b0), wallPt(side, a1, b1), wallPt(side, a0, b1)]; + fillPoly(quad(t0 - 0.04, t1 + 0.04, h0 - 1, h0 + hh + 1), 'M0'); + fillPoly(quad(t0, t1, h0, h0 + hh), glass ? 'P2' : 'P1'); + if (glass) { + const hm = h0 + Math.floor(hh / 2); + fillPoly(quad(t0, t1, hm, hm + 1), 'M1'); // переплёт + } else { + c.set(...wallPt(side, t1 - 0.1, h0 + Math.floor(hh * 0.45)), 'B0'); // ручка + } + }; + // Труба на скате: двухпиксельная колонна с ржавым верхом. + const pipe = (px, py) => { + for (let d = 1; d <= 6; d++) { + c.set(px, py - d, 'M0'); + c.set(px + 1, py - d, 'M1'); + } + c.set(px, py - 7, 'R1'); + c.set(px + 1, py - 7, 'R1'); + }; + + // Стены: юго-запад (свет) и юго-восток (тень), сходятся ближним углом. + wallFill([[0, by], [16, H], [16, H - wh], [0, wt]], 'C1', 'C0'); + wallFill([[16, H], [31, by], [31, wt], [16, H - wh]], 'C0', 'M0'); + // Стойки по углам. + for (let y = wt; y < by; y++) { + c.set(0, y, 'M1'); + c.set(31, y, 'M1'); + } + for (let y = H - wh; y < H; y++) c.set(16, y, 'M1'); + + if (mat === 'metal') { + // Шатровая крыша из ржавого листа: два видимых ската от вершины. + const ay = wt - gh; + const face = (pts, base) => + fillPolyRows(c, pts, (y, lx, rx) => { + for (let x = lx; x <= rx; x++) { + c.set(x, y, y % 4 === 0 ? 'M0' : r() < 0.85 ? base : 'M0'); + } + }); + face([[0, wt], [16, H - wh], [16, ay]], 'M1'); + face([[16, H - wh], [31, wt], [16, ay]], 'M0'); + // Потёки ржавчины на теневом скате. + for (let i = 0; i < 5; i++) c.set(18 + Math.floor(r() * 11), ay + 2 + Math.floor(r() * (wh - 4)), 'R1'); + line(16, ay, 0, wt, 'M0'); + line(16, ay, 16, H - wh, 'M2'); // ближнее ребро ловит свет + line(16, ay, 31, wt, 'M0'); + } else { + // Двускатная: фронтон над юго-западной стеной, скат к юго-востоку. + const gx = 8; + const gy = H - wh - 4 - gh; // вершина фронтона + wallFill([[0, wt], [16, H - wh], [gx, gy]], 'C1', 'C0'); + fillPolyRows(c, [[gx, gy], [gx + 16, gy - 8], [31, wt], [16, H - wh]], (y, lx, rx) => { + for (let x = lx; x <= rx; x++) { + c.set(x, y, y % 4 === 0 ? 'T0' : r() < 0.85 ? 'T1' : 'T0'); + } + }); + line(c, gx, gy, 0, wt, 'T0'); // кромки фронтона + line(c, gx, gy, 16, H - wh, 'T0'); + line(c, 16, H - wh, 31, wt, 'T0'); // свес над теневой стеной + line(c, gx, gy, gx + 16, gy - 8, 'T1'); // конёк ловит свет + } + + // Шпальный фундамент вдоль основания обеих стен. + for (let i = -1; i <= 17; i++) { + const t = i / 16; + c.set(Math.round(15 * t), Math.round(H - 8 + 8 * t), i % 2 ? 'R0' : 'R1'); + c.set(Math.round(15 * t), Math.round(H - 8 + 8 * t) + 1, 'R0'); + c.set(16 + i, H - Math.round(8 * t), i % 2 ? 'R0' : 'R1'); + c.set(16 + i, H - Math.round(8 * t) + 1, 'R0'); + } + // Пепел у стен. + c.set(3, H - 3, 'P5'); + c.set(6, H - 2, 'P4'); + c.set(27, H - 4, 'P5'); + c.set(24, H - 2, 'P4'); + c.set(13, H - 1, 'P4'); + + if (kind === 'shack') { + opening('sw', 0.2, 0.55, 4, 4, true); + opening('se', 0.45, 0.8, 0, 8, false); + } else if (kind === 'cottage') { + opening('sw', 0.12, 0.42, 5, 5, true); + opening('se', 0.5, 0.85, 0, 10, false); + pipe(20, H - wh - 9); + } else if (kind === 'shed') { + opening('sw', 0.18, 0.75, 3, 5, true); // широкое окно во всю стену + opening('se', 0.75, 1.0, 0, 7, false); + } else if (kind === 'barn') { + opening('se', 0.35, 0.75, 0, 16, false); // ворота + fillPoly([wallPt('se', 0.38, 5), wallPt('se', 0.72, 5), wallPt('se', 0.72, 6), wallPt('se', 0.38, 6)], 'T0'); + fillPoly([wallPt('se', 0.38, 11), wallPt('se', 0.72, 11), wallPt('se', 0.72, 12), wallPt('se', 0.38, 12)], 'T0'); + opening('sw', 0.4, 0.6, wh - 7, 4, false); // слуховое окно сена + } else { + // Двухэтажный: два ряда окон на светлой стене, дверь на теневой. + opening('sw', 0.18, 0.42, 4, 5, true); + opening('sw', 0.58, 0.82, 4, 5, true); + opening('sw', 0.38, 0.62, wh - 8, 5, true); + opening('se', 0.5, 0.85, 0, 9, false); + pipe(18, wt - 2); + } + + return c; +} + +/** + * Валун 32x32: крупный камень в изо-объёме. Верхняя грань P4/P5, юго-западная + * светлая (P5 с бликами P5H), юго-восточная в тени (P2/P3), контур P0, + * трещины P1, пепел на верхней грани. Tall-объект ~1.05 юнита, непроходим. + */ +/** + * Валун, 3 вида: round — большой округлый (текущий), spike — высокий острый + * скол (32x40), flat — низкий плоский камень (32x24). Общий приём: тень- + * подошва накрывает ground-ромб, рисунок до пола слота — камень «стоит». + */ +function boulderTile({ kind = 'round', seed = 555 } = {}) { + const c = new Canvas(...SIZES.tall[kind === 'round' ? 'boulder' : kind === 'spike' ? 'boulder_2' : 'boulder_3']); + const r = rng(seed); + const h = c.height; + const ellipse = (cx, cy, rx2, ry2, color) => { + for (let y = -ry2; y <= ry2; y++) { + for (let x = -rx2; x <= rx2; x++) { + if ((x * x) / (rx2 * rx2) + (y * y) / (ry2 * ry2) <= 1) c.set(cx + x, cy + y, color); + } + } + }; + + // Тень-подошва: накрывает центр ground-ромба. + ellipse(16, h - 4, 14, 4, 'P1'); + for (let i = 0; i < 10; i++) { + const x = 4 + Math.floor(r() * 25); + const y = h - 7 + Math.floor(r() * 6); + if (c.get(x, y) && r() < 0.5) c.set(x, y, 'P2'); + } + + // Грани: юго-запад (свет), юго-восток (тень), верхняя грань. + const sw = kind === 'spike' + ? [[13, 4], [16, 3], [18, 6], [10, h - 2], [4, h - 8]] + : kind === 'flat' + ? [[4, 10], [20, 8], [28, 12], [26, h - 2], [8, h - 2]] + : [[4, 18], [8, 23], [12, h - 2], [2, h - 7]]; + const se = kind === 'spike' + ? [[16, 3], [18, 6], [26, h - 9], [24, h - 2], [10, h - 2]] + : kind === 'flat' + ? [[20, 8], [28, 12], [26, h - 2], [18, h - 3]] + : [[8, 23], [20, 24], [28, 17], [28, h - 9], [18, h - 2], [12, h - 2]]; + const top = kind === 'spike' + ? [[13, 4], [16, 3], [18, 6], [16, 10]] + : kind === 'flat' + ? [[4, 10], [20, 8], [28, 12], [24, 16], [10, 15]] + : [[4, 18], [10, 10], [22, 9], [28, 17], [20, 24], [8, 23]]; + fillPolyRows(c, sw, (y, lx, rx) => { + for (let x = lx; x <= rx; x++) c.set(x, y, r() < 0.8 ? 'P4' : 'P5'); + }); + fillPolyRows(c, se, (y, lx, rx) => { + for (let x = lx; x <= rx; x++) c.set(x, y, r() < 0.7 ? 'P3' : 'P2'); + }); + fillPolyRows(c, top, (y, lx, rx) => { + for (let x = lx; x <= rx; x++) c.set(x, y, r() < 0.75 ? 'P5' : 'P4'); + }); + + // Блик на верхне-левом склоне и пепел на верхней грани. + const peakY = kind === 'spike' ? 6 : kind === 'flat' ? 12 : 11; + c.set(13, peakY, 'P5H'); + c.set(14, peakY - 1, 'P5H'); + for (let i = 0; i < 7; i++) { + const x = 10 + Math.floor(r() * 13); + const y = peakY + 2 + Math.floor(r() * 6); + if (c.get(x, y)) c.set(x, y, r() < 0.5 ? 'P6' : 'P4'); + } + // Трещины: тёмные ломаные по верхней и теневой граням. + line(c, 17, peakY, 21, peakY + 5, 'P1'); + line(c, 21, peakY + 5, 19, peakY + 10, 'P1'); + if (kind !== 'flat') line(c, 9, h - 9, 13, h - 4, 'P1'); + + // Контур по нижнему силуэту и рёбрам граней. + if (kind === 'spike') { + line(c, 4, h - 8, 10, h - 2, 'P0'); + line(c, 10, h - 2, 24, h - 2, 'P0'); + line(c, 24, h - 2, 26, h - 9, 'P0'); + line(c, 13, 4, 4, h - 8, 'P0'); + line(c, 16, 3, 13, 4, 'P0'); + line(c, 16, 3, 18, 6, 'P0'); + line(c, 18, 6, 26, h - 9, 'P0'); + } else if (kind === 'flat') { + line(c, 4, 10, 20, 8, 'P0'); + line(c, 20, 8, 28, 12, 'P0'); + line(c, 28, 12, 26, h - 2, 'P0'); + line(c, 26, h - 2, 8, h - 2, 'P0'); + line(c, 8, h - 2, 4, 10, 'P0'); + } else { + line(c, 2, h - 7, 12, h - 2, 'P0'); + line(c, 12, h - 2, 18, h - 2, 'P0'); + line(c, 18, h - 2, 28, h - 9, 'P0'); + line(c, 4, 18, 8, 23, 'P0'); + line(c, 8, 23, 20, 24, 'P0'); + line(c, 20, 24, 28, 17, 'P0'); + } + return c; +} + +/** + * Большой мёртвый куст, 3 вида: cloud — облако на «ноге» (32x40, текущий), + * spread — низкий раскидистый (32x32), tall — высокий узкий конус (32x48). + * Общий приём: тень-подошва + тёмная нога ветвей от земли — куст «стоит». + */ +function bushTile({ kind = 'cloud', seed = 656 } = {}) { + const c = new Canvas(...SIZES.tall[kind === 'cloud' ? 'bush' : kind === 'spread' ? 'bush_2' : 'bush_3']); + const r = rng(seed); + const h = c.height; + const ellipse = (cx, cy, rx, ry, color) => { + for (let y = -ry; y <= ry; y++) { + for (let x = -rx; x <= rx; x++) { + if ((x * x) / (rx * rx) + (y * y) / (ry * ry) <= 1) c.set(cx + x, cy + y, color); + } + } + }; + + // Пропорции вида: центр кроны, её эллипсы (центр + бока), нога. + const crown = kind === 'spread' + ? { c: [16, 15, 12, 6], sides: [[7, 19, 7, 4], [25, 14, 7, 4]], inner: [16, 14, 9, 4], legTop: 21, legBottom: h - 3 } + : kind === 'tall' + ? { c: [16, 11, 8, 9], sides: [[10, 21, 5, 5], [22, 20, 5, 5]], inner: [16, 10, 5, 6], legTop: 24, legBottom: h - 3 } + : { c: [16, 18, 11, 8], sides: [[8, 23, 6, 5], [24, 22, 6, 5]], inner: [16, 17, 8, 5], legTop: 27, legBottom: h - 3 }; + + // Тень-подошва: накрывает центр ground-ромба — куст «стоит», а не лежит. + ellipse(16, h - 5, 12, 4, 'P1'); + for (let i = 0; i < 8; i++) { + const x = 5 + Math.floor(r() * 23); + const y = h - 8 + Math.floor(r() * 6); + if (c.get(x, y) && r() < 0.5) c.set(x, y, 'P2'); + } + + // Нога ветвей: сужается от земли к кроне. + for (let y = crown.legTop; y <= crown.legBottom; y++) { + const half = Math.max(1, Math.round((crown.legBottom - y) * 0.36)); + for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, r() < 0.75 ? 'G0' : 'G1'); + } + // Крона-облако: центр + боковые пучки, внутренний тон. + ellipse(crown.c[0], crown.c[1], crown.c[2], crown.c[3], 'G0'); + for (const [cx, cy, rx, ry] of crown.sides) ellipse(cx, cy, rx, ry, 'G0'); + ellipse(crown.inner[0], crown.inner[1], crown.inner[2], crown.inner[3], 'G1'); + for (let i = 0; i < 22; i++) { + const x = 6 + Math.floor(r() * 20); + const y = crown.c[1] - crown.c[3] + Math.floor(r() * (crown.c[3] * 2 + 6)); + if (c.get(x, y) && r() < 0.5) c.set(x, y, 'G2'); + } + // Прорехи (сухой куст) и стебли наружу. + const topY = crown.c[1] - crown.c[3] + 3; + for (const [x, y] of [[12, topY + 4], [21, topY + 8], [9, topY + 11]]) { + c.set(x, y, null); + c.set(x + 1, y, null); + } + for (const [x, y] of [[7, topY + 3], [13, topY - 2], [19, topY - 3], [25, topY + 3]]) { + c.set(x, y, 'G2H'); + c.set(x, y + 1, 'G2'); + } + // Пепел у корней по краям тени. + c.set(5, h - 3, 'P5'); + c.set(27, h - 3, 'P5'); + return c; +} + +/** + * Большой дом 64x80 (проба работы C): двухмодульный дом на footprint 2×2, + * композит из повторяемых частей — стены-щиты + стойки, крыша из шпал, + * фундамент из рельсов. Якорь — низ-центр footprint'а (ставит движок, prop). + */ +function bigHouseTile() { + const c = new Canvas(...SIZES.prop.houseBig); + const r = rng(4242); + const planks = (x0, x1, y0, y1) => { + for (let y = y0; y <= y1; y++) { + const seam = (y - y0) % 6 === 0; + for (let x = x0; x <= x1; x++) c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0'); + } + }; + const windowAt = (x0, y0) => { + for (let y = y0; y <= y0 + 7; y++) for (let x = x0; x <= x0 + 6; x++) c.set(x, y, 'P2'); + for (let x = x0 - 1; x <= x0 + 7; x++) { c.set(x, y0 - 1, 'M0'); c.set(x, y0 + 8, 'M0'); } + for (let y = y0 - 1; y <= y0 + 8; y++) { c.set(x0 - 1, y, 'M0'); c.set(x0 + 7, y, 'M0'); } + for (let x = x0; x <= x0 + 6; x++) c.set(x, y0 + 3, 'M1'); // переплёт + c.set(x0 + 3, y0, 'P5'); // пепел на раме + }; + + // Стены: два модуля щитов, разделены и обрамлены стойками. + planks(5, 58, 31, 74); + for (const x of [5, 31, 32, 58]) for (let y = 30; y <= 74; y++) c.set(x, y, 'M1'); + for (let x = 4; x <= 59; x++) c.set(x, 30, 'M0'); // верхний венец + + // Крыша: треугольник шпал, расширяется книзу, свес по краям. + for (let i = 0; i <= 27; i++) { + const y = 2 + i; + const half = Math.min(28, 2 + Math.floor(i * 0.95)); + for (let dx = -half; dx <= half; dx++) c.set(32 + dx, y, i % 4 === 0 ? 'T0' : 'T1'); + } + for (let x = 2; x <= 61; x++) c.set(x, 29, 'T0'); + + // Дверь правого модуля + козырёк. + for (let y = 56; y <= 74; y++) for (let x = 44; x <= 52; x++) c.set(x, y, 'P1'); + c.set(50, 65, 'B0'); + for (let i = 0; i <= 5; i++) for (let dx = -i; dx <= i + 7; dx++) c.set(48 + dx, 50 + i, i === 5 ? 'T0' : 'T1'); + + // Окна: по одному на модуль. + windowAt(10, 38); + windowAt(37, 38); + + // Фундамент из рельсов, пепел у стен. + for (let x = 3; x <= 60; x++) { c.set(x, 75, 'R1'); c.set(x, 76, 'R0'); } + c.set(2, 77, 'P5'); c.set(61, 77, 'P5'); c.set(8, 78, 'P4'); c.set(55, 78, 'P4'); + + return c; +} + +/** Башня Звенца 32x80: водонапорная башня с колоколом из рельсов. Tall-объект 2.5 юнита. */ +/** Пол интерьера: доски вагонных платформ вдоль изометрии, швы темнее. */ +function floorTile() { + const c = new Canvas(SIZES.tile.w, SIZES.tile.h); + fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, 'T1'); + const r = rng(808); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 32; x++) { + if (!c.get(x, y)) continue; + // Доски «полосами» по экранному Y + случайная щепка. + const seam = y % 5 === 0; + c.set(x, y, seam ? 'T0' : r() < 0.12 ? 'T0' : 'T1'); + } + } + return c; +} + +/** Каменная стена вагонного цеха: блоки C1 со швами C0, тень по низу. */ +function wallTile() { + const c = new Canvas(...SIZES.tall.wall); + const r = rng(909); + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const seam = y % 8 === 0 || (x + Math.floor(y / 8) * 8) % 16 === 0; + c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0'); + } + } + // Нижний ряд темнее (тень у земли). + for (let x = 0; x < 32; x++) { + c.set(x, 30, 'C0'); + c.set(x, 31, 'C0'); + } + return c; +} + +/** Старый телеграфный колодец: каменное жерло, бронзовый обруч — звон = жизнь. */ +function wellTile() { + const c = new Canvas(...SIZES.tall.well); + const r = rng(1010); + + // Тёмное жерло (вода) — верхняя часть, видна внутри кольца. + for (let y = 20; y <= 26; y++) { + const half = Math.floor((26 - y) * 1.8) + 2; + for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'W0'); + } + // Каменная кладка вокруг жерла (эллипс). + for (let y = 16; y <= 28; y++) { + const half = Math.floor((24 - y) * 1.35) + 3; + for (let dx = -half; dx <= half; dx++) { + const rim = Math.abs(dx) > half - 2; + c.set(16 + dx, y, rim ? (r() < 0.8 ? 'C1' : 'C0') : null === c.get(16 + dx, y) ? 'C0' : c.get(16 + dx, y)); + } + } + // Бронзовый обруч по кромке — тёплый: колодец «звонный». + for (let x = 6; x <= 25; x++) { + c.set(x, 15, x % 4 === 0 ? 'B0' : 'B1'); + c.set(x, 16, 'B0'); + } + // Ворото с бронзовой рукоятью. + for (let y = 4; y <= 15; y++) { + c.set(7, y, 'T1'); + c.set(24, y, 'T1'); + } + for (let x = 7; x <= 24; x++) c.set(x, 5, 'T1'); + c.set(12, 4, 'B1'); + c.set(13, 4, 'B2'); // блик бронзы + return c; +} + +/** Сундук: вощёное дерево с бронзовой кромкой и замком — «звонное» железо. */ +function chestTile() { + const c = new Canvas(...SIZES.prop.chest); + const r = rng(1020); + + // Крышка: слегка выпуклая, доски из тёплого дерева. + for (let x = 4; x <= 19; x++) { + const lift = x >= 7 && x <= 16 ? 1 : 0; + for (let y = 6 - lift; y <= 9; y++) c.set(x, y, r() < 0.85 ? 'T1' : 'T0'); + } + // Корпус: темнее крышки. + for (let y = 11; y <= 15; y++) { + for (let x = 4; x <= 19; x++) c.set(x, y, r() < 0.85 ? 'T0' : 'T1'); + } + // Бронзовая полоса по стыку крышки и корпуса (как на ящике-плейсхолдере). + for (let x = 4; x <= 19; x++) c.set(x, 10, x % 5 === 0 ? 'B0' : 'B1'); + // Замок: вертикальная накладка с бликом. + for (let y = 10; y <= 15; y++) { + c.set(11, y, 'B1'); + c.set(12, y, 'B0'); + } + c.set(11, 12, 'B2'); + // Тень под днищем. + for (let x = 5; x <= 19; x++) c.set(x, 16, 'P1'); + return c; +} + +function towerTile() { + const c = new Canvas(...SIZES.tall.tower); + const r = rng(1414); + + // Бак: цилиндр из полос металла. + for (let y = 12; y <= 40; y++) { + for (let x = 7; x <= 24; x++) { + c.set(x, y, r() < 0.8 ? 'M1' : 'M0'); + } + // Боковые тени. + c.set(7, y, 'M0'); + c.set(8, y, 'M0'); + c.set(24, y, 'M0'); + } + // Пояса обруча. + for (const y of [14, 22, 30, 38]) { + for (let x = 7; x <= 24; x++) c.set(x, y, 'M2'); + } + // Коническая крыша бака. + for (let i = 0; i <= 6; i++) { + const half = 9 - i; + for (let dx = -half; dx <= half; dx++) c.set(16 + dx, 11 - i, 'M2'); + } + // Флюгер-шпиль. + for (let y = 2; y <= 4; y++) c.set(16, y, 'M2'); + c.set(18, 2, 'B1'); // бронзовый флажок — колокол помнит голос + + // Ноги-рельсы. + for (const [lx, rx] of [ + [10, 12], + [19, 21] + ]) { + for (let y = 41; y <= 77; y++) { + c.set(lx, y, 'M2'); + c.set(lx + 1, y, 'M0'); + c.set(rx, y, 'M2'); + c.set(rx - 1, y, 'M0'); + } + } + // Раскосы между ногами. + for (let i = 0; i < 4; i++) { + const y = 46 + i * 8; + for (let dx = 0; dx < 8; dx++) { + c.set(12 + dx, y + dx, i % 2 === 0 ? 'M1' : 'M2'); + c.set(19 - dx, y + dx, i % 2 === 0 ? 'M1' : 'M2'); + } + } + + // Колокол из рельсов под баком, между ног. + for (let y = 44; y <= 54; y++) { + const half = y < 50 ? 2 : 3; + for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'B0'); + } + c.set(16, 45, 'B2'); // блик + c.set(15, 48, 'B1'); + c.set(16, 55, 'M0'); // язык + + // Пепел у ног. + c.set(8, 79, 'P5'); + c.set(23, 79, 'P5'); + + return c; +} + +// ---------- тайлы акта 2: роща и разъезд ---------- + +/** Бухта кабеля: ржавые кольца труб/кабелей, лежат грудой (низкий tall-объект). */ +function wireTile() { + const c = new Canvas(...SIZES.tall.wire); + const r = rng(1515); + + // Груда: три кольца-сечения, нижнее шире (перспективный вал). + const rings = [ + { y: 24, half: 8, h: 5 }, + { y: 18, half: 6, h: 4 }, + { y: 13, half: 4, h: 4 } + ]; + for (const { y, half, h } of rings) { + for (let dy = 0; dy < h; dy++) { + for (let dx = -half; dx <= half; dx++) { + // Пустота внутри эллипса кольца (сечение трубы). + const inside = Math.abs(dx) < half - 2 && dy > 0 && dy < h - 1; + if (inside) continue; + // Ржавчина: внешняя дуга рыжая, тень снизу. + const rust = dy === 0 || dx === -half; + c.set(16 + dx, y + dy, rust ? (r() < 0.6 ? 'R2' : 'R1') : r() < 0.7 ? 'M1' : 'M0'); + } + } + // Сердцевина сечения — темный провал. + c.set(16, y + 1, 'P0'); + c.set(15, y + 2, 'P1'); + c.set(17, y + 2, 'P1'); + // Блик по верху кольца. + c.set(16 - half + 1, y, 'M2'); + c.set(16 + half - 1, y, 'M2'); + } + // Одиночный хвост кабеля в сторону. + for (let dx = 0; dx < 6; dx++) c.set(23 + dx, 28 + (dx % 2), dx < 4 ? 'M1' : 'M0'); + // Пепел у подножия. + c.set(6, 30, 'P5'); + c.set(26, 30, 'P5'); + return c; +} + +/** Рельсы: две нити со шпалами вдоль оси Y (экранная вертикаль ромба). */ +function railTile() { + const c = tileBase('P2', 'P3', 'P1', 19, 14); + const inDiamond = (x, y) => Math.abs(x - 15.5) / 16 + Math.abs(y - 7.5) / 8 <= 1; + // Шпалы: горизонтальные доски T0/T1 через равные промежутки. + for (const y of [3, 6, 9, 12]) { + for (let x = 2; x <= 29; x++) { + if (inDiamond(x, y)) c.set(x, y, x % 5 === 0 ? 'T0' : 'T1'); + } + } + // Нити рельсов: вертикальные полосы ржавого металла с бликом. + for (const [x1, x2] of [[11, 12], [19, 20]]) { + for (let y = 0; y <= 15; y++) { + for (const x of [x1, x2]) { + if (inDiamond(x, y)) c.set(x, y, x === x1 ? 'R1' : 'R2'); + } + } + } + // Блик верха рельса. + for (const x of [11, 19]) { + for (let y = 2; y <= 13; y++) if (inDiamond(x, y) && y % 4 === 1) c.set(x, y, 'R2'); + } + return c; +} + +/** Люк-ступени вниз: тёмный провал с тремя ступенями (ходовой тайл). */ +function stairsTile() { + const c = tileBase('P3', 'P4', 'P2', 23, 10); + // Проём: тёмный ромб в центре, уходящий вниз. + for (let y = 2; y <= 13; y++) { + const half = Math.floor((y - 2) * 0.8) + 1; + for (let dx = -half; dx <= half; dx++) { + const x = 15 + dx; + if (Math.abs(x - 15.5) / 16 + Math.abs(y - 7.5) / 8 <= 0.75) c.set(x, y, 'P0'); + } + } + // Ступени: горизонтальные полосы, светлеют кверху. + for (const [y, col] of [[11, 'P1'], [8, 'P2'], [5, 'P3']]) { + for (let x = 9; x <= 22; x++) { + if (Math.abs(x - 15.5) / 16 + Math.abs(y - 7.5) / 8 <= 0.7) c.set(x, y, col); + } + } + // Кромка люка — металл с ржавыми пятнами. + for (let y = 1; y <= 14; y++) { + for (let x = 4; x <= 27; x++) { + const d = Math.abs(x - 15.5) / 16 + Math.abs(y - 7.5) / 8; + if (d <= 1.0 && d > 0.78 && (x + y) % 3 === 0) c.set(x, y, 'R1'); + } + } + return c; +} + +/** + * Дерево-механизм Ржавой рощи: мёртвый металлический ствол в чёрных кабелях + * с медными колокольчиками (квест резонатора, interactables rust_grove). + */ +function toneTreeTile() { + const c = new Canvas(...SIZES.tall.tree); + + // Ствол: металл с изгибом, светлее с одной стороны, ржавчина у корней. + for (let y = 12; y <= 47; y++) { + const bend = Math.round(Math.sin((y - 12) * 0.11) * 2); + const half = y > 42 ? 3 : y > 18 ? 2 : 1; + for (let dx = -half; dx <= half; dx++) { + c.set(16 + bend + dx, y, y > 40 ? 'R1' : dx === half ? 'M2' : 'M1'); + } + c.set(16 + bend - half - 1, y, y > 40 ? 'R0' : 'M0'); + } + // Обрубки ветвей: слепые отростки в стороны. + for (const [x0, y0, x1, y1] of [ + [14, 14, 8, 10], + [19, 12, 25, 9], + [13, 20, 9, 18] + ]) { + line(c, x0, y0, x1, y1, 'M1'); + c.set(x1, y1, 'M2'); + } + + // Кабели: чёрные струны вдоль ствола + косые обмотки кольцами. + line(c, 12, 14, 15, 46, 'P0'); + line(c, 21, 13, 18, 46, 'P0'); + for (let y = 16; y <= 42; y += 5) line(c, 12, y, 21, y + 2, 'P0'); + + // Медные колокольчики на кабельных петлях (тепло — бронза, «жизнь»). + for (const [bx, by] of [ + [10, 12], + [24, 11], + [11, 22] + ]) { + c.set(bx, by - 1, 'P0'); // подвес + c.set(bx, by, 'B0'); + c.set(bx + 1, by, 'B1'); + c.set(bx, by + 1, 'B0'); + c.set(bx + 1, by + 1, 'B0'); + } + + // Корни в стороны и пепел у основания. + for (let i = 0; i < 4; i++) { + c.set(11 - i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); + c.set(22 + i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); + } + c.set(13, 47, 'P5'); + c.set(20, 47, 'P5'); + return c; +} + +/** + * Резонатор Клинта: медный колокол на ржавой стальной раме, с кабелем к чаше + * (interactables rust_grove, kind lever). + */ +function resonatorTile() { + const c = new Canvas(32, 40); + + // Ноги рамы: ржавый металл, расширяются к подошве. + for (let y = 18; y <= 37; y++) { + const w = y > 34 ? 3 : 2; + for (let dx = -w; dx <= w; dx++) { + c.set(8 + dx, y, dx === w ? 'R2' : 'R1'); + c.set(23 + dx, y, dx === -w ? 'R2' : 'R1'); + } + } + // Подошвы. + for (let x = 5; x <= 11; x++) c.set(x, 38, 'R0'); + for (let x = 20; x <= 26; x++) c.set(x, 38, 'R0'); + + // Верхняя балка и раскосы. + for (let x = 6; x <= 25; x++) { + c.set(x, 14, 'M2'); + c.set(x, 15, 'M1'); + } + line(c, 9, 16, 13, 24, 'M1'); + line(c, 22, 16, 18, 24, 'M1'); + + // Колокол: медный купол под балкой (тепло — бронза). + for (let y = 0; y < 9; y++) { + const half = Math.round(Math.sqrt(((y + 1) / 10) * 36)); + for (let dx = -half; dx <= half; dx++) { + c.set(15 + dx, 16 + y, dx === -half ? 'B1' : 'B0'); + } + } + // Кромка и язых. + for (let x = 12; x <= 18; x++) c.set(x, 25, 'B1'); + c.set(15, 27, 'M0'); + c.set(15, 28, 'M0'); + + // Кабель от языха к резонансной чаше на раме. + line(c, 15, 29, 15, 31, 'P0'); + for (let x = 11; x <= 20; x++) c.set(x, 32, 'M2'); + for (let x = 12; x <= 19; x++) c.set(x, 33, 'M1'); + // Блик на чаше. + c.set(13, 32, 'P5H'); + return c; +} + +/** Старая Машина: уснувшая печь-автоматон (высокий тайл, слот башни). */ +function machineTile() { + const c = new Canvas(...SIZES.tall.machine); + const r = rng(1616); + + // Корпус: массивный железный шкаф с боковыми тенями. + for (let y = 10; y <= 70; y++) { + for (let x = 6; x <= 25; x++) { + c.set(x, y, r() < 0.75 ? 'M1' : 'M0'); + } + c.set(6, y, 'M0'); + c.set(7, y, 'M0'); + c.set(24, y, 'M0'); + c.set(25, y, 'M0'); + // Потёки ржавчины: редкие вертикальные штрихи. + if (y % 7 === 3) { + c.set(9 + (y % 11), y, 'R1'); + c.set(21 - (y % 9), y, 'R2'); + } + } + // Сборные пояса и заклёпки. + for (const y of [14, 28, 42, 56]) { + for (let x = 6; x <= 25; x++) c.set(x, y, 'M2'); + } + for (const y of [18, 32, 46, 60]) { + for (const x of [8, 13, 18, 23]) c.set(x, y, 'P5'); + } + // Труба сверху с обручем. + for (let y = 2; y <= 9; y++) { + for (let x = 12; x <= 19; x++) c.set(x, y, r() < 0.8 ? 'M1' : 'M0'); + c.set(12, y, 'M0'); + c.set(19, y, 'M0'); + } + for (let x = 12; x <= 19; x++) c.set(x, 4, 'M2'); + + // Устье печи: тёмная арка, внутри — тлеющие угли (огонь = жизнь). + for (let y = 58; y <= 69; y++) { + const half = y < 62 ? 7 - (62 - y) : 7; + for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'P0'); + } + for (const [x, y, col] of [ + [13, 66, 'B0'], + [16, 67, 'F0'], + [18, 65, 'B0'], + [15, 64, 'F0'], + [20, 67, 'B0'] + ]) { + c.set(x, y, col); + } + // Обрамление арки: бронзовые заклёпки по углам — «звонное» железо. + c.set(9, 58, 'B0'); + c.set(22, 58, 'B0'); + // Ноги-опоры. + for (const [lx, rx] of [[7, 24]]) { + for (let y = 71; y <= 78; y++) { + c.set(lx, y, 'M0'); + c.set(rx, y, 'M0'); + } + } + // Пепел у подножия. + c.set(5, 79, 'P5'); + c.set(26, 79, 'P5'); + return c; +} + +// ---------- персонажи 16x24 ---------- + +// Общие цвета: o — контур/глаза (P0), c/C — плащ тень/свет, +// t/T — шляпа тень/свет, s/S — кожа тень/свет, b/B — бронза, p — сапоги (P1). +const HERO_MAP = { o: 'P0', c: 'C0', C: 'C1', t: 'T0', T: 'T1', s: 'S0', S: 'S1', b: 'B0', B: 'B1', p: 'P1', P: 'P2' }; + +const heroDown1 = fromAscii([ + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '..oTttttttttTo..', + '..oooooooooooo..', + '....oSSSSSSo....', + '....oSoSSoSo....', + '....oSSSsSSo....', + '.....oSSSSo.....', + '....oCCCCCCo....', + '...oCCCCCCCCo...', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '..obbBBBBBBbbo..', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '...oCCo..oCCo...', + '....oPo..oPo....', + '....oPo..oPo....', + '...oPPo..oPPo...', + '...oPPo..oPPo...', + '....oo....oo....', + '................' +], HERO_MAP); + +// Второй кадр ходьбы: ноги сошлись (passing), туловище на пиксель ниже — боб походки. +const heroDown2 = fromAscii([ + '................', + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '..oTttttttttTo..', + '..oooooooooooo..', + '....oSSSSSSo....', + '....oSoSSoSo....', + '....oSSSsSSo....', + '.....oSSSSo.....', + '....oCCCCCCo....', + '...oCCCCCCCCo...', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '..obbBBBBBBbbo..', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '....oCCoCCo.....', + '....oPo.oPo.....', + '....oPo.oPo.....', + '....oPPoPPo.....', + '.....oPPPo......', + '......ooo.......' +], HERO_MAP); + +// Спина: швы капюшона под шляпой, лампа на поясе (единственный тёплый пиксель). +const heroUpRows = [ + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '..oTttttttttTo..', + '..oooooooooooo..', + '.....oTttTo.....', + '....oCCCCCCo....', + '...oCCCCCCCCo...', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '..oCcCCCCCCcCo..', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '..obbBBffBBbbo..', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '...oCCo..oCCo...', + '....oPo..oPo....', + '....oPo..oPo....', + '...oPPo..oPPo...', + '...oPPo..oPPo...', + '....oo....oo....', + '................' +]; + +const heroUp1 = fromAscii(heroUpRows, { ...HERO_MAP, f: 'F0' }); + +// Второй кадр спины: туловище на пиксель ниже — боб походки. +const heroUp2 = fromAscii(['................', ...heroUpRows.slice(0, -1)], { ...HERO_MAP, f: 'F0' }); + +const heroSide1 = fromAscii([ + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '..oTttttttttTo..', + '..oooooooooooo..', + '.....oSSSSSSo...', + '.....oSSoSSo....', + '.....oSSSSSs....', + '......oSSSSo....', + '.....oCCCCCo....', + '....oCCCCCCCo...', + '...oCCCCCCCCCo..', + '...oCCCCCCCCCo..', + '...obbBBBBBBbo..', + '...oCCCCCCCCCo..', + '...oCCCCCCCCCo..', + '....oCCoCCo.....', + '....oPo.oPo.....', + '....oPo.oPo.....', + '...oPPo.oPPo....', + '...oPPo.oPPo....', + '....oo...oo.....', + '................' +], HERO_MAP); + +// Шаг в бок: ноги шире (stride), туловище на пиксель ниже — боб походки. +const heroSide2 = fromAscii([ + '................', + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '..oTttttttttTo..', + '..oooooooooooo..', + '.....oSSSSSSo...', + '.....oSSoSSo....', + '.....oSSSSSs....', + '......oSSSSo....', + '.....oCCCCCo....', + '....oCCCCCCCo...', + '...oCCCCCCCCCo..', + '...oCCCCCCCCCo..', + '...obbBBBBBBbo..', + '...oCCCCCCCCCo..', + '...oCCCCCCCCCo..', + '....oCCoCCo.....', + '...oPo...oPo....', + '...oPo...oPo....', + '..oPPo...oPPo...', + '..oPPo...oPPo...', + '...oo.....oo....' +], HERO_MAP); + +const elderIrwin = fromAscii([ + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '...otttttttto...', + '....ooSSSSoo....', + '....oSoPSoSo....', + '....oSsSsSso....', + '.....oSSSSo.....', + '.....oTTTTo.....', + '....oTssssTo....', + '...oTTsssSTo....', + '...oTtTssTtTo...', + '...oTttTTttTo...', + '...oTttttttTo...', + '...oTttttttTo...', + '...oTttttttTo...', + '...oTttttttTo...', + '....oTtTTtTo....', + '....oTtttTo.....', + '....oTttttTo....', + '....oTttttTo....', + '....oTttttTo....', + '....oooooooo....' +], HERO_MAP); + +const traderMila = fromAscii([ + '................', + '.....oooooo.....', + '....oWWWWWWo....', + '...oWwWWWwWo....', + '...oWWWWWWWo....', + '....oWSSSSo.....', + '....oSoPSoSo....', + '....oSsSsSso....', + '.....oSSSSo.....', + '.....oWWWWo.....', + '....oWwWWwWo....', + '...oWWWWWWWWo...', + '...oWWWWWWWWo...', + '...oWWWBWWWWo...', + '...oWWWWWWWWo...', + '....oWWWWWWo....', + '....oWooWoo.....', + '....oPo..oPo....', + '....oPo..oPo....', + '...oPPo..oPPo...', + '...oPPo..oPPo...', + '....oo....oo....', + '................', + '................' +], { o: 'P0', W: 'W3', w: 'S0', S: 'S1', s: 'S0', P: 'P0', B: 'B1' }); + +// Клинт, кузнец-починщик (акт 2): кожаный фартук T0/T1, ржавые пятна R1, +// руки мастера S0/S1 — ворчун с молотком. +const clint = fromAscii([ + '................', + '.....oooooo.....', + '....oPPPPPPo....', + '...oPpPPPpPo....', + '...oPPPPPPPo....', + '....oSSSSSSo....', + '....oSoSSoSo....', + '....oSsSsSso....', + '.....oSSSSo.....', + '.....oTTTTo.....', + '....oTtTTtTo....', + '...oTTTTTTTTo...', + '...oTtTTTTtTo...', + '...oTTTtTTTTo...', + '...oTTTTTTTToB..', + '...oTTTTTTTTo...', + '...oTtTTTTtTo...', + '....oTo..oTo....', + '....oPo..oPo....', + '....oPo..oPo....', + '...oPPo..oPPo...', + '...oPPo..oPPo...', + '....oo....oo....', + '................' +], { o: 'P0', P: 'P2', p: 'P1', S: 'S1', s: 'S0', T: 'T0', t: 'T1', B: 'B0' }); + +// ---------- вывод ---------- + +mkdirSync(OUT + 'tiles', { recursive: true }); +mkdirSync(OUT + 'chars', { recursive: true }); + +function save(canvas, rel) { + writeFileSync(OUT + rel, canvas.toPng()); + console.log(` ${rel}`); +} + +console.log('Генерация тайлов:'); +save(grassTile(), 'tiles/grass.png'); +save(grassTile(83), 'tiles/grass_2.png'); +save(grassTile(149), 'tiles/grass_3.png'); +save(pathTile(), 'tiles/path.png'); +save(pathTile(57), 'tiles/path_2.png'); +save(waterTile(0), 'tiles/water_1.png'); +save(waterTile(1), 'tiles/water_2.png'); +save(ashTile(), 'tiles/ash.png'); +save(ashTile(41, 5), 'tiles/ash_2.png'); // реже трава — редкий второй оттенок +save(decorTuft(), 'tiles/decor_tuft.png'); +save(decorStone(), 'tiles/decor_stone.png'); +save(decorBald(), 'tiles/decor_bald.png'); +save(bellflowerTile(), 'tiles/bellflower.png'); +// Деревья: три вида кроны, у каждого кадр качания (сдвиг кроны на 1px). +save(treeTile({ kind: 'wide', seed: 2026, cable: true }), 'tiles/tree.png'); +save(treeTile({ kind: 'wide', seed: 2026, cable: true, sway: 1 }), 'tiles/tree_sway.png'); +save(treeTile({ kind: 'sparse', seed: 404, cable: false }), 'tiles/tree_2.png'); +save(treeTile({ kind: 'sparse', seed: 404, cable: false, sway: 1 }), 'tiles/tree_2_sway.png'); +save(treeTile({ kind: 'young', seed: 909, cable: false }), 'tiles/tree_3.png'); +save(treeTile({ kind: 'young', seed: 909, cable: false, sway: 1 }), 'tiles/tree_3_sway.png'); +// Дома: три фасада (шпальная крыша / ржавый лист / длинное однооконное). +save(isoHouseTile({ kind: 'shack', seed: 777 }), 'tiles/house.png'); +save(isoHouseTile({ kind: 'cottage', seed: 1717 }), 'tiles/house_2.png'); +save(isoHouseTile({ kind: 'shed', seed: 2727 }), 'tiles/house_3.png'); +save(isoHouseTile({ kind: 'barn', seed: 3737 }), 'tiles/house_4.png'); +save(isoHouseTile({ kind: 'two', seed: 4747 }), 'tiles/house_5.png'); +save(boulderTile(), 'tiles/boulder.png'); +save(boulderTile({ kind: 'spike' }), 'tiles/boulder_2.png'); +save(boulderTile({ kind: 'flat' }), 'tiles/boulder_3.png'); +save(bushTile(), 'tiles/bush.png'); +save(bushTile({ kind: 'spread' }), 'tiles/bush_2.png'); +save(bushTile({ kind: 'tall' }), 'tiles/bush_3.png'); +save(bigHouseTile(), 'tiles/house_big.png'); +save(towerTile(), 'tiles/tower.png'); +save(floorTile(), 'tiles/floor.png'); +save(wallTile(), 'tiles/wall.png'); +save(wellTile(), 'tiles/well.png'); +save(chestTile(), 'tiles/chest.png'); +save(wireTile(), 'tiles/wire.png'); +save(railTile(), 'tiles/rail.png'); +save(stairsTile(), 'tiles/stairs.png'); +save(machineTile(), 'tiles/machine.png'); +// Интерактивы Ржавой рощи — рукодельные (AI-кандидаты не дали чистого фона). +save(toneTreeTile(), 'tiles/tone_tree.png'); +save(resonatorTile(), 'tiles/resonator.png'); + +console.log('Генерация персонажей:'); +save(heroDown1, 'chars/hero_down_1.png'); +save(heroDown2, 'chars/hero_down_2.png'); +save(heroUp1, 'chars/hero_up_1.png'); +save(heroUp2, 'chars/hero_up_2.png'); +save(heroSide1, 'chars/hero_side_1.png'); +save(heroSide2, 'chars/hero_side_2.png'); +save(elderIrwin, 'chars/elder_irwin.png'); +save(traderMila, 'chars/trader_mila.png'); +save(clint, 'chars/clint.png'); + +// ---------- фонарь героя (7x8, латунь B*, тёплое стекло F*) ---------- +// Вешается ребёнком на спрайт героя (пояс, правый бок): свет лампы — «от фонаря». +const heroLamp = fromAscii( + [ + '...B...', + '..BBB..', + '.B1F1B.', + '.BF2FB.', + '.BF2FB.', + '.B1F1B.', + '..B1B..', + '...B...' + ], + { B: 'B0', 1: 'B1', F: 'F1', 2: 'B2' } +); +save(heroLamp, 'chars/hero_lamp.png'); + +// ---------- безгласный олень (пейзажная фауна) ---------- +// Выцветший: серо-оливковые G*/M2, никаких тёплых пикселей — голос забран. + +const DEER_MAP = { o: 'P0', m: 'M2', G: 'G1', l: 'G2', d: 'P0' }; + +// Кадр 1: стоит (ноги вместе). +const deerStand = fromAscii([ + '................', + '................', + '...o.....o......', + '....o...o.......', + '.....o.o........', + '......omo.......', + '.....ommmo......', + '....oGGGGo......', + '...oGGdGGo......', + '....oGGGo.......', + '.....oGo........', + '..oGGGGGGGGo....', + '.oGGGGGGGGGGo...', + '.oGllGGGlllGo...', + '.oGllGGGlllGo...', + '..ooGGGGGGoo....', + '...oGo..oGo.....', + '...oGo..oGo.....', + '...oGo..oGo.....', + '...oGo..oGo.....', + '...ooo..ooo.....', + '................', + '................', + '................' +], DEER_MAP); + +// Кадр 2: шаг (ноги врозь). +const deerWalk = fromAscii([ + '................', + '................', + '...o.....o......', + '....o...o.......', + '.....o.o........', + '......omo.......', + '.....ommmo......', + '....oGGGGo......', + '...oGGdGGo......', + '....oGGGo.......', + '.....oGo........', + '..oGGGGGGGGo....', + '.oGGGGGGGGGGo...', + '.oGllGGGlllGo...', + '.oGllGGGlllGo...', + '..ooGGGGGGoo....', + '..oGo....oGo....', + '..oGo....oGo....', + '.oGo......oGo...', + '.oGo......oGo...', + '.ooo......ooo...', + '................', + '................', + '................' +], DEER_MAP); + +save(deerStand, 'chars/fauna_deer_1.png'); +save(deerWalk, 'chars/fauna_deer_2.png'); + +// атлас фауны (кадры — та же сетка, что у героя) +const FRAME_W_FAUNA = SIZES.fauna.w; +const FRAME_H_FAUNA = SIZES.fauna.h; +const faunaSheet = buildAtlas( + [deerStand, deerWalk].map((c) => new Uint8Array(c.data)), + ['fauna_deer_1', 'fauna_deer_2'], + { + frameW: FRAME_W_FAUNA, frameH: FRAME_H_FAUNA, image: 'fauna_sheet.png', + animations: { fauna_deer_walk: [0, 1] } + } +); +writeFileSync(OUT + 'chars/fauna_sheet.png', faunaSheet.png); +writeFileSync(OUT + 'chars/fauna_sheet.json', JSON.stringify(faunaSheet.json, null, 2)); +console.log(` chars/fauna_sheet.png (+ .json): ${FRAME_W_FAUNA * 2}x${FRAME_H_FAUNA}`); + +// ---------- пепельные сгустки (враги демо-боя) ---------- +// Серые P*/G* тела; тёплое ядро F* — «жизнь» загорается, когда сгусток просыпается. + +const CLUMP_MAP = { o: 'P0', d: 'P2', p: 'P3', q: 'P4', h: 'P5H', f: 'F0', F: 'F1' }; + +// Ползун 14x12: плоский слизень на коротких ножках. +const crawler1 = fromAscii([ + '..............', + '..............', + '...oooooo.....', + '..odppppdo....', + '.odpppppppdo..', + '.dpppffppppdo.', + '.dpppppppppdo.', + '.odpppppppdo..', + '..oddpppddo...', + '..o.o....o.o..', + '..o.o....o.o..', + '..............' +], CLUMP_MAP); + +const crawler2 = fromAscii([ + '..............', + '..............', + '...oooooo.....', + '..odppppdo....', + '.odpppppppdo..', + '.dpppffppppdo.', + '.dpppppppppdo.', + '.odpppppppdo..', + '..oddpppddo...', + '...o.o..o.o...', + '...o.o..o.o...', + '..............' +], CLUMP_MAP); + +// Плевун 14x14: столбик с тёмной пастью. +const spitter1 = fromAscii([ + '....oooooo....', + '...odppppdo...', + '..odppppppdo..', + '..dpppppppppd.', + '..dpppffppppd.', + '..dpppppppppd.', + '..dpoooopppd..', + '..dpppppppppd.', + '...dpppppppd..', + '...odpppppdo..', + '....odpppdo...', + '....o.o.o.o...', + '...o..o..o....', + '..............' +], CLUMP_MAP); + +const spitter2 = fromAscii([ + '....oooooo....', + '...odppppdo...', + '..odppppppdo..', + '..dpppppppppd.', + '..dpppffppppd.', + '..dpppppppppd.', + '..dppoooopppd.', + '..dpppppppppd.', + '...dpppppppd..', + '...odpppppdo..', + '....odpppdo...', + '...o..o..o....', + '....o.o.o.o...', + '..............' +], CLUMP_MAP); + +// Наст 20x20: тяжёлая корка с трещинами; медленно давит. +const heavy1 = fromAscii([ + '.......oooooo.......', + '.....oodppppdoo.....', + '....odppppppppdo....', + '...odppppppppppdo...', + '...dpppddppppppppd..', + '..dppppppddpppppppd.', + '..dppppppppddppffpd.', + '..dpppffppppppppfpd.', + '..dpppffppppppppppd.', + '..dpppppppppppppppd.', + '..dpppppppppppppppd.', + '..dpppppppppppppppd.', + '..odpppppppppppppdo.', + '..odqpppppppppppdo..', + '...odqqppppqqpdo....', + '....odqpppqqpdo.....', + '.....oddddddddo.....', + '...ooooddddooooo....', + '....................', + '....................' +], CLUMP_MAP); + +const heavy2 = fromAscii([ + '.......oooooo.......', + '.....oodppppdoo.....', + '....odppppppppdo....', + '...odppppppppppdo...', + '...dpppppppddppppd..', + '..dpppddpppppddpppd.', + '..dpppppddpppppffpd.', + '..dpppffpppddpppfpd.', + '..dpppffppppppppppd.', + '..dpppppppppppppppd.', + '..dpppppppppppppppd.', + '..dpppppppppppppppd.', + '..odpppppppppppppdo.', + '..odqpppppppppppdo..', + '...odqqppppqqpdo....', + '....odqpppqqpdo.....', + '.....oddddddddo.....', + '...ooooddddooooo....', + '....................', + '....................' +], CLUMP_MAP); + +save(crawler1, 'chars/clump_crawler_1.png'); +save(crawler2, 'chars/clump_crawler_2.png'); +save(spitter1, 'chars/clump_spitter_1.png'); +save(spitter2, 'chars/clump_spitter_2.png'); +save(heavy1, 'chars/clump_heavy_1.png'); +save(heavy2, 'chars/clump_heavy_2.png'); + +// атлас сгустков (кадры разной ширины — вписаны по низу-центру в ячейку) +const CLUMP_W = SIZES.clump.w; +const CLUMP_H = SIZES.clump.h; +/** Canvas → кадр w×h×4 с выравниванием по низу-центру. */ +const toClumpFrame = (c) => { + const out = new Uint8Array(CLUMP_W * CLUMP_H * 4); + const dx = Math.floor((CLUMP_W - c.width) / 2); + for (let y = 0; y < c.height; y++) { + for (let x = 0; x < c.width; x++) { + const rgba = c.get(x, y); + if (!rgba) continue; + out.set(rgba, ((CLUMP_H - c.height + y) * CLUMP_W + dx + x) * 4); + } + } + return out; +}; +const clumpSheet = buildAtlas( + [crawler1, crawler2, spitter1, spitter2, heavy1, heavy2].map(toClumpFrame), + ['clump_crawler_1', 'clump_crawler_2', 'clump_spitter_1', 'clump_spitter_2', 'clump_heavy_1', 'clump_heavy_2'], + { + frameW: CLUMP_W, frameH: CLUMP_H, image: 'clumps_sheet.png', + animations: { clump_crawler: [0, 1], clump_spitter: [2, 3], clump_heavy: [4, 5] } + } +); +writeFileSync(OUT + 'chars/clumps_sheet.png', clumpSheet.png); +writeFileSync(OUT + 'chars/clumps_sheet.json', JSON.stringify(clumpSheet.json, null, 2)); +console.log(` chars/clumps_sheet.png (+ .json): ${CLUMP_W * 6}x${CLUMP_H}`); + +// ---------- атлас героя (Spritesheet: один PNG + JSON + animations) ---------- + +const FRAME_W = SIZES.char.w; +const FRAME_H = SIZES.char.h; +const heroSheet = buildAtlas( + [heroDown1, heroDown2, heroUp1, heroUp2, heroSide1, heroSide2].map((c) => new Uint8Array(c.data)), + ['hero_down_1', 'hero_down_2', 'hero_up_1', 'hero_up_2', 'hero_side_1', 'hero_side_2'], + { + frameW: FRAME_W, frameH: FRAME_H, image: 'hero_sheet.png', + animations: { + hero_walk_down: [0, 1], + hero_walk_up: [2, 3], + hero_walk_side: [4, 5] + } + } +); +writeFileSync(OUT + 'chars/hero_sheet.png', heroSheet.png); +writeFileSync(OUT + 'chars/hero_sheet.json', JSON.stringify(heroSheet.json, null, 2)); +console.log(` chars/hero_sheet.png (+ .json): ${FRAME_W * 6}x${FRAME_H}`); + +// ---------- атлас NPC: idle-кадры из опорных (bobFrame) ---------- + +const npcSheet = buildAtlas( + [ + new Uint8Array(elderIrwin.data), + bobFrame(new Uint8Array(elderIrwin.data), FRAME_W, FRAME_H), + new Uint8Array(traderMila.data), + bobFrame(new Uint8Array(traderMila.data), FRAME_W, FRAME_H), + new Uint8Array(clint.data), + bobFrame(new Uint8Array(clint.data), FRAME_W, FRAME_H) + ], + ['elder_irwin_1', 'elder_irwin_2', 'trader_mila_1', 'trader_mila_2', 'clint_1', 'clint_2'], + { + frameW: FRAME_W, frameH: FRAME_H, image: 'npcs_sheet.png', + animations: { elder_irwin_idle: [0, 1], trader_mila_idle: [2, 3], clint_idle: [4, 5] } + } +); +writeFileSync(OUT + 'chars/npcs_sheet.png', npcSheet.png); +writeFileSync(OUT + 'chars/npcs_sheet.json', JSON.stringify(npcSheet.json, null, 2)); +console.log(` chars/npcs_sheet.png (+ .json): ${FRAME_W * 6}x${FRAME_H}`); + +console.log('Готово: ' + OUT); \ No newline at end of file diff --git a/v1/apps/game/tools/pixelart/palette.mjs b/v1/apps/game/tools/pixelart/palette.mjs new file mode 100644 index 0000000..ed6c7b8 --- /dev/null +++ b/v1/apps/game/tools/pixelart/palette.mjs @@ -0,0 +1,58 @@ +/** + * Палитра из docs/art-style.md — единственный источник цветов арта. + */ + +export const PALETTE = { + // Пепел + P0: '#17171d', + P1: '#23232b', + P2: '#2f2f38', + P3: '#3e3e48', + P4: '#5b5b66', + P5: '#767681', + P6: '#94949e', + P5H: '#b2b2bb', + // Мёртвая трава + G0: '#343b2c', + G1: '#4a5340', + G2: '#626c4c', + G2H: '#7d8662', + // Ржавчина и металл + R0: '#3a2822', + R1: '#5c3b2e', + R2: '#82503a', + M0: '#26262e', + M1: '#44454e', + M2: '#61626c', + // Вода + W0: '#22343c', + W1: '#3b5e6b', + W2: '#568590', + // Тепло — только жизнь + B0: '#8a6a2e', + B1: '#d99a32', + B2: '#f2cd68', + F0: '#d97f2b', + F1: '#f2b45a', + // Люди и ткань + C0: '#3a4356', + C1: '#4d5a72', + W3: '#8a8f7d', + S0: '#8a6d55', + S1: '#c9a689', + T0: '#5a4a3c', + T1: '#77634f' +}; + +/** Прозрачный пиксель. */ +export const TRANSPARENT = [0, 0, 0, 0]; + +export function hexToRgba(hex) { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff, 255]; +} + +/** Ключи палитры -> RGBA-значения (кэш). */ +export const RGBA = Object.fromEntries( + Object.entries(PALETTE).map(([k, v]) => [k, hexToRgba(v)]) +); \ No newline at end of file diff --git a/v1/apps/game/tools/pixelart/sizes.mjs b/v1/apps/game/tools/pixelart/sizes.mjs new file mode 100644 index 0000000..e0030b0 --- /dev/null +++ b/v1/apps/game/tools/pixelart/sizes.mjs @@ -0,0 +1,64 @@ +/** + * Реестр размеров спрайтов игры — единственный источник истины для + * генераторов (gen.mjs), линтера (aiart/lint-art.mjs) и aiart-тулз. + * Таблица размеров в docs/art-style.md ссылается сюда (§2). + * + * Рантайм размеры не отсюда: движок читает их из атласного JSON + * (`AssetLoader.frameSize`) — тулзовый реестр движок не знает. + */ +export const SIZES = { + /** Тайл-ромб (изометрия 2:1). */ + tile: { w: 32, h: 16 }, + /** Высокие тайлы: спрайт якорится низом в центр ромба. */ + tall: { + wall: [32, 32], + well: [32, 40], + tree: [32, 48], + house: [32, 56], + /** Высокие дома (амбар, двухэтажный) — ростом с башню. */ + houseTall: [32, 80], + /** Валун — крупный непроходимый камень в изо-объёме (3 вида). */ + boulder: [32, 32], + boulder_2: [32, 40], + boulder_3: [32, 24], + /** Большой мёртвый куст-облако (3 вида). */ + bush: [32, 40], + bush_2: [32, 32], + bush_3: [32, 48], + tower: [32, 80], + /** Бухта кабеля Ржавой рощи (низкая, в полтайла). */ + wire: [32, 32], + /** Старая Машина: печь-автоматон зала разъезда (слот башни). */ + machine: [32, 80] + }, + /** Крупные пропы. */ + prop: { + houseBig: [64, 80], + chest: [24, 18] + }, + /** Кадры героя и NPC (общая сетка атласов chars/*_sheet). */ + char: { w: 16, h: 24 }, + /** Кадры фауны (та же сетка, что у героя). */ + fauna: { w: 16, h: 24 }, + /** Кадры сгустков (враги демо-боя; рисунки вписаны по низу-центру). */ + clump: { w: 20, h: 20 }, + /** Спрайты декора поверх тайлов (микро-кусты, камни, проплешины). */ + decor: { w: 16, h: 16 }, + /** Одиночные спрайты-сателлиты (вешаются на персонажей). */ + satellite: { heroLamp: [7, 8] } +}; + +/** Слоты вне реестра: рукодельные кадры сгустков, крупные AI-ассеты, иконки 8×8. */ +const EXTRA_SLOTS = ['8x8', '14x12', '14x14', '48x48', '96x96', '128x112']; + +const dim = (s) => (Array.isArray(s) ? `${s[0]}x${s[1]}` : `${s.w}x${s.h}`); + +/** Все размеры группы: группа — либо один размер {w,h}, либо карта имён → размер. */ +const groupDims = (group) => + 'w' in group ? [group] : Object.values(group); + +/** Все допустимые слоты линтера: реестр + исключения. */ +export const SLOTS = new Set([ + ...Object.values(SIZES).flatMap((group) => groupDims(group).map(dim)), + ...EXTRA_SLOTS +]); \ No newline at end of file diff --git a/v1/apps/game/tools/smoke-act1.mjs b/v1/apps/game/tools/smoke-act1.mjs new file mode 100644 index 0000000..6b0ef4f --- /dev/null +++ b/v1/apps/game/tools/smoke-act1.mjs @@ -0,0 +1,55 @@ +/** + * Полный прогон акта 1 через агентный мост: луга -> Звенец -> квест у Ирвина -> + * (сбор цветов эмулируется var'ом) -> сдача -> кат-сцена с колоколом. + * Запуск: node tools/smoke-act1.mjs [url] [скриншот] + */ +import { openGame } from './lib.mjs'; + +const url = process.argv[2] ?? 'http://localhost:5199/'; +const shot = process.argv[3] ?? '/tmp/rpg_act1.png'; + +const { browser, page, agent } = await openGame({ url, newGame: true }); +page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); + +// Усыпить сгустков: путь героя не должен прерываться уроном. +async function sleepAll() { + await page.evaluate(() => { + const g = window.__game; + for (const [, en] of g.scenes.current.combat?.enemies ?? []) en.brain.putToSleep(9999); + }); +} +await sleepAll(); + +// 1) Луга -> тропа в Звенец (54,28) — триггер срабатывает на самом тайле. +console.log('шаг 1: луга -> Звенец'); +if (!(await agent.walkTo(54, 28, { timeoutTicks: 6000 }))) console.log(' ! герой не дошёл до (54,28)'); +await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 300 }); +// Фаза «in» fade-перехода: сцена ещё игнорирует клики. +await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); +await sleepAll(); // переход мог вернуть героя в бой + +// 2-3) Подойти к Ирвину (12,14) и нажать действие E — диалог откроется в радиусе. +console.log('шаг 2: квест у Ирвина'); +if (await agent.approach(12, 14)) agent.press('interact'); +else console.log(' ! не подошли к Ирвину'); +if (!(await agent.runDialogue())) console.log(' ! диалог с Ирвином не открылся'); + +// 4) Сбор цветов — эмуляция var'ом (переход в пруды проверяется checks/transitions). +console.log('шаг 4: цветы собраны (эмуляция)'); +await agent.command('scene:setVar', { id: 'flowers', value: 3 }); + +// 5) Сдача Ирвину -> кат-сцена. +console.log('шаг 5: сдача -> кат-сцена'); +if (await agent.approach(12, 14)) agent.press('interact'); +else console.log(' ! не подошли к Ирвину для сдачи'); +if (!(await agent.runDialogue())) console.log(' ! диалог сдачи не открылся'); + +// 6) Кат-сцена: крутим шаги, пока активна (мост перематывает без реального ожидания). +await agent.waitFor('s.cutscene == null || !s.cutscene.active', { timeoutTicks: 1200 }); + +const snap = await agent.snapshot(); +console.log('итог:', JSON.stringify({ flags: snap.flags, vars: snap.vars })); +await page.screenshot({ path: shot }); +console.log(`Скриншот: ${shot}`); +if (!snap.flags.includes('quest_bells_done')) console.log(' ! квест не закрыт (quest_bells_done)'); +await browser.close(); \ No newline at end of file diff --git a/v1/apps/game/tools/smoke.mjs b/v1/apps/game/tools/smoke.mjs new file mode 100644 index 0000000..368b890 --- /dev/null +++ b/v1/apps/game/tools/smoke.mjs @@ -0,0 +1,22 @@ +/** + * Базовый смоук через агентный мост: новая игра, шаги, снапшот, скриншот. + * Запуск: node tools/smoke.mjs [url] [файл-скриншота] + */ +import { openGame } from './lib.mjs'; + +const url = process.argv[2] ?? 'http://localhost:5199/'; +const shot = process.argv[3] ?? '/tmp/rpg_smoke.png'; + +const { browser, page, agent } = await openGame({ url, newGame: true }); + +// 300 фиксированных шагов без ошибок инвариантов. +await agent.step(300); +const inv = await agent.invariants(); +const errs = inv.filter((i) => i.severity === 'error'); +if (errs.length) console.log(' ! инварианты:', JSON.stringify(errs)); + +const snap = await agent.snapshot(); +console.log('снапшот:', JSON.stringify({ area: snap.area, hero: snap.hero?.tile, hp: snap.hero?.hp })); +await page.screenshot({ path: shot }); +console.log(`Скриншот: ${shot}`); +await browser.close(); \ No newline at end of file diff --git a/v1/apps/game/tsconfig.json b/v1/apps/game/tsconfig.json new file mode 100644 index 0000000..d16bc7c --- /dev/null +++ b/v1/apps/game/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["vite/client"], + "resolveJsonModule": true + }, + "include": ["src"] +} \ No newline at end of file diff --git a/v1/apps/game/vite.config.ts b/v1/apps/game/vite.config.ts new file mode 100644 index 0000000..315b6fe --- /dev/null +++ b/v1/apps/game/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import { fileURLToPath } from 'node:url'; + +// Движок — TS-исходники workspace-пакета: алиас гарантирует, что Vite +// собирает их как исходный код, а не как предсобранный пакет. +// publicDir = apps/game/assets: PNG-ассеты раздаются с корня и копируются в dist. +export default defineConfig({ + publicDir: 'assets', + resolve: { + alias: [ + // Точный алиас: под-пути (@rpg/engine/assets/*) идут через exports пакета. + { + find: /^@rpg\/engine$/, + replacement: fileURLToPath(new URL('../../packages/engine/src/index.ts', import.meta.url)) + } + ] + } +}); \ No newline at end of file diff --git a/v1/eslint.config.js b/v1/eslint.config.js new file mode 100644 index 0000000..dd26360 --- /dev/null +++ b/v1/eslint.config.js @@ -0,0 +1,75 @@ +/** + * ESLint (flat config): исходники движка и игры. Логика качества — tsc (типы) + * и гвард границы (imports); ESLint ловит остальное: неиспользуемое, промахи + * промисов, равенства. Тулзы .mjs не линтятся (node-скрипты без типов). + */ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['**/node_modules/**', '**/dist/**', '**/*.d.ts'] + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['packages/engine/src/**/*.ts', 'apps/game/src/**/*.ts', 'apps/game/tools/**/*.ts'], + rules: { + // no-undef в TS ложится на глобали DOM/Vite — типы проверяет tsc. + 'no-undef': 'off', + // Неиспользуемое — ошибка, кроме параметров с ведущим _ (хуки движка). + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' } + ], + 'no-console': 'off', + 'prefer-const': 'error' + } + }, + { + // Node-тулзы (.mjs): глобали Node без типов (tsc их не видит). + files: ['apps/game/tools/**/*.mjs', 'apps/game/tools/**/*.ts'], + languageOptions: { + globals: { + console: 'readonly', + process: 'readonly', + URL: 'readonly', + Buffer: 'readonly', + fetch: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + // Страница игры в puppeteer-сценариях (page.evaluate). + window: 'readonly', + performance: 'readonly' + } + } + }, + { + // Сценарии-тулзы: консоль — их интерфейс; пустой catch — «браузер мог умереть». + files: ['apps/game/tools/**/*.ts', 'apps/game/tools/**/*.mjs'], + rules: { + 'no-console': 'off', + 'no-empty': ['error', { allowEmptyCatch: true }] + } + }, + { + // Браузерный скрипт редактора диалогов. + files: ['apps/game/tools/dialogue-editor/**/*.js'], + languageOptions: { + globals: { + console: 'readonly', + document: 'readonly', + window: 'readonly', + fetch: 'readonly', + setTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + requestAnimationFrame: 'readonly', + localStorage: 'readonly', + history: 'readonly', + Option: 'readonly', + confirm: 'readonly' + } + } + } +); \ No newline at end of file diff --git a/v1/package-lock.json b/v1/package-lock.json new file mode 100644 index 0000000..e1b7592 --- /dev/null +++ b/v1/package-lock.json @@ -0,0 +1,3803 @@ +{ + "name": "rpg", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rpg", + "version": "0.1.0", + "workspaces": [ + "packages/*", + "apps/*" + ], + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.10.0", + "typescript": "^5.6.0", + "typescript-eslint": "^8.69.0", + "vitest": "^2.1.0" + } + }, + "apps/game": { + "name": "@rpg/game", + "version": "0.1.0", + "dependencies": { + "@rpg/engine": "*", + "pixi.js": "^8.19.0" + }, + "devDependencies": { + "puppeteer-core": "^25.10.0", + "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/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "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/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "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/@puppeteer/browsers": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz", + "integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.8.4", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, + "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/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "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/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", + "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/type-utils": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.69.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", + "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", + "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.69.0", + "@typescript-eslint/types": "^8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", + "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", + "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", + "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", + "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", + "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.69.0", + "@typescript-eslint/tsconfig-utils": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", + "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", + "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "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/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "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/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "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/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/devtools-protocol": { + "version": "0.0.1666840", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", + "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "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/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "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/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.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/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "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/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "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/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "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/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.4.tgz", + "integrity": "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer-core": { + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz", + "integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.2.2", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.3", + "ws": "^8.21.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "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/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "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/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, + "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/typescript-eslint": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", + "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.69.0", + "@typescript-eslint/parser": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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/webdriver-bidi-protocol": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", + "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/engine": { + "name": "@rpg/engine", + "version": "0.1.0", + "dependencies": { + "pixi.js": "^8.19.0" + } + } + } +} diff --git a/v1/package.json b/v1/package.json new file mode 100644 index 0000000..716360f --- /dev/null +++ b/v1/package.json @@ -0,0 +1,38 @@ +{ + "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", + "art": "node apps/game/tools/pixelart/gen.mjs", + "aiart": "node apps/game/tools/aiart/atlas.mjs", + "art:lint": "node apps/game/tools/aiart/lint-art.mjs $(find apps/game/assets -name '*.png')", + "audio": "node apps/game/tools/audio/gen.mjs", + "aiaudio": "node apps/game/tools/aiaudio/index.mjs", + "maps": "RUN_MAPS_GEN=1 vitest run apps/game/tools/maps/gen.test.ts", + "dialogues:dry": "vite-node apps/game/tools/dialogues/dry-run.ts", + "dialogues": "node apps/game/tools/dialogue-editor/server.mjs", + "guard": "node apps/game/tools/guards/boundary.mjs", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit -p packages/engine && tsc --noEmit -p apps/game", + "lint": "eslint packages/engine/src apps/game/src apps/game/tools", + "check:fast": "npm run typecheck && npm run lint && npm run guard && npm test", + "agent": "node apps/game/tools/agent.mjs dev", + "agent:check": "node apps/game/tools/agent.mjs check", + "agent:snapshot": "node apps/game/tools/agent.mjs snapshot --new-game" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.10.0", + "typescript": "^5.6.0", + "typescript-eslint": "^8.69.0", + "vitest": "^2.1.0" + } +} diff --git a/v1/packages/engine/assets/fonts/OFL.txt b/v1/packages/engine/assets/fonts/OFL.txt new file mode 100644 index 0000000..bcb092f --- /dev/null +++ b/v1/packages/engine/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/v1/packages/engine/assets/fonts/VT323-Regular.ttf b/v1/packages/engine/assets/fonts/VT323-Regular.ttf new file mode 100644 index 0000000..afa6909 --- /dev/null +++ b/v1/packages/engine/assets/fonts/VT323-Regular.ttf Binary files differ diff --git a/v1/packages/engine/package.json b/v1/packages/engine/package.json new file mode 100644 index 0000000..1959fc0 --- /dev/null +++ b/v1/packages/engine/package.json @@ -0,0 +1,15 @@ +{ + "name": "@rpg/engine", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./assets/*": "./assets/*", + "./tools/*": "./tools/*" + }, + "dependencies": { + "pixi.js": "^8.19.0" + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/agent/EngineAgent.ts b/v1/packages/engine/src/agent/EngineAgent.ts new file mode 100644 index 0000000..1fb0776 --- /dev/null +++ b/v1/packages/engine/src/agent/EngineAgent.ts @@ -0,0 +1,221 @@ +import type { + AgentHost, + EngineSnapshot, + Invariant, + SceneAgent, + SnapshotLayer, + WaitForOptions +} from './types'; +import { mergeInvariants } from './invariants'; + +/** + * Top-level ключи движкового слоя снапшота (см. snapshot()). Игра расширяет + * его своими слоями (AGENT_SNAPSHOT_KEYS) — валидатор предикатов waitFor + * сверяет обращения `s.<ключ>` с объединённым списком. + */ +export const ENGINE_SNAPSHOT_KEYS = [ + 'tick', + 'fps', + 'fixedStep', + 'scenes', + 'transitioning', + 'camera', + 'pointer' +] as const; + +/** + * Движковый агентный мост: снапшоты, детерминированные шаги, инъекция ввода. + * Никогда не бросает: каждый доступ — через safe() с try/catch, ошибка + * становится слоем снапшота `{ error }`. + */ +export class EngineAgent { + constructor( + private host: AgentHost, + /** Фабрика сцены-агента: сцена может отсутствовать (меню, переход). */ + private sceneAgent: () => SceneAgent | null + ) {} + + /** Полный снапшот: движковый слой + контентный слой текущей сцены. */ + snapshot(): SnapshotLayer { + const base = this.safe('snapshot', (): EngineSnapshot => { + const h = this.host; + return { + tick: h.tickCount, + fps: h.fps, + fixedStep: h.fixedStep, + scenes: [h.scenes.current?.constructor.name ?? null].filter( + (s): s is string => s !== null + ), + transitioning: h.scenes.transitioning, + camera: { + x: num(h.camera.x), + y: num(h.camera.y), + shaking: h.camera.shaking + }, + pointer: this.pointerLayer() + }; + }) as SnapshotLayer; + const scene = this.sceneAgent(); + if (scene) { + const layer = this.safe('scene.snapshot', () => scene.agentSnapshot()); + if (layer) Object.assign(base, layer); + } + return base; + } + + /** Нарушенные инварианты: движковые + сценические. */ + invariants(): Invariant[] { + const engine: Invariant[] = this.safe('invariants', () => { + const cam = this.host.camera; + return mergeInvariants( + checkNum('camera.x', cam.x, 'engine/camera'), + checkNum('camera.y', cam.y, 'engine/camera'), + this.pointerInvariants() + ); + }) ?? []; + const scene = this.sceneAgent(); + const sceneInvs = scene + ? (this.safe('scene.invariants', () => scene.agentInvariants()) ?? []) + : []; + return [...engine, ...(Array.isArray(sceneInvs) ? sceneInvs : [])]; + } + + /** n фиксированных шагов без реального ожидания (детерминированно). */ + step(n = 1, opts?: { render?: boolean }): { tick: number } { + const render = opts?.render ?? false; + // Серия целиком в ручном режиме: реальный rAF-цикл между шагами не + // тикает и не очищает «just pressed» инъекции середины серии. + try { + this.host.stepTicks(n, render); + } catch { + return { tick: this.host.tickCount }; + } + return { tick: this.host.tickCount }; + } + + /** + * Крутить шаги, пока предикат не станет истинным (или лимит шагов). + * pred вызывается после каждого шага на полном снапшоте. + */ + async waitFor( + pred: (s: SnapshotLayer) => boolean, + opts?: WaitForOptions + ): Promise<{ ok: boolean; snapshot: SnapshotLayer; ticks: number }> { + const limit = opts?.timeoutTicks ?? 600; + const render = opts?.render ?? false; + for (let i = 0; i < limit; i++) { + this.step(1, { render }); + let s: SnapshotLayer; + try { + s = this.snapshot(); + } catch { + continue; + } + let ok = false; + try { + ok = pred(s); + } catch { + // предикат упал — трактуем как «не совпало» + } + if (ok) return { ok: true, snapshot: s, ticks: i + 1 }; + } + return { ok: false, snapshot: this.snapshot(), ticks: limit }; + } + + /** Ввод в виртуальных пикселях: эквивалент pointerdown/up на канвасе. */ + /** + * Клик в виртуальных пикселях. Инъекция и один шаг логики — атомарно + * (один JS-тик): между evaluate rAF-кадр успел бы очистить «just pressed». + */ + tapVirtual(vx: number, vy: number): void { + this.safe('tapVirtual', () => { + this.host.input.injectPointerDown(vx, vy); + this.host.input.injectPointerUp(vx, vy); + this.host.stepTick(); + }); + } + + /** Действие через маппинг действий (isActionJustPressed внутри этого же тика). */ + press(action: string, holdTicks = 1): void { + this.safe('press', () => { + this.host.input.injectAction(action); + this.host.stepTick(); // действие видно сцене в этом же тике + for (let i = 1; i < holdTicks; i++) this.host.stepTick(); + this.host.input.injectActionRelease(action); + }); + } + + /** Сырой код клавиши (e.code) — атомарно с одним шагом логики. */ + key(code: string): void { + this.safe('key', () => { + this.host.input.injectKeyCode(code); + this.host.stepTick(); + this.host.input.injectKeyCodeUp(code); + }); + } + + /** + * Реальный DOM-клик по канвасу (виртуальные px → CSS): нужен для Pixi-кнопок, + * если сцена слушает DOM напрямую. В геймплее предпочитай действия (press). + */ + uiTap(vx: number, vy: number): void { + this.safe('uiTap', () => { + const canvas = document.querySelector('canvas'); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const sx = rect.left + (vx / this.host.virtualWidth) * rect.width; + const sy = rect.top + (vy / this.host.virtualHeight) * rect.height; + canvas.dispatchEvent(new MouseEvent('pointerdown', { + clientX: sx, clientY: sy, bubbles: true + })); + canvas.dispatchEvent(new MouseEvent('pointerup', { + clientX: sx, clientY: sy, bubbles: true + })); + }); + } + + /** Команда сцены (whitelist в реализации SceneAgent). */ + command(name: string, args?: unknown): unknown { + const scene = this.sceneAgent(); + if (!scene?.agentCommand) return null; + return this.safe('command', () => scene.agentCommand!(name, args as never)) ?? null; + } + + // --- внутреннее --- + + private pointerLayer(): SnapshotLayer { + const p = this.host.input.getPointer(); + return { + x: num(p.x), y: num(p.y), down: p.down, + justPressed: p.justPressed, downTicks: p.downTicks + }; + } + + private pointerInvariants(): Invariant[] { + const p = this.host.input.getPointer(); + return mergeInvariants( + checkNum('pointer.x', p.x, 'engine/pointer'), + checkNum('pointer.y', p.y, 'engine/pointer') + ); + } + + /** Выполнить fn, поймав исключение: ошибка -> слой { error }, а не бросок. */ + private safe(label: string, fn: () => T): T | null { + try { + return fn(); + } catch (e) { + return { error: `${label}: ${e instanceof Error ? e.message : String(e)}` } as unknown as T; + } + } +} + +function num(v: number): number { + return Number.isFinite(v) ? Math.round(v * 1000) / 1000 : String(v) as unknown as number; +} + +function checkNum(name: string, v: number, where: string): Invariant | null { + if (!Number.isFinite(v)) { + return { id: 'pos-nan', severity: 'error', message: `${name} = ${v}`, where }; + } + return null; +} \ No newline at end of file diff --git a/v1/packages/engine/src/agent/__tests__/EngineAgent.test.ts b/v1/packages/engine/src/agent/__tests__/EngineAgent.test.ts new file mode 100644 index 0000000..589b706 --- /dev/null +++ b/v1/packages/engine/src/agent/__tests__/EngineAgent.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { Camera } from '../../render/Camera'; +import { EventBus } from '../../core/EventBus'; +import { EngineAgent } from '../EngineAgent'; +import type { AgentHost, SceneAgent, SnapshotLayer } from '../types'; + +/** + * Фейковый хост: без Pixi/канваса. stepTick двигает «время» и вызывает + * sceneAgent; renderFrame только помечается. + */ +function fakeHost(): AgentHost & { renders: number } { + let ticks = 0; + const events = new EventBus(); + return { + events, + scenes: { current: undefined, transitioning: false } as never, + input: { + getPointer: () => ({ x: 0, y: 0, down: false, justPressed: false, downTicks: 0 }) + } as never, + camera: new Camera(480, 270), + virtualWidth: 480, + virtualHeight: 270, + fixedStep: 1 / 60, + fps: 0, + get tickCount() { + return ticks; + }, + stepTick() { + return ++ticks; + }, + stepTicks(n = 1) { + for (let i = 0; i < n; i++) this.stepTick(); + return ticks; + }, + renderFrame() { + this.renders++; + }, + renders: 0 + }; +} + +function fakeScene(): SceneAgent { + return { + agentSnapshot: () => ({ area: 'test', hp: 5 }), + agentInvariants: () => [{ id: 'warn-x', severity: 'warn', message: 'подозрительно' }], + agentCommand: (name) => (name === 'ping' ? 'pong' : null) + }; +} + +describe('EngineAgent — снапшот и шаги', () => { + it('снапшот содержит движковый слой + слой сцены', () => { + const host = fakeHost(); + const agent = new EngineAgent(host, () => fakeScene()); + const s = agent.snapshot() as SnapshotLayer; + expect(s.tick).toBe(0); + expect(s.fixedStep).toBeCloseTo(1 / 60, 6); + expect(s.transitioning).toBe(false); + expect(s.camera).toBeTruthy(); + expect(s.pointer).toBeTruthy(); + expect(s.area).toBe('test'); + expect(s.hp).toBe(5); + }); + + it('без сцены снапшот всё равно валиден', () => { + const agent = new EngineAgent(fakeHost(), () => null); + const s = agent.snapshot(); + expect(s.tick).toBe(0); + expect(s.area).toBeUndefined(); + }); +}); + +describe('EngineAgent — инварианты', () => { + it('мержит движковые и сценические инварианты', () => { + const host = fakeHost(); + host.camera.x = NaN; // движковый инвариант pos-nan + const agent = new EngineAgent(host, () => fakeScene()); + const invs = agent.invariants(); + expect(invs.map((i) => i.id)).toEqual(['pos-nan', 'warn-x']); + }); +}); + +describe('EngineAgent — шаги и команды', () => { + it('step крутит ровно n тиков', () => { + const host = fakeHost(); + const agent = new EngineAgent(host, () => fakeScene()); + expect(agent.step(10).tick).toBe(10); + expect(host.tickCount).toBe(10); + }); + + it('waitFor завершается по предикату и по лимиту', async () => { + const host = fakeHost(); + const agent = new EngineAgent(host, () => fakeScene()); + const ok = await agent.waitFor((s) => (s.tick as number) >= 5, { timeoutTicks: 100 }); + expect(ok.ok).toBe(true); + expect(ok.ticks).toBe(5); + const fail = await agent.waitFor(() => false, { timeoutTicks: 3 }); + expect(fail.ok).toBe(false); + expect(fail.ticks).toBe(3); + }); + + it('command проходит в сцену, неизвестная — null', () => { + const agent = new EngineAgent(fakeHost(), () => fakeScene()); + expect(agent.command('ping')).toBe('pong'); + expect(agent.command('unknown')).toBeNull(); + }); + + it('ошибка в сцене не вылетает, а попадает в слой { error }', () => { + const bad: SceneAgent = { + agentSnapshot: () => { + throw new Error('бум'); + }, + agentInvariants: () => { + throw new Error('бум'); + } + }; + const agent = new EngineAgent(fakeHost(), () => bad); + const s = agent.snapshot() as SnapshotLayer; + expect(String(s.error)).toContain('бум'); + expect(agent.invariants()).toEqual([]); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/agent/__tests__/invariants.test.ts b/v1/packages/engine/src/agent/__tests__/invariants.test.ts new file mode 100644 index 0000000..12fa1d4 --- /dev/null +++ b/v1/packages/engine/src/agent/__tests__/invariants.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { + checkBounds, + checkFinite, + checkRange, + checkWalkable, + mergeInvariants +} from '../invariants'; + +const where = 'test'; + +describe('инварианты — хелперы', () => { + it('checkFinite находит NaN и Infinity', () => { + const invs = checkFinite({ x: 1.5, y: NaN, z: Infinity }, where); + expect(invs).toHaveLength(2); + expect(invs.every((i) => i.id === 'pos-nan' && i.severity === 'error')).toBe(true); + expect(checkFinite({ ok: 0 }, where)).toHaveLength(0); + }); + + it('checkRange ловит выход за диапазон и NaN', () => { + expect(checkRange('hp', 3, 0, 5, where)).toBeNull(); + expect(checkRange('hp', -1, 0, 5, where)?.id).toBe('range'); + expect(checkRange('hp', NaN, 0, 5, where)?.id).toBe('pos-nan'); + }); + + it('checkBounds и checkWalkable работают по тайлу', () => { + const map = { isWalkable: (x: number, y: number) => x + y > 0 }; + expect(checkBounds('герой', { x: 2, y: 3 }, 10, 10, where)).toBeNull(); + expect(checkBounds('герой', { x: -1, y: 3 }, 10, 10, where)?.id).toBe('out-of-bounds'); + expect(checkWalkable('герой', { x: 1, y: 1 }, map, where)).toBeNull(); + expect(checkWalkable('герой', { x: 0, y: 0 }, map, where)?.id).toBe('in-wall'); + }); + + it('mergeInvariants разворачивает группы и отбрасывает пустое', () => { + const a = { id: 'a', severity: 'warn', message: 'a' } as const; + const b = { id: 'b', severity: 'error', message: 'b' } as const; + expect(mergeInvariants(null, false, undefined, a, [b as never], null)).toEqual([a, b]); + expect(mergeInvariants()).toEqual([]); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/agent/invariants.ts b/v1/packages/engine/src/agent/invariants.ts new file mode 100644 index 0000000..c463ccf --- /dev/null +++ b/v1/packages/engine/src/agent/invariants.ts @@ -0,0 +1,79 @@ +import type { Invariant } from './types'; + +/** + * Чистые хелперы инвариантов: сцены и системы собирают нарушения через них, + * мост мержит и отдаёт списком. Без зависимостей — тестируется в Vitest. + */ + +function inv(id: string, severity: 'error' | 'warn', message: string, where?: string): Invariant { + return where ? { id, severity, message, where } : { id, severity, message }; +} + +/** NaN/Infinity в числовых значениях (позиции, hp и т.п.) — почти всегда сломанный мир. */ +export function checkFinite(values: Record, where: string): Invariant[] { + const out: Invariant[] = []; + for (const [name, v] of Object.entries(values)) { + if (!Number.isFinite(v)) { + out.push(inv('pos-nan', 'error', `${name} = ${v} (не конечное число)`, where)); + } + } + return out; +} + +/** Число вне диапазона (hp < 0, скорость < 0, ...). */ +export function checkRange( + name: string, + v: number, + min: number, + max: number, + where: string +): Invariant | null { + if (!Number.isFinite(v)) { + return inv('pos-nan', 'error', `${name} = ${v} (не конечное число)`, where); + } + if (v < min || v > max) { + return inv('range', 'error', `${name} = ${v} вне [${min}, ${max}]`, where); + } + return null; +} + +/** Тайл в границах карты. */ +export function checkBounds( + name: string, + tile: { x: number; y: number }, + w: number, + h: number, + where: string +): Invariant | null { + if (!Number.isInteger(tile.x) || !Number.isInteger(tile.y) || tile.x < 0 || tile.y < 0 + || tile.x >= w || tile.y >= h) { + return inv('out-of-bounds', 'error', `${name}: тайл (${tile.x},${tile.y}) вне карты ${w}×${h}`, where); + } + return null; +} + +/** Тайл проходим (герой/NPC не должны стоять в стене). */ +export function checkWalkable( + name: string, + tile: { x: number; y: number }, + map: { isWalkable(x: number, y: number): boolean }, + where: string +): Invariant | null { + if (!map.isWalkable(tile.x, tile.y)) { + return inv('in-wall', 'error', `${name} стоит в непроходимом тайле (${tile.x},${tile.y})`, where); + } + return null; +} + +/** Мерж групп: null/false/undefined отбрасываются, группы — разворачиваются. */ +export function mergeInvariants( + ...groups: (Invariant | null | undefined | false | Invariant[])[] +): Invariant[] { + const out: Invariant[] = []; + for (const g of groups) { + if (!g) continue; + if (Array.isArray(g)) out.push(...g); + else out.push(g); + } + return out; +} \ No newline at end of file diff --git a/v1/packages/engine/src/agent/types.ts b/v1/packages/engine/src/agent/types.ts new file mode 100644 index 0000000..1834f2d --- /dev/null +++ b/v1/packages/engine/src/agent/types.ts @@ -0,0 +1,76 @@ +import type { Camera } from '../render/Camera'; +import type { EventBus } from '../core/EventBus'; +import type { InputManager } from '../input/InputManager'; +import type { SceneManager } from '../scene/SceneManager'; + +/** + * Агентный мост (движковый каркас): машиночитаемый доступ к игре — + * снапшоты состояния, детерминированные шаги, инъекция ввода, инварианты. + * Игра строит контентный слой поверх каркаса (см. docs/engine/agent.md). + * Всё, что делает мост, — никогда не бросает исключений: ошибка превращается + * в слой снапшота `{ error: string }`, а не в потерю телеметрии. + */ + +export type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue }; +export type SnapshotLayer = { [k: string]: JsonValue }; + +/** Нарушенный инвариант: error — «мир сломан», warn — «подозрительно». */ +export interface Invariant { + /** Стабильный id: 'pos-nan', 'hero-in-wall', ... */ + id: string; + severity: 'error' | 'warn'; + message: string; + /** Где обнаружено: 'scene/LocationScene', 'engine/camera', ... */ + where?: string; +} + +/** + * Что сцена отдаёт мосту. Реализуется игровыми сценами; движок вызывает + * методы через фабрику `() => SceneAgent | null` (сцена может быть не готова). + */ +export interface SceneAgent { + /** Контентный слой снапшота этой сцены. */ + agentSnapshot(): SnapshotLayer; + /** Инварианты сцены (мост мержит со своими и движковыми). */ + agentInvariants(): Invariant[]; + /** Произвольные команды (перемотки/читы для проверок). null — команда неизвестна. */ + agentCommand?(name: string, args?: JsonValue): JsonValue; +} + +/** Минимальный хост, который нужен мосту (удовлетворяется Engine). */ +export interface AgentHost { + readonly scenes: SceneManager; + readonly input: InputManager; + readonly camera: Camera; + readonly events: EventBus; + /** Виртуальное разрешение (для пересчёта в CSS-пиксели в uiTap). */ + readonly virtualWidth: number; + readonly virtualHeight: number; + /** Один фиксированный шаг (без рендера); возвращает счётчик тиков. */ + stepTick(): number; + /** Серия шагов в ручном режиме (setManual — без «догоняния» rAF). */ + stepTicks(n?: number, render?: boolean): number; + renderFrame(): void; + readonly tickCount: number; + readonly fixedStep: number; + /** Реальный FPS отрисовки (окно ~0,5 с). */ + readonly fps: number; +} + +/** Движковый слой снапшота. */ +export interface EngineSnapshot extends SnapshotLayer { + tick: number; + fps: number; + fixedStep: number; + scenes: string[]; + transitioning: boolean; + camera: SnapshotLayer; + pointer: SnapshotLayer; +} + +export interface WaitForOptions { + /** Лимит шагов (по умолчанию 600 — 10 с игрового времени). */ + timeoutTicks?: number; + /** Рендерить ли каждый шаг (нужно только перед скриншотом). */ + render?: boolean; +} \ No newline at end of file diff --git a/v1/packages/engine/src/anim/SpriteAnimator.ts b/v1/packages/engine/src/anim/SpriteAnimator.ts new file mode 100644 index 0000000..41e85c2 --- /dev/null +++ b/v1/packages/engine/src/anim/SpriteAnimator.ts @@ -0,0 +1,164 @@ +/** + * Кадровый аниматор спрайта с именованными клипами: play/pause, режимы + * loop/once/pingpong, события onFrame/onFinish, рассинхрон толпы через + * randomPhase (seed). Тонкий Pixi-адаптер: математика — в anim/clip.ts, + * текстура пишется только при смене шага. Тикается вручную либо через + * engine.fx (реестр Updatables). + */ +import type { Sprite, Texture } from 'pixi.js'; +import type { Updatable } from '../core/Updatables'; +import { createRng } from '../math/rng'; +import { stepClip, type ClipLoop } from './clip'; + +export interface SpriteClip { + frames: readonly Texture[]; + /** Кадров в секунду; без значения — дефолт аниматора. */ + fps?: number; + loop?: ClipLoop; + speedScale?: number; +} + +export interface SpriteAnimatorOptions { + /** Дефолт fps для клипов без своего (8, как у FrameAnimation). */ + fps?: number; + /** Дефолтный режим для клипов без своего. */ + loop?: ClipLoop; + speedScale?: number; + /** Seed: детерминированная случайная фаза старта (рассинхрон толпы). */ + randomPhase?: number; + /** Шаг, который показывать до первого play(). */ + startStep?: number; +} + +export class SpriteAnimator implements Updatable { + readonly sprite: Sprite; + /** Вызывается при смене шага. */ + onFrame?: (step: number) => void; + /** Вызывается один раз при finished у once/pingpong-прохода. */ + onFinish?: (clip: string) => void; + + /** Самозачистка из engine.fx: спрайт разрушен — реестр отпустит запись. */ + get destroyed(): boolean { + return this.sprite.destroyed; + } + + private clips: Record; + private defaults: Required>; + private speedScale_: number; + private currentName: string | null = null; + private time = 0; + private step_ = 0; + private paused_ = false; + private finishedNotified = false; + /** Случайная фаза старта в долях шага [0,1) — из seed (без Math.random). */ + private phase = 0; + + constructor( + sprite: Sprite, + clips: Record, + current?: string, + opts: SpriteAnimatorOptions = {} + ) { + this.sprite = sprite; + this.clips = clips; + this.defaults = { fps: opts.fps ?? 8, loop: opts.loop ?? 'loop' }; + this.speedScale_ = opts.speedScale ?? 1; + if (opts.randomPhase !== undefined) this.applyRandomPhase(opts.randomPhase); + if (current !== undefined) { + this.play(current, { restart: true }); + } else { + // Стартовый шаг первого клипа — просто отрисовать, не запуская время. + const first = Object.keys(clips)[0]; + if (first !== undefined) { + this.currentName = first; + this.showStep(opts.startStep ?? 0); + } + } + } + + /** Включить клип; тот же клип без restart продолжается с текущего шага. */ + play(name: string, opts: { restart?: boolean } = {}): void { + const clip = this.clips[name]; + if (!clip) throw new Error(`клип «${name}» не найден`); + if (name !== this.currentName || opts.restart) { + this.currentName = name; + this.time = this.phase; + this.finishedNotified = false; + this.setStep(this.currentName, 0); + } + } + + pause(): void { + this.paused_ = true; + } + + resume(): void { + this.paused_ = false; + } + + get paused(): boolean { + return this.paused_; + } + + get clip(): string | null { + return this.currentName; + } + + get step(): number { + return this.step_; + } + + get speedScale(): number { + return this.speedScale_; + } + + set speedScale(v: number) { + this.speedScale_ = v; + } + + /** Заморозить на конкретном шаге текущего клипа (idle-поза без клипа). */ + showStep(step: number): void { + this.paused_ = true; + this.setStep(this.currentName, step); + } + + /** Заменить кадры клипа на лету (пересборка атласа, вариации окраса). */ + setClipFrames(name: string, frames: readonly Texture[]): void { + const clip = this.clips[name]; + if (!clip) throw new Error(`клип «${name}» не найден`); + clip.frames = frames; + if (name === this.currentName) this.setStep(name, Math.min(this.step_, Math.max(0, frames.length - 1))); + } + + update(dt: number): void { + if (this.paused_ || this.currentName === null) return; + const name = this.currentName; + const clip = this.clips[name]; + if (clip.frames.length === 0) return; + const def = { fps: clip.fps ?? this.defaults.fps, loop: clip.loop ?? this.defaults.loop, speedScale: clip.speedScale }; + const { time, tick } = stepClip(clip.frames.length, def, this.time, dt * this.speedScale_); + this.time = time; + if (tick.step !== this.step_) this.setStep(name, tick.step); + if (tick.finished && !this.finishedNotified) { + this.finishedNotified = true; + this.onFinish?.(name); + } + // pingpong/once с конечным временем: после конца время не двигается. + if (tick.finished && def.loop === 'once') this.paused_ = true; + } + + /** Случайная фаза из seed: доля периода клипа, детерминированная. */ + private applyRandomPhase(seed: number): void { + // Единый ГПСЧ движка (mulberry32): [0,1) из целого seed без Math.random. + this.phase = createRng(seed | 0 || 0x9e3779b9).next(); + } + + private setStep(name: string | null, step: number): void { + if (name === null) return; + const frames = this.clips[name].frames; + this.step_ = step; + const tex = frames[step]; + if (tex && this.sprite.texture !== tex) this.sprite.texture = tex; + this.onFrame?.(step); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/anim/SpriteMotion.ts b/v1/packages/engine/src/anim/SpriteMotion.ts new file mode 100644 index 0000000..eaa5b8d --- /dev/null +++ b/v1/packages/engine/src/anim/SpriteMotion.ts @@ -0,0 +1,75 @@ +/** + * Pixi-адаптер оживителей: применяет выборку sampleMotion к view (position + * относительно базовой при bob, rotation при sway, alpha/scale при + * pulse/blink). Базы снимаются в конструкторе; позиция/поворот + * перезаписываются только когда есть bob/sway — view с чистым blink/pulse + * можно двигать извне. Тикается через engine.fx. + */ +import type { Container } from 'pixi.js'; +import type { Updatable } from '../core/Updatables'; +import { createRng } from '../math/rng'; +import { sampleMotion, type MotionParams } from './motion'; + +export interface SpriteMotionOptions { + /** Seed — детерминированная случайная фаза (рассинхрон кустов/искр). */ + seed?: number; +} + +export class SpriteMotion implements Updatable { + /** Живые параметры: меняйте на лету (колокол качается только при звоне). */ + params: MotionParams; + + private readonly view: Container; + private readonly baseX: number; + private readonly baseY: number; + private readonly baseAlpha: number; + private readonly baseScaleX: number; + private readonly baseScaleY: number; + private t = 0; + + constructor(view: Container, params: MotionParams, opts: SpriteMotionOptions = {}) { + this.view = view; + this.params = params; + this.baseX = view.x; + this.baseY = view.y; + this.baseAlpha = view.alpha; + this.baseScaleX = view.scale.x; + this.baseScaleY = view.scale.y; + if (opts.seed !== undefined) { + // Фаза из seed: доля периода, детерминированная (без Math.random). + const rng = createRng(opts.seed); + const phase = (rng.int(0, 1000) / 1000); + this.t = phase * (params.bob?.period ?? params.sway?.period ?? params.pulse?.period ?? params.blink?.period ?? 1); + } + } + + update(dt: number): void { + this.t += dt; + const s = sampleMotion(this.params, this.t); + // Позицию/поворот трогаем только если они «живые» (bob/sway): view с + // чистым blink/pulse имеет право двигаться извне (герой, враги). + if (this.params.bob) { + this.view.x = this.baseX; + this.view.y = this.baseY + s.y; + } + if (this.params.sway) this.view.rotation = s.angle; + if (this.params.blink || this.params.pulse) this.view.alpha = this.baseAlpha * s.alpha; + if (this.params.pulse) this.view.scale.set(this.baseScaleX * s.scale, this.baseScaleY * s.scale); + } + + /** Вернуть исходные position/rotation/alpha/scale (при выходе из сцены). */ + detach(): void { + if (this.params.bob) { + this.view.x = this.baseX; + this.view.y = this.baseY; + } + if (this.params.sway) this.view.rotation = 0; + if (this.params.blink || this.params.pulse) this.view.alpha = this.baseAlpha; + if (this.params.pulse) this.view.scale.set(this.baseScaleX, this.baseScaleY); + } + + /** Самозачистка из engine.fx: view разрушен — реестр отпустит запись. */ + get destroyed(): boolean { + return this.view.destroyed; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/anim/__tests__/SpriteAnimator.test.ts b/v1/packages/engine/src/anim/__tests__/SpriteAnimator.test.ts new file mode 100644 index 0000000..066658b --- /dev/null +++ b/v1/packages/engine/src/anim/__tests__/SpriteAnimator.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest'; + +// SpriteAnimator пишет texture в Sprite — минимальный стаб. +function makeSprite() { + return { texture: null as unknown, destroyed: false } as unknown as import('pixi.js').Sprite; +} + +function frames(n: number): import('pixi.js').Texture[] { + return Array.from({ length: n }, (_, i) => ({ idx: i }) as unknown as import('pixi.js').Texture); +} + +import { SpriteAnimator } from '../SpriteAnimator'; + +describe('SpriteAnimator', () => { + it('конструктор без клипа: показывает стартовый шаг, время стоит', () => { + const sprite = makeSprite(); + const a = new SpriteAnimator(sprite, { idle: { frames: frames(4) } }, undefined, { startStep: 2 }); + expect(a.clip).toBe('idle'); + expect(a.step).toBe(2); + expect(a.paused).toBe(true); + a.update(1); + expect(a.step).toBe(2); // заморожен + }); + + it('loop: шаги идут по fps, по кругу', () => { + const sprite = makeSprite(); + const a = new SpriteAnimator(sprite, { walk: { frames: frames(3), fps: 10 } }, 'walk'); + expect(a.step).toBe(0); + a.update(0.1); // 1 шаг при 10 fps + expect(a.step).toBe(1); + a.update(0.2); // ещё 2 — на конец + expect(a.step).toBe(0); // 3 % 3 + }); + + it('тот же клип без restart продолжается; restart начинает с нуля', () => { + const sprite = makeSprite(); + const a = new SpriteAnimator(sprite, { walk: { frames: frames(4), fps: 10 } }, 'walk'); + a.update(0.15); + expect(a.step).toBe(1); + a.play('walk'); + expect(a.step).toBe(1); // продолжение + a.play('walk', { restart: true }); + expect(a.step).toBe(0); + }); + + it('once: доигрывает до последнего шага, paused, onFinish один раз', () => { + const sprite = makeSprite(); + const finished: string[] = []; + const a = new SpriteAnimator(sprite, { hit: { frames: frames(3), fps: 10, loop: 'once' } }, 'hit'); + a.onFinish = (c) => finished.push(c); + for (let i = 0; i < 10; i++) a.update(0.1); // 1 с — клип 0.3 с + expect(a.step).toBe(2); + expect(a.paused).toBe(true); + expect(finished).toEqual(['hit']); + }); + + it('большой dt проматывает промежуточные шаги (сообщается финальный)', () => { + const sprite = makeSprite(); + const seen: number[] = []; + const a = new SpriteAnimator(sprite, { walk: { frames: frames(8), fps: 10 } }, 'walk'); + a.onFrame = (s) => seen.push(s); + a.update(1); // 10 шагов за тик + expect(a.step).toBe(2); // 10 % 8 + expect(seen).toEqual([2]); // промежуточные не долетели + }); + + it('randomPhase: одинаковый seed — одинаковая фаза (шаги совпадают)', () => { + const mk = (seed: number) => + new SpriteAnimator(makeSprite(), { walk: { frames: frames(3) } }, undefined, { randomPhase: seed }); + const a = mk(1234); + const b = mk(1234); + // Фаза приватна, но детерминизм виден через play + апдейты: + // time = phase, поэтому при одинаковых апдейтах шаги совпадают. + a.play('walk'); + b.play('walk'); + a.update(0.05); + b.update(0.05); + expect(a.step).toBe(b.step); + a.update(0.3); + b.update(0.3); + expect(a.step).toBe(b.step); + }); + + it('setClipFrames: кадры меняются на лету, шаг клэмпится', () => { + const sprite = makeSprite(); + const a = new SpriteAnimator(sprite, { walk: { frames: frames(4), fps: 10 } }, 'walk'); + a.update(0.3); // шаг 3 + expect(a.step).toBe(3); + a.setClipFrames('walk', frames(2)); + expect(a.step).toBe(1); // клэмп к 2-1 + }); + + it('несуществующий клип — ошибка', () => { + const a = new SpriteAnimator(makeSprite(), { walk: { frames: frames(2) } }); + expect(() => a.play('fly')).toThrow('fly'); + }); + + it('destroyed спрайта пробрасывается (самозачистка из engine.fx)', () => { + const sprite = makeSprite(); + const a = new SpriteAnimator(sprite, { walk: { frames: frames(2) } }); + expect(a.destroyed).toBe(false); + sprite.destroyed = true; + expect(a.destroyed).toBe(true); + }); + + it('speedScale замедляет/ускоряет клип', () => { + const a = new SpriteAnimator(makeSprite(), { walk: { frames: frames(10), fps: 10 } }, 'walk', { speedScale: 0.5 }); + a.update(0.1); + expect(a.step).toBe(0); // полшага — не дошёл + a.update(0.1); + expect(a.step).toBe(1); + }); + + it('пустой набор клипов допустим (без падений)', () => { + const a = new SpriteAnimator(makeSprite(), {}); + expect(a.clip).toBeNull(); + expect(() => a.update(0.1)).not.toThrow(); + }); + + it('onFrame получает номер шага', () => { + const onFrame = vi.fn(); + const a = new SpriteAnimator(makeSprite(), { walk: { frames: frames(2), fps: 10 } }, 'walk'); + a.onFrame = onFrame; + a.update(0.1); + expect(onFrame).toHaveBeenCalledWith(1); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/anim/__tests__/SpriteMotion.test.ts b/v1/packages/engine/src/anim/__tests__/SpriteMotion.test.ts new file mode 100644 index 0000000..20a89ba --- /dev/null +++ b/v1/packages/engine/src/anim/__tests__/SpriteMotion.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { SpriteMotion } from '../SpriteMotion'; + +/** Минимальный стенд под интерфейс Container, который трогает SpriteMotion. */ +function fakeView(x = 10, y = 20) { + const v = { + x, + y, + rotation: 0, + alpha: 1, + scale: { _sx: 1, _sy: 1, set(sx: number, sy: number) { this._sx = sx; this._sy = sy; }, + get x() { return this._sx; }, get y() { return this._sy; } }, + destroyed: false + }; + return v; +} + +describe('SpriteMotion', () => { + it('blink не трогает позицию: движущийся view сохраняет внешнее перемещение', () => { + const v = fakeView(5, 7); + const m = new SpriteMotion(v as never, { blink: { period: 0.25, duty: 1 } }); + v.x = 100; // извне (синк вью героя) + v.y = 50; + m.update(0.1); + m.update(0.1); + expect(v.x).toBe(100); + expect(v.y).toBe(50); + }); + + it('bob смещает Y относительно базы, X держит; detach возвращает базу', () => { + const v = fakeView(10, 20); + const m = new SpriteMotion(v as never, { bob: { amplitude: 4, period: 1 } }); + m.update(0.25); // четверть периода — максимальное смещение + expect(v.x).toBe(10); + expect(v.y).not.toBe(20); + m.detach(); + expect(v.x).toBe(10); + expect(v.y).toBe(20); + }); + + it('pulse меняет alpha и scale, но не позицию', () => { + const v = fakeView(); + const m = new SpriteMotion(v as never, { pulse: { min: 0.5, max: 1, period: 1 } }); + m.update(0.25); + expect(v.alpha).not.toBe(1); + expect(v.x).toBe(10); + expect(v.y).toBe(20); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/anim/__tests__/clip.test.ts b/v1/packages/engine/src/anim/__tests__/clip.test.ts new file mode 100644 index 0000000..4183987 --- /dev/null +++ b/v1/packages/engine/src/anim/__tests__/clip.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { stepClip, type ClipDef } from '../clip'; + +const loop: ClipDef = { fps: 10, loop: 'loop' }; +const once: ClipDef = { fps: 10, loop: 'once' }; +const ping: ClipDef = { fps: 10, loop: 'pingpong' }; + +describe('stepClip (время в шагах: fps=10 → 1 сек = 10 шагов)', () => { + it('loop: шаги идут по кругу, time не ограничено', () => { + let time = 0; + const steps: number[] = []; + for (let i = 0; i < 25; i++) { + const r = stepClip(4, loop, time, 0.1); + time = r.time; + steps.push(r.tick.step); + } + expect(steps).toEqual([1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1]); + expect(time).toBeCloseTo(25, 6); // время в шагах: 25 × (0.1 с × 10 fps) + }); + + it('loop: отрицательный dt ходит назад без ухода в минус', () => { + const r = stepClip(4, loop, 0, -0.1); + expect(r.tick.step).toBe(3); + }); + + it('once: стоп на последнем кадре, finished один раз', () => { + let time = 0; + const finished: boolean[] = []; + for (let i = 0; i < 5; i++) { + const r = stepClip(3, once, time, 0.1); + time = r.time; + finished.push(r.tick.finished); + } + // 3 кадра → конец после 2-го продвижения; дальше шаг держится на 2. + expect(finished).toEqual([false, true, false, false, false]); + let last: number | null = null; + for (let i = 0; i < 3; i++) last = stepClip(3, once, time, 0.1).tick.step; + expect(last).toBe(2); + }); + + it('pingpong: туда-обратно без дубля крайних кадров', () => { + let time = 0; + const steps: number[] = []; + for (let i = 0; i < 8; i++) { + const r = stepClip(3, ping, time, 0.1); + time = r.time; + steps.push(r.tick.step); + } + // Период 2*(3-1)=4: 0,1,2,1 | 0,1,2,1 + expect(steps).toEqual([1, 2, 1, 0, 1, 2, 1, 0]); + }); + + it('speedScale замедляет и ускоряет клип', () => { + // 0.1 сек при fps 10 = 1 шаг; с speedScale 0.5 — полшага. + expect(stepClip(4, { ...loop, speedScale: 0.5 }, 0, 0.1).tick.step).toBe(0); + expect(stepClip(4, { ...loop, speedScale: 2 }, 0, 0.1).tick.step).toBe(2); + }); + + it('dt больше периода: сообщается только финальный шаг', () => { + const r = stepClip(4, loop, 0, 10); // 10 сек = 100 шагов вперёд + expect(r.tick.step).toBe(0); // 100 % 4 = 0 + expect(r.tick.prevStep).toBe(0); + }); + + it('prevStep отражает шаг до продвижения', () => { + const r = stepClip(4, loop, 1.05, 0.1); + expect(r.tick.prevStep).toBe(1); + expect(r.tick.step).toBe(2); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/anim/__tests__/flash.test.ts b/v1/packages/engine/src/anim/__tests__/flash.test.ts new file mode 100644 index 0000000..0fe5f2d --- /dev/null +++ b/v1/packages/engine/src/anim/__tests__/flash.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { TintFlash, lerpColor } from '../flash'; + +describe('TintFlash', () => { + it('не активен до start', () => { + const f = new TintFlash(0xffffff, 0xff0000, 0.2); + expect(f.active).toBe(false); + expect(f.update(0.1)).toBeNull(); + }); + + it('в начале — исходный цвет, в середине — ближе к вспышке', () => { + const f = new TintFlash(0xffffff, 0xff0000, 0.2); + f.start(); + expect(f.update(0)).toBe(0xffffff); + const mid = f.update(0.1); // середина: максимум вспышки + expect(mid).toBe(0xff0000); + }); + + it('к концу возвращается к исходному, затем null', () => { + const f = new TintFlash(0xffffff, 0xff0000, 0.2); + f.start(); + f.update(0.19); + const end = f.update(0.05); // пересекли границу + expect(end).toBe(0xffffff); + expect(f.active).toBe(false); + expect(f.update(0.05)).toBeNull(); + }); + + it('повторный запуск работает', () => { + const f = new TintFlash(0xffffff, 0x00ff00, 0.1); + f.start(); + f.update(0.2); // завершён + f.start(); + expect(f.active).toBe(true); + expect(f.update(0)).toBe(0xffffff); + }); +}); + +describe('lerpColor', () => { + it('края и середина', () => { + expect(lerpColor(0x000000, 0xffffff, 0)).toBe(0x000000); + expect(lerpColor(0x000000, 0xffffff, 1)).toBe(0xffffff); + expect(lerpColor(0x000000, 0xffffff, 0.5)).toBe(0x808080); + }); + + it('по каналам, не по всему числу', () => { + // от красного к синему: середина — фиолетовый (0x800080), а не «серый мусор» + expect(lerpColor(0xff0000, 0x0000ff, 0.5)).toBe(0x800080); + expect(lerpColor(0x102030, 0x102030, 0.7)).toBe(0x102030); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/anim/__tests__/motion.test.ts b/v1/packages/engine/src/anim/__tests__/motion.test.ts new file mode 100644 index 0000000..c757e8f --- /dev/null +++ b/v1/packages/engine/src/anim/__tests__/motion.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { sampleMotion } from '../motion'; + +describe('sampleMotion', () => { + it('bob: синус по Y, округлён до целого px', () => { + const p = { bob: { amplitude: 2, period: 1 } }; + expect(sampleMotion(p, 0).y).toBe(0); // 0 + expect(sampleMotion(p, 0.25).y).toBe(2); // пик + expect(sampleMotion(p, 0.75).y).toBe(-2); // низ + // Между узлами — целые: субпиксельного дрожания нет + const mid = sampleMotion(p, 0.05).y; + expect(Number.isInteger(mid)).toBe(true); + }); + + it('sway: угол в рад, амплитуда соблюдена', () => { + const p = { sway: { amplitude: 0.1, period: 2 } }; + expect(sampleMotion(p, 0.5).angle).toBeCloseTo(0.1, 6); // четверть периода = пик + expect(sampleMotion(p, 1.5).angle).toBeCloseTo(-0.1, 6); + expect(sampleMotion(p, 0).angle).toBeCloseTo(0, 6); + }); + + it('pulse: границы min/max, треугольная волна', () => { + const p = { pulse: { min: 0.4, max: 1, period: 2 } }; + expect(sampleMotion(p, 0).alpha).toBeCloseTo(0.4); // старт с минимума + expect(sampleMotion(p, 1).alpha).toBeCloseTo(1); // середина = max + expect(sampleMotion(p, 2).alpha).toBeCloseTo(0.4); // период замкнулся + expect(sampleMotion(p, 1).scale).toBeCloseTo(1); + }); + + it('blink: строб 0/1 с duty', () => { + const p = { blink: { period: 1, duty: 0.25 } }; + expect(sampleMotion(p, 0).alpha).toBe(1); + expect(sampleMotion(p, 0.2).alpha).toBe(1); // внутри duty + expect(sampleMotion(p, 0.5).alpha).toBe(0); // вне duty + expect(sampleMotion(p, 0.95).alpha).toBe(0); + }); + + it('композиция: bob+sway+pulse+blink независимы', () => { + const p = { + bob: { amplitude: 2, period: 1 }, + sway: { amplitude: 0.1, period: 1 }, + pulse: { min: 0.5, max: 1, period: 1 }, + blink: { period: 1, duty: 1 } // всегда включён + }; + const s = sampleMotion(p, 0.25); + expect(s.y).toBe(2); + expect(s.angle).toBeCloseTo(0.1, 6); + expect(s.alpha).toBeCloseTo(0.75, 6); // pulse: tri(0.25)=0.5 → k=0.75, blink=1 + }); + + it('phase сдвигает волну (доля периода)', () => { + const p = { bob: { amplitude: 2, period: 1, phase: 0.25 } }; + expect(sampleMotion(p, 0).y).toBe(2); // при t=0 уже пик + }); + + it('пустые параметры — нейтральная выборка', () => { + const s = sampleMotion({}, 5); + expect(s).toEqual({ y: 0, angle: 0, alpha: 1, scale: 1 }); + }); + + it('волна замкнута на границе периода (без скачка)', () => { + const p = { bob: { amplitude: 3, period: 2 } }; + expect(sampleMotion(p, 0).y).toBe(sampleMotion(p, 2).y); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/anim/clip.ts b/v1/packages/engine/src/anim/clip.ts new file mode 100644 index 0000000..dba30c9 --- /dev/null +++ b/v1/packages/engine/src/anim/clip.ts @@ -0,0 +1,60 @@ +/** + * Чистая математика покадровых клипов (без Pixi): время в «шагах», + * режимы loop/once/pingpong, события смены шага и завершения. + * Тикается от фиксированного шага движка; dt больше периода осознанно + * «проматывает» промежуточные шаги (сообщается только финальный) — + * по аналогии с пересечением границы delay в TweenManager. + */ + +/** Режим клипа: loop — цикл; once — один раз и стоп на последнем кадре; + * pingpong — цикл туда-обратно (крайние кадры без дубля). */ +export type ClipLoop = 'loop' | 'once' | 'pingpong'; + +export interface ClipDef { + /** Кадров в секунду. */ + fps: number; + loop?: ClipLoop; + /** Множитель скорости клипа (перемножается со speedScale аниматора). */ + speedScale?: number; +} + +export interface ClipTick { + /** Индекс шага после продвижения (для once не превышает len-1). */ + step: number; + /** Индекс шага до продвижения. */ + prevStep: number; + /** Клип дошёл до конца (once) — ровно один раз при пересечении конца. */ + finished: boolean; +} + +/** + * Продвинуть время клипа (сек) на dt и посчитать новый шаг. + * Время накапливается в шагах: time += dt * fps * speedScale. + */ +export function stepClip( + len: number, + def: ClipDef, + prevTime: number, + dt: number +): { time: number; tick: ClipTick } { + const scale = def.speedScale ?? 1; + const time = prevTime + dt * def.fps * scale; + const prevStep = stepAt(len, def.loop ?? 'loop', prevTime); + const step = stepAt(len, def.loop ?? 'loop', time); + // finished — только для once и только в тик, когда конец был пересечён. + const finished = def.loop !== 'loop' && time >= len - 1 && prevTime < len - 1; + return { time, tick: { step, prevStep, finished } }; +} + +/** Индекс шага в момент time (в шагах) для заданного режима. */ +function stepAt(len: number, loop: ClipLoop, time: number): number { + if (loop === 'once') return Math.min(Math.max(0, Math.floor(time)), len - 1); + if (loop === 'pingpong') { + // Период 2*(len-1): туда 0..len-1, обратно len-1..0; крайние кадры без дубля. + const period = Math.max(1, 2 * (len - 1)); + const m = ((Math.floor(time) % period) + period) % period; + return m < len ? m : period - m; + } + const m = ((Math.floor(time) % len) + len) % len; + return m; +} \ No newline at end of file diff --git a/v1/packages/engine/src/anim/flash.ts b/v1/packages/engine/src/anim/flash.ts new file mode 100644 index 0000000..ac67052 --- /dev/null +++ b/v1/packages/engine/src/anim/flash.ts @@ -0,0 +1,68 @@ +/** + * Хит-флэш: лерп цвета tint от исходного к «вспышке» и обратно. + * Чистая логика (без Pixi) — результат применимо к любому числовому свойству. + */ + +export class TintFlash { + private elapsed: number | null = null; + /** tint на момент запуска (возвращается к нему по окончании). */ + private base: number; + + /** + * @param from исходный tint (например 0xffffff) + * @param to цвет вспышки + * @param seconds полная длительность (туда-обратно) + */ + constructor( + from: number, + private readonly to: number, + private readonly seconds: number + ) { + this.base = from; + } + + /** Запустить (перезапускается с нуля). */ + start(): void { + this.elapsed = 0; + } + + /** + * Обновить; возвращает текущий tint или null, если флэш не активен. + * При окончании возвращает исходный цвет. + */ + update(dt: number): number | null { + if (this.elapsed === null) return null; + this.elapsed += dt; + if (this.elapsed >= this.seconds) { + this.elapsed = null; + return this.base; + } + // треугольная волна: 0..1..0 + const t = 1 - Math.abs(this.elapsed / this.seconds - 0.5) * 2; + return lerpColor(this.base, this.to, t); + } + + get active(): boolean { + return this.elapsed !== null; + } + + /** Немедленно завершить; возвращает базовый цвет (вызывающий применяет сам). */ + finish(): number { + this.elapsed = null; + return this.base; + } +} + +/** Лерп двух цветов 0xRRGGBB по каналам. */ +export function lerpColor(a: number, b: number, t: number): number { + const ar = (a >> 16) & 0xff; + const ag = (a >> 8) & 0xff; + const ab = a & 0xff; + const br = (b >> 16) & 0xff; + const bg = (b >> 8) & 0xff; + const bb = b & 0xff; + const r = Math.round(ar + (br - ar) * t); + const g = Math.round(ag + (bg - ag) * t); + const bl = Math.round(ab + (bb - ab) * t); + return (r << 16) | (g << 8) | bl; +} \ No newline at end of file diff --git a/v1/packages/engine/src/anim/motion.ts b/v1/packages/engine/src/anim/motion.ts new file mode 100644 index 0000000..085e6ad --- /dev/null +++ b/v1/packages/engine/src/anim/motion.ts @@ -0,0 +1,92 @@ +/** + * Процедурные «оживители» статичных объектов — чистая математика без Pixi: + * синусные волны, композиция до четырёх одновременно. Фазы — в долях + * периода; случайная фаза берётся из seed снаружи (детерминизм). + */ + +export interface BobParams { + /** Амплитуда, px (округляется до целого — пиксель-арт не дрожит). */ + amplitude: number; + /** Полный период, сек. */ + period: number; + /** Фаза, доли периода (0..1). */ + phase?: number; +} + +export interface SwayParams { + /** Амплитуда, рад (вокруг основания view). */ + amplitude: number; + period: number; + phase?: number; +} + +export interface PulseParams { + /** Нижняя и верхняя граница пульса (alpha или scale). */ + min: number; + max: number; + period: number; + phase?: number; +} + +export interface BlinkParams { + period: number; + /** Доля периода в «включённом» состоянии (0..1), по умолчанию 0.5. */ + duty?: number; + phase?: number; +} + +export interface MotionParams { + bob?: BobParams; + sway?: SwayParams; + pulse?: PulseParams; + blink?: BlinkParams; +} + +/** Выборка оживителя в момент t (сек) относительно базового состояния. */ +export interface MotionSample { + /** Смещение по Y, px (целое). */ + y: number; + /** Поворот, рад. */ + angle: number; + /** Множитель альфы (произведение пульса и строба). */ + alpha: number; + /** Множитель масштаба. */ + scale: number; +} + +const TAU = Math.PI * 2; + +/** Фаза волны 0..1 в момент t для периода period и стартовой фазы phase. */ +function wave(t: number, period: number, phase = 0): number { + return ((t / period + phase) % 1 + 1) % 1; +} + +/** Выборка всех оживителей в момент t; параметры независимы, эффекты перемножаются. */ +export function sampleMotion(p: MotionParams, t: number): MotionSample { + let y = 0; + let angle = 0; + let alpha = 1; + let scale = 1; + if (p.bob) { + const w = wave(t, p.bob.period, p.bob.phase); + // Синус фазы 0..1 → -1..1; округление до целого px. + y = Math.round(Math.sin(w * TAU) * p.bob.amplitude); + } + if (p.sway) { + const w = wave(t, p.sway.period, p.sway.phase); + angle = Math.sin(w * TAU) * p.sway.amplitude; + } + if (p.pulse) { + const w = wave(t, p.pulse.period, p.pulse.phase); + // Треугольная волна 0..1..0 — пульс без резких скачков фазы. + const tri = 1 - Math.abs(w * 2 - 1); + const k = p.pulse.min + (p.pulse.max - p.pulse.min) * tri; + alpha *= k; + scale *= k; + } + if (p.blink) { + const w = wave(t, p.blink.period, p.blink.phase); + alpha *= w < (p.blink.duty ?? 0.5) ? 1 : 0; + } + return { y, angle, alpha, scale }; +} \ No newline at end of file diff --git a/v1/packages/engine/src/assets/AssetLoader.ts b/v1/packages/engine/src/assets/AssetLoader.ts new file mode 100644 index 0000000..cad6f93 --- /dev/null +++ b/v1/packages/engine/src/assets/AssetLoader.ts @@ -0,0 +1,115 @@ +import { Assets, Spritesheet, Texture } from 'pixi.js'; + +/** + * Загрузчик ассетов поверх Assets из PixiJS: группы, прогресс, готовые Texture. + * URL ассетов передаётся функцией-резолвером (приложение решает, где лежат файлы). + * Атласы (Spritesheet JSON + PNG) загружаются тем же механизмом: loadAtlas(). + */ +export class AssetLoader { + private textures = new Map(); + private atlases = new Map(); + private initPromise: Promise | null = null; + + constructor(private resolveUrl: (key: string) => string) {} + + /** Assets требует однократной инициализации до первой загрузки. */ + private ensureInit(): Promise { + if (!this.initPromise) { + this.initPromise = Assets.init({}); + } + return this.initPromise; + } + + /** Загрузить группу ключей параллельно; onProgress получает долю 0..1. */ + async load(keys: string[], onProgress?: (p: number) => void): Promise { + await this.ensureInit(); + const urls = keys.map((key) => this.resolveUrl(key)); + let loaded: Record; + try { + loaded = await Assets.load(urls, onProgress); + } catch (err) { + throw new Error(`Не удалось загрузить ассеты (${urls.join(', ')}): ${String(err)}`, { cause: err }); + } + keys.forEach((key, i) => { + const tex = loaded[urls[i]!]; + if (!tex) throw new Error(`Не удалось загрузить ${urls[i]}: нет результата по URL`); + // Пиксель-арт мира: nearest — чёткие квадратные пиксели на любом масштабе. + tex.source.scaleMode = 'nearest'; + this.textures.set(key, tex); + }); + } + + /** + * Загрузить атлас (Spritesheet JSON). JSON должен ссылаться на PNG + * относительным путём — Pixi подгрузит картинку сам. + * Ключ резолвится как есть: приложение должно дать URL на .json + * (например, ключ 'chars/hero_sheet.json'). + */ + async loadAtlas(key: string): Promise { + await this.ensureInit(); + const existing = this.atlases.get(key); + if (existing) return existing; + const sheet = await Assets.load(this.resolveUrl(key)); + if (!(sheet instanceof Spritesheet) || !sheet.textures) { + throw new Error(`Атлас ${key} не распознан как Spritesheet (проверьте URL и формат JSON)`); + } + // Кадры атласа — тоже nearest (общий источник, но пройдёмся по всем на всякий случай). + for (const tex of Object.values(sheet.textures)) { + tex.source.scaleMode = 'nearest'; + } + this.atlases.set(key, sheet); + return sheet; + } + + /** + * Кадры анимации из загруженного атласа: все кадры, чьё имя начинается + * с prefix, отсортированные по числу в конце имени (hero_walk_1, hero_walk_2, ...). + */ + frames(key: string, prefix: string): Texture[] { + const sheet = this.atlases.get(key); + if (!sheet) throw new Error(`Атлас не загружен: ${key}`); + const entries = Object.keys(sheet.textures) + .filter((name) => name.startsWith(prefix)) + .sort((a, b) => { + const na = Number(a.match(/(\d+)$/)?.[1] ?? -1); + const nb = Number(b.match(/(\d+)$/)?.[1] ?? -1); + return na - nb; + }); + if (entries.length === 0) { + throw new Error(`В атласе ${key} нет кадров с префиксом ${prefix}`); + } + return entries.map((name) => sheet.textures[name]!); + } + + /** Готовая анимация из sheet.animations (если генератор заполняет animations). */ + animation(key: string, name: string): Texture[] { + const sheet = this.atlases.get(key); + if (!sheet) throw new Error(`Атлас не загружен: ${key}`); + const frames = sheet.animations[name]; + if (!frames) throw new Error(`В атласе ${key} нет анимации ${name}`); + return frames; + } + + /** + * Размер кадра атласа из JSON (sourceSize: номинал ячейки до обрезки). + * Незнание имени кадра — размер первого кадра атласа. + */ + frameSize(key: string, frameName?: string): { w: number; h: number } { + const sheet = this.atlases.get(key); + if (!sheet) throw new Error(`Атлас не загружен: ${key}`); + const name = frameName ?? Object.keys(sheet.data.frames)[0]; + const frame = sheet.data.frames[name]; + if (!frame?.sourceSize) throw new Error(`В атласе ${key} нет кадра ${name} (или без sourceSize)`); + return { w: frame.sourceSize.w, h: frame.sourceSize.h }; + } + + 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/v1/packages/engine/src/audio/AudioManager.ts b/v1/packages/engine/src/audio/AudioManager.ts new file mode 100644 index 0000000..031e585 --- /dev/null +++ b/v1/packages/engine/src/audio/AudioManager.ts @@ -0,0 +1,292 @@ +/** + * WebAudio с четырьмя шинами (master/music/sfx/ambience): публичный фасад. + * Полифония sfx — Voices, лупы/кроссфейд — Loops, кэш буферов — BufferCache. + * Игры подключают Settings.onChange и вызывают setBusVolume. + */ +import { BufferCache } from './decode'; +import { LoopSlots } from './Loops'; +import { renderSpec, RATE, type SoundSpec } from './SoundSpec'; +import { SfxVoices } from './Voices'; +import { + DEFAULT_FADE, + type AudioBuses, + type BusName, + type LoopOptions, + type MusicHandle, + type PlayOptions, + type SfxHandle +} from './types'; + +export { type AudioBuses, type BusName, type MusicHandle, type PlayOptions, type SfxHandle } from './types'; + +export class AudioManager { + private ctx: AudioContext | null = null; + private buses: AudioBuses | null = null; + /** Буферы спек-синтеза (playSpec): компиляция один раз на ключ. */ + private specBuffers = new Map(); + private cache = new BufferCache(); + private voices: SfxVoices; + private loops = new LoopSlots(); + /** Слушатель visibility (снимается в dispose). */ + private onVisibility: (() => void) | null = null; + + /** + * Шпионский хук: вызывается при каждом фактическом запуске звука — + * и разового sfx, и лупов (музыка/амбиент/слои; rate 1, pan 0). + * Для тестов и DEV-лога игры. + */ + onPlayed?: (key: string, opts: PlayOptions) => void; + + /** Звук доиграл до конца (stop/вор/новый restart — не считаются). */ + onEnded?: (key: string) => void; + + constructor( + private resolveUrl: (key: string) => string, + private ctxFactory: () => AudioContext = () => new AudioContext(), + { maxVoices = 24, pauseOnHide = true }: { maxVoices?: number; pauseOnHide?: boolean } = {} + ) { + this.voices = new SfxVoices(maxVoices); + this.voices.onEnded = (key) => this.onEnded?.(key); + // Свернул таб — тишина, вернулся — звук сам (сверху не нужен жест, мы + // уже разблокированы); движку без DOM (тесты) listener не ставится. + if (pauseOnHide && typeof document !== 'undefined') { + this.onVisibility = () => { + if (document.hidden) { + void this.ctx?.suspend(); + } else if (this.ctx?.state === 'suspended') { + void this.ctx.resume(); + } + }; + document.addEventListener('visibilitychange', this.onVisibility); + } + } + + /** Создать контекст и шины синхронно (без resume) — для prepare(). */ + private createCtx(): void { + if (this.ctx) return; + this.ctx = this.ctxFactory(); + const master = this.ctx.createGain(); + const music = this.ctx.createGain(); + const sfx = this.ctx.createGain(); + const ambience = this.ctx.createGain(); + music.connect(master); + sfx.connect(master); + ambience.connect(master); + master.connect(this.ctx.destination); + this.buses = { master, music, sfx, ambience }; + } + + /** + * Синхронно завести контекст без resume: после него createBuffer работает, + * а первый play доведёт разблокировку до конца (resume — асинхронный). + * Нужно генеративным лупам (темы), которые компилируются вне async-цепочки. + */ + prepare(): void { + this.createCtx(); + } + + /** Расблокировать аудио — вызвать из обработчика пользовательского ввода. */ + async unlock(): Promise { + this.createCtx(); + if (this.ctx?.state === 'suspended') { + await this.ctx.resume(); + } + } + + /** Громкость шины 0..1 (до unlock шин ещё нет — вызов игнорируется). */ + setBusVolume(bus: BusName, volume: number): void { + if (this.buses) { + this.buses[bus].gain.value = Math.max(0, Math.min(1, volume)); + } + } + + /** Зарегистрировать URL ключа (файл читается при play/preload). */ + register(key: string): void { + this.cache.register(key, this.resolveUrl(key)); + } + + /** + * Декодировать заранее (после unlock, например в сцене загрузки), + * чтобы первый play не тратил время на fetch+decode. + */ + async preload(key: string): Promise { + const ctx = await this.ensureContext(); + if (!ctx) return; + await this.cache.decode(key, ctx); + } + + /** + * Проиграть sfx и вернуть хендл играющего голоса (null — не декодировался + * или контекст не готов; хендл «мёртв» после stop и естественного конца). + * opts — число (громкость, обратная совместимость) или PlayOptions. + */ + async play(key: string, opts?: number | PlayOptions): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + const buf = await this.cache.decode(key, ctx); + if (!buf) return null; + return this.startSfx(ctx, key, buf, opts); + } + + /** + * Проиграть буфер из памяти как sfx (процедурные звуки в рантайме). Ключ — + * логическое имя голоса (для restart, полифонии и шпионов), не файл. + */ + async playBuffer(key: string, buffer: AudioBuffer, opts?: number | PlayOptions): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + return this.startSfx(ctx, key, buffer, opts); + } + + /** + * Буфер из FLOAT-сэмплов (для процедурных звуков в рантайме): моно + * (channels 1) или interleaved L,R (channels 2 — стерео-рендеры движка). + * До unlock контекста нет — null; создать буфер по ключу не получится, + * вызывать после первого play/unlock. + */ + createBuffer(data: Float32Array, sampleRate = 22050, channels: 1 | 2 = 1): AudioBuffer | null { + if (!this.ctx) return null; + const frames = Math.floor(data.length / channels); + const buf = this.ctx.createBuffer(channels, frames, sampleRate); + // DOM-тип требует Float32Array — сэмплы из генераторов не обязаны. + if (channels === 1) { + buf.copyToChannel(data as Float32Array, 0); + } else { + const l = new Float32Array(frames); + const r = new Float32Array(frames); + for (let i = 0; i < frames; i++) { + l[i] = data[i * 2]!; + r[i] = data[i * 2 + 1]!; + } + buf.copyToChannel(l as Float32Array, 0); + buf.copyToChannel(r as Float32Array, 1); + } + return buf; + } + + /** + * Синтез по спеку (см. SoundSpec): детерминированно компилирует описание + * звука в буфер (кэш по ключу) и играет как sfx. Для ИИ-агента: описывает + * звук параметрами, без файлов; факт запуска виден в шпионе onPlayed. + */ + async playSpec(key: string, spec: SoundSpec, opts?: number | PlayOptions): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + let buf = this.specBuffers.get(key); + if (!buf) { + const created = this.createBuffer(renderSpec(spec), RATE); + if (!created) return null; + buf = created; + this.specBuffers.set(key, buf); + } + return this.startSfx(ctx, key, buf, opts); + } + + /** + * Длительность буфера в секундах (после preload/декода); не декодирован — + * null. Для катсцен и генераторов, синхронизирующихся со звуком. + */ + duration(key: string): number | null { + return this.cache.cached(key)?.duration ?? null; + } + + /** + * Играть луп в заданной шине; НЕ занимает ни слот музыки, ни слот амбиента — + * база для локальных амбиент-слоёв (несколько параллельных лупов). + */ + async playLoop(key: string, opts: LoopOptions = {}): Promise { + return this.playLoopFrom(key, opts); + } + + /** + * Луп из буфера в памяти (процедурная музыка: renderMusic → буфер → стем + * на шине). Ключ — логическое имя слота (для шпионов), не файл. + */ + async playLoopBuffer(key: string, buffer: AudioBuffer, opts: LoopOptions = {}): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + const full = this.loopOpts(opts); + const slot = this.loops.startSlot(key, ctx, this.buses, buffer, full, this.played()); + return this.loops.makeHandle(slot, full.fade); + } + + /** + * Играть музыку с кроссфейдом: текущий трек затухает, новый нарастает + * за fadeSeconds. Слот один — повторный вызов вытесняет прежний трек. + */ + async playMusic(key: string, opts: Omit = {}): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + const buf = await this.cache.decode(key, ctx); + if (!buf) return null; // негодный ключ не должен заглушить текущий трек + const full = this.loopOpts(opts); + const slot = this.loops.swapMusic(key, ctx, this.buses, buf, full, this.played()); + return this.loops.makeHandle(slot, full.fade); + } + + /** Остановить музыку (с затуханием). */ + stopMusic(fadeSeconds = 0.5): void { + this.loops.stopMusic(fadeSeconds); + } + + /** + * Играть амбиент с кроссфейдом — слот отдельный от музыки: + * playAmbience и playMusic не вытесняют друг друга. + */ + async playAmbience(key: string, { fade = DEFAULT_FADE, volume = 1 }: { fade?: number; volume?: number } = {}): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + const buf = await this.cache.decode(key, ctx); + if (!buf) return null; // негодный ключ не должен заглушить текущий амбиент + const slot = this.loops.swapAmbience(key, ctx, this.buses, buf, { loop: true, fade, volume, bus: 'ambience' }, this.played()); + return this.loops.makeHandle(slot, fade); + } + + /** Остановить амбиент (с затуханием). */ + stopAmbience(fadeSeconds = 0.5): void { + this.loops.stopAmbience(fadeSeconds); + } + + /** Погасить всё и снять слушатели (пересоздание аудио/HMR — без утечек). */ + dispose(): void { + if (this.onVisibility) { + document.removeEventListener('visibilitychange', this.onVisibility); + this.onVisibility = null; + } + this.voices.killAll(); + this.loops.stopMusic(0.05); + this.loops.stopAmbience(0.05); + } + + /** Общий запуск голоса sfx (нормализация opts-числа — здесь). */ + private startSfx(ctx: AudioContext, key: string, buf: AudioBuffer, opts?: number | PlayOptions): SfxHandle | null { + const o: PlayOptions = typeof opts === 'number' ? { volume: opts } : (opts ?? {}); + return this.voices.start(ctx, this.buses!, key, buf, o, (k, n) => this.onPlayed?.(k, n)); + } + + /** Нормализованные опции лупа. */ + private loopOpts(opts: LoopOptions): Required { + return { loop: opts.loop ?? true, fade: opts.fade ?? DEFAULT_FADE, volume: opts.volume ?? 1, bus: opts.bus ?? 'music' }; + } + + /** Шпионский колбэк для Loops (startSlot/swap* зовут на каждый запуск). */ + private played(): (key: string, opts: PlayOptions) => void { + return (key, opts) => this.onPlayed?.(key, opts); + } + + /** Файловый луп без слота (playLoop): декод + независимый запуск. */ + private async playLoopFrom(key: string, opts: LoopOptions): Promise { + const ctx = await this.ensureContext(); + if (!ctx || !this.buses) return null; + const buf = await this.cache.decode(key, ctx); + if (!buf) return null; + const full = this.loopOpts(opts); + const slot = this.loops.startSlot(key, ctx, this.buses, buf, full, this.played()); + return this.loops.makeHandle(slot, full.fade); + } + + private async ensureContext(): Promise { + await this.unlock(); + return this.ctx; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/audio/Loops.ts b/v1/packages/engine/src/audio/Loops.ts new file mode 100644 index 0000000..5377e20 --- /dev/null +++ b/v1/packages/engine/src/audio/Loops.ts @@ -0,0 +1,119 @@ +/** + * Лупы (музыка/амбиент/локальные слои): запуск из буфера, кроссфейд, слоты + * музыки и амбиента (взаимно не вытесняют). Выбор трека — AudioManager. + */ +import { MIN_FADE_VOLUME, type AudioBuses, type LoopOptions, type MusicHandle, type PlayOptions } from './types'; + +/** Запущенный луп: контекст + источник + огибающая громкости. */ +export interface Slot { + ctx: AudioContext; + source: AudioBufferSourceNode; + gain: GainNode; +} + +export class LoopSlots { + private currentMusic: Slot | null = null; + private currentAmbience: Slot | null = null; + + /** + * Запуск лупа из готового буфера: источник + линейное нарастание громкости. + * Не занимает ни слот музыки, ни слот амбиента — база локальных слоёв. + */ + startSlot( + key: string, + ctx: AudioContext, + buses: AudioBuses, + buf: AudioBuffer, + { loop, fade, volume, bus }: Required, + onPlayed: (key: string, opts: PlayOptions) => void + ): Slot { + const source = ctx.createBufferSource(); + source.buffer = buf; + source.loop = loop; + const gain = ctx.createGain(); + gain.gain.setValueAtTime(MIN_FADE_VOLUME, ctx.currentTime); + gain.gain.linearRampToValueAtTime(volume, ctx.currentTime + fade); + source.connect(gain).connect(buses[bus]); + source.start(); + onPlayed(key, { volume, rate: 1, pan: 0 }); + return { ctx, source, gain }; + } + + /** Handle над слотом: затухание + мгновенная громкость (без борьбы с ramp). */ + makeHandle(slot: Slot, fade: number): MusicHandle { + return { + stop: (fadeSeconds = fade) => this.fadeOut(slot, fadeSeconds), + setVolume: (volume: number) => { + const t = slot.ctx.currentTime; + slot.gain.gain.cancelScheduledValues(t); + slot.gain.gain.setValueAtTime(Math.max(0, volume), t); + } + }; + } + + /** + * Слот музыки: новый трек вытесняет прежний (кроссфейд). Негодный ключ не + * должен заглушить текущий трек — проверка decode до вызова. + */ + swapMusic( + key: string, + ctx: AudioContext, + buses: AudioBuses, + buf: AudioBuffer, + opts: Required, + onPlayed: (key: string, opts: PlayOptions) => void + ): Slot { + this.stopMusic(opts.fade); + const slot = this.startSlot(key, ctx, buses, buf, opts, onPlayed); + this.currentMusic = slot; + return slot; + } + + /** Слот амбиента — отдельный от музыки: playAmbience и playMusic не конфликтуют. */ + swapAmbience( + key: string, + ctx: AudioContext, + buses: AudioBuses, + buf: AudioBuffer, + opts: Required, + onPlayed: (key: string, opts: PlayOptions) => void + ): Slot { + this.stopAmbience(opts.fade); + const slot = this.startSlot(key, ctx, buses, buf, opts, onPlayed); + this.currentAmbience = slot; + return slot; + } + + /** Остановить музыку (с затуханием). */ + stopMusic(fadeSeconds: number): void { + const current = this.currentMusic; + if (!current) return; + this.currentMusic = null; + this.fadeOut(current, fadeSeconds); + } + + /** Остановить амбиент (с затуханием). */ + stopAmbience(fadeSeconds: number): void { + const current = this.currentAmbience; + if (!current) return; + this.currentAmbience = null; + this.fadeOut(current, fadeSeconds); + } + + /** Затухание слота (экспонента — слуху линейный спад слышен ступенькой). */ + fadeOut(slot: Slot, fadeSeconds: number): void { + const t = slot.ctx.currentTime; + slot.gain.gain.cancelScheduledValues(t); + // Экспоненциальный ramp не определён от нуля — поднимаем до минимума. + const from = Math.max(slot.gain.gain.value, MIN_FADE_VOLUME); + slot.gain.gain.setValueAtTime(from, t); + slot.gain.gain.exponentialRampToValueAtTime(MIN_FADE_VOLUME, t + fadeSeconds); + setTimeout(() => { + try { + slot.source.stop(); + } catch { + // уже остановлен + } + }, fadeSeconds * 1000 + 50); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/audio/Music.ts b/v1/packages/engine/src/audio/Music.ts new file mode 100644 index 0000000..56d509d --- /dev/null +++ b/v1/packages/engine/src/audio/Music.ts @@ -0,0 +1,167 @@ +/** + * Музыкальный секвенсор движка: партитура (ноты + инструменты) → сэмплы-луп. + * Адресат — ИИ-агент: описывает партию нотами (MIDI), движок детерминированно + * компилирует её в буфер (AudioManager.playLoopBuffer — стем на шине music). + * Инструменты — осциллятор + огибающая поверх примитивов synth.mjs; сам + * рендер чистый (без ГПСЧ) — детерминизм мелодии обеспечивают сиды + * генератора нот на стороне игры/агента, а не рендера. + */ +import { RATE, decay, loopify, normalize } from '@rpg/engine/tools/synth.mjs'; + +/** Инструменты: осциллятор + огибающая (набор минимальный, расширять по мере надобности). */ +export type InstrumentKind = 'pluck' | 'bell' | 'pad'; + +/** Нота: старт и длина в долях (beats), высота в MIDI (69 = A4 = 440 Гц). */ +export interface NoteEvent { + beat: number; + dur: number; + midi: number; + /** Громкость ноты 0..1 (по умолчанию 0.8). */ + vel?: number; +} + +/** Партия = трек = стем: инструмент + ноты + громкость трека. */ +export interface TrackSpec { + instrument: InstrumentKind; + notes: NoteEvent[]; + /** Громкость трека (по умолчанию 1). */ + gain?: number; + /** Панорама -1..1 (только в стерео-рендере; по умолчанию 0 — центр). */ + pan?: number; +} + +/** Партитура лупа: темп, длина в долях, партии. */ +export interface MusicSpec { + /** Темп, долей в минуту. */ + bpm: number; + /** Длина лупа в долях (ноты, вылезшие за край, обрежаются). */ + beats: number; + tracks: TrackSpec[]; +} + +const midiHz = (midi: number): number => 440 * Math.pow(2, (midi - 69) / 12); + +/** Партиалы [множитель частоты, доля амплитуды] для мелодических инструментов. */ +const PLUCK_PARTIALS: [number, number][] = [ + [1, 0.6], + [2, 0.25], + [3, 0.15] +]; +const BELL_PARTIALS: [number, number][] = [ + [1, 0.5], + [1.52, 0.3], + [2.38, 0.2] +]; + +/** Сумма партиалов с экспоненциальным затуханием — pluck/bell. */ +function partialVoice(partials: [number, number][], f: number, ndur: number, power: number): (t: number) => number { + return (t) => { + let v = 0; + for (const [k, amp] of partials) { + v += Math.sin(2 * Math.PI * f * k * t) * amp * decay(t, ndur, power); + } + return v; + }; +} + +/** Пэд: sine + слегка расстроенная копия, мягкая атака/релиз вместо щелчка. */ +function padVoice(f: number, ndur: number): (t: number) => number { + const attack = Math.min(0.3, ndur * 0.25); + const release = Math.min(0.5, ndur * 0.35); + return (t) => { + const env = Math.min(1, t / attack) * Math.min(1, Math.max(0, (ndur - t) / release)); + return (Math.sin(2 * Math.PI * f * t) + Math.sin(2 * Math.PI * f * 1.004 * t)) * 0.5 * env; + }; +} + +/** Голос ноты: (t сек от начала ноты) → значение волны. */ +function voice(kind: InstrumentKind, f: number, ndur: number): (t: number) => number { + switch (kind) { + case 'pluck': + return partialVoice(PLUCK_PARTIALS, f, ndur, 3); + case 'bell': + return partialVoice(BELL_PARTIALS, f, ndur, 4); + case 'pad': + return padVoice(f, ndur); + } +} + +/** + * Суммирование нот всех партий в два канала. Моно-рендер кладёт всё в левый + * канал с весами 1 (pan не слышен); стерео — раскладывает по equal-power + * панораме трека. + */ +function mixTracks( + spec: MusicSpec, + total: number, + stereo: boolean +): [Float32Array, Float32Array] { + const spb = 60 / Math.max(1, spec.bpm); // сек на долю + const left = new Float32Array(total); + const right = new Float32Array(total); + for (const track of spec.tracks) { + const gain = track.gain ?? 1; + // Equal-power панорама: 0 → центр (обе 0.707), -1 → лево, 1 → право. + const a = ((Math.max(-1, Math.min(1, track.pan ?? 0)) + 1) * Math.PI) / 4; + const gl = stereo ? Math.cos(a) : 1; + const gr = stereo ? Math.sin(a) : 1; + for (const n of track.notes) { + const start = Math.round(n.beat * spb * RATE); + const len = Math.max(1, Math.round(n.dur * spb * RATE)); + const wave = voice(track.instrument, midiHz(n.midi), len / RATE); + const vel = (n.vel ?? 0.8) * gain; + for (let i = 0; i < len && start + i < total; i++) { + const v = wave(i / RATE) * vel; + left[start + i] += v * gl; + right[start + i] += v * gr; + } + } + } + return [left, right]; +} + +/** Нормализация к пику 0.8 по общему пику каналов + кроссфейд краёв лупа. */ +function finalizeLoop(left: Float32Array, right: Float32Array): void { + let peakMax = 0; + for (let i = 0; i < left.length; i++) + peakMax = Math.max(peakMax, Math.abs(left[i]!), Math.abs(right[i]!)); + const k = peakMax > 0 ? 0.8 / peakMax : 0; + if (k !== 1) { + for (let i = 0; i < left.length; i++) { + left[i] = left[i]! * k; + right[i] = right[i]! * k; + } + } + const fade = Math.min(0.5, (left.length / RATE) * 0.25); + loopify(left, fade); + loopify(right, fade); +} + +/** + * Партитура → моно-сэмплы-луп (RATE): ноты всех партий суммируются, луп + * сшивается кроссфейдом и нормализуется к пику 0.8. Одинаковые спеки дают + * одинаковые сэмплы (рендер чистый); pan треков не слышен. + */ +export function renderMusic(spec: MusicSpec): Float32Array { + const total = Math.max(1, Math.round(spec.beats * (60 / Math.max(1, spec.bpm)) * RATE)); + const [left] = mixTracks(spec, total, false); + return loopify(normalize(left, 0.8), Math.min(0.5, (total / RATE) * 0.25)); +} + +/** + * Партитура → стерео-сэмплы-луп (RATE, interleaved L,R): треки раскладываются + * по панораме `pan` (equal-power), нормализация к пику 0.8 — по общему пику + * каналов (баланс не искажается), кроссфейд краёв синхронный. Проигрывается + * как обычный AudioBuffer: `createBuffer(data, RATE, 2)`. + */ +export function renderMusicStereo(spec: MusicSpec): Float32Array { + const total = Math.max(1, Math.round(spec.beats * (60 / Math.max(1, spec.bpm)) * RATE)); + const [left, right] = mixTracks(spec, total, true); + finalizeLoop(left, right); + const out = new Float32Array(total * 2); + for (let i = 0; i < total; i++) { + out[i * 2] = left[i]!; + out[i * 2 + 1] = right[i]!; + } + return out; +} \ No newline at end of file diff --git a/v1/packages/engine/src/audio/SoundSpec.ts b/v1/packages/engine/src/audio/SoundSpec.ts new file mode 100644 index 0000000..bcd5141 --- /dev/null +++ b/v1/packages/engine/src/audio/SoundSpec.ts @@ -0,0 +1,235 @@ +import { + RATE, + bandNoise, + bellPartial, + decay, + loopify, + makeRng, + normalize, + synth +} from '@rpg/engine/tools/synth.mjs'; + +/** + * Спек-синтез: детерминированная компиляция описания звука в сэмплы. + * Адресат — ИИ-агент (движок агент-первый): описывает звук параметрами, + * без слуха и без библиотек сэмплов; движок компилирует спек в буфер + * (AudioManager.playSpec), факт запуска наблюдаем через шпион onPlayed. + * Формулы — те же примитивы, что у файлового генератора игры + * (tools/synth.mjs), поэтому спек-звук и WAV-звук звучат в одной палитре. + */ + +export type SoundKind = 'hit' | 'chime' | 'scrape' | 'hum' | 'tone'; + +/** + * Дополнительный голос смеси: шум (low/high) или тон (freq) со своей + * огибающей. Параметры не заданы — наследуются от спека. + */ +export interface SpecVoice { + /** Тон голоса, Гц (чистый синус; вместе с delay — «блип» в конце). */ + freq?: number; + /** Полоса шума, Гц (шумовой голос). */ + low?: number; + high?: number; + /** Вес в смеси (дефолт 1). */ + w?: number; + /** Своя крутизна затухания (по умолчанию — power спека). */ + power?: number; + /** Своя длительность, сек (по умолчанию — dur спека). */ + dur?: number; + /** Нарастание, сек (min(1, t/attack) от старта голоса). */ + attack?: number; + /** Задержка старта, сек — до неё голос молчит. */ + delay?: number; + /** Свой сид шума (по умолчанию — seed спека). */ + seed?: number; +} + +export interface SoundSpec { + kind: SoundKind; + /** Длительность, сек (0.05..4; hum — до 8). */ + dur: number; + /** Полоса шума, Гц (hit/scrape; дефолты по kind). */ + low?: number; + high?: number; + /** Низкий тон, Гц: подложка удара/шороха или основной тон гула. */ + tone?: number; + /** Частота тона/звона, Гц (tone/chime; дефолты по kind). */ + freq?: number; + /** Конечная частота линейного свипа (tone): f = freq + (freqTo−freq)·t/dur. */ + freqTo?: number; + /** Вес основного тела в смеси (дефолт 1). */ + w?: number; + /** Крутизна затухания (дефолты по kind). */ + power?: number; + /** Нарастание основного тела, сек (min(1, t/attack)) — шипение, скрип. */ + attack?: number; + /** Линейный спад амплитуды к этому множителю к концу (1 → fadeTo). */ + fadeTo?: number; + /** Тремоло, Гц: множитель wobbleBase + wobbleDepth·sin(2π·wobbleHz·t + фаза). */ + wobbleHz?: number; + wobbleDepth?: number; + /** База тремоло (дефолт 1 — амплитуда гуляет вокруг единицы). */ + wobbleBase?: number; + /** Фаза тремоло, рад (дефолт 0). */ + wobblePhase?: number; + /** Дополнительные голоса поверх основного тела (см. SpecVoice). */ + layers?: SpecVoice[]; + /** Сид шума/клика — детерминизм (дефолт 1). */ + seed?: number; + /** Пик нормализации 0..1 (дефолт 0.5). */ + peak?: number; + /** Огибающая «0 → 1 → 0» вместо спада с самого громкого старта (воздух, свуш). */ + swell?: boolean; + /** Зациклить (кроссфейд краёв; для hum — по умолчанию). */ + loop?: boolean; +} + +/** Клэмп длительности: снизу — против нулевых буферов, сверху — по kind. */ +const clampDur = (dur: number, max: number): number => Math.max(0.05, Math.min(max, dur)); + +/** Огибающая: спад с самого громкого старта или «0 → 1 → 0» (swell). */ +function envelope(spec: SoundSpec, dur: number, power: number): (t: number) => number { + return spec.swell + ? (t) => Math.sin(Math.PI * Math.min(1, t / dur)) + : (t) => decay(t, dur, power); +} + +/** Нарастание: линейное от старта до полной громкости за attack секунд. */ +const attackEnv = (t: number, attack: number | undefined): number => + attack ? Math.min(1, t / attack) : 1; + +/** + * Микс: основное тело + доп. голоса (layers), поверх — тремоло и линейный + * спад. Без модификаторов совпадает с normalize(synth(dur, base), peak) + * бит-в-бит (множители не применяются вовсе). + */ +function renderMix(spec: SoundSpec, dur: number, base: (t: number) => number): Float32Array { + const voices = (spec.layers ?? []).map((l) => makeVoice(l, spec, dur)); + const wobbleHz = spec.wobbleHz; + const wobbleAmp = spec.wobbleDepth ?? 0; + const wobbleBase = spec.wobbleBase ?? 1; + const wobblePhase = spec.wobblePhase ?? 0; + const fadeTo = spec.fadeTo; + return normalize( + synth(dur, (t) => { + let v = base(t); + for (const voice of voices) v += voice(t); + if (wobbleHz !== undefined) + v *= wobbleBase + wobbleAmp * Math.sin(2 * Math.PI * wobbleHz * t + wobblePhase); + if (fadeTo !== undefined) v *= 1 + (fadeTo - 1) * (t / dur); + return v; + }), + spec.peak ?? 0.5 + ); +} + +/** Голос слоя: шум (low/high) или тон (freq), своя огибающая и задержка. */ +function makeVoice(l: SpecVoice, spec: SoundSpec, dur: number): (t: number) => number { + const vd = clampDur(l.dur ?? dur, 8); + const delay = l.delay ?? 0; + const w = l.w ?? 1; + const power = l.power ?? spec.power ?? 2; + if (l.low !== undefined || l.high !== undefined) { + const noise = bandNoise(l.seed ?? spec.seed ?? 1, l.low ?? 100, l.high ?? 900, vd); + return (t) => { + const lt = t - delay; + if (lt < 0) return 0; + return noise() * w * attackEnv(lt, l.attack) * decay(lt, vd, power); + }; + } + const f = l.freq ?? 440; + return (t) => { + const lt = t - delay; + if (lt < 0) return 0; + return Math.sin(2 * Math.PI * f * lt) * w * attackEnv(lt, l.attack) * decay(lt, vd, power); + }; +} + +/** Шумовой удар: полоса шума с резким спадом; tone — низкая подложка. */ +function hitBase(spec: SoundSpec, dur: number, env: (t: number) => number): (t: number) => number { + const noise = bandNoise(spec.seed ?? 1, spec.low ?? 600, spec.high ?? 3000, dur); + const tone = spec.tone ?? 0; + const w = spec.w ?? 1; + return (t) => { + let v = noise() * env(t) * w; + if (tone > 0) v += Math.sin(2 * Math.PI * tone * t) * decay(t, dur, (spec.power ?? 2) * 0.8) * 0.4; + return v; + }; +} + +/** Скрип/шорох: низкая полоса, медленный спад; swell — воздух; attack — нарастание. */ +function scrapeBase(spec: SoundSpec, dur: number, env: (t: number) => number): (t: number) => number { + const noise = bandNoise(spec.seed ?? 1, spec.low ?? 100, spec.high ?? 900, dur); + const w = spec.w ?? 1; + const attack = spec.attack; + return (t) => noise() * w * attackEnv(t, attack) * env(t); +} + +/** Чистый тон: синус freq (со свипом к freqTo), огибающая decay/swell. */ +function toneBase(spec: SoundSpec, dur: number, env: (t: number) => number): (t: number) => number { + const f0 = spec.freq ?? 440; + const f1 = spec.freqTo; + const w = spec.w ?? 1; + const attack = spec.attack; + return (t) => { + const f = f1 === undefined ? f0 : f0 + (f1 - f0) * (t / dur); + return Math.sin(2 * Math.PI * f * t) * w * attackEnv(t, attack) * env(t); + }; +} + +/** Звон: негармоничные партиалы одной частоты + короткий клик. */ +function chimeBase(spec: SoundSpec, dur: number): (t: number) => number { + const f = spec.freq ?? 520; + const power = spec.power ?? 5; + // Клик — спад с одной случайной амплитудой (как у файлового генератора: + // формулы совпадают дословно — мигрированные звуки звучат один в один). + const clickAmp = makeRng(spec.seed ?? 1)() * 2 - 1; + return (t) => { + const body = + bellPartial(f, 1.0, dur, power)(t) * 0.5 + + bellPartial(f, 1.52, dur, power + 1)(t) * 0.3 + + bellPartial(f, 2.38, dur * 0.6, power + 2)(t) * 0.2 + + bellPartial(f, 3.91, dur * 0.4, power + 3)(t) * 0.12; + const click = t < 0.008 ? clickAmp * (1 - t / 0.008) * 0.4 : 0; + return body * 0.8 + click; + }; +} + +/** Гул: два тона с биением (tone и tone×1.5) на «дыхании», луп по умолчанию. */ +function humBase(spec: SoundSpec, dur: number): (t: number) => number { + const tone = spec.tone ?? 55; + const breath = spec.power ?? 2; // число периодов «дыхания» на луп + return (t) => { + const swell = 0.6 + 0.4 * Math.sin(2 * Math.PI * (t / dur) * breath + 0.5); + const body = + Math.sin(2 * Math.PI * tone * t) * 0.5 + + Math.sin(2 * Math.PI * tone * 1.5 * t) * 0.3; + return body * swell * 0.35; + }; +} + +/** + * Спек → сэмплы (моно, RATE). Одинаковые спеки дают одинаковые сэмплы + * (детерминизм: сиды фикс, ГПСЧ детерминирован). + */ +export function renderSpec(spec: SoundSpec): Float32Array { + const dur = clampDur(spec.dur, spec.kind === 'hum' ? 8 : 4); + const env = envelope(spec, dur, spec.power ?? 2); + const base = + spec.kind === 'hit' + ? hitBase(spec, dur, env) + : spec.kind === 'chime' + ? chimeBase(spec, dur) + : spec.kind === 'scrape' + ? scrapeBase(spec, dur, env) + : spec.kind === 'tone' + ? toneBase(spec, dur, env) + : humBase(spec, dur); + const samples = renderMix(spec, dur, base); + if (spec.loop ?? spec.kind === 'hum') { + return loopify(samples, Math.min(0.5, dur / 4)); + } + return samples; +} + +export { RATE }; \ No newline at end of file diff --git a/v1/packages/engine/src/audio/Voices.ts b/v1/packages/engine/src/audio/Voices.ts new file mode 100644 index 0000000..bded2ea --- /dev/null +++ b/v1/packages/engine/src/audio/Voices.ts @@ -0,0 +1,153 @@ +/** + * Полифония разовых sfx: учёт играющих голосов, лимит с воровством самого + * тихого, restart по ключу, панорама в полёте. Шины и контекст даёт AudioManager. + */ +import { clamp, MIN_FADE_VOLUME, type AudioBuses, type BusName, type PlayOptions, type SfxHandle } from './types'; + +/** Голос разового sfx: узлы + ключ (учёт для лимита полифонии и restart). */ +interface SfxVoice { + key: string; + /** Контекст и шины запуска (фейки тестов не дают source.context). */ + ctx: AudioContext; + buses: AudioBuses; + source: AudioBufferSourceNode; + gain: GainNode; + panner: StereoPannerNode | null; + /** Шина голоса (pan в полёте переподключается в ней же, не в sfx). */ + bus: BusName; + /** Заглушен принудительно (stop/вор/новый restart) — onEnded не зовётся. */ + killed: boolean; +} + +export class SfxVoices { + private voices: SfxVoice[] = []; + /** Звук доиграл до конца (stop/вор/новый restart — не считаются). */ + onEnded?: (key: string) => void; + + /** @param maxVoices лимит одновременных голосов (0 — без лимита). */ + constructor(private maxVoices: number) {} + + /** + * Общий запуск голоса (play — после декода, playBuffer — как есть). + * onPlayed зовётся после фактического source.start с нормализованными опциями. + */ + start( + ctx: AudioContext, + buses: AudioBuses, + key: string, + buf: AudioBuffer, + o: PlayOptions, + onPlayed: (key: string, opts: PlayOptions) => void + ): SfxHandle | null { + const volume = o.volume ?? 1; + const rate = clamp(o.rate ?? 1, 0.5, 2); + const pan = clamp(o.pan ?? 0, -1, 1); + const bus = o.bus ?? 'sfx'; + if (o.restart) this.stopKey(key); + this.stealIfNeeded(); + + const source = ctx.createBufferSource(); + source.buffer = buf; + source.playbackRate.value = rate; + const gain = ctx.createGain(); + gain.gain.value = volume; + source.connect(gain); + let panner: StereoPannerNode | null = null; + if (Math.abs(pan) >= 0.01 && typeof ctx.createStereoPanner === 'function') { + panner = ctx.createStereoPanner(); + panner.pan.value = pan; + gain.connect(panner).connect(buses[bus]); + } else { + gain.connect(buses[bus]); + } + source.start(); + const voice: SfxVoice = { key, ctx, buses, source, gain, panner, bus, killed: false }; + source.onended = () => { + this.release(voice); + if (!voice.killed) this.onEnded?.(key); + }; + this.voices.push(voice); + onPlayed(key, { volume, rate, pan }); + return this.makeHandle(voice); + } + + /** Остановить все играющие голоса ключа (короткий фейд). */ + stopKey(key: string, fadeSeconds = 0.05): void { + for (const voice of [...this.voices]) { + if (voice.key === key) this.kill(voice, fadeSeconds); + } + } + + /** Погасить все голоса (dispose менеджера). */ + killAll(): void { + for (const voice of [...this.voices]) this.kill(voice, 0.05); + } + + /** Хендл над голосом: после снятия с учёта (stop/конец) — no-op. */ + private makeHandle(voice: SfxVoice): SfxHandle { + const alive = () => this.voices.includes(voice); + return { + stop: (fadeSeconds = 0.05) => { + if (alive()) this.kill(voice, fadeSeconds); + }, + setVolume: (v: number) => { + if (alive()) voice.gain.gain.value = Math.max(0, v); + }, + setPan: (p: number) => { + if (alive()) this.setPan(voice, clamp(p, -1, 1)); + }, + setRate: (r: number) => { + if (alive()) voice.source.playbackRate.value = clamp(r, 0.5, 2); + } + }; + } + + /** Панорама голоса: узел создаётся при первом ненулевом значении — в шине голоса. */ + private setPan(voice: SfxVoice, pan: number): void { + if (voice.panner) { + voice.panner.pan.value = pan; + return; + } + if (Math.abs(pan) < 0.01 || typeof voice.ctx.createStereoPanner !== 'function') return; + const panner = voice.ctx.createStereoPanner(); + panner.pan.value = pan; + voice.gain.disconnect(); + voice.gain.connect(panner).connect(voice.buses[voice.bus]); + voice.panner = panner; + } + + /** Лимит голосов: при переполнении воруется самый тихий (ничья — самый старый). */ + private stealIfNeeded(): void { + while (this.maxVoices > 0 && this.voices.length >= this.maxVoices) { + let victim = this.voices[0]!; + for (const v of this.voices) { + if (v.gain.gain.value < victim.gain.gain.value) victim = v; + } + this.kill(victim, 0.05); + } + } + + /** Затухание голоса + снятие с учёта (handle сразу «мёртв»). */ + private kill(voice: SfxVoice, fadeSeconds: number): void { + voice.killed = true; + const t = voice.ctx.currentTime; + voice.gain.gain.cancelScheduledValues(t); + const from = Math.max(voice.gain.gain.value, MIN_FADE_VOLUME); + voice.gain.gain.setValueAtTime(from, t); + voice.gain.gain.exponentialRampToValueAtTime(MIN_FADE_VOLUME, t + fadeSeconds); + setTimeout(() => { + try { + voice.source.stop(); + } catch { + // уже остановлен + } + }, fadeSeconds * 1000 + 50); + this.release(voice); + } + + /** Снять голос с учёта (idempotent — зовётся и из onended, и из kill). */ + private release(voice: SfxVoice): void { + const i = this.voices.indexOf(voice); + if (i >= 0) this.voices.splice(i, 1); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/audio/__tests__/AudioManager.test.ts b/v1/packages/engine/src/audio/__tests__/AudioManager.test.ts new file mode 100644 index 0000000..9ea1207 --- /dev/null +++ b/v1/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -0,0 +1,603 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AudioManager, type PlayOptions } from '../AudioManager'; + +/** Параметр WebAudio: значение + планировщик огибающей (мгновенно применяет). */ +function makeParam(value = 1) { + const p = { + value, + setValueAtTime: vi.fn((v: number) => { + p.value = v; + }), + linearRampToValueAtTime: vi.fn((v: number) => { + p.value = v; + }), + exponentialRampToValueAtTime: vi.fn((v: number) => { + p.value = v; + }), + cancelScheduledValues: vi.fn() + }; + return p; +} + +/** Минимальный граф WebAudio: считает узлы, запоминает соединения. */ +function makeCtx() { + const graph = { + gains: [] as { gain: ReturnType; dests: unknown[] }[], + sources: [] as { buffer: unknown; loop: boolean; playbackRate: ReturnType; start: ReturnType; stop: ReturnType; dests: unknown[] }[], + panners: [] as { pan: ReturnType; dests: unknown[] }[], + destination: { destination: true } + }; + const connect = (dests: unknown[]) => (dest: unknown) => { + dests.push(dest); + return dest; + }; + const ctx = { + currentTime: 0, + state: 'running', + destination: graph.destination, + resume: vi.fn(async () => undefined), + suspend: vi.fn(async () => { + ctx.state = 'suspended'; + }), + createGain: () => { + const node: { + gain: ReturnType; + dests: unknown[]; + connect: (d: unknown) => unknown; + disconnect: () => void; + } = { + gain: makeParam(1), + dests: [], + connect: () => undefined, + disconnect: () => undefined + }; + node.connect = connect(node.dests); + graph.gains.push(node); + return node; + }, + createBufferSource: () => { + const node: { buffer: unknown; loop: boolean; playbackRate: ReturnType; start: ReturnType; stop: ReturnType; dests: unknown[]; connect: (d: unknown) => unknown } = { + buffer: null, + loop: false, + playbackRate: makeParam(1), + start: vi.fn(), + stop: vi.fn(), + dests: [], + connect: () => undefined + }; + node.connect = connect(node.dests); + graph.sources.push(node); + return node; + }, + createStereoPanner: () => { + const node: { pan: ReturnType; dests: unknown[]; connect: (d: unknown) => unknown } = { + pan: makeParam(0), + dests: [], + connect: () => undefined + }; + node.connect = connect(node.dests); + graph.panners.push(node); + return node; + }, + decodeAudioData: vi.fn(async () => ({ decoded: true, duration: 1.25 })), + createBuffer: vi.fn((channels: number, length: number, rate: number) => { + const buf: { channels: number; length: number; sampleRate: number; data: Float32Array | null; copyToChannel: ReturnType } = { + channels, + length, + sampleRate: rate, + data: null, + copyToChannel: vi.fn((data: Float32Array) => { + buf.data = data; + }) + }; + return buf; + }) + }; + return { ctx, graph }; +} + +function makeManager(opts: { maxVoices?: number; pauseOnHide?: boolean } = {}) { + const { ctx, graph } = makeCtx(); + const audio = new AudioManager((key) => `url:${key}`, () => ctx as unknown as AudioContext, opts); + return { + audio, + graph, + ctx: ctx as unknown as { + createBuffer: ReturnType; + suspend: ReturnType; + resume: ReturnType; + state: string; + } + }; +} + +describe('AudioManager', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) }))); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('unlock строит 4 шины: music/sfx/ambience ведут в master, master — в выход', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + + expect(graph.gains).toHaveLength(4); + const [master, music, sfx, ambience] = graph.gains; + expect(master!.dests).toContain(graph.destination); + for (const bus of [music, sfx, ambience]) { + expect(bus!.dests).toContain(master); + } + }); + + it('setBusVolume: до unlock — без броска, после unlock — меняет громкость шины', async () => { + const { audio, graph } = makeManager(); + expect(() => audio.setBusVolume('sfx', 0.3)).not.toThrow(); + expect(graph.gains).toHaveLength(0); + + await audio.unlock(); + audio.setBusVolume('sfx', 0.3); + audio.setBusVolume('ambience', 0.4); + expect(graph.gains[2]!.gain.value).toBe(0.3); + expect(graph.gains[3]!.gain.value).toBe(0.4); + }); + + it('play(key, 0.5) ≡ play(key, {volume: 0.5}) — источник в sfx через gain', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + await audio.play('bell', 0.5); + await audio.play('bell', { volume: 0.5 }); + + const sfxBus = graph.gains[2]!; + for (const src of graph.sources) { + const gainNode = src.dests[0] as { gain: ReturnType; dests: unknown[] }; + expect(gainNode.gain.value).toBe(0.5); + expect(gainNode.dests).toContain(sfxBus); + } + }); + + it('rate и pan доезжают до узлов; pan ≈ 0 — StereoPanner не создаётся', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + await audio.play('bell', { rate: 1.5, pan: 0.7 }); + expect(graph.sources[0]!.playbackRate.value).toBe(1.5); + expect(graph.panners).toHaveLength(1); + expect(graph.panners[0]!.pan.value).toBe(0.7); + + await audio.play('bell'); + expect(graph.panners).toHaveLength(1); // второй sfx без панорамы + expect(graph.sources[1]!.playbackRate.value).toBe(1); + }); + + it('незагруженный ключ — тихо ничего (null), onPlayed не зовётся', async () => { + const { audio } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + + await expect(audio.play('ghost')).resolves.toBeNull(); + expect(played).toHaveLength(0); + }); + + it('onPlayed зовётся после фактического запуска с нормализованными опциями', async () => { + const { audio, graph } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + await audio.unlock(); + await audio.register('bell'); + + await audio.play('bell', { volume: 0.6, rate: 1.2, pan: -0.4 }); + await audio.play('bell', 0.5); + + expect(played).toEqual([ + { key: 'bell', opts: { volume: 0.6, rate: 1.2, pan: -0.4 } }, + { key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } } + ]); + expect(graph.sources).toHaveLength(2); + }); + + it('play возвращает хендл: setVolume/setRate/setPan меняют узлы, stop глушит', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + const h = await audio.play('bell', { volume: 0.5, pan: 0.3 }); + expect(h).not.toBeNull(); + + const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number } }; + h!.setVolume(0.8); + expect(gainNode.gain.value).toBe(0.8); + + h!.setRate(1.4); + expect(graph.sources[0]!.playbackRate.value).toBe(1.4); + + h!.setPan(-0.5); + expect(graph.panners[0]!.pan.value).toBe(-0.5); + + h!.stop(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + }); + + it('setPan создаёт panner, если его не было', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + const h = await audio.play('bell', 0.5); + expect(graph.panners).toHaveLength(0); + + h!.setPan(0.4); + expect(graph.panners).toHaveLength(1); + expect(graph.panners[0]!.pan.value).toBeCloseTo(0.4); + // Пересоединение: gain теперь ведёт и в panner + const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] }; + expect(gainNode.dests).toContain(graph.panners[0]); + }); + + it('после stop хендл мёртв: setVolume/setRate — no-op', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + const h = await audio.play('bell', 0.5); + h!.stop(0); + h!.setVolume(0.9); + h!.setRate(1.5); + + // stop затушил громкость; setVolume мёртвого хендла её не вернул + const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number } }; + expect(gainNode.gain.value).toBeCloseTo(0.0001); + expect(graph.sources[0]!.playbackRate.value).toBe(1); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalledTimes(1); + }); + + it('restart: новый экземпляр ключа глушит прежний; без restart — играют оба', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + await audio.play('bell', 0.5); + await audio.play('bell', { volume: 0.5, restart: true }); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); + + await audio.play('bell', 0.5); + vi.advanceTimersByTime(100); + expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // без restart живут оба + expect(graph.sources[2]!.stop).not.toHaveBeenCalled(); + }); + + it('maxVoices: при переполнении воруется самый тихий голос', async () => { + const { audio, graph } = makeManager({ maxVoices: 2 }); + await audio.unlock(); + await audio.register('bell'); + + await audio.play('bell', 0.9); + await audio.play('bell', 0.1); + await audio.play('bell', 0.5); + vi.advanceTimersByTime(100); + + expect(graph.sources[1]!.stop).toHaveBeenCalled(); // самый тихий (0.1) + expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); + expect(graph.sources[2]!.stop).not.toHaveBeenCalled(); + }); + + it('onPlayed зовётся и для лупов (музыка/амбиент), и для sfx', async () => { + const { audio } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + await audio.unlock(); + await audio.register('m'); + await audio.register('a'); + await audio.register('bell'); + + await audio.playMusic('m', { volume: 0.6, fade: 0 }); + await audio.playAmbience('a', { fade: 0 }); + await audio.play('bell', 0.5); + + expect(played).toEqual([ + { key: 'm', opts: { volume: 0.6, rate: 1, pan: 0 } }, + { key: 'a', opts: { volume: 1, rate: 1, pan: 0 } }, + { key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } } + ]); + }); + + it('onEnded зовётся на естественном конце звука', async () => { + const { audio, graph } = makeManager(); + const ended: string[] = []; + audio.onEnded = (key) => ended.push(key); + await audio.unlock(); + await audio.register('bell'); + + await audio.play('bell'); + expect(ended).toHaveLength(0); + + // Фейковый источник не завершается сам — дёргаем onended руками. + (graph.sources[0] as unknown as { onended?: () => void }).onended?.(); + expect(ended).toEqual(['bell']); + }); + + it('заглушенный голос (stop) onEnded не зовёт', async () => { + const { audio, graph } = makeManager(); + const ended: string[] = []; + audio.onEnded = (key) => ended.push(key); + await audio.unlock(); + await audio.register('bell'); + + const h = await audio.play('bell'); + h!.stop(0); + // Источник «остановился» после затухания — голос убит, событие не для него. + (graph.sources[0] as unknown as { onended?: () => void }).onended?.(); + expect(ended).toEqual([]); + }); + + it('duration: после load+preload — секунды буфера, иначе null', async () => { + const { audio } = makeManager(); + await audio.unlock(); + + expect(audio.duration('m')).toBeNull(); + await audio.register('m'); + await audio.preload('m'); + expect(audio.duration('m')).toBeCloseTo(1.25); + }); + + it('createBuffer: до unlock — null, после — буфер с сэмплами', async () => { + const { audio, ctx } = makeManager(); + const data = new Float32Array([0, 0.5, -0.5]); + expect(audio.createBuffer(data)).toBeNull(); // контекста ещё нет + + await audio.unlock(); + const buf = audio.createBuffer(data, 44100) as unknown as { data: Float32Array | null; copyToChannel: (d: Float32Array, ch: number) => void }; + expect(ctx.createBuffer).toHaveBeenCalledWith(1, 3, 44100); + expect(buf.data).toBe(data); + expect(buf!.copyToChannel).toHaveBeenCalledWith(data, 0); + }); + + it('createBuffer: channels 2 — interleaved L,R деинтерливится в два канала', async () => { + const { audio, ctx } = makeManager(); + await audio.unlock(); + const copied: { ch: number; data: Float32Array }[] = []; + const data = new Float32Array([0.1, 0.9, -0.2, -0.8]); + const buf = audio.createBuffer(data, 22050, 2) as unknown as { + copyToChannel: (d: Float32Array, ch: number) => void; + }; + // Собираем вызовы по каналам: фейк хранит только последний, читаем аргументы. + (ctx.createBuffer as ReturnType).mock.results.at(-1)!.value.copyToChannel.mock.calls.forEach( + ([d, ch]: [Float32Array, number]) => copied.push({ ch, data: d }) + ); + expect(ctx.createBuffer).toHaveBeenCalledWith(2, 2, 22050); + expect(copied).toEqual([ + { ch: 0, data: new Float32Array([0.1, -0.2]) }, + { ch: 1, data: new Float32Array([0.9, -0.8]) } + ]); + void buf; + }); + + it('playBuffer играет буфер в sfx с заданным ключом и возвращает хендл', async () => { + const { audio, graph } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + await audio.unlock(); + + const buf = audio.createBuffer(new Float32Array(100).fill(0.3))!; + const h = await audio.playBuffer('sfx/step_grass#0', buf, { volume: 0.4 }); + + expect(h).not.toBeNull(); + const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] }; + expect(gainNode.gain.value).toBe(0.4); + expect(gainNode.dests).toContain(graph.gains[2]); // шина sfx + expect(played).toEqual([{ key: 'sfx/step_grass#0', opts: { volume: 0.4, rate: 1, pan: 0 } }]); + + h!.stop(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + }); + + it('playSpec: спек компилируется в буфер и кэшируется по ключу', async () => { + const { audio, graph, ctx } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + await audio.unlock(); + + const spec = { kind: 'chime', dur: 0.1, freq: 660 } as const; + const h1 = await audio.playSpec('agent/door', spec, 0.7); + await audio.playSpec('agent/door', spec, 0.7); // из кэша + + expect(graph.sources).toHaveLength(2); + expect(ctx.createBuffer).toHaveBeenCalledTimes(1); // компиляция один раз + const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] }; + expect(gainNode.gain.value).toBe(0.7); + expect(gainNode.dests).toContain(graph.gains[2]); // шина sfx + expect(played.map((p) => p.key)).toEqual(['agent/door', 'agent/door']); + expect(h1).not.toBeNull(); + + // Другой ключ — своя компиляция + await audio.playSpec('agent/bush', spec, 0.5); + expect(ctx.createBuffer).toHaveBeenCalledTimes(2); + }); + + it('playSpec: спек рендерится в буфер с длиной dur×RATE (моно, 22050)', async () => { + const { audio, ctx } = makeManager(); + await audio.playSpec('x', { kind: 'hit', dur: 0.1 }); + expect(ctx.createBuffer).toHaveBeenCalledWith(1, 2205, 22050); + }); + + it('setVolume хендла сразу задаёт громкость слота', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('m'); + + const handle = await audio.playLoop('m', { fade: 0, volume: 1 }); + handle!.setVolume(0.3); + const slotGain = (graph.sources[0]!.dests[0] as { gain: { value: number } }).gain; + expect(slotGain.value).toBeCloseTo(0.3); + }); + + it('playMusic и playAmbience независимы: остановка музыки не глушит амбиент', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('m'); + await audio.register('a'); + + const music = await audio.playMusic('m', { fade: 0 }); + const amb = await audio.playAmbience('a', { fade: 0 }); + expect(music).not.toBeNull(); + expect(amb).not.toBeNull(); + expect(graph.sources).toHaveLength(2); + + music!.stop(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); + }); + + it('playAmbience дважды — прежний слот остановлен', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('a'); + + const first = await audio.playAmbience('a', { fade: 0 }); + await audio.playAmbience('a', { fade: 0 }); + vi.advanceTimersByTime(100); + + expect(first).not.toBeNull(); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // новый играет + }); + + it('playMusic с негодным ключом не глушит текущий трек', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('m'); + + await audio.playMusic('m', { fade: 0 }); + const bad = await audio.playMusic('ghost', { fade: 0 }); + expect(bad).toBeNull(); + expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); + }); + + it('playLoop играет в заданную шину и не занимает слот музыки', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('m'); + + const layer = await audio.playLoop('m', { bus: 'sfx', fade: 0 }); + expect(layer).not.toBeNull(); + const sfxBus = graph.gains[2]!; + expect(sfxBus.dests).not.toContain(graph.destination); + // gain лупа ведёт в sfx + const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] }; + expect(gainNode.dests).toContain(sfxBus); + + // музыка стартует отдельно, луп не заглушается + await audio.playMusic('m', { fade: 0 }); + expect(graph.sources).toHaveLength(2); + audio.stopMusic(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).not.toHaveBeenCalled(); + expect(graph.sources[1]!.stop).toHaveBeenCalled(); + }); + + it('bus в PlayOptions: разовый голос уходит в заданную шину', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('bell'); + + // graph.gains: master, music, sfx, ambience + await audio.play('bell', { volume: 0.5, bus: 'music' }); + const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] }; + expect(gainNode.dests).toContain(graph.gains[1]); // шина music + expect(gainNode.dests).not.toContain(graph.gains[2]); + + await audio.play('bell'); // без bus — по-прежнему sfx + const plain = graph.sources[1]!.dests[0] as { dests: unknown[] }; + expect(plain.dests).toContain(graph.gains[2]); + }); + + it('fadeOut: экспоненциальный спад от нуля поднимается до минимума', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.register('m'); + + const layer = await audio.playLoop('m', { fade: 0, volume: 0 }); + layer!.stop(0.5); + const slotGain = graph.sources[0]!.dests[0] as { gain: ReturnType }; + // from = max(0, MIN) → setValueAtTime(MIN), затем exponential- ramp + expect(slotGain.gain.setValueAtTime).toHaveBeenCalledWith(0.0001, 0); + expect(slotGain.gain.exponentialRampToValueAtTime).toHaveBeenCalledWith(0.0001, 0.5); + }); + + it('visibilitychange: скрыли таб — suspend, вернули — resume', async () => { + const listeners: Record void)[]> = {}; + const doc = { + hidden: false, + addEventListener: (type: string, fn: () => void) => { + (listeners[type] ??= []).push(fn); + } + }; + vi.stubGlobal('document', doc); + try { + const { audio, ctx } = makeManager(); + await audio.unlock(); + expect(ctx.suspend).not.toHaveBeenCalled(); + + doc.hidden = true; + listeners.visibilitychange![0]!(); + expect(ctx.suspend).toHaveBeenCalledTimes(1); + + doc.hidden = false; + ctx.state = 'suspended'; + listeners.visibilitychange![0]!(); + expect(ctx.resume).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('playLoopBuffer: луп из памяти на заданной шине, хендл стопит', async () => { + const { audio, graph } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + await audio.unlock(); + await audio.register('m'); // createBuffer фейка требует unlock — берём его буфер + + const buf = audio.createBuffer(new Float32Array(22050).fill(0.2))!; + const handle = await audio.playLoopBuffer('music/meadows', buf, { fade: 0, volume: 0.7 }); + expect(handle).not.toBeNull(); + const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] }; + expect(gainNode.gain.value).toBe(0.7); + expect(gainNode.dests).toContain(graph.gains[1]); // шина music по умолчанию + expect(played).toEqual([{ key: 'music/meadows', opts: { volume: 0.7, rate: 1, pan: 0 } }]); + + handle!.stop(0); + vi.advanceTimersByTime(100); + expect(graph.sources[0]!.stop).toHaveBeenCalled(); + }); + + it('pauseOnHide: false — visibilitychange игнорируется', async () => { + const listeners: Record void)[]> = {}; + vi.stubGlobal('document', { + hidden: true, + addEventListener: (type: string, fn: () => void) => { + (listeners[type] ??= []).push(fn); + } + }); + try { + const { ctx } = makeManager({ pauseOnHide: false }); + expect(listeners.visibilitychange).toBeUndefined(); // listener не ставился + expect(ctx.suspend).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/audio/__tests__/Music.test.ts b/v1/packages/engine/src/audio/__tests__/Music.test.ts new file mode 100644 index 0000000..221f11e --- /dev/null +++ b/v1/packages/engine/src/audio/__tests__/Music.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { renderMusic, renderMusicStereo, type MusicSpec } from '../Music'; + +const RATE = 22050; +const spb = (bpm: number): number => 60 / bpm; + +const same = (a: Float32Array, b: Float32Array): boolean => + a.length === b.length && a.every((v, i) => v === b[i]); + +const peak = (samples: Float32Array): number => Math.max(...[...samples].map(Math.abs)); + +/** Одна нота на долю — простейшая партитура. */ +const one = (over: Partial = {}): MusicSpec => ({ + bpm: 120, + beats: 4, + tracks: [{ instrument: 'pluck', notes: [{ beat: 0, dur: 1, midi: 69 }] }], + ...over +}); + +describe('renderMusic', () => { + it('детерминизм: одинаковые спеки → одинаковые сэмплы', () => { + expect(same(renderMusic(one()), renderMusic(one()))).toBe(true); + }); + + it('длина лупа — сырая минус кроссфейд (фейд = min(0.5с, 25% длины))', () => { + const t1 = 4 * spb(120) * RATE; // 2 с + expect(renderMusic(one({ bpm: 120, beats: 4 })).length).toBe(t1 - 0.5 * RATE); + const t2 = 8 * spb(60) * RATE; // 8 с — фейд упёрся в 0.5 с + expect(renderMusic(one({ bpm: 60, beats: 8 })).length).toBe(t2 - 0.5 * RATE); + }); + + it('высота ноты доезжает: другой midi — другие сэмплы', () => { + const a = renderMusic(one()); + const b = renderMusic(one({ tracks: [{ instrument: 'pluck', notes: [{ beat: 0, dur: 1, midi: 71 }] }] })); + expect(same(a, b)).toBe(false); + }); + + it('vel и gain меняют микс (пик после нормализации — 0.8 вне кроссфейд-зоны)', () => { + const beat1 = one({ tracks: [{ instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }] }] }); + expect(peak(renderMusic(beat1))).toBeCloseTo(0.8, 2); // нормализация + + // gain одной партии при опорной второй стереть нормализацией нельзя: + const mix = (g: number): MusicSpec => ({ + bpm: 120, + beats: 4, + tracks: [ + { instrument: 'pad', notes: [{ beat: 0, dur: 4, midi: 57 }] }, + { instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }], gain: g } + ] + }); + expect(same(renderMusic(mix(1)), renderMusic(mix(0.3)))).toBe(false); + }); + + it('ноты за краем лупа обрезаются, пустой трек — тишина нормализована в 0', () => { + const empty = renderMusic(one({ tracks: [{ instrument: 'pad', notes: [] }] })); + expect(peak(empty)).toBe(0); + + const clipped = renderMusic(one({ beats: 1, tracks: [{ instrument: 'pluck', notes: [{ beat: 0.5, dur: 2, midi: 69 }] }] })); + expect(clipped.length).toBeLessThanOrEqual(1 * spb(120) * RATE); + expect(peak(clipped)).toBeGreaterThan(0); + }); + + it('стерео: interleaved L,R; pan 0 — каналы равны, pan -1 — громче слева', () => { + const frames = 4 * spb(120) * RATE; + const spec = one({ tracks: [{ instrument: 'pluck', notes: [{ beat: 0, dur: 1, midi: 69 }], pan: -1 }] }); + const s = renderMusicStereo(spec); + expect(s.length).toBe(frames * 2); + + // Пики каналов (вне кроссфейд-зоны начала). + let lPeak = 0; + let rPeak = 0; + for (let i = 0; i < frames; i++) { + lPeak = Math.max(lPeak, Math.abs(s[i * 2]!)); + rPeak = Math.max(rPeak, Math.abs(s[i * 2 + 1]!)); + } + expect(lPeak).toBeGreaterThan(rPeak * 2); // панорама влево + + const centered = renderMusicStereo(one()); + let equal = true; + for (let i = 0; i < centered.length; i += 2) { + if (centered[i] !== centered[i + 1]) equal = false; + } + expect(equal).toBe(true); // pan 0 — моно-копия в обоих каналах + }); + + it('стерео: нормализация по общему пику каналов — баланс pan не искажается', () => { + // Тихий левый + громкий правый: пик пары равен 0.8 (нота вне кроссфейд-зоны). + const spec = one({ + tracks: [ + { instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }], pan: -1 }, + { instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }], pan: 1, gain: 2 } + ] + }); + const s = renderMusicStereo(spec); + expect(peak(s)).toBeCloseTo(0.8, 2); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/audio/__tests__/SoundSpec.test.ts b/v1/packages/engine/src/audio/__tests__/SoundSpec.test.ts new file mode 100644 index 0000000..91299d9 --- /dev/null +++ b/v1/packages/engine/src/audio/__tests__/SoundSpec.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { renderSpec, type SoundSpec } from '../SoundSpec'; + +const same = (a: Float32Array, b: Float32Array): boolean => + a.length === b.length && a.every((v, i) => v === b[i]); + +const peak = (samples: Float32Array): number => Math.max(...[...samples].map(Math.abs)); + +describe('renderSpec — детерминизм', () => { + it('одинаковые спеки → одинаковые сэмплы', () => { + const spec: SoundSpec = { kind: 'hit', dur: 0.2, seed: 42 }; + expect(same(renderSpec(spec), renderSpec(spec))).toBe(true); + }); + + it('другой сид → другой шум (но та же форма затухания)', () => { + const a = renderSpec({ kind: 'hit', dur: 0.2, seed: 1 }); + const b = renderSpec({ kind: 'hit', dur: 0.2, seed: 2 }); + expect(same(a, b)).toBe(false); + expect(peak(a)).toBeCloseTo(peak(b), 5); // нормализация по одному peak + }); +}); + +describe('renderSpec — kinds', () => { + it('hit/chime/scrape нормализуются к peak, немые буферы исключены', () => { + for (const kind of ['hit', 'chime', 'scrape'] as const) { + const samples = renderSpec({ kind, dur: 0.3, peak: 0.6 }); + expect(peak(samples)).toBeCloseTo(0.6, 2); + expect(peak(samples)).toBeGreaterThan(0); + } + }); + + it('длительности: без лупа — ровно dur×RATE; луп сшит (короче на фейд)', () => { + const hit = renderSpec({ kind: 'hit', dur: 0.4 }); + expect(hit.length).toBe(8820); // 0.4 с × 22050 + + const hum = renderSpec({ kind: 'hum', dur: 2 }); // луп по умолчанию + expect(hum.length).toBeLessThan(44100); // срезан кроссфейд-хвост + expect(hum.length).toBeGreaterThanOrEqual(44100 - 11025); // фейд ≤ 0.5 с + }); + + it('hum: заданный loop=false не сшивает; hit с loop=true сшивает', () => { + const humRaw = renderSpec({ kind: 'hum', dur: 1, loop: false }); + expect(humRaw.length).toBe(22050); + const hitLoop = renderSpec({ kind: 'hit', dur: 1, loop: true }); + expect(hitLoop.length).toBeLessThan(22050); + }); + + it('клэмп длительности: 0.01 → мин 0.05 с, hum 100 → макс 8 с', () => { + expect(renderSpec({ kind: 'hit', dur: 0.01 }).length).toBe(1102); // 0.05 с + expect(renderSpec({ kind: 'hum', dur: 100, loop: false }).length).toBe(176400); // 8 с + }); + + it('параметры доезжают: freq меняет тембр звона, tone — подложку удара', () => { + const low = renderSpec({ kind: 'chime', dur: 0.3, freq: 220, loop: false }); + const high = renderSpec({ kind: 'chime', dur: 0.3, freq: 880, loop: false }); + expect(same(low, high)).toBe(false); + + const plain = renderSpec({ kind: 'hit', dur: 0.3, seed: 5, loop: false }); + const toned = renderSpec({ kind: 'hit', dur: 0.3, seed: 5, tone: 120, loop: false }); + expect(same(plain, toned)).toBe(false); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/audio/decode.ts b/v1/packages/engine/src/audio/decode.ts new file mode 100644 index 0000000..d1df125 --- /dev/null +++ b/v1/packages/engine/src/audio/decode.ts @@ -0,0 +1,46 @@ +/** + * Кэш декодированных аудио-буферов: реестр URL + decodeAudioData. + * Параллельные вызовы decode по одному ключу делят один in-flight промис — + * двойной fetch+decode при старте сцены исключён. + */ +export class BufferCache { + private urls = new Map(); + private buffers = new Map(); + private pending = new Map>(); + + /** Зарегистрировать URL ключа (без загрузки — файл читается в decode). */ + register(key: string, url: string): void { + this.urls.set(key, url); + } + + /** Уже декодированный буфер (без загрузки); не декодирован — null. */ + cached(key: string): AudioBuffer | null { + return this.buffers.get(key) ?? null; + } + + /** Декодировать (fetch + decodeAudioData); сбой — null с warn в консоль. */ + async decode(key: string, ctx: AudioContext): Promise { + const cached = this.buffers.get(key); + if (cached) return cached; + let flight = this.pending.get(key); + if (!flight) { + const url = this.urls.get(key); + if (!url) return null; + flight = this.fetchDecode(key, url, ctx).finally(() => this.pending.delete(key)); + this.pending.set(key, flight); + } + return flight; + } + + private async fetchDecode(key: string, url: string, ctx: AudioContext): Promise { + try { + const res = await fetch(url); + const buf = await ctx.decodeAudioData(await res.arrayBuffer()); + this.buffers.set(key, buf); + return buf; + } catch (err) { + console.warn(`[audio] не удалось декодировать ${key}:`, err); + return null; + } + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/audio/types.ts b/v1/packages/engine/src/audio/types.ts new file mode 100644 index 0000000..056c911 --- /dev/null +++ b/v1/packages/engine/src/audio/types.ts @@ -0,0 +1,53 @@ +/** + * Общие типы и константы аудио-подсистемы (шины, хендлы, опции). + * Реализации — в AudioManager (фасад), Voices (полифония sfx), Loops (лупы). + */ + +export interface AudioBuses { + master: GainNode; + music: GainNode; + sfx: GainNode; + ambience: GainNode; +} + +export type BusName = 'master' | 'music' | 'sfx' | 'ambience'; + +export interface MusicHandle { + stop(fadeSeconds?: number): void; + /** Задать громкость сразу (для медленных тик-фейдов поверх — локальные слои). */ + setVolume(volume: number): void; +} + +/** Хендл играющего разового sfx: параметры в полёте + остановка. */ +export interface SfxHandle { + stop(fadeSeconds?: number): void; + setVolume(volume: number): void; + setPan(pan: number): void; + setRate(rate: number): void; +} + +/** Опции разового sfx. */ +export interface PlayOptions { + /** Относительная громкость в шине (см. bus), 0..1+. */ + volume?: number; + /** Скорость воспроизведения 0.5..2 (джиттер шагов, вариации тона). */ + rate?: number; + /** Панорама -1..1; |pan| < 0.01 — узел StereoPanner не создаётся. */ + pan?: number; + /** Заглушить прежние играющие экземпляры этого же ключа (короткий фейд). */ + restart?: boolean; + /** Шина голоса (по умолчанию sfx) — например UI-звук в master. */ + bus?: BusName; +} + +export interface LoopOptions { + loop?: boolean; + fade?: number; + volume?: number; + bus?: BusName; +} + +export const DEFAULT_FADE = 1; +export const MIN_FADE_VOLUME = 0.0001; + +export const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); \ No newline at end of file diff --git a/v1/packages/engine/src/core/Cooldown.ts b/v1/packages/engine/src/core/Cooldown.ts new file mode 100644 index 0000000..c1624fa --- /dev/null +++ b/v1/packages/engine/src/core/Cooldown.ts @@ -0,0 +1,45 @@ +/** + * Кулдаун на фиксированном шаге: триггер -> отсчёт -> готовность. + * Чистая логика без Pixi — тестируется в Vitest. + */ +export class Cooldown { + private remaining = 0; + + constructor(private readonly seconds: number) {} + + /** Запустить отсчёт заново (только если готов — см. triggerForce для перебива). */ + trigger(): boolean { + if (!this.ready) return false; + this.remaining = this.seconds; + return true; + } + + /** Перезапустить отсчёт даже если не готов. */ + triggerForce(): void { + this.remaining = this.seconds; + } + + update(dt: number): void { + if (this.remaining > 0) { + this.remaining = Math.max(0, this.remaining - dt); + } + } + + get ready(): boolean { + return this.remaining <= 0; + } + + /** Секунд до готовности (0 — уже готов). */ + get timeRemaining(): number { + return this.remaining; + } + + /** 1 — только запущен, 0 — готов. */ + get progress(): number { + return this.seconds > 0 ? this.remaining / this.seconds : 0; + } + + reset(): void { + this.remaining = 0; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/core/Engine.ts b/v1/packages/engine/src/core/Engine.ts new file mode 100644 index 0000000..29e443a --- /dev/null +++ b/v1/packages/engine/src/core/Engine.ts @@ -0,0 +1,193 @@ +import { Application, Graphics } from 'pixi.js'; +import { Renderer, RendererOptions } from '../render/Renderer'; +import { Camera } from '../render/Camera'; +import { computeScale } from '../render/scale'; +import { GameLoop } from './GameLoop'; +import { FpsMeter } from './FpsMeter'; +import { EventBus } from './EventBus'; +import { TweenManager } from './Tween'; +import { Updatables } from './Updatables'; +import { InputManager } from '../input/InputManager'; +import { SceneManager } from '../scene/SceneManager'; + +/** + * Собирает подсистемы движка. Приложение создаёт Engine, добавляет стартовую + * сцену через scenes.replace() и вызывает start(). + */ +export interface EngineOptions extends RendererOptions { + /** FPS фиксированного шага. */ + fixedFps: number; + /** Лимит отрисовки, кадров/сек (0 — без лимита; шаги логики копятся). */ + maxFps?: number; + /** Пересчитывать масштаб при ресайзе окна (по умолчанию true). */ + autoResize?: boolean; +} + +export class Engine { + readonly renderer: Renderer; + readonly camera: Camera; + readonly events = new EventBus(); + readonly scenes = new SceneManager(); + /** Твины и таймеры: обновляются каждым фиксированным шагом. */ + readonly tweens = new TweenManager(); + /** Эффекты/аниматоры/флэши, тикаемые движком сами (фиксированный шаг). */ + readonly fx = new Updatables(); + input!: InputManager; + /** Дебаг-счётчик FPS (F3); создаётся в init(). */ + fpsMeter!: FpsMeter; + private loop: GameLoop; + private started = false; + private ticks = 0; + private readonly autoResize: boolean; + private onWindowResize = (): void => { + this.renderer.resize( + computeScale( + this.renderer.virtualWidth, + this.renderer.virtualHeight, + window.innerWidth, + window.innerHeight + ) + ); + }; + + constructor(options: EngineOptions) { + this.renderer = new Renderer(options); + this.camera = new Camera(options.virtualWidth, options.virtualHeight); + this.autoResize = options.autoResize ?? true; + this.loop = new GameLoop( + options.fixedFps, + { + update: (dt) => this.tick(dt), + render: () => this.render() + }, + { maxFps: options.maxFps } + ); + } + + /** + * Инициализация 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 + ); + + // Оверлей затемнения для переходов между сценами — верхний слой UI. + const fade = new Graphics(); + this.renderer.uiRoot.addChild(fade); + this.scenes.setOverlay(fade, this.renderer.virtualWidth, this.renderer.virtualHeight); + + // Дебаг-счётчик FPS (F3) — поверх мира, под фейдом переходов. + this.fpsMeter = new FpsMeter(); + this.renderer.uiRoot.addChild(this.fpsMeter.view); + + if (this.autoResize) { + window.addEventListener('resize', this.onWindowResize); + } + return this.renderer.app; + } + + async start(): Promise { + if (this.started) return; + this.started = true; + this.loop.start(); + } + + stop(): void { + this.loop.stop(); + } + + /** + * Полная зачистка (HMR/выгрузка страницы): цикл, текущая сцена (exit — + * там сцены снимают свои подписки и вьюхи), эффекты, твины, ввод, + * FPS-метр, слушатели окна и Pixi-приложение. После destroy движок не годится. + */ + destroy(): void { + this.stop(); + void this.scenes.current?.exit(); + this.fx.clear(); + this.tweens.clear(); + this.events.clear(); + this.input?.dispose(); + this.fpsMeter?.dispose(); + window.removeEventListener('resize', this.onWindowResize); + this.renderer.app.destroy(true, { children: true }); + } + + /** Счётчик фиксированных шагов с запуска (для агента и отладки). */ + get tickCount(): number { + return this.ticks; + } + + /** Длительность фиксированного шага, сек. */ + get fixedStep(): number { + return this.loop.step; + } + + /** Реальный FPS отрисовки (окно ~0,5 с) — для моста и отладки. */ + get fps(): number { + return this.fpsMeter.fps; + } + + /** Виртуальное разрешение (для пересчёта виртуальных px в CSS). */ + get virtualWidth(): number { + return this.renderer.virtualWidth; + } + + get virtualHeight(): number { + return this.renderer.virtualHeight; + } + + /** Один фиксированный шаг (без рендера) — мост/тесты крутят шаги сами. */ + stepTick(): number { + this.tick(this.loop.step); + this.ticks++; + this.events.emit('engine:tick', { tick: this.ticks }); + return this.ticks; + } + + /** Один кадр рендера без шага логики. */ + renderFrame(): void { + this.render(); + } + + /** + * n фиксированных шагов детерминированно: цикл на время переводится в ручной + * режим, после — тайминги сбрасываются, чтобы rAF не «догонял» пропущенное. + * finally: исключение в шаге не оставляет цикл в ручном режиме навсегда. + */ + stepTicks(n = 1, render = false): number { + this.loop.setManual(true); + try { + for (let i = 0; i < n; i++) { + this.stepTick(); + if (render) this.render(); + } + } finally { + this.loop.setManual(false); + this.loop.resetTiming(); + } + return this.ticks; + } + + /** Один фиксированный шаг. */ + private tick(dt: number): void { + this.input?.update(); // опрос геймпадов до логики + this.camera.update(dt); // тряска и прочее «живое» поведение камеры + this.tweens.update(dt); + this.fx.update(dt); // эффекты/аниматоры поверх твин-движения, до логики сцены + this.scenes.update(dt); + this.input?.endTick(); + } + + private render(): void { + this.fpsMeter?.frame(); + this.camera.apply(this.renderer.worldRoot); + this.scenes.render(); + this.renderer.render(); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/core/EventBus.ts b/v1/packages/engine/src/core/EventBus.ts new file mode 100644 index 0000000..941696c --- /dev/null +++ b/v1/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/v1/packages/engine/src/core/FpsMeter.ts b/v1/packages/engine/src/core/FpsMeter.ts new file mode 100644 index 0000000..9495cd2 --- /dev/null +++ b/v1/packages/engine/src/core/FpsMeter.ts @@ -0,0 +1,69 @@ +import { Container, Graphics } from 'pixi.js'; +import { PixelText } from '../ui/PixelText'; + +/** + * Дебаг-счётчик FPS движка: плашка в левом верхнем углу, среднее за окно + * ~полсекунды (текст перерисовывается не чаще — растеризация дорогая). + * Скрыт по умолчанию; переключение — F3: движок слушает клавишу сам, + * независимо от привязок ввода приложения (preventDefault — F3 в Firefox + * открывает поиск). + */ +export class FpsMeter { + readonly view = new Container(); + private bg = new Graphics(); + private label = new PixelText({ text: 'FPS --', size: 10, color: 0xd7e4ec }); + private frames = 0; + private windowStart = 0; + private lastFps = 0; + private shown = false; + private onKey = (e: KeyboardEvent): void => { + if (e.code !== 'F3') return; + e.preventDefault(); + this.toggle(); + }; + + constructor() { + this.view.addChild(this.bg, this.label); + this.label.position.set(4, 2); + this.view.position.set(2, 2); + this.view.visible = false; + window.addEventListener('keydown', this.onKey); + } + + /** Учёт кадра отрисовки (Engine зовёт из render). */ + frame(): void { + this.frames++; + const now = performance.now(); + const elapsed = now - this.windowStart; + if (elapsed < 500) return; + this.lastFps = Math.round((this.frames * 1000) / elapsed); + this.frames = 0; + this.windowStart = now; + if (!this.shown) return; + this.label.text = `FPS ${this.lastFps}`; + this.bg + .clear() + .rect(0, 0, this.label.width + 8, this.label.height + 4) + .fill({ color: 0x101018, alpha: 0.8 }) + .stroke({ color: 0x8899aa, width: 1 }); + } + + /** Средний FPS за последнее окно (~0,5 с); считается всегда, даже скрытым. */ + get fps(): number { + return this.lastFps; + } + + /** Показать/скрыть; после показа окно начинается с нуля. */ + toggle(): void { + this.shown = !this.shown; + this.view.visible = this.shown; + if (this.shown) { + this.frames = 0; + this.windowStart = performance.now(); + } + } + + dispose(): void { + window.removeEventListener('keydown', this.onKey); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/core/GameLoop.ts b/v1/packages/engine/src/core/GameLoop.ts new file mode 100644 index 0000000..e5ba056 --- /dev/null +++ b/v1/packages/engine/src/core/GameLoop.ts @@ -0,0 +1,104 @@ +/** + * Игровой цикл: фиксированный шаг обновления (60 Гц по умолчанию) + отрисовка + * после каждого шага (рендер с последним состоянием, без интерполяции — + * на 2D-пиксель-арте это стандартный компромисс). Лок кадров (maxFps) режет + * частоту отрисовки: шаги логики копятся и выполняются пачкой. + */ +export interface LoopCallbacks { + /** dt в секундах, всегда фиксированный. */ + update: (dt: number) => void; + render: () => void; +} + +export interface LoopOptions { + /** Лимит отрисовки, кадров/сек (0 — без лимита, по умолчанию). */ + maxFps?: number; +} + +export class GameLoop { + /** Длительность фиксированного шага, сек. */ + readonly step: number; + /** Максимум шагов за кадр (защита от «догоняния» после лагов). */ + maxStepsPerFrame = 5; + + private callbacks: LoopCallbacks; + private rafId = 0; + private lastTime = 0; + private accumulator = 0; + private running = false; + /** Ручной режим: rAF не планирует шаги (их крутит мост/тесты через stepTick). */ + private manual = false; + /** Минимальный интервал между отрисованными кадрами, мс (0 — без лока). */ + private minFrameMs: number; + /** Время последнего отрисованного кадра, мс. */ + private lastFrame = 0; + + constructor(fps: number, callbacks: LoopCallbacks, { maxFps = 0 }: LoopOptions = {}) { + this.step = 1 / fps; + this.callbacks = callbacks; + this.minFrameMs = maxFps > 0 ? 1000 / maxFps : 0; + } + + start(): void { + if (this.running) return; + this.running = true; + this.lastTime = performance.now(); + this.lastFrame = this.lastTime; + this.rafId = requestAnimationFrame(this.tick); + } + + stop(): void { + this.running = false; + cancelAnimationFrame(this.rafId); + } + + /** Ручной режим: кадры rAF не делают ни шагов, ни рендера. */ + setManual(on: boolean): void { + this.manual = on; + if (on) this.resetTiming(); + } + + /** + * Сброс таймингов: аккумулятор и lastTime обнуляются, чтобы следующий + * реальный кадр не «догонял» пропущенное время (до 5 лишних тиков). + */ + resetTiming(): void { + this.accumulator = 0; + this.lastTime = performance.now(); + this.lastFrame = this.lastTime; + } + + private tick = (now: number): void => { + if (!this.running) return; + if (this.manual) { + // Ручной режим: кадр пустой, но rAF и тайминги живут — иначе при + // выходе из ручного режима цикл «догонит» пропущенное время. + this.rafId = requestAnimationFrame(this.tick); + this.accumulator = 0; + this.lastTime = now; + return; + } + this.rafId = requestAnimationFrame(this.tick); + + // Лок кадров: кадр режется, но время не теряется — накопится в + // аккумулятор к следующему пропущенному кадру (шаги догонят пачкой). + if (now - this.lastFrame < this.minFrameMs - 1) return; + this.lastFrame = now; + + 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/v1/packages/engine/src/core/GameState.ts b/v1/packages/engine/src/core/GameState.ts new file mode 100644 index 0000000..b87f693 --- /dev/null +++ b/v1/packages/engine/src/core/GameState.ts @@ -0,0 +1,105 @@ +/** + * Состояние игры: флаги сюжета и именованные переменные. + * Жанронезависимо: движок знает только механику, контент (какие флаги есть) + * определяется игрой. Сериализуется в/из сейва вместе с позицией и прочим. + */ + +export interface GameStateData { + version: number; + flags: string[]; + vars: Record; +} + +export type GameStateMigration = (data: GameStateData) => GameStateData; + +export class GameState { + private flags = new Set(); + private vars = new Map(); + private migrations: GameStateMigration[] = []; + + /** Текущая версия формата данных (растёт при изменениях структуры сейва). */ + dataVersion = 1; + + /** + * Зарегистрировать миграцию: применяется к сейвам с dataVersion меньше + * текущей. Миграции регистрируются по порядку от старых версий к новым. + */ + addMigration(m: GameStateMigration): void { + this.migrations.push(m); + } + + // --- флаги --- + + setFlag(flag: string): void { + this.flags.add(flag); + } + + hasFlag(flag: string): boolean { + return this.flags.has(flag); + } + + clearFlag(flag: string): void { + this.flags.delete(flag); + } + + /** Все флаги (для отладки). */ + get allFlags(): string[] { + return [...this.flags]; + } + + // --- переменные --- + + setVar(key: string, value: number | string | boolean): void { + this.vars.set(key, value); + } + + getVar(key: string): number | string | boolean | undefined { + return this.vars.get(key); + } + + getNumber(key: string): number { + const v = this.vars.get(key); + return typeof v === 'number' ? v : 0; + } + + getString(key: string): string { + const v = this.vars.get(key); + return typeof v === 'string' ? v : ''; + } + + getBool(key: string): boolean { + const v = this.vars.get(key); + return v === true || typeof v === 'number' && v !== 0; + } + + // --- сериализация --- + + serialize(): GameStateData { + return { + version: this.dataVersion, + flags: [...this.flags], + vars: Object.fromEntries(this.vars) + }; + } + + /** Загрузить данные (с применением миграций). Восстанавливает состояние поверх текущего. */ + load(data: GameStateData): void { + let d = data; + // Контракт: миграция сама повышает version. Если она забыла — + // выходим, как только данные догнали текущую версию, чтобы не + // применить следующую миграцию к уже повышенным данным дважды. + for (const m of this.migrations) { + if (d.version >= this.dataVersion) break; + d = m(d); + } + this.dataVersion = d.version; + this.flags = new Set(d.flags); + this.vars = new Map(Object.entries(d.vars)); + } + + /** Сбросить всё (новая игра). */ + reset(): void { + this.flags.clear(); + this.vars.clear(); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/core/Settings.ts b/v1/packages/engine/src/core/Settings.ts new file mode 100644 index 0000000..d9344ec --- /dev/null +++ b/v1/packages/engine/src/core/Settings.ts @@ -0,0 +1,92 @@ +/** + * Настройки игрока: громкости, язык, прочие предпочтения. + * Отдельный стор поверх SaveManager (слот «settings»), не попадает в сейвы игры. + */ + +import type { StorageLike } from '../save/SaveManager'; + +export interface SettingsData { + /** Громкости 0..1. */ + master: number; + music: number; + sfx: number; + /** Громкость амбиента мира (фоновые лупы областей и локальные слои). */ + ambience: number; + /** Язык локализации (BCP-47, например 'ru'). */ + lang: string; + /** Прочие настройки игры (произвольные пары). */ + extra: Record; +} + +export const DEFAULT_SETTINGS: SettingsData = { + master: 1, + music: 1, + sfx: 1, + ambience: 1, + lang: 'ru', + extra: {} +}; + +/** Интерфейс подписки на изменения (например, AudioManager применяет громкости). */ +export type SettingsListener = (s: SettingsData) => void; + +export class Settings { + private current: SettingsData = { ...DEFAULT_SETTINGS, extra: {} }; + private listeners = new Set(); + + constructor(private storage: StorageLike, private slot = 'settings') {} + + get data(): Readonly { + return this.current; + } + + /** Загрузить из хранилища; отсутствующий/битый слот — настройки по умолчанию. */ + load(): void { + try { + const raw = this.storage.getItem(this.slot); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + this.current = { + ...DEFAULT_SETTINGS, + ...parsed, + extra: { ...(parsed.extra ?? {}) } + }; + } + } catch { + this.current = { ...DEFAULT_SETTINGS, extra: {} }; + } + } + + /** Изменить часть настроек, сохранить и уведомить слушателей. */ + update(patch: Partial> & { extra?: Record }): void { + this.current = { + ...this.current, + ...patch, + extra: { ...this.current.extra, ...(patch.extra ?? {}) } + }; + this.save(); + this.emit(); + } + + /** Вернуть настройку из extra (или undefined). */ + getExtra(key: string): number | string | boolean | undefined { + return this.data.extra[key]; + } + + onChange(listener: SettingsListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private save(): void { + try { + this.storage.setItem(this.slot, JSON.stringify(this.data)); + } catch { + // хранилище может быть недоступно (приватный режим) — работаем в памяти + } + } + + private emit(): void { + for (const l of this.listeners) l(this.data); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/core/StateMachine.ts b/v1/packages/engine/src/core/StateMachine.ts new file mode 100644 index 0000000..b802273 --- /dev/null +++ b/v1/packages/engine/src/core/StateMachine.ts @@ -0,0 +1,97 @@ +/** + * Конечный автомат: состояния с enter/exit/update и переходы по событиям. + * Используется для AI врагов, состояний игрока (idle/run/attack), экранов меню и т.п. + */ + +export interface StateDef { + /** Вызывается при входе в состояние. */ + enter?: () => void; + exit?: () => void; + /** dt в секундах, фиксированный шаг. */ + update?: (dt: number) => void; +} + +export class StateMachine { + private states = new Map(); + /** (состояние, событие) -> состояние-цель. */ + private transitions = new Map(); + /** Переходы «из любого состояния» (кроме self-loop): событие -> цель. */ + private anyTransitions = new Map(); + private currentName: string | null = null; + private timeInState = 0; + + constructor( + /** Неизвестное событие в текущем состоянии (по умолчанию — просто игнор). */ + private onUnhandled?: (state: string, event: string) => void + ) {} + + add(name: string, def: StateDef): this { + this.states.set(name, def); + return this; + } + + /** Переход из состояния `from` по событию `event` в состояние `to`. */ + transition(from: string, event: string, to: string): this { + if (!this.states.has(to)) { + throw new Error(`Переход в неизвестное состояние: ${to}`); + } + this.transitions.set(`${from}::${event}`, to); + return this; + } + + /** Перейти в состояние (exit текущего -> enter нового). Повторный вход в то же состояние игнорируется. */ + change(name: string): void { + if (!this.states.has(name)) { + throw new Error(`Неизвестное состояние: ${name}`); + } + if (this.currentName === name) return; + if (this.currentName) this.states.get(this.currentName)!.exit?.(); + this.currentName = name; + this.timeInState = 0; + this.states.get(name)!.enter?.(); + } + + /** + * Переход по событию из ЛЮБОГО состояния (кроме повторного входа в цель). + * Удобно для hurt/die в бою: не регистрировать переход из каждого состояния. + * Обычные переходы имеют приоритет над transitionAny. + */ + transitionAny(event: string, to: string): this { + if (!this.states.has(to)) { + throw new Error(`Переход в неизвестное состояние: ${to}`); + } + this.anyTransitions.set(event, to); + return this; + } + + /** Обработать событие: обычный переход текущего состояния, затем «из любого». */ + handleEvent(event: string): void { + if (!this.currentName) return; + const to = this.transitions.get(`${this.currentName}::${event}`) ?? this.anyTransitions.get(event); + if (to) { + this.change(to); + } else { + this.onUnhandled?.(this.currentName, event); + } + } + + get current(): string | null { + return this.currentName; + } + + /** Секунд с момента входа в текущее состояние. */ + get time(): number { + return this.timeInState; + } + + update(dt: number): void { + if (!this.currentName) return; + this.timeInState += dt; + this.states.get(this.currentName)!.update?.(dt); + } + + reset(): void { + this.currentName = null; + this.timeInState = 0; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/core/Tween.ts b/v1/packages/engine/src/core/Tween.ts new file mode 100644 index 0000000..b747294 --- /dev/null +++ b/v1/packages/engine/src/core/Tween.ts @@ -0,0 +1,137 @@ +import { EaseFn, linear } from './easing'; + +/** + * Твины и таймеры: плавное изменение числовых свойств объекта и отложенные вызовы. + * Менеджер обновляется игровым циклом (движок делает это сам в Engine.tick). + */ + +export interface TweenOptions { + /** Длительность в секундах. */ + duration: number; + /** Функция сглаживания (по умолчанию linear). */ + ease?: EaseFn; + /** Задержка до старта в секундах. */ + delay?: number; + /** Вызывается по завершении. */ + onDone?: () => void; +} + +/** Управление конкретным твином/таймером: можно отменить. */ +export interface TweenHandle { + cancel(): void; +} + +interface ActiveTween { + target: Record; + props: Record; + from: Record | null; // снимается при старте (после delay) + elapsed: number; + options: TweenOptions; + cancelled: boolean; +} + +interface ActiveDelay { + remaining: number; + fn: () => void; + cancelled: boolean; +} + +export class TweenManager { + private tweens: ActiveTween[] = []; + private delays: ActiveDelay[] = []; + + /** + * Плавно привести числовые свойства target к заданным значениям. + * Начальные значения снимаются в момент старта (после delay). + */ + to(target: object, props: Record, options: TweenOptions): TweenHandle { + const tween: ActiveTween = { + target: target as Record, + props, + from: null, + elapsed: 0, + options: { ease: linear, ...options }, + cancelled: false + }; + this.tweens.push(tween); + return { cancel: () => (tween.cancelled = true) }; + } + + /** Вызвать fn через seconds секунд. */ + delay(seconds: number, fn: () => void): TweenHandle { + const d: ActiveDelay = { remaining: seconds, fn, cancelled: false }; + this.delays.push(d); + return { cancel: () => (d.cancelled = true) }; + } + + /** Отменить все твины, изменяющие target (таймеры не привязаны к target). */ + cancelFor(target: object): void { + for (const t of this.tweens) { + if (t.target === target) t.cancelled = true; + } + this.tweens = this.tweens.filter((t) => !t.cancelled); + } + + /** Отменить всё. */ + clear(): void { + for (const t of this.tweens) t.cancelled = true; + for (const d of this.delays) d.cancelled = true; + this.tweens = []; + this.delays = []; + } + + /** dt в секундах (фиксированный шаг). */ + update(dt: number): void { + // Твины: компакт на месте (без filter — тик не должен аллоцировать). + let w = 0; + for (const t of this.tweens) { + if (t.cancelled) continue; + const delay = t.options.delay ?? 0; + let dtLeft = dt; + if (t.elapsed < delay) { + const skipped = Math.min(dtLeft, delay - t.elapsed); + t.elapsed += skipped; + dtLeft -= skipped; + if (dtLeft <= 0) { + this.tweens[w++] = t; // dt целиком ушёл в delay — твин остаётся + continue; + } + } + if (!t.from) { + t.from = {}; + for (const key of Object.keys(t.props)) { + t.from[key] = t.target[key] ?? 0; + } + } + t.elapsed += dtLeft; + const raw = Math.min(1, (t.elapsed - (t.options.delay ?? 0)) / t.options.duration); + const k = t.options.ease!(raw); + for (const key of Object.keys(t.props)) { + t.target[key] = t.from[key] + (t.props[key] - t.from[key]) * k; + } + if (raw >= 1) { + t.cancelled = true; + t.options.onDone?.(); + } + if (!t.cancelled) this.tweens[w++] = t; + } + this.tweens.length = w; + + // Таймеры + w = 0; + for (const d of this.delays) { + if (d.cancelled) continue; + d.remaining -= dt; + if (d.remaining <= 0) { + d.cancelled = true; + d.fn(); + } + if (!d.cancelled) this.delays[w++] = d; + } + this.delays.length = w; + } + + get activeCount(): number { + return this.tweens.filter((t) => !t.cancelled).length + this.delays.filter((d) => !d.cancelled).length; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/core/Updatables.ts b/v1/packages/engine/src/core/Updatables.ts new file mode 100644 index 0000000..1e00c86 --- /dev/null +++ b/v1/packages/engine/src/core/Updatables.ts @@ -0,0 +1,57 @@ +/** + * Реестр объектов, тикаемых движком от фиксированного шага: эффекты, + * аниматоры, вспышки. Инстанс живёт в Engine (`engine.fx`) и тикается + * между tween-ами и сценами; допускается и локальный инстанс в сцене. + * + * Владение отсутствует: запись с `destroyed === true` (Pixi-объекты + * выставляют его при destroy) вычищается сама при очередном update. + */ + +/** Всё, что движок может тикать; destroyed — признак самозачистки. */ +export interface Updatable { + update(dt: number): void; + destroyed?: boolean; +} + +export class Updatables { + private list: Updatable[] = []; + + /** Добавить и вернуть тот же объект — удобно: `engine.fx.add(emitter)`. */ + add(u: Updatable): Updatable { + if (!this.list.includes(u)) this.list.push(u); + return u; + } + + remove(u: Updatable): void { + const i = this.list.indexOf(u); + if (i !== -1) this.list.splice(i, 1); + } + + clear(): void { + this.list.length = 0; + } + + /** Обойти живые записи (например, чтобы погасить все эффекты слоя). */ + forEach(fn: (u: Updatable) => void): void { + for (const u of this.list) fn(u); + } + + /** + * Тик в порядке добавления; разрушенные записи не тикаются (Pixi после + * destroy обнуляет transform — касаться их нельзя) и вычищаются + * компактом на месте (без filter — тик не должен аллоцировать). + */ + update(dt: number): void { + let w = 0; + for (const u of this.list) { + if (u.destroyed) continue; + u.update(dt); + if (!u.destroyed) this.list[w++] = u; // update мог разрушить сам объект + } + this.list.length = w; + } + + get count(): number { + return this.list.length; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/core/__tests__/Cooldown.test.ts b/v1/packages/engine/src/core/__tests__/Cooldown.test.ts new file mode 100644 index 0000000..3ac915f --- /dev/null +++ b/v1/packages/engine/src/core/__tests__/Cooldown.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { Cooldown } from '../Cooldown'; + +describe('Cooldown', () => { + it('изначально готов', () => { + const cd = new Cooldown(1); + expect(cd.ready).toBe(true); + expect(cd.timeRemaining).toBe(0); + expect(cd.progress).toBe(0); + }); + + it('trigger запускает отсчёт, update уменьшает', () => { + const cd = new Cooldown(0.5); + expect(cd.trigger()).toBe(true); + expect(cd.ready).toBe(false); + cd.update(0.2); + expect(cd.timeRemaining).toBeCloseTo(0.3); + cd.update(0.3); + expect(cd.ready).toBe(true); + }); + + it('trigger не срабатывает, пока не готов', () => { + const cd = new Cooldown(1); + cd.trigger(); + expect(cd.trigger()).toBe(false); + cd.update(1); + expect(cd.trigger()).toBe(true); + }); + + it('triggerForce перебивает', () => { + const cd = new Cooldown(2); + cd.trigger(); + cd.update(1.5); + cd.triggerForce(); + expect(cd.timeRemaining).toBeCloseTo(2); + }); + + it('progress: 1 сразу после trigger, 0 к концу', () => { + const cd = new Cooldown(1); + cd.trigger(); + expect(cd.progress).toBeCloseTo(1); + cd.update(0.5); + expect(cd.progress).toBeCloseTo(0.5); + cd.update(0.5); + expect(cd.progress).toBe(0); + }); + + it('обновление больше остатка не уводит в минус', () => { + const cd = new Cooldown(0.1); + cd.trigger(); + cd.update(5); + expect(cd.ready).toBe(true); + expect(cd.timeRemaining).toBe(0); + }); + + it('нулевой кулдаун всегда готов', () => { + const cd = new Cooldown(0); + cd.triggerForce(); + expect(cd.ready).toBe(true); + expect(cd.progress).toBe(0); + }); + + it('reset возвращает готовность', () => { + const cd = new Cooldown(3); + cd.trigger(); + cd.reset(); + expect(cd.ready).toBe(true); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/core/__tests__/Engine.test.ts b/v1/packages/engine/src/core/__tests__/Engine.test.ts new file mode 100644 index 0000000..1569351 --- /dev/null +++ b/v1/packages/engine/src/core/__tests__/Engine.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Pixi-зависимости — минимальные стабы: Engine в Node не инициализирует WebGL +// (init() не зовётся), но конструктор Renderer создаёт Application сразу. +vi.mock('pixi.js', () => { + class Container { + position = { set: () => undefined }; + scale = { set: () => undefined }; + visible = true; + destroyed = false; + addChild(..._c: unknown[]) { + return this; + } + destroy(_opts?: unknown): void {} + } + class Graphics extends Container {} + class Application { + stage = new Container(); + canvas = { style: {} as Record }; + renderer = { render: () => undefined }; + async init(): Promise {} + render(): void {} + destroy(_a?: unknown, _b?: unknown): void {} + } + class Text extends Container {} + return { Container, Graphics, Application, Text }; +}); + +import { Engine } from '../Engine'; +import type { Scene } from '../../scene/SceneManager'; + +function makeEngine(): Engine { + return new Engine({ + virtualWidth: 480, + virtualHeight: 270, + scale: 2, + background: 0x000000, + parent: {} as HTMLElement, + fixedFps: 60 + }); +} + +function makeScene(update: (dt: number) => void = () => {}, render: () => void = () => {}): Scene { + return { enter: () => {}, exit: () => {}, update, render }; +} + +describe('Engine (шаги без цикла)', () => { + it('stepTick: фиксированный шаг, dt = 1/fps в сцену, событие engine:tick', () => { + const engine = makeEngine(); + const dts: number[] = []; + void engine.scenes.push(makeScene((dt) => dts.push(dt))); + const ticks: number[] = []; + engine.events.on<{ tick: number }>('engine:tick', (p) => ticks.push(p!.tick)); + + engine.stepTicks(3); + + expect(engine.tickCount).toBe(3); + expect(dts).toEqual([1 / 60, 1 / 60, 1 / 60]); + expect(ticks).toEqual([1, 2, 3]); + }); + + it('stepTicks(render: true) вызывает render сцены', () => { + const engine = makeEngine(); + let renders = 0; + void engine.scenes.push(makeScene(() => {}, () => renders++)); + + engine.stepTicks(2, true); + expect(renders).toBe(2); + + renders = 0; + engine.stepTicks(2, false); // без рендера — только логика + expect(renders).toBe(0); + }); + + it('tickCount копится между вызовами, fixedStep = 1/fps', () => { + const engine = makeEngine(); + expect(engine.fixedStep).toBeCloseTo(1 / 60, 10); + engine.stepTicks(5); + expect(engine.tickCount).toBe(5); + engine.stepTicks(2); + expect(engine.tickCount).toBe(7); + }); + + it('исключение в шаге не оставляет счётчики вранья: тик не засчитан', () => { + const engine = makeEngine(); + void engine.scenes.push( + makeScene(() => { + throw new Error('бум'); + }) + ); + expect(() => engine.stepTick()).toThrow('бум'); + // Счётчик и событие не продвинулись — шаг не доигран. + expect(engine.tickCount).toBe(0); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/core/__tests__/EventBus.test.ts b/v1/packages/engine/src/core/__tests__/EventBus.test.ts new file mode 100644 index 0000000..f946f2a --- /dev/null +++ b/v1/packages/engine/src/core/__tests__/EventBus.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EventBus } from '../EventBus'; + +describe('EventBus', () => { + it('emit доставляет payload подписчикам темы', () => { + const bus = new EventBus(); + const got: number[] = []; + bus.on('hit', (n) => got.push(n)); + bus.emit('hit', 5); + bus.emit('hit', 7); + expect(got).toEqual([5, 7]); + }); + + it('чужая тема не получает событий; emit без подписчиков — no-op', () => { + const bus = new EventBus(); + const got: string[] = []; + bus.on('a', (s) => got.push(s)); + expect(() => bus.emit('b', 'x')).not.toThrow(); + bus.emit('a', 'y'); + expect(got).toEqual(['y']); + }); + + it('on возвращает off-функцию: отписка снимает только этот хендлер', () => { + const bus = new EventBus(); + const a = vi.fn(); + const b = vi.fn(); + const offA = bus.on('t', a); + bus.on('t', b); + + offA(); + bus.emit('t'); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledTimes(1); + }); + + it('повторная off-отписка не роняет (idempotent)', () => { + const bus = new EventBus(); + const fn = vi.fn(); + const off = bus.on('t', fn); + off(); + expect(() => off()).not.toThrow(); + }); + + it('emit во время обработки: подписка внутри хендлера не роняет обход (снапшот Set)', () => { + const bus = new EventBus(); + const late = vi.fn(); + let added = false; + bus.on('t', () => { + if (!added) { + added = true; + bus.on('t', late); + } + }); + + bus.emit('t'); // снапшот на обходе: late на этом emit ещё не зовётся + expect(late).not.toHaveBeenCalled(); + bus.emit('t'); + expect(late).toHaveBeenCalledTimes(1); + }); + + it('clear снимает все подписки', () => { + const bus = new EventBus(); + const fn = vi.fn(); + bus.on('t', fn); + bus.clear(); + bus.emit('t'); + expect(fn).not.toHaveBeenCalled(); + }); + + it('один хендлер, подписанный дважды, зовётся один раз (Set)', () => { + const bus = new EventBus(); + const fn = vi.fn(); + bus.on('t', fn); + bus.on('t', fn); + bus.emit('t'); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/core/__tests__/FpsMeter.test.ts b/v1/packages/engine/src/core/__tests__/FpsMeter.test.ts new file mode 100644 index 0000000..2bf7708 --- /dev/null +++ b/v1/packages/engine/src/core/__tests__/FpsMeter.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; + +// Pixi-зависимости (Text из PixelText, Graphics, Container) — минимальные стабы. +vi.mock('pixi.js', () => { + class Container { + children: unknown[] = []; + position = { set: () => undefined }; + visible = true; + addChild(...c: unknown[]) { + this.children.push(...c); + return this; + } + } + class Text { + text = ''; + width = 30; + height = 10; + position = { set: () => undefined }; + constructor(opts?: { text?: string }) { + this.text = opts?.text ?? ''; + } + } + class Graphics { + clear() { + return this; + } + rect() { + return this; + } + fill() { + return this; + } + stroke() { + return this; + } + } + return { Container, Text, Graphics }; +}); + +import { FpsMeter } from '../FpsMeter'; + +/** Заглушка performance.now + keydown: клик по F3 и продвижение времени вручную. */ +function installEnv() { + let now = 0; + const listeners: ((e: { code: string; preventDefault: () => void }) => void)[] = []; + vi.stubGlobal('performance', { now: () => now }); + vi.stubGlobal('window', { + addEventListener: (_: string, cb: (e: { code: string; preventDefault: () => void }) => void) => listeners.push(cb), + removeEventListener: (_: string, cb: unknown) => { + const i = listeners.indexOf(cb as never); + if (i >= 0) listeners.splice(i, 1); + } + }); + return { + advance: (ms: number) => { + now += ms; + }, + pressF3: () => { + for (const cb of [...listeners]) cb({ code: 'F3', preventDefault: () => undefined }); + }, + listenerCount: () => listeners.length + }; +} + +/** Текст счётчика (второй ребёнок view — label). */ +function labelOf(meter: FpsMeter): string { + return (meter.view.children[1] as unknown as { text: string }).text; +} + +describe('FpsMeter', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('скрыт по умолчанию; F3 показывает, повторное — прячет', () => { + const env = installEnv(); + const meter = new FpsMeter(); + expect(meter.view.visible).toBe(false); + + env.pressF3(); + expect(meter.view.visible).toBe(true); + env.pressF3(); + expect(meter.view.visible).toBe(false); + + meter.dispose(); + expect(env.listenerCount()).toBe(0); + }); + + it('считает кадры и обновляет текст раз в ~полсекунды', () => { + const env = installEnv(); + const meter = new FpsMeter(); + meter.toggle(); // окно открыто на now = 0 + + env.advance(100); + meter.frame(); // кадр 1 + env.advance(16); + meter.frame(); // кадр 2 + expect(labelOf(meter)).toBe('FPS --'); // окно не дозрело + + env.advance(420); // суммарно ~536 мс с показа + meter.frame(); + expect(labelOf(meter)).toBe('FPS 6'); // 3 кадра за 536 мс + + meter.dispose(); + }); + + it('после нового показа окно чистое: счётчики сброшены toggle-ом', () => { + const env = installEnv(); + const meter = new FpsMeter(); + meter.toggle(); + env.advance(600); + meter.frame(); // 1 кадр за 600 мс + expect(labelOf(meter)).toBe('FPS 2'); + + meter.toggle(); // скрыть + env.advance(600); + meter.frame(); // скрыт — не считается + meter.toggle(); // показать заново: счётчики сброшены + env.advance(600); + meter.frame(); + expect(labelOf(meter)).toBe('FPS 2'); // без утечки прежних кадров + + meter.dispose(); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/core/__tests__/GameLoop.timing.test.ts b/v1/packages/engine/src/core/__tests__/GameLoop.timing.test.ts new file mode 100644 index 0000000..9d92b3d --- /dev/null +++ b/v1/packages/engine/src/core/__tests__/GameLoop.timing.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { GameLoop } from '../GameLoop'; + +/** Заглушки rAF: frame() продвигает кадры вручную. */ +function installRaf(): (advanceMs: number) => void { + let now = 0; + const callbacks: ((t: number) => void)[] = []; + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => { + callbacks.push(cb); + return callbacks.length; + }); + vi.stubGlobal('cancelAnimationFrame', () => undefined); + vi.stubGlobal('performance', { now: () => now }); + return (advanceMs: number) => { + now += advanceMs; + const list = [...callbacks]; + callbacks.length = 0; + for (const cb of list) cb(now); + }; +} + +describe('GameLoop — ручной режим', () => { + it('setManual(true) гасит кадры, resetTiming не даёт догонять', () => { + const frame = installRaf(); + const updates: number[] = []; + const loop = new GameLoop(60, { + update: (dt) => updates.push(dt), + render: () => undefined + }); + loop.start(); + frame(1000); // ~60 шагов сгущаются в максимум 5 + expect(updates.length).toBe(5); + + loop.setManual(true); + frame(1000); + expect(updates.length).toBe(5); // кадры в ручном режиме пустые + + loop.setManual(false); + frame(20); // resetTiming в setManual(true) обнулил аккумулятор — без «догоняния» + expect(updates.length).toBe(6); // ровно один шаг, а не 5+5 + loop.stop(); + vi.unstubAllGlobals(); + }); +}); + +describe('GameLoop — лок кадров (maxFps)', () => { + it('рендер не чаще лимита, шаги копятся и идут пачкой', () => { + const frame = installRaf(); + const updates: number[] = []; + let renders = 0; + const loop = new GameLoop( + 60, + { + update: (dt) => updates.push(dt), + render: () => renders++ + }, + { maxFps: 30 } + ); + loop.start(); + + frame(17); // 17 < 32-1: кадр срезан, ни рендера, ни шагов + expect(renders).toBe(0); + expect(updates.length).toBe(0); + + frame(17); // 34 мс с прошлого рендера: один кадр = 2 шага 60 Гц + expect(renders).toBe(1); + expect(updates.length).toBe(2); + expect(updates[0]).toBeCloseTo(1 / 60); + + frame(17); // снова срезан + frame(100); // крупный скачок: догоняет пачкой до maxStepsPerFrame + expect(renders).toBe(2); + expect(updates.length).toBe(7); // 2 + 5 (лимит за кадр) + loop.stop(); + vi.unstubAllGlobals(); + }); + + it('maxFps = 0 (по умолчанию) — без лока: рендер на каждом rAF', () => { + const frame = installRaf(); + let renders = 0; + const loop = new GameLoop(60, { + update: () => undefined, + render: () => renders++ + }); + loop.start(); + frame(16); + frame(16); + expect(renders).toBe(2); + loop.stop(); + vi.unstubAllGlobals(); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/core/__tests__/GameState.test.ts b/v1/packages/engine/src/core/__tests__/GameState.test.ts new file mode 100644 index 0000000..5f02f4f --- /dev/null +++ b/v1/packages/engine/src/core/__tests__/GameState.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { GameState } from '../GameState'; + +describe('GameState', () => { + it('флаги устанавливаются, проверяются и снимаются', () => { + const s = new GameState(); + expect(s.hasFlag('met_elder')).toBe(false); + s.setFlag('met_elder'); + expect(s.hasFlag('met_elder')).toBe(true); + s.clearFlag('met_elder'); + expect(s.hasFlag('met_elder')).toBe(false); + }); + + it('переменные типизированы по геттерам', () => { + const s = new GameState(); + s.setVar('gold', 42); + s.setVar('name', 'Ирвин'); + expect(s.getNumber('gold')).toBe(42); + expect(s.getString('name')).toBe('Ирвин'); + expect(s.getNumber('name')).toBe(0); // не число -> 0 + expect(s.getString('gold')).toBe(''); // не строка -> '' + expect(s.getVar('missing')).toBeUndefined(); + }); + + it('serialize/load сохраняет состояние', () => { + const s = new GameState(); + s.setFlag('a'); + s.setVar('hp', 7); + const data = s.serialize(); + + const s2 = new GameState(); + s2.load(data); + expect(s2.hasFlag('a')).toBe(true); + expect(s2.getNumber('hp')).toBe(7); + expect(s2.dataVersion).toBe(1); + }); + + it('миграция применяется к сейву старой версии', () => { + const s = new GameState(); + s.dataVersion = 2; + s.addMigration((d) => ({ + ...d, + version: 2, + vars: { ...d.vars, gold: 100 } // в v2 появилось золото + })); + + s.load({ version: 1, flags: ['x'], vars: {} }); + expect(s.dataVersion).toBe(2); + expect(s.getNumber('gold')).toBe(100); + expect(s.hasFlag('x')).toBe(true); + }); + + it('две миграции применяются по порядку, по одной на версию', () => { + const s = new GameState(); + s.dataVersion = 3; + s.addMigration((d) => ({ ...d, version: 2, flags: [...d.flags, 'step1'] })); + s.addMigration((d) => ({ ...d, version: 3, flags: [...d.flags, 'step2'] })); + + s.load({ version: 1, flags: [], vars: {} }); + expect(s.allFlags).toEqual(['step1', 'step2']); + }); + + it('миграция без повышения version не применяется дважды', () => { + const s = new GameState(); + s.dataVersion = 2; + // Миграция нарушает контракт (забыла version) — цикл всё равно + // не даёт следующей миграции испортить данные. + s.addMigration((d) => ({ ...d, vars: { ...d.vars, n: Number(d.vars.n ?? 0) + 1 } })); + s.addMigration((d) => ({ ...d, version: 2, flags: ['done'] })); + + s.load({ version: 1, flags: [], vars: {} }); + expect(s.getNumber('n')).toBe(1); + expect(s.hasFlag('done')).toBe(true); + }); + + it('reset очищает всё', () => { + const s = new GameState(); + s.setFlag('a'); + s.setVar('b', 1); + s.reset(); + expect(s.allFlags).toEqual([]); + expect(s.getVar('b')).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/core/__tests__/Settings.test.ts b/v1/packages/engine/src/core/__tests__/Settings.test.ts new file mode 100644 index 0000000..339dbae --- /dev/null +++ b/v1/packages/engine/src/core/__tests__/Settings.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { Settings } from '../Settings'; +import type { StorageLike } from '../../save/SaveManager'; + +function memoryStorage(): StorageLike & { store: Map } { + const store = new Map(); + return { + store, + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => void store.set(k, v), + removeItem: (k) => void store.delete(k), + key: (i) => [...store.keys()][i] ?? null, + get length() { + return store.size; + } + }; +} + +describe('Settings', () => { + it('отсутствующий слот даёт настройки по умолчанию', () => { + const s = new Settings(memoryStorage()); + s.load(); + expect(s.data.master).toBe(1); + expect(s.data.lang).toBe('ru'); + }); + + it('update применяет патч, сохраняет в хранилище и уведомляет слушателей', () => { + const storage = memoryStorage(); + const s = new Settings(storage); + s.load(); + + let notified = 0; + s.onChange(() => notified++); + s.update({ master: 0.5, music: 0.2 }); + + expect(notified).toBe(1); + expect(s.data.master).toBe(0.5); + const saved = JSON.parse(storage.store.get('settings')!); + expect(saved.master).toBe(0.5); + expect(saved.music).toBe(0.2); + + // новый экземпляр читает то же + const s2 = new Settings(storage); + s2.load(); + expect(s2.data.master).toBe(0.5); + }); + + it('битый JSON в хранилище не роняет загрузку', () => { + const storage = memoryStorage(); + storage.store.set('settings', '{oops'); + const s = new Settings(storage); + expect(() => s.load()).not.toThrow(); + expect(s.data.master).toBe(1); + }); + + it('extra хранит произвольные значения', () => { + const s = new Settings(memoryStorage()); + s.load(); + s.update({ extra: { hintSeen: true } }); + expect(s.getExtra('hintSeen')).toBe(true); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/core/__tests__/StateMachine.test.ts b/v1/packages/engine/src/core/__tests__/StateMachine.test.ts new file mode 100644 index 0000000..e2f7bea --- /dev/null +++ b/v1/packages/engine/src/core/__tests__/StateMachine.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { StateMachine } from '../StateMachine'; + +describe('StateMachine', () => { + it('change вызывает exit предыдущего и enter нового', () => { + const log: string[] = []; + const sm = new StateMachine(); + sm.add('idle', { + enter: () => log.push('enter:idle'), + exit: () => log.push('exit:idle') + }); + sm.add('walk', { + enter: () => log.push('enter:walk') + }); + + sm.change('idle'); + sm.change('walk'); + expect(log).toEqual(['enter:idle', 'exit:idle', 'enter:walk']); + expect(sm.current).toBe('walk'); + }); + + it('повторный вход в то же состояние игнорируется', () => { + let enters = 0; + const sm = new StateMachine(); + sm.add('a', { enter: () => enters++ }); + sm.change('a'); + sm.change('a'); + expect(enters).toBe(1); + }); + + it('handleEvent выполняет переход по таблице', () => { + const sm = new StateMachine(); + sm.add('patrol', {}); + sm.add('chase', {}); + sm.transition('patrol', 'seePlayer', 'chase'); + sm.transition('chase', 'losePlayer', 'patrol'); + + sm.change('patrol'); + sm.handleEvent('seePlayer'); + expect(sm.current).toBe('chase'); + sm.handleEvent('losePlayer'); + expect(sm.current).toBe('patrol'); + }); + + it('неизвестное событие попадает в onUnhandled', () => { + let handled: string | undefined; + const sm = new StateMachine((state, event) => (handled = `${state}:${event}`)); + sm.add('idle', {}); + sm.change('idle'); + sm.handleEvent('boom'); + expect(handled).toBe('idle:boom'); + expect(sm.current).toBe('idle'); + }); + + it('update тикает текущее состояние и считает время в нём', () => { + let t = 0; + const sm = new StateMachine(); + sm.add('a', { update: (dt) => (t += dt) }); + sm.add('b', {}); + sm.change('a'); + sm.update(0.5); + sm.update(0.5); + expect(t).toBeCloseTo(1); + expect(sm.time).toBeCloseTo(1); + sm.change('b'); + expect(sm.time).toBe(0); + }); + + it('change в неизвестное состояние бросает', () => { + const sm = new StateMachine(); + expect(() => sm.change('nope')).toThrow(); + }); + + it('transitionAny срабатывает из любого состояния', () => { + const sm = new StateMachine(); + sm.add('idle', {}); + sm.add('chase', {}); + sm.add('windup', {}); + sm.add('dead', { enter: () => deadEntered++ }); + sm.transition('idle', 'wake', 'chase'); + sm.transitionAny('die', 'dead'); + let deadEntered = 0; + + sm.change('idle'); + sm.handleEvent('die'); + expect(sm.current).toBe('dead'); + expect(deadEntered).toBe(1); + + // и после смены состояния переход «из любого» продолжает работать + sm.change('windup'); + sm.handleEvent('die'); + expect(sm.current).toBe('dead'); + expect(deadEntered).toBe(2); + }); + + it('обычный переход приоритетнее transitionAny', () => { + const sm = new StateMachine(); + sm.add('a', {}); + sm.add('b', {}); + sm.add('c', {}); + sm.transitionAny('go', 'b'); + sm.transition('a', 'go', 'c'); + sm.change('a'); + sm.handleEvent('go'); + expect(sm.current).toBe('c'); + }); + + it('transitionAny в неизвестное состояние бросает', () => { + const sm = new StateMachine(); + sm.add('a', {}); + expect(() => sm.transitionAny('go', 'nope')).toThrow(); + }); + + it('transitionAny не даёт повторного входа в то же состояние', () => { + let enters = 0; + const sm = new StateMachine(); + sm.add('loop', { enter: () => enters++ }); + sm.transitionAny('again', 'loop'); + sm.change('loop'); + sm.handleEvent('again'); + expect(enters).toBe(1); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/core/__tests__/Tween.test.ts b/v1/packages/engine/src/core/__tests__/Tween.test.ts new file mode 100644 index 0000000..72264b2 --- /dev/null +++ b/v1/packages/engine/src/core/__tests__/Tween.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { TweenManager } from '../Tween'; +import { linear, quadOut, cubicIn, sineInOut, quadIn, cubicOut } from '../easing'; + +describe('easing', () => { + it('края области определения всегда 0 и 1', () => { + for (const e of [linear, quadIn, quadOut, cubicIn, cubicOut, sineInOut]) { + expect(e(0)).toBeCloseTo(0); + expect(e(1)).toBeCloseTo(1); + } + }); + + it('quadOut в середине быстрее linear', () => { + expect(quadOut(0.5)).toBeGreaterThan(0.5); + expect(cubicIn(0.5)).toBeLessThan(0.5); + }); +}); + +describe('TweenManager', () => { + it('линейный твин доходит до цели за duration', () => { + const tw = new TweenManager(); + const obj = { x: 0 }; + tw.to(obj, { x: 10 }, { duration: 2 }); + + tw.update(1); + expect(obj.x).toBeCloseTo(5); + tw.update(1); + expect(obj.x).toBeCloseTo(10); + expect(tw.activeCount).toBe(0); + }); + + it('не проходит цель и вызывает onDone один раз', () => { + const tw = new TweenManager(); + const obj = { y: 0 }; + let done = 0; + tw.to(obj, { y: 8 }, { duration: 1, onDone: () => done++ }); + + tw.update(0.7); + expect(obj.y).toBeCloseTo(5.6); + tw.update(0.7); // переполнение — зажимается + expect(obj.y).toBeCloseTo(8); + expect(done).toBe(1); + + tw.update(5); + expect(done).toBe(1); + }); + + it('ease применяется к прогрессу', () => { + const tw = new TweenManager(); + const obj = { v: 0 }; + tw.to(obj, { v: 100 }, { duration: 2, ease: cubicIn }); + tw.update(1); + expect(obj.v).toBeCloseTo(100 * cubicIn(0.5)); + expect(obj.v).toBeLessThan(50); + }); + + it('delay откладывает старт: значения не трогаются до истечения', () => { + const tw = new TweenManager(); + const obj = { x: 1 }; + tw.to(obj, { x: 5 }, { duration: 1, delay: 0.5 }); + + tw.update(0.4); + expect(obj.x).toBe(1); // ещё в delay + tw.update(0.6); // delay закончился, первый кадр движения + expect(obj.x).toBeGreaterThan(1); + tw.update(1); + expect(obj.x).toBeCloseTo(5); + }); + + it('delay(seconds, fn) вызывает fn один раз', () => { + const tw = new TweenManager(); + let calls = 0; + tw.delay(0.5, () => calls++); + tw.update(0.3); + expect(calls).toBe(0); + tw.update(0.3); + expect(calls).toBe(1); + tw.update(1); + expect(calls).toBe(1); + }); + + it('cancel останавливает твин', () => { + const tw = new TweenManager(); + const obj = { x: 0 }; + const h = tw.to(obj, { x: 10 }, { duration: 1 }); + tw.update(0.5); + h.cancel(); + tw.update(0.5); + expect(obj.x).toBeCloseTo(5); + expect(tw.activeCount).toBe(0); + }); + + it('cancelFor отменяет только твины целевого объекта', () => { + const tw = new TweenManager(); + const a = { x: 0 }; + const b = { x: 0 }; + tw.to(a, { x: 10 }, { duration: 1 }); + tw.to(b, { x: 10 }, { duration: 1 }); + tw.cancelFor(a); + tw.update(1); + expect(a.x).toBe(0); + expect(b.x).toBeCloseTo(10); + }); + + it('начальные значения снимаются при старте (после delay), а не при создании', () => { + const tw = new TweenManager(); + const obj = { x: 0 }; + tw.to(obj, { x: 10 }, { duration: 1, delay: 1 }); + obj.x = 4; // изменили до старта + tw.update(1.5); // delay прошёл, половина движения + expect(obj.x).toBeCloseTo(7); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/core/__tests__/Updatables.test.ts b/v1/packages/engine/src/core/__tests__/Updatables.test.ts new file mode 100644 index 0000000..33d854d --- /dev/null +++ b/v1/packages/engine/src/core/__tests__/Updatables.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { Updatables, type Updatable } from '../Updatables'; + +/** Фейковый тикаемый: считает вызовы, может пометить себя destroyed. */ +function make(log: string[], name: string, destroyed = false): Updatable { + return { + destroyed, + update(dt: number) { + log.push(`${name}@${dt}`); + } + }; +} + +describe('Updatables', () => { + it('тикает в порядке добавления', () => { + const log: string[] = []; + const r = new Updatables(); + r.add(make(log, 'a')); + r.add(make(log, 'b')); + r.update(1 / 60); + expect(log).toEqual(['a@0.016666666666666666', 'b@0.016666666666666666']); + expect(r.count).toBe(2); + }); + + it('add возвращает тот же объект (chaining)', () => { + const u: Updatable = { update() {} }; + expect(new Updatables().add(u)).toBe(u); + }); + + it('add не дублирует и remove убирает', () => { + const r = new Updatables(); + const u: Updatable = { update() {} }; + r.add(u); + r.add(u); + expect(r.count).toBe(1); + r.remove(u); + expect(r.count).toBe(0); + r.remove(u); // отсутствие — не ошибка + }); + + it('destroyed-записи не тикаются и вычищаются после прохода', () => { + const log: string[] = []; + const r = new Updatables(); + r.add(make(log, 'zombie', true)); + r.add(make(log, 'alive')); + r.update(0.1); + expect(log).toEqual(['alive@0.1']); // разрушенный не тикается (transform обнулён) + expect(r.count).toBe(1); + r.update(0.1); + expect(log).toEqual(['alive@0.1', 'alive@0.1']); + }); + + it('запись, разрушенная в чужом update, не тикается на следующем проходе', () => { + const log: string[] = []; + const r = new Updatables(); + const victim = make(log, 'victim'); + r.add(victim); + r.add({ + update(dt: number) { + log.push(`killer@${dt}`); + victim.destroyed = true; // сцена разрушила вьюху между тиками + } + }); + r.update(0.1); + expect(log).toEqual(['victim@0.1', 'killer@0.1']); + r.update(0.1); + expect(log).toEqual(['victim@0.1', 'killer@0.1', 'killer@0.1']); // victim молчит + expect(r.count).toBe(1); + }); + + it('clear опустошает реестр', () => { + const r = new Updatables(); + r.add({ update() {} }); + r.clear(); + expect(r.count).toBe(0); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/core/__tests__/fade.test.ts b/v1/packages/engine/src/core/__tests__/fade.test.ts new file mode 100644 index 0000000..22f6b34 --- /dev/null +++ b/v1/packages/engine/src/core/__tests__/fade.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { FadeState } from '../fade'; + +const lerpNum = (a: number, b: number, k: number): number => a + (b - a) * k; + +describe('FadeState', () => { + it('мгновенно задаёт значение при seconds = 0', () => { + const f = new FadeState(lerpNum, 0); + f.to(0.7); + expect(f.value).toBe(0.7); + expect(f.target).toBe(0.7); + expect(f.active).toBe(false); + }); + + it('линейно идёт к цели и точно её достигает', () => { + const f = new FadeState(lerpNum, 0); + f.to(1, 2); + f.update(0.5); + expect(f.value).toBeCloseTo(0.25); + f.update(0.5); + expect(f.value).toBeCloseTo(0.5); + f.update(1); + expect(f.value).toBe(1); + expect(f.active).toBe(false); + }); + + it('тик за пределами остатка не уводит значение дальше цели', () => { + const f = new FadeState(lerpNum, 0); + f.to(1, 1); + f.update(0.25); + f.update(10); // остаток 0.75 — лишнее не интерполируется + expect(f.value).toBe(1); + expect(f.active).toBe(false); + }); + + it('повторный to() в середине фейда стартует от текущего значения', () => { + const f = new FadeState(lerpNum, 0); + f.to(1, 4); + f.update(1); // 0.25 + f.to(0, 2); + expect(f.origin).toBeCloseTo(0.25); + f.update(1); + expect(f.value).toBeCloseTo(0.125); + f.update(1); + expect(f.value).toBe(0); + }); + + it('update без активного фейда — no-op', () => { + const f = new FadeState(lerpNum, 5); + f.update(100); + expect(f.value).toBe(5); + }); + + it('работает с произвольным типом (lerp цветов)', () => { + // Простейший лерп RGB-упакованного цвета по каналам. + const lerpColor = (a: number, b: number, k: number): number => { + const ch = (c: number, s: number) => (c >> s) & 0xff; + let out = 0; + for (let s = 0; s < 24; s += 8) { + const v = Math.round(lerpNum(ch(a, s), ch(b, s), k)); + out |= v << s; + } + return out; + }; + const f = new FadeState(lerpColor, 0xffffff); + f.to(0x000000, 1); + f.update(0.5); + expect(f.value).toBe(0x808080); + f.update(0.5); + expect(f.value).toBe(0x000000); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/core/easing.ts b/v1/packages/engine/src/core/easing.ts new file mode 100644 index 0000000..782896c --- /dev/null +++ b/v1/packages/engine/src/core/easing.ts @@ -0,0 +1,24 @@ +/** + * Функции easing: t=0..1 -> 0..1. Используются в Tween и переходах сцен. + */ + +export type EaseFn = (t: number) => number; + +export const linear: EaseFn = (t) => t; + +export const quadIn: EaseFn = (t) => t * t; + +export const quadOut: EaseFn = (t) => 1 - (1 - t) * (1 - t); + +export const cubicIn: EaseFn = (t) => t * t * t; + +export const cubicOut: EaseFn = (t) => 1 - Math.pow(1 - t, 3); + +export const sineInOut: EaseFn = (t) => -(Math.cos(Math.PI * t) - 1) / 2; + +/** Затухающий овершут: 0 → ~1.1 → 1 («появление с пружинкой», pop-эффекты). */ +export const backOut: EaseFn = (t) => { + const s = 1.70158; + const u = t - 1; + return 1 + (s + 1) * u * u * u + s * u * u; +}; \ No newline at end of file diff --git a/v1/packages/engine/src/core/fade.ts b/v1/packages/engine/src/core/fade.ts new file mode 100644 index 0000000..5b8527d --- /dev/null +++ b/v1/packages/engine/src/core/fade.ts @@ -0,0 +1,58 @@ +/** + * Плавный линейный переход значения к цели за заданное время. + * Чистый класс без Pixi (headless-тестируется): ambient/виньетка Lighting, + * оверлей переходов SceneManager — вместо ручных пар fadeLeft/fadeTotal. + */ +export class FadeState { + private from: T; + /** Поле не может называться `to` — имя занято методом to(). */ + private toVal: T; + private current: T; + private left = 0; + private total = 0; + + constructor( + private readonly lerp: (a: T, b: T, k: number) => T, + initial: T + ) { + this.from = this.toVal = this.current = initial; + } + + /** Текущее значение (в процессе фейда — интерполированное). */ + get value(): T { + return this.current; + } + + /** Цель, заданная последним to(). */ + get target(): T { + return this.toVal; + } + + /** Начальное значение текущего фейда (на момент запуска). */ + get origin(): T { + return this.from; + } + + /** Идёт ли сейчас фейд. */ + get active(): boolean { + return this.left > 0; + } + + /** Задать цель; seconds > 0 — плавный лерп от текущего значения. */ + to(target: T, seconds = 0): void { + this.from = this.current; + this.toVal = target; + this.total = Math.max(0, seconds); + this.left = this.total; + if (this.total === 0) this.current = target; + } + + /** Тик; по завершении значение точно равно цели. */ + update(dt: number): void { + if (this.left <= 0) return; + this.left = Math.max(0, this.left - dt); + const k = this.total > 0 ? 1 - this.left / this.total : 1; + this.current = this.lerp(this.from, this.toVal, k); + if (this.left === 0) this.current = this.toVal; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/cutscene/CutsceneRunner.ts b/v1/packages/engine/src/cutscene/CutsceneRunner.ts new file mode 100644 index 0000000..415d009 --- /dev/null +++ b/v1/packages/engine/src/cutscene/CutsceneRunner.ts @@ -0,0 +1,184 @@ +import { sineInOut, type EaseFn } from '../core/easing'; +import { GameState } from '../core/GameState'; +import type { Camera } from '../render/Camera'; +import type { Vec2 } from '../math/Vec2'; +import { applyEase } from './tween'; + +/** + * Раннер кат-сцен: view-агностичная последовательность шагов. + * Каждый шаг при старте выполняет свой эффект; раннер ждёт его длительность + * (0 — шаг «параллельный», следующий стартует сразу) и переходит к следующему. + * Сцена на время сцены ставит геймплей на паузу — раннер только сообщает `active`. + */ + +/** Всё, что можно двигать шагом moveView: структурно — любой Container. */ +export interface Movable { + position: Vec2; +} + +export type CutsceneStep = + /** Подождать N секунд (ничего не делая). */ + | { kind: 'wait'; seconds: number } + /** Ждать внешнее событие (диалог, приход актёра): пока predicate() не true. + * timeout — принудительное завершение шага (сек); без него шаг может + * длиться вечно — скип/агентный мост на таком шаге не повиснет только + * если событие реально наступит. */ + | { kind: 'until'; predicate: () => boolean; label?: string; timeout?: number } + /** Плавно переместить камеру (в юнитах) к точке. */ + | { kind: 'cameraMove'; x: number; y: number; seconds: number; camera: Camera } + /** Плавно сменить зум камеры (seconds: 0 — мгновенно, параллелен ходу). */ + | { kind: 'cameraZoom'; camera: Camera; z: number; seconds?: number } + /** Плавно переместить вьюху (актёра, объект) в локальных координатах её родителя. */ + | { kind: 'moveView'; view: Movable; x: number; y: number; seconds: number; ease?: EaseFn } + /** Поднять флаг прохождения (мгновенно). */ + | { kind: 'setFlag'; flag: string; state: GameState } + /** Разовый эффект (звук, кольцо, вспышка частиц). */ + | { kind: 'burst'; fn: () => void; seconds?: number } + /** Произвольный вызов (диалог, посадка, тост и т.п.). */ + | { kind: 'call'; fn: () => void; seconds?: number }; + +interface RunningStep { + seconds: number; + /** Ожидание внешнего события: шаг кончается, когда предикат стал true. */ + until?: { predicate: () => boolean }; + /** Движение камеры: from -> to по easing. */ + cameraMove?: { from: Vec2; to: Vec2; camera: Camera }; + /** Движение вьюхи: from -> to по easing. */ + moveView?: { from: Vec2; to: Vec2; view: Movable; ease: EaseFn }; +} + +/** Плавность движения камеры по умолчанию. */ +const EASE: EaseFn = sineInOut; + +export class CutsceneRunner { + private queue: CutsceneStep[] = []; + private current: RunningStep | null = null; + private t = 0; + /** Внутри skip(): until форсируются, сцена обязана дойти до конца. */ + private skipping = false; + /** Вызывается, когда все шаги дошли до конца. */ + onComplete: (() => void) | null = null; + + constructor( + /** Наблюдатель: вызывается при старте каждого шага (лог, отладка). */ + private onStep?: (step: CutsceneStep) => void + ) {} + + /** Запустить последовательность шагов (немедленно). */ + play(steps: CutsceneStep[]): void { + this.queue = [...steps]; + this.t = 0; + this.next(); + } + + /** Идёт ли сейчас сцена (геймплей на паузе). */ + get active(): boolean { + return this.current !== null; + } + + /** Секунд прошло с начала текущего шага. */ + get time(): number { + return this.t; + } + + private next(): void { + const step = this.queue.shift(); + if (!step) { + this.current = null; + this.onComplete?.(); + return; + } + this.onStep?.(step); + switch (step.kind) { + case 'wait': + this.current = { seconds: step.seconds }; + break; + case 'until': { + // Предикат уже выполнен — шаг не блокирует (секунды 0 → сразу дальше). + const done = step.predicate(); + this.current = { + seconds: done ? 0 : (step.timeout ?? Infinity), + until: done ? undefined : { predicate: step.predicate } + }; + break; + } + case 'cameraMove': + this.current = { + seconds: step.seconds, + cameraMove: { + from: { x: step.camera.x, y: step.camera.y }, + to: { x: step.x, y: step.y }, + camera: step.camera + } + }; + break; + case 'cameraZoom': + step.camera.zoomTo(step.z, step.seconds ?? 0); + this.current = { seconds: step.seconds ?? 0 }; + break; + case 'moveView': + this.current = { + seconds: step.seconds, + moveView: { + from: { x: step.view.position.x, y: step.view.position.y }, + to: { x: step.x, y: step.y }, + view: step.view, + ease: step.ease ?? EASE + } + }; + break; + case 'setFlag': + step.state.setFlag(step.flag); + this.current = { seconds: 0 }; + break; + case 'burst': + case 'call': + step.fn(); + this.current = { seconds: step.seconds ?? 0 }; + break; + } + // Параллельный шаг (0 секунд) — сразу к следующему. + if (this.current.seconds <= 0) this.next(); + } + + update(dt: number): void { + if (!this.current) return; + this.t += dt; + const current = this.current; + // Ожидание события: предикат сработал — шаг кончен (timeout ловит «>=» ниже). + // Скип форсирует любые until: катсцена обязана кончиться. + if (current.until && (this.skipping || current.until.predicate())) { + this.t = 0; + this.next(); + return; + } + const move = current.cameraMove; + if (move) { + const p = applyEase(move.from, move.to, this.t / current.seconds, EASE); + move.camera.x = p.x; + move.camera.y = p.y; + } + const viewMove = current.moveView; + if (viewMove) { + const p = applyEase(viewMove.from, viewMove.to, this.t / current.seconds, viewMove.ease); + viewMove.view.position.x = p.x; + viewMove.view.position.y = p.y; + } + if (this.t >= current.seconds) { + this.t = 0; + this.next(); + } + } + + /** Дожать сцену до конца немедленно: until форсируются, движение — в конечные точки. */ + skip(): void { + if (!this.current) return; + this.skipping = true; + // Лимит — защита от шага, который при скипе порождает шаги бесконечно. + for (let i = 0; i < 1000 && this.current; i++) { + const remain = this.current.until ? 0 : this.current.seconds - this.t; + this.update(Math.max(0, remain) + 0.001); + } + this.skipping = false; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts b/v1/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts new file mode 100644 index 0000000..6d9c9ac --- /dev/null +++ b/v1/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; +import { GameState } from '../../core/GameState'; +import { linear } from '../../core/easing'; +import { Camera } from '../../render/Camera'; +import { CutsceneRunner } from '../CutsceneRunner'; +import { applyEase } from '../tween'; + +/** Продвинуть раннер: dt N раз. */ +function tick(runner: CutsceneRunner, dt: number, times: number): void { + for (let i = 0; i < times; i++) runner.update(dt); +} + +/** Фиктивный «взгляд»: камера с заданным центром. */ +function cameraAt(x: number, y: number): Camera { + const cam = new Camera(480, 270); + cam.x = x; + cam.y = y; + return cam; +} + +describe('CutsceneRunner — последовательность шагов', () => { + it('шаги выполняются по порядку, wait держит паузу', () => { + const runner = new CutsceneRunner(); + const order: string[] = []; + runner.play([ + { kind: 'call', fn: () => order.push('a') }, + { kind: 'wait', seconds: 0.5 }, + { kind: 'call', fn: () => order.push('b') } + ]); + // Первый шаг стартовал сразу, второй ещё ждёт. + expect(order).toEqual(['a']); + expect(runner.active).toBe(true); + tick(runner, 0.1, 4); // 0.4с — всё ещё wait + expect(order).toEqual(['a']); + tick(runner, 0.2, 1); // 0.6с — wait кончился + expect(order).toEqual(['a', 'b']); + expect(runner.active).toBe(false); + }); + + it('шаг без seconds параллелен: следующий стартует сразу', () => { + const runner = new CutsceneRunner(); + const order: string[] = []; + runner.play([ + { kind: 'burst', fn: () => order.push('burst') }, + { kind: 'call', fn: () => order.push('call') } + ]); + // Шаги без длительности не блокируют: оба стартовали в том же тике. + expect(order).toEqual(['burst', 'call']); + expect(runner.active).toBe(false); + }); + + it('cameraMove плавно ведёт камеру к точке', () => { + const runner = new CutsceneRunner(); + const cam = cameraAt(0, 0); + runner.play([{ kind: 'cameraMove', x: 10, y: -20, seconds: 1, camera: cam }]); + tick(runner, 0.25, 2); // половина пути + expect(cam.x).toBeGreaterThan(0); + expect(cam.x).toBeLessThan(10); + expect(cam.y).toBeLessThan(0); + tick(runner, 0.25, 3); // время вышло — точно в точке + expect(cam.x).toBe(10); + expect(cam.y).toBe(-20); + expect(runner.active).toBe(false); + }); + + it('setFlag поднимает флаги, onComplete вызывается в конце', () => { + const runner = new CutsceneRunner(); + const state = new GameState(); + let done = 0; + runner.onComplete = () => done++; + runner.play([ + { kind: 'setFlag', flag: 'flag1', state }, + { kind: 'setFlag', flag: 'flag2', state } + ]); + expect(state.hasFlag('flag1')).toBe(true); + expect(state.hasFlag('flag2')).toBe(true); + expect(done).toBe(1); + }); + + it('onStep наблюдает порядок шагов', () => { + const seen: string[] = []; + const runner = new CutsceneRunner((s) => seen.push(s.kind)); + runner.play([ + { kind: 'call', fn: () => undefined, seconds: 0.1 }, + { kind: 'wait', seconds: 0.1 } + ]); + // Первый шаг стартовал; второй — после его длительности. + expect(seen).toEqual(['call']); + tick(runner, 0.2, 1); + expect(seen).toEqual(['call', 'wait']); + }); + + it('until ждёт внешнее событие и завершается по предикату', () => { + const runner = new CutsceneRunner(); + const order: string[] = []; + let ready = false; + runner.play([ + { kind: 'until', predicate: () => ready, label: 'событие' }, + { kind: 'call', fn: () => order.push('after') } + ]); + tick(runner, 0.1, 5); // событие не наступило — стоим на месте + expect(order).toEqual([]); + expect(runner.active).toBe(true); + ready = true; + runner.update(0.1); + expect(order).toEqual(['after']); + expect(runner.active).toBe(false); + }); + + it('until с уже выполненным предикатом не блокирует', () => { + const runner = new CutsceneRunner(); + const order: string[] = []; + runner.play([ + { kind: 'until', predicate: () => true }, + { kind: 'call', fn: () => order.push('next') } + ]); + expect(order).toEqual(['next']); + expect(runner.active).toBe(false); + }); + + it('until с timeout завершается принудительно по истечении', () => { + const runner = new CutsceneRunner(); + let done = 0; + runner.onComplete = () => done++; + runner.play([{ kind: 'until', predicate: () => false, timeout: 0.3 }]); + tick(runner, 0.1, 2); // 0.2с — ещё ждём + expect(runner.active).toBe(true); + tick(runner, 0.1, 1); // 0.3с — timeout + expect(runner.active).toBe(false); + expect(done).toBe(1); + }); + + it('moveView плавно ведёт вьюху в локальных координатах родителя', () => { + const runner = new CutsceneRunner(); + const view = { position: { x: 0, y: 0 } }; + runner.play([{ kind: 'moveView', view, x: 40, y: -10, seconds: 1 }]); + tick(runner, 0.25, 2); // половина пути + expect(view.position.x).toBeGreaterThan(0); + expect(view.position.x).toBeLessThan(40); + expect(view.position.y).toBeLessThan(0); + tick(runner, 0.25, 3); // время вышло — точно в точке + expect(view.position.x).toBe(40); + expect(view.position.y).toBe(-10); + expect(runner.active).toBe(false); + }); + + it('moveView с кастомным ease', () => { + const runner = new CutsceneRunner(); + const view = { position: { x: 0, y: 0 } }; + runner.play([{ kind: 'moveView', view, x: 10, y: 0, seconds: 1, ease: (t) => t * t }]); + runner.update(0.5); + expect(view.position.x).toBeCloseTo(2.5); // quadIn(0.5) = 0.25 + }); + + it('skip дожимает сцену до конца: движение — в конечные точки', () => { + const runner = new CutsceneRunner(); + const cam = cameraAt(0, 0); + const view = { position: { x: 0, y: 0 } }; + const order: string[] = []; + runner.onComplete = () => order.push('done'); + runner.play([ + { kind: 'moveView', view, x: 12, y: 6, seconds: 5 }, + { kind: 'cameraMove', x: 3, y: 3, seconds: 5, camera: cam }, + { kind: 'call', fn: () => order.push('call') } + ]); + runner.skip(); + expect(order).toEqual(['call', 'done']); + expect(view.position.x).toBe(12); + expect(view.position.y).toBe(6); + expect(cam.x).toBe(3); + expect(cam.y).toBe(3); + expect(runner.active).toBe(false); + }); + + it('skip форсирует until (иначе скип висел бы навсегда)', () => { + const runner = new CutsceneRunner(); + runner.play([ + { kind: 'call', fn: () => undefined }, + { kind: 'until', predicate: () => false }, // событие никогда не наступит + { kind: 'call', fn: () => undefined, seconds: 0.2 } + ]); + runner.skip(); + expect(runner.active).toBe(false); + }); + + it('cameraZoom ведёт зум камеры; seconds: 0 — мгновенно', () => { + const runner = new CutsceneRunner(); + const cam = cameraAt(0, 0); + const order: string[] = []; + runner.play([ + { kind: 'cameraZoom', camera: cam, z: 2, seconds: 1 }, + { kind: 'call', fn: () => order.push('after') } + ]); + runner.update(0.5); + cam.update(0.5); // анимацию зума тикает камера (движок — каждый шаг) + expect(cam.zoomLevel).toBeGreaterThan(1); + expect(cam.zoomLevel).toBeLessThan(2); + expect(order).toEqual([]); // зум ещё идёт — сцена не двинулась + runner.update(0.5); + cam.update(0.5); + expect(cam.zoomLevel).toBe(2); + expect(order).toEqual(['after']); + }); + + it('cameraZoom без seconds — параллельный шаг (не блокирует)', () => { + const runner = new CutsceneRunner(); + const cam = cameraAt(0, 0); + runner.play([{ kind: 'cameraZoom', camera: cam, z: 2 }]); + expect(cam.zoomLevel).toBe(2); + expect(runner.active).toBe(false); + }); +}); + +describe('tween — applyEase', () => { + it('интерполирует точку по кривой easing', () => { + const from = { x: 0, y: 0 }; + const to = { x: 10, y: -20 }; + const mid = applyEase(from, to, 0.5, linear); + expect(mid.x).toBeCloseTo(5); + expect(mid.y).toBeCloseTo(-10); + }); + + it('клэмпит k за пределы 0..1', () => { + const end = applyEase({ x: 0, y: 0 }, { x: 8, y: 4 }, 2, linear); + expect(end.x).toBe(8); + expect(end.y).toBe(4); + const start = applyEase({ x: 0, y: 0 }, { x: 8, y: 4 }, -1, linear); + expect(start.x).toBe(0); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/cutscene/tween.ts b/v1/packages/engine/src/cutscene/tween.ts new file mode 100644 index 0000000..baba792 --- /dev/null +++ b/v1/packages/engine/src/cutscene/tween.ts @@ -0,0 +1,13 @@ +import type { EaseFn } from '../core/easing'; +import type { Vec2 } from '../math/Vec2'; + +/** + * Чистая математика перемещений кат-сцен: интерполяция точки по easing. + * Общая для cameraMove и moveView — движение камеры и вьюх считается одинаково. + */ + +/** Точка на кривой from→to в доле k (0..1), прошедшей через ease. */ +export function applyEase(from: Vec2, to: Vec2, k: number, ease: EaseFn): Vec2 { + const e = ease(Math.min(1, Math.max(0, k))); + return { x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e }; +} \ No newline at end of file diff --git a/v1/packages/engine/src/debug/DebugOverlay.ts b/v1/packages/engine/src/debug/DebugOverlay.ts new file mode 100644 index 0000000..aa5c150 --- /dev/null +++ b/v1/packages/engine/src/debug/DebugOverlay.ts @@ -0,0 +1,39 @@ +import { Text, Container } from 'pixi.js'; +import type { FpsMeter } from '../core/FpsMeter'; + +/** + * Дебаг-оверлей: FPS (из FpsMeter движка) и произвольные строки статистики. + * Добавьте view в uiRoot и вызывайте update(dt) каждый тик; перерисовка Text + * дорога (растеризация) — не чаще 0,5 с. + */ +export class DebugOverlay { + readonly view: Container; + private text: Text; + private lines: string[] = []; + private acc = 0; + + constructor( + private readonly fpsMeter: FpsMeter, + 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; + if (this.acc < 0.5) return; + this.acc = 0; + this.text.text = `fps ${this.fpsMeter.fps}\n${this.lines.join('\n')}`; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/dialogue/DialogueRunner.ts b/v1/packages/engine/src/dialogue/DialogueRunner.ts new file mode 100644 index 0000000..2434206 --- /dev/null +++ b/v1/packages/engine/src/dialogue/DialogueRunner.ts @@ -0,0 +1,299 @@ +import { GameState } from '../core/GameState'; +import { + hasConditions, + hasText, + evalConditions, + type DialogueChoice, + type DialogueConditions, + type DialogueEffects, + type DialogueEffectOp, + type DialogueGraph, + type DialogueHooks, + type DialogueNode, + type DialogueResult +} from './graph'; + +/** + * Рантайм обхода диалоговых графов, отделённый от отрисовки и от модели + * (типы графа и чистые предикаты — dialogue/graph.ts). Игра рисует диалог + * своим view (например DialogueBox) и вызывает advance()/pick(). + */ + +/** Вид: игра рисует реплику и варианты своими средствами. */ +export interface DialogueView { + /** Показать реплику. choices пуст, если выбора нет. */ + show(node: { + speaker?: string; + text: string; + mood?: string; + tags?: string[]; + choices: { index: number; text: string }[]; + }): void; + hide(): void; +} + +const MAX_STEPS = 1000; // защита от циклов в графе без текста + +export class DialogueRunner { + /** Вызывается по завершении диалога: граф + как он был пройден. */ + onFinish: ((graph: DialogueGraph, result: DialogueResult) => void) | null = null; + /** + * Игровой эффект из `do[]`: движок только сообщает, исполнение — игра + * (EffectSink). `at` — где сработал: узел или выбор в узле. + */ + onEffect: ((op: DialogueEffectOp, at: { nodeId: string; choice?: number }) => void) | null = + null; + + private graph: DialogueGraph | null = null; + private view: DialogueView | null = null; + private hooks: DialogueHooks | undefined; + private shownChoices: { index: number; text: string }[] = []; + private pendingNext: string | undefined; + private awaitingChoice = false; + private walkedPath: string[] = []; + private madePicks: { nodeId: string; index: number; text: string }[] = []; + private walkedResult: DialogueResult | null = null; + private currentId: string | null = null; + + constructor( + private state: GameState, + view?: DialogueView, + hooks?: DialogueHooks + ) { + this.view = view ?? null; + this.hooks = hooks; + } + + /** Подключить/заменить view (например, после пересоздания UI). */ + setView(view: DialogueView): void { + this.view = view; + } + + /** Подключить/заменить хуки мира (сумка, локализация строк). */ + setHooks(hooks: DialogueHooks): void { + this.hooks = hooks; + } + + get active(): boolean { + return this.graph !== null; + } + + // --- для агентного моста/отладки: раскрытие текущего состояния --- + + /** id текущего узла (или null, если диалог не активен/завершается). */ + get nodeId(): string | null { + return this.currentId; + } + + /** Текущий узел (для снапшота моста). */ + get node(): DialogueNode | null { + if (!this.graph || this.currentId === null) return null; + return this.graph.nodes[this.currentId] ?? null; + } + + /** Показанные варианты выбора (индексы — в узел графа). */ + get choices(): { index: number; text: string }[] { + return this.shownChoices; + } + + /** Ожидается ли выбор варианта. */ + get waitingForChoice(): boolean { + return this.awaitingChoice; + } + + /** Показанные узлы по порядку (текущий прогон). */ + get path(): string[] { + return this.walkedPath; + } + + /** Итог последнего завершённого диалога (null — не завершался). */ + get result(): DialogueResult | null { + return this.walkedResult; + } + + /** Запустить диалог. */ + start(graph: DialogueGraph): void { + this.graph = graph; + this.awaitingChoice = false; + this.pendingNext = undefined; + this.walkedPath = []; + this.madePicks = []; + this.walkedResult = null; + this.enterNode(graph.start); + } + + /** + * «Дальше»: показывает следующий узел. Во время выбора варианта игнорируется. + */ + advance(): void { + if (!this.graph || this.awaitingChoice) return; + const next = this.pendingNext; + if (next === undefined) { + this.finish(); + return; + } + this.enterNode(next); + } + + /** Выбрать вариант ответа. */ + pick(index: number): void { + if (!this.graph || !this.awaitingChoice) return; + const shown = this.shownChoices[index]; + if (!shown) return; + const choice = this.node?.choices?.[shown.index]; + if (!choice) return; + + const atNode = this.currentId; + this.awaitingChoice = false; + this.shownChoices = []; + if (atNode !== null) { + this.madePicks.push({ nodeId: atNode, index: shown.index, text: shown.text }); + } + this.applyEffects(choice, atNode, shown.index); + if (choice.next === undefined) { + this.finish(); + } else { + this.enterNode(choice.next); + } + } + + /** Завершить диалог досрочно. */ + finish(): void { + const graph = this.graph; + this.graph = null; + this.awaitingChoice = false; + this.shownChoices = []; + this.pendingNext = undefined; + this.view?.hide(); + if (graph) { + this.walkedResult = { + lastNodeId: this.walkedPath.length > 0 ? this.walkedPath[this.walkedPath.length - 1] : null, + path: [...this.walkedPath], + picks: [...this.madePicks] + }; + this.onFinish?.(graph, this.walkedResult); + } + } + + /** Войти в узел: проверить условия, применить эффекты, показать или продолжить. */ + private enterNode(id: string): void { + if (!this.graph) return; + const node = this.graph.nodes[id]; + if (!node) { + // несуществующий next трактуем как конец + this.finish(); + return; + } + + // Шагаем через «пустые» узлы (не прошедшие условия или без текста), + // пока не встретим реплику с выбором, реплику с ожиданием или конец. + let current: { id: string; node: DialogueNode } | null = { id, node }; + for (let steps = 0; current && steps < MAX_STEPS; steps++) { + const n: DialogueNode = current.node; + + if (hasConditions(n) && !this.checkConditions(n)) { + // условие не прошло — уходим по next или заканчиваем + if (n.next === undefined) { + this.currentId = null; + this.finish(); + return; + } + const nextNode = this.graph.nodes[n.next]; + if (!nextNode) { + this.currentId = null; + this.finish(); + return; + } + current = { id: n.next, node: nextNode }; + continue; + } + + this.currentId = current.id; + this.applyEffects(n, this.currentId); + + if (n.choices && n.choices.length > 0) { + const choices: { index: number; text: string }[] = n.choices + .map((c: DialogueChoice, index: number) => ({ c, index })) + .filter(({ c }) => this.checkConditions(c)) + .map(({ c, index }) => ({ index, text: c.text })); + if (hasText(n)) { + this.show(n, choices); + } else if (choices.length > 0) { + // выбор без реплики: показываем только варианты + this.show({ text: '' }, choices); + } else { + // нет ни текста, ни доступных вариантов — конец + this.currentId = null; + this.finish(); + return; + } + return; + } + + if (hasText(n)) { + this.pendingNext = n.next; + this.show(n, []); + return; + } + + // Действие без текста: продолжаем по next или заканчиваем + if (n.next === undefined || n.end) { + this.currentId = null; + this.finish(); + return; + } + const nextNode = this.graph.nodes[n.next]; + if (!nextNode) { + this.currentId = null; + this.finish(); + return; + } + current = { id: n.next, node: nextNode }; + } + + // Защита сработала — принудительный конец + this.currentId = null; + this.finish(); + } + + /** Текст реплики: textKey резолвится хуком (fallback — сам ключ). */ + private resolveText(n: { text?: string; textKey?: string }): string { + if (n.textKey !== undefined) return this.hooks?.resolve?.(n.textKey) ?? n.textKey; + return n.text ?? ''; + } + + private resolveSpeaker(n: { speaker?: string; speakerKey?: string }): string | undefined { + if (n.speakerKey !== undefined) return this.hooks?.resolve?.(n.speakerKey) ?? n.speakerKey; + return n.speaker; + } + + private show( + n: DialogueNode, + choices: { index: number; text: string }[], + textlessChoice = false + ): void { + if (this.currentId !== null) this.walkedPath.push(this.currentId); + this.shownChoices = choices; + this.awaitingChoice = choices.length > 0; + this.view?.show({ + speaker: this.resolveSpeaker(n), + text: textlessChoice ? '' : this.resolveText(n), + mood: n.mood, + tags: n.tags, + choices + }); + } + + private applyEffects(e: DialogueEffects, atNode: string | null, choice?: number): void { + for (const f of e.setFlags ?? []) this.state.setFlag(f); + for (const f of e.clearFlags ?? []) this.state.clearFlag(f); + for (const [k, v] of Object.entries(e.setVars ?? {})) this.state.setVar(k, v); + if (atNode === null) return; + const at = { nodeId: atNode, choice }; + for (const op of e.do ?? []) this.onEffect?.(op, at); + } + + private checkConditions(c: DialogueConditions): boolean { + return evalConditions(c, this.state, this.hooks?.world); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts b/v1/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts new file mode 100644 index 0000000..602641b --- /dev/null +++ b/v1/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts @@ -0,0 +1,455 @@ +import { describe, it, expect } from 'vitest'; +import { DialogueRunner, type DialogueView } from '../DialogueRunner'; +import { evalConditions } from '../graph'; +import { GameState } from '../../core/GameState'; + +/** Тестовый view: запоминает показанные реплики. */ +interface ShownLine { + speaker?: string; + text: string; + mood?: string; + tags?: string[]; + choices: { index: number; text: string }[]; +} + +function makeView(): { view: DialogueView; shown: ShownLine[]; hidden: () => number } { + const shown: ShownLine[] = []; + let hideCount = 0; + const view: DialogueView = { + show: (n) => shown.push(n), + hide: () => hideCount++ + }; + return { view, shown, hidden: () => hideCount }; +} + +describe('DialogueRunner', () => { + it('линейный диалог: advance до конца, onFinish вызывается', () => { + const state = new GameState(); + const { view, shown, hidden } = makeView(); + const dr = new DialogueRunner(state, view); + let finished = 0; + dr.onFinish = () => finished++; + + dr.start({ + start: 'a', + nodes: { + a: { text: 'Привет.', next: 'b' }, + b: { text: 'Пока.', next: 'c' }, + c: { text: '...' } + } + }); + + expect(dr.active).toBe(true); + expect(shown[0]?.text).toBe('Привет.'); + dr.advance(); + expect(shown[1]?.text).toBe('Пока.'); + dr.advance(); + expect(shown[2]?.text).toBe('...'); + dr.advance(); + expect(finished).toBe(1); + expect(dr.active).toBe(false); + expect(hidden()).toBe(1); + }); + + it('узел без next — конец диалога', () => { + const state = new GameState(); + const dr = new DialogueRunner(state, makeView().view); + let finished = 0; + dr.onFinish = () => finished++; + dr.start({ start: 'a', nodes: { a: { text: 'Конец' } } }); + dr.advance(); + expect(finished).toBe(1); + }); + + it('выборы: показываются только прошедшие условия, pick уходит по next', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + + dr.start({ + start: 'q', + nodes: { + q: { + speaker: 'Ирвин', + text: 'Поможешь?', + choices: [ + { text: 'Да', next: 'yes', setFlags: ['quest_taken'] }, + { text: 'Нет', next: 'no' }, + { text: 'Секрет', when: ['knows_secret'], next: 'secret' } + ] + }, + yes: { text: 'Спасибо!' }, + no: { text: 'Жаль.' }, + secret: { text: 'Ты знаешь тайну.' } + } + }); + + expect(shown[0]?.speaker).toBe('Ирвин'); + expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Да', 'Нет']); + + dr.pick(0); + expect(state.hasFlag('quest_taken')).toBe(true); + // advance показывает следующий узел после применения эффекта выбора + dr.advance(); + expect(shown[1]?.text).toBe('Спасибо!'); + }); + + it('advance во время выбора игнорируется', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'q', + nodes: { q: { text: 'Выбор?', choices: [{ text: 'A', next: 'a2' }], next: 'x' }, a2: { text: 'A' }, x: { text: 'X' } } + }); + dr.advance(); + expect(shown.length).toBe(1); // реплика 'X' не показалась + }); + + it('узел-действие без текста применяет флаги и переходит дальше', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'give', + nodes: { + give: { setFlags: ['reward_given'], next: 'msg' }, + msg: { text: 'Готово.' } + } + }); + expect(shown[0]?.text).toBe('Готово.'); + expect(state.hasFlag('reward_given')).toBe(true); + }); + + it('when/whenNot фильтруют узлы: диалог идёт по другой ветке', () => { + const state = new GameState(); + state.setFlag('was_here'); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'check', + nodes: { + check: { when: ['was_here'], text: 'Опять ты.', next: 'end' }, + first: { text: 'Первый раз.' }, + end: { text: 'Конец.' } + } + }); + // check прошёл условие + expect(shown[0]?.text).toBe('Опять ты.'); + }); + + it('когда узел не прошёл условие, диалог уходит по его next', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'check', + nodes: { + check: { when: ['vip'], text: 'Ветка VIP.', next: 'end' }, + end: { text: 'Финал.' } + } + }); + // check не прошёл (нет vip) — но у check есть next: показывается end + expect(shown[0]?.text).toBe('Финал.'); + expect(dr.active).toBe(true); + }); + + it('узел-действие с whenNot переходит по next при проходе условия', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'hub', + nodes: { + hub: { whenNot: ['vip'], next: 'normal' }, + normal: { text: 'Обычная ветка.', next: 'end' }, + end: { text: 'Финал.' } + } + }); + // hub без текста: условие прошло, эффектов нет, next -> normal + expect(shown[0]?.text).toBe('Обычная ветка.'); + }); + + it('whenVar фильтрует по числовой переменной', () => { + const state = new GameState(); + state.setVar('gold', 5); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'q', + nodes: { + q: { + text: 'Торговец:', + choices: [ + { text: 'Купить (10 зол.)', whenVar: { key: 'gold', op: 'ge', value: 10 }, next: 'buy' }, + { text: 'Уйти', next: 'bye' } + ] + }, + buy: { text: 'Куплено.' }, + bye: { text: 'Пока.' } + } + }); + expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Уйти']); + }); + + it('setVars в выборах меняет состояние', () => { + const state = new GameState(); + const dr = new DialogueRunner(state, makeView().view); + dr.start({ + start: 'q', + nodes: { + q: { text: 'Сколько?', choices: [{ text: 'Дать 30', setVars: { gold: 30 }, next: 'ok' }] }, + ok: { text: 'Ок.' } + } + }); + dr.pick(0); + expect(state.getNumber('gold')).toBe(30); + }); + + it('цикл из узлов без текста не зависает — защита по MAX_STEPS', () => { + const state = new GameState(); + const dr = new DialogueRunner(state, makeView().view); + dr.start({ + start: 'a', + nodes: { + a: { next: 'b' }, + b: { next: 'a' } + } + }); + expect(dr.active).toBe(false); // безопасно завершился + }); + + it('whenVars: несколько условий по переменным, AND', () => { + const state = new GameState(); + state.setVar('gold', 5); + state.setVar('trust', 2); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'q', + nodes: { + q: { + text: 'Торговец:', + choices: [ + { + text: 'Дорогой товар', + whenVars: [ + { key: 'gold', op: 'ge', value: 10 }, + { key: 'trust', op: 'ge', value: 1 } + ], + next: 'buy' + }, + { text: 'Уйти', next: 'bye' } + ] + }, + buy: { text: 'Куплено.' }, + bye: { text: 'Пока.' } + } + }); + // gold < 10 — первая ветка скрыта, хотя trust проходит + expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Уйти']); + state.setVar('gold', 50); + dr.start({ + start: 'q', + nodes: { + q: { + text: 'Торговец:', + choices: [ + { + text: 'Дорогой товар', + whenVars: [ + { key: 'gold', op: 'ge', value: 10 }, + { key: 'trust', op: 'ge', value: 1 } + ], + next: 'buy' + }, + { text: 'Уйти', next: 'bye' } + ] + }, + buy: { text: 'Куплено.' }, + bye: { text: 'Пока.' } + } + }); + expect(shown[1]?.choices.map((c) => c.text)).toEqual(['Дорогой товар', 'Уйти']); + }); + + it('hasItem резолвится через DialogueWorld; без world — ложно', () => { + const state = new GameState(); + const world = { hasItem: (id: string) => id === 'cloth' }; + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view, { world }); + dr.start({ + start: 'q', + nodes: { + q: { + text: 'Торговец:', + choices: [ + { text: 'Продать ткань', hasItem: ['cloth'], next: 'sell' }, + { text: 'Продать соль', hasItem: ['salt'], next: 'nope' }, + { text: 'Уйти', next: 'bye' } + ] + }, + sell: { text: 'Продано.' }, + nope: { text: 'Нет соли.' }, + bye: { text: 'Пока.' } + } + }); + expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Продать ткань', 'Уйти']); + + // узел-выбор без текста и без world: hasItem-варианты скрыты — конец диалога + const dr2 = new DialogueRunner(state, makeView().view); + dr2.start({ + start: 'q', + nodes: { + q: { choices: [{ text: 'Продать ткань', hasItem: ['cloth'], next: 'sell' }] }, + sell: { text: 'Продано.' } + } + }); + expect(dr2.active).toBe(false); // нет доступных вариантов — конец + }); + + it('evalConditions работает без рантайма на чистом GameState', () => { + const state = new GameState(); + state.setFlag('met_elder'); + state.setVar('flowers', 3); + const world = { hasItem: (id: string) => id === 'cloth' }; + + expect(evalConditions({ when: ['met_elder'] }, state)).toBe(true); + expect(evalConditions({ when: ['met_elder'], whenNot: ['quest_done'] }, state)).toBe(true); + expect(evalConditions({ when: ['quest_done'] }, state)).toBe(false); + expect( + evalConditions( + { whenVars: [{ key: 'flowers', op: 'ge', value: 3 }] }, + state, + world + ) + ).toBe(true); + expect( + evalConditions( + { whenVars: [{ key: 'flowers', op: 'ge', value: 4 }] }, + state + ) + ).toBe(false); + expect(evalConditions({ hasItem: ['cloth'] }, state, world)).toBe(true); + expect(evalConditions({ hasItem: ['cloth'] }, state)).toBe(false); // нет world + expect(evalConditions({}, state)).toBe(true); // пустые условия — истина + }); + + it('do[] эмитится через onEffect в порядке следования, у узла и у выбора', () => { + const state = new GameState(); + const { view } = makeView(); + const dr = new DialogueRunner(state, view); + const emitted: { kind: string; at: string; choice?: number }[] = []; + dr.onEffect = (op, at) => emitted.push({ kind: op.kind, at: at.nodeId, choice: at.choice }); + + dr.start({ + start: 'hub', + nodes: { + hub: { + do: [{ kind: 'sound', id: 'bell' }], + text: 'Возьми?', + choices: [ + { text: 'Да', next: 'ok', do: [{ kind: 'giveItem', id: 'cloth' }, { kind: 'toast', text: 'Ткань' }] } + ] + }, + ok: { text: 'Держи.', do: [{ kind: 'custom', id: 'plant' }] } + } + }); + // эффекты узла при входе + expect(emitted).toEqual([{ kind: 'sound', at: 'hub' }]); + dr.pick(0); + // сначала эффекты выбора, потом эффекты узла ok + expect(emitted.map((e) => e.kind)).toEqual(['sound', 'giveItem', 'toast', 'custom']); + expect(emitted[3]).toEqual({ kind: 'custom', at: 'ok' }); + expect(emitted[1].choice).toBe(0); + }); + + it('do[] без подписчика и с неизвестным kind не падают', () => { + const state = new GameState(); + const dr = new DialogueRunner(state, makeView().view); + dr.start({ + start: 'a', + nodes: { a: { text: 'Тишина.', do: [{ kind: 'custom' as const, id: 'unknown_thing' }] } } + }); + dr.advance(); + expect(dr.active).toBe(false); + expect(state.hasFlag('nothing')).toBe(false); + }); + + it('path/result: путь показанных узлов и выборы; onFinish получает результат', () => { + const state = new GameState(); + const { view } = makeView(); + const dr = new DialogueRunner(state, view); + let finishedResult: unknown = null; + dr.onFinish = (_graph, result) => { + finishedResult = result; + }; + + dr.start({ + start: 'q', + nodes: { + q: { text: 'Вопрос?', next: 'mid' }, + mid: { text: 'Выбирай.', choices: [{ text: 'Ок', next: 'end' }, { text: 'Нет' }] }, + end: { text: 'Конец.' } + } + }); + expect(dr.path).toEqual(['q']); + dr.advance(); + dr.pick(0); + dr.advance(); + expect(dr.active).toBe(false); + + const expected = { + lastNodeId: 'end', + path: ['q', 'mid', 'end'], + picks: [{ nodeId: 'mid', index: 0, text: 'Ок' }] + }; + expect(dr.result).toEqual(expected); + expect(finishedResult).toEqual(expected); + // result переживает новый запуск и сбрасывается + dr.start({ start: 'a', nodes: { a: { text: 'Заново' } } }); + expect(dr.result).toBeNull(); + expect(dr.path).toEqual(['a']); + }); + + it('mood/tags доходят до view', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'a', + nodes: { a: { text: 'Грустно.', mood: 'sad', tags: ['epilogue'] } } + }); + expect(shown[0]?.mood).toBe('sad'); + expect(shown[0]?.tags).toEqual(['epilogue']); + expect(shown[0]?.text).toBe('Грустно.'); + }); + + it('textKey резолвится через hooks.resolve; без хука — сам ключ', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view, { + resolve: (key) => (key === 'elder.greet' ? 'Здравствуй, путник.' : key) + }); + dr.start({ + start: 'a', + nodes: { + a: { textKey: 'elder.greet', speakerKey: 'name.elder', next: 'b' }, + b: { textKey: 'missing.key' } + } + }); + expect(shown[0]?.text).toBe('Здравствуй, путник.'); + expect(shown[0]?.speaker).toBe('name.elder'); // ключ без перевода — сам ключ + dr.advance(); + expect(shown[1]?.text).toBe('missing.key'); + }); + + it('узел только с textKey — реплика (не действие)', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view, { resolve: () => 'Строка' }); + dr.start({ start: 'a', nodes: { a: { textKey: 'only.key' } } }); + expect(shown[0]?.text).toBe('Строка'); + expect(dr.active).toBe(true); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/dialogue/graph.ts b/v1/packages/engine/src/dialogue/graph.ts new file mode 100644 index 0000000..0ea39d6 --- /dev/null +++ b/v1/packages/engine/src/dialogue/graph.ts @@ -0,0 +1,174 @@ +import { GameState } from '../core/GameState'; + +/** + * Модель диалоговых графов и чистые предикаты над ней (без рантайма). + * Граф — данные (TS/JSON): узлы с репликами, выборами и условиями по GameState. + * Рантайм обхода графа — DialogueRunner; валидатор/dry-run/квесты пользуются + * типами и evalConditions отсюда. + */ + +/** Условие по переменной состояния. */ +export interface VarCondition { + key: string; + op: 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'; + value: number | string; +} + +/** Действия, применяемые к GameState при входе в узел/выборе. */ +export interface DialogueEffects { + setFlags?: string[]; + clearFlags?: string[]; + setVars?: Record; + /** + * Игровые эффекты как данные: движок только эмитит их через onEffect, + * исполнение — на стороне игры (EffectSink). Неизвестный kind — ошибка + * валидатора, рантайм его игнорирует. + */ + do?: DialogueEffectOp[]; +} + +/** Одна игровая операция эффекта (что именно она значит — знает игра). */ +export interface DialogueEffectOp { + kind: 'giveItem' | 'takeItem' | 'sound' | 'toast' | 'custom'; + /** id предмета (give/take), звука (sound) или имя сюжетного эффекта (custom). */ + id?: string; + /** Количество (give/take), по умолчанию 1. */ + count?: number; + /** Текст всплывашки (toast). */ + text?: string; + /** Свободные параметры (custom). */ + payload?: Record; +} + +/** Итог пройденного диалога: где остановились, как дошли, что выбрали. */ +export interface DialogueResult { + /** id последнего показанного узла (null — ни одного). */ + lastNodeId: string | null; + /** ids узлов по порядку показа. */ + path: string[]; + /** Выборы игрока: узел, индекс в узле, текст варианта. */ + picks: { nodeId: string; index: number; text: string }[]; +} + +/** Хуки мира: предикаты условий и резолв ключей строк (локализация). */ +export interface DialogueHooks { + world?: DialogueWorld; + /** Ключ строки → текст (textKey); нет резолва — ключ и есть текст. */ + resolve?: (key: string) => string; +} + +/** + * Предикаты мира, которых движок знать не может (сумка, репутация...). + * Игра передаёт реализацию в раннер; без неё hasItem-условия ложны. + */ +export interface DialogueWorld { + hasItem(id: string): boolean; +} + +/** Условия показа узла/варианта (все перечисленные группы — AND). */ +export interface DialogueConditions { + /** Все эти флаги должны быть установлены. */ + when?: string[]; + /** Ни один из этих флагов не должен быть установлен. */ + whenNot?: string[]; + /** Условие по переменной. */ + whenVar?: VarCondition; + /** Несколько условий по переменным, AND. */ + whenVars?: VarCondition[]; + /** Все эти предметы должны быть в сумке (резолв — DialogueWorld). */ + hasItem?: string[]; +} + +export interface DialogueChoice extends DialogueEffects, DialogueConditions { + text: string; + /** Следующий узел (по умолчанию — конец диалога). */ + next?: string; + /** Настроение реплики (игра мапит на цвет/портрет). */ + mood?: string; + /** Свободные метки для агента/инструментов (на геймплей не влияют). */ + tags?: string[]; + /** Ключ строки в таблице локализации; переопределяет inline text. */ + textKey?: string; +} + +export interface DialogueNode extends DialogueEffects, DialogueConditions { + /** Имя говорящего (опционально). */ + speaker?: string; + /** Ключ имени говорящего (локализация, как textKey). */ + speakerKey?: string; + /** Текст реплики. Узел без текста — «действие»: применяет эффекты и уходит в next. */ + text?: string; + /** Варианты ответа игрока. */ + choices?: DialogueChoice[]; + /** Следующий узел. */ + next?: string; + /** Явный конец диалога (для конечных узлов без choices/next). */ + end?: boolean; + /** Настроение реплики (игра мапит на цвет/портрет). */ + mood?: string; + /** Свободные метки для агента/инструментов (на геймплей не влияют). */ + tags?: string[]; + /** Ключ строки в таблице локализации; переопределяет inline text. */ + textKey?: string; +} + +export interface DialogueGraph { + /** id стартового узла. */ + start: string; + nodes: Record; +} + +/** Есть ли в условиях хоть что-то для проверки. */ +export function hasConditions(c: DialogueConditions): boolean { + return Boolean( + c.when?.length || c.whenNot?.length || c.whenVar || c.whenVars?.length || c.hasItem?.length + ); +} + +/** Узел-реплика: есть inline text или ключ строки. */ +export function hasText(n: { text?: string; textKey?: string }): boolean { + return n.text !== undefined || n.textKey !== undefined; +} + +/** Сравнение переменной по op; undefined (переменной нет) не проходит gt/lt/ge/le. */ +function checkVar(state: GameState, cond: VarCondition): boolean { + const v = state.getVar(cond.key); + switch (cond.op) { + case 'eq': + return v === cond.value; + case 'ne': + return v !== cond.value; + case 'gt': + return Number(v) > Number(cond.value); + case 'lt': + return Number(v) < Number(cond.value); + case 'ge': + return Number(v) >= Number(cond.value); + case 'le': + return Number(v) <= Number(cond.value); + } +} + +/** + * Чистая проверка условий узла/выбора без рантайма: удобна для квестовых + * стадий, dry-run и валидатора. world не задан → hasItem ложен. + */ +export function evalConditions(c: DialogueConditions, state: GameState, world?: DialogueWorld): boolean { + for (const f of c.when ?? []) { + if (!state.hasFlag(f)) return false; + } + for (const f of c.whenNot ?? []) { + if (state.hasFlag(f)) return false; + } + if (c.whenVar && !checkVar(state, c.whenVar)) return false; + for (const cond of c.whenVars ?? []) { + if (!checkVar(state, cond)) return false; + } + if (c.hasItem) { + if (!world) return false; + for (const id of c.hasItem) { + if (!world.hasItem(id)) return false; + } + } + return true; +} \ No newline at end of file diff --git a/v1/packages/engine/src/ecs/__tests__/ecs.test.ts b/v1/packages/engine/src/ecs/__tests__/ecs.test.ts new file mode 100644 index 0000000..dd0c484 --- /dev/null +++ b/v1/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/v1/packages/engine/src/ecs/ecs.ts b/v1/packages/engine/src/ecs/ecs.ts new file mode 100644 index 0000000..18d14a4 --- /dev/null +++ b/v1/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/v1/packages/engine/src/index.ts b/v1/packages/engine/src/index.ts new file mode 100644 index 0000000..bdb79a1 --- /dev/null +++ b/v1/packages/engine/src/index.ts @@ -0,0 +1,276 @@ +/** + * Публичный 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 { FpsMeter } from './core/FpsMeter'; +export { EventBus } from './core/EventBus'; +export { TweenManager, type TweenOptions, type TweenHandle } from './core/Tween'; +export { Updatables, type Updatable } from './core/Updatables'; +export { + linear, + quadIn, + quadOut, + cubicIn, + cubicOut, + sineInOut, + backOut, + type EaseFn +} from './core/easing'; + +// agent (инструменты ИИ-агента: снапшоты, шаги, ввод, инварианты) +export { EngineAgent, ENGINE_SNAPSHOT_KEYS } from './agent/EngineAgent'; +export { + type AgentHost, + type EngineSnapshot, + type Invariant, + type SceneAgent, + type SnapshotLayer, + type JsonValue, + type WaitForOptions +} from './agent/types'; +export { + checkFinite, + checkRange, + checkBounds, + checkWalkable, + mergeInvariants +} from './agent/invariants'; + +// state (см. core) +export { GameState, type GameStateData } from './core/GameState'; +export { StateMachine, type StateDef } from './core/StateMachine'; +export { Cooldown } from './core/Cooldown'; +export { Settings, type SettingsData } from './core/Settings'; + +// inventory +export { + Inventory, + inventoryFromData, + type InventoryData, + type InventoryOptions, + type InventoryChange, + type InventoryChangeKind +} from './inventory/Inventory'; +// cutscene +export { CutsceneRunner, type CutsceneStep, type Movable } from './cutscene/CutsceneRunner'; +export { applyEase } from './cutscene/tween'; + +// scene +export { SceneManager, type Scene, type SceneTransition } from './scene/SceneManager'; +export { + SceneRegistry, + type SceneObjectDef, + type SceneObject +} from './scene/SceneRegistry'; + +// ecs +export { World, type Entity, type System } from './ecs/ecs'; + +// math +export { add, sub, len, dist, moveTo, type Vec2 } from './math/Vec2'; +export { + isoToScreen, + screenToIso, + worldToScreen, + screenToWorld, + unitsToPx, + worldLen, + worldNorm, + worldDist, + moveTowardsW, + tileToWorld, + worldToTile, + worldRectToScreen, + DEFAULT_ISO, + type IsoLayout +} from './math/iso'; +export { createRng, hashTile, type Rng } from './math/rng'; +export { + inCircle, + inCircleW, + inCone, + inConeW, + angleBetween, + nearest, + conePath +} from './math/shapes'; + +// map +export { + IsometricTileMap, + type TileMapData, + type TallSpec, + type PropData, + type PropPlaced, + type PropView, + type DecorData +} from './map/IsometricTileMap'; +export { findPath, findPathToNeighbor, type Grid } from './map/pathfinding'; +export { + gridOf, + circleFits, + moveCircle, + separateCircles +} from './map/collision'; +export { + encodeMap, + parseMap, + type MapFile, + type MapFileV1 +} from './map/mapFormat'; +export { fromTiledIso, type TiledMap } from './map/tiled'; +export { + renderMinimap, + type MinimapImage, + type MinimapMarker, + type MinimapOptions +} from './map/minimap'; + +// render +export { Renderer, type RendererOptions } from './render/Renderer'; +export { Camera } from './render/Camera'; +export { IsoDepthLayer } from './render/IsoDepthLayer'; +export { computeScale } from './render/scale'; +export { ParticleEmitter, type EmitterOptions } from './render/Particles'; +export { + Lighting, + makeGlowTexture, + makeVignetteTexture, + type LightingOptions, + type DarkSpotDef +} from './render/Lighting'; +export { Shake } from './render/shake'; +export { SpriteFlash } from './render/spriteFx'; + +// fx (слой one-shot эффектов и пресеты) +export { FxLayer, type FxLayerOptions } from './render/fx/FxLayer'; +export { FxRing } from './render/fx/FxRing'; +export { FxFloatText } from './render/fx/FxFloatText'; +export { FxFade, type FxFadeOptions } from './render/fx/FxFade'; +export { FxPop } from './render/fx/FxPop'; +export { FxTrail } from './render/fx/FxTrail'; +export { sparkPreset, smokePreset, emberPreset, dustPreset } from './render/fx/particlePresets'; +export { + sampleRing, + ringDuration, + sampleFloat, + readSeconds, + sampleFade, + samplePop, + type FxRingSpec, + type FxRingSample, + type FxFloatSpec, + type FxFloatSample, + type FxFadeSpec, + type FxPopSpec +} from './render/fx/fxSim'; +export { + GLOW_BASE_PX, + flickerFactor, + lightFrame, + lerpAmbient, + dimColor, + pulseEnvelope, + pulseTotal, + dayNightFactor, + DEFAULT_DAY_NIGHT, + dayNightAmbient, + DUSK_PEAK, + DEFAULT_DUSK_COLOR, + type LightDef, + type LightFrame, + type PulseSpec, + type DayNightSpec +} from './render/lightSim'; +export { + blurKernel, + blurStrengthAt, + aberrationShiftAt, + shimmerOffsetY, + type ShimmerParams +} from './render/postfx/postSim'; +export { PostFx, type BlurState, type ShimmerState } from './render/postfx/PostFx'; +export { + sunShadow, + blobPlacement, + SHADOW_COLOR, + type SunShadow, + type BlobPlacement +} from './render/shadowSim'; +export { makeShadowTexture, applyBlobShadow, SHADOW_TEX_PX } from './render/shadows'; +export { + stepParticle, + sampleSpawn, + sampleBurst, + mergeEmitterOptions, + type ParticleState, + type ParticleSimOptions, + type ParticleVisual +} from './render/particleSim'; + +// input +export { InputManager, type PointerState } from './input/InputManager'; +export { VirtualJoystick, type VirtualJoystickOptions } from './input/VirtualJoystick'; + +// anim +export { TintFlash, lerpColor } from './anim/flash'; +export { SpriteAnimator } from './anim/SpriteAnimator'; +export type { SpriteClip, SpriteAnimatorOptions } from './anim/SpriteAnimator'; +export { stepClip } from './anim/clip'; +export type { ClipLoop, ClipDef, ClipTick } from './anim/clip'; +export { sampleMotion } from './anim/motion'; +export type { MotionParams, MotionSample, BobParams, SwayParams, PulseParams, BlinkParams } from './anim/motion'; +export { SpriteMotion } from './anim/SpriteMotion'; +export type { SpriteMotionOptions } from './anim/SpriteMotion'; + +// dialogue: модель графа и чистые предикаты (graph.ts) + рантайм обхода +export { + DialogueRunner, + type DialogueView +} from './dialogue/DialogueRunner'; +export { + evalConditions, + hasConditions, + type DialogueGraph, + type DialogueNode, + type DialogueChoice, + type DialogueWorld, + type DialogueConditions, + type DialogueEffects, + type DialogueEffectOp, + type DialogueResult, + type DialogueHooks, + type VarCondition +} from './dialogue/graph'; + +// ui +export { DialogueBox, type DialogueLine, type DialogueBoxOptions } from './ui/DialogueBox'; +export { revealText, type RevealState } from './ui/reveal'; +export { PixelText, type PixelTextOptions, ensurePixelFont, setPixelTextResolution } from './ui/PixelText'; +export { Panel, type PanelOptions } from './ui/Panel'; +export { Button, type ButtonOptions } from './ui/Button'; +export { MenuList, type MenuListOptions, type MenuItem } from './ui/MenuList'; +export { ListScroll } from './ui/listScroll'; +export { readMenuInput, type MenuActionMap, type MenuNavEvent, type MenuInputSource } from './ui/menuInput'; +export { MenuSceneBase, type MenuSceneContext } from './ui/MenuSceneBase'; + +// audio +export { AudioManager, type AudioBuses, type BusName, type MusicHandle, type PlayOptions, type SfxHandle } from './audio/AudioManager'; +export { renderSpec, RATE, type SoundSpec, type SoundKind, type SpecVoice } from './audio/SoundSpec'; +export { renderMusic, renderMusicStereo, type MusicSpec, type TrackSpec, type NoteEvent, type InstrumentKind } from './audio/Music'; + +// assets +export { AssetLoader } from './assets/AssetLoader'; + +// save +export { SaveManager, type StorageLike } from './save/SaveManager'; + +// debug +export { DebugOverlay } from './debug/DebugOverlay'; +export { SpriteDebugView } from './ui/SpriteDebugView'; \ No newline at end of file diff --git a/v1/packages/engine/src/input/InputManager.ts b/v1/packages/engine/src/input/InputManager.ts new file mode 100644 index 0000000..10936b6 --- /dev/null +++ b/v1/packages/engine/src/input/InputManager.ts @@ -0,0 +1,330 @@ +/** + * Ввод: клавиатура (маппинг «действие -> клавиши»), геймпад (стандартная карта) + * и указатель (мышь/тач) в координатах виртуального разрешения. + */ + +export interface PointerState { + /** Виртуальные пиксели. */ + x: number; + y: number; + down: boolean; + /** Нажато в этом кадре. */ + justPressed: boolean; + /** Отпущено в этом кадре. */ + justReleased: boolean; + /** true, если это событие тача (для мобильного управления). */ + isTouch: boolean; + /** Сколько фиксированных шагов удержана (0 — только нажата). */ + downTicks: number; +} + +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 keysReleased = new Set(); + /** code -> действия (одна клавиша может маппиться на несколько действий). */ + private keyActions = new Map(); + + // --- геймпад (стандартная карта кнопок) --- + /** action -> индексы кнопок стандартной карты. */ + private padActions = new Map(); + private padDown = new Set(); + private padPressed = new Set(); + private padReleased = new Set(); + + private pointer: PointerState = { + x: 0, + y: 0, + down: false, + justPressed: false, + justReleased: false, + isTouch: false, + downTicks: 0 + }; + + 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', this.onContextMenu); + } + + /** Маппинг: действие -> список кодов клавиш (e.code). */ + bindActions(map: Record): void { + for (const [action, codes] of Object.entries(map)) { + for (const code of codes) { + const actions = this.keyActions.get(code) ?? []; + if (!actions.includes(action)) actions.push(action); + this.keyActions.set(code, actions); + } + } + } + + /** + * Маппинг геймпада: действие -> индексы кнопок стандартной карты + * (0=A, 1=B, 8=Select, 9=Start, 12..15 — крестовина). + */ + bindGamepad(map: Record): void { + for (const [action, buttons] of Object.entries(map)) { + this.padActions.set(action, buttons); + } + } + + /** + * Опрос геймпадов. Вызывается движком в начале каждого фиксированного шага + * (до scenes.update). + */ + update(): void { + this.padPressed.clear(); + this.padReleased.clear(); + if (typeof navigator === 'undefined' || !navigator.getGamepads) return; + for (const pad of navigator.getGamepads()) { + if (!pad) continue; + pad.buttons.forEach((btn, i) => { + const pressed = btn.pressed; + if (pressed && !this.padDown.has(i)) { + this.padPressed.add(i); + } + if (!pressed && this.padDown.has(i)) { + this.padReleased.add(i); + } + if (pressed) { + this.padDown.add(i); + } else { + this.padDown.delete(i); + } + }); + break; // опрашиваем первый подключённый геймпад + } + } + + /** Оси левого стика (-1..1) первого подключённого геймпада (или null). */ + getLeftStick(): { x: number; y: number } | null { + if (typeof navigator === 'undefined' || !navigator.getGamepads) return null; + for (const pad of navigator.getGamepads()) { + if (!pad) continue; + const [x = 0, y = 0] = pad.axes; + return { + x: Math.abs(x) < 0.15 ? 0 : x, // мёртвая зона + y: Math.abs(y) < 0.15 ? 0 : y + }; + } + return null; + } + + /** Действие активно (зажато): клавиатура или геймпад. */ + isActionActive(action: string): boolean { + for (const [code, actions] of this.keyActions) { + if (actions.includes(action) && this.keysDown.has(code)) return true; + } + const padButtons = this.padActions.get(action); + if (padButtons) { + for (const b of padButtons) { + if (this.padDown.has(b)) return true; + } + } + return false; + } + + /** Действие нажато именно в этом тике. */ + isActionJustPressed(action: string): boolean { + for (const code of this.keysPressed) { + if (this.keyActions.get(code)?.includes(action)) return true; + } + const padButtons = this.padActions.get(action); + if (padButtons) { + for (const b of padButtons) { + if (this.padPressed.has(b)) return true; + } + } + return false; + } + + /** Действие отпущено именно в этом тике (клавиатура или геймпад). */ + isActionJustReleased(action: string): boolean { + for (const code of this.keysReleased) { + if (this.keyActions.get(code)?.includes(action)) return true; + } + const padButtons = this.padActions.get(action); + if (padButtons) { + for (const b of padButtons) { + if (this.padReleased.has(b)) return true; + } + } + return false; + } + + isKeyDown(code: string): boolean { + return this.keysDown.has(code); + } + + wasKeyPressed(code: string): boolean { + return this.keysPressed.has(code); + } + + wasKeyReleased(code: string): boolean { + return this.keysReleased.has(code); + } + + getPointer(): Readonly { + return this.pointer; + } + + /** Вызывается в конце каждого тика: очищает «just pressed/released», копит downTicks. */ + endTick(): void { + this.keysPressed.clear(); + this.keysReleased.clear(); + this.padPressed.clear(); + this.padReleased.clear(); + this.pointer.justPressed = false; + this.pointer.justReleased = false; + if (this.pointer.down) { + this.pointer.downTicks += 1; + } else { + this.pointer.downTicks = 0; + } + } + + 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); + this.canvas.removeEventListener('contextmenu', this.onContextMenu); + } + + // --- инъекция ввода (агентный мост / тесты) --- + // Идёт через ТЕ ЖЕ структуры, что и реальные события: инъекция между тиками + // семантически идентична нажатию, «just pressed» виден ровно один тик. + + /** pointerdown в виртуальных пикселях (без DOM). */ + injectPointerDown(x: number, y: number, isTouch = false): void { + this.pressPointer(x, y, isTouch); + } + + /** pointerup в виртуальных пикселях (без DOM). */ + injectPointerUp(x: number, y: number, isTouch = false): void { + this.releasePointer(x, y, isTouch); + } + + /** Действие «нажато» (isActionJustPressed на следующем тике). */ + injectAction(action: string): void { + for (const code of this.codesOf(action)) this.pressCode(code); + } + + /** Действие «отпущено». */ + injectActionRelease(action: string): void { + for (const code of this.codesOf(action)) this.releaseCode(code); + } + + /** Сырой код клавиши (e.code) — нажать и отпустить нельзя по отдельности: см. injectKeyCodeUp. */ + injectKeyCode(code: string): void { + this.pressCode(code); + } + + /** Сырой код клавиши (e.code) — отпустить. */ + injectKeyCodeUp(code: string): void { + this.releaseCode(code); + } + + // --- общие примитивы (DOM-события и инъекции идут через одни и те же) --- + + private pressCode(code: string): void { + if (!this.keysDown.has(code)) this.keysPressed.add(code); + this.keysDown.add(code); + } + + private releaseCode(code: string): void { + this.keysDown.delete(code); + this.keysReleased.add(code); + } + + /** Все коды клавиш, замапленные на действие. */ + private codesOf(action: string): string[] { + const out: string[] = []; + for (const [code, actions] of this.keyActions) { + if (actions.includes(action)) out.push(code); + } + return out; + } + + private pressPointer(x: number, y: number, isTouch: boolean): void { + this.pointer.x = x; + this.pointer.y = y; + this.pointer.down = true; + this.pointer.justPressed = true; + this.pointer.downTicks = 0; + this.pointer.isTouch = isTouch; + this.handlers.onPointerDown?.(this.pointer); + } + + private releasePointer(x: number, y: number, isTouch: boolean): void { + this.pointer.x = x; + this.pointer.y = y; + this.pointer.down = false; + this.pointer.justReleased = true; + this.pointer.isTouch = isTouch; + this.handlers.onPointerUp?.(this.pointer); + } + + private onKeyDown = (e: KeyboardEvent): void => { + this.pressCode(e.code); + }; + + private onKeyUp = (e: KeyboardEvent): void => { + this.releaseCode(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.pressPointer(x, y, e.pointerType === 'touch'); + }; + + private onPointerUp = (e: PointerEvent): void => { + const { x, y } = this.toVirtual(e); + this.releasePointer(x, y, e.pointerType === 'touch'); + }; + + private onPointerMove = (e: PointerEvent): void => { + const { x, y } = this.toVirtual(e); + this.pointer.x = x; + this.pointer.y = y; + this.handlers.onPointerMove?.(this.pointer); + }; + + private onContextMenu = (e: Event): void => { + e.preventDefault(); + }; +} \ No newline at end of file diff --git a/v1/packages/engine/src/input/VirtualJoystick.ts b/v1/packages/engine/src/input/VirtualJoystick.ts new file mode 100644 index 0000000..6637ab8 --- /dev/null +++ b/v1/packages/engine/src/input/VirtualJoystick.ts @@ -0,0 +1,112 @@ +import { Container, Graphics, Rectangle } from 'pixi.js'; + +/** + * Виртуальный джойстик для тач-устройств: рисуется в uiRoot, показывает + * базовый круг и ручку. Игра читает getVector() и двигает персонажа. + * Появляется там, где игрок коснулся экрана (полупрозрачно), исчезает при отпускании. + */ +export interface VirtualJoystickOptions { + /** Радиус базового круга (виртуальные пиксели). */ + radius?: number; + /** Цвета. */ + color?: number; + /** Отступ от краёв экрана, за который джойстик не заходит. */ + margin?: number; + /** Полные размеры экрана (виртуальные), чтобы клэмпить позицию. */ + screen: { width: number; height: number }; +} + +export class VirtualJoystick extends Container { + private base: Graphics; + private knob: Graphics; + private radius: number; + private margin: number; + private screen: { width: number; height: number }; + private color: number; + private dragging = false; + + /** Текущий вектор (-1..1 по каждой оси; длина ≤ 1). */ + private vector = { x: 0, y: 0 }; + + constructor(options: VirtualJoystickOptions) { + super(); + this.radius = options.radius ?? 28; + this.margin = options.margin ?? 12; + this.screen = options.screen; + this.color = options.color ?? 0x8899aa; + + this.visible = false; + this.eventMode = 'static'; + this.hitArea = new Rectangle(0, 0, this.screen.width, this.screen.height); + + this.base = new Graphics(); + this.knob = new Graphics(); + this.addChild(this.base, this.knob); + this.drawStatic(); + + this.on('pointerdown', (e) => this.onDown(e.global.x, e.global.y)); + this.on('pointermove', (e) => { + if (this.dragging) this.onMove(e.global.x, e.global.y); + }); + this.on('pointerup', () => this.release()); + this.on('pointerupoutside', () => this.release()); + } + + /** Вектор управления; активен ли джойстик сейчас. */ + getVector(): { x: number; y: number } { + return { x: this.vector.x, y: this.vector.y }; + } + + get active(): boolean { + return this.dragging; + } + + /** Обновить размеры экрана (при ресайзе). */ + setScreen(width: number, height: number): void { + this.screen = { width, height }; + } + + private onDown(px: number, py: number): void { + this.dragging = true; + this.visible = true; + this.reposition(px, py); + this.updateKnob(px, py); + } + + private onMove(px: number, py: number): void { + this.updateKnob(px, py); + } + + private release(): void { + this.dragging = false; + this.visible = false; + this.vector = { x: 0, y: 0 }; + } + + private reposition(px: number, py: number): void { + const x = Math.min(Math.max(px, this.margin + this.radius), this.screen.width - this.margin - this.radius); + const y = Math.min(Math.max(py, this.margin + this.radius), this.screen.height - this.margin - this.radius); + this.position.set(x, y); + } + + private updateKnob(px: number, py: number): void { + let dx = px - this.x; + let dy = py - this.y; + const len = Math.hypot(dx, dy); + const max = this.radius; + if (len > max) { + dx = (dx / len) * max; + dy = (dy / len) * max; + } + this.knob.position.set(dx, dy); + this.vector = { x: dx / max, y: dy / max }; + } + + private drawStatic(): void { + this.base.clear(); + this.base.circle(0, 0, this.radius).stroke({ color: this.color, width: 1, alpha: 0.5 }); + this.base.circle(0, 0, this.radius * 0.6).fill({ color: this.color, alpha: 0.08 }); + this.knob.clear(); + this.knob.circle(0, 0, this.radius * 0.35).fill({ color: this.color, alpha: 0.6 }); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/input/__tests__/InputManager.test.ts b/v1/packages/engine/src/input/__tests__/InputManager.test.ts new file mode 100644 index 0000000..d8eae25 --- /dev/null +++ b/v1/packages/engine/src/input/__tests__/InputManager.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { InputManager, type PointerState } from '../InputManager'; + +/** + * Фейковые canvas/window: достаточно, чтобы InputManager работал без браузера. + * События диспатчим напрямую в сохранённые обработчики. + */ +type Handler = (e: unknown) => void; + +function makeFakeEnv() { + const canvasListeners = new Map(); + const windowListeners = new Map(); + const canvas = { + addEventListener: (type: string, fn: Handler) => canvasListeners.set(type, fn), + removeEventListener: (type: string) => canvasListeners.delete(type), + getBoundingClientRect: () => ({ left: 0, top: 0, width: 480, height: 270 }) + }; + (globalThis as Record).window = { + addEventListener: (type: string, fn: Handler) => windowListeners.set(type, fn), + removeEventListener: (type: string) => windowListeners.delete(type) + }; + return { canvas, canvasListeners, windowListeners } as const; +} + +function keyEvent(code: string) { + return { code, pointerType: 'mouse', clientX: 0, clientY: 0 }; +} + +function pointerEvent(x: number, y: number, type = 'mouse') { + return { clientX: x, clientY: y, pointerType: type }; +} + +describe('InputManager (фейковый canvas/window)', () => { + let input: InputManager; + let env: ReturnType; + + beforeEach(() => { + env = makeFakeEnv(); + input = new InputManager(env.canvas as unknown as HTMLCanvasElement, 480, 270); + input.bindActions({ attack: ['Space'], advance: ['Enter'] }); + }); + + it('justPressed/justReleased по клавиатуре, endTick очищает', () => { + const kd = env.windowListeners.get('keydown')!; + const ku = env.windowListeners.get('keyup')!; + + kd(keyEvent('Space')); + expect(input.isActionJustPressed('attack')).toBe(true); + expect(input.isActionActive('attack')).toBe(true); + input.endTick(); + + expect(input.isActionJustPressed('attack')).toBe(false); + expect(input.isActionActive('attack')).toBe(true); + + ku(keyEvent('Space')); + expect(input.wasKeyReleased('Space')).toBe(true); + expect(input.isActionJustReleased('attack')).toBe(true); + expect(input.isActionActive('attack')).toBe(false); + input.endTick(); + expect(input.isActionJustReleased('attack')).toBe(false); + }); + + it('isActionJustReleased не путает действия', () => { + const kd = env.windowListeners.get('keydown')!; + const ku = env.windowListeners.get('keyup')!; + kd(keyEvent('Space')); + input.endTick(); + ku(keyEvent('Space')); + expect(input.isActionJustReleased('attack')).toBe(true); + expect(input.isActionJustReleased('advance')).toBe(false); + }); + + it('одна клавиша маппится на несколько действий', () => { + const kd = env.windowListeners.get('keydown')!; + input.bindActions({ advance: ['Space'] }); // Space уже занят attack + kd(keyEvent('Space')); + expect(input.isActionJustPressed('attack')).toBe(true); + expect(input.isActionJustPressed('advance')).toBe(true); + expect(input.isActionActive('attack')).toBe(true); + expect(input.isActionActive('advance')).toBe(true); + }); + + it('указатель: downTicks копится, justPressed сбрасывается в endTick', () => { + const pd = env.canvasListeners.get('pointerdown')!; + const pu = env.canvasListeners.get('pointerup')!; + + pd(pointerEvent(240, 135)); + const p = input.getPointer() as PointerState; + expect(p.justPressed).toBe(true); + expect(p.downTicks).toBe(0); + expect(p.x).toBe(240); + input.endTick(); + + expect(p.justPressed).toBe(false); + expect(p.down).toBe(true); + input.endTick(); + expect(p.downTicks).toBe(2); // накопился за два тика удержания + + pu(pointerEvent(240, 135)); + expect(p.justReleased).toBe(true); + expect(p.down).toBe(false); + input.endTick(); + expect(p.downTicks).toBe(0); + }); + + it('координаты переводятся в виртуальное разрешение', () => { + const pm = env.canvasListeners.get('pointermove')!; + // фейковый rect: 0..480x270 1:1, проверяем смещение + pm(pointerEvent(96, 54)); + const p = input.getPointer(); + expect(p.x).toBe(96); + expect(p.y).toBe(54); + }); + + it('isTouch помечает тач-события', () => { + const pd = env.canvasListeners.get('pointerdown')!; + pd(pointerEvent(0, 0, 'touch')); + expect(input.getPointer().isTouch).toBe(true); + }); + + // --- инъекция ввода (агентный мост): та же семантика, что у реальных событий --- + + it('injectPointerDown/Up видны как justPressed ровно один тик', () => { + input.injectPointerDown(96, 54); + const p = input.getPointer(); + expect(p.down).toBe(true); + expect(p.justPressed).toBe(true); + expect(p.x).toBe(96); + input.endTick(); + expect(input.getPointer().justPressed).toBe(false); + input.injectPointerUp(96, 54); + expect(input.getPointer().justReleased).toBe(true); + }); + + it('injectAction срабатывает в isActionJustPressed один тик', () => { + input.injectAction('attack'); + expect(input.isActionJustPressed('attack')).toBe(true); + expect(input.isActionActive('attack')).toBe(true); + input.endTick(); + expect(input.isActionJustPressed('attack')).toBe(false); + expect(input.isActionActive('attack')).toBe(true); // всё ещё зажато + input.injectActionRelease('attack'); + expect(input.isActionActive('attack')).toBe(false); + expect(input.isActionJustReleased('attack')).toBe(true); + }); + + it('injectKeyCode виден в wasKeyPressed/в действие-маппинге', () => { + input.injectKeyCode('Space'); + expect(input.wasKeyPressed('Space')).toBe(true); + expect(input.isActionJustPressed('attack')).toBe(true); + input.injectKeyCodeUp('Space'); + expect(input.isKeyDown('Space')).toBe(false); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/inventory/Inventory.ts b/v1/packages/engine/src/inventory/Inventory.ts new file mode 100644 index 0000000..892b0a6 --- /dev/null +++ b/v1/packages/engine/src/inventory/Inventory.ts @@ -0,0 +1,155 @@ +/** + * Инвентарь — жанронезависимый контейнер предметов. + * Хранит счётчики по id: `Map`. Стеки неявные — количество. + * Лимиты (слоты/стак) обрезают добавление и возвращают факт — модель не бросает. + * Смена состава оповещает подписчиков (view-агностично): подписка — как у EventBus, + * но события эмитит сам контейнер, чтобы работать без внешней шины. + */ + +/** Лимиты контейнера (undefined/0 — без лимита). */ +export interface InventoryOptions { + /** Максимум разных слотов (id). */ + maxSlots?: number; + /** Максимум штук в одном слоте. */ + maxPerStack?: number; +} + +export type InventoryChangeKind = 'add' | 'remove' | 'clear' | 'load'; + +/** Деталь изменения: знаковая дельта + итог; clear/load — batch-событие (id '', count 0). */ +export interface InventoryChange { + kind: InventoryChangeKind; + id: string; + count: number; + /** Итоговый счётчик предмета после изменения (0 для clear/load). */ + after: number; +} + +/** Создать инвентарь из сериализованного состояния. */ +export function inventoryFromData( + data: InventoryData | undefined | null, + options: InventoryOptions = {} +): Inventory { + const inv = new Inventory(options); + if (data) inv.load(data); + return inv; +} + +/** Сериализованное состояние инвентаря (для сейвов; формат стабильный). */ +export interface InventoryData { + items: Record; +} + +export class Inventory { + private counts = new Map(); + private listeners = new Set<(change: InventoryChange) => void>(); + private slotLimitValue: number; + private stackLimitValue: number; + + constructor(options: InventoryOptions = {}) { + this.slotLimitValue = options.maxSlots && options.maxSlots > 0 ? options.maxSlots : Infinity; + this.stackLimitValue = options.maxPerStack && options.maxPerStack > 0 ? options.maxPerStack : Infinity; + } + + /** Подписка на изменения. Возвращает отписку. */ + onChange(fn: (change: InventoryChange) => void): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + /** + * Добавить n штук (лимиты обрезают; n <= 0 — ничего). + * Возвращает, сколько реально влезло. + */ + add(itemId: TId, n = 1): number { + if (n <= 0) return 0; + const before = this.counts.get(itemId) ?? 0; + if (before === 0 && this.counts.size >= this.slotLimitValue) return 0; + const gained = Math.min(n, this.stackLimitValue - before); + if (gained <= 0) return 0; + this.counts.set(itemId, before + gained); + this.notify({ kind: 'add', id: itemId, count: gained, after: before + gained }); + return gained; + } + + /** Сколько ещё влезет этого предмета (лимиты стака и слотов). */ + spaceFor(itemId: TId): number { + const have = this.counts.get(itemId) ?? 0; + if (have > 0) return Math.max(0, this.stackLimitValue - have); + return this.counts.size >= this.slotLimitValue ? 0 : this.stackLimitValue; + } + + /** + * Убрать n штук предмета. Если после вычитания стало <= 0 — слот исчезает. + * Возвращает, сколько реально убрано (нельзя убрать больше, чем есть). + */ + remove(itemId: TId, n = 1): number { + const have = this.counts.get(itemId) ?? 0; + if (have <= 0 || n <= 0) return 0; + const left = Math.max(0, have - n); + if (left === 0) this.counts.delete(itemId); + else this.counts.set(itemId, left); + this.notify({ kind: 'remove', id: itemId, count: -Math.min(n, have), after: left }); + return Math.min(n, have); + } + + /** Сколько штук предмета лежит (0, если нет). */ + count(itemId: TId): number { + return this.counts.get(itemId) ?? 0; + } + + /** Есть ли предмет (в количестве >= 1). */ + has(itemId: TId): boolean { + return this.count(itemId) > 0; + } + + /** Все слоты (id -> количество); порядок вставки. */ + get all(): Array<{ id: string; count: number }> { + return [...this.counts].map(([id, count]) => ({ id, count })); + } + + get empty(): boolean { + return this.counts.size === 0; + } + + /** Занято слотов (разных id). */ + get size(): number { + return this.counts.size; + } + + /** Лимит слотов (Infinity — без лимита). */ + get slotLimit(): number { + return this.slotLimitValue; + } + + /** Лимит стака (Infinity — без лимита). */ + get stackLimit(): number { + return this.stackLimitValue; + } + + /** Полностью очистить (новая игра). */ + clear(): void { + if (this.counts.size === 0) return; + this.counts.clear(); + this.notify({ kind: 'clear', id: '', count: 0, after: 0 }); + } + + serialize(): InventoryData { + return { items: Object.fromEntries(this.counts) }; + } + + /** Загрузить состояние (мусор и count <= 0 игнорируются; кламп под лимиты). */ + load(data: InventoryData): void { + this.counts.clear(); + for (const [id, count] of Object.entries(data.items ?? {})) { + if (typeof count !== 'number' || count <= 0) continue; + if (this.counts.size >= this.slotLimitValue) break; // берём первые N слотов + this.counts.set(id, Math.min(count, this.stackLimitValue)); + } + this.notify({ kind: 'load', id: '', count: 0, after: 0 }); + } + + private notify(change: InventoryChange): void { + for (const fn of this.listeners) fn(change); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/inventory/__tests__/Inventory.test.ts b/v1/packages/engine/src/inventory/__tests__/Inventory.test.ts new file mode 100644 index 0000000..9e3a97a --- /dev/null +++ b/v1/packages/engine/src/inventory/__tests__/Inventory.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Inventory, inventoryFromData } from '../Inventory'; + +describe('Inventory', () => { + it('добавляет и считает предметы', () => { + const inv = new Inventory(); + expect(inv.empty).toBe(true); + inv.add('cloth'); + inv.add('bellflower', 2); + expect(inv.count('cloth')).toBe(1); + expect(inv.count('bellflower')).toBe(2); + expect(inv.has('cloth')).toBe(true); + expect(inv.has('salt')).toBe(false); + expect(inv.empty).toBe(false); + }); + + it('remove вычитает и чистит пустые слоты', () => { + const inv = new Inventory(); + inv.add('bellflower', 3); + expect(inv.remove('bellflower', 2)).toBe(2); + expect(inv.count('bellflower')).toBe(1); + expect(inv.remove('bellflower', 5)).toBe(1); // нельзя убрать больше, чем есть + expect(inv.count('bellflower')).toBe(0); + expect(inv.has('bellflower')).toBe(false); + expect(inv.remove('cloth')).toBe(0); // нет предмета — ничего + }); + + it('add с неположительным n — no-op', () => { + const inv = new Inventory(); + inv.add('salt', 0); + inv.add('salt', -1); + expect(inv.empty).toBe(true); + }); + + it('onChange оповещает о каждом изменении, отписка работает', () => { + const inv = new Inventory(); + const fn = vi.fn(); + const off = inv.onChange(fn); + inv.add('cloth'); + inv.add('cloth'); + inv.remove('cloth'); + off(); + inv.add('cloth'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('serialize/load round-trip; load игнорирует мусор', () => { + const inv = new Inventory(); + inv.add('cloth'); + inv.add('bellflower', 3); + const data = inv.serialize(); + const back = inventoryFromData(JSON.parse(JSON.stringify(data))); + expect(back.all).toEqual([ + { id: 'cloth', count: 1 }, + { id: 'bellflower', count: 3 } + ]); + back.load({ items: { salt: 0, bad: -2, cloth: 2 } }); + expect(back.count('salt')).toBe(0); + expect(back.count('bad')).toBe(0); + expect(back.count('cloth')).toBe(2); + }); + + it('clear сбрасывает содержимое', () => { + const inv = new Inventory(); + inv.add('cloth'); + inv.clear(); + expect(inv.empty).toBe(true); + }); + + it('maxPerStack обрезает добавление и возвращает факт', () => { + const inv = new Inventory({ maxPerStack: 3 }); + expect(inv.add('bellflower', 5)).toBe(3); + expect(inv.count('bellflower')).toBe(3); + expect(inv.add('bellflower', 1)).toBe(0); // стак полон + expect(inv.spaceFor('bellflower')).toBe(0); + expect(inv.spaceFor('salt')).toBe(3); + }); + + it('maxSlots не даёт заводить новый слот, но стек в существующий идёт', () => { + const inv = new Inventory({ maxSlots: 2 }); + inv.add('cloth'); + inv.add('salt'); + expect(inv.add('bellflower')).toBe(0); // слотов нет + expect(inv.has('bellflower')).toBe(false); + expect(inv.add('salt', 2)).toBe(2); // свой слот не лимитируем + }); + + it('load клампит под лимиты', () => { + const inv = new Inventory({ maxSlots: 2, maxPerStack: 5 }); + inv.load({ items: { a: 9, b: 2, c: 1 } }); + expect(inv.all).toEqual([ + { id: 'a', count: 5 }, + { id: 'b', count: 2 } + ]); + expect(inv.size).toBe(2); + }); + + it('событие несёт деталь изменения для всех kind', () => { + const inv = new Inventory(); + const fn = vi.fn(); + inv.onChange(fn); + inv.add('cloth', 2); + expect(fn).toHaveBeenLastCalledWith({ kind: 'add', id: 'cloth', count: 2, after: 2 }); + inv.remove('cloth'); + expect(fn).toHaveBeenLastCalledWith({ kind: 'remove', id: 'cloth', count: -1, after: 1 }); + inv.remove('cloth', 1); + expect(fn).toHaveBeenLastCalledWith({ kind: 'remove', id: 'cloth', count: -1, after: 0 }); + inv.clear(); // пусто — clear не эмитится + inv.add('salt'); + inv.load({ items: {} }); + expect(fn).toHaveBeenLastCalledWith({ kind: 'load', id: '', count: 0, after: 0 }); + inv.add('salt'); + inv.clear(); + expect(fn).toHaveBeenLastCalledWith({ kind: 'clear', id: '', count: 0, after: 0 }); + }); + + it('генерик типизирует id (компиляция)', () => { + type Item = 'a' | 'b'; + const inv = new Inventory(); + inv.add('a', 2); + expect(inv.count('a')).toBe(2); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/map/IsometricTileMap.ts b/v1/packages/engine/src/map/IsometricTileMap.ts new file mode 100644 index 0000000..a3aca30 --- /dev/null +++ b/v1/packages/engine/src/map/IsometricTileMap.ts @@ -0,0 +1,519 @@ +import { Container, Graphics, Texture, Sprite } from 'pixi.js'; +import { Grid } from './pathfinding'; +import { StaticGrid } from './collision'; +import { IsoLayout, DEFAULT_ISO, isoToScreen, screenToIso } from '../math/iso'; +import { hashTile } from '../math/rng'; +import { IsoDepthLayer } from '../render/IsoDepthLayer'; + +/** + * Высокий объект на тайле: рисуется спрайтом (якорь в центре ромба) или, + * если текстуры нет, колонной высотой height мировых юнитов (·tileW px). + * ground — id тайла, каким рисовать землю под объектом (по умолчанию — сам объект). + * shadow — спрайт шириной в тайл экранно накрывает тайл «за» объектом + * (tx-1, ty-1): он блокируется для движения (герой не заходит «внутрь» дома). + */ +export interface TallSpec { + height: number; + ground?: number; + shadow?: boolean; +} + +/** + * Крупный объект на несколько тайлов (дом, большое дерево): footprint w×h + * от северо-западного угла (x, y). Коллизия — весь footprint; земля под ним — + * ground (если задан); спрайт якорится низом в центр footprint'а. + * Спрайт живёт в map.propViews — сцена добавляет его в свой IsoDepthLayer + * через addRect, чтобы герои корректно перекрывались объектом. + */ +export interface PropData { + /** id текстуры спрайта. */ + id: number; + /** Северо-западный тайл footprint. */ + x: number; + y: number; + /** Размер footprint в тайлах (по умолчанию 1×1). */ + w?: number; + h?: number; + /** Чем рисовать землю под footprint (по умолчанию — не трогать). */ + ground?: number; + /** Высота в мировых юнитах — для плейсхолдера без текстуры. */ + height?: number; +} + +/** Проп после нормализации (w/h заполнены). */ +export interface PropPlaced extends PropData { + w: number; + h: number; +} + +/** Спрайт пропа + его footprint — сцена вставляет view в свой depth-слой. */ +export interface PropView { + view: Container; + prop: PropPlaced; +} + +/** + * Мелкий плоский декор на тайле (кустик, камень, проплешина) — рисуется + * плашмя между землёй и высокими объектами, глубина не сортируется. + */ +export interface DecorData { + /** id текстуры декора. */ + id: number; + /** Тайл (ромб), на котором лежит декор. */ + x: number; + y: number; + /** Смещение от центра ромба в px виртуального разрешения. */ + dx?: number; + dy?: number; +} + +/** + * Изометрическая тайл-карта. + * Данные карты — числа (id тайлов); отрисовка — по таблице id -> Texture. + * Проходимость определяется набором блокирующих id; тайлы «высоких» объектов + * (стены, деревья) рисуются поверх земли по строкам глубины. + */ +export interface TileMapData { + width: number; + height: number; + /** Индексы тайлов, length = width * height. */ + tiles: number[]; + /** Тайлы с этими id непроходимы. */ + blocked: number[]; + /** Тайлы с этими id рисуются как вертикальные объекты. */ + tall?: Record; + /** Крупные объекты с footprint'ом в несколько тайлов. */ + props?: PropData[]; + /** + * Варианты текстур: id -> список id-шников вариантов (включая сам id). + * Клетка рисуется одним из вариантов — детерминированно по (x, y, id), + * поэтому вид карты стабилен между запусками и при setTile. + */ + variants?: Record; + /** Мелкий плоский декор (микро-кусты, камни) — движок только рисует список. */ + decor?: DecorData[]; +} + +/** Спрайты одной ячейки — для перерисовки тайла без перестроения карты. */ +interface CellSprites { + ground?: Sprite; + object?: Sprite; + /** Плейсхолдеры без текстуры (чистятся вместе с ячейкой при перерисовке). */ + groundPlaceholder?: Container; + objectPlaceholder?: Container; + /** id тайла, по которому создан спрайт (для регистрации в анимациях). */ + groundId?: number; + objectId?: number; +} + +/** Анимация тайлов одного id: кадры, fps и общий таймлайн на все клетки. */ +interface TileAnim { + frames: Texture[]; + fps: number; + t: number; + sprites: Set; +} + +export class IsometricTileMap implements Grid { + readonly data: TileMapData; + readonly iso: IsoLayout; + readonly view: Container; + + private textures: Map; + /** Проходимость (blocked/пропы/тени) — единая с collision.gridOf логика. */ + private grid: StaticGrid; + private tallSpecs: Map; + /** Пропы после нормализации; их footprint блокирует клетки карты. */ + readonly props: PropPlaced[]; + /** Спрайты пропов: сцена вставляет их в свой IsoDepthLayer (addRect). */ + readonly propViews: PropView[] = []; + /** Depth-слой для tall-объектов (null — рисовать в objectsLayer, как раньше). */ + private tallDepthLayer: IsoDepthLayer | null = null; + private groundLayer: Container; + private decorLayer: Container; + private objectsLayer: Container; + /** Декор по тайлам (key = ty*width+tx) — один спрайт на тайл. */ + private decorSprites = new Map(); + private cells = new Map(); + private anims = new Map(); + private spriteAnims = new Map(); + /** Варианты текстур по id (из данных или setTileVariants). */ + private variants = new Map(); + /** Окно куллинга (тайлы); null — без куллинга (всё видно). */ + private cullRange: { x0: number; y0: number; x1: number; y1: number } | null = null; + + 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.textures = textures; + this.grid = new StaticGrid(data); + for (const [id, ids] of Object.entries(data.variants ?? {})) { + this.variants.set(Number(id), ids); + } + this.tallSpecs = new Map( + Object.entries(data.tall ?? {}).map(([id, spec]) => [ + Number(id), + typeof spec === 'number' ? { height: spec } : spec + ]) + ); + this.view = new Container(); + + const ground = new Container(); + const decor = new Container(); + const objects = new Container(); + this.groundLayer = ground; + this.decorLayer = decor; + this.objectsLayer = objects; + // Порядок детей = порядок отрисовки: земля -> декор (плашмя) -> объекты. + this.view.addChild(ground, decor, objects); + + // Сортировка глубины по диагонали (tx + ty) — классика изометрии. + const cells: { tx: number; ty: number }[] = []; + for (let ty = 0; ty < data.height; ty++) { + for (let tx = 0; tx < data.width; tx++) { + cells.push({ tx, ty }); + } + } + cells.sort((a, b) => a.tx + a.ty - (b.tx + b.ty)); + + for (const cell of cells) { + this.drawCell(cell.tx, cell.ty); + } + + // Пропы: footprint блокирует клетки (в grid), под ним рисуем ground, + // спрайт — в propViews (сцена вставит его в свой depth-слой с addRect). + this.props = (data.props ?? []).map((p) => ({ ...p, w: p.w ?? 1, h: p.h ?? 1 })); + for (const prop of this.props) { + this.drawPropGround(prop); + this.propViews.push({ view: this.makePropView(prop), prop }); + } + + // Декор из данных: движок только рисует список (генерация — на стороне игры). + for (const d of data.decor ?? []) { + const tex = this.textures.get(d.id); + if (tex) this.addDecor(d.x, d.y, tex, { dx: d.dx, dy: d.dy }); + } + } + + /** + * Положить декор на тайл (микро-куст, камень): плашмя, якорь в центре ромба, + * опциональное смещение dx/dy для живости. Один спрайт на тайл — повторный + * addDecor заменяет предыдущий. setTile декор не трогает. + * Возвращает спрайт (игра может его анимировать); тайл вне карты — undefined. + */ + addDecor(tx: number, ty: number, tex: Texture, opts: { dx?: number; dy?: number } = {}): Sprite | undefined { + if (tx < 0 || ty < 0 || tx >= this.data.width || ty >= this.data.height) return undefined; + this.clearDecor(tx, ty); + const p = isoToScreen(tx, ty, this.iso); + const s = new Sprite(tex); + s.anchor.set(0.5, 0.5); + s.position.set(p.x + (opts.dx ?? 0), p.y + this.iso.tileH / 2 + (opts.dy ?? 0)); + this.decorLayer.addChild(s); + this.decorSprites.set(ty * this.data.width + tx, s); + return s; + } + + /** Убрать декор с тайла (no-op, если его нет). */ + clearDecor(tx: number, ty: number): void { + const s = this.decorSprites.get(ty * this.data.width + tx); + if (!s) return; + s.destroy(); + this.decorSprites.delete(ty * this.data.width + tx); + } + + /** Земля под footprint'ом пропа (если задан ground и текстура есть). */ + private drawPropGround(prop: PropPlaced): void { + const groundTex = prop.ground !== undefined ? this.textures.get(prop.ground) : undefined; + if (!groundTex) return; + for (let ty = prop.y; ty < prop.y + prop.h; ty++) { + for (let tx = prop.x; tx < prop.x + prop.w; tx++) { + const p = isoToScreen(tx, ty, this.iso); + const s = new Sprite(groundTex); + s.anchor.set(0.5, 0); + s.position.set(p.x, p.y); + this.groundLayer.addChild(s); + } + } + } + + /** Спрайт пропа (якорь низ-центр footprint'а) или колонна-плейсхолдер. */ + private makePropView(prop: PropPlaced): Container { + const c = isoToScreen(prop.x + prop.w / 2, prop.y + prop.h / 2, this.iso); + const tex = this.textures.get(prop.id); + if (tex) { + const s = new Sprite(tex); + s.anchor.set(0.5, 1); + s.position.set(c.x, c.y); + return s; + } + return this.drawPlaceholder(c.x, c.y, prop.w, prop.h, prop.height ?? 2); + } + + /** + * Колонна-плейсхолдер: основание — параллелограмм footprint (w×h юнитов), + * выдавленный вверх на height юнитов. Три видимые грани: верх, лево, право. + */ + private drawPlaceholder(cx: number, cy: number, w: number, h: number, height: number): Container { + const hw = this.iso.tileW / 2; + const hh = this.iso.tileH / 2; + const top = height * this.iso.tileW; + // Углы основания относительно центра (проекция мирового смещения). + const nw = { x: ((h - w) / 2) * hw, y: -((w + h) / 2) * hh }; + const ne = { x: ((w + h) / 2) * hw, y: ((h - w) / 2) * hh }; + const se = { x: ((w - h) / 2) * hw, y: ((w + h) / 2) * hh }; + const sw = { x: -((w + h) / 2) * hw, y: ((w - h) / 2) * hh }; + const g = new Graphics(); + const face = (a: typeof nw, b: typeof nw, fill: number) => + g.poly([cx + a.x, cy + a.y - top, cx + b.x, cy + b.y - top, cx + b.x, cy + b.y, cx + a.x, cy + a.y]).fill(fill); + // верхняя грань + g.poly([cx + nw.x, cy + nw.y - top, cx + ne.x, cy + ne.y - top, cx + se.x, cy + se.y - top, cx + sw.x, cy + sw.y - top]).fill(0x6b5a44); + // левые и правые грани (к юго-западному и юго-восточному ребру) + face(sw, se, 0x4a3b2a); + face(se, ne, 0x352a1e); + return g; + } + + /** + * Изменить тайл: обновляет данные и перерисовывает одну ячейку + * (сбор предметов, посадка цветов, разрушаемые стены и т.п.). + */ + setTile(x: number, y: number, id: number): void { + if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return; + this.data.tiles[y * this.data.width + x] = id; + this.drawCell(x, y); + } + + /** + * Отдавать tall-спрайты в depth-слой актёров (герой за деревом/домом + * прячется): глубина tx+ty, как у героев. null — вернуть в objectsLayer + * (порядок отрисовки по строкам, как до вызова). Куллинг не ломается: + * видимость переключается по ссылке из cells независимо от родителя. + */ + setTallDepthLayer(layer: IsoDepthLayer | null): void { + this.tallDepthLayer = layer; + for (const [key, cell] of this.cells) { + const tx = key % this.data.width; + const ty = Math.floor(key / this.data.width); + this.placeTallView(cell, tx, ty); + } + } + + /** Положить tall-вьюху клетки в назначенный слой (или обратно в карту). */ + private placeTallView(cell: CellSprites, tx: number, ty: number): void { + const target: Container | null = this.tallDepthLayer ?? this.objectsLayer; + const view = cell.object ?? cell.objectPlaceholder; + if (target && view && view.parent !== target) { + if (this.tallDepthLayer) this.tallDepthLayer.add(view, tx, ty); + else target.addChild(view); + } + } + + /** + * Анимированный тайл: клетки с этим id переключают кадры по одному общему + * таймлайну (дёшево при сотнях клеток воды). Регистрирует и уже + * нарисованные клетки. frames — текстуры кадров (вода_1, вода_2, ...). + * Сочетается с вариантами: анимация регистрируется на фактический + * (вариантный) id клетки, поэтому для полного покрытия id с вариантами + * вызовите setTileAnimation и для каждого варианта. + */ + setTileAnimation(id: number, frames: Texture[], fps: number): void { + if (frames.length === 0 || fps <= 0) return; + this.anims.set(id, { frames, fps, t: 0, sprites: new Set() }); + for (const cell of this.cells.values()) { + if (cell.ground && cell.groundId === id) this.registerAnim(cell.ground, id); + if (cell.object && cell.objectId === id) this.registerAnim(cell.object, id); + } + } + + /** Тик анимаций тайлов; из update сцены (no-op, если анимаций нет). */ + update(dt: number): void { + for (const anim of this.anims.values()) { + anim.t += dt; + const tex = anim.frames[Math.floor(anim.t * anim.fps) % anim.frames.length]; + for (const s of anim.sprites) s.texture = tex; + } + } + + private registerAnim(sprite: Sprite, id: number): void { + this.anims.get(id)?.sprites.add(sprite); + this.spriteAnims.set(sprite, id); + } + + /** + * Рантайм-варианты текстур (движок не знает, что это — трава или пепел): + * клетки id рисуются детерминированным выбором из ids (включая сам id). + * Сочетается с анимацией: каждая клетка анимируется по своему фактическому + * варианту (см. setTileAnimation). + */ + setTileVariants(id: number, ids: number[]): void { + if (ids.length === 0) return; + this.variants.set(id, ids); + for (const [key, cell] of this.cells) { + if (cell.groundId === id || cell.objectId === id) { + this.drawCell(key % this.data.width, Math.floor(key / this.data.width)); + } + } + } + + private unregisterAnim(sprite: Sprite): void { + const id = this.spriteAnims.get(sprite); + if (id === undefined) return; + this.anims.get(id)?.sprites.delete(sprite); + this.spriteAnims.delete(sprite); + } + + /** Нарисовать (или перерисовать после setTile) одну ячейку карты. */ + private drawCell(tx: number, ty: number): void { + const iso = this.iso; + const id = this.data.tiles[ty * this.data.width + tx]; + const key = ty * this.data.width + tx; + const old = this.cells.get(key); + if (old?.ground) { this.unregisterAnim(old.ground); old.ground.destroy(); } + if (old?.object) { this.unregisterAnim(old.object); old.object.destroy(); } + old?.groundPlaceholder?.destroy(); + old?.objectPlaceholder?.destroy(); + const fresh: CellSprites = {}; + this.cells.set(key, fresh); + + const tall = this.tallSpecs.get(id); + const p = isoToScreen(tx, ty, iso); + + // Земля: под высоким объектом рисуем его ground-тайл (или плейсхолдер). + const groundBase = tall?.ground ?? id; + const groundId = this.variantOf(groundBase, tx, ty); + fresh.groundId = groundBase; // для матчинга анимаций — базовый id + const groundTex = this.textures.get(groundId); + if (groundTex) { + const s = new Sprite(groundTex); + s.anchor.set(0.5, 0); + s.position.set(p.x, p.y); + this.groundLayer.addChild(s); + fresh.ground = s; + this.registerAnim(s, groundBase); + } 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.grid.isBlockedId(id) ? 0x4a3b2a : 0x2d5a27); + this.groundLayer.addChild(g); + fresh.groundPlaceholder = g; + } + + // Высокий объект: спрайт с якорем в центре ромба, иначе колонна. + // objectId хранит фактический (вариантный) id — анимация матчится по нему. + if (tall) { + const objId = this.variantOf(id, tx, ty); + fresh.objectId = objId; + const objTex = this.textures.get(objId); + if (objTex) { + const s = new Sprite(objTex); + s.anchor.set(0.5, 1); + s.position.set(p.x, p.y + iso.tileH / 2); + fresh.object = s; + this.registerAnim(s, objId); + } else { + fresh.objectPlaceholder = this.drawPlaceholder(p.x, p.y + iso.tileH / 2, 1, 1, tall.height); + } + // Слой: depth-слой актёров (если назначен) или objectsLayer карты. + this.placeTallView(fresh, tx, ty); + } + } + + /** + * Вариант id для клетки: детерминированный выбор по хэшу (без анимации). + * Публичный — игре полезно знать, какой id фактически нарисован. + */ + variantOf(id: number, tx: number, ty: number): number { + const ids = this.variants.get(id); + return ids ? ids[hashTile(tx, ty, id) % ids.length]! : id; + } + + /** + * Показать только тайлы, чьи спрайты пересекают прямоугольник в px слоя + * view (мировые px: экранный канвас минус position worldRoot); null — + * вернуть всё видимым. Спрайты переключаются только при смене тайлового + * окна; запас по краям — на tall-объекты (растут вверх от якоря). + */ + cullToRect(rect: { x: number; y: number; width: number; height: number } | null): void { + if (!rect) { + if (this.cullRange !== null) this.applyCull(null); + return; + } + const { x, y } = screenToIso(rect.x, rect.y, this.iso); + const { x: x1, y: y1 } = screenToIso(rect.x + rect.width, rect.y, this.iso); + const { x: x2, y: y2 } = screenToIso(rect.x, rect.y + rect.height, this.iso); + const { x: x3, y: y3 } = screenToIso(rect.x + rect.width, rect.y + rect.height, this.iso); + const margin = this.tallMarginRows(); + const range = { + x0: Math.min(x, x1, x2, x3) - 1, // спрайты шире точки якоря + x1: Math.max(x, x1, x2, x3) + 1, + y0: Math.min(y, y1, y2, y3) - margin, // tall растёт вверх по строкам + y1: Math.max(y, y1, y2, y3) + 1 + }; + const last = this.cullRange; + if (last && last.x0 === range.x0 && last.x1 === range.x1 && last.y0 === range.y0 && last.y1 === range.y1) { + return; // окно в тайлах не сдвинулось — спрайты не трогаем + } + this.applyCull(range); + } + + /** Видим ли тайл при текущем окне куллинга (для тестов и отладки). */ + isTileVisible(tx: number, ty: number): boolean { + const r = this.cullRange; + if (!r) return true; + return tx >= r.x0 && tx <= r.x1 && ty >= r.y0 && ty <= r.y1; + } + + /** Запас строк сверху: высота самого высокого tall-спрайта, переведённая в тайлы. */ + private tallMarginRows(): number { + let maxPx = 0; + for (const spec of this.tallSpecs.values()) { + maxPx = Math.max(maxPx, spec.height * this.iso.tileW); + } + return Math.ceil(maxPx / this.iso.tileH) + 1; + } + + /** Переключить видимость всех спрайтов ячеек/декора под окном. */ + private applyCull(range: { x0: number; y0: number; x1: number; y1: number } | null): void { + this.cullRange = range; + const w = this.data.width; + for (const [key, cell] of this.cells) { + const visible = range === null || this.tileInRange(key % w, Math.floor(key / w), range); + if (cell.ground) cell.ground.visible = visible; + if (cell.object) cell.object.visible = visible; + if (cell.groundPlaceholder) cell.groundPlaceholder.visible = visible; + if (cell.objectPlaceholder) cell.objectPlaceholder.visible = visible; + } + for (const [key, s] of this.decorSprites) { + s.visible = range === null || this.tileInRange(key % w, Math.floor(key / w), range); + } + } + + private tileInRange(tx: number, ty: number, r: { x0: number; y0: number; x1: number; y1: number }): boolean { + return tx >= r.x0 && tx <= r.x1 && ty >= r.y0 && ty <= r.y1; + } + + isWalkable(x: number, y: number): boolean { + return this.grid.isWalkable(x, y); + } + + /** Тайл накрыт footprint'ом пропа (для LOS/снарядов: дом — препятствие). */ + isPropBlocked(x: number, y: number): boolean { + return this.grid.isPropBlocked(x, y); + } + + /** Границы карты в мировых юнитах (для ограничения камеры). */ + get worldBounds(): { x: number; y: number; width: number; height: number } { + return { x: 0, y: 0, width: this.data.width, height: this.data.height }; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/map/__tests__/collision.test.ts b/v1/packages/engine/src/map/__tests__/collision.test.ts new file mode 100644 index 0000000..7b245cb --- /dev/null +++ b/v1/packages/engine/src/map/__tests__/collision.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import { circleFits, gridOf, moveCircle, separateCircles, shadowTilesOf } from '../collision'; +import type { Grid } from '../pathfinding'; +import type { TileMapData } from '../IsometricTileMap'; + +/** Карта 8×8: трава (0) везде, стена по x=4 (id 1), дом-проп 2×1 в (6,1). */ +function testMap(): TileMapData { + const w = 8; + const h = 8; + const tiles = new Array(w * h).fill(0); + for (let y = 0; y < h; y++) tiles[y * w + 4] = 1; // вертикальная стена + return { + width: w, + height: h, + tiles, + blocked: [1], + props: [{ id: 2, x: 6, y: 1, w: 2, h: 1 }] + }; +} + +/** Grid из набора строк-символов: '.' — проходимо, '#' — стена. */ +function gridFrom(rows: string[]): Grid { + return { + width: rows[0]!.length, + height: rows.length, + isWalkable: (x, y) => rows[y]?.[x] === '.' + }; +} + +describe('gridOf', () => { + it('blocked-иды и footprint пропов блокируют тайлы', () => { + const g = gridOf(testMap()); + expect(g.isWalkable(0, 0)).toBe(true); + expect(g.isWalkable(4, 3)).toBe(false); // стена + expect(g.isWalkable(6, 1)).toBe(false); // дом + expect(g.isWalkable(7, 1)).toBe(false); // дом (вторая клетка footprint) + expect(g.isWalkable(6, 2)).toBe(true); + expect(g.isWalkable(-1, 0)).toBe(false); + expect(g.isWalkable(8, 0)).toBe(false); + }); +}); + +describe('shadowTilesOf', () => { + /** Карта 6×6: дом (id 2) в (3,2) с тенью, ещё высокий объект (id 3) в (0,0)... вне поля. */ + function shadowMap(): TileMapData { + const w = 6; + const h = 6; + const tiles = new Array(w * h).fill(0); + tiles[3 * w + 3] = 2; // дом с tall.shadow + tiles[2 * w + 4] = 3; // высокий объект БЕЗ тени + return { + width: w, + height: h, + tiles, + blocked: [2], + tall: { 2: { height: 1.75, shadow: true }, 3: { height: 1 } } + }; + } + + it('тень ложится на тайл за объектом и не блокирует ничего больше', () => { + const shadow = shadowTilesOf(shadowMap()); + expect([...shadow]).toEqual([2 * 6 + 2]); // (2,2) — тайл «за» домом + const g = gridOf(shadowMap()); + expect(g.isWalkable(2, 2)).toBe(false); // тень непроходима + expect(g.isWalkable(3, 3)).toBe(false); // сам дом + expect(g.isWalkable(4, 2)).toBe(true); // высокий без тени не блокирует соседей + expect(g.isWalkable(4, 3)).toBe(true); + }); + + it('два высоких объекта подряд не создают тень друг на друге', () => { + const data = shadowMap(); + data.tiles[2 * 6 + 2] = 3; // высокий сосед прямо в «тени» дома + const shadow = shadowTilesOf(data); + expect([...shadow]).toEqual([]); // сосед высокий — блокируется и без тени + }); +}); + +describe('circleFits', () => { + it('тело меньше тайла проходит в свободном тайле', () => { + const g = gridOf(testMap()); + expect(circleFits(g, { x: 2.5, y: 2.5 }, 0.35)).toBe(true); + }); + + it('не заходит в стену с любой стороны', () => { + const g = gridOf(testMap()); + const r = 0.35; + expect(circleFits(g, { x: 3.5, y: 2.5 }, r)).toBe(true); + expect(circleFits(g, { x: 3.66, y: 2.5 }, r)).toBe(false); // x+r = 4.01 > 4 + expect(circleFits(g, { x: 4.34, y: 2.5 }, r)).toBe(false); // x-r = 3.99 < 5 + expect(circleFits(g, { x: 3.65, y: 2.5 }, r)).toBe(true); // касание вплотную + }); + + it('ловит угловой прокол диагонального тайла (выборка точек его пропускает)', () => { + const g = gridFrom([ + '....', + '...#', + '....', + '....' + ]); + // Угол blocked-тайла (3,1) в круге, а крайние точки — в свободных. + expect(circleFits(g, { x: 2.8, y: 1.8 }, 0.45)).toBe(false); + }); + + it('вне карты — стена', () => { + const g = gridOf(testMap()); + expect(circleFits(g, { x: 0.2, y: 0.5 }, 0.35)).toBe(false); + }); +}); + +describe('moveCircle — скольжение вдоль стены', () => { + it('прижим к стене не мешает движению вдоль неё', () => { + const g = gridOf(testMap()); + const pos = { x: 3.6, y: 1.0 }; + // Вдоль стены (вниз по y): x-компонента блокирована, y — проходит. + const moved = moveCircle(g, pos, { x: 0.2, y: 0.5 }, 0.35); + expect(moved).toBe(true); + expect(pos.x).toBeCloseTo(3.6); // x не сдвинулся + expect(pos.y).toBeCloseTo(1.5); + }); + + it('в угол — обе оси блокированы, позиция не меняется', () => { + const g = gridFrom([ + '.#', + '#.' + ]); + const pos = { x: 0.5, y: 0.5 }; + expect(moveCircle(g, pos, { x: 0.4, y: 0.4 }, 0.3)).toBe(false); + expect(pos).toEqual({ x: 0.5, y: 0.5 }); + }); +}); + +describe('moveCircle — совместимость с A*-маршрутом', () => { + it('путь по центрам тайлов проходим телом r ≤ 0.45 (диагональ у стены)', () => { + const g = gridOf(testMap()); + const path = [{ x: 2, y: 2 }, { x: 3, y: 3 }, { x: 3, y: 4 }]; + const pos = { x: 2.5, y: 2.5 }; + const near = (a: number, b: number) => Math.abs(a - b) < 0.01; + for (const tile of path) { + // Шаг к центру следующего тайла — так двигает waypoint-логика. + const target = { x: tile.x + 0.5, y: tile.y + 0.5 }; + for (let i = 0; i < 200 && !(near(pos.x, target.x) && near(pos.y, target.y)); i++) { + const dx = Math.sign(target.x - pos.x) * 0.1; + const dy = Math.sign(target.y - pos.y) * 0.1; + const before = { ...pos }; + moveCircle(g, pos, { x: dx, y: dy }, 0.35); + expect(pos).not.toEqual(before); // продвижение есть — путь не застрял + } + expect(near(pos.x, target.x) && near(pos.y, target.y)).toBe(true); // дошли + } + }); +}); + +describe('separateCircles', () => { + it('перекрытие — a выталкивается ровно на глубину, b не трогается', () => { + const a = { x: 1.0, y: 0.0 }; + const b = { x: 1.3, y: 0.0 }; + separateCircles(a, 0.35, b, 0.2); + // Перекрытие = (0.35+0.2) − 0.3 = 0.25. + expect(b).toEqual({ x: 1.3, y: 0.0 }); + expect(a.x).toBeCloseTo(0.75); + expect(a.y).toBeCloseTo(0.0); + }); + + it('без перекрытия — ничего не двигается', () => { + const a = { x: 0.0, y: 0.0 }; + const b = { x: 2.0, y: 0.0 }; + separateCircles(a, 0.35, b, 0.35); + expect(a).toEqual({ x: 0.0, y: 0.0 }); + }); + + it('слитые в точку — расходятся по +X (детерминированный фолбэк)', () => { + const a = { x: 5.0, y: 5.0 }; + separateCircles(a, 0.3, { x: 5.0, y: 5.0 }, 0.3); + expect(a.x).toBeCloseTo(5.6); + expect(a.y).toBeCloseTo(5.0); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/map/__tests__/isometricMap.test.ts b/v1/packages/engine/src/map/__tests__/isometricMap.test.ts new file mode 100644 index 0000000..089d689 --- /dev/null +++ b/v1/packages/engine/src/map/__tests__/isometricMap.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from 'vitest'; +import { Container, Texture } from 'pixi.js'; +import { IsometricTileMap, type TileMapData } from '../IsometricTileMap'; +import { IsoDepthLayer } from '../../render/IsoDepthLayer'; +import { isoToScreen } from '../../math/iso'; + +function makeData(): TileMapData { + return { + width: 4, + height: 4, + tiles: new Array(16).fill(0), + blocked: [2], + tall: { 3: { height: 24, ground: 0 } } + }; +} + +describe('IsometricTileMap.setTile', () => { + it('обновляет данные и проходимость ячейки', () => { + const map = new IsometricTileMap(makeData(), new Map()); + expect(map.isWalkable(1, 1)).toBe(true); + + map.setTile(1, 1, 3); // высокий объект + expect(map.data.tiles[1 * 4 + 1]).toBe(3); + expect(map.isWalkable(1, 1)).toBe(true); // 3 не в blocked + + map.setTile(1, 1, 2); // блокирующий id + expect(map.data.tiles[1 * 4 + 1]).toBe(2); + expect(map.isWalkable(1, 1)).toBe(false); + }); + + it('игнорирует координаты за границей карты', () => { + const map = new IsometricTileMap(makeData(), new Map()); + expect(() => map.setTile(-1, 0, 2)).not.toThrow(); + expect(() => map.setTile(4, 0, 2)).not.toThrow(); + expect(() => map.setTile(0, 4, 2)).not.toThrow(); + // данные не изменились + expect(map.data.tiles.every((t) => t === 0)).toBe(true); + }); +}); + +describe('IsometricTileMap.setTallDepthLayer', () => { + it('tall-вьюхи живут в depth-слое с глубиной tx+ty — герой за деревом прячется', () => { + const map = new IsometricTileMap(makeData(), new Map()); + const layer = new IsoDepthLayer(); + const hero = new Container(); + layer.add(hero, 1, 4); // юг дерева: глубина 5 + map.setTallDepthLayer(layer); + + map.setTile(1, 3, 3); // tall-объект: вьюха сразу в слое, глубина 4 + const tree = layer.children.find((c) => c !== hero); + expect(tree?.parent).toBe(layer); + expect(tree?.zIndex).toBe(1 + 3); + + layer.sortableChildren = true; // север дерева (глубина 3) — дерево поверх героя + hero.zIndex = 1 + 2; + const orderBehind = layer.children.slice().sort((a, b) => a.zIndex - b.zIndex); + expect(orderBehind.indexOf(hero)).toBeLessThan(orderBehind.indexOf(tree!)); + + hero.zIndex = 1 + 4; // юг (глубина 5) — герой поверх дерева + const orderFront = layer.children.slice().sort((a, b) => a.zIndex - b.zIndex); + expect(orderFront.indexOf(tree!)).toBeLessThan(orderFront.indexOf(hero)); + }); + + it('setTile убирает вьюху из слоя, setTallDepthLayer(null) возвращает в карту', () => { + const map = new IsometricTileMap(makeData(), new Map()); + const layer = new IsoDepthLayer(); + map.setTallDepthLayer(layer); + map.setTile(2, 2, 3); + expect(layer.children.length).toBe(1); + + map.setTile(2, 2, 0); // tall больше нет — вьюха уничтожена + expect(layer.children.length).toBe(0); + + map.setTile(2, 2, 3); + map.setTallDepthLayer(null); // назад в objectsLayer карты + const objectsLayer = map.view.children.at(-1)!; + expect(objectsLayer.children.length).toBe(1); + expect(layer.children.length).toBe(0); + }); +}); + +describe('IsometricTileMap.variants', () => { + it('вариант выбирается по хэшу координат и стабилен при setTile', () => { + const map = new IsometricTileMap(makeDataWithVariants(), new Map()); + + const variant = map.variantOf(0, 3, 2); + expect([0, 1, 2]).toContain(variant); + // второй экземпляр той же карты — тот же выбор (детерминизм) + const again = new IsometricTileMap(makeDataWithVariants(), new Map()); + expect(again.variantOf(0, 3, 2)).toBe(variant); + + // setTile других id не меняет выбор для id с вариантами + map.setTile(1, 1, 2); + expect(map.variantOf(0, 3, 2)).toBe(variant); + // setTile на клетке с id из variants — вариант той же клетки стабилен + map.setTile(3, 2, 2); + map.setTile(3, 2, 0); + expect(map.variantOf(0, 3, 2)).toBe(variant); + }); + + it('setTileVariants задаёт таблицу рантаймом, пустой массив — no-op', () => { + const map = new IsometricTileMap(makeData(), new Map()); + expect(map.variantOf(0, 2, 2)).toBe(0); // без вариантов id как есть + map.setTileVariants(0, [0, 5]); + // выбор детерминирован и покидает только варианты + const v22 = map.variantOf(0, 2, 2); + const v32 = map.variantOf(0, 3, 2); + expect([0, 5]).toContain(v22); + expect([0, 5]).toContain(v32); + // выбор стабилен + expect(map.variantOf(0, 2, 2)).toBe(v22); + expect(map.variantOf(0, 3, 2)).toBe(v32); + map.setTileVariants(0, []); // пустой массив — no-op (таблица не сбрасывается) + expect(map.variantOf(0, 2, 2)).toBe(v22); + // id без вариантов рисуется как есть + expect(map.variantOf(2, 3, 2)).toBe(2); + }); + + it('анимация сочетается с вариантами: клетки анимируются по фактическому id', () => { + // id 3 — высокий объект (tall в makeData): с вариантами [3, 5]. + const data = makeData(); + data.tiles = new Array(16).fill(3); + const map = new IsometricTileMap(data, new Map([[3, Texture.WHITE]])); + map.setTileVariants(3, [3, 5]); + // Клетки получили варианты: у части клеток фактический id 5. + const resolved = new Set([map.variantOf(3, 0, 0), map.variantOf(3, 1, 0)]); + // Текстуры вариантных клеток отсутствуют — плейсхолдеры; важна семантика + // variantOf: анимация больше не снимает варианты. + expect(resolved.size).toBeGreaterThan(0); + map.setTileAnimation(3, [Texture.WHITE, Texture.EMPTY], 4); + expect(map.variantOf(3, 0, 0)).not.toBe(3); // вариант не снят + // Анимация фактического id (в т.ч. вариантного) регистрирует кадры. + map.setTileAnimation(5, [Texture.WHITE, Texture.EMPTY], 4); + map.update(0.5); + // Тик не бросает и не ломает клетки (дым-тест семантики). + }); +}); + +function makeDataWithVariants(): TileMapData { + return { ...makeData(), variants: { 0: [0, 1, 2] } }; +} + +describe('IsometricTileMap.decor', () => { + it('decorLayer между землёй и объектами, декор из данных рисуется', () => { + const data = makeData(); + data.decor = [{ id: 7, x: 1, y: 2, dx: 2, dy: -1 }]; + const map = new IsometricTileMap(data, new Map()); + const layers = map.view.children; + expect(layers.length).toBe(3); + // порядок детей = порядок отрисовки: ground -> decor -> objects + const s = map.addDecor(0, 0, Texture.WHITE)!; + expect(s.parent).toBe(layers[1]); + expect(s.anchor.x).toBe(0.5); + // смещение dx/dy — от центра ромба + const shifted = map.addDecor(2, 1, Texture.WHITE, { dx: 3, dy: -2 })!; + const c = isoToScreen(2, 1, map.iso); + expect(shifted.position.x).toBe(c.x + 3); + expect(shifted.position.y).toBe(c.y + map.iso.tileH / 2 - 2); + }); + + it('addDecor кладёт один спрайт на тайл, повторный — заменяет; clearDecor убирает', () => { + const map = new IsometricTileMap(makeData(), new Map()); + const first = map.addDecor(1, 1, Texture.WHITE)!; + const second = map.addDecor(1, 1, Texture.WHITE)!; // замена, а не второй спрайт + map.addDecor(2, 1, Texture.WHITE); + expect(first.parent).toBeNull(); // прежний спрайт убран + expect(second.parent).not.toBeNull(); + expect(map.data.decor).toBeUndefined(); + + map.clearDecor(1, 1); + map.clearDecor(1, 1); // повторный clear — no-op + map.clearDecor(9, 9); // чужой тайл — no-op + expect(second.parent).toBeNull(); + }); + + it('addDecor за границей карты игнорируется; setTile декор не трогает', () => { + const data = makeData(); + data.decor = [{ id: 7, x: 2, y: 2 }]; + const map = new IsometricTileMap(data, new Map()); + expect(map.addDecor(-1, 0, Texture.WHITE)).toBeUndefined(); + expect(map.addDecor(4, 0, Texture.WHITE)).toBeUndefined(); + + map.setTile(2, 2, 0); // перерисовка ячейки не должна задеть декор + map.setTile(2, 2, 2); + expect(() => map.clearDecor(2, 2)).not.toThrow(); + }); +}); + +describe('IsometricTileMap.cullToRect', () => { + it('прячет тайлы вне прямоугольника видимости, null возвращает всё', () => { + const data = makeData(); + delete data.tall; // без tall-объектов минимальный запас по краям + const map = new IsometricTileMap(data, new Map()); + expect(map.isTileVisible(0, 0)).toBe(true); // до куллинга видно всё + + // окно вокруг центра тайла (3,3) размером в полтайла + const c = isoToScreen(3, 3, map.iso); + map.cullToRect({ + x: c.x - map.iso.tileW / 4, + y: c.y - map.iso.tileH / 4, + width: map.iso.tileW / 2, + height: map.iso.tileH / 2 + }); + expect(map.isTileVisible(3, 3)).toBe(true); + expect(map.isTileVisible(2, 2)).toBe(true); // сосед — внутри запаса + expect(map.isTileVisible(0, 0)).toBe(false); // дальний угол спрятан + expect(map.isTileVisible(3, 0)).toBe(false); + + map.cullToRect(null); // сброс — снова видно всё + expect(map.isTileVisible(0, 0)).toBe(true); + }); +}); + +describe('IsometricTileMap.props', () => { + it('блокирует весь footprint и нормализует размеры', () => { + const data = makeData(); + data.props = [{ id: 7, x: 1, y: 1, w: 2, h: 2 }]; + const map = new IsometricTileMap(data, new Map()); + + expect(map.props).toEqual([{ id: 7, x: 1, y: 1, w: 2, h: 2 }]); + // весь footprint 2×2 непроходим, вокруг — проходим + expect(map.isWalkable(1, 1)).toBe(false); + expect(map.isWalkable(2, 2)).toBe(false); + expect(map.isWalkable(0, 1)).toBe(true); + expect(map.isWalkable(1, 3)).toBe(true); + // спрайт пропа отдан сцене для depth-слоя + expect(map.propViews).toHaveLength(1); + expect(map.propViews[0]!.prop).toBe(map.props[0]); + }); + + it('footprint по умолчанию 1×1', () => { + const data = makeData(); + data.props = [{ id: 7, x: 2, y: 0 }]; + const map = new IsometricTileMap(data, new Map()); + expect(map.props[0]!.w).toBe(1); + expect(map.props[0]!.h).toBe(1); + expect(map.isWalkable(2, 0)).toBe(false); + expect(map.isWalkable(3, 0)).toBe(true); + }); + + it('setTile внутри footprint не снимает блокировку', () => { + const data = makeData(); + data.props = [{ id: 7, x: 1, y: 1 }]; + const map = new IsometricTileMap(data, new Map()); + map.setTile(1, 1, 0); + expect(map.isWalkable(1, 1)).toBe(false); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/map/__tests__/mapFormat.test.ts b/v1/packages/engine/src/map/__tests__/mapFormat.test.ts new file mode 100644 index 0000000..c0ebe75 --- /dev/null +++ b/v1/packages/engine/src/map/__tests__/mapFormat.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { encodeMap, parseMap, rleEncode, rleDecode } from '../mapFormat'; +import type { TileMapData } from '../IsometricTileMap'; + +const sample: TileMapData = { + width: 3, + height: 2, + tiles: [0, 0, 0, 1, 1, 2], + blocked: [2], + tall: { 3: { height: 1.5, ground: 0 } } +}; + +describe('RLE', () => { + it('кодирует серии одинаковых id', () => { + expect(rleEncode([0, 0, 0, 1, 1, 2])).toEqual([ + [0, 3], + [1, 2], + [2, 1] + ]); + }); + + it('декодирует обратно', () => { + expect(rleDecode([[0, 3], [1, 2], [2, 1]])).toEqual([0, 0, 0, 1, 1, 2]); + }); + + it('пустой массив', () => { + expect(rleEncode([])).toEqual([]); + expect(rleDecode([])).toEqual([]); + }); +}); + +describe('encodeMap/parseMap', () => { + it('roundtrip с RLE сохраняет карту', () => { + const file = encodeMap(sample); + expect(file.format).toBe('rpg-map'); + expect(file.encoding).toBe('rle'); + const parsed = parseMap(JSON.parse(JSON.stringify(file))); + expect(parsed).toEqual(sample); + }); + + it('roundtrip с raw', () => { + const file = encodeMap(sample, 'raw'); + expect(file.tiles).toEqual([0, 0, 0, 1, 1, 2]); + const parsed = parseMap(file); + expect(parsed.tiles).toEqual(sample.tiles); + }); + + it('RLE реально сжимает однородную карту', () => { + const big: TileMapData = { ...sample, width: 28, height: 28, tiles: new Array(784).fill(0) }; + const file = encodeMap(big); + expect(JSON.stringify(file.tiles).length).toBeLessThan(20); + }); + + it('roundtrip сохраняет пропы с footprint', () => { + const withProps: TileMapData = { + ...sample, + props: [{ id: 7, x: 0, y: 0, w: 2, h: 2, ground: 1, height: 3 }] + }; + const parsed = parseMap(JSON.parse(JSON.stringify(encodeMap(withProps)))); + expect(parsed.props).toEqual(withProps.props); + }); + + it('roundtrip сохраняет variants (строковые ключи файла -> числовые)', () => { + const withVariants: TileMapData = { + ...sample, + variants: { 0: [0, 5, 9], 12: [12] } + }; + const parsed = parseMap(JSON.parse(JSON.stringify(encodeMap(withVariants)))); + expect(parsed.variants).toEqual(withVariants.variants); + }); + + it('roundtrip сохраняет decor', () => { + const withDecor: TileMapData = { + ...sample, + decor: [{ id: 7, x: 1, y: 0, dx: 2 }, { id: 9, x: 2, y: 1 }] + }; + const parsed = parseMap(JSON.parse(JSON.stringify(encodeMap(withDecor)))); + expect(parsed.decor).toEqual(withDecor.decor); + // без декора поле опускается (старые карты) + expect(parseMap(encodeMap(sample)).decor).toBeUndefined(); + }); + + it('parseMap отвергает decor вне границ карты и не с целыми числами', () => { + // карта 3×2: тайл (3, 0) вне карты по X + expect(() => parseMap({ ...encodeMap(sample), decor: [{ id: 7, x: 3, y: 0 }] })).toThrow(/границ/); + expect(() => parseMap({ ...encodeMap(sample), decor: [{ id: 7.5, x: 0, y: 0 }] })).toThrow(/целыми/); + expect(() => parseMap({ ...encodeMap(sample), decor: [{ id: 7, x: 0, y: 0, dx: 'a' }] })).toThrow(/числами/); + expect(() => parseMap({ ...encodeMap(sample), decor: [] })).not.toThrow(); + }); + + it('parseMap отвергает пустой/нечисловой variants-массив', () => { + expect(() => parseMap({ ...encodeMap(sample), variants: { 0: [] } })).toThrow(/variants/); + expect(() => parseMap({ ...encodeMap(sample), variants: { 0: [1.5] } })).toThrow(/variants/); + expect(() => parseMap({ ...encodeMap(sample), variants: { 0: ['a'] } })).toThrow(/variants/); + // отсутствует/пустая таблица — ок (старые карты) + expect(parseMap(encodeMap(sample)).variants).toBeUndefined(); + expect(parseMap({ ...encodeMap(sample), variants: {} }).variants).toBeUndefined(); + }); + + it('parseMap отвергает проп за границей карты', () => { + // карта 3×2, footprint 2×2 от (2, 1) вылезает вправо и вниз + const file = encodeMap({ ...sample, props: [{ id: 7, x: 2, y: 1, w: 2, h: 2 }] }); + expect(() => parseMap(file)).toThrow(/границ/); + }); + + it('parseMap отвергает дробный/нулевой footprint', () => { + const bad = (props: unknown) => + parseMap({ ...encodeMap(sample), props }); + expect(() => bad([{ id: 7, x: 0, y: 0, w: 1.5, h: 1 }])).toThrow(/w\/h/); + expect(() => bad([{ id: 7, x: 0, y: 0, w: 0, h: 1 }])).toThrow(/w\/h/); + }); + + it('parseMap отвергает высоту пропа в пикселях', () => { + const file = encodeMap({ ...sample, props: [{ id: 7, x: 0, y: 0, height: 96 }] }); + expect(() => parseMap(file)).toThrow(/пикселях/); + }); + + it('parseMap отвергает не-объект', () => { + expect(() => parseMap('nope')).toThrow(); + expect(() => parseMap(null)).toThrow(); + }); + + it('parseMap отвергает высоту в пикселях (tall.height > 16)', () => { + expect(() => parseMap({ ...encodeMap(sample), tall: { 3: { height: 48, ground: 0 } } })).toThrow(/пикселях/); + }); + + it('parseMap отвергает чужой format/версию', () => { + expect(() => parseMap({ ...encodeMap(sample), format: 'other' })).toThrow(/format/); + expect(() => parseMap({ ...encodeMap(sample), version: 99 })).toThrow(/версия|версию|version/); + }); + + it('parseMap отвергает неверное число тайлов', () => { + const file = encodeMap(sample, 'raw'); + (file as { tiles: number[] }).tiles = [0, 1, 2]; + expect(() => parseMap(file)).toThrow(/784|тайлов|6/); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/map/__tests__/minimap.test.ts b/v1/packages/engine/src/map/__tests__/minimap.test.ts new file mode 100644 index 0000000..5ef36f6 --- /dev/null +++ b/v1/packages/engine/src/map/__tests__/minimap.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import type { TileMapData } from '../IsometricTileMap'; +import { renderMinimap } from '../minimap'; + +/** Карта 3×2: два разных тайла + заблокированная клетка. */ +function tinyMap(): TileMapData { + return { + width: 3, + height: 2, + tiles: [1, 2, 1, 2, 1, 2], + blocked: [0, 0, 1, 0, 0, 0], + }; +} + +const px = (img: { width: number; rgba: Uint8Array }, x: number, y: number): [number, number, number] => { + const i = (y * img.width + x) * 4; + return [img.rgba[i]!, img.rgba[i + 1]!, img.rgba[i + 2]!]; +}; + +describe('renderMinimap', () => { + it('размер картинки = карта × cell', () => { + const img = renderMinimap(tinyMap(), { colors: { 1: 0x101010, 2: 0x202020 }, cell: 3 }); + expect(img.width).toBe(9); + expect(img.height).toBe(6); + expect(img.rgba.length).toBe(9 * 6 * 4); + }); + + it('клетка целиком красится цветом своего тайла', () => { + const img = renderMinimap(tinyMap(), { colors: { 1: 0xff0000, 2: 0x00ff00 }, cell: 2 }); + // Тайл (0,0) = id 1, тайл (1,0) = id 2. + expect(px(img, 0, 0)).toEqual([255, 0, 0]); + expect(px(img, 1, 1)).toEqual([255, 0, 0]); + expect(px(img, 2, 0)).toEqual([0, 255, 0]); + expect(px(img, 5, 3)).toEqual([0, 255, 0]); + }); + + it('незаданный тайл — fallbackColor', () => { + const img = renderMinimap(tinyMap(), { colors: { 1: 0xff0000 }, fallbackColor: 0x0a0b0c, cell: 2 }); + expect(px(img, 2, 0)).toEqual([10, 11, 12]); + // И без явного fallback — тёмный по умолчанию. + const plain = renderMinimap(tinyMap(), { colors: { 1: 0xff0000 }, cell: 2 }); + expect(px(plain, 2, 0)[0]).toBeLessThan(64); + }); + + it('маркер перекрашивает свою клетку с тёмной рамкой', () => { + const img = renderMinimap(tinyMap(), { + colors: { 1: 0x101010, 2: 0x101010 }, + cell: 3, + markers: [{ x: 1, y: 1, color: 0xffff00 }], + }); + // Центр клетки — цвет маркера, угол — рамка. + expect(px(img, 4, 4)).toEqual([255, 255, 0]); + expect(px(img, 3, 3)[0]).toBeLessThan(64); + // Соседняя клетка не задета. + expect(px(img, 7, 4)).toEqual([16, 16, 16]); + }); + + it('маркер вне карты молча пропускается', () => { + const img = renderMinimap(tinyMap(), { + colors: { 1: 0x101010, 2: 0x101010 }, + cell: 2, + markers: [{ x: 9, y: 9, color: 0xffff00 }], + }); + expect(px(img, 0, 0)).toEqual([16, 16, 16]); + }); + + it('проп красит весь footprint своим цветом поверх тайлов', () => { + const map = { ...tinyMap(), props: [{ id: 7, x: 0, y: 0, w: 2, h: 2 }] }; + const img = renderMinimap(map, { colors: { 1: 0xff0000, 2: 0x00ff00, 7: 0x0000ff }, cell: 3 }); + // Клетки (0,0) и (1,1) — под пропом, тайл 1/2 перекрашен синим. + expect(px(img, 0, 0)).toEqual([0, 0, 255]); + expect(px(img, 4, 4)).toEqual([0, 0, 255]); + // Клетка вне footprint осталась цветом своего тайла (id 1 — красный). + expect(px(img, 7, 0)).toEqual([255, 0, 0]); + }); + + it('проп без записи в colors — fallbackColor', () => { + const map = { ...tinyMap(), props: [{ id: 7, x: 2, y: 1, w: 1, h: 1 }] }; + const img = renderMinimap(map, { colors: { 1: 0x101010, 2: 0x101010 }, fallbackColor: 0x3c3c48, cell: 3 }); + expect(px(img, 8, 4)).toEqual([60, 60, 72]); + }); + + it('footprint у края карты не выходит за границы', () => { + // Проп 3×3 от (1,0): правый столбец клеток вылезает за ширину 3. + const map = { ...tinyMap(), props: [{ id: 7, x: 1, y: 0, w: 3, h: 3 }] }; + const img = renderMinimap(map, { colors: { 1: 0x101010, 7: 0x0000ff }, cell: 3 }); + expect(() => px(img, 8, 5)).not.toThrow(); + // Клетка (2,0) под пропом покрашена, выход за карту не рисуется. + expect(px(img, 8, 0)).toEqual([0, 0, 255]); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/map/__tests__/pathfinding.test.ts b/v1/packages/engine/src/map/__tests__/pathfinding.test.ts new file mode 100644 index 0000000..de1e75d --- /dev/null +++ b/v1/packages/engine/src/map/__tests__/pathfinding.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { findPath, findPathToNeighbor, 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 }]); + }); +}); +describe('findPathToNeighbor', () => { + it('цель проходима — всё равно останавливаемся рядом', () => { + const path = findPathToNeighbor(makeGrid(10, 10), { x: 0, y: 0 }, { x: 2, y: 0 }); + const end = path!.at(-1)!; + expect(Math.max(Math.abs(end.x - 2), Math.abs(end.y - 0))).toBe(1); + }); + + it('цель занята — путь к соседней клетке', () => { + const grid = makeGrid(10, 10, ['5,5']); + const path = findPathToNeighbor(grid, { x: 0, y: 5 }, { x: 5, y: 5 }); + expect(path).not.toBeNull(); + const end = path!.at(-1)!; + expect(Math.max(Math.abs(end.x - 5), Math.abs(end.y - 5))).toBe(1); + }); + + it('цель окружена — путь не найден', () => { + const grid = makeGrid(5, 5, [ + '1,1', '2,1', '3,1', + '1,2', '2,2', '3,2', + '1,3', '2,3', '3,3' + ]); + expect(findPathToNeighbor(grid, { x: 0, y: 4 }, { x: 2, y: 2 })).toBeNull(); + }); + + it('выбирает ближайшего из 8 соседей за один прогон', () => { + // Цель в углу сетки, старт по диагонали: из трёх соседей цели + // ближайший к старту — (4,4), путь к нему кратчайший возможный. + const grid = makeGrid(6, 6); + const path = findPathToNeighbor(grid, { x: 0, y: 0 }, { x: 5, y: 5 }); + expect(path).not.toBeNull(); + expect(path!.at(-1)).toEqual({ x: 4, y: 4 }); + expect(path!.length).toBe(8); // манхэттен (0,0)->(4,4), без крюка + }); + + it('старт уже сосед цели — пустой путь', () => { + const grid = makeGrid(10, 10); + expect(findPathToNeighbor(grid, { x: 3, y: 5 }, { x: 4, y: 5 })).toEqual([]); + }); +}); diff --git a/v1/packages/engine/src/map/__tests__/tiled.test.ts b/v1/packages/engine/src/map/__tests__/tiled.test.ts new file mode 100644 index 0000000..2cd153c --- /dev/null +++ b/v1/packages/engine/src/map/__tests__/tiled.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { fromTiledIso, type TiledMap } from '../tiled'; + +const tiled: TiledMap = { + width: 4, + height: 3, + tilewidth: 32, + tileheight: 16, + orientation: 'isometric', + tilesets: [{ firstgid: 1 }], + layers: [ + { type: 'tilelayer', name: 'ground', data: [1, 1, 2, 1, 1, 3, 1, 1, 0, 1, 1, 1] }, + { type: 'tilelayer', name: 'props', data: [0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0] }, + { type: 'objectgroup', name: 'spawns', objects: [] } + ] +} as unknown as TiledMap; + +describe('fromTiledIso', () => { + it('смещает gid на firstgid и склеивает слои', () => { + const map = fromTiledIso(tiled, { blocked: [2] }); + expect(map.width).toBe(4); + expect(map.height).toBe(3); + expect(map.tiles).toEqual([ + 0, 0, 1, 0, // ground: 1,1,2,1 + 0, 2, 0, 0, // ground: 1,3,1,1 + 2, 0, 0, 0 // props: верхний слой кладёт 2 на пустое место (index 8) + ]); + expect(map.blocked).toEqual([2]); + }); + + it('фильтрует слои по имени', () => { + const map = fromTiledIso(tiled, { layers: ['ground'] }); + expect(map.tiles[9]).toBe(0); // props-слой не применён + }); + + it('отвергает не-изометрическую ориентацию', () => { + expect(() => fromTiledIso({ ...tiled, orientation: 'orthogonal' })).toThrow(/isometric/); + }); + + it('отвергает слой неверного размера', () => { + const bad = { + ...tiled, + layers: [{ type: 'tilelayer', name: 'bad', data: [1, 2, 3] }] + }; + expect(() => fromTiledIso(bad)).toThrow(/размера/); + }); + + it('игнорирует невидимые слои', () => { + const hidden = { + ...tiled, + layers: [ + { type: 'tilelayer', name: 'ground', visible: false, data: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, + { type: 'tilelayer', name: 'ok', data: [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] } + ] + }; + const map = fromTiledIso(hidden); + expect(map.tiles[0]).toBe(1); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/map/collision.ts b/v1/packages/engine/src/map/collision.ts new file mode 100644 index 0000000..cd0aa7a --- /dev/null +++ b/v1/packages/engine/src/map/collision.ts @@ -0,0 +1,154 @@ +import type { Grid } from './pathfinding'; +import type { TileMapData } from './IsometricTileMap'; +import type { Vec2 } from '../math/Vec2'; + +/** + * Круговые коллизии поверх тайловой сетки (чистая математика, без Pixi). + * Актор — круг радиуса r в мировых юнитах (тайл = 1×1); правило стиля: + * r < 0.5 — тело уже тайла, тогда путь A* по центрам тайлов остаётся + * проходимым и проверка сводится к соседним с центром тайлам. + * Проверка круга — точная (ближайшая точка квадрата тайла), не выборка + * точек: выборка пропускает угловой прокол Blocked-тайла по диагонали. + */ + +/** + * Тайлы «тени» высоких объектов: спрайт тайла (tx, ty) с tall.shadow + * экранно накрывает тайл (tx-1, ty-1) — герой не должен заходить в тело + * (дом и т.п.). Сосед в (tx-1, ty-1) сам высокий — он блокируется и без тени. + */ +export function shadowTilesOf(data: TileMapData): Set { + const out = new Set(); + for (let ty = 1; ty < data.height; ty++) { + for (let tx = 1; tx < data.width; tx++) { + const spec = data.tall?.[data.tiles[ty * data.width + tx]!]; + if (typeof spec !== 'object' || !spec.shadow) continue; + const behind = data.tiles[(ty - 1) * data.width + (tx - 1)]!; + if (data.tall?.[behind] !== undefined) continue; + out.add((ty - 1) * data.width + (tx - 1)); + } + } + return out; +} + +/** + * Grid над сырыми данными карты (без Pixi): blocked-иды + footprint пропов + + * тени высоких объектов. Единый источник проходимости: IsometricTileMap + * делегирует сюда, headless-потребители (валидатор, фауна, снапшот) берут + * через gridOf. data.tiles читается на каждый вызов — setTile виден сразу. + */ +export class StaticGrid implements Grid { + private blockedSet: Set; + private propBlocked = new Set(); + private shadowSet: Set; + + constructor(private data: TileMapData) { + this.blockedSet = new Set(data.blocked); + this.shadowSet = shadowTilesOf(data); + for (const p of data.props ?? []) { + const w = p.w ?? 1; + const h = p.h ?? 1; + for (let ty = p.y; ty < p.y + h; ty++) { + for (let tx = p.x; tx < p.x + w; tx++) this.propBlocked.add(ty * data.width + tx); + } + } + } + + get width(): number { + return this.data.width; + } + + get height(): number { + return this.data.height; + } + + isWalkable(x: number, y: number): boolean { + if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return false; + if (this.propBlocked.has(y * this.data.width + x)) return false; + if (this.shadowSet.has(y * this.data.width + x)) return false; + return !this.blockedSet.has(this.data.tiles[y * this.data.width + x]!); + } + + /** Тайл накрыт footprint'ом пропа. */ + isPropBlocked(x: number, y: number): boolean { + return this.propBlocked.has(y * this.data.width + x); + } + + /** id тайла — из набора блокирующих (для плейсхолдеров рендера). */ + isBlockedId(id: number): boolean { + return this.blockedSet.has(id); + } +} + +/** Grid-адаптер над сырыми данными карты. */ +export function gridOf(data: TileMapData): Grid { + return new StaticGrid(data); +} + +/** Тайл блокирует движение (вне карты — тоже стена). */ +function isBlocked(grid: Grid, tx: number, ty: number): boolean { + return tx < 0 || ty < 0 || tx >= grid.width || ty >= grid.height || !grid.isWalkable(tx, ty); +} + +/** Помещается ли круг в позицию (точное пересечение с blocked-тайлами bbox). */ +export function circleFits(grid: Grid, pos: Vec2, r: number): boolean { + if (r <= 0) { + return !isBlocked(grid, Math.floor(pos.x), Math.floor(pos.y)); + } + // r < 0.5 — bbox накрывает не более 2×2 тайлов. + const x0 = Math.floor(pos.x - r); + const x1 = Math.floor(pos.x + r); + const y0 = Math.floor(pos.y - r); + const y1 = Math.floor(pos.y + r); + for (let ty = y0; ty <= y1; ty++) { + for (let tx = x0; tx <= x1; tx++) { + if (!isBlocked(grid, tx, ty)) continue; + // Ближайшая к центру круга точка квадрата тайла. + const nx = Math.max(tx, Math.min(pos.x, tx + 1)); + const ny = Math.max(ty, Math.min(pos.y, ty + 1)); + const dx = pos.x - nx; + const dy = pos.y - ny; + if (dx * dx + dy * dy < r * r) return false; // касание стеной разрешено + } + } + return true; +} + +/** + * Сдвинуть круг на delta со скольжением вдоль стен: оси проверяются + * раздельно (X -> Y), прижатая к стене ось не блокирует движение по другой. + * Мутирует pos. Возвращает true, если хоть одна ось сместилась. + */ +export function moveCircle(grid: Grid, pos: Vec2, delta: Vec2, r: number): boolean { + let moved = false; + const nx = pos.x + delta.x; + if (circleFits(grid, { x: nx, y: pos.y }, r)) { + pos.x = nx; + moved = moved || delta.x !== 0; + } + const ny = pos.y + delta.y; + if (circleFits(grid, { x: pos.x, y: ny }, r)) { + pos.y = ny; + moved = moved || delta.y !== 0; + } + return moved; +} + +/** + * Растолкать круг a из круга b: a сдвигается на глубину перекрытия + * (двигаем только a — каждый актор выталкивает себя сам). Слившиеся + * в точку круги расходятся по +X (детерминированный фолбэк). + */ +export function separateCircles(a: Vec2, ra: number, b: Vec2, rb: number): void { + const dx = a.x - b.x; + const dy = a.y - b.y; + const d = Math.hypot(dx, dy); + const overlap = ra + rb - d; + if (overlap <= 0) return; + if (d < 1e-6) { + a.x += overlap; + return; + } + const k = overlap / d; + a.x += dx * k; + a.y += dy * k; +} \ No newline at end of file diff --git a/v1/packages/engine/src/map/mapFormat.ts b/v1/packages/engine/src/map/mapFormat.ts new file mode 100644 index 0000000..fc71de2 --- /dev/null +++ b/v1/packages/engine/src/map/mapFormat.ts @@ -0,0 +1,171 @@ +import type { TileMapData, TallSpec, PropData, DecorData } from './IsometricTileMap'; + +/** + * Файловый формат карт движка: JSON с RLE-сжатием тайлов. + * RLE — пары [id, длина серии]: карта лугов 28×28 сжимается в разы, + * и её можно править текстовым редактором. + */ + +export interface MapFileV1 { + format: 'rpg-map'; + version: 1; + width: number; + height: number; + /** id тайлов, которые считаются блокирующими. */ + blocked: number[]; + /** Высокие объекты: id -> высота или полная спецификация. */ + tall?: Record; + /** Крупные объекты с footprint'ом в несколько тайлов. */ + props?: PropData[]; + /** Варианты текстур: id -> список id-шников вариантов (ключи — строки JSON). */ + variants?: Record; + /** Мелкий плоский декор на тайлах. */ + decor?: DecorData[]; + /** 'rle' — tiles как пары [id, runLength]; 'raw' — плоский массив. */ + encoding: 'rle' | 'raw'; + tiles: number[] | [number, number][]; +} + +export type MapFile = MapFileV1; + +/** Упаковать тайлы в RLE: последовательности одинаковых id -> пары [id, длина]. */ +export function rleEncode(tiles: number[]): [number, number][] { + const out: [number, number][] = []; + let i = 0; + while (i < tiles.length) { + const id = tiles[i]!; + let run = 1; + while (i + run < tiles.length && tiles[i + run] === id) run++; + out.push([id, run]); + i += run; + } + return out; +} + +/** Распаковать RLE обратно в плоский массив. */ +export function rleDecode(pairs: [number, number][]): number[] { + const out: number[] = []; + for (const [id, run] of pairs) { + for (let k = 0; k < run; k++) out.push(id); + } + return out; +} + +/** Сериализовать карту в файловый формат (RLE по умолчанию). */ +export function encodeMap(data: TileMapData, encoding: 'rle' | 'raw' = 'rle'): MapFileV1 { + return { + format: 'rpg-map', + version: 1, + width: data.width, + height: data.height, + blocked: data.blocked, + tall: data.tall, + props: data.props, + variants: data.variants + ? Object.fromEntries(Object.entries(data.variants).map(([id, ids]) => [String(id), ids])) + : undefined, + decor: data.decor, + encoding, + tiles: encoding === 'rle' ? rleEncode(data.tiles) : data.tiles + }; +} + +/** Распарсить файл карты (с валидацией размеров). */ +export function parseMap(file: unknown): TileMapData { + if (typeof file !== 'object' || file === null) { + throw new Error('Карта: ожидался JSON-объект'); + } + const f = file as Partial; + if (f.format !== 'rpg-map') { + throw new Error('Карта: неверный format (ожидался rpg-map)'); + } + if (f.version !== 1) { + throw new Error(`Карта: неподдерживаемая версия ${f.version}`); + } + if (typeof f.width !== 'number' || typeof f.height !== 'number' || !Array.isArray(f.tiles)) { + throw new Error('Карта: missing width/height/tiles'); + } + let tiles: number[]; + if (f.encoding === 'rle') { + tiles = rleDecode(f.tiles as [number, number][]); + } else { + tiles = f.tiles as number[]; + if (tiles.some((t) => typeof t !== 'number')) { + throw new Error('Карта: raw-тайлы должны быть числами'); + } + } + const expected = f.width * f.height; + if (tiles.length !== expected) { + throw new Error(`Карта: тайлов ${tiles.length}, ожидалось ${expected} (${f.width}×${f.height})`); + } + // Высоты высоких объектов задаются в мировых юнитах (1 юнит = 1 тайл); + // значение больше 16 почти наверняка значит протухший файл с высотой в px. + for (const spec of Object.values(f.tall ?? {})) { + const h = typeof spec === 'number' ? spec : spec.height; + if (h > 16) { + throw new Error(`Карта: tall.height=${h} — похоже, высота задана в пикселях (ожидается мировые юниты)`); + } + } + validateProps(f.props ?? [], f.width, f.height); + const decor = validateDecor(f.decor, f.width, f.height); + const variants = validateVariants(f.variants); + return { + width: f.width, + height: f.height, + tiles, + blocked: f.blocked ?? [], + tall: f.tall, + props: f.props, + decor, + variants + }; +} + +/** Валидация декора: тайл в границах карты, смещения — числа (пустой список -> undefined). */ +function validateDecor(decor: DecorData[] | undefined, width: number, height: number): DecorData[] | undefined { + if (!decor || decor.length === 0) return undefined; + for (const d of decor) { + if (!Number.isInteger(d.id) || !Number.isInteger(d.x) || !Number.isInteger(d.y)) { + throw new Error('Карта: decor — id/x/y должны быть целыми числами'); + } + if (d.x < 0 || d.y < 0 || d.x >= width || d.y >= height) { + throw new Error(`Карта: decor (${d.x}, ${d.y}) вне границ карты ${width}×${height}`); + } + if ((d.dx !== undefined && typeof d.dx !== 'number') || (d.dy !== undefined && typeof d.dy !== 'number')) { + throw new Error(`Карта: decor (${d.x}, ${d.y}) — dx/dy должны быть числами`); + } + } + return decor; +} + +/** Валидация вариантов: непустые массивы целых id (пустой объект -> undefined). */ +function validateVariants(variants: Record | undefined): Record | undefined { + if (!variants || Object.keys(variants).length === 0) return undefined; + const out: Record = {}; + for (const [key, ids] of Object.entries(variants)) { + if (!Array.isArray(ids) || ids.length === 0 || ids.some((v) => !Number.isInteger(v))) { + throw new Error(`Карта: variants[${key}] — ожидался непустой массив целых id`); + } + out[Number(key)] = ids; + } + return out; +} + +/** Валидация пропов: целые w/h ≥ 1 и footprint целиком внутри карты. */ +function validateProps(props: PropData[], width: number, height: number): void { + for (const p of props) { + const w = p.w ?? 1; + const h = p.h ?? 1; + if (!Number.isInteger(w) || !Number.isInteger(h) || w < 1 || h < 1) { + throw new Error(`Карта: prop (${p.x}, ${p.y}) — w/h должны быть целыми ≥ 1, получено ${w}×${h}`); + } + if (p.x < 0 || p.y < 0 || p.x + w > width || p.y + h > height) { + throw new Error( + `Карта: prop (${p.x}, ${p.y}) ${w}×${h} выходит за границы карты ${width}×${height}` + ); + } + if (p.height !== undefined && p.height > 16) { + throw new Error(`Карта: prop.height=${p.height} — похоже, высота задана в пикселях (ожидается мировые юниты)`); + } + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/map/minimap.ts b/v1/packages/engine/src/map/minimap.ts new file mode 100644 index 0000000..50eef23 --- /dev/null +++ b/v1/packages/engine/src/map/minimap.ts @@ -0,0 +1,93 @@ +import type { TileMapData } from './IsometricTileMap'; + +/** + * Миникарта: чистый рендер TileMapData в RGBA-картинку (без Pixi — движок + * не знает про текстуры; в пиксельную текстуру картинку превращает игра). + * Схема «вид сверху»: клетка сетки = тайл, цвет — из палитры вызывающей + * стороны (Record). Незаданные тайлы — fallbackColor. + * Пропы рисуются поверх — весь footprint цветом `colors[prop.id]`. + */ + +export interface MinimapMarker { + /** Тайловые координаты точки интереса. */ + x: number; + y: number; + /** Цвет маркера (0xRRGGBB). */ + color: number; +} + +export interface MinimapOptions { + /** Цвет клетки по id тайла (0xRRGGBB). */ + colors: Record; + /** Цвет тайла без записи в colors (по умолчанию тёмный). */ + fallbackColor?: number; + /** Размер клетки в пикселях (2–3 читаются лучше всего). */ + cell?: number; + /** Точки интереса поверх карты (герой, переходы, NPC). */ + markers?: MinimapMarker[]; +} + +/** Результат рендера: RGBA-байты, 4 на пиксель. */ +export interface MinimapImage { + width: number; + height: number; + rgba: Uint8Array; +} + +/** 0xRRGGBB → [r, g, b]. */ +function rgbOf(color: number): [number, number, number] { + return [(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff]; +} + +/** + * Отрисовать карту в пиксельную картинку: сетка width×height клеток + * (cell px каждая), маркеры закрашивают свою клетку поверх. + */ +export function renderMinimap(data: TileMapData, opts: MinimapOptions): MinimapImage { + const cell = opts.cell ?? 3; + const width = data.width * cell; + const height = data.height * cell; + const rgba = new Uint8Array(width * height * 4); + const put = (x: number, y: number, [r, g, b]: [number, number, number]): void => { + const i = (y * width + x) * 4; + rgba[i] = r; + rgba[i + 1] = g; + rgba[i + 2] = b; + rgba[i + 3] = 255; + }; + for (let ty = 0; ty < data.height; ty++) { + for (let tx = 0; tx < data.width; tx++) { + const id = data.tiles[ty * data.width + tx]!; + const color = rgbOf(opts.colors[id] ?? opts.fallbackColor ?? 0x1a1a20); + for (let dy = 0; dy < cell; dy++) { + for (let dx = 0; dx < cell; dx++) put(tx * cell + dx, ty * cell + dy, color); + } + } + } + // Пропы: весь footprint цветом пропа — крупный объект читается схемой, + // а не только цветом земли под ним. + for (const p of data.props ?? []) { + const color = rgbOf(opts.colors[p.id] ?? opts.fallbackColor ?? 0x1a1a20); + const w = p.w ?? 1; + const h = p.h ?? 1; + for (let ty = p.y; ty < p.y + h && ty < data.height; ty++) { + for (let tx = p.x; tx < p.x + w && tx < data.width; tx++) { + for (let dy = 0; dy < cell; dy++) { + for (let dx = 0; dx < cell; dx++) put(tx * cell + dx, ty * cell + dy, color); + } + } + } + } + // Маркеры: закрашенная клетка с рамкой цвета фона — читается на любом поле. + for (const m of opts.markers ?? []) { + if (m.x < 0 || m.y < 0 || m.x >= data.width || m.y >= data.height) continue; + const [r, g, b] = rgbOf(m.color); + for (let dy = 0; dy < cell; dy++) { + for (let dx = 0; dx < cell; dx++) { + const border = dx === 0 || dy === 0 || dx === cell - 1 || dy === cell - 1; + put(m.x * cell + dx, m.y * cell + dy, border ? [20, 20, 24] : [r, g, b]); + } + } + } + return { width, height, rgba }; +} \ No newline at end of file diff --git a/v1/packages/engine/src/map/pathfinding.ts b/v1/packages/engine/src/map/pathfinding.ts new file mode 100644 index 0000000..0040506 --- /dev/null +++ b/v1/packages/engine/src/map/pathfinding.ts @@ -0,0 +1,209 @@ +/** + * Поиск пути 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; + /** Порядок вставки: при равном f выбираем ранее вставленный (детерминизм обхода). */ + seq: number; +} + +interface Vec2 { + x: number; + y: number; +} + +/** Соседство 4/8 (диагонали без среза углов проверяются отдельно). */ +const DIRS4: readonly Vec2[] = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 } +]; +const DIRS8: readonly Vec2[] = [...DIRS4, { x: 1, y: 1 }, { x: 1, y: -1 }, { x: -1, y: 1 }, { x: -1, y: -1 }]; + +function manhattan(a: Vec2, b: Vec2): number { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} + +/** a важнее b в куче: меньшее f, при равном — более ранней вставки. */ +function before(a: Node, b: Node): boolean { + return a.f < b.f || (a.f === b.f && a.seq < b.seq); +} + +/** Мин-куча открытых узлов по f — вместо линейного скана (O(log n) на операцию). */ +class OpenHeap { + private nodes: Node[] = []; + + get size(): number { + return this.nodes.length; + } + + push(n: Node): void { + const a = this.nodes; + a.push(n); + let i = a.length - 1; + while (i > 0) { + const p = (i - 1) >> 1; + if (!before(a[i]!, a[p]!)) break; + a[i] = a[p]!; + a[p] = n; + i = p; + } + } + + pop(): Node | null { + const a = this.nodes; + const top = a[0] ?? null; + const last = a.pop()!; + if (a.length > 0) { + a[0] = last; + let i = 0; + for (;;) { + const l = 2 * i + 1; + const r = l + 1; + let m = i; + if (l < a.length && before(a[l]!, a[m]!)) m = l; + if (r < a.length && before(a[r]!, a[m]!)) m = r; + if (m === i) break; + const t = a[i]!; + a[i] = a[m]!; + a[m] = t; + i = m; + } + } + return top; + } +} + +/** + * Общий каркас A*: цель и эвристика — параметры (многоцелевой прогон + * findPathToNeighbor переиспользует тот же цикл). Возвращает конечный узел + * (путь — по цепочке parent) или null. + */ +function astar( + grid: Grid, + start: Vec2, + dirs: readonly Vec2[], + isGoal: (x: number, y: number) => boolean, + h: (x: number, y: number) => number +): Node | null { + const key = (x: number, y: number) => y * grid.width + x; + const open = new OpenHeap(); + const best = new Map(); + let seq = 0; + open.push({ x: start.x, y: start.y, g: 0, f: h(start.x, start.y), parent: null, seq: seq++ }); + best.set(key(start.x, start.y), 0); + + while (open.size > 0) { + const cur = open.pop()!; + if (isGoal(cur.x, cur.y)) return cur; + + for (const d of dirs) { + 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 + h(nx, ny), parent: cur, seq: seq++ }); + } + } + + return null; +} + +/** Путь из цепочки parent (без стартового тайла, включая конечный). */ +function tracePath(node: Node): { x: number; y: number }[] { + const path: { x: number; y: number }[] = []; + for (let n: Node | null = node; n !== null; n = n.parent) { + path.unshift({ x: n.x, y: n.y }); + } + path.shift(); // убираем стартовый тайл + return path; +} + +/** + * Возвращает путь (без стартового тайла, включая конечный) или null, если пути нет. + * Включение диагоналей добавляет шаги (±1, ±1), но только если оба ортогональных + * соседа проходимы (без среза углов). + */ +export function findPath( + grid: Grid, + start: Vec2, + goal: Vec2, + 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 node = astar( + grid, + start, + allowDiagonal ? DIRS8 : DIRS4, + (x, y) => x === goal.x && y === goal.y, + (x, y) => manhattan({ x, y }, goal) + ); + return node ? tracePath(node) : null; +} + +/** + * Путь к ближайшей проходимой клетке, соседней с goal (8-соседство). + * Идём к краю цели: goal может быть непроходим (тайл NPC/объекта) или занят — + * стоять на нём не нужно. Один прогон A* со всеми 8 соседями как целями + * (эвристика — минимум манхэттена по целям); пути нет — null. + */ +export function findPathToNeighbor( + grid: Grid, + start: Vec2, + goal: Vec2 +): { x: number; y: number }[] | null { + const goals: Vec2[] = []; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const tx = goal.x + dx; + const ty = goal.y + dy; + if (tx < 0 || ty < 0 || tx >= grid.width || ty >= grid.height) continue; + if (grid.isWalkable(tx, ty)) goals.push({ x: tx, y: ty }); + } + } + if (goals.length === 0) return null; + if (goals.some((g) => g.x === start.x && g.y === start.y)) return []; + + const goalKeys = new Set(goals.map((g) => g.y * grid.width + g.x)); + const node = astar( + grid, + start, + DIRS4, + (x, y) => goalKeys.has(y * grid.width + x), + (x, y) => { + let min = Infinity; + for (const g of goals) { + const d = Math.abs(x - g.x) + Math.abs(y - g.y); + if (d < min) min = d; + } + return min; + } + ); + return node ? tracePath(node) : null; +} \ No newline at end of file diff --git a/v1/packages/engine/src/map/tiled.ts b/v1/packages/engine/src/map/tiled.ts new file mode 100644 index 0000000..f1f8231 --- /dev/null +++ b/v1/packages/engine/src/map/tiled.ts @@ -0,0 +1,76 @@ +import type { TileMapData } from './IsometricTileMap'; + +/** + * Импортёр карт из Tiled (https://www.mapeditor.org). + * Поддерживается isometric-карта с tilelayer-слоями (CSV или сжатые — + * Tiled отдаёт их уже распакованными в JSON). GID 0 — пустой тайл; + * gid смещаются на firstgid первого тайлсета, чтобы id шли с 0. + */ + +/** Минимум того, что читаем из JSON Tiled. */ +export interface TiledMap { + width: number; + height: number; + tilewidth: number; + tileheight: number; + orientation: string; + layers: { + type: string; + name?: string; + data?: number[]; + visible?: boolean; + }[]; + tilesets?: { firstgid: number }[]; +} + +export interface FromTiledOptions { + /** Имена слоёв в порядке снизу вверх (по умолчанию — все tilelayer в порядке следования). */ + layers?: string[]; + /** id тайлов, которые считаются блокирующими (после смещения). */ + blocked?: number[]; + /** Высокие объекты. */ + tall?: TileMapData['tall']; +} + +/** + * Преобразовать isometric-карту Tiled в TileMapData движка. + * Верхние слои перезаписывают нули (пустые клетки) нижних. + */ +export function fromTiledIso(tiled: TiledMap, options: FromTiledOptions = {}): TileMapData { + if (tiled.orientation !== 'isometric') { + throw new Error(`Tiled: ожидается orientation=isometric, получен ${tiled.orientation}`); + } + const { width, height } = tiled; + + const firstGid = tiled.tilesets?.[0]?.firstgid ?? 1; + const layers = tiled.layers.filter( + (l) => + l.type === 'tilelayer' && + l.visible !== false && + (options.layers ? options.layers.includes(l.name ?? '') : true) + ); + if (layers.length === 0) { + throw new Error('Tiled: в карте нет tilelayer-слоёв'); + } + + const tiles = new Array(width * height).fill(0); + for (const layer of layers) { + if (!layer.data || layer.data.length !== width * height) { + throw new Error(`Tiled: слой ${layer.name ?? '?'} неверного размера`); + } + for (let i = 0; i < layer.data.length; i++) { + const gid = layer.data[i]!; + if (gid !== 0 && tiles[i] === 0) { + tiles[i] = gid - firstGid; + } + } + } + + return { + width, + height, + tiles, + blocked: options.blocked ?? [], + tall: options.tall + }; +} \ No newline at end of file diff --git a/v1/packages/engine/src/math/Vec2.ts b/v1/packages/engine/src/math/Vec2.ts new file mode 100644 index 0000000..45ed782 --- /dev/null +++ b/v1/packages/engine/src/math/Vec2.ts @@ -0,0 +1,32 @@ +/** + * Простейшая 2D-векторная математика (виртуальные пиксели). + */ +export interface Vec2 { + x: number; + y: number; +} + +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/v1/packages/engine/src/math/__tests__/iso.test.ts b/v1/packages/engine/src/math/__tests__/iso.test.ts new file mode 100644 index 0000000..8a8ea4a --- /dev/null +++ b/v1/packages/engine/src/math/__tests__/iso.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { isoToScreen, screenToIso } 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); + } + } + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/math/__tests__/rng.test.ts b/v1/packages/engine/src/math/__tests__/rng.test.ts new file mode 100644 index 0000000..5c49d04 --- /dev/null +++ b/v1/packages/engine/src/math/__tests__/rng.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { createRng, hashTile } from '../rng'; + +describe('createRng (mulberry32)', () => { + it('одинаковый seed — одинаковая последовательность', () => { + const a = createRng(12345); + const b = createRng(12345); + for (let i = 0; i < 100; i++) { + expect(a.next()).toBe(b.next()); + } + }); + + it('разные seed — разные последовательности', () => { + const a = createRng(1); + const b = createRng(2); + const seqA = Array.from({ length: 10 }, () => a.next()); + const seqB = Array.from({ length: 10 }, () => b.next()); + expect(seqA).not.toEqual(seqB); + }); + + it('next в диапазоне 0..1', () => { + const rng = createRng(42); + for (let i = 0; i < 1000; i++) { + const v = rng.next(); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it('int внутри [min, max] включительно', () => { + const rng = createRng(7); + for (let i = 0; i < 500; i++) { + const v = rng.int(2, 5); + expect(v).toBeGreaterThanOrEqual(2); + expect(v).toBeLessThanOrEqual(5); + expect(Number.isInteger(v)).toBe(true); + } + }); + + it('int достигает обоих концов диапазона', () => { + const rng = createRng(9); + const seen = new Set(); + for (let i = 0; i < 500; i++) seen.add(rng.int(1, 3)); + expect(seen.has(1)).toBe(true); + expect(seen.has(3)).toBe(true); + }); + + it('pick берёт элемент из массива', () => { + const rng = createRng(3); + const arr = ['a', 'b', 'c'] as const; + for (let i = 0; i < 50; i++) { + expect(arr).toContain(rng.pick(arr)); + } + }); + + it('pick из пустого массива бросает', () => { + const rng = createRng(3); + expect(() => rng.pick([])).toThrow(); + }); + + it('chance(0) всегда false, chance(1) всегда true', () => { + const rng = createRng(11); + for (let i = 0; i < 100; i++) { + expect(rng.chance(0)).toBe(false); + expect(rng.chance(1)).toBe(true); + } + }); + + it('state() отражает позицию в последовательности (для сейва в связке с seed)', () => { + const a = createRng(777); + a.next(); + const s1 = a.state(); + a.next(); + const s2 = a.state(); + expect(s2).not.toBe(s1); + + // тот же seed и те же два вызова дают то же состояние + const b = createRng(777); + b.next(); + b.next(); + expect(b.state()).toBe(s2); + }); +}); + +describe('hashTile', () => { + it('детерминирован и не зависит от порядка вызовов', () => { + expect(hashTile(3, 5, 7)).toBe(hashTile(3, 5, 7)); + // отсутствие состояния: значения не «плывут» между вызовами + const first = hashTile(0, 0, 1); + hashTile(10, 20, 2); + hashTile(30, 40, 3); + expect(hashTile(0, 0, 1)).toBe(first); + }); + + it('разные координаты/seed — разные хэши (в массе)', () => { + const seen = new Set(); + for (let x = 0; x < 20; x++) { + for (let y = 0; y < 20; y++) seen.add(hashTile(x, y, 42)); + } + // 400 тайлов — коллизий почти нет (32-битный хэш) + expect(seen.size).toBeGreaterThan(390); + }); + + it('возвращает неотрицательное целое', () => { + for (let i = -5; i <= 5; i++) { + const h = hashTile(i, i * 3, 9); + expect(Number.isInteger(h)).toBe(true); + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThan(2 ** 32); + } + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/math/__tests__/shapes.test.ts b/v1/packages/engine/src/math/__tests__/shapes.test.ts new file mode 100644 index 0000000..1a5539b --- /dev/null +++ b/v1/packages/engine/src/math/__tests__/shapes.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import { inCircle, inCircleW, inCone, inConeW, angleBetween, nearest, conePath } from '../shapes'; +import { worldToScreen } from '../iso'; + +describe('inCircle', () => { + it('точка внутри и на границе', () => { + const c = { x: 0, y: 0 }; + expect(inCircle(c, 10, { x: 3, y: 4 })).toBe(true); + expect(inCircle(c, 5, { x: 3, y: 4 })).toBe(true); // ровно 5 + expect(inCircle(c, 5, { x: 3, y: 5 })).toBe(false); + expect(inCircle(c, 0, { x: 0, y: 0 })).toBe(true); + }); + + it('точка вне центра', () => { + expect(inCircle({ x: 100, y: 50 }, 10, { x: 0, y: 0 })).toBe(false); + // расстояние sqrt(50² + 30²) ≈ 58.3 + expect(inCircle({ x: 100, y: 50 }, 60, { x: 50, y: 20 })).toBe(true); + }); +}); + +describe('inCone', () => { + const from = { x: 0, y: 0 }; + const dir = { x: 1, y: 0 }; // смотрит вправо + const halfAngle = Math.PI / 4; // 45° + + it('внутри конуса', () => { + expect(inCone(from, dir, 50, halfAngle, { x: 30, y: 10 })).toBe(true); + expect(inCone(from, dir, 50, halfAngle, { x: 30, y: -10 })).toBe(true); + }); + + it('за пределами дальности', () => { + expect(inCone(from, dir, 50, halfAngle, { x: 60, y: 0 })).toBe(false); + }); + + it('за границей угла («слепая зона» за спиной)', () => { + expect(inCone(from, dir, 50, halfAngle, { x: 30, y: 40 })).toBe(false); + expect(inCone(from, dir, 50, halfAngle, { x: -30, y: 0 })).toBe(false); + }); + + it('вблизи границы угла и радиуса', () => { + // чуть внутри границы угла 45° (tan 45°=1) + expect(inCone(from, dir, 50, halfAngle, { x: 10, y: 9.9 })).toBe(true); + // чуть снаружи + expect(inCone(from, dir, 50, halfAngle, { x: 10, y: 10.1 })).toBe(false); + // расстояние ровно 50 — граница радиуса включена + expect(inCone(from, dir, 50, halfAngle, { x: 50, y: 0 })).toBe(true); + expect(inCone(from, dir, 50, halfAngle, { x: 50.1, y: 0 })).toBe(false); + }); + + it('нулевое направление — только дальность', () => { + expect(inCone(from, { x: 0, y: 0 }, 50, halfAngle, { x: 0, y: -40 })).toBe(true); + expect(inCone(from, { x: 0, y: 0 }, 50, halfAngle, { x: 60, y: 0 })).toBe(false); + }); +}); + +describe('angleBetween', () => { + it('известные углы', () => { + expect(angleBetween({ x: 1, y: 0 }, { x: 0, y: 1 })).toBeCloseTo(Math.PI / 2); + expect(angleBetween({ x: 1, y: 0 }, { x: -1, y: 0 })).toBeCloseTo(Math.PI); + expect(angleBetween({ x: 2, y: 0 }, { x: 5, y: 0 })).toBeCloseTo(0); + }); + + it('нулевой вектор даёт 0 (без NaN)', () => { + expect(angleBetween({ x: 0, y: 0 }, { x: 1, y: 0 })).toBe(0); + expect(angleBetween({ x: 1, y: 0 }, { x: 0, y: 0 })).toBe(0); + }); +}); + +describe('nearest', () => { + it('выбирает ближайшую', () => { + const list = [ + { x: 50, y: 0 }, + { x: 10, y: 0 }, + { x: 30, y: 0 } + ]; + expect(nearest(list, { x: 0, y: 0 })).toEqual({ x: 10, y: 0 }); + }); + + it('учитывает maxRange', () => { + const list = [{ x: 100, y: 0 }]; + expect(nearest(list, { x: 0, y: 0 }, 50)).toBeNull(); + expect(nearest(list, { x: 0, y: 0 }, 100)).toEqual({ x: 100, y: 0 }); + }); + + it('пустой список — null', () => { + expect(nearest([], { x: 0, y: 0 })).toBeNull(); + }); +}); + +describe('W-варианты (мировые юниты)', () => { + const iso = { tileW: 32, tileH: 16, originX: 0, originY: 0 }; + + it('inCircleW эквивалентен проекции в экранные px', () => { + const center = { x: 3, y: 1 }; + for (let wx = 0; wx <= 6; wx += 0.5) { + for (let wy = 0; wy <= 2; wy += 0.5) { + const point = { x: wx, y: wy }; + expect(inCircleW(center, 1.5, point, iso)).toBe( + inCircle(worldToScreen(center.x, center.y, iso), 48, worldToScreen(point.x, point.y, iso)) + ); + } + } + }); + + it('inConeW эквивалентен проекции в экранные px', () => { + const from = { x: 3, y: 1 }; + const dir = { x: 1, y: -1 }; // «вправо» в мировых юнитах + for (let wx = 0; wx <= 6; wx += 0.5) { + for (let wy = 0; wy <= 2; wy += 0.5) { + const point = { x: wx, y: wy }; + expect(inConeW(from, dir, 2, Math.PI / 3, point, iso)).toBe( + inCone( + worldToScreen(from.x, from.y, iso), + worldToScreen(dir.x, dir.y, iso), + 64, + Math.PI / 3, + worldToScreen(point.x, point.y, iso) + ) + ); + } + } + }); +}); +describe('conePath', () => { + it('вершина в from, точки дуги на радиусе', () => { + const from = { x: 10, y: 20 }; + const pts = conePath(from, { x: 1, y: 0 }, 50, Math.PI / 4, 8); + expect(pts[0]).toEqual({ x: 10, y: 20 }); + expect(pts).toHaveLength(10); // вершина + segments + 1 + for (const p of pts.slice(1)) { + const d = Math.hypot(p.x - from.x, p.y - from.y); + expect(d).toBeCloseTo(50, 6); + } + }); + + it('дуга симметрична относительно dir, крайние точки на ±halfAngle', () => { + const from = { x: 0, y: 0 }; + const half = Math.PI / 3; + const pts = conePath(from, { x: 0, y: -1 }, 100, half, 12); + const first = pts[1]!; + const last = pts[pts.length - 1]!; + // зеркальность по экранному X (dir вверх — ось симметрии x = 0) + expect(first.x).toBeCloseTo(-last.x, 6); + expect(first.y).toBeCloseTo(last.y, 6); + // средняя точка дуги — ровно по dir + const mid = pts[1 + 6]!; + expect(mid.x).toBeCloseTo(0, 6); + expect(mid.y).toBeCloseTo(-100, 6); + }); + + it('нулевой halfAngle — вырожденный сектор (луч)', () => { + const pts = conePath({ x: 1, y: 1 }, { x: 1, y: 0 }, 40, 0, 5); + expect(pts).toHaveLength(7); + for (const p of pts.slice(1)) expect(p.y).toBeCloseTo(1, 6); + }); +}); diff --git a/v1/packages/engine/src/math/__tests__/world.test.ts b/v1/packages/engine/src/math/__tests__/world.test.ts new file mode 100644 index 0000000..8805efd --- /dev/null +++ b/v1/packages/engine/src/math/__tests__/world.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import type { Vec2 } from '../Vec2'; +import { + worldToScreen, + screenToWorld, + unitsToPx, + worldLen, + worldNorm, + worldDist, + moveTowardsW, + tileToWorld, + worldToTile, + worldRectToScreen, + DEFAULT_ISO +} from '../iso'; + +describe('проекция мир↔экран', () => { + it('совпадает с iso-формулой', () => { + // Центр тайла (2, 3) на экране: (2.5-3.5)*16, (2.5+3.5)*8 + const s = worldToScreen(2.5, 3.5); + expect(s.x).toBeCloseTo(-16, 10); + expect(s.y).toBeCloseTo(48, 10); + }); + + it('round-trip worldToScreen ∘ screenToWorld', () => { + for (let wx = -5; wx <= 5; wx += 0.7) { + for (let wy = -5; wy <= 5; wy += 0.7) { + const s = worldToScreen(wx, wy); + const back = screenToWorld(s.x, s.y); + expect(back.x).toBeCloseTo(wx, 10); + expect(back.y).toBeCloseTo(wy, 10); + } + } + }); + + it('учитывает origin', () => { + const iso = { ...DEFAULT_ISO, originX: 100, originY: 50 }; + const s = worldToScreen(1, 0, iso); + expect(s.x).toBeCloseTo(100 + 16, 10); + expect(s.y).toBeCloseTo(50 + 8, 10); + }); +}); + +describe('скаляры: юниты↔пиксели', () => { + it('линейка проекции — tileW px на юнит', () => { + expect(unitsToPx(1)).toBe(32); + expect(unitsToPx(1.5)).toBe(48); + }); +}); + +describe('метрика проекции', () => { + it('длины характерных смещений', () => { + // Экранные оси — мировые диагонали: «вправо» = (1,-1) -> 1 юнит. + expect(worldLen(1, -1)).toBeCloseTo(1, 10); + expect(worldLen(-1, 1)).toBeCloseTo(1, 10); + // Диагональ сетки (1,1) — вертикальный шаг: на экране 8+8=16 px -> 0.5 юнита. + expect(worldLen(1, 1)).toBeCloseTo(0.5, 10); + // По оси сетки: соседние тайлы — sqrt(16² + 8²)/32 ≈ 0.56 юнита. + expect(worldLen(1, 0)).toBeCloseTo(Math.hypot(16, 8) / 32, 10); + }); + + it('worldDist совпадает с worldLen', () => { + expect(worldDist({ x: 0, y: 0 }, { x: 3, y: -3 })).toBeCloseTo(3, 10); + expect(worldDist({ x: 1, y: 2 }, { x: 1, y: 2 })).toBeCloseTo(0, 10); + }); + + it('worldNorm даёт постоянную экранную скорость', () => { + // Нормализованный вектор в юнитах при скорости s юнит/с даёт ровно 32·s px/с. + const s = 1.75; + const dirs: Vec2[] = [ + { x: 1, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: -1, y: 1 }, + { x: 2, y: -1 } + ]; + for (const d of dirs) { + const n = worldNorm(d.x, d.y); + const step = { x: n.x * s, y: n.y * s }; + const px = worldToScreen(step.x, step.y); + expect(Math.hypot(px.x, px.y)).toBeCloseTo(32 * s, 6); + } + }); + + it('worldNorm нулевого вектора', () => { + expect(worldNorm(0, 0)).toEqual({ x: 0, y: 0 }); + }); +}); + +describe('moveTowardsW', () => { + const a = { x: 0, y: 0 }; + const b = { x: 3, y: -3 }; // 3 юнита + + it('не проскакивает цель', () => { + expect(moveTowardsW(a, b, 10)).toEqual(b); + }); + + it('постоянная экранная скорость на любом направлении', () => { + const step = moveTowardsW(a, b, 1.75); + const px = worldToScreen(step.x, step.y); + expect(Math.hypot(px.x, px.y)).toBeCloseTo(32 * 1.75, 6); + }); + + it('последовательность шагов достигает цели', () => { + let p = a; + for (let i = 0; i < 100 && p !== b; i++) { + const next = moveTowardsW(p, b, 1.75); + if (next === p) break; + p = next; + } + expect(p).toEqual(b); + }); +}); + +describe('тайлы↔мир', () => { + it('tileToWorld — центр тайла', () => { + expect(tileToWorld(2, 3)).toEqual({ x: 2.5, y: 3.5 }); + }); + + it('worldToTile — floor внутри карты', () => { + expect(worldToTile(2.5, 3.5, 10, 10)).toEqual({ x: 2, y: 3 }); + expect(worldToTile(0, 0, 10, 10)).toEqual({ x: 0, y: 0 }); + }); + + it('worldToTile вне карты -> null', () => { + expect(worldToTile(-0.1, 5, 10, 10)).toBeNull(); + expect(worldToTile(5, 10, 10, 10)).toBeNull(); + }); +}); + +describe('worldRectToScreen', () => { + it('карта 28x28 — границы от западного угла', () => { + const r = worldRectToScreen(0, 0, 28, 28); + expect(r).toEqual({ x: -448, y: 0, width: 896, height: 448 }); + }); + + it('произвольный прямоугольник', () => { + // Углы (2,0) и (2,1): x от (2-1)*16 до 2*16. + const r = worldRectToScreen(2, 0, 1, 1); + expect(r.x).toBeCloseTo((2 - 1) * 16, 10); + expect(r.width).toBeCloseTo(2 * 16, 10); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/math/iso.ts b/v1/packages/engine/src/math/iso.ts new file mode 100644 index 0000000..17e57e0 --- /dev/null +++ b/v1/packages/engine/src/math/iso.ts @@ -0,0 +1,140 @@ +/** + * Изометрия 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 { + /** Ширина ромба тайла (пиксели, чётное) — проекция 1 мирового юнита. */ + 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) }; +} + +// ---------- мировые юниты ---------- +// +// Позиции хранятся в мировых юнитах (1 юнит = 1 тайл), float в мировой плоскости. +// Экран — виртуальные пиксели; перевод мир→экран — проекция, округление только +// на границе отрисовки (Camera.apply). Две «линейки»: +// • точка (позиция) — анизотропная проекция: tileW/2 и tileH/2 px на юнит; +// • скаляр (дистанция, радиус, скорость, высота) — линейка проекции: tileW px на юнит. + +import type { Vec2 } from './Vec2'; + +/** Мировая позиция → экранные пиксели (float, без округления). */ +export function worldToScreen(wx: number, wy: number, iso: IsoLayout = DEFAULT_ISO): Vec2 { + return { + x: (wx - wy) * (iso.tileW / 2) + iso.originX, + y: (wx + wy) * (iso.tileH / 2) + iso.originY + }; +} + +/** Экранные пиксели → мировая позиция (float). */ +export function screenToWorld(sx: number, sy: number, iso: IsoLayout = DEFAULT_ISO): Vec2 { + const halfW = iso.tileW / 2; + const halfH = iso.tileH / 2; + const dx = (sx - iso.originX) / halfW; + const dy = (sy - iso.originY) / halfH; + return { x: (dx + dy) / 2, y: (dy - dx) / 2 }; +} + +/** Скаляр (дистанция, скорость…) из юнитов в пиксели. */ +export function unitsToPx(units: number, iso: IsoLayout = DEFAULT_ISO): number { + return units * iso.tileW; +} + +/** Длина смещения в юнитах по «метрике проекции»: |M(d)| / tileW. */ +export function worldLen(dx: number, dy: number, iso: IsoLayout = DEFAULT_ISO): number { + const sx = (dx - dy) * (iso.tileW / 2); + const sy = (dx + dy) * (iso.tileH / 2); + return Math.hypot(sx, sy) / iso.tileW; +} + +/** Нормализованный мировой вектор (нулевой -> {0, 0}). */ +export function worldNorm(dx: number, dy: number, iso: IsoLayout = DEFAULT_ISO): Vec2 { + const l = worldLen(dx, dy, iso); + if (l < 1e-8) return { x: 0, y: 0 }; + return { x: dx / l, y: dy / l }; +} + +/** Расстояние между точками в юнитах по метрике проекции. */ +export function worldDist(a: Vec2, b: Vec2, iso: IsoLayout = DEFAULT_ISO): number { + return worldLen(b.x - a.x, b.y - a.y, iso); +} + +/** Шаг из a в сторону b на step юнитов (аналог moveTo в мировых юнитах). */ +export function moveTowardsW(a: Vec2, b: Vec2, step: number, iso: IsoLayout = DEFAULT_ISO): Vec2 { + const dx = b.x - a.x; + const dy = b.y - a.y; + const l = worldLen(dx, dy, iso); + if (l <= step || l < 1e-8) return { x: b.x, y: b.y }; + const k = step / l; + return { x: a.x + dx * k, y: a.y + dy * k }; +} + +/** Центр тайла в мировых юнитах. */ +export function tileToWorld(tx: number, ty: number): Vec2 { + return { x: tx + 0.5, y: ty + 0.5 }; +} + +/** Мировая позиция -> тайл (floor + границы карты); вне карты -> null. */ +export function worldToTile( + wx: number, + wy: number, + mapW: number, + mapH: number +): { x: number; y: number } | null { + const tx = Math.floor(wx); + const ty = Math.floor(wy); + if (tx < 0 || ty < 0 || tx >= mapW || ty >= mapH) return null; + return { x: tx, y: ty }; +} + +/** Мировой прямоугольник -> экранный bbox (для bounds камеры). */ +export function worldRectToScreen( + x: number, + y: number, + w: number, + h: number, + iso: IsoLayout = DEFAULT_ISO +): { x: number; y: number; width: number; height: number } { + const tl = worldToScreen(x, y, iso); + const hw = iso.tileW / 2; + const hh = iso.tileH / 2; + // Левый край bbox — у соседа-близнеца (x, y + h), правый — у (x + w, y). + return { + x: tl.x - h * hw, + y: tl.y, + width: (w + h) * hw, + height: (w + h) * hh + }; +} \ No newline at end of file diff --git a/v1/packages/engine/src/math/rng.ts b/v1/packages/engine/src/math/rng.ts new file mode 100644 index 0000000..41a008c --- /dev/null +++ b/v1/packages/engine/src/math/rng.ts @@ -0,0 +1,52 @@ +/** + * Детерминированный RNG (mulberry32): одинаковый seed — одинаковая последовательность. + * Используется для генерации мира, лута, частиц — везде, где нужен повторяемый «случай». + */ + +export interface Rng { + /** Следующее число 0..1. */ + next(): number; + /** Целое в диапазоне [min, max] включительно. */ + int(min: number, max: number): number; + /** Случайный элемент массива (массив должен быть непустым). */ + pick(arr: readonly T[]): T; + /** true с вероятностью p (0..1). */ + chance(p: number): boolean; + /** Текущее состояние — можно сохранить и восстановить для продолжения последовательности. */ + state(): number; +} + +/** Создать RNG из 32-битного seed. */ +export function createRng(seed: number): Rng { + let s = seed >>> 0; + const next = (): number => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + return { + next, + int: (min, max) => min + Math.floor(next() * (max - min + 1)), + pick: (arr: readonly T[]) => { + if (arr.length === 0) throw new Error('pick из пустого массива'); + return arr[Math.floor(next() * arr.length)]; + }, + chance: (p) => next() < p, + state: () => s + }; +} + +/** + * Детерминированный хэш координат тайла без состояния: одинаковые (x, y, seed) + * — одинаковый результат навсегда. В отличие от createRng не потребляет + * последовательность и не зависит от порядка вызовов — годится для выбора + * варианта текстуры по тайлу (hashTile(x, y, id) % варианты.length). + */ +export function hashTile(x: number, y: number, seed = 0): number { + let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 2654435761)) >>> 0; + h = (h ^ (h >>> 13)) >>> 0; + h = Math.imul(h, 1274126177) >>> 0; + return (h ^ (h >>> 16)) >>> 0; +} \ No newline at end of file diff --git a/v1/packages/engine/src/math/shapes.ts b/v1/packages/engine/src/math/shapes.ts new file mode 100644 index 0000000..893bda0 --- /dev/null +++ b/v1/packages/engine/src/math/shapes.ts @@ -0,0 +1,110 @@ +/** + * Простейшие проверки фигур и выбора целей — чистая математика без Pixi. + * Используется для боёв (конус удара, радиус взрыва), триггеров и подбора ближайшего. + */ + +import type { Vec2 } from './Vec2'; +import { DEFAULT_ISO, type IsoLayout, worldToScreen } from './iso'; + +/** Точка внутри круга (включая границу). */ +export function inCircle(center: Vec2, radius: number, point: Vec2): boolean { + const dx = point.x - center.x; + const dy = point.y - center.y; + return dx * dx + dy * dy <= radius * radius; +} + +/** Угол между векторами a и b (радианы, 0..PI). */ +export function angleBetween(a: Vec2, b: Vec2): number { + const la = Math.hypot(a.x, a.y); + const lb = Math.hypot(b.x, b.y); + if (la === 0 || lb === 0) return 0; + let cos = (a.x * b.x + a.y * b.y) / (la * lb); + cos = Math.min(1, Math.max(-1, cos)); + return Math.acos(cos); +} + +/** + * Точка внутри конуса: в пределах range от from и угол до dir не больше halfAngle. + * Нулевое направление — конус «схлопывается» в отрезок по range (угол не проверяется). + */ +export function inCone( + from: Vec2, + dir: Vec2, + range: number, + halfAngle: number, + point: Vec2 +): boolean { + const dx = point.x - from.x; + const dy = point.y - from.y; + if (dx * dx + dy * dy > range * range) return false; + if (dir.x === 0 && dir.y === 0) return true; + return angleBetween(dir, { x: dx, y: dy }) <= halfAngle; +} + +// ---------- мировые юниты: метризованные варианты ---------- + +/** Точка внутри круга; позиции и радиус — в мировых юнитах. */ +export function inCircleW( + center: Vec2, + radius: number, + point: Vec2, + iso: IsoLayout = DEFAULT_ISO +): boolean { + const c = worldToScreen(center.x, center.y, iso); + const p = worldToScreen(point.x, point.y, iso); + return inCircle(c, radius * iso.tileW, p); +} + +/** Точка внутри конуса; позиции, range и dir — в мировых юнитах (dir проецируется). */ +export function inConeW( + from: Vec2, + dir: Vec2, + range: number, + halfAngle: number, + point: Vec2, + iso: IsoLayout = DEFAULT_ISO +): boolean { + const f = worldToScreen(from.x, from.y, iso); + const p = worldToScreen(point.x, point.y, iso); + // Углы меняются под анизотропной проекцией — меряем их в экранном пространстве. + const d = worldToScreen(dir.x, dir.y, iso); + return inCone(f, d, range * iso.tileW, halfAngle, p); +} + +/** Ближайшая к from точка из списка (в пределах maxRange включительно), или null. */ +export function nearest(list: T[], from: Vec2, maxRange = Infinity): T | null { + let best: T | null = null; + let bestDist = maxRange * maxRange; + for (const p of list) { + const dx = p.x - from.x; + const dy = p.y - from.y; + const d = dx * dx + dy * dy; + if (d <= bestDist) { + bestDist = d; + best = p; + } + } + return best; +} + +/** + * Полигон сектора для отрисовки (Graphics): вершина в from, дуга по углам + * dir±halfAngle из segments сегментов. Углы — в том же пространстве, что inCone + * (экранном); радиус — px. Возвращает [from, ...дуга]; замыкание контура — + * на вызывающей стороне (closePath). Нулевое направление — дуга по base = 0. + */ +export function conePath( + from: Vec2, + dir: Vec2, + radius: number, + halfAngle: number, + segments = 10 +): Vec2[] { + const base = Math.atan2(dir.y, dir.x); + const out: Vec2[] = [{ x: from.x, y: from.y }]; + for (let i = 0; i <= segments; i++) { + const a = base - halfAngle + (2 * halfAngle * i) / segments; + out.push({ x: from.x + Math.cos(a) * radius, y: from.y + Math.sin(a) * radius }); + } + return out; +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/Camera.ts b/v1/packages/engine/src/render/Camera.ts new file mode 100644 index 0000000..f3a1eda --- /dev/null +++ b/v1/packages/engine/src/render/Camera.ts @@ -0,0 +1,196 @@ +import { Container } from 'pixi.js'; +import { Shake } from './shake'; +import { sineInOut, type EaseFn } from '../core/easing'; +import { DEFAULT_ISO, type IsoLayout, screenToWorld, worldRectToScreen, worldToScreen } from '../math/iso'; +import type { Vec2 } from '../math/Vec2'; + +/** + * Камера виртуального разрешения: позиция — центр взгляда в мировых юнитах + * (1 юнит = 1 тайл). Округляет проекцию до целого пикселя (apply — единственная + * точка перевода мир→экран), чтобы пиксель-арт не «дрожал». + * Границы прокрутки — мировой прямоугольник в юнитах (у карты: {0, 0, w, h}); + * клэмп выполняется по его экранному bbox (проекция). + * Поддерживает тряску (addShake) — движок вызывает update(dt) каждый тик. + * Зум: apply ставит scale контейнера мира; игровые уровни — целые (1/2), + * анимация промежуточно квантуется до 0.25 (пиксель-арт читается и в движении). + */ +export interface CameraBounds { + x: number; + y: number; + width: number; + height: number; +} + +/** Минимальный зум (сильнее отдалять нельзя — пустые края). */ +const ZOOM_MIN = 0.5; + +/** Шаг квантования анимации зума (финал — ровно целевой z). */ +const ZOOM_STEP = 0.25; + +export class Camera { + /** Центр взгляда, мировые юниты. */ + x = 0; + y = 0; + /** Мировой прямоугольник прокрутки, юниты. */ + bounds: CameraBounds | null = null; + /** + * «Мёртвая зона» (виртуальные границы экрана), экранные пиксели: пока цель + * внутри окна, камера стоит; у края окна цель толкает камеру на величину выхода. + * null — камера всегда центрируется на цели. + */ + deadZonePx: { width: number; height: number } | null = null; + private readonly shake = new Shake(); + private zoom = 1; + private zoomAnim: { from: number; to: number; t: number; seconds: number; ease: EaseFn } | null = null; + + constructor( + private readonly viewWidth: number, + private readonly viewHeight: number, + private readonly iso: IsoLayout = DEFAULT_ISO + ) {} + + /** Текущий зум (1 — без масштабирования). */ + get zoomLevel(): number { + return this.zoom; + } + + /** Мгновенно поставить зум (и пережать границы). */ + setZoom(z: number): void { + this.zoom = Math.max(ZOOM_MIN, z); + this.zoomAnim = null; + this.clamp(); + } + + /** Плавный переход к зуму (сек); промежуточные значения квантуются до 0.25. */ + zoomTo(z: number, seconds: number, ease: EaseFn = sineInOut): void { + const target = Math.max(ZOOM_MIN, z); + if (seconds <= 0 || target === this.zoom) { + this.setZoom(target); + return; + } + this.zoomAnim = { from: this.zoom, to: target, t: 0, seconds, ease }; + } + + follow(targetX: number, targetY: number): void { + const dz = this.deadZonePx; + if (dz) { + // Окно живёт на экране: сравниваем экранный разнос проекций + // (умножен на зум), двигаем каждую ось на величину выхода за окно. + const z = this.zoom; + const c = worldToScreen(this.x, this.y, this.iso); + const t = worldToScreen(targetX, targetY, this.iso); + const dxs = (t.x - c.x) * z; + const dys = (t.y - c.y) * z; + const ex = dxs < -dz.width / 2 ? dxs + dz.width / 2 : dxs > dz.width / 2 ? dxs - dz.width / 2 : 0; + const ey = dys < -dz.height / 2 ? dys + dz.height / 2 : dys > dz.height / 2 ? dys - dz.height / 2 : 0; + if (ex !== 0 || ey !== 0) { + // Сдвиг в экране ex — это мировой сдвиг ex/z после масштаба. + const w = screenToWorld(ex / z, ey / z, this.iso); + this.x += w.x; + this.y += w.y; + } + } else { + this.x = targetX; + this.y = targetY; + } + this.clamp(); + } + + /** Мгновенно поставить камеру на точку (спавн, телепорт, смена локации). */ + snap(targetX: number, targetY: number): void { + const dz = this.deadZonePx; + this.deadZonePx = null; + this.follow(targetX, targetY); + this.deadZonePx = dz; + } + + /** Запустить толчок: амплитуда в экранных пикселях, длительность в секундах. */ + addShake(strength: number, duration: number): void { + this.shake.add(strength, duration); + } + + clearShake(): void { + this.shake.clear(); + } + + get shaking(): boolean { + return this.shake.active; + } + + /** Обновление тряски и анимации зума — движок вызывает каждый фиксированный шаг. */ + update(dt: number): void { + this.shake.update(dt); + const anim = this.zoomAnim; + if (!anim) return; + anim.t += dt; + const k = Math.min(1, anim.t / anim.seconds); + if (k >= 1) { + this.setZoom(anim.to); // финал — ровно целевой зум + return; + } + const raw = anim.from + (anim.to - anim.from) * anim.ease(k); + const quantized = Math.round(raw / ZOOM_STEP) * ZOOM_STEP; + if (quantized !== this.zoom) { + this.zoom = Math.max(ZOOM_MIN, quantized); + this.clamp(); + } + } + + /** Позиция и масштаб контейнера мира (единственная точка мир→экран). */ + apply(container: Container): void { + const o = this.shake.offset; + const c = worldToScreen(this.x, this.y, this.iso); + container.position.set(this.originX(c.x * this.zoom) + o.x, this.originY(c.y * this.zoom) + o.y); + if (container.scale.x !== this.zoom) container.scale.set(this.zoom); + } + + /** + * Экранная позиция мировой точки с учётом камеры, зума и тряски — то, куда + * точка реально спроецируется следующим кадром (без округления). Для слоёв + * вне worldRoot (свет, эффекты), которым нужна проекция без лага на кадр. + * Потомки worldRoot лежат в мировых px и масштабируются контейнером сами — + * формула совпадает и для них. + */ + toScreen(wx: number, wy: number): Vec2 { + const o = this.shake.offset; + const c = worldToScreen(this.x, this.y, this.iso); + const p = worldToScreen(wx, wy, this.iso); + const z = this.zoom; + return { x: this.originX(c.x * z) + o.x + p.x * z, y: this.originY(c.y * z) + o.y + p.y * z }; + } + + private originX(cProjX: number): number { + return Math.floor(this.viewWidth / 2) - Math.round(cProjX); + } + + private originY(cProjY: number): number { + return Math.floor(this.viewHeight / 2) - Math.round(cProjY); + } + + private clamp(): void { + if (!this.bounds) return; + const z = this.zoom; + const halfW = this.viewWidth / 2; + const halfH = this.viewHeight / 2; + // Мировой прямоугольник -> экранный bbox (×зум); клэмпим проекцию центра. + const b = worldRectToScreen( + this.bounds.x, + this.bounds.y, + this.bounds.width, + this.bounds.height, + this.iso + ); + const c = worldToScreen(this.x, this.y, this.iso); + const cx = c.x * z; + const cy = c.y * z; + const px = b.width * z > this.viewWidth + ? Math.min(Math.max(cx, b.x * z + halfW), (b.x + b.width) * z - halfW) + : (b.x + b.width / 2) * z; + const py = b.height * z > this.viewHeight + ? Math.min(Math.max(cy, b.y * z + halfH), (b.y + b.height) * z - halfH) + : (b.y + b.height / 2) * z; + const w = screenToWorld(px / z, py / z, this.iso); + this.x = w.x; + this.y = w.y; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/IsoDepthLayer.ts b/v1/packages/engine/src/render/IsoDepthLayer.ts new file mode 100644 index 0000000..a82c6a6 --- /dev/null +++ b/v1/packages/engine/src/render/IsoDepthLayer.ts @@ -0,0 +1,57 @@ +import { Container } from 'pixi.js'; + +/** + * Контейнер с сортировкой глубины для изометрической сцены. + * Глубина = tx + ty (классика изометрии 2:1): чем юго-восточнее объект, + * тем «ближе к камере» он рисуется. Герой, NPC, деревья и прочие сущности + * добавляются сюда, а не прямо в слой карты. + * + * Если объект движется, каждый кадр (или при смене тайла) вызывайте setDepth. + */ +export class IsoDepthLayer extends Container { + constructor() { + super(); + this.sortableChildren = true; + } + + /** Добавить объект и выставить глубину по тайловым координатам. */ + add(view: Container, tx: number, ty: number): Container { + this.setDepth(view, tx, ty); + this.addChild(view); + return view; + } + + /** Обновить глубину объекта по тайловым координатам (без смены — сортировку не будим). */ + setDepth(view: Container, tx: number, ty: number): void { + const z = tx + ty; + if (view.zIndex !== z) view.zIndex = z; + } + + /** Добавить крупный объект (проп) с footprint w×h от северо-западного угла. */ + addRect(view: Container, x: number, y: number, w: number, h: number): Container { + this.setDepthRect(view, x, y, w, h); + this.addChild(view); + return view; + } + + /** + * Глубина крупного объекта по footprint'у: диагональ юго-восточного + * (ближнего к камере) угла. Актёр перед южным краем дома рисуется поверх, + * за северным — под ним. + */ + setDepthRect(view: Container, x: number, y: number, w: number, h: number): void { + const z = x + w - 1 + (y + h - 1); + if (view.zIndex !== z) view.zIndex = z; + } + + /** + * Эффект на тайле (искры удара, кольца): как add, но с надбавкой bias — + * искры поверх актора на том же тайле. Движущийся эффект — setDepth + * в том же месте, где обновляется глубина вью. + */ + addFx(view: Container, tx: number, ty: number, bias = 0.5): Container { + view.zIndex = tx + ty + bias; + this.addChild(view); + return view; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/Lighting.ts b/v1/packages/engine/src/render/Lighting.ts new file mode 100644 index 0000000..4a43f5f --- /dev/null +++ b/v1/packages/engine/src/render/Lighting.ts @@ -0,0 +1,608 @@ +/** + * Слой освещения «тьма с прорезями»: карта силы тьмы в RenderTexture, + * источники прорезают её erase-спрайтами, тон (мягкая аддитивная подсветка + * вокруг прорези) рисуется поверх. Плюс импульсы (pulseLight/pulseAmbient), + * виньетка и тёмные пятна. Тонкий Pixi-адаптер над lightSim (чистая математика — там). + * + * Механика: RT хранит не цвет, а силу тьмы (белый fill → erase-спрайты); + * display-спрайт RT рисуется с blendMode 'multiply' и tint = ambient — + * смена ambient-цвета не требует перерисовки RT. Bake (отрисовка в RT) + * делается только при изменении квантованных подписей источников — + * мерцание живёт в квантовании альфы и тоне, обновляемом каждый тик. + * + * Живёт в renderer.lightRoot — экранное пространство между миром и UI. + * Порядок детей: карта тьмы → виньетка → тёмные пятна → тон → ambient-вспышка. + * Важно: bake-контейнер НЕ в дереве сцены — erase-спрайты вне RT стёрли бы мир. + */ + +import { Container, Graphics, RenderTexture, Sprite, Texture } from 'pixi.js'; +import { FadeState } from '../core/fade'; +import type { Renderer } from './Renderer'; +import { + GLOW_BASE_PX, + HOLE_PROFILE, + TONE_PROFILE, + lightFrame, + lerpAmbient, + pulseEnvelope, + pulseTotal, + quantizeHoleAlpha, + stepAlphas, + type LightDef, + type LightFrame, + type PulseSpec, + type RingStep +} from './lightSim'; + +export interface LightingOptions { + /** Виртуальные размеры сцены (480×270). */ + width: number; + height: number; + /** Живой рендерер — для генерации текстур и bake; без него Texture.WHITE. */ + renderer?: Renderer; + /** Размер пула спрайтов источников (по умолчанию 16). */ + maxLights?: number; + /** Размер пула спрайтов тёмных пятен (по умолчанию 16). */ + maxDarkSpots?: number; +} + +/** Сила аддитивного тона поверх прорези: тон намёк, яркость несёт сама прорезь. */ +const TONE_GAIN = 0.3; + +/** Масштаб тона относительно радиуса прорези: тон уже, чем дыра. */ +const TONE_SCALE = 0.8; + +/** + * Ступенчатая круговая текстура из колец (в порядке stepAlphas) — общая + * для прорези, тона и свечения. resolution 1 + nearest — пиксельные ступени. + */ +function makeSteppedTexture(renderer: Renderer, rings: RingStep[], dither: boolean): Texture { + const g = new Graphics(); + // Дизер-кольцо: редкие точки по внешнему радиусу — смягчают резкую ступень. + if (dither) { + const outer = rings[0].radius; + for (let i = 0; i < 32; i++) { + const a = ((i * 2) / 64) * Math.PI * 2; + g.rect(Math.cos(a) * outer - 0.5, Math.sin(a) * outer - 0.5, 1, 1).fill({ + color: 0xffffff, + alpha: rings[0].paintAlpha + }); + } + } + for (const ring of rings) { + g.circle(0, 0, ring.radius).fill({ color: 0xffffff, alpha: ring.paintAlpha }); + } + const tex = renderer.app.renderer.generateTexture({ + target: g, + resolution: 1, + antialias: false, + textureSourceOptions: { scaleMode: 'nearest' } + }); + g.destroy(); + return tex; +} + +/** + * Ступенчатая пиксельная текстура свечения: концентрические круги с дискретной + * альфой (никаких гладких градиентов) + дизер-кольцо. Используется тёмными + * пятнами. Кэшируется вызывающим (Lighting держит её на инстансе — модульный + * кэш переживал destroy рендерера). + */ +export function makeGlowTexture(renderer: Renderer, steps = 4): Texture { + const profile: number[] = []; + for (let i = 1; i <= steps; i++) profile.push(1 - 1 / (i + 1)); // накопление красок 1/(i+1) + return makeSteppedTexture(renderer, stepAlphas(profile), true); +} + +/** Текстура прорези (erase в RT): 7 ступеней из HOLE_PROFILE + дизер-кольцо. */ +export function makeHoleTexture(renderer: Renderer): Texture { + return makeSteppedTexture(renderer, stepAlphas(HOLE_PROFILE), true); +} + +/** Текстура тона (аддитивный свет вокруг прорези): приглушённое ядро. */ +export function makeToneTexture(renderer: Renderer): Texture { + return makeSteppedTexture(renderer, stepAlphas(TONE_PROFILE), false); +} + +/** + * Ступенчатая виньетка: белый центр → серые кольца к тёмным углам (multiply). + * Интенсивность задаётся alpha спрайта, текстура постоянна. Кэшируется + * вызывающим (см. makeGlowTexture). + */ +export function makeVignetteTexture(renderer: Renderer, width: number, height: number): Texture { + const g = new Graphics(); + const r = Math.hypot(width, height) / 2; // дистанция центра до угла + g.rect(0, 0, width, height).fill({ color: 0x6a6a74 }); + g.circle(width / 2, height / 2, r * 0.72).fill({ color: 0xa8a8b0 }); + g.circle(width / 2, height / 2, r * 0.42).fill({ color: 0xffffff }); + const tex = renderer.app.renderer.generateTexture({ + target: g, + resolution: 1, + antialias: false, + textureSourceOptions: { scaleMode: 'nearest' } + }); + g.destroy(); + return tex; +} + +/** Тёмное пятно: локальная аура опасности/тени (multiply). */ +export interface DarkSpotDef { + id: string; + /** Позиция центра в экранных px. */ + x: number; + y: number; + /** Радиус в px. */ + radius: number; + /** Сила затемнения 0..1. */ + alpha: number; +} + +/** Цвет тени тёмного пятна (умножается на сцену) — тёплая пепельная мгла. */ +const DARK_SPOT_TINT = 0x4a4440; + +interface LightEntry { + def: LightDef; + /** Спрайт тона (аддитивный, в toneLayer). */ + sprite: Sprite; + /** Erase-спрайт в bake — прорезает карту тьмы (есть только с RT). */ + hole: Sprite | null; +} + +/** Апсейт def по полям (id — ключ карты, не меняется): без пересоздания на тике. */ +function copyLightDef(into: LightDef, from: LightDef): void { + into.x = from.x; + into.y = from.y; + into.color = from.color; + into.intensity = from.intensity; + into.radius = from.radius; + into.flicker = from.flicker; + into.flickerPeriod = from.flickerPeriod; + into.seed = from.seed; + into.enabled = from.enabled; +} + +interface PulseEntry { + def: LightDef; + /** Базовая интенсивность до огибающей. */ + base: number; + spec: PulseSpec; + elapsed: number; +} + +interface AmbientPulse { + color: number; + peak: number; + spec: PulseSpec; + elapsed: number; +} + +interface DarkEntry { + def: DarkSpotDef; + sprite: Sprite; +} + +export class Lighting extends Container { + /** Display-спрайт карты тьмы: multiply, tint = ambient-цвет. */ + private readonly darkSprite: Sprite; + private readonly vignette: Sprite; + private readonly darkLayer: Container; + private readonly toneLayer: Container; + private readonly pulseAmb: Sprite; + private readonly toneFree: Sprite[] = []; + private readonly holeFree: Sprite[] = []; + private readonly darkFree: Sprite[] = []; + private readonly lights = new Map(); + private readonly darks = new Map(); + private readonly pulses = new Map(); + private readonly renderer: Renderer | null; + /** Карта силы тьмы (resolution 1); null — fallback без RT (Vitest, node). */ + private readonly rt: RenderTexture | null; + /** Bake-контейнер: fill + erase-спрайты. НЕ в дереве сцены! */ + private readonly bake: Container; + private readonly bakeFill: Sprite; + private readonly glow: Texture; + private readonly hole: Texture; + private readonly tone: Texture; + /** Сгенерированные (не WHITE) текстуры — уничтожаются в destroy(). */ + private readonly generated: Texture[] = []; + private readonly maxLights: number; + private readonly maxDarkSpots: number; + private pulseSeq = 0; + /** Подпись набора квантованных прорезей; null — bake ещё не делался. */ + private bakeSig: string | null = null; + /** Тьма была активна на прошлом тике (для форс-bake на включении). */ + private darkOn = false; + /** Переиспользуемый кадр applyFrame — без аллокации на источник/тик. */ + private readonly frame: LightFrame = { id: '', x: 0, y: 0, tint: 0xffffff, alpha: 0, scale: 1 }; + + private time = 0; + private ambientFade: FadeState; + private vignetteFade: FadeState; + private ambPulse: AmbientPulse | null = null; + + constructor(options: LightingOptions) { + super(); + this.maxLights = options.maxLights ?? 16; + this.maxDarkSpots = options.maxDarkSpots ?? 16; + this.renderer = options.renderer ?? null; + + // Карта тьмы: Texture.WHITE без рендерера (multiply белого = не трогает). + this.darkSprite = new Sprite(Texture.WHITE); + this.darkSprite.width = options.width; + this.darkSprite.height = options.height; + this.darkSprite.blendMode = 'multiply'; + this.darkSprite.tint = 0xffffff; + this.ambientFade = new FadeState(lerpAmbient, 0xffffff); + this.addChild(this.darkSprite); + + // Виньетка: Texture.WHITE без рендерера (multiply белого = не трогает). + const vignetteTex = this.renderer + ? makeVignetteTexture(this.renderer, options.width, options.height) + : Texture.WHITE; + if (this.renderer) this.generated.push(vignetteTex); + this.vignette = new Sprite(vignetteTex); + this.vignette.width = options.width; + this.vignette.height = options.height; + this.vignette.blendMode = 'multiply'; + this.vignette.alpha = 0; + this.vignetteFade = new FadeState((a, b, k) => a + (b - a) * k, 0); + this.addChild(this.vignette); + + // Тёмные пятна — под тоном: свет пробивает локальную тень. + this.darkLayer = new Container(); + this.addChild(this.darkLayer); + + // Тон источников — аддитивный слой над тьмой (не в RT: цвет каждый тик). + this.toneLayer = new Container(); + this.addChild(this.toneLayer); + + // Ambient-вспышка: Texture.WHITE, аддитивная, alpha по огибающей. + this.pulseAmb = new Sprite(Texture.WHITE); + this.pulseAmb.width = options.width; + this.pulseAmb.height = options.height; + this.pulseAmb.blendMode = 'add'; + this.pulseAmb.alpha = 0; + this.addChild(this.pulseAmb); + + // RT карты тьмы + bake-контейнер (вне сцены): fill → erase-прорези. + this.rt = this.renderer + ? RenderTexture.create({ + width: options.width, + height: options.height, + resolution: 1, + scaleMode: 'nearest', + antialias: false + }) + : null; + this.bake = new Container(); + this.bakeFill = new Sprite(Texture.WHITE); + this.bakeFill.width = options.width; + this.bakeFill.height = options.height; + this.bake.addChild(this.bakeFill); + // Display-спрайт показывает саму карту силы тьмы (без рендерера RT нет — + // остаётся Texture.WHITE: multiply белого сцену не трогает). + if (this.rt) this.darkSprite.texture = this.rt; + + this.glow = this.renderer ? makeGlowTexture(this.renderer) : Texture.WHITE; + this.hole = this.renderer ? makeHoleTexture(this.renderer) : Texture.WHITE; + this.tone = this.renderer ? makeToneTexture(this.renderer) : Texture.WHITE; + for (const tex of [this.glow, this.hole, this.tone]) { + if (tex !== Texture.WHITE) this.generated.push(tex); + } + for (let i = 0; i < this.maxLights; i++) this.toneFree.push(this.makeToneSprite()); + for (let i = 0; i < this.maxLights; i++) this.holeFree.push(this.makeHoleSprite()); + for (let i = 0; i < this.maxDarkSpots; i++) this.darkFree.push(this.makeDarkSprite()); + } + + private makeToneSprite(): Sprite { + const s = new Sprite(this.tone); + s.anchor.set(0.5); + s.blendMode = 'add'; + s.visible = false; + this.toneLayer.addChild(s); + return s; + } + + private makeHoleSprite(): Sprite { + const s = new Sprite(this.hole); + s.anchor.set(0.5); + s.blendMode = 'erase'; + s.visible = false; + this.bake.addChild(s); + return s; + } + + private makeDarkSprite(): Sprite { + const s = new Sprite(this.glow); + s.anchor.set(0.5); + s.blendMode = 'multiply'; + s.tint = DARK_SPOT_TINT; + s.visible = false; + this.darkLayer.addChild(s); + return s; + } + + // --- ambient (фундамент смены времени дня) --- + + /** Задать ambient-цвет; fadeSec > 0 — плавный лерп (день/ночь, закат). */ + setAmbient(color: number, fadeSec = 0): void { + this.ambientFade.to(color, Math.max(0, fadeSec)); + this.applyAmbient(); + } + + /** Целевой (заданный) ambient-цвет. */ + get ambientColor(): number { + return this.ambientFade.target; + } + + /** ambient на display-спрайт карты тьмы; белый — день, слой выключен. */ + private applyAmbient(): void { + this.darkSprite.tint = this.ambientFade.value; + this.darkSprite.visible = this.ambientFade.value !== 0xffffff; + } + + // --- виньетка (край экрана темнеет: низкий hp, опасная зона) --- + + /** Виньетка 0..1 (0 — не трогает); fadeSec > 0 — плавный лерп. */ + setVignette(intensity: number, fadeSec = 0): void { + this.vignetteFade.to(Math.max(0, Math.min(1, intensity)), Math.max(0, fadeSec)); + this.vignette.alpha = this.vignetteFade.value; + } + + /** Целевая интенсивность виньетки. */ + get vignetteLevel(): number { + return this.vignetteFade.target; + } + + // --- источники: runtime add/remove/move/color/enable --- + + /** Добавить источник или обновить его параметры целиком. */ + upsertLight(def: LightDef): void { + let entry = this.lights.get(def.id); + if (!entry) { + const sprite = this.toneFree.pop(); + if (!sprite) return; // пул исчерпан — лишние источники игнорируются + entry = { def: { ...def }, sprite, hole: this.takeHole() }; + this.lights.set(def.id, entry); + sprite.visible = true; + } + copyLightDef(entry.def, def); // апсейт по полям — без пересоздания на тике + } + + /** Erase-слот из пула (или null без RT — прорезать нечего). */ + private takeHole(): Sprite | null { + if (!this.rt) return null; + const hole = this.holeFree.pop(); + if (hole) hole.visible = true; + return hole ?? null; + } + + removeLight(id: string): void { + const entry = this.lights.get(id); + if (!entry) return; + entry.sprite.visible = false; + entry.sprite.alpha = 0; + this.toneFree.push(entry.sprite); + if (entry.hole) { + entry.hole.visible = false; + entry.hole.alpha = 0; + this.holeFree.push(entry.hole); + } + this.lights.delete(id); + } + + hasLight(id: string): boolean { + return this.lights.has(id); + } + + setLightPos(id: string, x: number, y: number): void { + const entry = this.lights.get(id); + if (entry) { + entry.def.x = x; + entry.def.y = y; + } + } + + setLightColor(id: string, color: number): void { + const entry = this.lights.get(id); + if (entry) entry.def.color = color; + } + + setLightEnabled(id: string, enabled: boolean): void { + const entry = this.lights.get(id); + if (entry) entry.def.enabled = enabled; + } + + // --- импульсы: короткие вспышки в пуле источников и на весь экран --- + + /** + * Импульс света в точке: занимает спрайт общего пула, снимается по концу + * огибающей. Возвращает id (или пустую строку, если пул исчерпан). + */ + pulseLight(args: { + x: number; + y: number; + color: number; + intensity?: number; + radius?: number; + spec: PulseSpec; + }): string { + const id = `pulse#${this.pulseSeq++}`; + const sprite = this.toneFree.pop(); + if (!sprite) return ''; + const base = args.intensity ?? 1; + const def: LightDef = { + id, + x: args.x, + y: args.y, + color: args.color, + intensity: base * pulseEnvelope(0, args.spec), + radius: args.radius, + flicker: 0, + seed: 0 + }; + this.lights.set(id, { def, sprite, hole: this.takeHole() }); + sprite.visible = true; + this.pulses.set(id, { def, base, spec: args.spec, elapsed: 0 }); + return id; + } + + /** Вспышка на весь экран (аддитивная); один слот — новая побеждает старую. */ + pulseAmbient(args: { color: number; peak: number; spec: PulseSpec }): void { + this.pulseAmb.tint = args.color; + this.ambPulse = { ...args, elapsed: 0 }; + this.pulseAmb.alpha = 0; + this.pulseAmb.visible = true; // оживёт в ближайшем update + } + + /** Текущая альфа ambient-вспышки (0..1) — для тестов и снапшота. */ + get pulseAmbientAlpha(): number { + return this.pulseAmb.alpha; + } + + // --- тёмные пятна: локальные ауры опасности (multiply) --- + + /** Добавить тёмное пятно или обновить его параметры. */ + upsertDarkSpot(def: DarkSpotDef): void { + let entry = this.darks.get(def.id); + if (!entry) { + const sprite = this.darkFree.pop(); + if (!sprite) return; // пул исчерпан — лишние пятна игнорируются + entry = { def: { ...def }, sprite }; + this.darks.set(def.id, entry); + sprite.visible = true; + } + const d = entry.def; + d.x = def.x; + d.y = def.y; + d.radius = def.radius; + d.alpha = def.alpha; + const s = entry.sprite; + s.position.set(def.x, def.y); + s.alpha = Math.max(0, Math.min(1, def.alpha)); + s.scale.set(def.radius / GLOW_BASE_PX); + } + + removeDarkSpot(id: string): void { + const entry = this.darks.get(id); + if (!entry) return; + entry.sprite.visible = false; + entry.sprite.alpha = 0; + this.darkFree.push(entry.sprite); + this.darks.delete(id); + } + + hasDarkSpot(id: string): boolean { + return this.darks.has(id); + } + + // --- тик --- + + /** Тик: время, лерп ambient/виньетки, огибающие импульсов, кадры источников. */ + update(dt: number): void { + this.time += dt; + this.ambientFade.update(dt); + this.applyAmbient(); + // Тьма включилась на этом тике — форс-bake, даже если подпись та же. + const on = this.ambientFade.value !== 0xffffff; + if (on && !this.darkOn) this.bakeSig = null; + this.darkOn = on; + this.vignetteFade.update(dt); + this.vignette.alpha = this.vignetteFade.value; + this.vignette.visible = this.vignetteFade.value > 0; + for (const [id, p] of this.pulses) { + p.elapsed += dt; + if (p.elapsed >= pulseTotal(p.spec)) { + this.removeLight(id); + this.pulses.delete(id); // удаление во время итерации Map — безопасно + continue; + } + p.def.intensity = p.base * pulseEnvelope(p.elapsed, p.spec); + } + this.applyLights(); + if (this.ambPulse) { + this.ambPulse.elapsed += dt; + if (this.ambPulse.elapsed >= pulseTotal(this.ambPulse.spec)) { + this.ambPulse = null; + this.pulseAmb.alpha = 0; + this.pulseAmb.visible = false; + } else { + const e = pulseEnvelope(this.ambPulse.elapsed, this.ambPulse.spec); + this.pulseAmb.alpha = Math.min(1, this.ambPulse.peak * e); + this.pulseAmb.visible = this.pulseAmb.alpha > 0; + } + } + } + + /** Кадры источников: тон каждый тик, прорези — по квантованной подписи. */ + private applyLights(): void { + let sig = ''; + let idx = 0; + for (const entry of this.lights.values()) { + const f = lightFrame(entry.def, this.time, this.frame); + // Тон: полный precision, обновляется каждый тик (живость мерцания). + const s = entry.sprite; + s.position.set(f.x, f.y); + s.tint = f.tint; + s.alpha = Math.min(1, f.alpha) * TONE_GAIN; + s.scale.set(f.scale * TONE_SCALE); + s.visible = s.alpha > 0.01; + // Прорезь: квантованные значения — сигнатура = содержимое RT. + if (entry.hole) { + const qx = Math.round(f.x); + const qy = Math.round(f.y); + const qs = Math.round(f.scale * 8) / 8; + const qa = quantizeHoleAlpha(f.alpha); + entry.hole.position.set(qx, qy); + entry.hole.scale.set(qs); + entry.hole.alpha = qa; + entry.hole.visible = qa > 0; + sig += `${idx}:${qx},${qy},${qs},${qa};`; + } + idx++; + } + if (sig !== this.bakeSig) { + this.bakeSig = sig; + this.bakeNow(); + } + } + + /** Перерисовать карту тьмы в RT; день (белый ambient) — не нужно. */ + private bakeNow(): void { + if (!this.rt || !this.renderer || !this.darkSprite.visible) return; + this.renderer.app.renderer.render({ container: this.bake, target: this.rt, clear: true }); + } + + /** Видимые кадры источников (пост-мерцание, включая импульсы) — для снапшота. */ + frames(): readonly LightFrame[] { + const out: LightFrame[] = []; + for (const { def } of this.lights.values()) out.push(lightFrame(def, this.time)); + return out; + } + + override destroy(options?: Parameters[0]): void { + this.pulses.clear(); + for (const id of [...this.lights.keys()]) this.removeLight(id); + for (const id of [...this.darks.keys()]) this.removeDarkSpot(id); + for (const s of this.toneFree) s.destroy(); + this.toneFree.length = 0; + for (const s of this.holeFree) s.destroy(); + this.holeFree.length = 0; + this.bakeFill.destroy(); + this.bake.destroy({ children: false }); + this.rt?.destroy(true); + this.darks.clear(); + // Тёмные спрайты — дети darkLayer: destroy явно и один раз, слой без children. + for (const s of this.darkFree) s.destroy(); + this.darkFree.length = 0; + this.darkLayer.destroy({ children: false }); + this.darkSprite.destroy(); + this.vignette.destroy(); + this.pulseAmb.destroy(); + for (const tex of this.generated) { + if (tex !== Texture.WHITE) tex.destroy(true); // стаб-рендерер отдаёт WHITE + } + this.generated.length = 0; + super.destroy(options); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/Particles.ts b/v1/packages/engine/src/render/Particles.ts new file mode 100644 index 0000000..53b4f47 --- /dev/null +++ b/v1/packages/engine/src/render/Particles.ts @@ -0,0 +1,198 @@ +import { Container, Sprite, Texture } from 'pixi.js'; +import { createRng, type Rng } from '../math/rng'; +import { stepParticle, sampleSpawn, sampleBurst, mergeEmitterOptions, type ParticleState, type ParticleVisual } from './particleSim'; + +/** + * Система частиц: квадраты (Texture.WHITE) или текстуры, цвета/кривые по + * жизни, пул спрайтов с лимитом. Используется для атмосферы (пепел, мотыльки) + * и эффектов (взрывы — burst/oneShot). Тикается через engine.fx либо вручную. + */ +export interface EmitterOptions { + /** Цвет частиц (0xRRGGBB); по умолчанию белый (для текстур — без подкраски). */ + color?: number; + /** Вариация: случайный цвет из списка при спавне (вместо color). */ + colors?: number[]; + /** Цвет по жизни: лерп 0xRRGGBB от начала к концу. */ + colorOverLife?: [number, number]; + /** Текстура частицы вместо подкрашенного квадрата. */ + texture?: Texture; + /** Частиц в секунду. */ + rate: number; + /** Время жизни частицы, секунды (min..max). */ + lifetime: [number, number]; + /** Начальная скорость, виртуальных пикселей/сек: (minX..maxX, minY..maxY). */ + velocity?: { x: [number, number]; y: [number, number] }; + /** Ускорение (гравитация/ветер), пикселей/сек². */ + acceleration?: { x?: number; y?: number }; + /** Размер квадрата в px (1..4) или диапазон размеров. */ + size?: number | [number, number]; + /** Множитель масштаба текстурных частиц. */ + scale?: number; + /** Начальный угол, рад (min..max). */ + rotation?: [number, number]; + /** Скорость вращения, рад/сек (min..max). */ + spin?: [number, number]; + /** Масштаб по жизни: диапазон или предустановка ('shrink'/'grow'). */ + scaleOverLife?: [number, number] | 'shrink' | 'grow'; + /** Сек нарастания альфы в начале жизни. */ + fadeIn?: number; + /** Затухание альфы: true (по умолчанию) — вся жизнь; число — хвост в сек. */ + fadeOut?: boolean | number; + /** Торможение, 1/сек (искры, пух). */ + drag?: number; + /** Амплитуда синус-дрейфа по X, px/сек (мотыльки). */ + wobble?: number; + /** Спавн в прямоугольнике вокруг позиции эмиттера (по умолчанию точка). */ + spawnArea?: { width: number; height: number }; + /** Seed для повторяемого движения. */ + seed?: number; + /** + * Радиальный разброс вместо velocity: скорость частиц (min..max) по кругу + * (для burst-взрывов). Если задан — velocity игнорируется. + */ + radialSpeed?: [number, number]; + /** Лимит живых частиц (пул переиспользуется), по умолчанию 256. */ + max?: number; + /** Режим смешивания: 'add' — светящиеся (огонь, искры, мотыльки). */ + blend?: 'normal' | 'add'; +} + +interface Particle { + state: ParticleState; + sprite: Sprite; +} + +export class ParticleEmitter extends Container { + private particles: Particle[] = []; + private readonly free: Sprite[] = []; + private spawnTimer = 0; + private rng: Rng; + private selfDestruct = false; + /** Переиспользуемый визуал шага: без аллокации на частицу/тик. */ + private readonly visual: ParticleVisual = { alpha: 0, scale: 1, rotation: 0, tint: 0xffffff }; + + constructor(private options: EmitterOptions) { + super(); + this.rng = createRng(options.seed ?? 0x9e3779b9); + } + + update(dt: number): void { + // Спавн по rate (для burst-эмиттеров rate = 0) + this.spawnTimer += dt * this.options.rate; + while (this.spawnTimer >= 1) { + this.spawnTimer -= 1; + this.spawn(); + } + + // Обновление через чистую симуляцию + компакт в один проход: + // умершие (age >= lifetime) — спрайт в пул, не destroy; без filter. + let w = 0; + for (let i = 0; i < this.particles.length; i++) { + const p = this.particles[i]; + if (p.state.age >= p.state.lifetime) { + p.sprite.visible = false; + p.sprite.alpha = 0; + this.free.push(p.sprite); + continue; + } + const v = stepParticle(p.state, dt, this.options, this.visual); + p.sprite.x = p.state.x; + p.sprite.y = p.state.y; + p.sprite.alpha = v.alpha; + p.sprite.tint = v.tint; + p.sprite.rotation = v.rotation; + if (this.options.texture) { + p.sprite.scale.set(v.scale); + } else { + p.sprite.width = v.scale; + p.sprite.height = v.scale; + } + this.particles[w++] = p; + } + this.particles.length = w; + // oneShot: все частицы умерли и новые не рождаются — уничтожиться + if (this.selfDestruct && this.particles.length === 0 && this.options.rate <= 0) { + this.onFinish?.(); + // children: pooled-спрайты — дети контейнера, с ними же и уничтожаются + this.destroy({ children: true }); + } + } + + /** Вызывается один раз при самоуничтожении oneShot (цепочки эффектов). */ + onFinish?: () => void; + + /** Убрать все частицы (например, при выходе со сцены). */ + clear(): void { + for (const p of this.particles) p.sprite.destroy(); + for (const s of this.free) s.destroy(); + this.particles = []; + this.free.length = 0; + } + + get count(): number { + return this.particles.length; + } + + /** Заменить опции эмиттера (цвет/скорость/кривые) на лету. */ + setOptions(opts: Partial): void { + this.options = { ...this.options, ...opts }; + } + + /** + * Мгновенно выпустить count частиц (взрыв/удар): игнорирует rate. + * opts — разовые переопределения; базовые опции эмиттера не мутируют. + */ + burst(count: number, opts?: Partial): void { + const o = mergeEmitterOptions(this.options, opts); + if (o.radialSpeed) { + for (const s of sampleBurst(count, o.radialSpeed, o.lifetime, this.rng, o)) { + this.addParticle(s, o); + } + } else { + for (let i = 0; i < count; i++) this.spawnFrom(o); + } + } + + /** + * Одноразовый эмиттер-взрыв: спавнит count частиц сразу и уничтожает себя, + * когда все умерли. Добавить в world и в engine.fx — тик и зачистка сами. + */ + static oneShot(count: number, opts: EmitterOptions): ParticleEmitter { + const e = new ParticleEmitter({ ...opts, rate: 0 }); + e.selfDestruct = true; + e.burst(count); + return e; + } + + private spawn(): void { + this.spawnFrom(this.options); + } + + private spawnFrom(options: EmitterOptions): void { + this.addParticle(sampleSpawn(options, this.rng), options); + } + + private addParticle(state: ParticleState, options: EmitterOptions): void { + const max = options.max ?? 256; + if (this.particles.length >= max) return; + const sprite = this.free.pop() ?? this.makeSprite(options); + const size = typeof options.size === 'number' ? options.size + : options.size ? options.size[0] + this.rng.next() * (options.size[1] - options.size[0]) + : 2; + // Базовый размер частицы — в state.scale (кривые по жизни умножают его). + state.scale = options.texture ? options.scale ?? 1 : size; + sprite.visible = true; + sprite.alpha = 0; // появится в следующем кадре без «вспышки» + sprite.tint = state.tint; + sprite.rotation = state.rot; + this.particles.push({ state, sprite }); + if (sprite.parent !== this) this.addChild(sprite); + } + + private makeSprite(options: EmitterOptions): Sprite { + const sprite = new Sprite(options.texture ?? Texture.WHITE); + if (options.texture) sprite.anchor.set(0.5, 0.5); + sprite.blendMode = options.blend === 'add' ? 'add' : 'normal'; + return sprite; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/Renderer.ts b/v1/packages/engine/src/render/Renderer.ts new file mode 100644 index 0000000..a188796 --- /dev/null +++ b/v1/packages/engine/src/render/Renderer.ts @@ -0,0 +1,109 @@ +import { Application, Container } from 'pixi.js'; +import { setPixelTextResolution } from '../ui/PixelText'; + +/** + * Рендерер: виртуальное «пиксельное» разрешение. CSS-размер канваса — виртуальное + * разрешение, растянутое целым числом (image-rendering: pixelated); бэкинг-стор — + * в пикселях устройства (scale × dpr), поэтому UI и текст резкие, а мир остаётся + * пиксельным (текстуры мира сэмплируются nearest — см. AssetLoader). + */ +export interface RendererOptions { + /** Виртуальная ширина (например 480). */ + virtualWidth: number; + /** Виртуальная высота (например 270). */ + virtualHeight: number; + /** Целочисленный множитель масштаба. */ + scale: number; + /** Цвет фона. */ + background: number; + /** Родительский элемент для канваса. */ + parent: HTMLElement; +} + +export class Renderer { + readonly app: Application; + /** Корневой контейнер мира (к нему применяется камера). */ + readonly worldRoot: Container; + /** + * Освещение — экранное пространство: fullscreen ambient + аддитивные источники. + * Камерой не двигается; ниже uiRoot (HUD/fog/fade остаются читаемыми поверх). + */ + readonly lightRoot: Container; + /** Контейнер UI поверх мира (камерой не двигается). */ + readonly uiRoot: Container; + + readonly virtualWidth: number; + readonly virtualHeight: number; + private _scale: number; + + /** Текущий целочисленный масштаб. */ + get scale(): number { + return this._scale; + } + + private readonly parentEl: HTMLElement; + private readonly initPromise: Promise; + + constructor(options: RendererOptions) { + this.virtualWidth = options.virtualWidth; + this.virtualHeight = options.virtualHeight; + this._scale = options.scale; + this.parentEl = options.parent; + + this.app = new Application(); + this.initPromise = this.app.init({ + width: options.virtualWidth, + height: options.virtualHeight, + backgroundColor: options.background, + antialias: false, + roundPixels: true, + autoStart: false, + sharedTicker: false, + // Детерминизм рендера: всегда WebGL, WebGPU-бэкенд не выбирается. + preference: 'webgl' + }); + + this.worldRoot = new Container(); + this.lightRoot = new Container(); + this.uiRoot = new Container(); + } + + /** Дождаться инициализации WebGL и добавить канвас на страницу. */ + async setup(): Promise { + await this.initPromise; + this.app.stage.addChild(this.worldRoot, this.lightRoot, this.uiRoot); + + const canvas = this.app.canvas; + canvas.style.imageRendering = 'pixelated'; + this.applySize(); + this.parentEl.appendChild(canvas); + } + + /** + * Сменить целочисленный масштаб (обычно при ресайзе окна). + * Виртуальное разрешение не меняется — меняется только CSS-размер канваса. + */ + resize(scale: number): void { + this._scale = Math.max(1, Math.floor(scale)); + this.applySize(); + } + + private applySize(): void { + const canvas = this.app.canvas; + // Бэкинг-стор — в пикселях устройства: UI и текст рендерятся 1:1 с экраном. + const dpr = window.devicePixelRatio || 1; + const resolution = Math.max(1, Math.round(this._scale * dpr)); + if (this.app.renderer.resolution !== resolution) { + this.app.renderer.resolution = resolution; + this.app.renderer.resize(this.virtualWidth, this.virtualHeight); + setPixelTextResolution(resolution); + } + canvas.style.width = `${this.virtualWidth * this._scale}px`; + canvas.style.height = `${this.virtualHeight * this._scale}px`; + } + + /** Рендер кадра (вызывается игровым циклом). */ + render(): void { + this.app.renderer.render(this.app.stage); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/__tests__/IsoDepthLayer.test.ts b/v1/packages/engine/src/render/__tests__/IsoDepthLayer.test.ts new file mode 100644 index 0000000..7f025e2 --- /dev/null +++ b/v1/packages/engine/src/render/__tests__/IsoDepthLayer.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { Container } from 'pixi.js'; +import { IsoDepthLayer } from '../IsoDepthLayer'; + +describe('IsoDepthLayer', () => { + it('глубина точки = диагональ тайла', () => { + const layer = new IsoDepthLayer(); + const v = new Container(); + layer.add(v, 3, 4); + expect(v.zIndex).toBe(7); + layer.setDepth(v, 1, 1); + expect(v.zIndex).toBe(2); + }); + + it('глубина footprint = диагональ юго-восточного угла', () => { + const layer = new IsoDepthLayer(); + const house = new Container(); + layer.addRect(house, 2, 2, 3, 3); // юго-восточный угол (4, 4) + expect(house.zIndex).toBe(8); + + const north = new Container(); + layer.addRect(north, 2, 0, 1, 1); // герой к северу — под домом + const southwest = new Container(); + layer.addRect(southwest, 2, 5, 1, 1); // у южной стены — стена перекрывает + const southeast = new Container(); + layer.addRect(southeast, 4, 6, 1, 1); // перед южным углом — поверх дома + expect(north.zIndex).toBeLessThan(house.zIndex); + expect(southwest.zIndex).toBeLessThan(house.zIndex); + expect(southeast.zIndex).toBeGreaterThan(house.zIndex); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/render/__tests__/Lighting.test.ts b/v1/packages/engine/src/render/__tests__/Lighting.test.ts new file mode 100644 index 0000000..e66ebc6 --- /dev/null +++ b/v1/packages/engine/src/render/__tests__/Lighting.test.ts @@ -0,0 +1,387 @@ +import { describe, expect, it } from 'vitest'; +import { Container, Texture } from 'pixi.js'; +import { Lighting } from '../Lighting'; +import { GLOW_BASE_PX, HOLE_PROFILE, quantizeHoleAlpha, stepAlphas, type LightFrame } from '../lightSim'; +import type { Renderer } from '../Renderer'; + +function makeLighting(maxLights = 16): Lighting { + // Без renderer: RT не создаётся, glow/hole/tone = Texture.WHITE, bake no-op. + return new Lighting({ width: 480, height: 270, maxLights }); +} + +/** + * Стаб-рендерер: generateTexture отдаёт WHITE (destroy защищён guard'ом), + * render() записывает вызовы bake — dirty-логика тестируется без GPU. + */ +function makeFakeRenderer() { + const bakes: Array<{ container: Container; clear: boolean }> = []; + const renderer = { + app: { + renderer: { + generateTexture: () => Texture.WHITE, + render: (opts: { container: Container; clear: boolean }) => { + bakes.push(opts); + } + } + } + } as unknown as Renderer; + return { renderer, bakes }; +} + +describe('Lighting: ambient', () => { + it('карта тьмы: multiply, белый (день), на всю сцену', () => { + const l = makeLighting(); + const dark = l.children[0]; + expect(dark.blendMode).toBe('multiply'); + expect(dark.tint).toBe(0xffffff); + expect(dark.width).toBe(480); + expect(dark.height).toBe(270); + l.destroy(); + }); + + it('setAmbient без fade применяется мгновенно', () => { + const l = makeLighting(); + l.setAmbient(0x54586a); + expect(l.ambientColor).toBe(0x54586a); + expect(l.children[0].tint).toBe(0x54586a); + l.destroy(); + }); + + it('setAmbient с fade — лерп к целевому цвету', () => { + const l = makeLighting(); + l.setAmbient(0x000000); + l.setAmbient(0xffffff, 1); + l.update(0.5); + const mid = l.children[0].tint; + expect(mid).not.toBe(0x000000); + expect(mid).not.toBe(0xffffff); + l.update(0.5); + expect(l.children[0].tint).toBe(0xffffff); + expect(l.ambientColor).toBe(0xffffff); + l.destroy(); + }); +}); + +describe('Lighting: источники', () => { + it('upsertLight добавляет источник, кадр читается после update', () => { + const l = makeLighting(); + l.upsertLight({ id: 'hearth', x: 100, y: 50, color: 0xf2b45a }); + l.update(1 / 60); + const frames = l.frames(); + expect(frames.length).toBe(1); + expect(frames[0].tint).toBe(0xf2b45a); + expect(frames[0].alpha).toBeGreaterThan(0); + l.destroy(); + }); + + it('лишние источники сверх пула игнорируются', () => { + const l = makeLighting(2); + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); + l.upsertLight({ id: 'b', x: 0, y: 0, color: 0xffffff }); + l.upsertLight({ id: 'c', x: 0, y: 0, color: 0xffffff }); + expect(l.frames().length).toBe(2); + expect(l.hasLight('c')).toBe(false); + l.destroy(); + }); + + it('removeLight возвращает спрайты в пул: число детей стабильно', () => { + const l = makeLighting(); + const toneLayer = l.children[3]; + const before = toneLayer.children.length; + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); + l.upsertLight({ id: 'b', x: 0, y: 0, color: 0xffffff }); + l.removeLight('a'); + l.upsertLight({ id: 'c', x: 0, y: 0, color: 0xffffff }); + expect(toneLayer.children.length).toBe(before); + expect(l.hasLight('a')).toBe(false); + expect(l.hasLight('c')).toBe(true); + l.destroy(); + }); + + it('setLightPos/setLightEnabled отражаются в кадрах', () => { + const l = makeLighting(); + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff, radius: 2 * GLOW_BASE_PX }); + l.setLightPos('a', 123, 45); + l.setLightEnabled('a', false); + l.update(1 / 60); + const f: LightFrame = l.frames()[0]; + expect(f.x).toBe(123); + expect(f.y).toBe(45); + expect(f.alpha).toBe(0); + l.destroy(); + }); + + it('upsertLight обновляет параметры существующего', () => { + const l = makeLighting(); + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff, intensity: 0.5 }); + l.upsertLight({ id: 'a', x: 7, y: 8, color: 0x112233, intensity: 1, flicker: 0 }); + l.update(1 / 60); + const f = l.frames()[0]; + expect(f.x).toBe(7); + expect(f.tint).toBe(0x112233); + l.destroy(); + }); + + it('мерцание меняет alpha между апдейтами', () => { + const l = makeLighting(); + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff, flicker: 0.5 }); + l.update(0); + const a1 = l.frames()[0].alpha; + l.update(0.37); + const a2 = l.frames()[0].alpha; + expect(a1).not.toBe(a2); + l.destroy(); + }); +}); + +describe('Lighting: импульсы', () => { + it('pulseLight появляется в кадрах, модулируется огибающей и снимается', () => { + const l = makeLighting(2); + const id = l.pulseLight({ x: 10, y: 20, color: 0xf2b45a, intensity: 0.5, spec: { attack: 0.05, decay: 0.1 } }); + expect(id).toMatch(/^pulse#/); + l.update(0.01); + let f = l.frames().find((fr) => fr.id === id); + expect(f).toBeTruthy(); + expect(f!.tint).toBe(0xf2b45a); + expect(f!.alpha).toBeCloseTo(0.5 * 0.2, 5); // elapsed 0.01 из attack 0.05 + l.update(0.04); // elapsed 0.05 — пик огибающей + f = l.frames().find((fr) => fr.id === id); + expect(f!.alpha).toBeCloseTo(0.5, 5); + l.update(0.1); // elapsed 0.15 = total — снят + expect(l.frames().length).toBe(0); + expect(l.hasLight(id)).toBe(false); + expect(l.children.length).toBe(5); // карта тьмы+виньетка+пятна+тон+вспышка + l.destroy(); + }); + + it('pulseLight при исчерпании пула не рисуется (пустой id)', () => { + const l = makeLighting(1); + l.upsertLight({ id: 'hearth', x: 0, y: 0, color: 0xffffff }); + const id = l.pulseLight({ x: 0, y: 0, color: 0xffffff, spec: { attack: 0.05, decay: 0.1 } }); + expect(id).toBe(''); + expect(l.hasLight('pulse#1')).toBe(false); + expect(l.frames().length).toBe(1); + l.destroy(); + }); + + it('pulseAmbient: alpha по огибающей, новая вспышка побеждает', () => { + const l = makeLighting(); + l.pulseAmbient({ color: 0xd99a32, peak: 0.2, spec: { attack: 0.1, decay: 0.1 } }); + l.update(0.05); + expect(l.pulseAmbientAlpha).toBeCloseTo(0.1, 5); // 0.2 * 0.5 + l.pulseAmbient({ color: 0xb0453f, peak: 0.3, spec: { attack: 0, decay: 0.2 } }); + l.update(0.1); + expect(l.pulseAmbientAlpha).toBeCloseTo(0.15, 5); // 0.3 * 0.5 + l.update(0.2); // конец второй вспышки + expect(l.pulseAmbientAlpha).toBe(0); + l.destroy(); + }); +}); + +describe('Lighting: виньетка', () => { + it('setVignette мгновенно; значение клампится в 0..1', () => { + const l = makeLighting(); + l.setVignette(1.5); + expect(l.vignetteLevel).toBe(1); + expect(l.children[1].alpha).toBe(1); // спрайт виньетки — второй ребёнок + l.setVignette(-0.5); + expect(l.vignetteLevel).toBe(0); + expect(l.children[1].alpha).toBe(0); + l.destroy(); + }); + + it('setVignette с fade — лерп к целевой интенсивности', () => { + const l = makeLighting(); + l.setVignette(1); + l.setVignette(0, 1); + expect(l.vignetteLevel).toBe(0); + l.update(0.5); + const mid = l.children[1].alpha; + expect(mid).toBeGreaterThan(0); + expect(mid).toBeLessThan(1); + l.update(0.5); + expect(l.children[1].alpha).toBe(0); + l.destroy(); + }); +}); + +describe('Lighting: тёмные пятна', () => { + it('upsert/remove/has; спрайт multiply с нужным масштабом и альфой', () => { + const l = makeLighting(2); + l.upsertDarkSpot({ id: 'hazard@3,4', x: 10, y: 10, radius: 51, alpha: 0.22 }); + expect(l.hasDarkSpot('hazard@3,4')).toBe(true); + const layer = l.children[2]; // darkLayer — третий ребёнок + expect(layer.children.filter((c) => c.visible).length).toBe(1); + const spot = layer.children.find((c) => c.visible)!; + expect(spot.blendMode).toBe('multiply'); + expect(spot.alpha).toBeCloseTo(0.22, 5); + expect(spot.scale.x).toBeCloseTo(51 / GLOW_BASE_PX, 5); + l.upsertDarkSpot({ id: 'hazard@3,4', x: 12, y: 14, radius: 51, alpha: 0.3 }); + expect(layer.children.filter((c) => c.visible).length).toBe(1); // обновление без нового спрайта + expect(spot.x).toBe(12); + expect(spot.y).toBe(14); + l.removeDarkSpot('hazard@3,4'); + expect(l.hasDarkSpot('hazard@3,4')).toBe(false); + expect(l.children.length).toBe(5); // слои на месте, спрайт в пуле + l.destroy(); + }); + + it('пятна сверх пула игнорируются', () => { + const l = new Lighting({ width: 480, height: 270, maxDarkSpots: 2 }); + l.upsertDarkSpot({ id: 'a', x: 0, y: 0, radius: 32, alpha: 0.2 }); + l.upsertDarkSpot({ id: 'b', x: 0, y: 0, radius: 32, alpha: 0.2 }); + l.upsertDarkSpot({ id: 'c', x: 0, y: 0, radius: 32, alpha: 0.2 }); + expect(l.hasDarkSpot('a')).toBe(true); + expect(l.hasDarkSpot('b')).toBe(true); + expect(l.hasDarkSpot('c')).toBe(false); + l.destroy(); + }); +}); + +describe('Lighting: bake карты тьмы (fake-renderer)', () => { + it('bake-контейнер не в дереве сцены; erase-спрайты вне Lighting', () => { + const { renderer } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff }); + l.setAmbient(0x3e4658); + l.update(1 / 60); + // дети: карта тьмы, виньетка, пятна, тон, вспышка — bake среди них нет + expect(l.children.length).toBe(5); + for (const child of l.children) { + expect(child.blendMode).not.toBe('erase'); + } + l.destroy(); + }); + + it('первый update с тьмой — один bake; без изменений — без повторных', () => { + const { renderer, bakes } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.setAmbient(0x3e4658); + l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff }); + l.update(1 / 60); + expect(bakes.length).toBe(1); + l.update(1 / 60); + l.update(1 / 60); + expect(bakes.length).toBe(1); // подпись не изменилась — bake пропущен + l.destroy(); + }); + + it('квантованные значения в прорези: движение на суб-пиксель — без bake', () => { + const { renderer, bakes } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.setAmbient(0x3e4658); + l.upsertLight({ id: 'a', x: 40.7, y: 30.2, color: 0xffffff, flicker: 0 }); + l.update(1 / 60); + const first = bakes.length; + l.setLightPos('a', 41.3, 30.4); // округление то же (41, 30) + l.update(1 / 60); + expect(bakes.length).toBe(first); // округление погасило изменение + l.setLightPos('a', 44, 30); + l.update(1 / 60); + expect(bakes.length).toBe(first + 1); // сдвиг на целый px — bake + l.destroy(); + }); + + it('день (белый ambient) — fast-path: bake не вызывается', () => { + const { renderer, bakes } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff }); + l.update(1 / 60); + expect(bakes.length).toBe(0); // день — карта тьмы выключена + l.setAmbient(0x3e4658); + l.update(1 / 60); + expect(bakes.length).toBeGreaterThan(0); // тьма включилась — bake + l.setAmbient(0xffffff); + l.update(1 / 60); + const n = bakes.length; + l.upsertLight({ id: 'b', x: 10, y: 10, color: 0xffffff }); + l.update(1 / 60); + expect(bakes.length).toBe(n); // снова день — bake не нужен + l.destroy(); + }); + + it('тьма включилась после дня — форс-bake даже при той же подписи', () => { + const { renderer, bakes } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.setAmbient(0x3e4658); + l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff, flicker: 0 }); + l.update(1 / 60); + expect(bakes.length).toBe(1); + l.setAmbient(0xffffff); + l.update(1 / 60); // день — без bake + l.setAmbient(0x3e4658); + l.update(1 / 60); // та же подпись, но тьма включилась заново + expect(bakes.length).toBe(2); + l.destroy(); + }); + + it('removeLight меняет подпись — перерисовка', () => { + const { renderer, bakes } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.setAmbient(0x3e4658); + l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff, flicker: 0 }); + l.update(1 / 60); + const first = bakes.length; + l.removeLight('a'); + l.update(1 / 60); + expect(bakes.length).toBe(first + 1); + l.destroy(); + }); + + it('без RT (fallback) update и destroy не бросают', () => { + const l = makeLighting(); + l.setAmbient(0x3e4658); + l.upsertLight({ id: 'a', x: 40, y: 30, color: 0xffffff }); + expect(() => l.update(1 / 60)).not.toThrow(); + expect(l.frames().length).toBe(1); + l.destroy(); + expect(() => l.destroy()).not.toThrow(); + }); +}); + +describe('Lighting: профиль прорези (ступени)', () => { + it('paint-краски воспроизводят накопленный профиль при рисовании снаружи внутрь', () => { + const rings = stepAlphas(HOLE_PROFILE); + expect(rings.length).toBe(HOLE_PROFILE.length); + expect(rings[0].radius).toBe(GLOW_BASE_PX); // внешнее кольцо = полный радиус + // Симуляция source-over: красим снаружи внутрь, накопление к центру. + // rings[i] — кольцо k=n-i, накопленный профиль — HOLE_PROFILE[n-1-i]. + let acc = 0; + for (let i = 0; i < rings.length; i++) { + acc = 1 - (1 - rings[i].paintAlpha) * (1 - acc); + expect(acc).toBeCloseTo(HOLE_PROFILE[rings.length - 1 - i], 5); + } + // Центр прорези: тьма снята полностью. + expect(rings[rings.length - 1].paintAlpha).toBe(1); + }); + + it('quantizeHoleAlpha — сетка 1/8 с клампом', () => { + expect(quantizeHoleAlpha(0)).toBe(0); + expect(quantizeHoleAlpha(1)).toBe(1); + expect(quantizeHoleAlpha(0.1)).toBe(0.125); + expect(quantizeHoleAlpha(0.13)).toBe(0.125); + expect(quantizeHoleAlpha(2)).toBe(1); + expect(quantizeHoleAlpha(-1)).toBe(0); + expect(quantizeHoleAlpha(0.0625)).toBe(0.125); // половина шага — вверх + expect(quantizeHoleAlpha(0.06)).toBe(0); + }); +}); + +describe('Lighting: destroy', () => { + it('не бросает и повторный вызов безопасен', () => { + const l = makeLighting(); + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); + l.setAmbient(0x334455, 0.5); + l.destroy(); + expect(() => l.destroy()).not.toThrow(); + }); + + it('с fake-renderer (RT + bake) тоже безопасен', () => { + const { renderer } = makeFakeRenderer(); + const l = new Lighting({ width: 480, height: 270, renderer, maxLights: 2 }); + l.upsertLight({ id: 'a', x: 0, y: 0, color: 0xffffff }); + l.setAmbient(0x3e4658); + l.update(1 / 60); + expect(() => l.destroy()).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/render/__tests__/camera.test.ts b/v1/packages/engine/src/render/__tests__/camera.test.ts new file mode 100644 index 0000000..82786d9 --- /dev/null +++ b/v1/packages/engine/src/render/__tests__/camera.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest'; +import { Container } from 'pixi.js'; +import { Camera } from '../Camera'; +import { screenToWorld, worldToScreen } from '../../math/iso'; + +/** Смещение цели от центра камеры в экранных пикселях: мировая точка. */ +function targetAtScreenOffset(cam: Camera, dxPx: number, dyPx: number): { x: number; y: number } { + const c = worldToScreen(cam.x, cam.y); + return screenToWorld(c.x + dxPx, c.y + dyPx); +} + +describe('Camera dead zone', () => { + it('без dead zone — камера центрируется на цели', () => { + const cam = new Camera(480, 270); + cam.follow(3, 5); + expect(cam.x).toBe(3); + expect(cam.y).toBe(5); + }); + + it('цель внутри окна — камера стоит', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + cam.deadZonePx = { width: 160, height: 100 }; + // Цель на 60 px правее и 30 px ниже центра экрана — внутри окна. + const t = targetAtScreenOffset(cam, 60, 30); + cam.follow(t.x, t.y); + expect(cam.x).toBe(3); + expect(cam.y).toBe(5); + }); + + it('цель толкает камеру ровно на величину выхода', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + cam.deadZonePx = { width: 160, height: 100 }; + // Выход за окно: 20 px вправо и 20 px вверх. + const t = targetAtScreenOffset(cam, 100, -70); + cam.follow(t.x, t.y); + // Центр проекции сместился ровно на (20, −20) экранных пикселей. + const c0 = worldToScreen(3, 5); + const c = worldToScreen(cam.x, cam.y); + expect(c.x - c0.x).toBeCloseTo(20, 6); + expect(c.y - c0.y).toBeCloseTo(-20, 6); + }); + + it('snap ставит камеру на точку, минуя окно', () => { + const cam = new Camera(480, 270); + cam.deadZonePx = { width: 160, height: 100 }; + cam.snap(5, 2); + expect(cam.x).toBe(5); + expect(cam.y).toBe(2); + // Окно сохранено: цель чуть правее центра — камера не сдвигается. + const t = targetAtScreenOffset(cam, 50, 0); + cam.follow(t.x, t.y); + expect(cam.x).toBe(5); + }); +}); + +describe('Camera bounds (мировые юниты)', () => { + it('границы мира держат камеру прижатой', () => { + const cam = new Camera(480, 270); + cam.bounds = { x: 0, y: 0, width: 64, height: 64 }; + cam.snap(-1000, 500); // за границей по обеим осям + // bbox карты: x = (0 - 64)*16, y = 0; клэмп по проекции центра. + const c = worldToScreen(cam.x, cam.y); + expect(c.x).toBeCloseTo(-1024 + 240, 6); + expect(c.y).toBeCloseTo(135, 6); + }); + + it('apply ставит worldRoot по округлённой проекции', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + const view = new Container(); + cam.apply(view); + const applied = worldToScreen(cam.x, cam.y); + expect(view.x).toBe(240 - Math.round(applied.x)); + expect(view.y).toBe(135 - Math.round(applied.y)); + }); + + it('toScreen согласована с apply: точка мира проецируется в то же место', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + const view = new Container(); + cam.apply(view); + // Точка мира в worldRoot: worldToScreen(p) + позиция worldRoot. + const wx = 3.5, wy = 5.5; + const p = worldToScreen(wx, wy); + expect(view.x + p.x).toBeCloseTo(cam.toScreen(wx, wy).x, 6); + expect(view.y + p.y).toBeCloseTo(cam.toScreen(wx, wy).y, 6); + }); + + it('toScreen учитывает тряску так же, как apply', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + cam.addShake(8, 1); // тряска активна, offset фиксирован до update + const view = new Container(); + cam.apply(view); + const p = worldToScreen(3.5, 5.5); + expect(view.x + p.x).toBeCloseTo(cam.toScreen(3.5, 5.5).x, 6); + expect(view.y + p.y).toBeCloseTo(cam.toScreen(3.5, 5.5).y, 6); + }); +}); + +describe('Camera zoom', () => { + it('apply ставит scale и позицию при z=2, точка мира проецируется на место', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + cam.setZoom(2); + const view = new Container(); + cam.apply(view); + expect(view.scale.x).toBe(2); + // Точка мира в worldRoot: p*zoom + позиция worldRoot (формула toScreen). + const wx = 3.5, wy = 5.5; + const p = worldToScreen(wx, wy); + expect(view.x + p.x * 2).toBeCloseTo(cam.toScreen(wx, wy).x, 6); + expect(view.y + p.y * 2).toBeCloseTo(cam.toScreen(wx, wy).y, 6); + // Центр взгляда — в центре экрана. + const c = worldToScreen(3, 5); + expect(view.x + c.x * 2).toBeCloseTo(240, 6); + expect(view.y + c.y * 2).toBeCloseTo(135, 6); + }); + + it('тряска не масштабируется зумом', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + cam.setZoom(2); + cam.addShake(4, 1); // offset фиксирован до update + const view = new Container(); + cam.apply(view); + // Та же камера без тряски — разница позиций и есть offset. + const calm = new Camera(480, 270); + calm.snap(3, 5); + calm.setZoom(2); + const calmView = new Container(); + calm.apply(calmView); + // Сдвиг от тряски одинаков в apply и toScreen (добавлен один раз). + expect(view.x - calmView.x).toBeCloseTo(cam.toScreen(3.5, 5.5).x - calm.toScreen(3.5, 5.5).x, 6); + expect(view.y - calmView.y).toBeCloseTo(cam.toScreen(3.5, 5.5).y - calm.toScreen(3.5, 5.5).y, 6); + }); + + it('clamp при z=2 держит прижатие к границе мира', () => { + const cam = new Camera(480, 270); + cam.bounds = { x: 0, y: 0, width: 64, height: 64 }; + cam.setZoom(2); + cam.snap(-1000, 500); + // bbox карты ×2: x = (0-64)*16*2 = -2048; центр прижат: -2048 + 480/2. + const c = worldToScreen(cam.x, cam.y); + expect(c.x * 2).toBeCloseTo(-2048 + 240, 6); + expect(c.y * 2).toBeCloseTo(135, 6); + }); + + it('zoomTo анимируется и квантуется, финал — ровно целевой зум', () => { + const cam = new Camera(480, 270); + cam.zoomTo(2, 1); + cam.update(0.5); + const mid = cam.zoomLevel; + expect(mid).toBeGreaterThan(1); + expect(mid).toBeLessThan(2); + expect(mid * 4).toBeCloseTo(Math.round(mid * 4), 6); // кратно 0.25 + cam.update(0.5); + expect(cam.zoomLevel).toBe(2); + expect(cam.zoomLevel * 4).toBeCloseTo(Math.round(cam.zoomLevel * 4), 6); + }); + + it('zoomTo с seconds=0 — мгновенно; setZoom сбрасывает анимацию', () => { + const cam = new Camera(480, 270); + cam.zoomTo(2, 0); + expect(cam.zoomLevel).toBe(2); + cam.zoomTo(1, 10); + cam.setZoom(1.5); + cam.update(1); + expect(cam.zoomLevel).toBe(1.5); // анимация отменена + }); + + it('dead zone при зуме: выход цели считается в экранных пикселях', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + cam.setZoom(2); + cam.deadZonePx = { width: 160, height: 100 }; + // Экранный разнос цели 60px при зуме 2 — внутри окна: камера стоит. + const c = worldToScreen(3, 5); + const t = screenToWorld(c.x + 30, c.y + 15); // 30 мировых px = 60 экранных + cam.follow(t.x, t.y); + expect(cam.x).toBe(3); + expect(cam.y).toBe(5); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/render/__tests__/lightSim.test.ts b/v1/packages/engine/src/render/__tests__/lightSim.test.ts new file mode 100644 index 0000000..1f60853 --- /dev/null +++ b/v1/packages/engine/src/render/__tests__/lightSim.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_DAY_NIGHT, + DEFAULT_DUSK_COLOR, + DUSK_PEAK, + GLOW_BASE_PX, + dayNightAmbient, + dayNightFactor, + dimColor, + flickerFactor, + lightFrame, + lerpAmbient, + pulseEnvelope, + pulseTotal +} from '../lightSim'; + +describe('flickerFactor', () => { + it('детерминирован: одинаковые (time, seed) — одинаковый результат', () => { + const a = flickerFactor(1.234, 0.37, 0.3, 0.9); + const b = flickerFactor(1.234, 0.37, 0.3, 0.9); + expect(a).toBe(b); + }); + + it('разные seed дают разные значения', () => { + const a = flickerFactor(0.5, 0.1, 0.3, 0.9); + const b = flickerFactor(0.5, 0.9, 0.3, 0.9); + expect(a).not.toBe(b); + }); + + it('в диапазоне [1-amount, 1] на сетке времени', () => { + for (let i = 0; i < 180; i++) { + const f = flickerFactor(i / 60, 0.25, 0.35, 0.9); + expect(f).toBeGreaterThanOrEqual(1 - 0.35 - 1e-9); + expect(f).toBeLessThanOrEqual(1 + 1e-9); + } + }); + + it('amount = 0 — ровный свет', () => { + for (let i = 0; i < 60; i++) { + expect(flickerFactor(i / 60, 0.7, 0, 0.9)).toBe(1); + } + }); +}); + +describe('lightFrame', () => { + it('дефолты: intensity 1, radius 2*GLOW_BASE_PX', () => { + const f = lightFrame({ id: 'l', x: 10, y: 20, color: 0xf2b45a }, 0); + expect(f.tint).toBe(0xf2b45a); + expect(f.scale).toBe(2); + expect(f.alpha).toBeGreaterThan(0); + expect(f.alpha).toBeLessThanOrEqual(1); + expect(f.x).toBe(10); + expect(f.y).toBe(20); + }); + + it('alpha = intensity * flickerFactor, scale = radius / GLOW_BASE_PX', () => { + const def = { id: 'l', x: 0, y: 0, color: 0xffaa00, intensity: 1.5, radius: 64, flicker: 0, seed: 0 }; + const f = lightFrame(def, 1); + expect(f.alpha).toBeCloseTo(1.5, 10); + expect(f.scale).toBe(64 / GLOW_BASE_PX); + // alpha клампится в адаптере, но чистый кадр может превышать 1 + expect(f.alpha).toBe(1.5); + }); + + it('выключенный источник — alpha 0', () => { + const f = lightFrame({ id: 'l', x: 0, y: 0, color: 0xffffff, enabled: false }, 0); + expect(f.alpha).toBe(0); + }); + + it('мерцание меняет alpha между шагами', () => { + const def = { id: 'l', x: 0, y: 0, color: 0xffffff, intensity: 1, flicker: 0.5 }; + const a1 = lightFrame(def, 0).alpha; + const a2 = lightFrame(def, 0.37).alpha; + expect(a1).not.toBe(a2); + }); +}); + +describe('pulseEnvelope / pulseTotal', () => { + it('трапеция: рост, плато, спад, нули за пределами', () => { + const spec = { attack: 0.1, hold: 0.2, decay: 0.3 }; + expect(pulseEnvelope(0, spec)).toBe(0); + expect(pulseEnvelope(0.05, spec)).toBeCloseTo(0.5, 10); + expect(pulseEnvelope(0.15, spec)).toBe(1); + expect(pulseEnvelope(0.25, spec)).toBe(1); + expect(pulseEnvelope(0.45, spec)).toBeCloseTo(0.5, 10); + expect(pulseEnvelope(0.7, spec)).toBe(0); + expect(pulseTotal(spec)).toBeCloseTo(0.6, 10); + }); + + it('без hold — пик сразу после attack', () => { + const spec = { attack: 0.1, decay: 0.2 }; + expect(pulseEnvelope(0.1, spec)).toBe(1); + expect(pulseEnvelope(0.2, spec)).toBeCloseTo(0.5, 10); + expect(pulseTotal(spec)).toBeCloseTo(0.3, 10); + }); + + it('attack = 0 — мгновенный пик', () => { + const spec = { attack: 0, decay: 0.2 }; + expect(pulseEnvelope(0, spec)).toBe(0); + expect(pulseEnvelope(0.1, spec)).toBeCloseTo(0.5, 10); + expect(pulseEnvelope(0.2, spec)).toBe(0); + expect(pulseTotal(spec)).toBeCloseTo(0.2, 10); + }); + + it('отрицательное время и значения вне диапазона — 0', () => { + const spec = { attack: 0.1, decay: 0.2 }; + expect(pulseEnvelope(-0.5, spec)).toBe(0); + expect(pulseEnvelope(10, spec)).toBe(0); + }); +}); + +describe('lerpAmbient', () => { + it('краи и середина по каналам', () => { + expect(lerpAmbient(0x000000, 0xffffff, 0)).toBe(0x000000); + expect(lerpAmbient(0x000000, 0xffffff, 1)).toBe(0xffffff); + expect(lerpAmbient(0xff0000, 0x00ff00, 0.5)).toBe(0x808000); // round(127.5) = 128 + }); +}); + +describe('dayNightAmbient (золотой час)', () => { + it('краи: k=0 — день, k=1 — ночь', () => { + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, 0)).toBe(0xf7f4ea); + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, 1)).toBe(0x3e4658); + }); + + it('на пике (k = DUSK_PEAK) ambient равен закатному тону', () => { + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK)).toBe(DEFAULT_DUSK_COLOR); + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK, 0xffcc88)).toBe(0xffcc88); // кастомный тон + }); + + it('до пика — лерп день→закат, после — закат→ночь', () => { + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK / 2)).toBe( + lerpAmbient(0xf7f4ea, DEFAULT_DUSK_COLOR, 0.5) + ); + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK + (1 - DUSK_PEAK) / 2)).toBe( + lerpAmbient(DEFAULT_DUSK_COLOR, 0x3e4658, 0.5) + ); + }); + + it('непрерывен и монотонен по k (по каждому каналу)', () => { + let prevR = 256; + for (let k = 0; k <= 1.0001; k += 0.01) { + const c = dayNightAmbient(0xf7f4ea, 0x3e4658, k); + const r = (c >> 16) & 0xff; + expect(r).toBeLessThanOrEqual(prevR); // день светлее ночи по R + prevR = r; + } + // стык на пике: значения слева и справа совпадают + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK - 0.001)).toBeCloseTo( + dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK + 0.001), + 1 + ); + }); +}); + +describe('dimColor', () => { + it('краи: полная яркость и ноль', () => { + expect(dimColor(0x54586a, 1)).toBe(0x54586a); + expect(dimColor(0x54586a, 0)).toBe(0x000000); + }); + + it('монотонность по яркости', () => { + let prev = 0; + for (let k = 0; k <= 1.001; k += 0.1) { + const c = dimColor(0x808080, k); + expect(c).toBeGreaterThanOrEqual(prev); + prev = c; + } + }); +}); +describe('dayNightFactor', () => { + it('день — 0', () => { + expect(dayNightFactor(8)).toBe(0); + expect(dayNightFactor(12)).toBe(0); + expect(dayNightFactor(17.9)).toBe(0); + }); + + it('закат 18–20 — линейный рост', () => { + expect(dayNightFactor(18)).toBe(0); + expect(dayNightFactor(19)).toBeCloseTo(0.5, 10); + expect(dayNightFactor(20)).toBe(1); + }); + + it('ночь — 1', () => { + expect(dayNightFactor(23)).toBe(1); + expect(dayNightFactor(0)).toBe(1); + expect(dayNightFactor(5.9)).toBe(1); + }); + + it('рассвет 6–8 — линейный спад', () => { + expect(dayNightFactor(6)).toBe(1); + expect(dayNightFactor(7)).toBeCloseTo(0.5, 10); + expect(dayNightFactor(8)).toBe(0); + }); + + it('стык 24/0 непрерывен, отрицательные и большие часы ок', () => { + expect(dayNightFactor(24)).toBe(1); + expect(dayNightFactor(25)).toBe(dayNightFactor(1)); + expect(dayNightFactor(-4)).toBe(1); + expect(dayNightFactor(-16)).toBe(0); // -16 ≡ 8 + }); + + it('монотонность на переходах', () => { + for (let t = 18; t < 20; t += 0.1) { + expect(dayNightFactor(t + 0.1)).toBeGreaterThanOrEqual(dayNightFactor(t)); + } + for (let t = 6; t < 8; t += 0.1) { + expect(dayNightFactor(t + 0.1)).toBeLessThanOrEqual(dayNightFactor(t)); + } + }); + + it('кастомный spec', () => { + const spec = { dawn: 0, day: 1, dusk: 10, night: 12 }; + expect(dayNightFactor(5, spec)).toBe(0); + expect(dayNightFactor(11, spec)).toBeCloseTo(0.5, 10); + expect(dayNightFactor(12, spec)).toBe(1); + expect(DEFAULT_DAY_NIGHT.dusk).toBe(18); // дефолт не тронут + }); +}); diff --git a/v1/packages/engine/src/render/__tests__/particleSim.test.ts b/v1/packages/engine/src/render/__tests__/particleSim.test.ts new file mode 100644 index 0000000..8098d9d --- /dev/null +++ b/v1/packages/engine/src/render/__tests__/particleSim.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; +import { stepParticle, sampleSpawn, sampleBurst, mergeEmitterOptions, type ParticleState, type ParticleVisual } from '../particleSim'; +import { createRng } from '../../math/rng'; +import type { EmitterOptions } from '../Particles'; + +const opts: EmitterOptions = { + color: 0xffffff, + rate: 10, + lifetime: [1, 2], + velocity: { x: [-10, 10], y: [-10, 10] } +}; + +function state(over: Partial = {}): ParticleState { + return { x: 0, y: 0, vx: 0, vy: 0, age: 0, lifetime: 2, rot: 0, spin: 0, scale: 1, tint: 0xffffff, k: 0, ...over }; +} + +describe('stepParticle', () => { + it('позиция после шагов (полу-неявный Эйлер)', () => { + const p = state({ vx: 10, vy: -5, lifetime: 10 }); + stepParticle(p, 0.5, {}); + expect(p.x).toBeCloseTo(5); + expect(p.y).toBeCloseTo(-2.5); + expect(p.age).toBeCloseTo(0.5); + }); + + it('ускорение влияет на скорость', () => { + const p = state({ lifetime: 10 }); + stepParticle(p, 1, { acceleration: { y: 100 } }); + expect(p.vy).toBeCloseTo(100); + expect(p.y).toBeCloseTo(100); // скорость применилась в том же шаге + }); + + it('fadeOut: true — вся жизнь; число — хвост в сек; false — без затухания', () => { + const a = state(); + expect(stepParticle(a, 1, { fadeOut: true }).alpha).toBeCloseTo(0.5); // 1 из 2 сек + const b = state({ lifetime: 2 }); + expect(stepParticle(b, 1, { fadeOut: 0.5 }).alpha).toBe(1); // хвост ещё далеко + const c = state({ age: 0.75 }); + expect(stepParticle(c, 1, { fadeOut: 0.5 }).alpha).toBeCloseTo(0.5); // после шага age=1.75, хвост 0.5 + const d = state(); + expect(stepParticle(d, 1, { fadeOut: false }).alpha).toBe(1); + }); + + it('fadeIn наращивает альфу в начале', () => { + const p = state(); + expect(stepParticle(p, 0.25, { fadeIn: 1 }).alpha).toBeCloseTo(0.25); + }); + + it('drag тормозит', () => { + const p = state({ vx: 100 }); + stepParticle(p, 0.5, { drag: 2 }); // v *= 1 - 1.0 + expect(p.vx).toBeCloseTo(0); + }); + + it('spin вращает, scaleOverLife меняет масштаб', () => { + const a = state({ spin: Math.PI }); + expect(stepParticle(a, 0.5, {}).rotation).toBeCloseTo(Math.PI / 2); + const b = state({ scale: 4 }); + expect(stepParticle(b, 1, { scaleOverLife: [1, 2] }).scale).toBeCloseTo(6); // 4 × 1.5 + const c = state({ scale: 4 }); + expect(stepParticle(c, 1, { scaleOverLife: 'shrink' }).scale).toBeCloseTo(2); // 4 × 0.5 + }); + + it('colorOverLife лерпит цвет по жизни', () => { + const p = state({ lifetime: 2 }); + const v = stepParticle(p, 1, { colorOverLife: [0xff0000, 0x0000ff] }); + expect(v.tint).toBe(0x800080); + }); + + it('wobble дрейфует по синусу с фазой из k', () => { + const p = state({ k: 0, lifetime: 10 }); + const before = p.x; + stepParticle(p, 0.1, { wobble: 10 }); // sin(0.3) > 0 → дрейф вправо + expect(p.x).toBeGreaterThan(before); + }); + + it('out — визуал заполняется без аллокации (горячий путь)', () => { + const p = state({ lifetime: 2, spin: 1 }); + const out: ParticleVisual = { alpha: 0, scale: 0, rotation: 0, tint: 0 }; + const v = stepParticle(p, 1, { colorOverLife: [0xff0000, 0x0000ff] }, out); + expect(v).toBe(out); // тот же объект, не новый + expect(v.tint).toBe(0x800080); + expect(v.rotation).toBeCloseTo(1); + }); +}); + +describe('sampleSpawn', () => { + it('учитывает spawnArea и диапазоны', () => { + const rng = createRng(1); + for (let i = 0; i < 50; i++) { + const s = sampleSpawn({ ...opts, spawnArea: { width: 20, height: 10 } }, rng); + expect(s.x).toBeGreaterThanOrEqual(-10); + expect(s.x).toBeLessThanOrEqual(10); + expect(s.y).toBeGreaterThanOrEqual(-5); + expect(s.y).toBeLessThanOrEqual(5); + expect(s.vx).toBeGreaterThanOrEqual(-10); + expect(s.vx).toBeLessThanOrEqual(10); + expect(s.lifetime).toBeGreaterThanOrEqual(1); + expect(s.lifetime).toBeLessThanOrEqual(2); + expect(s.age).toBe(0); + } + }); + + it('без области — старт из точки', () => { + const s = sampleSpawn(opts, createRng(1)); + expect(s.x).toBe(0); + expect(s.y).toBe(0); + }); + + it('colors выбираются из списка, spin/rotation из диапазонов', () => { + const rng = createRng(3); + const o: EmitterOptions = { + ...opts, + colors: [0xff0000, 0x00ff00], + rotation: [0, 1], + spin: [-2, 2] + }; + for (let i = 0; i < 30; i++) { + const s = sampleSpawn(o, rng); + expect([0xff0000, 0x00ff00]).toContain(s.tint); + expect(s.rot).toBeGreaterThanOrEqual(0); + expect(s.rot).toBeLessThanOrEqual(1); + expect(s.spin).toBeGreaterThanOrEqual(-2); + expect(s.spin).toBeLessThanOrEqual(2); + } + }); +}); + +describe('sampleBurst', () => { + it('нужное количество, скорость в диапазоне', () => { + const rng = createRng(5); + const parts = sampleBurst(12, [30, 80], [0.2, 0.5], rng, { color: 0x52525c }); + expect(parts).toHaveLength(12); + for (const p of parts) { + const v = Math.hypot(p.vx, p.vy); + expect(v).toBeGreaterThanOrEqual(30 - 1e-9); + expect(v).toBeLessThanOrEqual(80 + 1e-9); + expect(p.lifetime).toBeGreaterThanOrEqual(0.2); + expect(p.lifetime).toBeLessThanOrEqual(0.5); + expect(p.tint).toBe(0x52525c); + } + }); + + it('детерминирован по seed', () => { + const a = sampleBurst(5, [10, 20], [0.1, 0.2], createRng(99)); + const b = sampleBurst(5, [10, 20], [0.1, 0.2], createRng(99)); + expect(a).toEqual(b); + }); +}); + +describe('mergeEmitterOptions', () => { + it('слияет чисто: база и переопределение не мутируются', () => { + const base: EmitterOptions = { ...opts }; + const over = { color: 0xff0000, lifetime: [0.1, 0.2] as [number, number] }; + const merged = mergeEmitterOptions(base, over); + expect(merged.color).toBe(0xff0000); + expect(merged.lifetime).toEqual([0.1, 0.2]); + expect(base.color).toBe(0xffffff); // база цела + expect(base.lifetime).toEqual([1, 2]); + expect(merged.velocity).toBe(base.velocity); // непереопределённые — те же ссылки + const back = mergeEmitterOptions(base); + expect(back).not.toBe(base); + expect(back).toEqual(base); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/render/__tests__/scale.test.ts b/v1/packages/engine/src/render/__tests__/scale.test.ts new file mode 100644 index 0000000..867fda6 --- /dev/null +++ b/v1/packages/engine/src/render/__tests__/scale.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { computeScale } from '../scale'; + +describe('computeScale', () => { + it('целый множитель: 480×270 в окно 960×540 — x2', () => { + expect(computeScale(480, 270, 960, 540)).toBe(2); + }); + + it('берёт минимум по осям (вписать целиком)', () => { + expect(computeScale(480, 270, 960, 810)).toBe(2); // по высоте больше — режет ширина + expect(computeScale(480, 270, 1440, 540)).toBe(2); // по ширине больше — режет высота + }); + + it('дробное вмещение округляется вниз до целого (pixel-perfect)', () => { + expect(computeScale(480, 270, 1000, 600)).toBe(2); + expect(computeScale(480, 270, 1200, 675)).toBe(2); + expect(computeScale(480, 270, 1440, 810)).toBe(3); + }); + + it('маленькое окно — минимум 1 (картинка не исчезает)', () => { + expect(computeScale(480, 270, 100, 100)).toBe(1); + expect(computeScale(480, 270, 0, 0)).toBe(1); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/render/__tests__/shadowSim.test.ts b/v1/packages/engine/src/render/__tests__/shadowSim.test.ts new file mode 100644 index 0000000..ea78658 --- /dev/null +++ b/v1/packages/engine/src/render/__tests__/shadowSim.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { blobPlacement, sunShadow } from '../shadowSim'; + +describe('sunShadow', () => { + it('ночью тени нет', () => { + expect(sunShadow(2)).toBeNull(); + expect(sunShadow(23)).toBeNull(); + expect(sunShadow(5.5)).toBeNull(); + }); + + it('на границах дня альфа нулевая, в разгар дня максимальная', () => { + expect(sunShadow(6)!.alpha).toBeCloseTo(0); + expect(sunShadow(19.9)!.alpha).toBeCloseTo(0); // к закату гаснет (в 20 — null) + const noon = sunShadow(13)!.alpha; + expect(noon).toBeGreaterThan(0.3); + const morning = sunShadow(7)!.alpha; // ещё в разгоне (dawn..day = 6..8) + expect(morning).toBeGreaterThan(0); + expect(morning).toBeLessThan(noon); + }); + + it('тень утром на запад экрана, вечером на восток', () => { + expect(sunShadow(7)!.dirX).toBeLessThan(-0.5); + expect(sunShadow(19)!.dirX).toBeGreaterThan(0.5); + expect(sunShadow(13)!.dirX).toBeCloseTo(0); // полдень — почти под объектом + }); + + it('длина максимальна утром/вечером, минимальна в полдень', () => { + const noon = sunShadow(13)!.len; + const morning = sunShadow(7)!.len; + const evening = sunShadow(19)!.len; + expect(noon).toBeLessThan(morning); + expect(noon).toBeLessThan(evening); + expect(morning).toBeCloseTo(evening, 1); // симметричный день + }); + + it('вектор направления единичный', () => { + for (let h = 6; h < 20; h += 0.5) { + const s = sunShadow(h)!; + expect(Math.hypot(s.dirX, s.dirY)).toBeCloseTo(1); + } + }); +}); + +describe('blobPlacement', () => { + it('масса эллипса сидит у базы: ближний край заходит за базу, вылет — вперёд', () => { + const sun = { dirX: 1, dirY: 0, len: 1, alpha: 0.3 }; + const b = blobPlacement(10, 40, sun); + const center = 10 * 0.3 + 40 * 0.2; // 11 — центр массы от базы + expect(b.x).toBeCloseTo(center); + expect(b.y).toBe(0); + expect(b.scaleX).toBeCloseTo(center + 5.5 + 8.8); // полуось вдоль тени + expect(b.scaleY).toBeCloseTo(10); // 0.75 + 0.25·min(1,len) = 1 при len=1 + expect(b.alpha).toBeCloseTo(0.3); + expect(b.rotation).toBeCloseTo(0); + // Ближний край эллипса за базой (тень «приклеена» к объекту)… + expect(b.x - b.scaleX).toBeLessThan(0); + // …а дальний край вылетает вперёд по тени. + expect(b.x + b.scaleX).toBeGreaterThan(0); + }); + + it('длинная утренняя тень дальше, чем короткая полуденная', () => { + const morning = blobPlacement(10, 40, { dirX: -1, dirY: 0, len: 1.5, alpha: 0.3 }); + const noon = blobPlacement(10, 40, { dirX: 0, dirY: 1, len: 0.6, alpha: 0.3 }); + expect(Math.hypot(morning.x, morning.y)).toBeGreaterThan(Math.hypot(noon.x, noon.y)); + }); + + it('поворот соответствует направлению тени', () => { + const b = blobPlacement(10, 40, { dirX: 0, dirY: 1, len: 1, alpha: 0.3 }); + expect(b.rotation).toBeCloseTo(Math.PI / 2); + }); + + it('центр массы близко к базе даже при длинной тени (объект не «висит»)', () => { + const long = blobPlacement(9, 12, { dirX: -0.9, dirY: 0.45, len: 1.6, alpha: 0.3 }); + expect(Math.hypot(long.x, long.y)).toBeLessThan(9); // герой: центр ~5-6 px от ног + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/render/__tests__/shake.test.ts b/v1/packages/engine/src/render/__tests__/shake.test.ts new file mode 100644 index 0000000..c4e5ff4 --- /dev/null +++ b/v1/packages/engine/src/render/__tests__/shake.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { Shake } from '../shake'; + +describe('Shake', () => { + it('неактивен без толчков, offset нулевой', () => { + const s = new Shake(); + expect(s.active).toBe(false); + expect(s.offset).toEqual({ x: 0, y: 0 }); + }); + + it('толчок с нулевой/отрицательной силой игнорируется', () => { + const s = new Shake(); + s.add(0, 1); + s.add(5, 0); + s.add(-3, 1); + expect(s.active).toBe(false); + }); + + it('активен во время действия и выключается по окончании', () => { + const s = new Shake(); + s.add(4, 0.5); + expect(s.active).toBe(true); + s.update(0.3); + expect(s.active).toBe(true); + s.update(0.3); + expect(s.active).toBe(false); + expect(s.offset).toEqual({ x: 0, y: 0 }); + }); + + it('амплитуда не превышает силу толчка и затухает', () => { + const s = new Shake(42); + s.add(6, 1); + let maxAbs = 0; + for (let i = 0; i < 60; i++) { + s.update(1 / 60); + const { x, y } = s.offset; + expect(Math.abs(x)).toBeLessThanOrEqual(6); + expect(Math.abs(y)).toBeLessThanOrEqual(6); + maxAbs = Math.max(maxAbs, Math.abs(x)); + } + // в какой-то из ранних кадров смещение ощутимо + expect(maxAbs).toBeGreaterThan(0); + }); + + it('одинаковый seed — одинаковые смещения', () => { + const a = new Shake(7); + const b = new Shake(7); + a.add(5, 0.3); + b.add(5, 0.3); + for (let i = 0; i < 20; i++) { + a.update(1 / 60); + b.update(1 / 60); + expect(a.offset).toEqual(b.offset); + } + }); + + it('offset округляется до целых пикселей', () => { + const s = new Shake(1); + s.add(0.5, 0.1); + s.update(1 / 120); + const { x, y } = s.offset; + expect(Number.isInteger(x)).toBe(true); + expect(Number.isInteger(y)).toBe(true); + }); + + it('два толчка складываются, clear сбрасывает', () => { + const s = new Shake(9); + s.add(3, 1); + s.add(3, 1); + s.update(0.1); + expect(s.active).toBe(true); + s.clear(); + expect(s.active).toBe(false); + expect(s.offset).toEqual({ x: 0, y: 0 }); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/render/__tests__/spriteFx.test.ts b/v1/packages/engine/src/render/__tests__/spriteFx.test.ts new file mode 100644 index 0000000..1119744 --- /dev/null +++ b/v1/packages/engine/src/render/__tests__/spriteFx.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { Sprite, Texture } from 'pixi.js'; +import { SpriteFlash } from '../spriteFx'; + +describe('SpriteFlash', () => { + it('вспышка перекрашивает спрайт и возвращает базовый tint', () => { + const sprite = new Sprite(Texture.WHITE); + const flash = new SpriteFlash(sprite, 0xff0000, 0.15); + flash.start(); + flash.update(1 / 60); + expect(sprite.tint).not.toBe(0xffffff); + for (let i = 0; i < 15; i++) flash.update(1 / 60); // 0.15+ с — доиграл + expect(sprite.tint).toBe(0xffffff); + }); + + it('destroyed следует за разрушением спрайта (самозачистка из реестра)', () => { + const sprite = new Sprite(Texture.WHITE); + const flash = new SpriteFlash(sprite, 0xff0000); + expect(flash.destroyed).toBe(false); + flash.update(1 / 60); // неактивен — no-op + sprite.destroy(); + expect(flash.destroyed).toBe(true); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/render/fx/FxFade.ts b/v1/packages/engine/src/render/fx/FxFade.ts new file mode 100644 index 0000000..4aab21c --- /dev/null +++ b/v1/packages/engine/src/render/fx/FxFade.ts @@ -0,0 +1,66 @@ +import { Container } from 'pixi.js'; +import type { Updatable } from '../../core/Updatables'; +import { sampleFade, type FxFadeSpec } from './fxSim'; + +/** Опции растворения: математика (FxFadeSpec) + жизненный цикл вьюхи. */ +export interface FxFadeOptions extends FxFadeSpec { + /** true — не уничтожать вьюху в конце (только альфа). */ + keep?: boolean; + /** Колбэк завершения (в т.ч. после destroy вьюхи). */ + onDone?: () => void; +} + +/** + * Растворение/проявление произвольной вьюхи + авто-удаление. Не display-объект: + * тикается в реестре, применяет альфу к чужой вьюхе. В конце removeChild + + * destroy({children: true}) — с защитой от двойного destroy (вьюху могли + * уничтожить извне, пока эффект играл). + */ +export class FxFade implements Updatable { + private t = 0; + private finished = false; + private readonly from: number; + + constructor( + private readonly view: Container, + private readonly opts: FxFadeOptions + ) { + this.from = view.alpha; + } + + /** + * Прекратить эффект: вернуть базовую альфу; вьюху, которую эффект уничтожил + * бы в конце (!keep), гасим сразу — контракт fx: cancel = конец эффекта. + */ + cancel(): void { + if (this.finished) return; + this.finished = true; + if (this.view.destroyed) return; + this.view.alpha = this.from; + if (!this.opts.keep) { + this.view.parent?.removeChild(this.view); + this.view.destroy({ children: true }); + } + } + + /** Вьюха разрушена извне или эффект закончился — запись вычистится сама. */ + get destroyed(): boolean { + return this.finished || this.view.destroyed; + } + + update(dt: number): void { + if (this.finished) return; + if (this.view.destroyed) return; // реестр вычистит по destroyed + this.t += dt; + this.view.alpha = sampleFade(this.t, this.from, this.opts); + const total = (this.opts.delay ?? 0) + this.opts.duration; + if (this.t >= total - 1e-6) { // эпсилон: накопление 1/60 не дотягивает до границы + this.finished = true; + if (!this.opts.keep) { + this.view.parent?.removeChild(this.view); + this.view.destroy({ children: true }); + } + this.opts.onDone?.(); + } + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/fx/FxFloatText.ts b/v1/packages/engine/src/render/fx/FxFloatText.ts new file mode 100644 index 0000000..409071b --- /dev/null +++ b/v1/packages/engine/src/render/fx/FxFloatText.ts @@ -0,0 +1,52 @@ +import type { Updatable } from '../../core/Updatables'; +import type { Vec2 } from '../../math/Vec2'; +import { PixelText, type PixelTextOptions } from '../../ui/PixelText'; +import { sampleFloat, type FxFloatSpec } from './fxSim'; + +/** + * Всплывающий текст: подъём + затухание (числа урона, тосты, подборы). + * Владеет своим PixelText: создаёт, тикает и уничтожает себя по завершении. + * Позиция задаётся при создании (at), update только смещает от неё. + */ +export class FxFloatText extends PixelText implements Updatable { + private t = 0; + private cancelled = false; + private readonly baseX: number; + private readonly baseY: number; + + constructor( + text: string, + at: Vec2, + private readonly spec: FxFloatSpec, + style?: Partial + ) { + super({ text, size: 11, ...style }); + this.anchor.set(0.5); + this.baseX = at.x; + this.baseY = at.y; + this.position.set(at.x, at.y); + } + + /** Досрочно погасить (например, новый тост вытесняет старый). */ + cancel(): void { + if (this.cancelled) return; + this.cancelled = true; + this.destroy({ children: true }); + } + + /** true, когда текст доиграл (или погашен). */ + get done(): boolean { + return this.cancelled || this.t >= this.spec.duration - 1e-6; + } + + update(dt: number): void { + if (this.destroyed) return; + this.t += dt; + const s = sampleFloat(this.t, this.spec); + this.x = this.baseX + s.x; + this.y = this.baseY + s.y; + this.alpha = s.alpha; + this.scale.set(s.scale); + if (this.done) this.destroy({ children: true }); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/fx/FxLayer.ts b/v1/packages/engine/src/render/fx/FxLayer.ts new file mode 100644 index 0000000..fe2b632 --- /dev/null +++ b/v1/packages/engine/src/render/fx/FxLayer.ts @@ -0,0 +1,138 @@ +import { Container, Sprite } from 'pixi.js'; +import { Updatables, type Updatable } from '../../core/Updatables'; +import type { Vec2 } from '../../math/Vec2'; +import type { PixelTextOptions } from '../../ui/PixelText'; +import { ParticleEmitter, type EmitterOptions } from '../Particles'; +import { SpriteFlash } from '../spriteFx'; +import { FxRing } from './FxRing'; +import { FxFloatText } from './FxFloatText'; +import { FxFade, type FxFadeOptions } from './FxFade'; +import { FxPop } from './FxPop'; +import { FxTrail } from './FxTrail'; +import type { FxRingSpec, FxFloatSpec, FxPopSpec } from './fxSim'; + +/** Опции слоя. */ +export interface FxLayerOptions { + /** Seed по умолчанию для разовых частиц (burst), если opts не передал свой. */ + seed?: number; +} + +/** Опции floatText: математика + стиль текста + родитель (для depth-слоёв). */ +export interface FxLayerFloatOptions extends Partial { + style?: Partial; + parent?: Container; +} + +/** Родитель вьюхи эффекта вместо самого слоя (для IsoDepthLayer.addFx). */ +export interface FxParentOptions { + parent?: Container; +} + +/** + * Слой визуальных эффектов: сам тикается (реализует Updatable — добавить один + * раз в engine.fx), спавнит one-shot пресеты и сам вычищает закончившиеся. + * Все тикающиеся эффекты — в одном реестре items; flashes — только индекс для + * перезапуска флэша по спрайту (сами SpriteFlash тикаются из items). + * Контракт эффектов: cancel() = остановить, вернуть базовое состояние, + * уничтожить свою вьюху; конец жизни — destroyed (done — алиас у своих). + * Позиции — локальные px контейнера, куда добавлен слой (worldRoot — мировые + * эффекты через проекцию сцены, uiRoot — тосты/HUD). Изометрию слой не знает. + */ +export class FxLayer extends Container implements Updatable { + private readonly items = new Updatables(); + /** Индекс флэшей по спрайту (перезапуск вместо дубля; тик — из items). */ + private readonly flashes = new Map(); + private readonly defaultSeed: number; + private seedCounter = 0; + + constructor(opts?: FxLayerOptions) { + super(); + this.defaultSeed = opts?.seed ?? 0x5eed; + } + + /** Ручное добавление своего эффекта (эмиттер, SpriteMotion, FxTrail…). */ + add(fx: T): T { + this.items.add(fx); + return fx; + } + + /** Погасить и уничтожить все активные эффекты (exit сцены, смена тоста). */ + clear(): void { + this.items.forEach((u) => { + const cancellable = u as { cancel?: () => void }; + if (cancellable.cancel) cancellable.cancel(); + else if (!u.destroyed && u instanceof Container) { + u.destroy({ children: true }); + } + }); + this.items.clear(); + this.flashes.clear(); + } + + update(dt: number): void { + this.items.update(dt); + } + + /** Расходящееся/сходящееся кольцо; repeat > 1 — телеграф зоны. */ + ring(spec: FxRingSpec, at: Vec2, opts?: FxParentOptions): FxRing { + const fx = new FxRing(spec); + fx.position.set(at.x, at.y); + return this.mount(fx, opts); + } + + /** Всплывающий текст (числа урона, тосты, подборы). */ + floatText(text: string, at: Vec2, opts?: FxLayerFloatOptions): FxFloatText { + const { style, parent, ...spec } = opts ?? {}; + const fx = new FxFloatText(text, at, { duration: 1, ...spec }, style); + return this.mount(fx, { parent }); + } + + /** Растворение (to < 1) или проявление произвольной вьюхи + авто-destroy. */ + fade(view: Container, spec: FxFadeOptions): FxFade { + return this.items.add(new FxFade(view, spec)) as FxFade; + } + + /** Появление с овершутом масштаба. */ + pop(view: Container, spec?: FxPopSpec): FxPop { + return this.items.add(new FxPop(view, spec)) as FxPop; + } + + /** Хит-флэш: один SpriteFlash на спрайт, повторный вызов перезапускает. */ + flash(sprite: Sprite, color: number, seconds?: number): void { + let flash = this.flashes.get(sprite); + if (!flash || flash.destroyed) { + flash = new SpriteFlash(sprite, color, seconds); + this.flashes.set(sprite, flash); + this.items.add(flash); + } + flash.start(); + } + + /** Разовые частицы в точке (ParticleEmitter.oneShot) с самоуничтожением. */ + burst(count: number, at: Vec2, opts: EmitterOptions, parent?: Container): ParticleEmitter { + const seed = opts.seed ?? (this.defaultSeed + this.seedCounter++); + const fx = ParticleEmitter.oneShot(count, { ...opts, seed }); + fx.position.set(at.x, at.y); + (parent ?? this).addChild(fx); + return this.items.add(fx) as ParticleEmitter; + } + + /** Шлейф за движущейся вьюхой (снаряды, герой). */ + trail(view: Container, opts: EmitterOptions, parent?: Container): FxTrail { + const fx = new FxTrail(view, opts); + (parent ?? this).addChild(fx.view); + return this.items.add(fx) as FxTrail; + } + + /** Повесить вьюху эффекта на слой или переданного родителя. */ + private mount(fx: T, opts?: FxParentOptions): T { + (opts?.parent ?? this).addChild(fx); + return this.items.add(fx) as T; + } + + /** Разрушение слоя гасит все эффекты (exit сцены — одна строка). */ + override destroy(options?: Parameters[0]): void { + this.clear(); + super.destroy(options); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/fx/FxPop.ts b/v1/packages/engine/src/render/fx/FxPop.ts new file mode 100644 index 0000000..835c496 --- /dev/null +++ b/v1/packages/engine/src/render/fx/FxPop.ts @@ -0,0 +1,40 @@ +import { Container } from 'pixi.js'; +import type { Updatable } from '../../core/Updatables'; +import { samplePop, type FxPopSpec } from './fxSim'; + +/** + * Появление с овершутом масштаба (подбор, спавн, UI-подтверждение). Базовый + * масштаб снимается в конструкторе — эффект не конфликтует с вьюхами, масштаб + * которых меняет игра; вьюху не уничтожает (она «уже есть»). + */ +export class FxPop implements Updatable { + private t = 0; + private finished = false; + private readonly base: number; + + constructor( + private readonly view: Container, + private readonly spec: FxPopSpec = {} + ) { + this.base = view.scale.x; + } + + /** Прекратить эффект и вернуть базовый масштаб. */ + cancel(): void { + this.finished = true; + if (!this.view.destroyed) this.view.scale.set(this.base); + } + + /** Вьюха разрушена извне или эффект закончился. */ + get destroyed(): boolean { + return this.finished || this.view.destroyed; + } + + update(dt: number): void { + if (this.finished) return; + if (this.view.destroyed) return; + this.t += dt; + this.view.scale.set(this.base * samplePop(this.t, this.spec)); + if (this.t >= (this.spec.duration ?? 0.25) - 1e-6) this.finished = true; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/fx/FxRing.ts b/v1/packages/engine/src/render/fx/FxRing.ts new file mode 100644 index 0000000..bd9faae --- /dev/null +++ b/v1/packages/engine/src/render/fx/FxRing.ts @@ -0,0 +1,61 @@ +import { Graphics } from 'pixi.js'; +import type { Updatable } from '../../core/Updatables'; +import { sampleRing, ringDuration, type FxRingSpec, type FxRingSample } from './fxSim'; + +/** + * Кольцо/волна: расходящаяся или сходящаяся окружность (или ромб — «footprint» + * зоны). repeat > 1 — телеграф зоны (проходы с паузами); repeat: Infinity — + * зацикленный индикатор, гасится только cancel(). Перерисовка каждый тик + * (clear + один stroke) — тот же приём, что у рукописных колец боевых вьюх. + */ +export class FxRing extends Graphics implements Updatable { + private t = 0; + private cancelled = false; + + constructor(private readonly spec: FxRingSpec) { + super(); + this.draw(sampleRing(0, spec)); + } + + /** Досрочно погасить (смена телеграфа, exit сцены). */ + cancel(): void { + if (this.cancelled) return; + this.cancelled = true; + this.destroy(); + } + + /** true, когда все проходы сыграны (или кольцо погашено). */ + get done(): boolean { + return this.cancelled || this.t >= ringDuration(this.spec) - 1e-6; + } + + update(dt: number): void { + if (this.destroyed) return; + this.t += dt; + this.draw(sampleRing(this.t, this.spec)); + if (this.done) this.destroy(); + } + + private draw(s: FxRingSample): void { + this.clear(); + if (this.spec.fill) { + this.shapePath(s.radius).fill({ + color: this.spec.fill, + alpha: (this.spec.fillAlpha ?? 0.25) * s.alpha + }); + } + this.shapePath(s.radius).stroke({ + color: this.spec.color, + width: this.spec.width ?? 1, + alpha: s.alpha + }); + } + + /** Контур зоны текущего радиуса: круг или ромб (квадрат, повёрнутый на 45°). */ + private shapePath(r: number): this { + if (this.spec.shape === 'diamond') { + return this.poly([0, -r, r, 0, 0, r, -r, 0]); + } + return this.circle(0, 0, r); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/fx/FxTrail.ts b/v1/packages/engine/src/render/fx/FxTrail.ts new file mode 100644 index 0000000..9706d3d --- /dev/null +++ b/v1/packages/engine/src/render/fx/FxTrail.ts @@ -0,0 +1,54 @@ +import { Container } from 'pixi.js'; +import type { Updatable } from '../../core/Updatables'; +import { ParticleEmitter, type EmitterOptions } from '../Particles'; + +/** + * Шлейф за движущейся вьюхой (снаряды, бегущий герой): эмиттер тянется за + * якорем каждый тик, пока не остановлен. stop() выключает спавн; когда + * последняя частица умрёт, эмиттер уничтожается и эффект заканчивается. + */ +export class FxTrail implements Updatable { + /** Готовый эмиттер (можно дотюнить setOptions — цвет, rate, lifetime). */ + readonly view: ParticleEmitter; + private stopped = false; + private finished = false; + + constructor( + private readonly anchor: Container, + opts: EmitterOptions + ) { + this.view = new ParticleEmitter({ + ...opts, + rate: opts.rate ?? 24, + lifetime: opts.lifetime ?? [0.3, 0.6] + }); + this.view.position.set(anchor.x, anchor.y); + } + + /** Перестать испускать (снаряд погас/умер): хвост самораспадается. */ + stop(): void { + this.stopped = true; + this.view.setOptions({ rate: 0 }); + } + + /** Погасить немедленно (контракт fx: cancel = конец + уничтожить свою вьюху). */ + cancel(): void { + if (this.finished) return; + this.finished = true; + if (!this.view.destroyed) this.view.destroy({ children: true }); + } + + get destroyed(): boolean { + return this.finished || this.anchor.destroyed; + } + + update(dt: number): void { + if (this.finished) return; + if (!this.stopped) this.view.position.set(this.anchor.x, this.anchor.y); + this.view.update(dt); + if (this.stopped && this.view.count === 0) { + this.view.destroy({ children: true }); + this.finished = true; + } + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/fx/__tests__/FxLayer.test.ts b/v1/packages/engine/src/render/fx/__tests__/FxLayer.test.ts new file mode 100644 index 0000000..8cebaf9 --- /dev/null +++ b/v1/packages/engine/src/render/fx/__tests__/FxLayer.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; +import { Container, Sprite, Texture } from 'pixi.js'; +import { FxLayer } from '../FxLayer'; +import { FxRing } from '../FxRing'; + +const TICK = 1 / 60; + +describe('FxLayer.ring', () => { + it('кольцо доигрывает и удаляется из слоя', () => { + const layer = new FxLayer(); + const ring = layer.ring({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 }, { x: 10, y: 20 }); + expect(layer.children).toContain(ring); + for (let i = 0; i < 24; i++) layer.update(TICK); + expect(ring.destroyed).toBe(true); + expect(layer.children).not.toContain(ring); + }); + + it('зацикленное кольцо не самоуничтожается, cancel() гасит', () => { + const layer = new FxLayer(); + const ring = layer.ring( + { color: 0xff0000, from: 8, to: 96, duration: 0.3, repeat: Infinity }, + { x: 0, y: 0 } + ); + for (let i = 0; i < 120; i++) layer.update(TICK); + expect(ring.destroyed).toBe(false); + ring.cancel(); + expect(ring.destroyed).toBe(true); + }); + + it('родитель-опция: вьюха уходит в переданный контейнер', () => { + const layer = new FxLayer(); + const depth = new Container(); + const ring = layer.ring({ color: 0xffffff, from: 0, to: 10, duration: 0.1 }, { x: 0, y: 0 }, { parent: depth }); + expect(ring.parent).toBe(depth); + }); +}); + +describe('FxLayer.floatText', () => { + it('текст создаётся, поднимается и уничтожается по завершении', () => { + const layer = new FxLayer(); + const fx = layer.floatText('+1', { x: 100, y: 100 }, { duration: 1, rise: 8 }); + expect(layer.children).toContain(fx); + layer.update(TICK); + expect(fx.alpha).toBeGreaterThan(0); + expect(fx.y).toBeLessThanOrEqual(100); + for (let i = 0; i < 60; i++) layer.update(TICK); + expect(fx.destroyed).toBe(true); + expect(layer.children).not.toContain(fx); + }); +}); + +describe('FxLayer.fade', () => { + it('вьюха растворяется и уничтожается', () => { + const layer = new FxLayer(); + const parent = new Container(); + const view = new Container(); + view.alpha = 1; + parent.addChild(view); + layer.fade(view, { duration: 0.5 }); + for (let i = 0; i < 30; i++) layer.update(TICK); + expect(view.destroyed).toBe(true); + expect(view.parent).toBeNull(); + }); + + it('keep: true — вьюха жива с целевой альфой', () => { + const layer = new FxLayer(); + const parent = new Container(); + const view = new Container(); + parent.addChild(view); + layer.fade(view, { duration: 0.5, to: 0.4, keep: true }); + for (let i = 0; i < 30; i++) layer.update(TICK); + expect(view.destroyed).toBe(false); + expect(view.alpha).toBeCloseTo(0.4, 5); + expect(view.parent).toBe(parent); + }); + + it('вьюха, разрушенная извне во время эффекта, не роняет update', () => { + const layer = new FxLayer(); + const view = new Container(); + layer.fade(view, { duration: 1 }); + layer.update(TICK); + view.destroy({ children: true }); + expect(() => layer.update(TICK)).not.toThrow(); + }); + + it('cancel без keep: альфа возвращена, вьюха погашена (контракт fx)', () => { + const layer = new FxLayer(); + const view = new Container(); + view.alpha = 1; + const fx = layer.fade(view, { duration: 1 }); + layer.update(TICK); + fx.cancel(); + expect(view.alpha).toBe(1); + expect(view.destroyed).toBe(true); + }); + + it('cancel с keep: альфа возвращена, вьюха жива (чужая — не наша)', () => { + const layer = new FxLayer(); + const view = new Container(); + view.alpha = 1; + const fx = layer.fade(view, { duration: 1, keep: true }); + layer.update(TICK); + fx.cancel(); + expect(view.alpha).toBe(1); + expect(view.destroyed).toBe(false); + }); +}); + +describe('FxLayer.pop', () => { + it('масштаб возвращается к базовому, вьюха жива', () => { + const layer = new FxLayer(); + const view = new Container(); + view.scale.set(2); + layer.pop(view, { duration: 0.25, fromScale: 0.5 }); + layer.update(TICK); + expect(view.scale.x).toBeGreaterThan(0.5 * 2); // разгоны от базового + for (let i = 0; i < 15; i++) layer.update(TICK); + expect(view.scale.x).toBeCloseTo(2, 5); + expect(view.destroyed).toBe(false); + }); +}); + +describe('FxLayer.flash', () => { + it('повторный флэш по тому же спрайту не дублируется, destroy спрайта чистится', () => { + const layer = new FxLayer(); + const sprite = new Sprite(Texture.WHITE); + layer.flash(sprite, 0xff0000); + layer.flash(sprite, 0xff0000); // перезапуск, а не вторая запись + layer.update(TICK); + expect(sprite.tint).not.toBe(0xffffff); // вспышка применилась + for (let i = 0; i < 20; i++) layer.update(TICK); // 0.15 с — флэш доиграл + expect(sprite.tint).toBe(0xffffff); + sprite.destroy(); + expect(() => layer.update(TICK)).not.toThrow(); + }); +}); + +describe('FxLayer.burst', () => { + it('детерминирован по seed: одинаковые позиции частиц', () => { + const a = new FxLayer({ seed: 42 }); + const b = new FxLayer({ seed: 42 }); + const opts = { rate: 0, lifetime: [0.5, 0.5] as [number, number], radialSpeed: [50, 100] as [number, number] }; + const ea = a.burst(5, { x: 0, y: 0 }, opts); + const eb = b.burst(5, { x: 0, y: 0 }, opts); + a.update(TICK); + b.update(TICK); + expect(ea.children.map((c) => `${c.x},${c.y}`)).toEqual(eb.children.map((c) => `${c.x},${c.y}`)); + }); + + it('эмиттер самоуничтожается, когда частицы умерли', () => { + const layer = new FxLayer({ seed: 1 }); + const fx = layer.burst(2, { x: 0, y: 0 }, { rate: 0, lifetime: [0.2, 0.2] }); + for (let i = 0; i < 20; i++) layer.update(TICK); + expect(fx.destroyed).toBe(true); + }); +}); + +describe('FxLayer.trail', () => { + it('тянет эмиттер за якорем; stop() гасит хвост и завершает эффект', () => { + const layer = new FxLayer(); + const anchor = new Container(); + anchor.position.set(10, 10); + const trail = layer.trail(anchor, { rate: 30, lifetime: [0.1, 0.1], seed: 7 }); + anchor.position.set(50, 30); + layer.update(TICK); + expect(trail.view.x).toBe(50); + trail.stop(); + for (let i = 0; i < 20; i++) layer.update(TICK); + expect(trail.destroyed).toBe(true); + }); + + it('cancel() гасит эмиттер немедленно', () => { + const layer = new FxLayer(); + const anchor = new Container(); + const trail = layer.trail(anchor, { rate: 30, lifetime: [0.1, 0.1], seed: 7 }); + trail.cancel(); + expect(trail.destroyed).toBe(true); + expect(trail.view.destroyed).toBe(true); + }); +}); + +describe('FxLayer.clear', () => { + it('гасит все активные эффекты и флэш-записи', () => { + const layer = new FxLayer(); + const ring = layer.ring({ color: 0xffffff, from: 0, to: 10, duration: 5 }, { x: 0, y: 0 }); + const view = new Container(); + layer.pop(view, { duration: 5 }); + const sprite = new Sprite(Texture.WHITE); + layer.flash(sprite, 0xff0000); + layer.clear(); + expect(ring.destroyed).toBe(true); + expect(view.scale.x).toBe(1); // pop вернул базовый масштаб + layer.update(TICK); // не падает + }); +}); + +describe('FxRing', () => { + it('радиус растёт по спеку, альфа гаснет к концу', () => { + const ring = new FxRing({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 }); + ring.update(0.2); + ring.update(0.2); + expect(ring.done).toBe(true); + expect(ring.destroyed).toBe(true); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/render/fx/__tests__/fxSim.test.ts b/v1/packages/engine/src/render/fx/__tests__/fxSim.test.ts new file mode 100644 index 0000000..037d172 --- /dev/null +++ b/v1/packages/engine/src/render/fx/__tests__/fxSim.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { + sampleRing, ringDuration, sampleFloat, readSeconds, sampleFade, samplePop +} from '../fxSim'; +import { quadOut } from '../../../core/easing'; + +describe('sampleRing', () => { + const spec = { color: 0xffffff, from: 8, to: 96, duration: 0.4 }; + + it('границы прохода: t=0 → from/alphaFrom, t=duration → to/alphaTo', () => { + expect(sampleRing(0, spec)).toEqual({ radius: 8, alpha: 1 }); + expect(sampleRing(0.4, spec)).toEqual({ radius: 96, alpha: 0 }); + }); + + it('ease применяется к радиусу, но не к альфе', () => { + const eased = sampleRing(0.2, { ...spec, ease: quadOut }); + // quadOut(0.5) = 0.75 → радиус дальше середины + expect(eased.radius).toBeCloseTo(8 + 88 * 0.75, 5); + expect(eased.alpha).toBeCloseTo(0.5, 5); + }); + + it('repeat с gap: второй проход начинается с from после паузы', () => { + const s = { ...spec, repeat: 3, gap: 0.1 }; + // конец первого прохода + пауза — радиус снова from (начало второго) + const second = sampleRing(0.4 + 0.1 + 0.01, s); + expect(second.radius).toBeCloseTo(8 + 88 * 0.025, 1); + // после всех проходов (3*0.4 + 2*0.1 = 1.4) — конец + expect(ringDuration(s)).toBeCloseTo(1.4, 5); + expect(sampleRing(1.4, s).alpha).toBeCloseTo(0, 5); + }); + + it('repeat: Infinity — бесконечная длительность, циклы повторяются', () => { + const s = { ...spec, repeat: Infinity, gap: 0.1 }; + expect(ringDuration(s)).toBe(Infinity); + // t = 0.5 (заход во второй цикл) — как t = 0.5 - 0.5 цикла... проверяем цикличность + const a = sampleRing(0.4 + 0.1 + 0.0, s); + const b = sampleRing(0.4 + 0.1 + 0.5 + 0.4 + 0.1, s); + expect(a.radius).toBeCloseTo(b.radius, 5); + expect(a.alpha).toBeCloseTo(b.alpha, 5); + }); + + it('отрицательный t клампится к началу', () => { + expect(sampleRing(-1, spec).radius).toBe(8); + }); +}); + +describe('sampleFloat', () => { + it('границы: t=0 → y=0/alpha=0, конец → y=-rise/alpha=0', () => { + const spec = { duration: 1, rise: 8 }; + expect(sampleFloat(0, spec)).toEqual({ x: 0, y: 0, alpha: 0, scale: 1 }); + const end = sampleFloat(1, spec); + expect(end.y).toBeCloseTo(-8, 5); + expect(end.alpha).toBe(0); + }); + + it('в середине — alpha 1, подъём по quadOut', () => { + const s = sampleFloat(0.5, { duration: 1, rise: 8 }); + expect(s.alpha).toBe(1); + expect(s.y).toBeCloseTo(-8 * 0.75, 5); // quadOut(0.5) + }); + + it('hold: плато позиции и альфы до конца hold', () => { + const spec = { duration: 2, hold: 1, rise: 10 }; + const mid = sampleFloat(0.5, spec); + expect(mid.y).toBe(0); + expect(mid.alpha).toBe(1); + }); + + it('driftX смещает x пропорционально прогрессу', () => { + const s = sampleFloat(1, { duration: 1, driftX: 12 }); + expect(s.x).toBeCloseTo(12, 5); + }); + + it('pop: масштаб растёт от fromScale к 1 в течение pop', () => { + const s = sampleFloat(0.15, { duration: 1, pop: 0.3, fromScale: 0.6 }); + expect(s.scale).toBeCloseTo(0.6 + 0.4 * 0.5, 5); + expect(sampleFloat(0.4, { duration: 1, pop: 0.3, fromScale: 0.6 }).scale).toBe(1); + }); +}); + +describe('readSeconds', () => { + it('база + perChar за символ', () => { + expect(readSeconds('')).toBeCloseTo(1.4, 5); + expect(readSeconds('ab')).toBeCloseTo(1.4 + 2 * 0.045, 5); + }); + + it('не больше max', () => { + expect(readSeconds('x'.repeat(500))).toBe(6); + }); +}); + +describe('sampleFade', () => { + it('границы: from → to', () => { + expect(sampleFade(0, 1, { duration: 0.5 })).toBe(1); + expect(sampleFade(0.5, 1, { duration: 0.5 })).toBeCloseTo(0, 5); + }); + + it('delay сдвигает старт', () => { + const spec = { duration: 0.5, delay: 0.25 }; + expect(sampleFade(0.25, 1, spec)).toBe(1); + expect(sampleFade(0.5, 1, spec)).toBeCloseTo(0.25, 5); // половина пути + expect(sampleFade(0.75, 1, spec)).toBeCloseTo(0, 5); + }); + + it('кастомная цель: проявление до 0.8', () => { + expect(sampleFade(1, 0.2, { duration: 1, to: 0.8 })).toBeCloseTo(0.8, 5); + }); +}); + +describe('samplePop', () => { + it('границы: t=0 → fromScale, конец → ровно 1', () => { + expect(samplePop(0, {})).toBeCloseTo(0.5, 5); + expect(samplePop(0.25, {})).toBe(1); + expect(samplePop(10, {})).toBe(1); + }); + + it('пик у overshoot в районе 70% времени', () => { + const peak = samplePop(0.175, { duration: 0.25 }); // u = 0.7 + expect(peak).toBeCloseTo(1.12, 5); + }); + + it('кастомные параметры honored', () => { + const spec = { duration: 1, fromScale: 0.2, overshoot: 1.5 }; + expect(samplePop(0, spec)).toBeCloseTo(0.2, 5); + expect(samplePop(0.7, spec)).toBeCloseTo(1.5, 5); + }); +}); + +describe('чистота сэмплов', () => { + it('двойной прогон даёт одинаковые значения (нет Math.random)', () => { + const ringSpec = { color: 0xd99a32, from: 8, to: 96, duration: 0.4, repeat: 3, gap: 0.1 }; + const floatSpec = { duration: 2, hold: 0.8, driftX: 5, pop: 0.3 }; + const a = [0, 0.13, 0.5, 1.23, 2].map((t) => JSON.stringify(sampleFloat(t, floatSpec))); + const b = [0, 0.2, 0.5, 0.9, 1.4].map((t) => JSON.stringify(sampleRing(t, ringSpec))); + const a2 = [0, 0.13, 0.5, 1.23, 2].map((t) => JSON.stringify(sampleFloat(t, floatSpec))); + const b2 = [0, 0.2, 0.5, 0.9, 1.4].map((t) => JSON.stringify(sampleRing(t, ringSpec))); + expect(a).toEqual(a2); + expect(b).toEqual(b2); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/render/fx/fxSim.ts b/v1/packages/engine/src/render/fx/fxSim.ts new file mode 100644 index 0000000..09f33fb --- /dev/null +++ b/v1/packages/engine/src/render/fx/fxSim.ts @@ -0,0 +1,187 @@ +/** + * Чистая математика one-shot эффектов: сэмплы состояния (радиус/альфа/смещение) + * в момент t. Без Pixi — тестируется в Vitest headless; адаптеры в этом каталоге + * только применяют сэмплы к вьюхам. + */ + +import { backOut, linear, quadOut, type EaseFn } from '../../core/easing'; + +/** Спецификация кольца/волны; repeat > 1 — телеграф зоны. */ +export interface FxRingSpec { + /** Цвет обводки (0xRRGGBB). */ + color: number; + /** Стартовый радиус, px. */ + from: number; + /** Конечный радиус, px. */ + to: number; + /** Секунд на один проход. */ + duration: number; + /** Альфа в начале/конце прохода (по умолчанию 1 → 0). */ + alphaFrom?: number; + alphaTo?: number; + /** Толщина обводки, px (по умолчанию 1). */ + width?: number; + /** Сглаживание радиуса (по умолчанию linear). */ + ease?: EaseFn; + /** Число проходов (по умолчанию 1; Infinity — зацикленный телеграф). */ + repeat?: number; + /** Пауза между проходами, сек (по умолчанию 0). */ + gap?: number; + /** Форма зоны: круг или «footprint»-ромб (квадрат, повёрнутый на 45°). */ + shape?: 'circle' | 'diamond'; + /** Цвет заливки зоны (телеграф); 0 — без заливки. */ + fill?: number; + /** Альфа заливки (по умолчанию 0.25). */ + fillAlpha?: number; +} + +/** Состояние кольца в момент t. */ +export interface FxRingSample { + radius: number; + alpha: number; +} + +/** Радиус и альфа кольца в момент t; repeat > 1 — проходы с паузой gap. */ +export function sampleRing(t: number, spec: FxRingSpec): FxRingSample { + const dur = Math.max(spec.duration, 1e-6); + const gap = spec.gap ?? 0; + const repeat = spec.repeat ?? 1; + const cycle = dur + gap; + const total = repeat === Infinity ? Infinity : repeat * cycle - gap; + // Прошли все проходы — стоим в конце последнего (mod не должен завернуть границу). + const ended = total !== Infinity && t >= total; + const k = repeat === Infinity ? t % cycle : Math.min(t, total); + const u = ended ? 1 : Math.min(1, Math.max(0, (k % cycle) / dur)); + const ease = spec.ease ?? linear; + const aFrom = spec.alphaFrom ?? 1; + const aTo = spec.alphaTo ?? 0; + return { + radius: spec.from + (spec.to - spec.from) * ease(u), + alpha: aFrom + (aTo - aFrom) * u + }; +} + +/** Полная длительность эффекта, сек (Infinity при repeat: Infinity). */ +export function ringDuration(spec: FxRingSpec): number { + const repeat = spec.repeat ?? 1; + if (repeat === Infinity) return Infinity; + return repeat * (spec.duration + (spec.gap ?? 0)) - (spec.gap ?? 0); +} + +/** Спецификация всплывающего текста (урон, тосты, подборы). */ +export interface FxFloatSpec { + /** Полная длительность, сек. */ + duration: number; + /** Плато после подъёма, сек (тосты — «держать, пока читается»). */ + hold?: number; + /** Подъём вверх, px (по умолчанию 8; отрицательный — вниз). */ + rise?: number; + /** Дрейф по X, px (число урона «уезжает» вбок). */ + driftX?: number; + /** Сек нарастания альфы в начале (по умолчанию 0.06). */ + fadeIn?: number; + /** Сек затухания в конце (по умолчанию 0.4). */ + fadeOut?: number; + /** Ease подъёма (по умолчанию quadOut). */ + ease?: EaseFn; + /** Пик овершута масштаба (0 — без «пружинки»). */ + pop?: number; + /** Стартовый масштаб при pop (по умолчанию 0.6). */ + fromScale?: number; +} + +/** Состояние текста в момент t (x/y — смещение от стартовой точки). */ +export interface FxFloatSample { + x: number; + y: number; + alpha: number; + scale: number; +} + +/** Положение, альфа и масштаб всплывающего текста в момент t. */ +export function sampleFloat(t: number, spec: FxFloatSpec): FxFloatSample { + const rise = spec.rise ?? 8; + const fadeIn = spec.fadeIn ?? 0.06; + const fadeOut = spec.fadeOut ?? 0.4; + const hold = spec.hold ?? 0; + const travel = Math.max(spec.duration - hold, 1e-6); + const u = Math.min(1, Math.max(0, (t - hold) / travel)); + const ease = spec.ease ?? quadOut; + const alphaIn = Math.min(1, t / Math.max(fadeIn, 1e-6)); + const alphaOut = Math.min(1, (spec.duration - t) / Math.max(fadeOut, 1e-6)); + const pop = spec.pop ?? 0; + const fromScale = spec.fromScale ?? 0.6; + const scale = pop > 0 + ? fromScale + (1 - fromScale) * Math.min(1, t / Math.max(pop, 1e-6)) + : 1; + return { + x: (spec.driftX ?? 0) * u, + y: 0 - rise * ease(u), // 0 − (…) вместо -(…): не даём «минус ноль» на старте + alpha: Math.max(0, Math.min(alphaIn, alphaOut)), + scale + }; +} + +/** + * Время чтения строки (для тостов): base + perChar*len, не больше max. + * Дефолты совпадают с историческим тостом демо: 1.4 + 0.045/символ, не дольше 6. + */ +export function readSeconds( + text: string, + opts?: { base?: number; perChar?: number; max?: number } +): number { + const base = opts?.base ?? 1.4; + const perChar = opts?.perChar ?? 0.045; + const max = opts?.max ?? 6; + return Math.min(max, base + text.length * perChar); +} + +/** Спецификация растворения/проявления произвольной вьюхи. */ +export interface FxFadeSpec { + /** Секунд до конца. */ + duration: number; + /** Целевая альфа (по умолчанию 0 — растворение). */ + to?: number; + /** Задержка старта, сек. */ + delay?: number; + /** Ease альфы (по умолчанию quadOut). */ + ease?: EaseFn; +} + +/** Альфа вьюхи в момент t: from → to за duration после delay. */ +export function sampleFade(t: number, from: number, spec: FxFadeSpec): number { + const delay = spec.delay ?? 0; + const u = Math.min(1, Math.max(0, (t - delay) / Math.max(spec.duration, 1e-6))); + const ease = spec.ease ?? quadOut; + const to = spec.to ?? 0; + return from + (to - from) * ease(u); +} + +/** Спецификация появления с овершутом (pop/squash). */ +export interface FxPopSpec { + /** Секунд (по умолчанию 0.25). */ + duration?: number; + /** Стартовый множитель масштаба (по умолчанию 0.5). */ + fromScale?: number; + /** Пик овершута (по умолчанию 1.12; 1 — без пружинки). */ + overshoot?: number; + /** Ease масштаба (по умолчанию backOut). */ + ease?: EaseFn; +} + +/** Множитель масштаба в момент t (1 в покое): from → overshoot → 1. */ +export function samplePop(t: number, spec: FxPopSpec = {}): number { + const dur = spec.duration ?? 0.25; + const from = spec.fromScale ?? 0.5; + const over = spec.overshoot ?? 1.12; + const ease = spec.ease ?? backOut; + const u = Math.min(1, Math.max(0, t / Math.max(dur, 1e-6))); + if (u >= 1) return 1; + // Две фазы: разгон from → overshoot (70% времени), затем плавный возврат к 1. + const peakT = 0.7; + if (u < peakT) { + return from + (over - from) * ease(u / peakT); + } + const settle = (u - peakT) / (1 - peakT); + return over + (1 - over) * settle; +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/fx/particlePresets.ts b/v1/packages/engine/src/render/fx/particlePresets.ts new file mode 100644 index 0000000..88522f3 --- /dev/null +++ b/v1/packages/engine/src/render/fx/particlePresets.ts @@ -0,0 +1,61 @@ +import type { EmitterOptions } from '../Particles'; +import { mergeEmitterOptions } from '../particleSim'; + +/** + * Готовые наборы EmitterOptions для частых one-shot эффектов. Каждая фабрика — + * детерминированные дефолты + переопределения вызывающего (mergeEmitterOptions): + * `oneShot(10, sparkPreset(0xf2b45a, { seed }))`. Цвет — обязательный первый + * аргумент там, где он определяет смысл эффекта (искры, угольки). + */ + +/** Искры удара: быстрые разлетающиеся, тормозят, светятся (add-блендинг). */ +export function sparkPreset(color: number, over?: Partial): EmitterOptions { + return mergeEmitterOptions({ + color, + rate: 0, + lifetime: [0.2, 0.45], + radialSpeed: [70, 170], + drag: 2.5, + size: [1, 2], + blend: 'add' + }, over); +} + +/** Дым/пепел: медленный подъём, рост и растворение, без блендинга. */ +export function smokePreset(color: number, over?: Partial): EmitterOptions { + return mergeEmitterOptions({ + color, + rate: 0, + lifetime: [0.6, 1.2], + velocity: { x: [-8, 8], y: [-24, -10] }, + acceleration: { y: 6 }, + size: [2, 4], + scaleOverLife: 'grow' + }, over); +} + +/** Угольки/мотыльки: медленный дрейф вверх с синус-качанием, тёплый свет. */ +export function emberPreset(color: number, over?: Partial): EmitterOptions { + return mergeEmitterOptions({ + color, + rate: 0, + lifetime: [1.0, 2.0], + velocity: { x: [-10, 10], y: [-18, -6] }, + wobble: 6, + size: 1, + fadeIn: 0.3, + blend: 'add' + }, over); +} + +/** Пыль от шага/приземления: короткая, низкая, быстро тормозит. */ +export function dustPreset(color: number, over?: Partial): EmitterOptions { + return mergeEmitterOptions({ + color, + rate: 0, + lifetime: [0.25, 0.5], + velocity: { x: [-14, 14], y: [-6, -2] }, + drag: 2, + size: [1, 2] + }, over); +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/lightSim.ts b/v1/packages/engine/src/render/lightSim.ts new file mode 100644 index 0000000..37fcaec --- /dev/null +++ b/v1/packages/engine/src/render/lightSim.ts @@ -0,0 +1,222 @@ +/** + * Чистая математика освещения — без Pixi, тестируется в Vitest. + * Lighting использует эти функции для кадров источников и лерпа ambient. + */ + +import { lerpColor } from '../anim/flash'; + +/** Базовый радиус текстуры свечения в px — на него нормируется scale спрайта. */ +export const GLOW_BASE_PX = 32; + +/** + * Источник света в экранных пикселях (позицию считает сцена через Camera.toScreen — + * движок ничего не знает об изометрии). + */ +export interface LightDef { + id: string; + x: number; + y: number; + /** Цвет свечения 0xRRGGBB. */ + color: number; + /** Яркость: 0..~1.5 (по умолчанию 1). */ + intensity?: number; + /** Радиус гало в px (по умолчанию 2 * GLOW_BASE_PX). */ + radius?: number; + /** Амплитуда мерцания 0..1 (0 — ровный свет). */ + flicker?: number; + /** Период мерцания, сек (по умолчанию 0.9). */ + flickerPeriod?: number; + /** Фаза мерцания — детерминизм между запусками. */ + seed?: number; + /** Выключенный источник не рисуется (кадр с alpha 0). */ + enabled?: boolean; +} + +/** Видимое состояние источника на шаге. */ +export interface LightFrame { + id: string; + x: number; + y: number; + tint: number; + alpha: number; + scale: number; +} + +/** + * Детерминированное мерцание: сумма двух синусов с фазой от seed. + * Одинаковые (time, seed) — одинаковый результат; диапазон [1-amount, 1]. + */ +export function flickerFactor(time: number, seed: number, amount: number, period: number): number { + if (amount <= 0) return 1; + const p = period > 0 ? period : 0.9; + const f = time / p + seed * Math.PI * 2; + const wave = (Math.sin(f) + Math.sin(f * 2.37 + seed * 5)) * 0.5; // -1..1 + return 1 - amount * (wave * 0.5 + 0.5); +} + +/** Кадр источника: чистая функция, без Pixi и без мутаций. */ +export function lightFrame(def: LightDef, time: number, out?: LightFrame): LightFrame { + const intensity = def.intensity ?? 1; + const radius = def.radius ?? 2 * GLOW_BASE_PX; + const enabled = def.enabled ?? true; + const alpha = enabled + ? intensity * flickerFactor(time, def.seed ?? 0, def.flicker ?? 0, def.flickerPeriod ?? 0.9) + : 0; + const f: LightFrame = out ?? { id: '', x: 0, y: 0, tint: 0xffffff, alpha: 0, scale: 1 }; + f.id = def.id; + f.x = def.x; + f.y = def.y; + f.tint = def.color; + f.alpha = alpha; + f.scale = radius / GLOW_BASE_PX; + return f; +} + +/** + * Огибающая импульса (вспышки): трапеция «рост → плато → спад». + * attack/decay — сек; hold — плато на пике (по умолчанию 0). + */ +export interface PulseSpec { + /** Рост 0→1, сек (0 — мгновенный пик). */ + attack: number; + /** Плато на пике, сек (по умолчанию 0). */ + hold?: number; + /** Спад 1→0, сек. */ + decay: number; +} + +/** Полная длительность импульса, сек. */ +export function pulseTotal(spec: PulseSpec): number { + return Math.max(0, spec.attack) + Math.max(0, spec.hold ?? 0) + Math.max(0, spec.decay); +} + +/** Огибающая импульса в момент t от старта: 0..1, вне [0, total] — 0. */ +export function pulseEnvelope(t: number, spec: PulseSpec): number { + const attack = Math.max(0, spec.attack); + const hold = Math.max(0, spec.hold ?? 0); + const decay = Math.max(0, spec.decay); + if (t <= 0 || t >= attack + hold + decay) return 0; + if (t < attack) return t / attack; + if (t < attack + hold) return 1; + return 1 - (t - attack - hold) / decay; +} + +// --- тьма с прорезями: ступенчатые профили текстур света --- + +/** Ступень круговой текстуры: внешнее кольцо и его краска. */ +export interface RingStep { + /** Радиус кольца в px (текстура радиусом GLOW_BASE_PX). */ + radius: number; + /** Альфа краски кольца (кольца рисуются в порядке массива, снаружи внутрь). */ + paintAlpha: number; +} + +/** + * Ступени из накопленного профиля A (изнутри наружу, A[0] — центр): + * радиус кольца k — GLOW_BASE_PX·k/n, краска p_k = 1 − (1−A_k)/(1−A_{k+1}) — + * чтобы перекрытие колец (рисование снаружи внутрь, source-over) дало в + * кольце k ровно накопленную A_k. Возвращает в порядке рисования. + */ +export function stepAlphas(profile: readonly number[]): RingStep[] { + const n = profile.length; + const out: RingStep[] = []; + let nextA = 0; // накопление за внутренним краем текущего кольца + for (let k = n; k >= 1; k--) { + const a = Math.max(0, Math.min(1, profile[k - 1])); + const paint = nextA >= 1 ? 0 : Math.max(0, 1 - (1 - a) / (1 - nextA)); + out.push({ radius: (GLOW_BASE_PX * k) / n, paintAlpha: paint }); + nextA = a; + } + return out; +} + +/** + * Профиль прорези тьмы: накопленная сила стирания тьмы изнутри наружу + * (7 ступеней: центр — тьма снята полностью, край — едва тронута). + */ +export const HOLE_PROFILE: readonly number[] = [1, 0.95, 0.82, 0.62, 0.4, 0.2, 0.08]; + +/** Профиль тона: приглушённое ядро, максимум на среднем кольце (6 ступеней). */ +export const TONE_PROFILE: readonly number[] = [0.35, 0.45, 0.4, 0.3, 0.18, 0.08]; + +/** Ступени текстуры прорези (erase-карта силы тьмы). */ +export function holeStepAlphas(): RingStep[] { + return stepAlphas(HOLE_PROFILE); +} + +/** Ступени текстуры тона (аддитивная подсветка вокруг прорези). */ +export function toneStepAlphas(): RingStep[] { + return stepAlphas(TONE_PROFILE); +} + +/** Шаг квантования силы прорези: меньше перерисовок RT, мерцание «ступеньками». */ +export const HOLE_ALPHA_STEPS = 8; + +/** Квантование альфы стирания до 1/HOLE_ALPHA_STEPS — детерминизм bake. */ +export function quantizeHoleAlpha(alpha: number): number { + const a = Math.max(0, Math.min(1, alpha)); + return Math.round(a * HOLE_ALPHA_STEPS) / HOLE_ALPHA_STEPS; +} + +/** Лерп ambient-цвета по каналам: k=0 → from, k=1 → to. */ +export function lerpAmbient(from: number, to: number, k: number): number { + return lerpColor(from, to, k); +} + +/** Яркость ambient-цвета: dimColor(c, 1) === c, dimColor(c, 0) === 0x000000. */ +export function dimColor(color: number, brightness: number): number { + const k = Math.max(0, Math.min(1, brightness)); + const r = Math.round(((color >> 16) & 0xff) * k); + const g = Math.round(((color >> 8) & 0xff) * k); + const b = Math.round((color & 0xff) * k); + return (r << 16) | (g << 8) | b; +} + +/** + * Границы суток в часах: рассвет dawn..day (ночь → день), + * день day..dusk, закат dusk..night (день → ночь), ночь night..dawn. + */ +export interface DayNightSpec { + dawn: number; + day: number; + dusk: number; + night: number; +} + +/** Дефолтная кривая игры: рассвет 06–08, закат 18–20, ночь 20–06. */ +export const DEFAULT_DAY_NIGHT: DayNightSpec = { dawn: 6, day: 8, dusk: 18, night: 20 }; + +/** Доля перехода день→ночь, на которой пик золотого часа. */ +export const DUSK_PEAK = 0.45; + +/** Дефолтный закатный тон (тёпло-золотой) — игра может переопределить. */ +export const DEFAULT_DUSK_COLOR = 0xe8c090; + +/** + * Трёхопорный микс суток по фактору ночи k: день → золотой час → ночь + * (рассвет проходит через тот же тёплый тон — это ожидаемо). Без к≥1/≤0 + * — крайние опоры; непрерывность и монотонность по k сохранены. + */ +export function dayNightAmbient( + day: number, + night: number, + k: number, + duskColor: number = DEFAULT_DUSK_COLOR +): number { + if (k <= 0) return day; + if (k >= 1) return night; + if (k < DUSK_PEAK) return lerpAmbient(day, duskColor, k / DUSK_PEAK); + return lerpAmbient(duskColor, night, (k - DUSK_PEAK) / (1 - DUSK_PEAK)); +} + +/** + * Фактор ночи 0..1 по времени суток (часы, mod 24): 0 — день, 1 — ночь, + * линейный переход на рассвете и закате. Непрерывен на стыке 24/0. + */ +export function dayNightFactor(tHours: number, spec: DayNightSpec = DEFAULT_DAY_NIGHT): number { + const t = ((tHours % 24) + 24) % 24; + if (t >= spec.night || t < spec.dawn) return 1; + if (t < spec.day) return 1 - (t - spec.dawn) / (spec.day - spec.dawn); + if (t < spec.dusk) return 0; + return (t - spec.dusk) / (spec.night - spec.dusk); +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/particleSim.ts b/v1/packages/engine/src/render/particleSim.ts new file mode 100644 index 0000000..502386c --- /dev/null +++ b/v1/packages/engine/src/render/particleSim.ts @@ -0,0 +1,158 @@ +/** + * Чистая симуляция частицы — без Pixi, тестируется в Vitest. + * ParticleEmitter использует эти функции для интеграции и спавна. + */ + +import type { Rng } from '../math/rng'; +import { lerpColor } from '../anim/flash'; +import type { EmitterOptions } from './Particles'; + +/** Состояние частицы без привязки к отображению. */ +export interface ParticleState { + x: number; + y: number; + vx: number; + vy: number; + age: number; + /** Секунд до смерти. */ + lifetime: number; + /** Угол (рад) и скорость вращения (рад/сек). */ + rot: number; + spin: number; + /** Базовый размер: px для квадратов, множитель для текстурных частиц. */ + scale: number; + /** Цвет, выбранный при спавне (из color/colors). */ + tint: number; + /** Персональное «зерно» 0..1: фаза wobble и прочая вариация. */ + k: number; +} + +/** Опции симуляции — общие для всех частиц эмиттера. */ +export interface ParticleSimOptions { + acceleration?: { x?: number; y?: number }; + /** Затухание альфы: true — вся жизнь; число — хвост в секундах. */ + fadeOut?: boolean | number; + /** Сек нарастания альфы в начале жизни. */ + fadeIn?: number; + /** Торможение, 1/сек: v *= max(0, 1 - drag*dt). */ + drag?: number; + /** Масштаб по жизни: явный диапазон или предустановка. */ + scaleOverLife?: [number, number] | 'shrink' | 'grow'; + /** Цвет по жизни: лерп 0xRRGGBB от начала к концу. */ + colorOverLife?: [number, number]; + /** Амплитуда синус-дрейфа по X, px/сек (мотыльки, планктон). */ + wobble?: number; +} + +/** Видимая часть частицы на шаге (базовый размер уже учтён в scale). */ +export interface ParticleVisual { + alpha: number; + scale: number; + rotation: number; + tint: number; +} + +/** + * Один шаг интеграции (полу-неявный Эйлер) + видимые кривые по жизни. + * Мутирует p (позиция/скорость/угол/возраст), возвращает визуал. + * out — переиспользуемый визуал (горячий путь: без аллокации на частицу/тик); + * без out — свежий объект. + */ +export function stepParticle(p: ParticleState, dt: number, opts: ParticleSimOptions, out?: ParticleVisual): ParticleVisual { + p.age += dt; + const drag = Math.max(0, 1 - (opts.drag ?? 0) * dt); + p.vx *= drag; + p.vy *= drag; + p.vx += (opts.acceleration?.x ?? 0) * dt; + p.vy += (opts.acceleration?.y ?? 0) * dt; + const wobble = opts.wobble ? Math.sin(p.age * 3 + p.k * Math.PI * 2) * opts.wobble : 0; + p.x += (p.vx + wobble) * dt; + p.y += p.vy * dt; + p.rot += p.spin * dt; + + // Альфа: нарастание в начале × затухание в конце. + const fadeIn = opts.fadeIn ? Math.min(1, p.age / opts.fadeIn) : 1; + const fadeTail = opts.fadeOut === false ? 1 + : opts.fadeOut === true || opts.fadeOut === undefined + ? 1 - p.age / p.lifetime + : Math.min(1, (p.lifetime - p.age) / opts.fadeOut); + const alpha = Math.max(0, Math.min(fadeIn, fadeTail)); + + // Масштаб: кривая по жизни, умноженная на базовый размер частицы. + const lifeK = Math.min(1, p.age / p.lifetime); + const curve = opts.scaleOverLife === 'shrink' + ? 1 - lifeK + : opts.scaleOverLife === 'grow' + ? lifeK + : opts.scaleOverLife + ? opts.scaleOverLife[0] + (opts.scaleOverLife[1] - opts.scaleOverLife[0]) * lifeK + : 1; + + const v: ParticleVisual = out ?? { alpha: 0, scale: 1, rotation: 0, tint: 0xffffff }; + v.alpha = alpha; + v.scale = Math.max(0, p.scale * curve); + v.rotation = p.rot; + v.tint = opts.colorOverLife ? lerpColor(opts.colorOverLife[0], opts.colorOverLife[1], lifeK) : p.tint; + return v; +} + +/** Случайная начальная частица по опциям эмиттера (позиция относительно центра). */ +export function sampleSpawn(options: EmitterOptions, rng: Rng): ParticleState { + const area = options.spawnArea; + const v = options.velocity; + return { + x: area ? rng.int(0, area.width) - area.width / 2 : 0, + y: area ? rng.int(0, area.height) - area.height / 2 : 0, + vx: v ? v.x[0] + rng.next() * (v.x[1] - v.x[0]) : 0, + vy: v ? v.y[0] + rng.next() * (v.y[1] - v.y[0]) : 0, + age: 0, + lifetime: + options.lifetime[0] + rng.next() * (options.lifetime[1] - options.lifetime[0]), + rot: sampleRange(options.rotation, rng), + spin: sampleRange(options.spin, rng), + scale: options.scale ?? 1, + tint: options.colors ? options.colors[rng.int(0, options.colors.length - 1)] : options.color ?? 0xffffff, + k: rng.next() + }; +} + +/** + * Случайный «взрыв»: частицы разлетаются из точки по кругу. + * speed — (min..max) пикселей/сек; spin/scale — опциональная вариация. + */ +export function sampleBurst( + count: number, + speed: [number, number], + lifetime: [number, number], + rng: Rng, + opts: { spin?: [number, number]; scale?: number; colors?: number[]; color?: number } = {} +): ParticleState[] { + const out: ParticleState[] = []; + for (let i = 0; i < count; i++) { + const angle = rng.next() * Math.PI * 2; + const v = speed[0] + rng.next() * (speed[1] - speed[0]); + out.push({ + x: 0, + y: 0, + vx: Math.cos(angle) * v, + vy: Math.sin(angle) * v, + age: 0, + lifetime: lifetime[0] + rng.next() * (lifetime[1] - lifetime[0]), + rot: sampleRange(opts.spin, rng) + angle, + spin: 0, + scale: opts.scale ?? 1, + tint: opts.colors ? opts.colors[rng.int(0, opts.colors.length - 1)] : opts.color ?? 0xffffff, + k: rng.next() + }); + } + return out; +} + +/** Слияние базовых опций с переопределением burst-а — чистое, без мутации. */ +export function mergeEmitterOptions(base: EmitterOptions, over?: Partial): EmitterOptions { + return over ? { ...base, ...over } : { ...base }; +} + +function sampleRange(range: [number, number] | undefined, rng: Rng): number { + return range ? range[0] + rng.next() * (range[1] - range[0]) : 0; +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/postfx/PostFx.ts b/v1/packages/engine/src/render/postfx/PostFx.ts new file mode 100644 index 0000000..53241f6 --- /dev/null +++ b/v1/packages/engine/src/render/postfx/PostFx.ts @@ -0,0 +1,255 @@ +/** + * Пост-эффекты: один кастомный фильтр на корне мира — переменный блюр + * (глобальный и точечный с радиусом), хроматическая аберрация, марево. + * API по образцу Lighting: состояние set* + затухающие импульсы pulse* + * (огибающая — pulseEnvelope из postSim). Координаты точек — виртуальные + * экранные пиксели (текстура фильтра выровнена по вьюпорту, камера уже + * учтена); радиусы — в тех же пикселях. blurObject — отдельный BlurFilter + * на конкретной вьюхе (не участвует в общем фильтре). + */ +import { BlurFilter, Container, Filter, GlProgram, UniformGroup } from 'pixi.js'; +import { blurKernel, pulseEnvelope, pulseTotal, type PulseSpec, type ShimmerParams } from './postSim'; + +const VERT = `in vec2 aPosition; +out vec2 vTextureCoord; +uniform vec4 uInputSize; +uniform vec4 uOutputFrame; +uniform vec4 uOutputTexture; +vec4 filterVertexPosition(vec2 aPosition) +{ + vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy; + position.x = position.x * (2.0 / uOutputTexture.x) - 1.0; + position.y = position.y * (2.0 * uOutputTexture.z / uOutputTexture.y) - uOutputTexture.z; + return vec4(position, 0.0, 1.0); +} +vec2 filterTextureCoord(vec2 aPosition) +{ + return aPosition * (uOutputFrame.zw * uInputSize.zw); +} +void main(void) +{ + gl_Position = filterVertexPosition(aPosition); + vTextureCoord = filterTextureCoord(aPosition); +}`; + +// Ядро блюра (постоянное): спираль золотого сечения из postSim, развёрнутая +// в явные сэмплы — const-массивы недопустимы в GLSL ES 1.00 (WebGL1-фолбэк). +const KERNEL = blurKernel(8) + .map(([x, y]) => `acc += texture(uTexture, base + vec2(${x.toFixed(4)}, ${y.toFixed(4)}) * s / uViewport);`) + .join('\n '); + +const FRAG = `in vec2 vTextureCoord; +out vec4 finalColor; +uniform sampler2D uTexture; +uniform vec2 uViewport; +uniform float uBlurStrength; +uniform vec2 uBlurCenter; +uniform float uBlurRadius; +uniform float uAberration; +uniform float uShimmerAmp; +uniform vec2 uShimmerCenter; +uniform float uShimmerRadius; +uniform float uShimmerWl; +uniform float uShimmerSpeed; +uniform float uTime; +// Спад силы от точки (гладкая ступень, как в postSim); радиус <= 0 — глобально. +float fall(vec2 px, vec2 center, float radius) +{ + if (radius <= 0.0) return 1.0; + float t = clamp(1.0 - length(px - center) / radius, 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} +void main(void) +{ + vec2 px = vTextureCoord * uViewport; + vec2 base = vTextureCoord; + if (uShimmerAmp > 0.001) { + float dy = uShimmerAmp * sin((px.x / uShimmerWl + uTime * uShimmerSpeed) * 6.2831853); + base.y += (dy * fall(px, uShimmerCenter, uShimmerRadius)) / uViewport.y; + } + if (uAberration > 0.001) { + // Радиальный сдвиг каналов от центра экрана: R наружу, B внутрь. + vec2 c = uViewport * 0.5; + vec2 d = px - c; + vec2 dir = length(d) > 0.0001 ? normalize(d) : vec2(0.0); + float r = texture(uTexture, base + dir * (uAberration / uViewport.x)).r; + vec4 g = texture(uTexture, base); + float b = texture(uTexture, base - dir * (uAberration / uViewport.x)).b; + finalColor = vec4(r, g.g, b, g.a); + return; + } + if (uBlurStrength > 0.001) { + float s = fall(px, uBlurCenter, uBlurRadius) * uBlurStrength; + vec4 acc = vec4(0.0); + ${KERNEL} + finalColor = acc / 8.0; + return; + } + finalColor = texture(uTexture, base); +}`; + +/** Состояние блюра: сила (px выборки), точка и радиус (px; радиус <= 0 — вся сцена). */ +export interface BlurState { + strength: number; + center?: { x: number; y: number }; + radius?: number; +} + +/** Марево: параметры из postSim + опорная зона (вне зоны амплитуда 0). */ +export type ShimmerState = ShimmerParams & { center?: { x: number; y: number }; radius?: number }; + +interface PulseEntry { + elapsed: number; + spec: PulseSpec; + blur?: BlurState; + aberration?: number; +} + +export class PostFx { + /** Фильтр — вешается на корень мира (Renderer.worldRoot.filters). */ + readonly filter: Filter; + private readonly worldRoot: Container; + private readonly viewportX: number; + private readonly viewportY: number; + private readonly uniforms: UniformGroup; + + private time = 0; + private blur: BlurState | null = null; + private aberration = 0; + private shimmer: ShimmerState | null = null; + private readonly pulses = new Map(); + private pulseSeq = 0; + private readonly objectBlurs = new Map(); + private disposed = false; + + constructor(worldRoot: Container, viewportX: number, viewportY: number) { + this.worldRoot = worldRoot; + this.viewportX = viewportX; + this.viewportY = viewportY; + this.uniforms = new UniformGroup({ + uViewport: { value: new Float32Array([viewportX, viewportY]), type: 'vec2' }, + uBlurStrength: { value: 0, type: 'f32' }, + uBlurCenter: { value: new Float32Array([viewportX / 2, viewportY / 2]), type: 'vec2' }, + uBlurRadius: { value: 0, type: 'f32' }, + uAberration: { value: 0, type: 'f32' }, + uShimmerAmp: { value: 0, type: 'f32' }, + uShimmerCenter: { value: new Float32Array([viewportX / 2, viewportY / 2]), type: 'vec2' }, + uShimmerRadius: { value: 0, type: 'f32' }, + uShimmerWl: { value: 40, type: 'f32' }, + uShimmerSpeed: { value: 1, type: 'f32' }, + uTime: { value: 0, type: 'f32' } + }); + this.filter = new Filter({ + glProgram: GlProgram.from({ vertex: VERT, fragment: FRAG }), + resources: { postFxUniforms: this.uniforms }, + // inherit: текстура фильтра в разрешении рендера — пиксель-арт не мылится. + resolution: 'inherit', + padding: 0 + }); + worldRoot.filters = [this.filter]; + } + + /** Блюр сцены/точки: null — выключить. Импульсы поверх (см. pulseBlur). */ + setBlur(args: BlurState | null): void { + this.blur = args ? { ...args } : null; + } + + /** Импульс блюра с огибающей (attack/hold/decay, сек) — удар, резонанс. */ + pulseBlur(args: BlurState, spec: PulseSpec): void { + const id = `blur#${this.pulseSeq++}`; + this.pulses.set(id, { elapsed: 0, spec, blur: { ...args } }); + } + + /** Хроматическая аберрация, px сдвига каналов (0 — выкл). */ + setAberration(px: number): void { + this.aberration = px; + } + + /** Импульс аберрации — урон, вспышки. */ + pulseAberration(px: number, spec: PulseSpec): void { + const id = `aberr#${this.pulseSeq++}`; + this.pulses.set(id, { elapsed: 0, spec, aberration: px }); + } + + /** Марево (горячий воздух); null — выключить. */ + setShimmer(args: ShimmerState | null): void { + this.shimmer = args ? { ...args } : null; + } + + /** Блюр конкретной вьюхи (отдельный BlurFilter, не общий фильтр). */ + blurObject(view: Container, strength = 3): void { + const existing = this.objectBlurs.get(view); + if (existing) { + existing.strength = strength; + return; + } + const blur = new BlurFilter({ strength, quality: 2, kernelSize: 5 }); + this.objectBlurs.set(view, blur); + view.filters = [...(view.filters ?? []), blur]; + } + + /** Снять объектный блюр. */ + clearBlurObject(view: Container): void { + const blur = this.objectBlurs.get(view); + if (!blur) return; + this.objectBlurs.delete(view); + view.filters = (view.filters ?? []).filter((f) => f !== blur); + } + + /** Сброс состояний и импульсов (объектный блюр не трогает). */ + clear(): void { + this.blur = null; + this.aberration = 0; + this.shimmer = null; + this.pulses.clear(); + } + + /** Тик: время марева, затухание импульсов, перенос состояния в шейдер. */ + update(dt: number): void { + if (this.disposed) return; + this.time += dt; + for (const [id, p] of [...this.pulses]) { + p.elapsed += dt; + if (p.elapsed >= pulseTotal(p.spec)) this.pulses.delete(id); + } + // Эффективные значения: импульс перекрывает установленное состояние. + let blur = this.blur; + let aberration = this.aberration; + for (const p of this.pulses.values()) { + const env = pulseEnvelope(p.elapsed, p.spec); + if (p.blur && env > 0) blur = { ...p.blur, strength: p.blur.strength * env }; + if (p.aberration !== undefined && env > 0) aberration = Math.max(aberration, p.aberration * env); + } + // uniforms-карта v8 даёт сами значения (не обёртки {value}). + const u = this.uniforms.uniforms; + u.uTime = this.time; + u.uBlurStrength = blur ? blur.strength : 0; + (u.uBlurCenter as Float32Array).set([ + blur?.center?.x ?? this.viewportX / 2, + blur?.center?.y ?? this.viewportY / 2 + ]); + u.uBlurRadius = blur?.radius ?? 0; + u.uAberration = aberration; + u.uShimmerAmp = this.shimmer ? this.shimmer.amp : 0; + (u.uShimmerCenter as Float32Array).set([ + this.shimmer?.center?.x ?? this.viewportX / 2, + this.shimmer?.center?.y ?? this.viewportY / 2 + ]); + u.uShimmerRadius = this.shimmer?.radius ?? 0; + u.uShimmerWl = this.shimmer?.wavelength ?? 40; + u.uShimmerSpeed = this.shimmer?.speed ?? 1; + this.filter.enabled = + (blur !== null && blur.strength > 0.001) || + aberration > 0.001 || + (this.shimmer !== null && this.shimmer.amp > 0.001) || + this.pulses.size > 0; + } + + /** Снять фильтр с корня мира и объектные блюры. */ + dispose(): void { + this.disposed = true; + this.clear(); + for (const view of [...this.objectBlurs.keys()]) this.clearBlurObject(view); + this.worldRoot.filters = (this.worldRoot.filters ?? []).filter((f) => f !== this.filter); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/postfx/__tests__/postSim.test.ts b/v1/packages/engine/src/render/postfx/__tests__/postSim.test.ts new file mode 100644 index 0000000..ffc289f --- /dev/null +++ b/v1/packages/engine/src/render/postfx/__tests__/postSim.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { + aberrationShiftAt, + blurKernel, + blurStrengthAt, + pulseEnvelope, + pulseTotal, + shimmerOffsetY +} from '../postSim'; + +describe('blurStrengthAt', () => { + it('глобальный режим: сила постоянна', () => { + expect(blurStrengthAt(0, 0, 4)).toBe(4); + expect(blurStrengthAt(1000, 0, 4)).toBe(4); + }); + + it('пик в центре, гладкий спад до нуля на радиусе', () => { + expect(blurStrengthAt(0, 100, 4)).toBe(4); + expect(blurStrengthAt(100, 100, 4)).toBe(0); + expect(blurStrengthAt(150, 100, 4)).toBe(0); // за радиусом — 0 + const mid = blurStrengthAt(50, 100, 4); + expect(mid).toBeGreaterThan(0); + expect(mid).toBeLessThan(4); + }); + + it('спад симметричен и монотонен', () => { + let prev = Infinity; + for (let d = 0; d <= 100; d += 5) { + const v = blurStrengthAt(d, 100, 4); + expect(v).toBeLessThanOrEqual(prev); + prev = v; + } + }); +}); + +describe('blurKernel', () => { + it('смещения в пределах единичного круга и различны', () => { + const k = blurKernel(8); + expect(k.length).toBe(8); + const seen = new Set(k.map(([x, y]) => `${x.toFixed(4)},${y.toFixed(4)}`)); + expect(seen.size).toBe(8); + for (const [x, y] of k) { + expect(Math.hypot(x, y)).toBeLessThanOrEqual(1); + } + }); +}); + +describe('aberrationShiftAt', () => { + it('тот же профиль спада, что у блюра', () => { + expect(aberrationShiftAt(0, 100, 2)).toBe(2); + expect(aberrationShiftAt(100, 100, 2)).toBe(0); + expect(aberrationShiftAt(0, 0, 2)).toBe(2); // глобальный режим + }); +}); + +describe('shimmerOffsetY', () => { + it('периодичен по X и по времени', () => { + const p = { amp: 1.5, wavelength: 40, speed: 2 }; + expect(shimmerOffsetY(0, 0, p)).toBeCloseTo(0); + expect(shimmerOffsetY(40, 0, p)).toBeCloseTo(0); // период по X = wavelength + expect(shimmerOffsetY(0, 0.5, p)).toBeCloseTo(0); // период по t = 1/speed + expect(Math.abs(shimmerOffsetY(10, 0, p))).toBeCloseTo(p.amp); // четверть волны + }); +}); + +describe('переэкспорт pulseEnvelope', () => { + it('атака/плато/спад сходятся', () => { + const spec = { attack: 0.1, hold: 0.2, decay: 0.3 }; + expect(pulseTotal(spec)).toBeCloseTo(0.6); + expect(pulseEnvelope(0.05, spec)).toBeCloseTo(0.5); + expect(pulseEnvelope(0.2, spec)).toBe(1); + expect(pulseEnvelope(0.45, spec)).toBeCloseTo(0.5); + expect(pulseEnvelope(0.7, spec)).toBe(0); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/render/postfx/postSim.ts b/v1/packages/engine/src/render/postfx/postSim.ts new file mode 100644 index 0000000..41be933 --- /dev/null +++ b/v1/packages/engine/src/render/postfx/postSim.ts @@ -0,0 +1,58 @@ +/** + * Чистая математика пост-эффектов: переменный блюр (радиальный спад силы), + * сэмпл-ядро блюра, хроматическая аберрация, марево. Без Pixi — тестируется + * в Vitest headless; PostFx.ts только переносит сэмплы в шейдер. + */ +export { pulseEnvelope, pulseTotal, type PulseSpec } from '../lightSim'; + +/** Гладкая ступень 0..1 (с нулевой производной на краях — без «шва» спада). */ +function smoothstep(t: number): number { + const x = Math.max(0, Math.min(1, t)); + return x * x * (3 - 2 * x); +} + +/** + * Сила блюра на расстоянии distPx от центра, px выборки: пик в центре, + * гладкий спад до нуля на радиусе. radiusPx <= 0 — глобальный режим + * (сила постоянна по всему кадру). + */ +export function blurStrengthAt(distPx: number, radiusPx: number, strengthPx: number): number { + if (radiusPx <= 0) return strengthPx; + return strengthPx * smoothstep(1 - distPx / radiusPx); +} + +/** + * Ядро блюра: taps смещений равномерного диска (спираль золотого сечения) — + * покрывает круг без кластерных артефактов сетки; норма 1 px выборки. + */ +export function blurKernel(taps = 8): ReadonlyArray { + const golden = Math.PI * (3 - Math.sqrt(5)) * 2; + const out: Array = []; + for (let i = 0; i < taps; i++) { + const r = Math.sqrt((i + 0.5) / taps); + const a = i * golden; + out.push([Math.cos(a) * r, Math.sin(a) * r]); + } + return out; +} + +/** + * Сдвиг каналов хроматической аберрации на расстоянии distPx от центра, px: + * R/B разводятся по радиалии; спад как у блюра — импульсы «от точки». + */ +export function aberrationShiftAt(distPx: number, radiusPx: number, strengthPx: number): number { + if (radiusPx <= 0) return strengthPx; + return strengthPx * smoothstep(1 - distPx / radiusPx); +} + +/** Параметры марева: амплитуда px, длина волны px, скорость фаз/с. */ +export interface ShimmerParams { + amp: number; + wavelength: number; + speed: number; +} + +/** Смещение по Y (px) синусоидой по X и времени — «горячий воздух». */ +export function shimmerOffsetY(xPx: number, timeSec: number, p: ShimmerParams): number { + return p.amp * Math.sin((xPx / p.wavelength + timeSec * p.speed) * Math.PI * 2); +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/scale.ts b/v1/packages/engine/src/render/scale.ts new file mode 100644 index 0000000..e8df89c --- /dev/null +++ b/v1/packages/engine/src/render/scale.ts @@ -0,0 +1,8 @@ +/** + * Целочисленный масштаб виртуального разрешения под размер окна (pixel-perfect). + */ + +/** Наибольший целый множитель, при котором виртуальный экран влезает в окно. */ +export function computeScale(vw: number, vh: number, winW: number, winH: number): number { + return Math.max(1, Math.floor(Math.min(winW / vw, winH / vh))); +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/shadowSim.ts b/v1/packages/engine/src/render/shadowSim.ts new file mode 100644 index 0000000..766fdb5 --- /dev/null +++ b/v1/packages/engine/src/render/shadowSim.ts @@ -0,0 +1,93 @@ +/** + * Чистая математика теней — без Pixi, тестируется в Vitest. + * Солнце ходит по дуге в пределах дня из DayNightSpec (dawn..night): тень + * утром «влево по экрану», вечером «вправо», в полдень короткая; направление + * проецируется изометрией из плоскости мира — тень всегда лежит на земле. + * Мир пепельный — свет рассеянный, тени мягкие: альфа скромная, края + * ступенчатые (рисует makeShadowTexture), геометрия — эллипс, сидящий на базе. + */ + +import { type DayNightSpec, DEFAULT_DAY_NIGHT } from './lightSim'; +import { DEFAULT_ISO } from '../math/iso'; + +/** Цвет тени — нейтральный тёмно-серый (пепельная гамма, не чёрный). */ +export const SHADOW_COLOR = 0x2f2f38; + +/** Гладкая ступень 0..1 (нулевой наклон на краях — без рывков альфы). */ +function smooth(t: number): number { + const x = Math.max(0, Math.min(1, t)); + return x * x * (3 - 2 * x); +} + +/** Состояние солнца для теней: направление тени (экран), длина, альфа. */ +export interface SunShadow { + /** Единичный вектор тени на экране (dx вправо, dy вниз); лежит на изо-земле. */ + dirX: number; + dirY: number; + /** Длина тени на единицу высоты объекта (короткая в полдень). */ + len: number; + /** Сила тени 0..1 (пепельный мир — солнце рассеянное). */ + alpha: number; +} + +/** + * Солнце по часам суток: null — ночи (тьма рисует darkness-слой, тени от + * солнца не видны). Границы и разгон альфы — из DayNightSpec: подъём + * dawn..day, спад dusk..night. Направление тени считается АЗИМУТОМ в + * плоскости мира и проецируется изометрией (мировой юг → экран влево-вниз, + * мировая диагональ юго-восток → ровно вниз): любой вектор с этой дуги + * лежит на земле — тень не «стекает по стене» и не отрывает объект от пола. + */ +export function sunShadow(hours: number, spec: DayNightSpec = DEFAULT_DAY_NIGHT): SunShadow | null { + const t = ((hours % 24) + 24) % 24; + const rise = spec.dawn; + const set = spec.night; + if (t < rise || t >= set) return null; + const up = smooth((t - rise) / Math.max(0.001, spec.day - rise)); + const down = 1 - smooth((t - spec.dusk) / Math.max(0.001, set - spec.dusk)); + const k = (t - rise) / (set - rise); // 0..1 — путь солнца за день + const az = (Math.PI / 2) * (1 - k); // мировой юг (утро) → юго-восток (вечер) + const gx = Math.cos(az); + const gy = Math.sin(az); + const px = (gx - gy) * (DEFAULT_ISO.tileW / 2); + const py = (gx + gy) * (DEFAULT_ISO.tileH / 2); + const n = Math.hypot(px, py); + const len = 0.55 + 1.05 * Math.pow(Math.abs(k - 0.5) * 2, 1.4); + return { dirX: px / n, dirY: py / n, len, alpha: 0.5 * Math.min(up, down) }; +} + +/** Размещение блоба-тени относительно базы объекта (в px виртуального экрана). */ +export interface BlobPlacement { + /** Центр эллипса относительно точки базы (ноги/основания). */ + x: number; + y: number; + /** Поворот эллипса — вдоль направления тени. */ + rotation: number; + /** Полуоси эллипса, px. */ + scaleX: number; + scaleY: number; + /** Прозрачность — сила солнца. */ + alpha: number; +} + +/** + * Эллипс тени для объекта высотой heightPx с «толщиной» radiusPx. Масса + * эллипса сидит у базы: центр лишь чуть съезжает вдоль тени, а ближний край + * заходит за базу — иначе видимая тёмная масса висит в стороне от объекта + * и тот читается «летающим». Вылет вдоль направления растёт с высотой и + * низким солнцем (len), ширина поперёк почти не меняется от длины. + */ +export function blobPlacement(radiusPx: number, heightPx: number, sun: SunShadow): BlobPlacement { + const reach = heightPx * sun.len; + const center = radiusPx * 0.3 + reach * 0.2; // центр массы от базы + const a = center + radiusPx * 0.55 + reach * 0.22; // полуось вдоль тени + const b = radiusPx * (0.75 + 0.25 * Math.min(1, sun.len)); // полуось поперёк + return { + x: sun.dirX * center, + y: sun.dirY * center, + rotation: Math.atan2(sun.dirY, sun.dirX), + scaleX: a, + scaleY: b, + alpha: sun.alpha + }; +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/shadows.ts b/v1/packages/engine/src/render/shadows.ts new file mode 100644 index 0000000..2ebfb42 --- /dev/null +++ b/v1/packages/engine/src/render/shadows.ts @@ -0,0 +1,41 @@ +/** + * Вьюхи теней поверх чистой математики shadowSim: текстура блоба + * (ступенчатый эллипс — мягкий край без мыла, пиксель-стиль) и раскладка + * спрайта по размещению из blobPlacement. Тонко: состояние считает сцена. + */ +import { Graphics, Sprite, Texture } from 'pixi.js'; +import { type BlobPlacement } from './shadowSim'; +import { SHADOW_COLOR } from './shadowSim'; +import { type Renderer } from './Renderer'; + +/** Полуось текстуры блоба, px виртуального разрешения (масштабируется спрайтом). */ +export const SHADOW_TEX_PX = 32; + +/** Текстура блоба-тени: три ступени прозрачности от края к центру. */ +export function makeShadowTexture(renderer: Renderer): Texture { + const g = new Graphics(); + // Ступени с запасом: спрайт-альфа (до ~0.5 в разгар дня) умножается + // поверх — суммарная плотность центра должна оставаться читаемой на + // тёмной траве, иначе объект «висит» без контактной тени. + g.ellipse(0, 0, SHADOW_TEX_PX, SHADOW_TEX_PX).fill({ color: SHADOW_COLOR, alpha: 0.4 }); + g.ellipse(0, 0, SHADOW_TEX_PX * 0.72, SHADOW_TEX_PX * 0.72).fill({ color: SHADOW_COLOR, alpha: 0.44 }); + g.ellipse(0, 0, SHADOW_TEX_PX * 0.45, SHADOW_TEX_PX * 0.45).fill({ color: SHADOW_COLOR, alpha: 0.5 }); + const tex = renderer.app.renderer.generateTexture({ target: g, resolution: 1 }); + g.destroy(); + return tex; +} + +/** + * Положить спрайт-блоб (текстура makeShadowTexture) по размещению blobPlacement: + * base — точка базы объекта (ноги/основание) в координатах родителя. + */ +export function applyBlobShadow( + sprite: Sprite, + base: { x: number; y: number }, + blob: BlobPlacement +): void { + sprite.position.set(base.x + blob.x, base.y + blob.y); + sprite.rotation = blob.rotation; + sprite.scale.set(blob.scaleX / SHADOW_TEX_PX, blob.scaleY / SHADOW_TEX_PX); + sprite.alpha = blob.alpha; +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/shake.ts b/v1/packages/engine/src/render/shake.ts new file mode 100644 index 0000000..6acf696 --- /dev/null +++ b/v1/packages/engine/src/render/shake.ts @@ -0,0 +1,58 @@ +/** + * Тряска камеры: несколько одновременных толчков, затухание линейно по времени. + * Чистая логика (без Pixi) — смещение детерминировано seed'ом, тестируется в Vitest. + */ + +import { createRng, type Rng } from '../math/rng'; + +interface ShakeEntry { + strength: number; + duration: number; + elapsed: number; +} + +export class Shake { + private entries: ShakeEntry[] = []; + private rng: Rng; + private offsetX = 0; + private offsetY = 0; + + constructor(seed = 0x5eed1234) { + this.rng = createRng(seed); + } + + /** Добавить толчок: амплитуда в пикселях, длительность в секундах. */ + add(strength: number, duration: number): void { + if (strength <= 0 || duration <= 0) return; + this.entries.push({ strength, duration, elapsed: 0 }); + } + + update(dt: number): void { + this.offsetX = 0; + this.offsetY = 0; + let w = 0; + for (const e of this.entries) { + e.elapsed += dt; + const k = Math.max(0, 1 - e.elapsed / e.duration); // линейное затухание + this.offsetX += (this.rng.next() * 2 - 1) * e.strength * k; + this.offsetY += (this.rng.next() * 2 - 1) * e.strength * k; + if (e.elapsed < e.duration) this.entries[w++] = e; // компакт на месте, без filter + } + this.entries.length = w; + } + + /** Текущее смещение в пикселях (0 вне активной тряски). */ + get offset(): { x: number; y: number } { + return { x: Math.round(this.offsetX), y: Math.round(this.offsetY) }; + } + + get active(): boolean { + return this.entries.length > 0; + } + + clear(): void { + this.entries = []; + this.offsetX = 0; + this.offsetY = 0; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/render/spriteFx.ts b/v1/packages/engine/src/render/spriteFx.ts new file mode 100644 index 0000000..a821bb7 --- /dev/null +++ b/v1/packages/engine/src/render/spriteFx.ts @@ -0,0 +1,48 @@ +import { Sprite } from 'pixi.js'; +import { TintFlash } from '../anim/flash'; + +/** + * Тонкий Pixi-адаптер над anim/flash: хит-флэш спрайта. + * Логика лерпа — в TintFlash (тестируется), здесь только применение tint. + */ +export class SpriteFlash { + private flash: TintFlash | null = null; + + constructor( + private readonly sprite: Sprite, + private readonly flashColor: number, + private readonly seconds = 0.15 + ) {} + + /** Запустить вспышку (перезапускается с нуля). */ + start(): void { + if (!this.flash) { + this.flash = new TintFlash(this.sprite.tint, this.flashColor, this.seconds); + } + this.flash.start(); + } + + /** Обновлять каждый тик владельцем (например, боевой системой). */ + update(dt: number): void { + if (!this.flash) return; + const tint = this.flash.update(dt); + if (tint !== null) { + this.sprite.tint = tint; + } else { + this.flash = null; + } + } + + /** Прекратить вспышку и вернуть исходный tint (контракт fx: cancel = сброс). */ + cancel(): void { + const f = this.flash; + this.flash = null; + if (!f || this.sprite.destroyed) return; + this.sprite.tint = f.finish(); + } + + /** Самозачистка из реестра: спрайт разрушен — запись вычистится сама. */ + get destroyed(): boolean { + return this.sprite.destroyed; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/save/SaveManager.ts b/v1/packages/engine/src/save/SaveManager.ts new file mode 100644 index 0000000..25ec53d --- /dev/null +++ b/v1/packages/engine/src/save/SaveManager.ts @@ -0,0 +1,91 @@ +/** + * Сохранения в JSON-слотах. Хранилище инъекцией (localStorage или любой + * Storage-подобный объект) — чтобы работать и в тестах без браузера. + * Метаданные слота (заголовок, время) — в параллельном ключе `meta:`: + * формат самих сейвов не меняется, старые сейвы остаются читаемыми. + */ + +/** Метаданные слота — читаются без полной загрузки сейва (задаёт игра). */ +export interface SaveSlotMeta { + /** Человекочитаемый заголовок (движок не знает жанра). */ + title?: string; + /** Момент записи (Date.now()). */ + savedAt?: number; + /** Версия формата сейва. */ + version?: number; + /** Жанровые поля игры (область, уровень, часы игры…). */ + extras?: Record; +} + +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, meta?: SaveSlotMeta): void { + this.storage.setItem(this.prefix + slot, JSON.stringify(data)); + if (meta) this.storage.setItem(this.metaKey(slot), JSON.stringify(meta)); + } + + 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; + } + } + + /** Метаданные слота (null — мета не писалась, например у старых сейвов). */ + slotMeta(slot: string): SaveSlotMeta | null { + const raw = this.storage.getItem(this.metaKey(slot)); + if (raw === null) return null; + try { + return JSON.parse(raw) as SaveSlotMeta; + } catch { + return null; + } + } + + /** Слоты с метой (мета может отсутствовать — у сейвов старых версий). */ + list(): Array<{ slot: string; meta: SaveSlotMeta | null }> { + return this.listSlots().map((slot) => ({ slot, meta: this.slotMeta(slot) })); + } + + delete(slot: string): void { + this.storage.removeItem(this.prefix + slot); + this.storage.removeItem(this.metaKey(slot)); + } + + /** Есть ли сохранение в слоте. */ + has(slot: string): boolean { + return this.storage.getItem(this.prefix + slot) !== null; + } + + /** Список занятых слотов (без префикса; мета-ключи не попадают). */ + 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)) { + const slot = key.slice(this.prefix.length); + if (!slot.startsWith('meta:')) out.push(slot); + } + } + return out; + } + + private metaKey(slot: string): string { + return this.prefix + 'meta:' + slot; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/save/__tests__/SaveManager.test.ts b/v1/packages/engine/src/save/__tests__/SaveManager.test.ts new file mode 100644 index 0000000..9d4bae9 --- /dev/null +++ b/v1/packages/engine/src/save/__tests__/SaveManager.test.ts @@ -0,0 +1,67 @@ +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']); + }); + + it('мета пишется в параллельный ключ и читается без сейва', () => { + const storage = memoryStorage(); + const sm = new SaveManager(storage); + sm.save('slot1', { hp: 3 }, { title: 'Луга', savedAt: 42, version: 3 }); + expect(storage.getItem('save:slot1')).not.toContain('Луга'); + expect(sm.slotMeta('slot1')).toEqual({ title: 'Луга', savedAt: 42, version: 3 }); + expect(sm.list()).toEqual([{ slot: 'slot1', meta: { title: 'Луга', savedAt: 42, version: 3 } }]); + }); + + it('сейв без меты и битая мета -> slotMeta null, сейв читается', () => { + const storage = memoryStorage(); + const sm = new SaveManager(storage); + sm.save('old', { hp: 1 }); // без меты — как старые сейвы + expect(sm.slotMeta('old')).toBeNull(); + storage.setItem('save:meta:broken', '{oops'); + expect(sm.slotMeta('broken')).toBeNull(); + }); + + it('delete чистит и сейв, и мету; listSlots без meta:*', () => { + const sm = new SaveManager(memoryStorage()); + sm.save('a', 1, { title: 'A' }); + expect(sm.listSlots()).toEqual(['a']); // meta-ключ не в списке + sm.delete('a'); + expect(sm.has('a')).toBe(false); + expect(sm.slotMeta('a')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/scene/SceneManager.ts b/v1/packages/engine/src/scene/SceneManager.ts new file mode 100644 index 0000000..ba282df --- /dev/null +++ b/v1/packages/engine/src/scene/SceneManager.ts @@ -0,0 +1,176 @@ +import { Graphics } from 'pixi.js'; +import { FadeState } from '../core/fade'; + +/** Линейная интерполяция числа (для фейда оверлея). */ +const lerpNum = (a: number, b: number, k: number): number => a + (b - a) * k; + +/** + * Сцена — экран игры (меню, локация, бой). Менеджер держит стек, + * но в 2D-RPG чаще всего достаточно push/pop/replace. + */ +export interface Scene { + /** Вызывается один раз при входе. */ + enter(): void | Promise; + exit(): void | Promise; + /** dt в секундах, фиксированный шаг. */ + update(dt: number): void; + render(): void; +} + +/** Затемнение экрана при смене сцены. */ +export interface SceneTransition { + /** Общая длительность (затухание + проявление), секунды. По умолчанию 0.5. */ + duration?: number; + /** Цвет затемнения. По умолчанию чёрный. */ + color?: number; +} + +type TransitionKind = 'push' | 'pop' | 'replace'; + +interface PendingTransition { + kind: TransitionKind; + scene?: Scene; + duration: number; + color: number; + phase: 'out' | 'in'; + /** Прозрачность оверлея 0..1 (FadeState вместо ручного t/duration). */ + alpha: FadeState; +} + +export class SceneManager { + private stack: Scene[] = []; + private overlay: Graphics | null = null; + private overlayWidth = 0; + private overlayHeight = 0; + private pending: PendingTransition | null = null; + + /** Движок подключает оверлей затемнения (верхний слой uiRoot). */ + setOverlay(g: Graphics, width: number, height: number): void { + this.overlay = g; + this.overlayWidth = width; + this.overlayHeight = height; + } + + async push(scene: Scene, transition?: SceneTransition): Promise { + if (transition) { + this.begin('push', scene, transition); + return; + } + this.stack.push(scene); + await scene.enter(); + } + + async pop(transition?: SceneTransition): Promise { + if (transition) { + this.begin('pop', undefined, transition); + return; + } + const scene = this.stack.pop(); + if (scene) { + await scene.exit(); + } + } + + /** Заменить верхнюю сцену (например меню -> локация). */ + async replace(scene: Scene, transition?: SceneTransition): Promise { + if (transition) { + this.begin('replace', scene, transition); + return; + } + await this.pop(); + await this.push(scene); + } + + get current(): Scene | undefined { + return this.stack[this.stack.length - 1]; + } + + /** Идёт ли сейчас переход (для блокировки ввода). */ + get transitioning(): boolean { + return this.pending !== null; + } + + update(dt: number): void { + if (this.pending) { + this.stepTransition(dt); + } + this.current?.update(dt); + } + + render(): void { + for (const scene of this.stack) { + scene.render(); + } + } + + private begin(kind: TransitionKind, scene: Scene | undefined, tr: SceneTransition): void { + if (this.pending) return; // параллельные переходы запрещены + // Оверлей должен быть поверх всего UI: сцены добавляли свои view позже. + if (this.overlay?.parent) { + this.overlay.parent.addChild(this.overlay); + } + const duration = tr.duration ?? 0.5; + const alpha = new FadeState(lerpNum, 0); + alpha.to(1, duration); + this.pending = { + kind, + scene, + duration, + color: tr.color ?? 0x000000, + phase: 'out', + alpha + }; + } + + private stepTransition(dt: number): void { + const p = this.pending!; + p.alpha.update(dt); + this.drawOverlay(p.color, p.alpha.value); + + if (p.phase === 'out') { + if (p.alpha.active) return; + // экран скрыт — меняем сцену + this.performSwap(p); + p.phase = 'in'; + p.alpha.to(0, p.duration); + return; + } + + // фаза 'in': проявление + if (!p.alpha.active) { + this.pending = null; + this.overlay?.clear(); + } + } + + private performSwap(p: PendingTransition): void { + switch (p.kind) { + case 'push': + if (p.scene) { + this.stack.push(p.scene); + } + break; + case 'pop': + this.stack.pop()?.exit(); + break; + case 'replace': + this.stack.pop()?.exit(); + if (p.scene) { + this.stack.push(p.scene); + } + break; + } + // enter может быть асинхронным — переход не ждёт его, + // новая сцена проявляется параллельно со своей инициализацией. + void this.stack[this.stack.length - 1]?.enter(); + } + + private drawOverlay(color: number, alpha: number): void { + if (!this.overlay) return; + const g = this.overlay; + g.clear(); + if (alpha > 0) { + g.rect(0, 0, this.overlayWidth, this.overlayHeight).fill({ color, alpha }); + } + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/scene/SceneRegistry.ts b/v1/packages/engine/src/scene/SceneRegistry.ts new file mode 100644 index 0000000..3bc5c12 --- /dev/null +++ b/v1/packages/engine/src/scene/SceneRegistry.ts @@ -0,0 +1,180 @@ +import type { Vec2 } from '../math/Vec2'; + +/** + * Реестр объектов сцены — жанронезависимый примитив «что-то стоит на карте»: + * позиция в мировых юнитах + радиус тела + footprint в тайлах + непрозрачная + * ссылка на контент. Движок не знает ни NPC, ни интерактивов: kind и ref + * задаёт игра. Индекс по тайлам даёт запросы «кто здесь / кто рядом» без + * перебора всей сцены. + */ + +/** Описание объекта при добавлении. pos — ноги объекта, мутабельно. */ +export interface SceneObjectDef { + /** Уникальный в реестре идентификатор ('npc:elder', 'enemy:7', 'prop:14,9'). */ + id: string; + /** Род объекта (строки задаёт игра: 'npc', 'interactable', 'enemy', ...). */ + kind: string; + /** Позиция в мировых юнитах (точка контакта с землёй). */ + pos: Vec2; + /** Радиус тела в юнитах (0 — точка без тела). */ + radius?: number; + /** Занимаемые тайлы от тайла pos (пропы: footprint w×h от низа-центра). */ + footprint?: { w: number; h: number }; + /** Ссылка на контент игры (NpcDef / InteractableDef / сущность ECS) — движок не читает. */ + ref?: unknown; +} + +/** Объект в реестре: def + вычисляемый тайл привязки. */ +export interface SceneObject extends SceneObjectDef { + readonly radius: number; + /** Тайл, к которому привязан объект (floor от pos). */ + tile: { x: number; y: number }; +} + +/** Ключ индекса: тайлы упаковываются в одно число (карты до 4096×4096). */ +const tileKey = (tx: number, ty: number): number => ty * 4096 + tx; + +export class SceneRegistry { + private byId = new Map(); + /** Индекс по тайлу → множество объектов, чей тайл/footprint покрывает его. */ + private byTile = new Map>(); + /** Переиспользуемый Set дедупликации near() — без Set на вызов. */ + private nearSeen = new Set(); + + /** Добавить объект; дубликат id — ошибка (ловится инвариантом сцены). */ + add(def: SceneObjectDef): SceneObject { + if (this.byId.has(def.id)) { + throw new Error(`SceneRegistry: дубликат id ${def.id}`); + } + const obj: SceneObject = { + ...def, + radius: def.radius ?? 0, + tile: { x: Math.floor(def.pos.x), y: Math.floor(def.pos.y) } + }; + this.byId.set(obj.id, obj); + for (const key of this.coveredKeys(obj)) { + this.tileSet(key).add(obj); + } + return obj; + } + + remove(id: string): void { + const obj = this.byId.get(id); + if (!obj) return; + for (const key of this.coveredKeys(obj)) { + this.tileSet(key).delete(obj); + } + this.byId.delete(id); + } + + get(id: string): SceneObject | undefined { + return this.byId.get(id); + } + + /** Все объекты рода kind (порядок добавления). */ + byKind(kind: string): readonly SceneObject[] { + return this.all.filter((o) => o.kind === kind); + } + + get size(): number { + return this.byId.size; + } + + /** Переместить объект (юниты); переиндексация — O(1), если тайл не сменился. */ + move(id: string, pos: Vec2): void { + const obj = this.byId.get(id); + if (!obj) return; + const tx = Math.floor(pos.x); + const ty = Math.floor(pos.y); + obj.pos.x = pos.x; + obj.pos.y = pos.y; + if (tx === obj.tile.x && ty === obj.tile.y) return; + for (const key of this.coveredKeys(obj)) { + this.tileSet(key).delete(obj); + } + obj.tile.x = tx; + obj.tile.y = ty; + for (const key of this.coveredKeys(obj)) { + this.tileSet(key).add(obj); + } + } + + /** Объект, привязанный к тайлу (точка тайла или footprint, накрывающий его). */ + at(tx: number, ty: number, kind?: string): SceneObject | null { + for (const obj of this.byTile.get(tileKey(tx, ty)) ?? []) { + if (!kind || obj.kind === kind) return obj; + } + return null; + } + + /** Объект в мировой точке: тайл точки или ближайший тайл с телом рядом. */ + atWorld(p: Vec2, kind?: string): SceneObject | null { + const hit = this.at(Math.floor(p.x), Math.floor(p.y), kind); + if (hit) return hit; + // Тело может выступать из тайла привязки — ищем в соседних 3×3. + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + for (const obj of this.byTile.get(tileKey(Math.floor(p.x) + dx, Math.floor(p.y) + dy)) ?? []) { + if (obj.radius > 0 && (!kind || obj.kind === kind)) { + const ddx = p.x - obj.pos.x; + const ddy = p.y - obj.pos.y; + if (ddx * ddx + ddy * ddy <= obj.radius * obj.radius) return obj; + } + } + } + } + return null; + } + + /** + * Объекты в радиусе (юниты) от точки; фильтр по роду опционален. + * out — переиспользуемый массив результата (горячий путь без аллокаций). + */ + near(p: Vec2, radius: number, kind?: string, out?: SceneObject[]): SceneObject[] { + const found = out ?? []; + found.length = 0; + const seen = this.nearSeen; + seen.clear(); + const x0 = Math.floor(p.x - radius - 1); + const x1 = Math.floor(p.x + radius + 1); + const y0 = Math.floor(p.y - radius - 1); + const y1 = Math.floor(p.y + radius + 1); + for (let ty = y0; ty <= y1; ty++) { + for (let tx = x0; tx <= x1; tx++) { + for (const obj of this.byTile.get(tileKey(tx, ty)) ?? []) { + if (seen.has(obj) || (kind && obj.kind !== kind)) continue; + seen.add(obj); + const dx = p.x - obj.pos.x; + const dy = p.y - obj.pos.y; + if (dx * dx + dy * dy <= radius * radius) found.push(obj); + } + } + } + return found; + } + + private get all(): SceneObject[] { + return [...this.byId.values()]; + } + + /** Ключи тайлов, покрываемых объектом: сам тайл + footprint. */ + private *coveredKeys(obj: SceneObject): Iterable { + const fw = obj.footprint?.w ?? 1; + const fh = obj.footprint?.h ?? 1; + for (let dy = 0; dy < fh; dy++) { + for (let dx = 0; dx < fw; dx++) { + yield tileKey(obj.tile.x + dx, obj.tile.y + dy); + } + } + } + + private tileSet(key: number): Set { + let set = this.byTile.get(key); + if (!set) { + set = new Set(); + this.byTile.set(key, set); + } + return set; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/scene/__tests__/SceneManager.test.ts b/v1/packages/engine/src/scene/__tests__/SceneManager.test.ts new file mode 100644 index 0000000..cd503d2 --- /dev/null +++ b/v1/packages/engine/src/scene/__tests__/SceneManager.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SceneManager, type Scene } from '../SceneManager'; + +/** Сцена-болванка с журналом вызовов (для проверки порядка enter/exit/update). */ +function makeScene(name: string) { + const events: string[] = []; + const scene: Scene = { + enter: () => { + events.push(`${name}:enter`); + }, + exit: () => { + events.push(`${name}:exit`); + }, + update: () => { + events.push(`${name}:update`); + }, + render: () => { + events.push(`${name}:render`); + } + }; + return { scene, events }; +} + +describe('SceneManager', () => { + it('push без перехода: enter сразу, сцена в стеке и тикает', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + void sm.push(a.scene); + expect(sm.current).toBe(a.scene); + sm.update(0.016); + expect(a.events).toContain('a:enter'); + expect(a.events).toContain('a:update'); + }); + + it('pop без перехода: exit верхней сцены, стек пуст', async () => { + const sm = new SceneManager(); + const a = makeScene('a'); + void sm.push(a.scene); + await sm.pop(); + expect(a.events).toContain('a:exit'); + expect(sm.current).toBeUndefined(); + }); + + it('replace с переходом: out -> swap (exit/enter) -> in, затем transitioning=false', async () => { + const sm = new SceneManager(); + const a = makeScene('a'); + const b = makeScene('b'); + await sm.replace(a.scene); // replace асинхронен даже без перехода + a.events.length = 0; + + void sm.replace(b.scene, { duration: 0.2 }); + expect(sm.transitioning).toBe(true); + + sm.update(0.1); // половина out — сцены ещё не менялись + expect(sm.current).toBe(a.scene); + expect(sm.transitioning).toBe(true); + + sm.update(0.1); // out завершён — swap: exit(a), enter(b) + expect(a.events).toContain('a:exit'); + expect(b.events).toContain('b:enter'); + expect(sm.current).toBe(b.scene); + + sm.update(0.1); + sm.update(0.1); // in завершён + expect(sm.transitioning).toBe(false); + }); + + it('push с переходом: новая сцена входит в середине (за чёрным экраном)', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + const b = makeScene('b'); + void sm.push(a.scene); + + void sm.push(b.scene, { duration: 0.2 }); + sm.update(0.2); // out завершён + expect(sm.current).toBe(b.scene); + expect(b.events).toContain('b:enter'); + }); + + it('параллельный переход глотается: новая сцена не входит', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + const b = makeScene('b'); + const c = makeScene('c'); + void sm.push(a.scene); + + void sm.replace(b.scene, { duration: 0.2 }); + void sm.replace(c.scene, { duration: 0.2 }); // глотается + sm.update(0.2); + sm.update(0.2); + sm.update(0.2); + + expect(sm.current).toBe(b.scene); + expect(c.events).toEqual([]); + }); + + it('update во время перехода тикает текущую (старую) сцену', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + void sm.push(a.scene); + a.events.length = 0; + + void sm.replace(makeScene('b').scene, { duration: 0.2 }); + sm.update(0.05); + expect(a.events).toContain('a:update'); + }); + + it('render рисует весь стек (нижние сцены видны под верхними)', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + const b = makeScene('b'); + void sm.push(a.scene); + void sm.push(b.scene); + + sm.render(); + expect(a.events).toContain('a:render'); + expect(b.events).toContain('b:render'); + }); + + it('enter может быть асинхронным — переход его не ждёт', async () => { + const sm = new SceneManager(); + let resolved = false; + const scene: Scene = { + enter: () => + new Promise((res) => { + setTimeout(() => { + resolved = true; + res(); + }, 5); + }), + exit: () => {}, + update: () => {}, + render: () => {} + }; + const spy = vi.spyOn(scene, 'enter'); + await sm.push(scene); + expect(spy).toHaveBeenCalled(); + // Не ждём промис — переход уже вернулся. + await new Promise((r) => setTimeout(r, 10)); + expect(resolved).toBe(true); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/scene/__tests__/SceneRegistry.test.ts b/v1/packages/engine/src/scene/__tests__/SceneRegistry.test.ts new file mode 100644 index 0000000..a7f4889 --- /dev/null +++ b/v1/packages/engine/src/scene/__tests__/SceneRegistry.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { SceneRegistry } from '../SceneRegistry'; + +describe('SceneRegistry', () => { + it('add/get/remove и byKind по роду', () => { + const r = new SceneRegistry(); + const a = r.add({ id: 'npc:1', kind: 'npc', pos: { x: 3.5, y: 4.5 } }); + r.add({ id: 'enemy:1', kind: 'enemy', pos: { x: 8.5, y: 2.5 } }); + expect(r.get('npc:1')).toBe(a); + expect(a.tile).toEqual({ x: 3, y: 4 }); + expect(r.byKind('npc').map((o) => o.id)).toEqual(['npc:1']); + expect(r.size).toBe(2); + r.remove('npc:1'); + expect(r.get('npc:1')).toBeUndefined(); + expect(r.at(3, 4)).toBeNull(); + }); + + it('дубликат id — throw', () => { + const r = new SceneRegistry(); + r.add({ id: 'x', kind: 'npc', pos: { x: 1, y: 1 } }); + expect(() => r.add({ id: 'x', kind: 'npc', pos: { x: 2, y: 2 } })).toThrow('дубликат'); + }); + + it('move переиндексирует тайл (и O(1) внутри тайла)', () => { + const r = new SceneRegistry(); + r.add({ id: 'e', kind: 'enemy', pos: { x: 2.5, y: 2.5 }, radius: 0.3 }); + r.move('e', { x: 2.9, y: 2.9 }); // тот же тайл + expect(r.at(2, 2, 'enemy')).not.toBeNull(); + expect(r.at(3, 3, 'enemy')).toBeNull(); + r.move('e', { x: 5.2, y: 7.8 }); + expect(r.at(2, 2, 'enemy')).toBeNull(); + expect(r.at(5, 7, 'enemy')).not.toBeNull(); + expect(r.get('e')!.pos).toEqual({ x: 5.2, y: 7.8 }); + r.remove('e'); // remove после move не оставляет «призраков» в старом тайле + expect(r.at(5, 7)).toBeNull(); + }); + + it('footprint-объект находится во всех покрытых тайлах', () => { + const r = new SceneRegistry(); + r.add({ + id: 'prop:1', kind: 'prop', pos: { x: 10.5, y: 10.5 }, + footprint: { w: 2, h: 2 } + }); + expect(r.at(10, 10, 'prop')).not.toBeNull(); + expect(r.at(11, 10, 'prop')).not.toBeNull(); + expect(r.at(10, 11, 'prop')).not.toBeNull(); + expect(r.at(11, 11, 'prop')).not.toBeNull(); + expect(r.at(12, 11, 'prop')).toBeNull(); + r.remove('prop:1'); + expect(r.at(11, 11)).toBeNull(); + }); + + it('atWorld находит тело, выступающее из тайла привязки', () => { + const r = new SceneRegistry(); + r.add({ id: 'npc:1', kind: 'npc', pos: { x: 10.9, y: 10.5 }, radius: 0.4 }); + // Точка в соседнем тайле, но внутри радиуса тела. + expect(r.atWorld({ x: 11.2, y: 10.5 }, 'npc')).not.toBeNull(); + // Вне радиуса — промах. + expect(r.atWorld({ x: 12.2, y: 10.5 }, 'npc')).toBeNull(); + }); + + it('near: по радиусу, с фильтром рода и без дублей на границе', () => { + const r = new SceneRegistry(); + r.add({ id: 'a', kind: 'npc', pos: { x: 5, y: 5 } }); + r.add({ id: 'b', kind: 'enemy', pos: { x: 5.5, y: 5.5 } }); + r.add({ id: 'c', kind: 'npc', pos: { x: 7.5, y: 7.5 } }); + const ids = (list: { id: string }[]) => list.map((o) => o.id).sort(); + expect(ids(r.near({ x: 5, y: 5 }, 1))).toEqual(['a', 'b']); + expect(ids(r.near({ x: 5, y: 5 }, 1, 'npc'))).toEqual(['a']); + expect(ids(r.near({ x: 5, y: 5 }, 10))).toEqual(['a', 'b', 'c']); + expect(r.near({ x: 20, y: 20 }, 2)).toEqual([]); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/ui/Button.ts b/v1/packages/engine/src/ui/Button.ts new file mode 100644 index 0000000..3d4f57b --- /dev/null +++ b/v1/packages/engine/src/ui/Button.ts @@ -0,0 +1,137 @@ +import { Container, Graphics, Rectangle, FederatedPointerEvent } from 'pixi.js'; +import { PixelText } from './PixelText'; + +/** + * Кнопка UI: пиксельная рамка, состояния normal/hover/pressed/focused. + * Активация — клик мышью или onSelect() из клавиатурной навигации (MenuList). + */ +export interface ButtonOptions { + label: string; + width: number; + height: number; + size?: number; + /** Значение справа (громкость, счётчик) — меняется без пересборки списка. */ + value?: string; + /** Недоступная кнопка: приглушена, клик/активация игнорируются. */ + disabled?: boolean; + onSelect?: () => void; +} + +type ButtonState = 'normal' | 'hover' | 'pressed' | 'focused'; + +const STATE_COLORS: Record = { + normal: { fill: 0x16161f, border: 0x8899aa, text: 0xcccccc }, + hover: { fill: 0x1e1e2a, border: 0xf0d878, text: 0xffffff }, + pressed: { fill: 0x2a2a38, border: 0xf0d878, text: 0xffffff }, + focused: { fill: 0x1e1e2a, border: 0xd99a32, text: 0xffffff } +}; + +const DISABLED_COLORS = { fill: 0x101018, border: 0x555560, text: 0x777780 }; + +/** Цвет значения (приглушённый акцент — не путать с меткой). */ +const VALUE_COLOR = 0xb8c4cc; + +export class Button extends Container { + readonly labelText: PixelText; + readonly valueText: PixelText | null = null; + onSelect: (() => void) | null; + + private bg: Graphics; + private state: ButtonState = 'normal'; + private readonly w: number; + private readonly h: number; + private readonly size: number; + private readonly disabled: boolean; + + constructor(options: ButtonOptions) { + super(); + this.w = options.width; + this.h = options.height; + this.size = options.size ?? 10; + this.disabled = options.disabled ?? false; + this.onSelect = options.onSelect ?? null; + + this.bg = new Graphics(); + // Защита от длинной метки: перенос по ширине кнопки (иначе текст + // вылезает за рамку; двухстрочная метка в высокой кнопке читаема). + this.labelText = new PixelText({ + text: options.label, + size: this.size, + color: this.disabled ? DISABLED_COLORS.text : STATE_COLORS.normal.text, + align: options.value !== undefined ? 'left' : 'center', + wordWrapWidth: this.w - 16 + }); + if (options.value !== undefined) { + // С value метка прижата влево, значение — к правому краю. + this.labelText.anchor.set(0, 0.5); + this.labelText.position.set(8, this.h / 2); + this.valueText = new PixelText({ + text: options.value, + size: this.size, + color: VALUE_COLOR, + align: 'right' + }); + this.valueText.anchor.set(1, 0.5); + this.valueText.position.set(this.w - 8, this.h / 2); + } else { + this.labelText.anchor.set(0.5); + this.labelText.position.set(this.w / 2, this.h / 2); + } + + this.addChild(this.bg, this.labelText); + if (this.valueText) this.addChild(this.valueText); + + // Недоступная кнопка прозрачна для ввода (клики проходят сквозь) + this.eventMode = this.disabled ? 'none' : 'static'; + if (!this.disabled) { + this.cursor = 'pointer'; + this.hitArea = new Rectangle(0, 0, this.w, this.h); + this.on('pointerover', () => this.setState(this.state === 'pressed' ? 'pressed' : 'hover')); + this.on('pointerout', () => this.setState(this.focused ? 'focused' : 'normal')); + this.on('pointerdown', (e: FederatedPointerEvent) => { + e.stopPropagation(); + this.setState('pressed'); + }); + this.on('pointerup', () => this.setState('hover')); + this.on('pointerupoutside', () => this.setState('normal')); + this.on('pointertap', () => this.onSelect?.()); + } + + this.draw(); + } + + /** Клавиатурный фокус (визуально — рамка акцентного цвета). */ + set focused(value: boolean) { + if (value && this.state === 'normal') this.setState('focused'); + else if (!value && this.state === 'focused') this.setState('normal'); + } + + get focused(): boolean { + return this.state === 'focused'; + } + + /** Программная активация (Enter из MenuList). */ + activate(): void { + if (this.disabled) return; + this.onSelect?.(); + } + + private setState(s: ButtonState): void { + this.state = s; + this.labelText.style.fill = STATE_COLORS[s].text; + if (this.valueText) this.valueText.style.fill = this.disabled ? DISABLED_COLORS.text : VALUE_COLOR; + this.draw(); + } + + /** Обновить значение справа (без пересборки списка). */ + set value(v: string) { + if (this.valueText) this.valueText.text = v; + } + + private draw(): void { + const c = this.disabled ? DISABLED_COLORS : STATE_COLORS[this.state]; + this.bg.clear(); + this.bg.rect(0, 0, this.w, this.h).fill({ color: c.fill, alpha: 0.95 }); + this.bg.rect(0, 0, this.w, this.h).stroke({ color: c.border, width: 1 }); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/ui/DialogueBox.ts b/v1/packages/engine/src/ui/DialogueBox.ts new file mode 100644 index 0000000..11fa1e1 --- /dev/null +++ b/v1/packages/engine/src/ui/DialogueBox.ts @@ -0,0 +1,262 @@ +import { Container, Graphics } from 'pixi.js'; +import { PixelText } from './PixelText'; +import { ListCursor } from './listCursor'; +import { revealText } from './reveal'; + +/** + * Универсальное окно диалога: имя говорящего, текст, варианты ответа, подсказка «далее». + * Жанронезависимо: игра сама решает, чей это диалог и что идёт дальше. + * Подходит как view для DialogueRunner. + * + * v2: typewriter (чистый revealText), цвета настроений, клавиатурный курсор + * выбора (ListCursor), высота панели растёт от контента вверх. + * В update обязательно звать update(dt), если задан typewriter. + */ +export interface DialogueLine { + speaker: string; + text: string; +} + +export interface DialogueChoiceItem { + index: number; + text: string; +} + +export interface DialogueBoxOptions { + /** Виртуальные пиксели. */ + width: number; + height: number; + /** Отступы панели от краёв экрана. */ + margin: number; + /** Выбор варианта ответа (из DialogueRunner.pick). */ + onChoice?: (index: number) => void; + /** Размер шрифта (виртуальные пиксели). */ + size?: number; + /** Скорость печати, символов в секунду; не задана — текст сразу. */ + typewriter?: number; + /** mood реплики → цвет текста (по умолчанию белый). */ + moodColors?: Record; +} + +const COLOR_BODY = 0xffffff; +const COLOR_CHOICE = 0xcccccc; +const COLOR_CHOICE_ACTIVE = 0xf0d878; + +export class DialogueBox { + readonly view: Container; + visible = false; + + private panel: Graphics; + private nameText: PixelText; + private bodyText: PixelText; + private hintText: PixelText; + private choiceViews: { item: DialogueChoiceItem; label: PixelText }[] = []; + private onChoice: ((index: number) => void) | null; + private readonly width: number; + private readonly height: number; + private readonly margin: number; + private readonly size: number; + private readonly cps: number; + private readonly moodColors: Record; + + // typewriter + private fullText = ''; + private revealElapsed = 0; + private revealingNow = false; + // клавиатурный курсор по вариантам + private cursor: ListCursor = new ListCursor(0); + + constructor(options: DialogueBoxOptions) { + this.width = options.width; + this.height = options.height; + this.margin = options.margin; + // 8–9px у VT323 нечитаемы (тонкие штрихи размазываются) — минимум 10. + this.size = Math.max(options.size ?? 10, 10); + this.onChoice = options.onChoice ?? null; + this.cps = options.typewriter ?? 0; + this.moodColors = options.moodColors ?? {}; + + this.view = new Container(); + this.view.visible = false; + this.view.eventMode = 'static'; + + this.panel = new Graphics(); + // Имя — с переносом: длинное имя говорящего не вылезет за панель. + this.nameText = new PixelText({ + text: '', + size: this.size, + color: 0xf0d878, + wordWrapWidth: options.width - options.margin * 4 - 10 + }); + this.bodyText = new PixelText({ + text: '', + size: this.size, + color: COLOR_BODY, + wordWrapWidth: options.width - options.margin * 4, + lineHeight: this.size + 3 + }); + this.hintText = new PixelText({ text: 'далее ▸', size: this.size, color: 0xaaaaaa }); + + this.view.addChild(this.panel, this.nameText, this.bodyText, this.hintText); + this.relayout(); + } + + /** + * Показать реплику. Если есть choices — рисуются варианты с курсором + * (клик и moveCursor/activate). mood подкрашивает текст, typewriter + * печатает текст постепенно (обновлять update(dt)). + */ + show(line: { + speaker?: string; + text: string; + mood?: string; + choices?: DialogueChoiceItem[]; + }): void { + this.nameText.text = line.speaker ?? ''; + this.fullText = line.text; + this.bodyText.style.fill = (line.mood && this.moodColors[line.mood]) || COLOR_BODY; + this.view.visible = true; + this.visible = true; + + this.rebuildChoices(line.choices ?? []); + + this.cursor = new ListCursor(this.choiceViews.length); + this.refreshChoiceColors(); + + // печать с начала; без typewriter — весь текст сразу + this.revealElapsed = 0; + this.revealingNow = this.cps > 0 && this.fullText.length > 0; + // Панель сразу полной высоты: измеряем полный текст и строим панель, + // только потом прячем текст в печать — иначе relayout видит пустую + // строку, панель схлопывается и текст печатается поверх мира. + this.bodyText.text = this.fullText; + this.relayout(); + if (this.revealingNow) this.bodyText.text = ''; + } + + /** Печать и анимации; звать из update сцены при typewriter (dt в секундах). */ + update(dt: number): void { + if (!this.revealingNow) return; + this.revealElapsed += dt * 1000; + const r = revealText(this.fullText, this.revealElapsed, this.cps); + this.bodyText.text = this.fullText.slice(0, r.shown); + if (r.done) { + this.revealingNow = false; + this.relayout(); // панель подросла — подсказка встанет под текст + } + } + + /** Идёт ли печать текста. */ + get revealing(): boolean { + return this.revealingNow; + } + + /** Показать текст целиком (пропуск печати). */ + skipReveal(): void { + if (!this.revealingNow) return; + this.revealingNow = false; + this.bodyText.text = this.fullText; + this.relayout(); + } + + /** Листать выбор курсором (up/down); без вариантов — no-op. */ + moveCursor(delta: number): void { + if (this.choiceViews.length === 0) return; + this.cursor.move(delta); + this.refreshChoiceColors(); + } + + /** Выбрать вариант под курсором (Enter); без вариантов — no-op. */ + activate(): void { + if (this.choiceViews.length === 0 || this.cursor.index < 0) return; + const current = this.choiceViews[this.cursor.index]; + if (current) this.onChoice?.(current.item.index); + } + + /** Индекс варианта под курсором (-1 — выбора нет). */ + get cursorIndex(): number { + return this.choiceViews.length > 0 ? this.cursor.index : -1; + } + + hide(): void { + this.view.visible = false; + this.visible = false; + for (const c of this.choiceViews) c.label.destroy(); + this.choiceViews = []; + this.revealingNow = false; + this.fullText = ''; + } + + // --- внутреннее --- + + private rebuildChoices(choices: DialogueChoiceItem[]): void { + for (const c of this.choiceViews) c.label.destroy(); + this.choiceViews = []; + for (const item of choices) { + const label = new PixelText({ + text: `▸ ${item.text}`, + size: this.size, + color: COLOR_CHOICE, + wordWrapWidth: this.width - this.margin * 4 + }); + label.eventMode = 'static'; + label.cursor = 'pointer'; + label.on('pointerover', () => { + const i = this.choiceViews.findIndex((c) => c.label === label); + if (i >= 0) { + this.cursor.index = i; + this.refreshChoiceColors(); + } + }); + label.on('pointerout', () => this.refreshChoiceColors()); + label.on('pointertap', () => this.onChoice?.(item.index)); + this.view.addChild(label); + this.choiceViews.push({ item, label }); + } + } + + private refreshChoiceColors(): void { + this.choiceViews.forEach((c, i) => { + c.label.style.fill = i === this.cursor.index ? COLOR_CHOICE_ACTIVE : COLOR_CHOICE; + }); + } + + /** Панель от контента: растёт вверх от нижнего края. */ + private relayout(): void { + const w = this.width; + const h = this.height; + const m = this.margin; + // Место под «далее ▸» резервируется всегда (без выбора) — панель не + // прыгает в конце печати; сам текст виден только после печати. + const withHint = this.choiceViews.length === 0; + this.hintText.visible = withHint && !this.revealingNow; + + this.panel.clear(); + const pad = 6; + const nameH = this.nameText.text ? this.nameText.height + 2 : 0; + const bodyH = this.fullText || this.bodyText.text ? this.bodyText.height : 0; + let choicesH = 0; + for (const c of this.choiceViews) choicesH += c.label.height + 3; + const hintH = withHint ? this.hintText.height + 2 : 0; + const panelH = Math.round(pad * 2 + nameH + bodyH + choicesH + hintH); + const top = h - m - panelH; + + this.panel.rect(m, top, w - m * 2, panelH).fill({ color: 0x101018, alpha: 0.92 }); + this.panel.rect(m, top, w - m * 2, panelH).stroke({ color: 0x8899aa, width: 1 }); + + let y = top + pad; + if (nameH > 0) { + this.nameText.position.set(m + 5, y); + y += nameH; + } + this.bodyText.position.set(m + 5, y); + y += bodyH; + for (const c of this.choiceViews) { + c.label.position.set(m + 5, y); + y += c.label.height + 3; + } + if (withHint) { + this.hintText.position.set(w - m - 5 - this.hintText.width, y + 2); + } + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/ui/MenuList.ts b/v1/packages/engine/src/ui/MenuList.ts new file mode 100644 index 0000000..9221648 --- /dev/null +++ b/v1/packages/engine/src/ui/MenuList.ts @@ -0,0 +1,123 @@ +import { Container } from 'pixi.js'; +import { Button } from './Button'; +import { ListCursor } from './listCursor'; +import { ListScroll } from './listScroll'; + +/** + * Вертикальное меню: список кнопок с клавиатурным фокусом и навигацией. + * Игра сама читает ввод и вызывает moveCursor/activate — движок не привязан к раскладке. + * Недоступные пункты (disabled) приглушены: курсор их пропускает, клик игнорируется. + * Длинные списки: visibleRows задаёт окно видимых строк (скролл — ListScroll); + * hover мышью переставляет курсор; пункт может нести value (громкость, счётчик). + */ + +/** Пункт меню. */ +export interface MenuItem { + label: string; + onSelect?: () => void; + /** Недоступный пункт: приглушён, курсор пропускает, активация игнорируется. */ + disabled?: boolean; + /** Значение справа (громкость, счётчик) — обновляется через setValue. */ + value?: string; +} + +export interface MenuListOptions { + width: number; + height: number; + /** Отступ между кнопками (виртуальные пиксели). */ + gap?: number; + size?: number; + /** Видимых строк (скролл); нет/0 — окно = весь список, как раньше. */ + visibleRows?: number; +} + +export class MenuList extends Container { + private buttons: Button[] = []; + private listCursor: ListCursor; + private scroll: ListScroll | null = null; + + constructor(private options: MenuListOptions) { + super(); + this.listCursor = new ListCursor(0); + } + + /** Пересобрать список пунктов (старые кнопки уничтожаются); фокус сохраняется. */ + setItems(items: MenuItem[]): void { + for (const b of this.buttons) b.destroy({ children: true }); + this.buttons = []; + const rows = this.options.visibleRows ?? 0; + this.scroll = rows > 0 ? new ListScroll(rows) : null; + let y = 0; + for (const item of items) { + const btn = new Button({ + label: item.label, + width: this.options.width, + height: this.options.height, + size: this.options.size, + value: item.value, + disabled: item.disabled, + onSelect: item.onSelect + }); + btn.position.set(0, y); + // Hover мышью/тачем переставляет курсор (как выбор в DialogueBox). + const index = this.buttons.length; + btn.on('pointerover', () => { + if (!item.disabled && index !== this.listCursor.index) { + this.listCursor.index = index; + this.applyFocus(); + } + }); + this.addChild(btn); + this.buttons.push(btn); + y += this.step(); + } + const keep = Math.min(this.listCursor.index, Math.max(0, items.length - 1)); + this.listCursor = new ListCursor(items.length, (i) => !items[i]?.disabled); + this.listCursor.index = keep; + if (items[keep]?.disabled) this.listCursor.move(1); // старт на выбираемом + this.applyFocus(); + } + + /** Сдвинуть фокус (↑ = -1, ↓ = +1); недоступные пункты пропускаются. */ + moveCursor(delta: number): void { + this.listCursor.move(delta); + this.applyFocus(); + } + + /** Выбранный индекс. */ + get index(): number { + return this.listCursor.index; + } + + /** Активировать кнопку под фокусом (Enter). */ + activate(): void { + this.buttons[this.listCursor.index]?.activate(); + } + + /** Обновить значение пункта справа (настройки — без пересборки списка). */ + setValue(index: number, value: string): void { + const btn = this.buttons[index]; + if (btn) btn.value = value; + } + + /** Окно видимых строк (или весь список, если скролла нет). */ + get window(): { first: number; last: number } | null { + return this.scroll ? { first: this.scroll.first, last: this.scroll.last } : null; + } + + private applyFocus(): void { + this.buttons.forEach((b, i) => (b.focused = i === this.listCursor.index)); + if (this.scroll) { + this.scroll.sync(this.listCursor.index, this.buttons.length); + for (let i = 0; i < this.buttons.length; i++) { + const visible = i >= this.scroll.first && i < this.scroll.last; + this.buttons[i]!.visible = visible; + this.buttons[i]!.position.y = (i - this.scroll.first) * this.step(); + } + } + } + + private step(): number { + return this.options.height + (this.options.gap ?? 2); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/ui/MenuSceneBase.ts b/v1/packages/engine/src/ui/MenuSceneBase.ts new file mode 100644 index 0000000..9e55209 --- /dev/null +++ b/v1/packages/engine/src/ui/MenuSceneBase.ts @@ -0,0 +1,86 @@ +import { Container } from 'pixi.js'; +import type { Scene } from '../scene/SceneManager'; +import type { MenuList } from './MenuList'; +import { readMenuInput, type MenuActionMap } from './menuInput'; + +/** + * Базовая сцена-меню: панель поверх вызывающей сцены (push/pop) с клавиатурной + * навигацией. Устраняет дублирование «up/down/confirm/cancel → moveCursor/ + * activate + гард перехода» из четырёх сцен игры. Игра строит панель и меню + * своим стилем в build(); ввод читается по карте действий сцены. + */ +export interface MenuSceneContext { + /** Ввод (InputManager или заглушка). */ + input: { isActionJustPressed(action: string): boolean }; + /** true, пока стек сцен в переходе — ввод блокируется. */ + inputBlocked(): boolean; +} + +export abstract class MenuSceneBase implements Scene { + /** Корень вью сцены; build() добавляет его в uiRoot. */ + protected readonly view = new Container(); + /** Список меню (null — сцена без списка, например просмотр сумки). */ + protected menu: MenuList | null = null; + + protected constructor( + private readonly ctx: MenuSceneContext, + private readonly actions: MenuActionMap + ) {} + + /** Построить панель/заголовок/меню и добавить this.view в uiRoot. */ + protected abstract build(): void; + + enter(): void { + this.build(); + } + + exit(): void { + this.view.destroy({ children: true }); + this.menu = null; + } + + render(): void {} + + update(_dt: number): void { + if (this.ctx.inputBlocked()) return; + const index = this.menu?.index ?? 0; + for (const ev of readMenuInput(this.ctx.input, index, this.actions)) { + switch (ev.kind) { + case 'move': + this.onMove(ev.delta, ev.index); + break; + case 'adjust': + this.onAdjust(ev.delta, ev.index); + break; + case 'confirm': + this.onConfirm(ev.index); + break; + case 'cancel': + this.onCancel(); + break; + case 'action': + this.onAction(ev.action, ev.index); + break; + } + } + } + + /** Дефолт: двигать курсор списка. */ + protected onMove(delta: number, _index: number): void { + this.menu?.moveCursor(delta); + } + + /** Подстройка значения выбранной строки (настройки); по умолчанию — ничего. */ + protected onAdjust(_delta: number, _index: number): void {} + + /** Дефолт: активировать кнопку под фокусом. */ + protected onConfirm(_index: number): void { + this.menu?.activate(); + } + + /** Назад/закрыть; по умолчанию — ничего (главное меню не закрывается). */ + protected onCancel(): void {} + + /** Прочее действие сцены (extra из карты); по умолчанию — ничего. */ + protected onAction(_action: string, _index: number): void {} +} \ No newline at end of file diff --git a/v1/packages/engine/src/ui/Panel.ts b/v1/packages/engine/src/ui/Panel.ts new file mode 100644 index 0000000..569bd41 --- /dev/null +++ b/v1/packages/engine/src/ui/Panel.ts @@ -0,0 +1,42 @@ +import { Container, Graphics, Texture, NineSliceSprite } from 'pixi.js'; + +/** + * Панель UI: тёмный прямоугольник с однопиксельной рамкой — базовый стиль движка. + * Если передана текстура, рисуется NineSliceSprite (растяжимые углы 4px). + */ +export interface PanelOptions { + width: number; + height: number; + fill?: number; + border?: number; + /** Прозрачность заливки 0..1. */ + fillAlpha?: number; + /** Текстура 9-slice (опционально). */ + texture?: Texture; +} + +export class Panel extends Container { + constructor(options: PanelOptions) { + super(); + if (options.texture) { + const nine = new NineSliceSprite({ + texture: options.texture, + leftWidth: 4, + topHeight: 4, + rightWidth: 4, + bottomHeight: 4, + width: options.width, + height: options.height + }); + this.addChild(nine); + return; + } + + const g = new Graphics(); + g.rect(0, 0, options.width, options.height) + .fill({ color: options.fill ?? 0x101018, alpha: options.fillAlpha ?? 0.92 }); + g.rect(0, 0, options.width, options.height) + .stroke({ color: options.border ?? 0x8899aa, width: 1 }); + this.addChild(g); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/ui/PixelText.ts b/v1/packages/engine/src/ui/PixelText.ts new file mode 100644 index 0000000..7e6237a --- /dev/null +++ b/v1/packages/engine/src/ui/PixelText.ts @@ -0,0 +1,83 @@ +import { Text, TextStyleOptions } from 'pixi.js'; + +/** + * Пиксельный шрифт движка: VT323 (OFL, есть кириллица). + * Загружается через FontFace API; PixelText — обёртка над Pixi Text + * с этим шрифтом. Если игры понадобится свой шрифт — меняется источник, + * обёртка остаётся. + */ + +export const PIXEL_FONT_FAMILY = 'VT323'; + +/** Фолбэк, если шрифт ещё не загрузился или не поддерживается. */ +export const PIXEL_FONT_FALLBACK = 'monospace'; + +let fontLoaded: Promise | null = null; + +/** + * Resolution растеризации текста (пикселей устройства на виртуальный пиксель). + * Задаётся Renderer'ом при смене масштаба; до этого тексты чуть мягче — не критично. + */ +let textResolution = 1; + +/** Задать resolution растеризации PixelText (вызывается движком при ресайзе). */ +export function setPixelTextResolution(resolution: number): void { + textResolution = Math.max(1, Math.round(resolution)); +} + +/** + * Загрузить пиксельный шрифт. url указывает на TTF (приложение решает, где он лежит). + * Повторные вызовы возвращают уже начатую загрузку. Безопасно вызывать до Pixi. + */ +export function ensurePixelFont(url: string, family: string = PIXEL_FONT_FAMILY): Promise { + if (fontLoaded) return fontLoaded; + fontLoaded = (async () => { + try { + const face = new FontFace(family, `url(${url})`); + await face.load(); + document.fonts.add(face); + } catch (err) { + // Не критично: PixelText останется с фолбэком monospace. + console.warn(`[engine] пиксельный шрифт не загрузился (${url}):`, err); + } + })(); + return fontLoaded; +} + +export interface PixelTextOptions { + text: string; + /** Размер в виртуальных пикселях (шрифт растровый — целые числа). */ + size: number; + /** Цвет (числом, 0xRRGGBB). */ + color?: number; + /** Перенос по ширине (виртуальные пиксели). */ + wordWrapWidth?: number; + lineHeight?: number; + align?: 'left' | 'center' | 'right'; + letterSpacing?: number; + fontFamily?: string; +} + +/** Текст пиксельным шрифтом движка. */ +export class PixelText extends Text { + constructor(options: PixelTextOptions) { + const family = `${options.fontFamily ?? PIXEL_FONT_FAMILY}, ${PIXEL_FONT_FALLBACK}`; + const style: TextStyleOptions = { + fontFamily: family, + fontSize: options.size, + fill: options.color ?? 0xffffff, + letterSpacing: options.letterSpacing ?? 0, + lineHeight: options.lineHeight + }; + if (options.wordWrapWidth !== undefined) { + style.wordWrap = true; + style.wordWrapWidth = options.wordWrapWidth; + style.breakWords = true; + } + if (options.align !== undefined) { + style.align = options.align; + } + // resolution > 1: глифы растеризуются 1:1 с пикселями устройства — резкий текст. + super({ text: options.text, style, resolution: textResolution }); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/ui/SpriteDebugView.ts b/v1/packages/engine/src/ui/SpriteDebugView.ts new file mode 100644 index 0000000..1754d7c --- /dev/null +++ b/v1/packages/engine/src/ui/SpriteDebugView.ts @@ -0,0 +1,47 @@ +import { Container, Graphics, Sprite, Texture } from 'pixi.js'; + +/** + * Дебаг-просмотр спрайта: текстура, увеличенная целым множителем (nearest), + * с сеткой по границам пикселей. Для правки пропорций и проверки кадров + * анимации. Показ текущего кадра — просто вызывайте setTexture каждый тик. + */ +export class SpriteDebugView { + readonly view: Container; + + private holder: Sprite; + private grid: Graphics; + private readonly zoom: number; + private readonly gridColor: number; + private readonly gridAlpha: number; + + constructor(opts: { zoom?: number; gridColor?: number; gridAlpha?: number } = {}) { + this.zoom = Math.max(1, Math.round(opts.zoom ?? 8)); + this.gridColor = opts.gridColor ?? 0x000000; + this.gridAlpha = opts.gridAlpha ?? 0.2; + + this.view = new Container(); + this.view.visible = false; + + this.holder = new Sprite(Texture.EMPTY); + this.holder.anchor.set(0.5); + this.grid = new Graphics(); + this.view.addChild(this.holder, this.grid); + } + + /** Показать текстуру: центрирует, масштабирует целым множителем, рисует сетку. */ + setTexture(tex: Texture): void { + this.holder.texture = tex; + this.holder.scale.set(this.zoom); + const w = tex.width * this.zoom; + const h = tex.height * this.zoom; + const g = this.grid; + g.clear(); + for (let x = 0; x <= tex.width; x++) { + g.moveTo(x * this.zoom - w / 2, -h / 2).lineTo(x * this.zoom - w / 2, h / 2); + } + for (let y = 0; y <= tex.height; y++) { + g.moveTo(-w / 2, y * this.zoom - h / 2).lineTo(w / 2, y * this.zoom - h / 2); + } + g.stroke({ color: this.gridColor, width: 1, alpha: this.gridAlpha }); + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/ui/__tests__/listCursor.test.ts b/v1/packages/engine/src/ui/__tests__/listCursor.test.ts new file mode 100644 index 0000000..8d9b0cd --- /dev/null +++ b/v1/packages/engine/src/ui/__tests__/listCursor.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { ListCursor } from '../listCursor'; + +describe('ListCursor', () => { + it('move зацикливается по кругу', () => { + const c = new ListCursor(3); + c.move(1); + expect(c.index).toBe(1); + c.move(1); + c.move(1); + expect(c.index).toBe(0); // 2 + 1 -> 0 + c.move(-1); + expect(c.index).toBe(2); + }); + + it('пустой список не двигается', () => { + const c = new ListCursor(0); + c.move(1); + c.move(-1); + expect(c.empty).toBe(true); + expect(c.index).toBe(0); + }); + + it('setCount зажимает курсор при уменьшении списка', () => { + const c = new ListCursor(5); + c.move(4); + expect(c.index).toBe(4); + c.setCount(2); + expect(c.index).toBe(1); + }); + + it('setCount с ростом списка сохраняет индекс', () => { + const c = new ListCursor(2); + c.move(1); + c.setCount(4); + expect(c.index).toBe(1); + }); + + it('home/end ставят крайние позиции', () => { + const c = new ListCursor(4); + c.end(); + expect(c.index).toBe(3); + c.home(); + expect(c.index).toBe(0); + }); + + it('предикат выбираемости пропускает недоступные пункты', () => { + const c = new ListCursor(4, (i) => i !== 1 && i !== 2); // 1 и 2 — disabled + c.setCount(4); + c.move(1); // 0 -> 3 (1 и 2 пропущены) + expect(c.index).toBe(3); + c.move(1); // 3 -> 0 (по кругу, через 1 и 2) + expect(c.index).toBe(0); + c.move(-1); // 0 -> 3 + expect(c.index).toBe(3); + }); + + it('setCount на недоступном пункте сдвигается к выбираемому', () => { + const c = new ListCursor(3, (i) => i !== 1); + c.index = 1; + c.setCount(3); + expect(c.index).toBe(2); + }); + + it('все пункты недоступны — курсор стоит на месте', () => { + const c = new ListCursor(2, () => false); + c.move(1); + c.move(-1); + expect(c.index).toBe(0); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/ui/__tests__/listScroll.test.ts b/v1/packages/engine/src/ui/__tests__/listScroll.test.ts new file mode 100644 index 0000000..a2fd645 --- /dev/null +++ b/v1/packages/engine/src/ui/__tests__/listScroll.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { ListScroll } from '../listScroll'; + +describe('ListScroll — окно видимых строк', () => { + it('курсор внутри окна — offset не меняется', () => { + const s = new ListScroll(5); + s.sync(0, 20); + s.sync(3, 20); + expect(s.offset).toBe(0); + expect(s.first).toBe(0); + expect(s.last).toBe(5); + }); + + it('движение вниз у нижнего края листает окно на 1', () => { + const s = new ListScroll(5); + s.sync(4, 20); + s.sync(5, 20); + expect(s.offset).toBe(1); + expect(s.first).toBe(1); + expect(s.last).toBe(6); + }); + + it('движение вверх у верхнего края листает окно на 1', () => { + const s = new ListScroll(5); + s.sync(5, 20); + s.sync(4, 20); + expect(s.offset).toBe(1); + s.sync(0, 20); + expect(s.offset).toBe(0); + }); + + it('прыжок в конец показывает хвост списка', () => { + const s = new ListScroll(5); + s.sync(19, 20); + expect(s.offset).toBe(15); + expect(s.last).toBe(20); + }); + + it('список короче окна — offset 0, clamped', () => { + const s = new ListScroll(5); + s.sync(3, 4); + expect(s.offset).toBe(0); + expect(s.last).toBe(4); + expect(s.clamped).toBe(true); + }); + + it('пересборка списка короче — offset зажимается', () => { + const s = new ListScroll(5); + s.sync(15, 20); + expect(s.offset).toBe(11); // окно [11..16), курсор в конце окна + s.sync(2, 6); // после пересборки курсор в начале, список почти пуст + expect(s.offset).toBe(1); // 6-5=1, курсор 2 в окне [1,6) + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/ui/__tests__/menuInput.test.ts b/v1/packages/engine/src/ui/__tests__/menuInput.test.ts new file mode 100644 index 0000000..0fea326 --- /dev/null +++ b/v1/packages/engine/src/ui/__tests__/menuInput.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { readMenuInput, type MenuActionMap, type MenuInputSource } from '../menuInput'; + +/** Фейковый источник: нажатия задаются списком действий на тик. */ +function fakeInput(pressed: string[]): MenuInputSource & { pressed: string[] } { + return { pressed, isActionJustPressed: (a) => pressed.includes(a) }; +} + +const MAP: MenuActionMap = { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu' }; + +describe('readMenuInput', () => { + it('движение и подтверждение по карте действий', () => { + const ev = readMenuInput(fakeInput(['down']), 2, MAP); + expect(ev).toEqual([{ kind: 'move', delta: 1, index: 2 }]); + expect(readMenuInput(fakeInput(['advance']), 1, MAP)).toEqual([ + { kind: 'confirm', index: 1 } + ]); + expect(readMenuInput(fakeInput(['menu']), 1, MAP)).toEqual([{ kind: 'cancel' }]); + }); + + it('cancel замыкает тик — остальные события не читаются', () => { + const ev = readMenuInput(fakeInput(['menu', 'down', 'advance']), 0, MAP); + expect(ev).toEqual([{ kind: 'cancel' }]); + }); + + it('up и down в один тик дают один move', () => { + const ev = readMenuInput(fakeInput(['up', 'down']), 0, MAP); + expect(ev).toEqual([{ kind: 'move', delta: -1, index: 0 }]); + }); + + it('нажатые left/right подавляют confirm (настройки: без двойного шага)', () => { + const map: MenuActionMap = { ...MAP, left: 'left', right: 'right' }; + const ev = readMenuInput(fakeInput(['right', 'advance']), 3, map); + expect(ev).toEqual([{ kind: 'adjust', delta: 1, index: 3 }]); + const onlyEnter = readMenuInput(fakeInput(['advance']), 3, map); + expect(onlyEnter).toEqual([{ kind: 'confirm', index: 3 }]); + }); + + it('extra-действия читаются последними', () => { + const map: MenuActionMap = { ...MAP, extra: ['attack'] }; + const ev = readMenuInput(fakeInput(['down', 'attack']), 1, map); + expect(ev).toEqual([ + { kind: 'move', delta: 1, index: 1 }, + { kind: 'action', action: 'attack', index: 1 } + ]); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/ui/__tests__/reveal.test.ts b/v1/packages/engine/src/ui/__tests__/reveal.test.ts new file mode 100644 index 0000000..6e45892 --- /dev/null +++ b/v1/packages/engine/src/ui/__tests__/reveal.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { revealText } from '../reveal'; + +describe('revealText', () => { + it('в начале не показано ничего', () => { + expect(revealText('привет', 0, 10)).toEqual({ shown: 0, done: false }); + }); + + it('печатает пропорционально времени', () => { + // 20 cps: 250мс → 5 символов + expect(revealText('привет мир', 250, 20)).toEqual({ shown: 5, done: false }); + expect(revealText('привет мир', 550, 20)).toEqual({ shown: 10, done: true }); + }); + + it('перелистывание назад невозможно: показ не больше длины', () => { + expect(revealText('ok', 10000, 5).shown).toBe(2); + }); + + it('cps <= 0 — весь текст сразу', () => { + expect(revealText('привет', 0, 0)).toEqual({ shown: 6, done: true }); + expect(revealText('привет', 0, -5)).toEqual({ shown: 6, done: true }); + }); + + it('пустой текст — сразу готов', () => { + expect(revealText('', 0, 10)).toEqual({ shown: 0, done: true }); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/src/ui/listCursor.ts b/v1/packages/engine/src/ui/listCursor.ts new file mode 100644 index 0000000..a6f3e80 --- /dev/null +++ b/v1/packages/engine/src/ui/listCursor.ts @@ -0,0 +1,58 @@ +/** + * Чистая логика курсора вертикального списка: без Pixi, тестируется изолированно. + * MenuList использует её для клавиатурной навигации; предикат выбираемости + * пропускает недоступные пункты (disabled). + */ + +export class ListCursor { + /** Индекс выбранного элемента. */ + index = 0; + + constructor( + private count: number, + private selectable: (index: number) => boolean = () => true + ) {} + + /** Изменить количество элементов (список пересобран); курсор зажимается. */ + setCount(count: number): void { + this.count = count; + if (this.index >= count) { + this.index = Math.max(0, count - 1); + } + if (!this.selectable(this.index)) this.move(1); + } + + /** Сдвиг на |delta| шагов; каждый шаг — к соседнему выбираемому элементу. */ + move(delta: number): void { + if (this.count === 0) return; + const dir = Math.sign(delta); + let steps = Math.abs(delta); + while (steps > 0) { + let landed = false; + for (let i = 1; i <= this.count; i++) { + const next = (((this.index + dir * i) % this.count) + this.count) % this.count; + if (this.selectable(next)) { + this.index = next; + landed = true; + break; + } + } + if (!landed) return; // все элементы недоступны — курсор стоит + steps--; + } + } + + /** Курсор в начало/конец. */ + home(): void { + this.index = 0; + } + + end(): void { + if (this.count > 0) this.index = this.count - 1; + } + + /** Есть ли элементы. */ + get empty(): boolean { + return this.count === 0; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/ui/listScroll.ts b/v1/packages/engine/src/ui/listScroll.ts new file mode 100644 index 0000000..800756e --- /dev/null +++ b/v1/packages/engine/src/ui/listScroll.ts @@ -0,0 +1,40 @@ +/** + * Чистая логика окна видимых строк вертикального списка: без Pixi, + * тестируется изолированно (по образцу ListCursor). MenuList использует её + * для скролла длинных списков: курсор всегда в окне, окно — в [0, count]. + */ +export class ListScroll { + /** Индекс первой видимой строки. */ + offset = 0; + /** Число элементов (из последнего sync) — для clamped. */ + private count = 0; + + constructor(readonly rows: number) {} + + /** Пересчёт окна под выбранный индекс; возвращает offset. */ + sync(index: number, count: number): number { + this.count = count; + const max = Math.max(0, count - this.rows); + if (this.offset > max) this.offset = max; // список пересобран короче + if (index < this.offset) this.offset = index; // курсор ушёл выше окна + else if (index >= this.offset + this.rows) { + this.offset = Math.min(max, index - this.rows + 1); // ниже окна + } + return this.offset; + } + + /** Индекс первой видимой строки. */ + get first(): number { + return this.offset; + } + + /** Исключающая граница окна (после последней видимой строки). */ + get last(): number { + return Math.min(this.count, this.offset + this.rows); + } + + /** Список короче окна: всё видно, прокрутки нет. */ + get clamped(): boolean { + return this.count <= this.rows; + } +} \ No newline at end of file diff --git a/v1/packages/engine/src/ui/menuInput.ts b/v1/packages/engine/src/ui/menuInput.ts new file mode 100644 index 0000000..39a9aa1 --- /dev/null +++ b/v1/packages/engine/src/ui/menuInput.ts @@ -0,0 +1,49 @@ +/** + * Чистое чтение навигации меню: без Pixi, тестируется изолированно. + * Движок не привязан к раскладке — имена действий даёт сцена (биндятся игрой). + * Порядок событий за тик: cancel → move → (adjust XOR confirm) → extra. + */ + +/** Карта действий меню (все — имена действий InputManager). */ +export interface MenuActionMap { + up: string; + down: string; + /** Активировать выбранный пункт. */ + confirm: string; + /** Назад / закрыть панель. */ + cancel: string; + /** Подстройка значения (настройки); нажатые left/right подавляют confirm. */ + left?: string; + right?: string; + /** Прочие действия сцены (удаление сейва, открытие сумки и т.п.). */ + extra?: readonly string[]; +} + +export type MenuNavEvent = + | { kind: 'move'; delta: -1 | 1; index: number } + | { kind: 'adjust'; delta: -1 | 1; index: number } + | { kind: 'confirm'; index: number } + | { kind: 'cancel' } + | { kind: 'action'; action: string; index: number }; + +/** Источник «нажато в этом тике» — InputManager (или заглушка в тестах). */ +export interface MenuInputSource { + isActionJustPressed(action: string): boolean; +} + +/** Прочитать навигационные события за один тик. */ +export function readMenuInput(input: MenuInputSource, index: number, map: MenuActionMap): MenuNavEvent[] { + if (input.isActionJustPressed(map.cancel)) return [{ kind: 'cancel' }]; + const events: MenuNavEvent[] = []; + if (input.isActionJustPressed(map.up)) events.push({ kind: 'move', delta: -1, index }); + else if (input.isActionJustPressed(map.down)) events.push({ kind: 'move', delta: 1, index }); + // left/right перекрывают confirm: в настройках Enter не должен давать двойной шаг + const left = map.left !== undefined && input.isActionJustPressed(map.left); + const right = map.right !== undefined && input.isActionJustPressed(map.right); + if (left || right) events.push({ kind: 'adjust', delta: right ? 1 : -1, index }); + else if (input.isActionJustPressed(map.confirm)) events.push({ kind: 'confirm', index }); + for (const action of map.extra ?? []) { + if (input.isActionJustPressed(action)) events.push({ kind: 'action', action, index }); + } + return events; +} \ No newline at end of file diff --git a/v1/packages/engine/src/ui/reveal.ts b/v1/packages/engine/src/ui/reveal.ts new file mode 100644 index 0000000..1882bac --- /dev/null +++ b/v1/packages/engine/src/ui/reveal.ts @@ -0,0 +1,21 @@ +/** + * Печать текста по символам (typewriter) — чистая логика без Pixi: + * тестируется в node, DialogueBox только применяет результат к PixelText. + */ + +export interface RevealState { + /** Сколько символов показано. */ + shown: number; + /** Печатание закончилось. */ + done: boolean; +} + +/** + * Сколько символов текста `full` видно через `elapsedMs` при скорости + * `cps` (символов в секунду). cps <= 0 — текст показан сразу. + */ +export function revealText(full: string, elapsedMs: number, cps: number): RevealState { + if (cps <= 0 || full.length === 0) return { shown: full.length, done: true }; + const shown = Math.min(full.length, Math.floor((elapsedMs * cps) / 1000)); + return { shown, done: shown >= full.length }; +} \ No newline at end of file diff --git a/v1/packages/engine/tools/__tests__/atlas.test.mjs b/v1/packages/engine/tools/__tests__/atlas.test.mjs new file mode 100644 index 0000000..48b5771 --- /dev/null +++ b/v1/packages/engine/tools/__tests__/atlas.test.mjs @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { findFigures, resizeRect, quantize } from '../imaging.mjs'; +import { buildAtlas } from '../atlas.mjs'; +import { encodePng, decodePng } from '../png.mjs'; + +/** Однотонное изображение w×h цвета [r,g,b] (alpha 255). */ +const solid = (w, h, [r, g, b]) => { + const rgba = new Uint8Array(w * h * 4); + for (let p = 0; p < rgba.length; p += 4) { + rgba[p] = r; rgba[p + 1] = g; rgba[p + 2] = b; rgba[p + 3] = 255; + } + return { width: w, height: h, rgba }; +}; + +/** Прямоугольник цвета color на фоне bg (внутри — сплошной). */ +const withRect = (img, rect, [r, g, b]) => { + for (let y = rect.y; y < rect.y + rect.h; y++) { + for (let x = rect.x; x < rect.x + rect.w; x++) { + const i = (y * img.width + x) * 4; + img.rgba[i] = r; img.rgba[i + 1] = g; img.rgba[i + 2] = b; + } + } + return img; +}; + +describe('findFigures', () => { + it('находит фигуры на фоне и сортирует сверху-вниз/слева-направо', () => { + const img = withRect(withRect(withRect( + solid(64, 64, [255, 255, 255]), + { x: 4, y: 8, w: 10, h: 12 }, [10, 20, 30]), + { x: 40, y: 4, w: 12, h: 14 }, [40, 50, 60]), + { x: 20, y: 40, w: 16, h: 16 }, [70, 80, 90] + ); + expect(findFigures(img, { minArea: 30 })).toEqual([ + { x: 40, y: 4, w: 12, h: 14 }, // y=4 раньше y=8 + { x: 4, y: 8, w: 10, h: 12 }, + { x: 20, y: 40, w: 16, h: 16 } + ]); + }); + + it('bg: corner берёт фон из углового пикселя, мелочь отсеивается по minArea', () => { + const img = withRect(solid(32, 32, [240, 240, 240]), + { x: 2, y: 2, w: 20, h: 20 }, [0, 0, 0]); + img.rgba[(31 * 32 + 31) * 4] = 0; // одиночный пиксель в углу — шум + const figs = findFigures(img, { minArea: 100 }); + expect(figs).toEqual([{ x: 2, y: 2, w: 20, h: 20 }]); + }); + + it('явный bg и dist: пиксели ближе порога сливаются с фоном', () => { + const img = withRect(solid(32, 32, [0, 0, 0]), + { x: 1, y: 1, w: 10, h: 10 }, [30, 30, 30]); + // dist 100: |30|*3 = 90 < 100 — фигура не видна + expect(findFigures(img, { bg: [0, 0, 0], dist: 100, minArea: 10 })).toEqual([]); + // dist 50: фигура видна + expect(findFigures(img, { bg: [0, 0, 0], dist: 50, minArea: 10 })).toEqual([ + { x: 1, y: 1, w: 10, h: 10 } + ]); + }); +}); + +describe('resizeRect', () => { + it('nearest-сэмпл по центрам ячеек', () => { + const img = withRect(solid(8, 8, [255, 255, 255]), + { x: 2, y: 2, w: 4, h: 4 }, [255, 0, 0]); + const out = resizeRect(img, { x: 2, y: 2, w: 4, h: 4 }, 2, 2); + // k = 2, центры (0.5,0.5)*2 = (1,1) — все сэмплы внутри красного + expect(out[0]).toBe(255); expect(out[1]).toBe(0); expect(out[3]).toBe(255); + }); + + it('фон становится прозрачным (bg corner + dist)', () => { + const img = withRect(solid(8, 8, [255, 255, 255]), + { x: 2, y: 2, w: 4, h: 4 }, [255, 0, 0]); + const out = resizeRect(img, { x: 0, y: 0, w: 8, h: 8 }, 4, 4); + // k = 2: углы сэмплят фон -> alpha 0; центр (2..3,2..3) — красный + expect(out[3]).toBe(0); + const center = (2 * 4 + 2) * 4; + expect(out[center]).toBe(255); expect(out[center + 1]).toBe(0); + expect(out[center + 3]).toBe(255); + }); +}); + +describe('buildAtlas', () => { + const frame = (r) => { + const f = new Uint8Array(16 * 24 * 4); + for (let p = 0; p < f.length; p += 4) f[p] = r, f[p + 3] = 255; + return f; + }; + it('кадры в строку + Pixi JSON (формат hero_sheet)', () => { + const { png, json } = buildAtlas([frame(1), frame(2)], ['a', 'b'], { frameW: 16, frameH: 24, image: 'x.png' }); + const sheet = decodePng(png); + expect(sheet.width).toBe(32); + expect(sheet.height).toBe(24); + expect(sheet.rgba[0]).toBe(1); + expect(sheet.rgba[(0 * 32 + 16) * 4]).toBe(2); // второй кадр с x=16 + expect(json.frames.a.frame).toEqual({ x: 0, y: 0, w: 16, h: 24 }); + expect(json.frames.b.frame).toEqual({ x: 16, y: 0, w: 16, h: 24 }); + expect(json.meta).toEqual({ image: 'x.png', size: { w: 32, h: 24 }, scale: 1 }); + }); + it('бросает при расхождении длины имён и размере кадра', () => { + expect(() => buildAtlas([frame(1)], ['a', 'b'], { frameW: 16, frameH: 24, image: 'x.png' })).toThrow(); + expect(() => buildAtlas([new Uint8Array(10)], ['a'], { frameW: 16, frameH: 24, image: 'x.png' })).toThrow(); + }); + it('animations: индексы → списки имён кадров (формат Pixi)', () => { + const { json } = buildAtlas([frame(1), frame(2), frame(3)], ['a', 'b', 'c'], { + frameW: 16, frameH: 24, image: 'x.png', + animations: { walk: [0, 1], idle: [0] } + }); + expect(json.animations).toEqual({ walk: ['a', 'b'], idle: ['a'] }); + // Без animations ключа — поле отсутствует (обратная совместимость) + const plain = buildAtlas([frame(1)], ['a'], { frameW: 16, frameH: 24, image: 'x.png' }); + expect('animations' in plain.json).toBe(false); + }); + it('animations: индекс вне кадров — ошибка', () => { + expect(() => buildAtlas([frame(1)], ['a'], { + frameW: 16, frameH: 24, image: 'x.png', animations: { walk: [1] } + })).toThrow(); + }); +}); + +describe('roundtrip findFigures -> resizeRect -> quantize на PNG', () => { + it('лист с двумя квадратами -> кадры в палитре', () => { + const img = withRect(withRect(solid(64, 32, [255, 255, 255]), + { x: 4, y: 4, w: 20, h: 24 }, [255, 0, 0]), + { x: 36, y: 4, w: 20, h: 24 }, [0, 0, 255]); + const figs = findFigures(img, { minArea: 100 }); + expect(figs).toHaveLength(2); + const first = quantize(resizeRect(img, figs[0], 8, 8), [[255, 0, 0], [0, 0, 255], [255, 255, 255]]); + const second = quantize(resizeRect(img, figs[1], 8, 8), [[255, 0, 0], [0, 0, 255], [255, 255, 255]]); + expect(first.length).toBe(8 * 8 * 4); + // все сэмплы внутри bbox фигуры -> весь кадр в цвете фигуры, без фона + expect([...first.slice(0, 3)]).toEqual([255, 0, 0]); + expect([...second.slice(0, 3)]).toEqual([0, 0, 255]); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/tools/__tests__/boundary.test.mjs b/v1/packages/engine/tools/__tests__/boundary.test.mjs new file mode 100644 index 0000000..d1a2a44 --- /dev/null +++ b/v1/packages/engine/tools/__tests__/boundary.test.mjs @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { checkBoundary, importSpecs } from '../boundary.mjs'; + +/** Пути в test-параметрах пишутся со слэшем, приводим к системным сепараторам. */ +const sep = process.platform === 'win32' ? '\\' : '/'; +const files = (entries) => new Map(entries.map(([f, src]) => [f.replaceAll('/', sep), src])); + +describe('importSpecs', () => { + it('вытаскивает статические, side-effect, re-export и динамические импорты', () => { + const src = [ + "import { a } from './a';", + "import './b';", + 'import * as ns from "../c/d";', + "const m = await import('./dynamic');", + "export { x } from './reexport';" + ].join('\n'); + expect(importSpecs(src)).toEqual(['./a', './b', '../c/d', './dynamic', './reexport']); + }); +}); + +describe('checkBoundary: ядро', () => { + it('импорт из приложения — error', () => { + const v = checkBoundary(files([ + ['packages/engine/src/core/x.ts', "import { hero } from '../../../apps/game/src/data/npcs';"] + ])); + expect(v).toEqual([ + expect.objectContaining({ severity: 'error', rule: 'engine-no-game' }) + ]); + }); + + it('относительный импорт мимо пакета — error', () => { + const v = checkBoundary(files([ + ['packages/engine/src/core/x.ts', "import { t } from '../../../CLAUDE.md';"] + ])); + expect(v[0]).toMatchObject({ rule: 'engine-self-contained', severity: 'error' }); + }); + + it('npm-импорты и внутренние относительные — чисто', () => { + const v = checkBoundary(files([ + ['packages/engine/src/render/y.ts', "import { Container } from 'pixi.js';\nimport { a } from '../core/a';"] + ])); + expect(v).toEqual([]); + }); +}); + +describe('checkBoundary: приложение', () => { + it('под-путь ядра мимо публичного API — error', () => { + const v = checkBoundary(files([ + ['apps/game/src/systems/z.ts', "import { iso } from '@rpg/engine/math/iso';"] + ])); + expect(v[0]).toMatchObject({ rule: 'game-engine-public-api', severity: 'error', spec: '@rpg/engine/math/iso' }); + }); + + it('публичное API разрешено: сам пакет, assets/* и tools/*', () => { + const v = checkBoundary(files([ + ['apps/game/src/main.ts', "import { Game } from '@rpg/engine';\nimport fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url';"], + ['apps/game/src/systems/steps.ts', "import { bandNoise } from '@rpg/engine/tools/wav.mjs';"] + ])); + expect(v).toEqual([]); + }); + + it('относительный импорт в ядро мимо API — error', () => { + const v = checkBoundary(files([ + ['apps/game/src/data/map.ts', "import { rleDecode } from '../../../packages/engine/src/map/mapFormat';"] + ])); + expect(v[0]).toMatchObject({ rule: 'game-self-contained', severity: 'error' }); + }); + + it('npm-импорты и внутренние относительные — чисто', () => { + const v = checkBoundary(files([ + ['apps/game/src/scenes/s.ts', "import * as PIXI from 'pixi.js';\nimport { AREAS } from '../data/locations';"] + ])); + expect(v).toEqual([]); + }); + + it('опции расширяют публичное API (tools-под-путь)', () => { + const v = checkBoundary(files([ + ['apps/game/src/main.ts', "import { encodePng } from '@rpg/engine/tools/png.mjs';"] + ]), { publicApi: ['@rpg/engine', '@rpg/engine/assets/', '@rpg/engine/tools/'] }); + expect(v).toEqual([]); + }); + + it('файлы вне пакетов игнорируются', () => { + const v = checkBoundary(files([ + ['scripts/x.ts', "import { a } from '../../../packages/engine/src/index';"] + ])); + expect(v).toEqual([]); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/tools/__tests__/frames.test.mjs b/v1/packages/engine/tools/__tests__/frames.test.mjs new file mode 100644 index 0000000..4d3cf3f --- /dev/null +++ b/v1/packages/engine/tools/__tests__/frames.test.mjs @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { shiftFrame, bobFrame, squashFrame, mirrorFrame, swingFrame, deriveCycle } from '../frames.mjs'; + +/** Кадр 4×4 из строк символов: '.' — прозрачный, '#' — непрозрачный белый. */ +const frame = (rows) => { + const w = rows[0].length; + const h = rows.length; + const rgba = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (rows[y][x] === '#') rgba[(y * w + x) * 4 + 3] = 255; + } + } + return rgba; +}; + +/** Кадр обратно в строки символов (по alpha). */ +const toRows = (rgba, w, h) => { + const rows = []; + for (let y = 0; y < h; y++) { + let row = ''; + for (let x = 0; x < w; x++) row += rgba[(y * w + x) * 4 + 3] ? '#' : '.'; + rows.push(row); + } + return rows; +}; + +const SRC = frame([ + '....', + '.##.', + '.##.', + '....' +]); + +describe('shiftFrame', () => { + it('сдвигает содержимое, пустота — прозрачность', () => { + expect(toRows(shiftFrame(SRC, 4, 4, 1, 0), 4, 4)).toEqual([ + '....', + '..##', + '..##', + '....' + ]); + expect(toRows(shiftFrame(SRC, 4, 4, 0, -1), 4, 4)).toEqual([ + '.##.', + '.##.', + '....', + '....' + ]); + // Сдвиг за край — кадр полностью прозрачный + expect(toRows(shiftFrame(SRC, 4, 4, 4, 0), 4, 4)).toEqual(['....', '....', '....', '....']); + }); +}); + +describe('bobFrame', () => { + it('поднимает тело на dy (по умолчанию 1)', () => { + expect(toRows(bobFrame(SRC, 4, 4), 4, 4)).toEqual([ + '.##.', + '.##.', + '....', + '....' + ]); + expect(toRows(bobFrame(SRC, 4, 4, 2), 4, 4)).toEqual([ + '.##.', + '....', + '....', + '....' + ]); + }); +}); + +describe('squashFrame', () => { + it('приседание: спрайт на 1 px вниз, нижний ряд срезается', () => { + const src = frame([ + '....', + '.##.', + '.##.', + '.##.' + ]); + expect(toRows(squashFrame(src, 4, 4), 4, 4)).toEqual([ + '....', + '....', + '.##.', + '.##.' + ]); + // Спрайт стал ниже: было 3 ряда содержимого, осталось 2 + const out = squashFrame(src, 4, 4); + expect(out[(0 * 4 + 1) * 4 + 3]).toBe(0); + expect(out[(3 * 4 + 1) * 4 + 3]).toBe(255); + }); +}); + +describe('mirrorFrame', () => { + it('отражает по горизонтали', () => { + const src = frame([ + '#...', + '.##.', + '....', + '....' + ]); + expect(toRows(mirrorFrame(src, 4, 4), 4, 4)).toEqual([ + '...#', + '.##.', + '....', + '....' + ]); + }); +}); + +describe('swingFrame', () => { + it('поворот вокруг низ-центра: 0 рад — кадр без изменений', () => { + expect(swingFrame(SRC, 4, 4, 0)).toEqual(SRC); + }); + + it('точка опоры остаётся на месте при малом угле', () => { + const out = swingFrame(SRC, 4, 4, 0.2); + // Нижняя пара пикселей фигуры (y=2) около оси — почти не сместилась + expect(out[(2 * 4 + 1) * 4 + 3]).toBe(255); + // Верх фигуры (y=1) ушла за пределы или сместилась — но что-то осталось + expect(toRows(out, 4, 4).join('')).not.toBe('................'); + }); +}); + +describe('deriveCycle', () => { + it('по дескриптору на кадр, операции применяются по порядку', () => { + const cycle = deriveCycle(SRC, 4, 4, [{}, { bob: 1 }, { mirror: true }]); + expect(cycle).toHaveLength(3); + expect(toRows(cycle[0], 4, 4)).toEqual(toRows(SRC, 4, 4)); + expect(toRows(cycle[1], 4, 4)).toEqual(toRows(bobFrame(SRC, 4, 4), 4, 4)); + expect(toRows(cycle[2], 4, 4)).toEqual(toRows(mirrorFrame(SRC, 4, 4), 4, 4)); + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/tools/__tests__/spectro.test.mjs b/v1/packages/engine/tools/__tests__/spectro.test.mjs new file mode 100644 index 0000000..61939a1 --- /dev/null +++ b/v1/packages/engine/tools/__tests__/spectro.test.mjs @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { encodeWav, decodeWav } from '../wav.mjs'; +import { spectrogram, envelope, spectroSheet } from '../spectro.mjs'; + +/** Синус 440 Гц амплитуды 0.5 (2 с — за пределами окна анализа). */ +const tone = (rate = 22050) => { + const n = rate * 2; + const s = new Float32Array(n); + for (let i = 0; i < n; i++) s[i] = 0.5 * Math.sin((2 * Math.PI * 440 * i) / rate); + return s; +}; + +describe('decodeWav', () => { + it('читает обратно то, что написал encodeWav', () => { + const src = tone(); + const { samples, rate } = decodeWav(encodeWav(src, 22050)); + expect(rate).toBe(22050); + expect(samples.length).toBe(src.length); + // 16-бит квантование: расхождение с оригиналом микроскопическое. + let err = 0; + for (let i = 0; i < samples.length; i++) err = Math.max(err, Math.abs(samples[i] - src[i])); + expect(err).toBeLessThan(1e-4); + }); + + it('падает с внятной ошибкой на не-WAV', () => { + expect(() => decodeWav(Buffer.from('not a wav file at all'))).toThrow(/не WAV/); + }); +}); + +describe('spectrogram/envelope', () => { + it('пик спектрограммы тона попадает на его частоту', () => { + const rate = 22050; + const spec = spectrogram(tone(rate), { win: 1024, hop: 512 }); + expect(spec.cols).toBeGreaterThan(0); + expect(Number.isFinite(spec.max)).toBe(true); + // 440 Гц → бин 440/22050*1024 ≈ 20 (из 512). + const bin = Math.round((440 / rate) * 1024); + let best = -Infinity; + let bestRow = 0; + for (let r = 0; r < spec.rows; r++) { + if (spec.db[r] > best) { + best = spec.db[r]; + bestRow = r; + } + } + expect(Math.abs(bestRow - bin)).toBeLessThanOrEqual(2); + }); + + it('огибающая нормализована в 0..1', () => { + const env = envelope(tone(), 22050); + for (const v of env) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(1); + } + expect(env.some((v) => v > 0.9)).toBe(true); // максимум достижим + }); +}); + +describe('spectroSheet', () => { + it('пишет PNG-лист по нескольким клипам', () => { + const dir = mkdtempSync(join(tmpdir(), 'spectro-')); + try { + const out = join(dir, 'sheet.png'); + const s = tone(); + spectroSheet( + [ + { name: 'a', samples: s, rate: 22050, loop: true }, + { name: 'b', samples: s, rate: 22050, loop: false } + ], + out + ); + const buf = readFileSync(out); + expect(buf.subarray(1, 4).toString()).toBe('PNG'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); \ No newline at end of file diff --git a/v1/packages/engine/tools/agent-lib.mjs b/v1/packages/engine/tools/agent-lib.mjs new file mode 100644 index 0000000..b1e342a --- /dev/null +++ b/v1/packages/engine/tools/agent-lib.mjs @@ -0,0 +1,186 @@ +/** + * Библиотека агентных проверок (puppeteer-core + системный Chromium) поверх + * моста window.__agent (EngineAgent). Единственное место с браузерным + * бойлерплейтом: смоуки и сценарии строятся поверх openGame()/AgentClient. + * Контент приложения (маяк загрузки и т.п.) передаётся параметрами. + */ +import puppeteer from 'puppeteer-core'; +import { spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; + +/** Корень монорепо (тулза живёт в packages/engine/tools — 3 уровня). */ +const ROOT = new URL('../../..', import.meta.url); + +export const CHROMIUM = '/usr/bin/chromium'; + +/** Запустить браузер с нужными headless-флагами (см. грабли autoplay-policy). */ +export async function launchBrowser() { + return puppeteer.launch({ + executablePath: CHROMIUM, + headless: true, + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', + '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] + }); +} + +/** + * Поднять dev-сервер vite (spawn + ожидание HTTP 200). Возвращает {url, stop}. + * cwd — каталог приложения с его vite-конфигом и index.html; по умолчанию + * корень монорепо (где vite есть, но конфига приложения нет — приложение + * должно передавать свой каталог, иначе получит 404 на всё). + */ +export async function startDevServer({ port = 5199, cwd = ROOT.pathname } = {}) { + // detached: true — процесс в своей группе: stop() убивает и детей npx-обёртки + // (голый kill обёртки оставляет зомби-вите, который держит порт). + const proc = spawn('npx', ['vite', '--port', String(port), '--strictPort'], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true + }); + const stop = () => { + try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); } + }; + // strictPort при занятом порте завершает наш vite сразу, но fetch продолжил + // бы отвечать чужим сервером — фиксируем выход и падаем. + let exited = null; + proc.on('exit', (code, signal) => { exited = { code, signal }; }); + const url = `http://localhost:${port}/`; + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + if (exited !== null) { + throw new Error(`vite (cwd ${cwd}) завершился сразу (код ${exited.code}) — порт ${port} занят чужим сервером?`); + } + try { + const res = await fetch(url); + if (res.ok) return { url, stop }; + } catch { /* ещё не поднялся */ } + await sleep(250); + } + stop(); + throw new Error(`dev-сервер на :${port} не поднялся за 30 с (cwd ${cwd})`); +} + +/** + * Открыть приложение и дождаться готовности агента. + * newGame=true — выполнить agent.newGame() и дождаться конца fade-перехода. + * beacon — консольная строка-маяк готовности; без него ждать сразу networkidle2. + * Возвращает {browser, page, agent}. + */ +export async function openGame({ url = 'http://localhost:5199/', newGame = true, beacon = null } = {}) { + const browser = await launchBrowser(); + const page = await browser.newPage(); + await page.setViewport({ width: 960, height: 540 }); + const consoleLogs = []; + page.on('console', (m) => consoleLogs.push(m.text())); + page.on('pageerror', (e) => consoleLogs.push(`[pageerror] ${e.message}`)); + + const booted = beacon + ? new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`маяк «${beacon}» не пришёл за 30 с`)), 30000); + page.on('console', (m) => { if (m.text().includes(beacon)) { clearTimeout(t); resolve(); } }); + }) + : Promise.resolve(); + await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); + await booted; + // Дождаться конца fade-перехода Boot->Menu: begin() молча отбрасывает + // команды, пришедшие во время перехода. + await sleep(1500); + + const agent = new AgentClient(page); + if (newGame) { + await agent.newGame(); + // fade-переход в локацию ещё идёт: сцена игнорирует клики до его конца. + await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); + } + return { browser, page, agent, consoleLogs }; +} + +/** + * Тонкий клиент поверх window.__agent: каждое поле — прокси-вызов page.evaluate. + * snapshot()/invariants() возвращают готовые объекты; waitFor — строка-pred. + */ +export class AgentClient { + constructor(page) { this.page = page; } + + async call(method, ...args) { + return this.page.evaluate((m, a) => { + const api = window.__agent; + if (!api) return { __agentError: 'window.__agent не зарегистрирован (DEV-сборка?)' }; + return api[m](...a); + }, method, args); + } + + snapshot() { return this.call('snapshot'); } + invariants() { return this.call('invariants'); } + step(n = 1, opts) { return this.call('step', n, opts); } + waitFor(pred, opts) { return this.call('waitFor', pred, opts); } + tapVirtual(x, y) { return this.call('tapVirtual', x, y); } + press(action, holdTicks) { return this.call('press', action, holdTicks); } + key(code) { return this.call('key', code); } + command(name, args) { return this.call('command', name, args); } + walkTo(x, y, opts) { return this.call('walkTo', x, y, opts); } + approach(x, y, opts) { return this.call('approach', x, y, opts); } + runDialogue(timeoutTicks) { return this.call('runDialogue', timeoutTicks); } + pickChoiceByText(text, timeoutTicks) { return this.call('pickChoiceByText', text, timeoutTicks); } + newGame() { return this.call('newGame'); } + currentArea() { return this.call('currentArea'); } + + async screenshot(path) { return this.page.screenshot({ path }); } +} + +/** + * Каркас сценариев: run() ловит исключения, finish() печатает JSON и ставит код. + * Использование: const c = new Checks('имя'); await c.run('шаг', async () => ...); c.finish(); + */ +export class Checks { + constructor(name) { + this.name = name; + this.results = []; + this.started = Date.now(); + } + /** Добавить проверку; исключение/ложь → неуспех, прогон продолжается. */ + async run(checkName, fn) { + const t0 = Date.now(); + try { + const details = await fn(); + this.results.push({ name: checkName, ok: details !== false, ms: Date.now() - t0, + details: details === true ? null : details ?? null }); + } catch (err) { + this.results.push({ name: checkName, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) }); + } + return this; + } + /** Ассерт-хелпер: неистинное условие кидает исключение с сообщением. */ + expect(cond, msg, details) { + if (!cond) throw new Error(msg + (details !== undefined ? `: ${JSON.stringify(details)}` : '')); + return details; + } + /** Напечатать JSON-отчёт и вернуть его (код выхода — через report.ok). */ + finish({ pretty = false } = {}) { + const report = { + name: this.name, + ok: this.results.every((r) => r.ok), + ms: Date.now() - this.started, + results: this.results + }; + console.log(pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report)); + return report; + } +} + +/** Разбор общих флагов CLI: --pretty/--json/--port/--only/--skip. */ +export function parseArgs(argv = process.argv.slice(2)) { + const out = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--pretty' || a === '--json') out.pretty = a === '--pretty'; + else if (a === '--port') out.port = Number(argv[++i]); + else if (a === '--only') out.only = (argv[++i] ?? '').split(',').filter(Boolean); + else if (a === '--skip') out.skip = (argv[++i] ?? '').split(',').filter(Boolean); + else if (a === '--new-game') out.newGame = true; + else if (a === '--out') out.out = argv[++i]; + else if (a === '--steps') out.steps = Number(argv[++i]); + else out._.push(a); + } + return out; +} \ No newline at end of file diff --git a/v1/packages/engine/tools/atlas.mjs b/v1/packages/engine/tools/atlas.mjs new file mode 100644 index 0000000..dc1b13a --- /dev/null +++ b/v1/packages/engine/tools/atlas.mjs @@ -0,0 +1,59 @@ +/** + * Сборка спрайт-атласа (Pixi Spritesheet): кадры в одну строку + JSON-описание. + * Модуль ничего не знает о контенте: кадры — массив Uint8Array w×h×4 (RGBA), + * имена и размеры задаёт вызывающий. + */ +import { encodePng } from './png.mjs'; + +/** + * Кадры в строку атласа шириной frames.length × frameW. + * @param frames массив Uint8Array (frameW×frameH×4, RGBA) + * @param names имена кадров (ключи JSON.frames) + * @param opts { frameW, frameH, image, animations? } — image: имя PNG рядом + * с JSON; animations: Record<имя, number[]> (индексы в names) — + * попадает в JSON.animations списками имён кадров (формат Pixi) + * @returns { png: Buffer, json: object } + */ +export function buildAtlas(frames, names, { frameW, frameH, image, animations }) { + if (frames.length !== names.length) { + throw new Error(`кадры и имена разной длины: ${frames.length} != ${names.length}`); + } + const sheetW = frameW * frames.length; + const sheet = new Uint8Array(sheetW * frameH * 4); + const framesJson = {}; + frames.forEach((src, i) => { + if (src.length !== frameW * frameH * 4) { + throw new Error(`кадр ${names[i]}: ожидается ${frameW}×${frameH}×4 байт, пришло ${src.length}`); + } + for (let y = 0; y < frameH; y++) { + for (let x = 0; x < frameW; x++) { + sheet.set(src.subarray((y * frameW + x) * 4, (y * frameW + x) * 4 + 4), ((y * sheetW) + i * frameW + x) * 4); + } + } + framesJson[names[i]] = { + frame: { x: i * frameW, y: 0, w: frameW, h: frameH }, + rotated: false, + trimmed: false, + sourceSize: { w: frameW, h: frameH } + }; + }); + const json = { + frames: framesJson, + meta: { image, size: { w: sheetW, h: frameH }, scale: 1 } + }; + if (animations) { + // Формат Pixi Spritesheet: имя анимации → список имён кадров. + json.animations = Object.fromEntries( + Object.entries(animations).map(([name, idx]) => [ + name, + idx.map((i) => { + if (i < 0 || i >= names.length) { + throw new Error(`анимация ${name}: индекс ${i} вне кадров (${names.length})`); + } + return names[i]; + }) + ]) + ); + } + return { png: encodePng(sheetW, frameH, Buffer.from(sheet)), json }; +} \ No newline at end of file diff --git a/v1/packages/engine/tools/boundary.mjs b/v1/packages/engine/tools/boundary.mjs new file mode 100644 index 0000000..e5cc679 --- /dev/null +++ b/v1/packages/engine/tools/boundary.mjs @@ -0,0 +1,111 @@ +/** + * Гвард границы «ядро / приложение» — правила проверки импортов: + * + * - src ядра не импортирует ничего из приложения и не выходит за пределы + * пакета относительными импортами; + * - src приложения импортирует ядро только через публичное API (сам пакет + * и разрешённые под-пути) и не выходит за пределы пакета. + * + * Библиотека не знает конкретной раскладки: корни пакетов и публичное API + * передаются параметрами (с дефолтами под раскладку этого монорепо). + * Сценарий запуска для конкретного репо — у приложения. + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Дефолтный корень монорепо (тулза живёт в packages/engine/tools — 3 уровня). */ +const DEFAULT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +/** @returns {string[]} файлы .ts/.tsx рекурсивно. */ +export function listSources(dir) { + const out = []; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) out.push(...listSources(p)); + else if (e.isFile() && /\.tsx?$/.test(e.name)) out.push(p); + } + return out; +} + +/** Спецификаторы всех статических и динамических импортов файла. */ +export function importSpecs(source) { + const specs = []; + // Тело импорта без ';', чтобы не переползти в следующий import (side-effect + // `import './b'` — без from: группа пропускается, кавычка берётся сразу). + const re = /(?:\bimport\s+(?:[^;'"]*?\sfrom\s*)?|\bexport\s+[^;'"]*?\sfrom\s+|import\s*\(\s*)(['"])([^'"]+)\1/g; + for (const m of source.matchAll(re)) specs.push(m[2]); + return specs; +} + +/** + * Опции границы: root — корень репо (ключи files — пути относительно него), + * engineDir/gameDir — корни пакетов, enginePkg — имя пакета ядра, publicApi — + * разрешённые префиксы импорта ядра из приложения. + */ +function resolveOpts({ root = DEFAULT_ROOT, engineDir = join(root, 'packages', 'engine'), + gameDir = join(root, 'apps', 'game'), enginePkg = '@rpg/engine', + publicApi = [enginePkg, `${enginePkg}/assets/`, `${enginePkg}/tools/`] } = {}) { + return { root, engineDir, gameDir, enginePkg, publicApi }; +} + +/** + * Чистая проверка: пары «путь файла → содержимое» (относительно root, + * с системными разделителями). Возвращает нарушения: + * {severity, file, spec, rule, message}. + */ +export function checkBoundary(files, opts = {}) { + const { root, engineDir, gameDir, enginePkg, publicApi } = resolveOpts(opts); + const engineRel = relative(root, engineDir); + const gameRel = relative(root, gameDir); + const out = []; + for (const [rel, source] of files) { + const pkg = rel.startsWith(engineRel + sep) ? 'engine' : rel.startsWith(gameRel + sep) ? 'game' : null; + if (!pkg) continue; + for (const spec of importSpecs(source)) { + // Ядро не должно знать ничего о приложении. + if (pkg === 'engine') { + if (spec.includes(gameRel) || /(^|[\\/])game([\\/]|$)/.test(spec)) { + out.push({ severity: 'error', file: rel, spec, rule: 'engine-no-game', message: 'ядро импортирует приложение' }); + continue; + } + if (spec.startsWith('.')) { + const target = resolve(dirname(join(root, rel)), spec); + if (!target.startsWith(resolve(engineDir) + sep)) { + out.push({ severity: 'error', file: rel, spec, rule: 'engine-self-contained', message: 'относительный импорт выходит из пакета ядра' }); + } + } + continue; + } + // Приложение: ядро только через публичное API (голое имя пакета — + // только само по себе, префиксы — по началу строки). + if (spec.startsWith(enginePkg)) { + const allowed = spec === enginePkg || + publicApi.some((p) => p !== enginePkg && spec.startsWith(p)); + if (!allowed) { + out.push({ severity: 'error', file: rel, spec, rule: 'game-engine-public-api', message: 'под-путь ядра мимо публичного API' }); + } + continue; + } + if (spec.startsWith('.')) { + const target = resolve(dirname(join(root, rel)), spec); + if (!target.startsWith(resolve(gameDir) + sep)) { + out.push({ severity: 'error', file: rel, spec, rule: 'game-self-contained', message: 'относительный импорт выходит из пакета приложения' }); + } + } + } + } + return out; +} + +/** Собрать исходники обоих пакетов: путь (относительно root) → содержимое. */ +export function collectSources(opts = {}) { + const { root, engineDir, gameDir } = resolveOpts(opts); + const files = new Map(); + for (const dir of [join(engineDir, 'src'), join(gameDir, 'src')]) { + for (const abs of listSources(dir)) { + files.set(relative(root, abs), readFileSync(abs, 'utf8')); + } + } + return files; +} \ No newline at end of file diff --git a/v1/packages/engine/tools/canvas.mjs b/v1/packages/engine/tools/canvas.mjs new file mode 100644 index 0000000..1d800f0 --- /dev/null +++ b/v1/packages/engine/tools/canvas.mjs @@ -0,0 +1,119 @@ +/** + * Мини-канвас для пиксель-арта: пиксель, ромб 2:1, детерминированный шум, + * ASCII-карты спрайтов. Таблица цветов (ключ -> [r,g,b]) передаётся параметром — + * модуль не знает ни одной конкретной палитры. + */ +import { encodePng } from './png.mjs'; + +export class Canvas { + /** + * @param {number} width + * @param {number} height + * @param {Record} [colors] таблица цветов + * по ключам; без неё set() принимает только [r,g,b]-массивы. + */ + constructor(width, height, colors = {}) { + this.width = width; + this.height = height; + this.colors = colors; + this.data = Buffer.alloc(width * height * 4); + this.data.fill(0); + } + + /** color — ключ из таблицы цветов, [r,g,b]-массив или null (стереть). */ + set(x, y, color, alpha = 255) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; + const i = (y * this.width + x) * 4; + if (color === null) { + this.data.fill(0, i, i + 4); + return; + } + const [r, g, b] = this.colors[color] ?? color; + this.data[i] = r; + this.data[i + 1] = g; + this.data[i + 2] = b; + this.data[i + 3] = alpha; + } + + /** Пиксель как [r,g,b,255] или null (вне канваса / прозрачный). */ + get(x, y) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return null; + const i = (y * this.width + x) * 4; + if (this.data[i + 3] === 0) return null; + return [this.data[i], this.data[i + 1], this.data[i + 2], 255]; + } + + toPng() { + return encodePng(this.width, this.height, this.data); + } +} + +/** Детерминированный ГПСЧ (mulberry32). */ +export function rng(seed) { + 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; + }; +} + +/** + * Залить ромб 2:1 по канвасу: base — базовый цвет; свет на верхних гранях, + * тень на нижних. Цвета — ключи палитры канваса или null. + * Центр ромба (cx, cy), ширина w, высота h. + */ +export function fillDiamond(canvas, cx, cy, w, h, base, light = null, dark = null) { + const hw = w / 2; + const hh = h / 2; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const rx = x - hw + 0.5; + const ry = y - hh + 0.5; + if (Math.abs(rx) / hw + Math.abs(ry) / hh <= 1) { + canvas.set(cx - hw + x, cy - hh + y, base); + } + } + } + for (let i = 0; i < hw; i++) { + // Верхний склон: от левого угла к верхней вершине (свет) и от вершины к правому углу. + const leftY = cy - Math.round((i / hw) * hh); + const rightY = cy - hh + Math.round((i / hw) * hh); + if (light) { + canvas.set(cx - hw + i, leftY, light); + canvas.set(cx + i, rightY, light); + } + // Нижний склон: от левого угла вниз к нижней вершине (тень) и от неё к правому углу. + const botLeftY = cy + Math.round((i / hw) * hh); + const botRightY = cy + hh - Math.round((i / hw) * hh); + if (dark) { + canvas.set(cx - hw + i, botLeftY, dark); + canvas.set(cx + i, botRightY, dark); + } + } +} + +/** + * ASCII-карта -> спрайт на новом канвасе. Каждый символ — ключ таблицы цветов; + * '.' — прозрачный пиксель; extra отображает дополнительные символы ('x' -> 'P1'). + */ +export function fromAscii(rows, colors = {}, extra = {}) { + const h = rows.length; + const w = Math.max(...rows.map((r) => r.length)); + const canvas = new Canvas(w, h, colors); + for (let y = 0; y < h; y++) { + for (let x = 0; x < rows[y].length; x++) { + const ch = rows[y][x]; + if (ch === '.') { + continue; + } + const key = extra[ch] ?? ch; + if (!colors[key]) { + throw new Error(`Неизвестный символ '${ch}' в строке ${y}: ${rows[y]}`); + } + canvas.set(x, y, key); + } + } + return canvas; +} \ No newline at end of file diff --git a/v1/packages/engine/tools/frames.mjs b/v1/packages/engine/tools/frames.mjs new file mode 100644 index 0000000..db29148 --- /dev/null +++ b/v1/packages/engine/tools/frames.mjs @@ -0,0 +1,108 @@ +/** + * Операции над кадрами (Uint8Array w×h×4 RGBA) — синтез производных кадров + * из опорных: псевдо-walk-циклы, idle-кадры оживителей, зеркалирование. + * Всё nearest, без сглаживания — пиксель-арт не мылится. + * Модуль жанронезависим: знает только формат RGBA-кадра. + */ + +/** Прозрачный ли пиксель (alpha 0). */ +const isEmpty = (rgba, i) => rgba[i + 3] === 0; + +/** Копия пикселя src[i] в dst[j] (или прозрачность, если src вне кадра). */ +function blit(src, dst, w, h, sx, sy, dx, dy) { + const j = (dy * w + dx) * 4; + if (sx < 0 || sy < 0 || sx >= w || sy >= h) { + dst[j + 3] = 0; + return; + } + const i = (sy * w + sx) * 4; + dst[j] = src[i]; + dst[j + 1] = src[i + 1]; + dst[j + 2] = src[i + 2]; + dst[j + 3] = src[i + 3]; +} + +/** + * Сдвиг кадра на (dx, dy) px; пустота заполняется прозрачностью. + * dx > 0 — вправо, dy > 0 — вниз. + */ +export function shiftFrame(src, w, h, dx, dy) { + const out = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + blit(src, out, w, h, x - dx, y - dy, x, y); + } + } + return out; +} + +/** + * Idle-кадр «парения»: тело поднимается на dy px (по умолчанию 1), + * снизу открывается прозрачность — для спрайтов с якорем по низу. + */ +export function bobFrame(src, w, h, dy = 1) { + return shiftFrame(src, w, h, 0, -dy); +} + +/** + * «Приседание»: весь спрайт уходит вниз на 1 px, нижний ряд срезается — + * спрайт становится ниже при том же якоре. Дешёвый 2-й кадр idle для + * спрайтов с полем прозрачности под ногами. + */ +export function squashFrame(src, w, h) { + return shiftFrame(src, w, h, 0, 1); +} + +/** Зеркалирование по горизонтали (кадр «вправо» → «влево»). */ +export function mirrorFrame(src, w, h) { + const out = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + blit(src, out, w, h, w - 1 - x, y, x, y); + } + } + return out; +} + +/** + * Покачивание: nearest-поворот на angle рад вокруг точки опоры + * (по умолчанию низ-центр — «укоренённое» качание, как sway). + * pivot: { x, y } в px. + */ +export function swingFrame(src, w, h, angle, pivot) { + const px = pivot ? pivot.x : (w - 1) / 2; + const py = pivot ? pivot.y : h - 1; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const out = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + // Обратный поворот dest → src (экранные координаты, y вниз). + const dx = x - px; + const dy = y - py; + blit(src, out, w, h, Math.round(px + dx * cos + dy * sin), Math.round(py - dx * sin + dy * cos), x, y); + } + } + return out; +} + +/** + * Цикл из опорного кадра: ops — список дескрипторов, один выходной кадр на + * дескриптор; операции внутри дескриптора применяются по порядку: + * { dx?, dy?, bob?, squash?, swing?, mirror? } + * (dx/dy — сдвиг; bob — парение вверх; squash — приседание; + * swing — угол покачивания в рад; mirror — зеркалирование). + */ +export function deriveCycle(src, w, h, ops) { + return ops.map((op) => applyOps(src, w, h, op)); +} + +function applyOps(src, w, h, op) { + let frame = src; + if (op.dx || op.dy) frame = shiftFrame(frame, w, h, op.dx ?? 0, op.dy ?? 0); + if (op.bob) frame = bobFrame(frame, w, h, op.bob); + if (op.squash) frame = squashFrame(frame, w, h); + if (op.swing) frame = swingFrame(frame, w, h, op.swing, op.pivot); + if (op.mirror) frame = mirrorFrame(frame, w, h); + return frame; +} \ No newline at end of file diff --git a/v1/packages/engine/tools/imaging.mjs b/v1/packages/engine/tools/imaging.mjs new file mode 100644 index 0000000..b3f6e1f --- /dev/null +++ b/v1/packages/engine/tools/imaging.mjs @@ -0,0 +1,168 @@ +/** + * Обработка растровых спрайтов: кроп по альфе, nearest-масштаб, квантизация + * в заданную палитру, обзорный лист. Палитра — параметр (массив [r,g,b]); + * модуль не знает ни одной конкретной палитры. + */ +import { writeFileSync } from 'node:fs'; +import { encodePng } from './png.mjs'; + +// ---------- кроп / масштаб ---------- + +/** Габариты непрозрачного содержимого (alpha >= 128). */ +function bbox({ width, height, rgba }) { + let x0 = width, y0 = height, x1 = -1, y1 = -1; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (rgba[(y * width + x) * 4 + 3] >= 128) { + if (x < x0) x0 = x; + if (y < y0) y0 = y; + if (x > x1) x1 = x; + if (y > y1) y1 = y; + } + } + } + return x1 < 0 ? null : { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 }; +} + +/** Пиксель (x,y) кропнутого изображения. */ +function at({ width, rgba }, b, x, y) { + const i = ((b.y + y) * width + b.x + x) * 4; + return [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]; +} + +/** Кроп по альфе + nearest-масштаб до size×size (вписывание с центровкой). */ +export function normalizeToSize(img, size = 16) { + const b = bbox(img); + if (!b) return new Uint8Array(size * size * 4); // пустой спрайт + const k = Math.max(b.w / size, b.h / size); + const dw = Math.max(1, Math.round(b.w / k)); + const dh = Math.max(1, Math.round(b.h / k)); + const ox = (size - dw) >> 1, oy = (size - dh) >> 1; + const out = new Uint8Array(size * size * 4); + for (let y = 0; y < dh; y++) { + for (let x = 0; x < dw; x++) { + const sx = b.x + Math.min(b.w - 1, Math.floor((x + 0.5) * k)); + const sy = b.y + Math.min(b.h - 1, Math.floor((y + 0.5) * k)); + const src = at(img, b, sx - b.x, sy - b.y); + const i = ((oy + y) * size + (ox + x)) * 4; + out.set(src, i); + } + } + return out; +} + +/** + * Квантизация RGBA в палитру (массив [r,g,b]): полупрозрачные пиксели -> + * прозрачные, остальные — ближайший по RGB цвет палитры. + */ +export function quantize(rgba, pal) { + const out = new Uint8Array(rgba.length); + for (let p = 0; p < rgba.length; p += 4) { + if (rgba[p + 3] < 128) continue; + let best = pal[0], bd = Infinity; + for (const c of pal) { + const d = (rgba[p] - c[0]) ** 2 + (rgba[p + 1] - c[1]) ** 2 + (rgba[p + 2] - c[2]) ** 2; + if (d < bd) { bd = d; best = c; } + } + out[p] = best[0]; out[p + 1] = best[1]; out[p + 2] = best[2]; out[p + 3] = 255; + } + return out; +} + +// ---------- фигуры на однотонном фоне (AI-листы) ---------- + +/** Манхэттен-дистанция RGB до фонового цвета (порог задаёт вызывающий). */ +function bgDist(img, i, bg) { + return Math.abs(img.rgba[i] - bg[0]) + Math.abs(img.rgba[i + 1] - bg[1]) + Math.abs(img.rgba[i + 2] - bg[2]); +} + +/** + * Поиск фигур на однотонном фоне (AI-лист с несколькими фигурами): связные + * компоненты пикселей, отстоящих от фона дальше `dist`. `bg: 'corner'` — фон + * берётся из углового пикселя листа; либо явный [r,g,b]. Возвращает bbox'ы + * фигур, отсортированные сверху-вниз, затем слева-направо (не более maxCount, + * площадь каждой — не меньше minArea). + */ +export function findFigures(img, { bg = 'corner', dist = 60, maxCount = 4, minArea = 400 } = {}) { + const bgColor = bg === 'corner' + ? [img.rgba[0], img.rgba[1], img.rgba[2]] + : bg; + const { width: w, height: h } = img; + const seen = new Uint8Array(w * h); + const boxes = []; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const p = y * w + x; + if (seen[p] || bgDist(img, p * 4, bgColor) < dist) continue; + // BFS компоненты с бегущими границами bbox. + let x0 = x, y0 = y, x1 = x, y1 = y, area = 0; + const queue = [p]; + seen[p] = 1; + while (queue.length) { + const q = queue.pop(); + const qx = q % w, qy = (q / w) | 0; + area++; + if (qx < x0) x0 = qx; if (qx > x1) x1 = qx; + if (qy < y0) y0 = qy; if (qy > y1) y1 = qy; + for (const [nx, ny] of [[qx - 1, qy], [qx + 1, qy], [qx, qy - 1], [qx, qy + 1]]) { + if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; + const n = ny * w + nx; + if (!seen[n] && bgDist(img, n * 4, bgColor) >= dist) { + seen[n] = 1; + queue.push(n); + } + } + } + if (area >= minArea) boxes.push({ x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 }); + } + } + return boxes + .sort((a, b) => a.y - b.y || a.x - b.x) + .slice(0, maxCount); +} + +/** + * Nearest-даунскейл прямоугольника `rect = {x,y,w,h}` изображения до w×h + * с фоном-прозрачностью: сэмпл по центрам ячеек, пиксели ближе `dist` к фону + * становятся прозрачными (квантовать результат — шаг вызывающего). + */ +export function resizeRect(img, rect, w, h, { bg = 'corner', dist = 60 } = {}) { + const bgColor = bg === 'corner' ? [img.rgba[0], img.rgba[1], img.rgba[2]] : bg; + const out = new Uint8Array(w * h * 4); + const k = Math.max(rect.w / w, rect.h / h); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sx = rect.x + Math.min(rect.w - 1, Math.floor((x + 0.5) * k)); + const sy = rect.y + Math.min(rect.h - 1, Math.floor((y + 0.5) * k)); + const si = (sy * img.width + sx) * 4; + if (bgDist(img, si, bgColor) < dist) continue; + out.set(img.rgba.subarray(si, si + 4), (y * w + x) * 4); + } + } + return out; +} + +// ---------- обзорный лист ---------- + +/** Лист PNG: сетка спрайтов (scale — во сколько раз увеличить каждый). */ +export function reviewSheet(files, path, { size = 16, scale = 4, cols = 6 } = {}) { + const pad = 6, cell = size * scale; + const rows = Math.ceil(files.length / cols); + const W = cols * (cell + pad) + pad; + const H = rows * (cell + pad) + pad; + const bg = Buffer.alloc(W * H * 4); + for (let p = 0; p < bg.length; p += 4) { bg[p] = 30; bg[p + 1] = 32; bg[p + 2] = 38; bg[p + 3] = 255; } + files.forEach(({ rgba }, i) => { + const cx = pad + (i % cols) * (cell + pad); + const cy = pad + Math.floor(i / cols) * (cell + pad); + for (let y = 0; y < cell; y++) { + for (let x = 0; x < cell; x++) { + const sp = ((y / scale) | 0) * size + ((x / scale) | 0); + const si = sp * 4, di = (cy + y) * W + cx + x; + if (rgba[si + 3] === 0) continue; + bg.set(rgba.subarray(si, si + 4), di * 4); + } + } + }); + writeFileSync(path, encodePng(W, H, Buffer.from(bg))); +} \ No newline at end of file diff --git a/v1/packages/engine/tools/png.mjs b/v1/packages/engine/tools/png.mjs new file mode 100644 index 0000000..7375ecb --- /dev/null +++ b/v1/packages/engine/tools/png.mjs @@ -0,0 +1,109 @@ +/** + * PNG-кодек без зависимостей (zlib из node:zlib): RGBA-пиксели <-> PNG-буфер. + * Энкодер пишет color type 6 (RGBA), bit depth 8, без чересстрочности; + * декодер читает ровно этот формат. + */ +import { deflateSync, inflateSync } from 'node:zlib'; + +// CRC32 для чанков PNG. +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buf) { + let c = 0xffffffff; + for (const b of buf) { + c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +/** rgba — Uint8Array длиной w*h*4. Возвращает PNG-буфер. */ +export function encodePng(width, height, rgba) { + // Сырые строки: байт фильтра (0 = None) + строка пикселей. + const raw = Buffer.alloc(height * (width * 4 + 1)); + for (let y = 0; y < height; y++) { + raw[y * (width * 4 + 1)] = 0; + rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4); + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // RGBA + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)) + ]); +} + +/** PNG-буфер -> { width, height, rgba: Buffer }. Поддержан только color type 6, 8 бит. */ +export function decodePng(buf) { + if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('не PNG'); + let pos = 8, width = 0, height = 0, bitDepth = 0, colorType = 0; + const idat = []; + while (pos < buf.length) { + const len = buf.readUInt32BE(pos); + const type = buf.toString('ascii', pos + 4, pos + 8); + const data = buf.subarray(pos + 8, pos + 8 + len); + if (type === 'IHDR') { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8]; + colorType = data[9]; + } else if (type === 'IDAT') idat.push(data); + pos += 12 + len; + } + if (colorType !== 6 || bitDepth !== 8) { + throw new Error(`PNG colorType=${colorType} bitDepth=${bitDepth} — жду RGBA 8-бит`); + } + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * 4; + const rgba = Buffer.alloc(height * stride); + let prev = Buffer.alloc(stride); + for (let y = 0; y < height; y++) { + const filter = raw[y * (stride + 1)]; + const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); + const cur = rgba.subarray(y * stride, (y + 1) * stride); + for (let x = 0; x < stride; x++) { + const a = x >= 4 ? cur[x - 4] : 0; + const b = prev[x]; + const c = x >= 4 ? prev[x - 4] : 0; + let v = line[x]; + if (filter === 1) v += a; + else if (filter === 2) v += b; + else if (filter === 3) v += (a + b) >> 1; + else if (filter === 4) { + const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); + v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } + cur[x] = v & 0xff; + } + prev = cur; + } + return { width, height, rgba }; +} \ No newline at end of file diff --git a/v1/packages/engine/tools/spectro.mjs b/v1/packages/engine/tools/spectro.mjs new file mode 100644 index 0000000..a15c7f2 --- /dev/null +++ b/v1/packages/engine/tools/spectro.mjs @@ -0,0 +1,227 @@ +/** + * Спектро-лист: «взгляд» ИИ-агента на WAV без слушания (аудио-гейты работ + * AI-контента). Жанронезависимо: вход — сэмплы, выход — PNG. На каждый клип — + * спектрограмма (лог-частота, dB-палитра), огибающая RMS и метки времени; + * для лупов — маркер шва (стык конца в начало). Модуль не знает контента игры. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { encodePng } from './png.mjs'; +import { decodeWav } from './wav.mjs'; + +const EPS = 1e-10; +/** Порог динамики спектрограммы, dB ниже максимума записи. */ +const DB_FLOOR = 66; + +/** Палитра dB→цвет: чёрный → синева → бирюза → золото → белый (жар). */ +const HEAT = [ + [12, 14, 20], + [26, 34, 62], + [38, 92, 108], + [70, 160, 130], + [158, 202, 96], + [222, 198, 74], + [252, 246, 220] +]; + +/** Итеративный radix-2 FFT (in-place, re/im длиной в степень двойки). */ +function fft(re, im) { + const n = re.length; + for (let i = 1, j = 0; i < n; i++) { + let bit = n >> 1; + for (; j & bit; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) { + [re[i], re[j]] = [re[j], re[i]]; + [im[i], im[j]] = [im[j], im[i]]; + } + } + for (let len = 2; len <= n; len <<= 1) { + const ang = (-2 * Math.PI) / len; + const wr = Math.cos(ang); + const wi = Math.sin(ang); + for (let i = 0; i < n; i += len) { + let cr = 1; + let ci = 0; + for (let k = 0; k < len / 2; k++) { + const ur = re[i + k]; + const ui = im[i + k]; + const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci; + const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr; + re[i + k] = ur + vr; + im[i + k] = ui + vi; + re[i + k + len / 2] = ur - vr; + im[i + k + len / 2] = ui - vi; + const ncr = cr * wr - ci * wi; + ci = cr * wi + ci * wr; + cr = ncr; + } + } + } +} + +/** + * Спектрограмма: окна Ханна win с шагом hop, |FFT| в dB. Возвращает + * { cols, rows, db: Float32Array (cols*rows, частота: строка 0 — верх/высокая) }. + */ +export function spectrogram(samples, { win = 1024, hop = 512 } = {}) { + const cols = Math.max(1, Math.floor((samples.length - win) / hop) + 1); + const rows = win / 2; + const db = new Float32Array(cols * rows); + const re = new Float32Array(win); + const im = new Float32Array(win); + let max = -Infinity; + for (let c = 0; c < cols; c++) { + for (let i = 0; i < win; i++) { + const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / win); + re[i] = (samples[c * hop + i] ?? 0) * w; + im[i] = 0; + } + fft(re, im); + for (let r = 0; r < rows; r++) { + const mag = Math.hypot(re[r], im[r]); // нижняя половина спектра + const v = 20 * Math.log10(mag + EPS); + db[c * rows + r] = v; + if (v > max) max = v; + } + } + return { cols, rows, db, max }; +} + +/** Огибающая RMS (0..1): окно winMs, шаг окно/2 — для полосы «дыхания». */ +export function envelope(samples, rate, winMs = 60) { + const win = Math.max(1, Math.round((winMs / 1000) * rate)); + const cols = Math.max(1, Math.floor(samples.length / win)); + const out = new Float32Array(cols); + let max = 0; + for (let c = 0; c < cols; c++) { + let sum = 0; + for (let i = 0; i < win; i++) { + const s = samples[c * win + i] ?? 0; + sum += s * s; + } + out[c] = Math.sqrt(sum / win); + if (out[c] > max) max = out[c]; + } + if (max > 0) for (let c = 0; c < cols; c++) out[c] /= max; + return out; +} + +/** dB→цвет палитры (0 = тише DB_FLOOR ниже максимума записи). */ +function heatIndex(db, max) { + const t = Math.max(0, Math.min(1, (db - (max - DB_FLOOR)) / DB_FLOOR)); + const seg = t * (HEAT.length - 1); + const i = Math.min(HEAT.length - 2, Math.floor(seg)); + const f = seg - i; + const mix = (c) => Math.round(HEAT[i][c] + (HEAT[i + 1][c] - HEAT[i][c]) * f); + return [mix(0), mix(1), mix(2)]; +} + +/** Крошечный шрифт цифр 3×5 (для номеров строк листа). */ +const DIGITS = ['111101101101111', '010110010010111', '111001111100111', '111001111001111', '101101111001001', '111100111001111', '111100111101111', '111001010100100', '111101111101111', '111101111001111']; + +/** Нарисовать цифру (индекс строки) в 3×5 с масштабом. */ +function drawNumber(px, x, y, n, scale = 2, color = [160, 168, 180]) { + for (const ch of String(n)) { + const g = DIGITS[Number(ch)]; + for (let i = 0; i < 15; i++) { + if (g[i] === '1') { + const gx = i % 3; + const gy = (i / 3) | 0; + for (let dy = 0; dy < scale; dy++) { + for (let dx = 0; dx < scale; dx++) { + const px0 = (y + gy * scale + dy) * px.width + x + gx * scale + dx; + px.data.set(color, px0 * 4); + } + } + } + } + x += 4 * scale; + } +} + +/** + * Лист клипов: спектро (log-частота) + огибающая + ось времени на строку. + * clips: { name, samples, rate, loop? }. loop=true — красный маркер шва + * (конец→начало) на правом краю спектро. Пишет PNG в path, возвращает путь. + */ +export function spectroSheet(clips, path, { width = 720, specH = 140, envH = 22 } = {}) { + const labelH = 12; + const rowH = labelH + specH + envH + 10; + const W = width; + const H = clips.length * rowH + 8; + const px = { width: W, height: H, data: Buffer.alloc(W * H * 4, 255) }; + const bg = [235, 236, 240]; + for (let i = 0; i < W * H; i++) px.data.set(bg, i * 4); + + clips.forEach((clip, row) => { + const y0 = row * rowH + 4; + const spec = spectrogram(clip.samples, { win: 1024, hop: Math.max(1, Math.floor(clip.samples.length / (W - 16))) }); + const fmin = 40; + const fmax = clip.rate / 2; + const x0 = 20; + const gw = W - x0 - 12; + for (let x = 0; x < gw; x++) { + const col = Math.min(spec.cols - 1, Math.floor((x / gw) * spec.cols)); + for (let y = 0; y < specH; y++) { + const f = fmax * Math.pow(fmin / fmax, y / specH); // лог-частота сверху вниз + const binIdx = Math.min(spec.rows - 1, Math.round((f / fmax) * spec.rows)); + const [r, g, b] = heatIndex(spec.db[col * spec.rows + binIdx], spec.max); + const pi = ((y0 + labelH + y) * W + x0 + x) * 4; + px.data.set([r, g, b], pi); + } + } + // Огибающая RMS: заполненная полоса под спектро. + const env = envelope(clip.samples, clip.rate); + const ey = y0 + labelH + specH + 4; + for (let x = 0; x < gw; x++) { + const v = env[Math.min(env.length - 1, Math.floor((x / gw) * env.length))] ?? 0; + const h = Math.round(v * (envH - 4)); + for (let y = 0; y < h; y++) { + const pi = ((ey + envH - 4 - y) * W + x0 + x) * 4; + px.data.set([70, 110, 140], pi); + } + } + // Секундные тики времени под огибающей. + const totalSec = clip.samples.length / clip.rate; + for (let s = 0; s <= totalSec; s++) { + const x = Math.round((s / totalSec) * (gw - 1)); + for (let y = 0; y < 4; y++) { + const pi = ((ey + envH + y) * W + x0 + x) * 4; + px.data.set([120, 126, 138], pi); + } + } + // Маркер шва лупа: красные кромки на стыке конец→начало. + if (clip.loop) { + for (let y = 0; y < specH; y++) { + const pl = ((y0 + labelH + y) * W + x0) * 4; + const pr = ((y0 + labelH + y) * W + x0 + gw - 1) * 4; + px.data.set([200, 60, 50], pl); + px.data.set([200, 60, 50], pr); + } + } + drawNumber(px, 6, y0 + labelH / 2 - 4, row + 1); + }); + + writeFileSync(path, encodePng(W, H, px.data)); + return path; +} + +// CLI: node spectro.mjs [clip.wav[:loop]...] +// :loop — пометить шов; порядок файлов = номера строк на листе. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [out, ...files] = process.argv.slice(2); + if (!out || files.length === 0) { + console.error('использование: node spectro.mjs [...]'); + process.exit(1); + } + const clips = files.map((f, i) => { + const loop = f.endsWith(':loop'); + const file = f.replace(/:loop$/, ''); + const buf = readFileSync(file); + const { samples, rate } = decodeWav(buf); + console.log(`${i + 1}: ${file} (${(samples.length / rate).toFixed(1)} с, ${rate} Гц)${loop ? ' [луп]' : ''}`); + return { name: file, samples, rate, loop }; + }); + console.log(`лист: ${spectroSheet(clips, out)}`); +} \ No newline at end of file diff --git a/v1/packages/engine/tools/synth.d.mts b/v1/packages/engine/tools/synth.d.mts new file mode 100644 index 0000000..d445fb8 --- /dev/null +++ b/v1/packages/engine/tools/synth.d.mts @@ -0,0 +1,29 @@ +/** + * Типы для synth.mjs: чистые примитивы синтеза без node-зависимостей + * (их можно импортировать и в браузере — в отличие от wav.mjs с node:fs). + * Сам .mjs без JSDoc-типов, декларация рядом покрывает оба tsconfig-проекта. + */ + +/** Частота дискретизации синтеза, Гц. */ +export declare const RATE: number; + +/** Детерминированный ГПСЧ (mulberry32): (seed) → функция () => [0..1). */ +export declare function makeRng(seed: number): () => number; + +/** Сэмплы длительностью seconds: fn(t секунды, i индекс) → значение волны. */ +export declare function synth(seconds: number, fn: (t: number, i: number) => number, rate?: number): Float32Array; + +/** Огибающая затухания: 1 на старте → 0 к концу (кривая t^power). */ +export declare function decay(t: number, dur: number, power?: number): number; + +/** Партиал колокола: sin(2π·freq·stretch·t) с затуханием decay. */ +export declare function bellPartial(freq: number, stretch: number, dur: number, power?: number): (t: number) => number; + +/** Полосовой шум: детерминированная последовательность сэмплов в полосе. */ +export declare function bandNoise(seed: number, lowHz: number, highHz: number, dur: number): () => number; + +/** Нормализация к пику (по модулю), дефолт 0.9. */ +export declare function normalize(samples: Float32Array, peak?: number): Float32Array; + +/** Сшить края в луп коротким кроссфейдом (возвращает копию без хвоста фейда). */ +export declare function loopify(samples: Float32Array, fadeSeconds?: number): Float32Array; \ No newline at end of file diff --git a/v1/packages/engine/tools/synth.mjs b/v1/packages/engine/tools/synth.mjs new file mode 100644 index 0000000..ad584a2 --- /dev/null +++ b/v1/packages/engine/tools/synth.mjs @@ -0,0 +1,72 @@ +/** + * Чистые примитивы синтеза (детерминированный шум, полосовой фильтр, + * огибающие, нормализация, кроссфейд-луп) — без node-импортов, чтобы + * работать и в тулзах (wav.mjs, gen.mjs), и в рантайме браузера + * (вариативные шаги игры поверх AudioManager.createBuffer). + * Частота по умолчанию — RATE Гц (моно). + */ + +export const RATE = 22050; + +/** Детерминированный шум. */ +export function makeRng(seed) { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Синтез: секунды -> Float32[]. fn(t, i) -> сэмпл. */ +export function synth(seconds, fn, rate = RATE) { + const n = Math.floor(seconds * rate); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) out[i] = fn(i / rate, i); + return out; +} + +/** Экспоненциальный спад: полный на старте, ~0 к концу. */ +export function decay(t, dur, power = 4) { + return Math.pow(Math.max(0, 1 - t / dur), power); +} + +/** Колокольный партиал: негармоничный (частота * растяжка), быстрый спад. */ +export function bellPartial(freq, stretch, dur, power) { + return (t) => Math.sin(2 * Math.PI * freq * stretch * t) * decay(t, dur, power); +} + +/** Полосовой шум через однополюсные фильтры (низ + верх). */ +export function bandNoise(seed, lowHz, highHz, dur) { + const r = makeRng(seed); + let l1 = 0; + let l2 = 0; + const aLow = 1 - Math.exp(-2 * Math.PI * lowHz / RATE); + const aHigh = 1 - Math.exp(-2 * Math.PI * highHz / RATE); + return () => { + const white = r() * 2 - 1; + l1 += aHigh * (white - l1); // фильтр верхов: выше highHz глушим + l2 += aLow * (l1 - l2); // фильтр низов: ниже lowHz глушим + return l2 - l1 < -1 ? -1 : Math.max(-1, Math.min(1, l1 - l2)); + }; +} + +/** Нормализовать по пику. */ +export function normalize(samples, peak = 0.9) { + let max = 0; + for (const s of samples) max = Math.max(max, Math.abs(s)); + if (max === 0) return samples; + const k = peak / max; + return samples.map((s) => s * k); +} + +/** Сшить луп: кроссфейд первых fade-секунд с концом. */ +export function loopify(samples, fadeSeconds = 0.5) { + const n = Math.floor(fadeSeconds * RATE); + for (let i = 0; i < n; i++) { + const k = i / n; + samples[i] = samples[i] * k + samples[samples.length - n + i] * (1 - k); + } + return samples.slice(0, samples.length - n); +} \ No newline at end of file diff --git a/v1/packages/engine/tools/wav.mjs b/v1/packages/engine/tools/wav.mjs new file mode 100644 index 0000000..d3e8519 --- /dev/null +++ b/v1/packages/engine/tools/wav.mjs @@ -0,0 +1,79 @@ +/** + * WAV-тулкит без зависимостей: энкодер поверх чистых примитивов синтеза + * (сами примитивы — в synth.mjs, они же используются в рантайме браузера). + * Формат: моно, RATE Гц, 16-бит PCM. + */ +import { + RATE, + bandNoise, + bellPartial, + decay, + loopify, + makeRng, + normalize, + synth +} from './synth.mjs'; + +export { RATE, bandNoise, bellPartial, decay, loopify, makeRng, normalize, synth }; + +/** Собрать WAV-файл из Float32 сэмплов (-1..1). */ +export function encodeWav(samples, rate = RATE) { + const n = samples.length; + const data = new ArrayBuffer(44 + n * 2); + const view = new DataView(data); + const str = (offset, s) => { + for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i)); + }; + str(0, 'RIFF'); + view.setUint32(4, 36 + n * 2, true); + str(8, 'WAVE'); + str(12, 'fmt '); + view.setUint32(16, 16, true); // размер fmt + view.setUint16(20, 1, true); // PCM + view.setUint16(22, 1, true); // моно + view.setUint32(24, rate, true); + view.setUint32(28, rate * 2, true); // байт/сек + view.setUint16(32, 2, true); // блок + view.setUint16(34, 16, true); // бит + str(36, 'data'); + view.setUint32(40, n * 2, true); + for (let i = 0; i < n; i++) { + const s = Math.max(-1, Math.min(1, samples[i])); + view.setInt16(44 + i * 2, Math.round(s * 32767), true); + } + return Buffer.from(data); +} + +/** + * Разобрать WAV в сэмплы: PCM 16-бит, моно или стерео (даунмикс в моно + * усреднением). Возвращает { samples: Float32Array (-1..1), rate }. Чанки + * (fmt/data) ищем по ID — файлы с LIST-примечаниями читаются корректно. + */ +export function decodeWav(buf) { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + const tag = (offset, n) => String.fromCharCode(...Array.from({ length: n }, (_, i) => view.getUint8(offset + i))); + if (tag(0, 4) !== 'RIFF' || tag(8, 4) !== 'WAVE') throw new Error('не WAV-файл'); + let rate = 0, bits = 0, channels = 0, data = null; + let off = 12; + while (off + 8 <= buf.length) { + const id = tag(off, 4); + const size = view.getUint32(off + 4, true); + if (id === 'fmt ') { + channels = view.getUint16(off + 10, true); + rate = view.getUint32(off + 12, true); + bits = view.getUint16(off + 22, true); + } else if (id === 'data') { + data = { off: off + 8, frames: Math.floor(size / 2 / channels) }; + } + off += 8 + size + (size % 2); // чанк выравнивается по слову + } + if (!data || !rate) throw new Error('WAV без fmt/data или не PCM'); + if (bits !== 16) throw new Error(`поддержан только PCM 16-бит (в файле ${bits})`); + const samples = new Float32Array(data.frames); + for (let i = 0; i < data.frames; i++) { + let sum = 0; + for (let c = 0; c < channels; c++) sum += view.getInt16(data.off + (i * channels + c) * 2, true) / 32768; + samples[i] = sum / channels; + } + return { samples, rate }; +} diff --git a/v1/packages/engine/tsconfig.json b/v1/packages/engine/tsconfig.json new file mode 100644 index 0000000..495443e --- /dev/null +++ b/v1/packages/engine/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": {}, + "include": ["src"] +} \ No newline at end of file diff --git a/v1/tsconfig.base.json b/v1/tsconfig.base.json new file mode 100644 index 0000000..84105cc --- /dev/null +++ b/v1/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 diff --git a/v1/vitest.config.ts b/v1/vitest.config.ts new file mode 100644 index 0000000..d971a8f --- /dev/null +++ b/v1/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Явный список тестов: юнит-тесты движка и игры (src), тулзы с чистой + * математикой (engine/tools, pixelart/aiart/maps). Остальное (smoke, checks, + * диалоговый редактор) — браузерные сценарии agent.mjs/smoke.mjs, не Vitest. + */ +export default defineConfig({ + test: { + include: [ + 'packages/engine/src/**/*.test.{ts,tsx}', + 'packages/engine/tools/**/*.test.mjs', + 'apps/game/src/**/*.test.{ts,tsx}', + 'apps/game/tools/**/*.{test.ts,test.mjs}' + ], + exclude: ['**/node_modules/**', '**/dist/**'] + } +}); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index d971a8f..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -/** - * Явный список тестов: юнит-тесты движка и игры (src), тулзы с чистой - * математикой (engine/tools, pixelart/aiart/maps). Остальное (smoke, checks, - * диалоговый редактор) — браузерные сценарии agent.mjs/smoke.mjs, не Vitest. - */ -export default defineConfig({ - test: { - include: [ - 'packages/engine/src/**/*.test.{ts,tsx}', - 'packages/engine/tools/**/*.test.mjs', - 'apps/game/src/**/*.test.{ts,tsx}', - 'apps/game/tools/**/*.{test.ts,test.mjs}' - ], - exclude: ['**/node_modules/**', '**/dist/**'] - } -}); \ No newline at end of file