diff --git a/CLAUDE.md b/CLAUDE.md index dfdcd83..bad373b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,10 +52,10 @@ - `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`), диалоги (`dialogues.ts` — графы `DialogueGraph`), NPC (`npcs.ts`); `validate.ts` — runtime-валидация контента → `Invariant[]` (проверяется в `agent:invariants` и юнит-тестах). +- `apps/game/src/data/` — весь контент: карта (`map.ts`), переходы и области (`locations.ts` — `TransitionDef`/`AREAS`), интерактивные объекты (`interactables.ts` — `INTERACTABLES`), диалоги (`dialogues.ts` — графы `DialogueGraph`), NPC (`npcs.ts`); `validate.ts` — runtime-валидация контента → `Invariant[]` (проверяется в `agent:invariants` и юнит-тестах). - `apps/game/src/agent/` — контентный слой агентного моста: `snapshot.ts` (сборка слоёв), `GameAgent.ts` (`window.__agent`, только DEV). - `apps/game/src/scenes/` — BootScene (грузит ассеты и шрифт) → MenuScene (MenuList) → LocationScene; сцены меняются через `SceneManager.replace/push/pop` (опционально с fade). -- `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация из атласа), диалоги (обёртка над DialogueRunner + DialogueBox). +- `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация из атласа), диалоги (обёртка над DialogueRunner + DialogueBox), интерактивные объекты (`Interactables.ts` — резолв реакций, used-флаги, `InteractSink`). - Флаги/переменные сюжета — в `GameState` (`game.state`), сериализуются в автосейв `autosave` (Esc в локации); настройки — `game.settings` (отдельный слот, не в сейвах). ### Пиксель-арт diff --git a/apps/game/assets/maps/house_elder.map b/apps/game/assets/maps/house_elder.map new file mode 100644 index 0000000..c8edec6 --- /dev/null +++ b/apps/game/assets/maps/house_elder.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":11,"height":9,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4},"9":{"height":1,"ground":8}},"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/meadows.map b/apps/game/assets/maps/meadows.map index 20ebdcb..43cb6ab 100644 --- a/apps/game/assets/maps/meadows.map +++ b/apps/game/assets/maps/meadows.map @@ -1 +1 @@ -{"format":"rpg-map","version":1,"width":28,"height":28,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4}},"encoding":"rle","tiles":[[0,1],[3,2],[4,1],[0,1],[4,3],[0,1],[4,3],[0,1],[4,1],[0,4],[4,8],[3,1],[4,1],[0,1],[1,1],[0,3],[4,3],[0,2],[4,1],[0,2],[4,1],[0,4],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[3,1],[1,2],[0,2],[4,2],[0,3],[4,4],[0,4],[4,1],[0,1],[4,2],[0,1],[4,4],[3,1],[0,2],[1,2],[0,1],[4,2],[0,6],[4,9],[0,1],[4,2],[3,1],[4,2],[3,1],[0,2],[1,2],[0,1],[4,2],[0,5],[4,3],[0,1],[4,2],[0,1],[4,1],[0,1],[4,1],[0,1],[4,2],[0,1],[4,1],[3,1],[0,1],[1,3],[0,8],[4,5],[5,1],[0,5],[4,1],[3,1],[4,2],[0,4],[1,2],[0,1],[4,3],[0,1],[4,1],[0,2],[4,2],[0,1],[4,1],[0,11],[3,1],[0,3],[1,3],[4,2],[0,1],[4,1],[0,1],[4,5],[0,1],[2,4],[0,7],[4,1],[0,1],[4,1],[0,1],[4,1],[1,2],[4,2],[0,2],[4,3],[0,1],[4,1],[0,1],[2,4],[0,3],[3,1],[0,3],[4,1],[3,1],[0,3],[4,1],[1,1],[4,3],[0,1],[4,5],[0,1],[2,2],[0,1],[5,1],[0,7],[3,1],[0,1],[4,1],[0,3],[1,2],[4,2],[0,1],[4,3],[0,8],[3,1],[0,3],[4,1],[3,1],[0,4],[4,3],[1,1],[0,3],[4,5],[0,6],[3,1],[0,2],[3,1],[0,1],[4,1],[3,1],[0,3],[4,3],[1,1],[0,4],[4,5],[0,1],[4,5],[0,1],[4,2],[0,2],[3,1],[0,4],[4,2],[1,1],[4,1],[0,5],[4,3],[0,1],[4,4],[0,3],[3,1],[0,1],[4,1],[0,1],[3,1],[0,4],[4,1],[1,2],[4,2],[0,3],[4,3],[0,1],[4,4],[0,1],[1,2],[4,1],[3,2],[4,1],[0,5],[4,1],[0,1],[1,1],[0,1],[4,1],[0,4],[4,1],[0,2],[4,4],[0,3],[3,2],[0,1],[4,1],[3,1],[0,2],[2,2],[0,2],[1,3],[4,1],[0,6],[4,1],[0,1],[4,4],[0,1],[4,1],[3,1],[0,1],[3,1],[4,3],[2,4],[5,1],[0,1],[1,1],[4,1],[0,13],[3,1],[0,1],[3,1],[4,5],[2,3],[4,2],[1,1],[4,3],[0,4],[4,1],[0,3],[3,1],[0,4],[3,1],[4,1],[3,1],[0,1],[4,4],[0,2],[5,1],[1,2],[4,2],[0,3],[4,3],[0,13],[4,4],[0,1],[4,1],[1,1],[0,5],[4,2],[0,1],[10,1],[0,2],[4,2],[0,1],[3,3],[0,6],[4,1],[0,2],[4,1],[1,3],[0,9],[4,1],[0,5],[3,1],[0,6],[4,4],[0,1],[1,1],[0,8],[4,3],[0,1],[3,1],[0,1],[3,1],[0,3],[4,4],[0,1],[4,1],[0,1],[4,1],[0,1],[1,1],[0,10],[3,1],[0,2],[3,3],[0,2],[4,1],[0,2],[4,5],[0,1],[1,1],[0,9],[4,2],[0,2],[3,1],[0,4],[4,2],[0,7],[1,10],[4,2],[3,1],[0,24],[4,4],[0,2],[3,1],[0,21],[3,4]]} \ No newline at end of file +{"format":"rpg-map","version":1,"width":28,"height":28,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4},"9":{"height":1,"ground":8}},"encoding":"rle","tiles":[[0,1],[3,2],[4,1],[0,1],[4,3],[0,1],[4,3],[0,1],[4,1],[0,4],[4,8],[3,1],[4,1],[0,1],[1,1],[0,3],[4,3],[0,2],[4,1],[0,2],[4,1],[0,4],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[3,1],[1,2],[0,2],[4,2],[0,3],[4,4],[0,4],[4,1],[0,1],[4,2],[0,1],[4,4],[3,1],[0,2],[1,2],[0,1],[4,2],[0,6],[4,9],[0,1],[4,2],[3,1],[4,2],[3,1],[0,2],[1,2],[0,1],[4,2],[0,5],[4,3],[0,1],[4,2],[0,1],[4,1],[0,1],[4,1],[0,1],[4,2],[0,1],[4,1],[3,1],[0,1],[1,3],[0,8],[4,5],[5,1],[0,5],[4,1],[3,1],[4,2],[0,4],[1,2],[0,1],[4,3],[0,1],[4,1],[0,2],[4,2],[0,1],[4,1],[0,11],[3,1],[0,3],[1,3],[4,2],[0,1],[4,1],[0,1],[4,5],[0,1],[2,4],[0,7],[4,1],[0,1],[4,1],[0,1],[4,1],[1,2],[4,2],[0,2],[4,3],[0,1],[4,1],[0,1],[2,4],[0,3],[3,1],[0,3],[4,1],[3,1],[0,3],[4,1],[1,1],[4,3],[0,1],[4,5],[0,1],[2,2],[0,1],[5,1],[0,7],[3,1],[0,1],[4,1],[0,3],[1,2],[4,2],[0,1],[4,3],[0,8],[3,1],[0,3],[4,1],[3,1],[0,4],[4,3],[1,1],[0,3],[4,5],[0,6],[3,1],[0,2],[3,1],[0,1],[4,1],[3,1],[0,3],[4,3],[1,1],[0,4],[4,5],[0,1],[4,5],[0,1],[4,2],[0,2],[3,1],[0,4],[4,2],[1,1],[4,1],[0,5],[4,3],[0,1],[4,4],[0,3],[3,1],[0,1],[4,1],[0,1],[3,1],[0,4],[4,1],[1,2],[4,2],[0,3],[4,3],[0,1],[4,4],[0,1],[1,2],[4,1],[3,2],[4,1],[0,5],[4,1],[0,1],[1,1],[0,1],[4,1],[0,4],[4,1],[0,2],[4,4],[0,3],[3,2],[0,1],[4,1],[3,1],[0,2],[2,2],[0,2],[1,3],[4,1],[0,6],[4,1],[0,1],[4,4],[0,1],[4,1],[3,1],[0,1],[3,1],[4,3],[2,4],[5,1],[0,1],[1,1],[4,1],[0,13],[3,1],[0,1],[3,1],[4,5],[2,3],[4,2],[1,1],[4,3],[0,4],[4,1],[0,3],[3,1],[0,4],[3,1],[4,1],[3,1],[0,1],[4,4],[0,2],[5,1],[1,2],[4,2],[0,3],[4,3],[0,13],[4,4],[0,1],[4,1],[1,1],[0,5],[4,2],[0,1],[10,1],[0,2],[4,2],[0,1],[3,3],[0,6],[4,1],[0,2],[4,1],[1,3],[0,9],[4,1],[0,5],[3,1],[0,6],[4,4],[0,1],[1,1],[0,8],[4,3],[0,1],[3,1],[0,1],[3,1],[0,3],[4,4],[0,1],[4,1],[0,1],[4,1],[0,1],[1,1],[0,10],[3,1],[0,2],[3,3],[0,2],[4,1],[0,2],[4,5],[0,1],[1,1],[0,9],[4,2],[0,2],[3,1],[0,4],[4,2],[0,7],[1,10],[4,2],[3,1],[0,24],[4,4],[0,2],[3,1],[0,21],[3,4]]} \ No newline at end of file diff --git a/apps/game/assets/maps/ponds.map b/apps/game/assets/maps/ponds.map index 686e0ba..e3510d3 100644 --- a/apps/game/assets/maps/ponds.map +++ b/apps/game/assets/maps/ponds.map @@ -1 +1 @@ -{"format":"rpg-map","version":1,"width":24,"height":24,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4}},"encoding":"rle","tiles":[[4,4],[0,1],[4,13],[0,3],[3,1],[4,2],[3,2],[0,4],[4,1],[0,1],[4,2],[0,1],[4,9],[0,3],[4,1],[3,2],[1,1],[0,3],[4,1],[0,1],[4,2],[0,1],[4,9],[0,1],[3,1],[4,3],[3,1],[4,1],[1,1],[0,1],[4,2],[0,2],[4,1],[0,2],[4,8],[0,1],[3,1],[0,2],[3,1],[0,5],[4,1],[0,2],[4,1],[0,2],[4,5],[0,1],[4,2],[0,1],[3,1],[0,2],[4,1],[0,2],[4,1],[1,1],[0,1],[4,1],[0,1],[4,1],[0,3],[4,4],[0,2],[4,3],[3,1],[0,1],[4,2],[3,1],[4,1],[0,1],[1,1],[0,1],[4,2],[0,6],[4,2],[5,1],[0,4],[4,1],[3,1],[0,1],[4,1],[0,1],[4,2],[1,2],[0,1],[4,1],[2,1],[0,1],[2,1],[4,1],[2,1],[4,1],[2,1],[0,1],[4,2],[0,1],[4,1],[0,2],[3,1],[4,3],[0,3],[1,1],[0,1],[4,1],[2,7],[4,1],[2,1],[4,1],[0,1],[4,1],[0,1],[4,1],[3,1],[4,2],[3,1],[0,2],[4,1],[1,3],[2,11],[0,3],[4,3],[3,2],[0,2],[2,2],[1,1],[2,10],[4,1],[2,1],[4,1],[0,1],[4,1],[3,2],[0,1],[4,1],[0,1],[4,2],[2,1],[1,2],[2,10],[4,1],[0,2],[4,2],[3,3],[0,2],[2,3],[1,1],[2,10],[0,2],[3,1],[4,2],[0,4],[4,1],[2,3],[1,1],[2,12],[4,1],[3,1],[4,2],[0,1],[3,1],[0,1],[4,1],[2,3],[1,1],[2,10],[4,5],[0,2],[4,4],[2,2],[1,2],[2,9],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[0,2],[4,2],[2,2],[4,1],[1,1],[2,7],[4,1],[5,1],[4,2],[3,2],[4,4],[0,2],[4,1],[0,1],[5,1],[2,1],[1,1],[4,1],[2,2],[4,1],[2,1],[4,1],[2,2],[4,6],[3,1],[4,3],[0,2],[4,3],[1,7],[4,5],[3,1],[4,1],[0,1],[4,2],[3,1],[0,1],[4,2],[0,2],[4,13],[0,3],[4,1],[3,1],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[5,1],[0,1],[4,7],[0,1],[4,1],[0,1],[4,1],[3,1],[4,3],[0,2],[4,4],[0,2],[4,10],[3,1],[4,11],[0,3],[4,21],[0,1],[4,10],[3,2]]} \ No newline at end of file +{"format":"rpg-map","version":1,"width":24,"height":24,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4},"9":{"height":1,"ground":8}},"encoding":"rle","tiles":[[4,4],[0,1],[4,13],[0,3],[3,1],[4,2],[3,2],[0,4],[4,1],[0,1],[4,2],[0,1],[4,9],[0,3],[4,1],[3,2],[1,1],[0,3],[4,1],[0,1],[4,2],[0,1],[4,9],[0,1],[3,1],[4,3],[3,1],[4,1],[1,1],[0,1],[4,2],[0,2],[4,1],[0,2],[4,8],[0,1],[3,1],[0,2],[3,1],[0,5],[4,1],[0,2],[4,1],[0,2],[4,5],[0,1],[4,2],[0,1],[3,1],[0,2],[4,1],[0,2],[4,1],[1,1],[0,1],[4,1],[0,1],[4,1],[0,3],[4,4],[0,2],[4,3],[3,1],[0,1],[4,2],[3,1],[4,1],[0,1],[1,1],[0,1],[4,2],[0,6],[4,2],[5,1],[0,4],[4,1],[3,1],[0,1],[4,1],[0,1],[4,2],[1,2],[0,1],[4,1],[2,1],[0,1],[2,1],[4,1],[2,1],[4,1],[2,1],[0,1],[4,2],[0,1],[4,1],[0,2],[3,1],[4,3],[0,3],[1,1],[0,1],[4,1],[2,7],[4,1],[2,1],[4,1],[0,1],[4,1],[0,1],[4,1],[3,1],[4,2],[3,1],[0,2],[4,1],[1,3],[2,11],[0,3],[4,3],[3,2],[0,2],[2,2],[1,1],[2,10],[4,1],[2,1],[4,1],[0,1],[4,1],[3,2],[0,1],[4,1],[0,1],[4,2],[2,1],[1,2],[2,10],[4,1],[0,2],[4,2],[3,3],[0,2],[2,3],[1,1],[2,10],[0,2],[3,1],[4,2],[0,4],[4,1],[2,3],[1,1],[2,12],[4,1],[3,1],[4,2],[0,1],[3,1],[0,1],[4,1],[2,3],[1,1],[2,10],[4,5],[0,2],[4,4],[2,2],[1,2],[2,9],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[0,2],[4,2],[2,2],[4,1],[1,1],[2,7],[4,1],[5,1],[4,2],[3,2],[4,4],[0,2],[4,1],[0,1],[5,1],[2,1],[1,1],[4,1],[2,2],[4,1],[2,1],[4,1],[2,2],[4,6],[3,1],[4,3],[0,2],[4,3],[1,7],[4,5],[3,1],[4,1],[0,1],[4,2],[3,1],[0,1],[4,2],[0,2],[4,13],[0,3],[4,1],[3,1],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[5,1],[0,1],[4,7],[0,1],[4,1],[0,1],[4,1],[3,1],[4,3],[0,2],[4,4],[0,2],[4,10],[3,1],[4,11],[0,3],[4,21],[0,1],[4,10],[3,2]]} \ No newline at end of file diff --git a/apps/game/assets/maps/shop.map b/apps/game/assets/maps/shop.map new file mode 100644 index 0000000..c8edec6 --- /dev/null +++ b/apps/game/assets/maps/shop.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":11,"height":9,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4},"9":{"height":1,"ground":8}},"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 index c6fd8a5..43910d8 100644 --- a/apps/game/assets/maps/zvenets.map +++ b/apps/game/assets/maps/zvenets.map @@ -1 +1 @@ -{"format":"rpg-map","version":1,"width":28,"height":20,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4}},"encoding":"rle","tiles":[[4,30],[3,1],[4,6],[0,4],[4,13],[3,1],[4,9],[0,2],[4,2],[0,1],[4,23],[0,3],[4,1],[0,1],[4,12],[3,1],[4,2],[3,1],[4,1],[3,1],[4,5],[0,5],[4,12],[3,1],[4,2],[0,2],[4,6],[0,5],[4,1],[0,1],[4,3],[7,1],[4,6],[3,1],[4,3],[3,1],[4,5],[7,1],[4,1],[10,1],[1,10],[4,8],[3,1],[0,2],[4,6],[1,5],[6,1],[4,1],[1,1],[4,1],[1,2],[4,6],[3,3],[4,1],[3,1],[4,6],[1,8],[4,1],[1,1],[4,6],[3,1],[4,3],[0,1],[4,7],[1,1],[4,1],[1,9],[4,1],[7,1],[4,8],[1,12],[0,1],[1,1],[4,1],[1,3],[4,1],[0,3],[4,4],[3,1],[0,1],[4,7],[1,2],[4,1],[1,8],[0,4],[4,5],[3,1],[0,5],[4,2],[1,11],[4,1],[0,3],[4,6],[3,1],[0,6],[1,8],[4,1],[1,2],[4,1],[0,3],[4,4],[3,1],[4,3],[0,3],[4,1],[0,5],[4,17],[0,1],[4,1],[0,2],[4,1],[0,1],[4,1],[0,2],[4,5],[0,4],[4,7],[3,1],[0,2],[4,1],[0,1],[4,1],[0,2],[4,2],[0,2],[4,4],[0,5],[4,5],[3,2],[4,2],[0,1],[3,1],[4,5],[0,12],[4,8],[0,3],[4,6],[0,5],[4,1],[0,5],[4,4],[0,1],[3,1],[0,1],[4,1],[3,1],[0,1],[4,7],[0,7],[4,1],[0,3],[4,4],[0,3],[4,1]]} \ No newline at end of file +{"format":"rpg-map","version":1,"width":28,"height":20,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4},"9":{"height":1,"ground":8}},"encoding":"rle","tiles":[[4,30],[3,1],[4,6],[0,4],[4,13],[3,1],[4,9],[0,2],[4,2],[0,1],[4,23],[0,3],[4,1],[0,1],[4,12],[3,1],[4,2],[3,1],[4,1],[3,1],[4,5],[0,5],[4,12],[3,1],[4,2],[0,2],[4,6],[0,5],[4,1],[0,1],[4,3],[7,1],[4,6],[3,1],[4,3],[3,1],[4,5],[7,1],[4,1],[10,1],[1,10],[4,8],[3,1],[0,2],[4,6],[1,5],[6,1],[4,1],[1,1],[4,1],[1,2],[4,6],[3,3],[4,1],[3,1],[4,6],[1,8],[4,1],[1,1],[4,6],[3,1],[4,3],[0,1],[4,7],[1,1],[4,1],[1,9],[4,1],[7,1],[4,8],[1,12],[0,1],[1,1],[4,1],[1,3],[4,1],[0,3],[4,4],[3,1],[0,1],[4,7],[1,2],[4,1],[1,8],[0,4],[4,5],[3,1],[0,5],[4,2],[1,11],[4,1],[0,3],[4,6],[3,1],[0,6],[1,8],[4,1],[1,2],[4,1],[0,3],[4,4],[3,1],[4,3],[0,3],[4,1],[0,5],[4,17],[0,1],[4,1],[0,2],[4,1],[0,1],[4,1],[0,2],[4,5],[0,4],[4,7],[3,1],[0,2],[4,1],[0,1],[4,1],[0,2],[4,2],[0,2],[4,4],[0,5],[4,5],[3,2],[4,2],[0,1],[3,1],[4,5],[0,12],[4,8],[0,3],[4,6],[0,5],[4,1],[0,5],[4,4],[0,1],[3,1],[0,1],[4,1],[3,1],[0,1],[4,7],[0,7],[4,1],[0,3],[4,4],[0,3],[4,1]]} \ No newline at end of file diff --git a/apps/game/src/agent/GameAgent.ts b/apps/game/src/agent/GameAgent.ts index 7ea4452..0ab42ba 100644 --- a/apps/game/src/agent/GameAgent.ts +++ b/apps/game/src/agent/GameAgent.ts @@ -90,7 +90,22 @@ ): Promise<{ ok: boolean; snapshot: GameSnapshot; ticks: number }> { // Строка-выражение приходит из tools/ (доверенная среда репозитория). const fn = new Function('s', `"use strict"; return (${pred});`) as (s: SnapshotLayer) => boolean; - return this.engineAgent.waitFor(fn, opts) as Promise<{ ok: boolean; snapshot: GameSnapshot; ticks: number }>; + 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 as unknown as SnapshotLayer); + } catch { + ok = false; + } + if (ok) return { ok: true, snapshot: s, ticks: i + 1 }; + } + return { ok: false, snapshot: this.snapshot(), ticks: limit }; } /** Клик по тайлу: юниты -> экран (виртуальные px) -> инъекция указателя. */ diff --git a/apps/game/src/agent/snapshot.ts b/apps/game/src/agent/snapshot.ts index dcceffe..0791e7d 100644 --- a/apps/game/src/agent/snapshot.ts +++ b/apps/game/src/agent/snapshot.ts @@ -52,6 +52,14 @@ label: string | null; } +/** Интерактивный объект области (used — для once-объектов). */ +export interface InteractableSnapshot { + id: string; + tile: { x: number; y: number }; + label: string | null; + used: boolean; +} + /** Контентный слой сцены локации. */ export interface LocationSnapshot { scene: 'location'; @@ -61,6 +69,7 @@ enemies: EnemySnapshot[]; npcs: NpcSnapshot[]; transitions: TransitionSnapshot[]; + interactables: InteractableSnapshot[]; dialogue: DialogueSnapshot | null; cutscene: { active: boolean } | null; /** Последний тост (текст + тик) — единственный канал текста реакций. */ diff --git a/apps/game/src/data/__tests__/interactables.test.ts b/apps/game/src/data/__tests__/interactables.test.ts new file mode 100644 index 0000000..083c197 --- /dev/null +++ b/apps/game/src/data/__tests__/interactables.test.ts @@ -0,0 +1,72 @@ +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', + 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/interactables.ts b/apps/game/src/data/interactables.ts new file mode 100644 index 0000000..127535b --- /dev/null +++ b/apps/game/src/data/interactables.ts @@ -0,0 +1,153 @@ +import type { ItemId } from './items'; + +/** + * Интерактивные объекты области: сундуки, очаги, записки, прилавки. + * Первая подходящая реакция (по `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 }; + /** Выдать предмет (в сумку). */ + gives?: ItemId; + /** Сколько штук gives (по умолчанию 1). */ + count?: number; + /** Ключ звука (sfx/...). */ + sound?: string; +} + +/** Объект мира, с которым герой взаимодействует кликом. */ +export interface InteractableDef { + id: string; + tile: { x: number; y: number }; + kind: 'examine' | 'pickup' | 'container' | 'lever' | 'signpost'; + /** Подпись при близости (и в снапшоте). */ + 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: дом Ирвина, лавка Милы). */ +export const INTERACTABLES: Record<'house_elder' | 'shop', InteractableDef[]> = { + house_elder: [ + { + id: 'chest_elder', + tile: { x: 3, y: 3 }, + kind: 'container', + label: 'Сундук', + once: true, + responses: [ + { + text: 'В сундуке — запас вощёного полотна.', + gives: 'cloth', + sound: 'sfx/ui_click' + } + ] + }, + { + id: 'hearth_elder', + tile: { x: 8, y: 0 }, + kind: 'examine', + label: 'Очаг', + responses: [ + { + when: { flag: 'quest_bells_done' }, + text: 'Очаг горит ровнее. Пепел отступает — и тепло возвращается.', + sound: 'sfx/bell_low' + }, + { text: 'В очаге тлеют щепки. Тепло — редкость теперь.', sound: 'sfx/ash_hiss' } + ] + }, + { + id: 'note_elder', + tile: { x: 10, y: 4 }, + kind: 'examine', + label: 'Записка', + once: true, + responses: [ + { + text: '«Тоны уходят вниз. Если колокол смолкнет — не буди Молчание».', + setFlags: ['read_note'], + sound: 'sfx/ui_click' + } + ] + } + ], + shop: [ + { + id: 'counter_shop', + tile: { x: 5, y: 3 }, + kind: 'examine', + label: 'Прилавок', + responses: [ + { + when: { flag: 'quest_bells_done' }, + text: 'На прилавке — соль и цветы в плошке. Поляна гудит — Мила звонит чаще.', + sound: 'sfx/bell_hit' + }, + { + text: 'Соль, вощёное полотно, вода в бутлях. Цен Мила не пишет — звонит.', + sound: 'sfx/bell_hit' + } + ] + } + ] +}; \ No newline at end of file diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 919465b..20fbd42 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -2,13 +2,14 @@ import type { EnemyKindId } from './enemies'; import { NPCS } from './npcs'; import type { ItemId } from './items'; +import { INTERACTABLES, type InteractableDef } from './interactables'; /** * Области демо-среза (улица и интерьер — общий контекст). Карты хранятся * файлами assets/maps/.map (формат rpg-map, RLE) и загружаются через * parseMap в BootScene. */ -export type AreaId = 'meadows' | 'ponds' | 'zvenets'; +export type AreaId = 'meadows' | 'ponds' | 'zvenets' | 'house_elder' | 'shop'; /** Куда ведёт переход: в другую область (с точкой входа) или назад, откуда вошли. */ export type TransitionTarget = @@ -54,6 +55,8 @@ npcs: NpcDef[]; enemies: { kind: EnemyKindId; tile: { x: number; y: number } }[]; transitions: TransitionDef[]; + /** Интерактивные объекты (сундуки, очаги, прилавки). */ + interactables?: InteractableDef[]; /** Атмосферные частицы: 'ash' — пепел, 'fog' — туман прудов, 'none' — интерьер. */ ambience: 'ash' | 'fog' | 'none'; /** Зоны наката (необязательно). */ @@ -159,9 +162,50 @@ target: { kind: 'area', area: 'meadows', entry: { x: 22, y: 21 } }, trigger: 'click', duration: 0.2 + }, + // Двери: клик по дому — внутрь (вход у проёма; выход — kind 'return'). + { + tile: { x: 7, y: 6 }, + label: 'Дом Ирвина', + target: { kind: 'area', area: 'house_elder', entry: { x: 5, y: 7 } }, + trigger: 'click', + duration: 0.2 + }, + { + tile: { x: 21, y: 9 }, + label: 'Лавка Милы', + target: { kind: 'area', area: 'shop', entry: { x: 5, y: 7 } }, + trigger: 'click', + duration: 0.2 } ], ambience: 'ash' + }, + // --- интерьеры: рамка WALL, пол FLOOR, дверной проём снизу --- + house_elder: { + id: 'house_elder', + name: 'Дом Ирвина', + music: 'music/zvenets', + spawn: { x: 5, y: 7 }, + npcs: [], + enemies: [], + transitions: [ + // Проём в нижней стене: шаг наружу — назад, откуда вошёл. + { tile: { x: 5, y: 8 }, target: { kind: 'return' }, duration: 0.2 } + ], + interactables: INTERACTABLES.house_elder, + ambience: 'none' + }, + shop: { + id: 'shop', + name: 'Лавка Милы', + music: 'music/zvenets', + spawn: { x: 5, y: 7 }, + npcs: [], + enemies: [], + transitions: [{ tile: { x: 5, y: 8 }, target: { kind: 'return' }, duration: 0.2 }], + interactables: INTERACTABLES.shop, + ambience: 'none' } }; diff --git a/apps/game/src/data/map.ts b/apps/game/src/data/map.ts index bb848b1..3ae705d 100644 --- a/apps/game/src/data/map.ts +++ b/apps/game/src/data/map.ts @@ -21,7 +21,9 @@ const TALL_OBJECTS: Record = { [TILES.TREE]: { height: 1.5, ground: TILES.GRASS }, [TILES.TOWER]: { height: 2.5, ground: TILES.PATH }, - [TILES.HOUSE]: { height: 1.75, ground: TILES.ASH } + [TILES.HOUSE]: { height: 1.75, ground: TILES.ASH }, + // Стены интерьеров — вертикальные объекты, под ними пол. + [TILES.WALL]: { height: 1, ground: TILES.FLOOR } }; /** Детерминированный ГПСЧ, чтобы локация выглядела одинаково между запусками. */ @@ -234,6 +236,42 @@ }; } /** + * Интерьер 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(); +} + +/** * Локация «Звенец»: деревня-мастерская вокруг старой железнодорожной * водонапорной башни с колоколом. Дома из вагонных щитов, крыши из * платформ, посреди площади — звонное место. Безопасный хаб (без врагов). diff --git a/apps/game/src/data/validate.ts b/apps/game/src/data/validate.ts index 207eb1d..793c12c 100644 --- a/apps/game/src/data/validate.ts +++ b/apps/game/src/data/validate.ts @@ -98,6 +98,9 @@ checkWalkable(`враг#${i}`, e.tile, grid, where) )); }); + 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))); diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index c0c2d3b..4fbe3a5 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -61,8 +61,11 @@ EnemySnapshot, NpcSnapshot, TransitionSnapshot, - LocationSnapshot + LocationSnapshot, + InteractableSnapshot } from '../agent/snapshot'; +import { Interactables } from '../systems/Interactables'; +import type { InteractableDef } from '../data/interactables'; /** * Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc. @@ -75,7 +78,8 @@ private npcs: { def: NpcDef; view: Container }[] = []; private actors = new IsoDepthLayer(); private hint: Container; - private ash: ParticleEmitter; + /** Атмосферные частицы (в интерьерах их нет). */ + private ash: ParticleEmitter | null; private locationLabel: PixelText; private camera: Camera; @@ -93,8 +97,12 @@ private pendingInteraction: | { kind: 'talk'; def: NpcDef } | { kind: 'flower'; x: number; y: number } + | { kind: 'interact'; def: InteractableDef } | { kind: 'transition'; def: TransitionDef; tile: { x: number; y: number } } | null = null; + /** Интерактивные объекты области (сундуки, очаги, прилавки). */ + private interactables: Interactables; + private interactViews: { def: InteractableDef; label: PixelText }[] = []; /** Таймер мигания при неуязвимости (свой, не боевой). */ private blinkT = 0; /** Оверлей дымки (зоны наката). Виден только в низинах. */ @@ -149,6 +157,19 @@ this.npcs.push({ def, view }); } + // Интерактивные объекты: те же вьюхи в 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) => void this.game.audio.play(k, 0.6), + showToast: (t) => this.showToast(t) + }); + for (const def of area.interactables ?? []) { + const made = this.makeInteractableView(def); + this.actors.add(made.view, def.tile.x, def.tile.y); + this.interactViews.push({ def, label: made.label }); + } + // --- боевая система: враги на ECS, звуки/события/урон героя — через колбэки --- this.combat = new CombatWorld( { @@ -222,9 +243,11 @@ this.dialogue.onDialogueFinished = (id) => this.onDialogueFinished(id); this.dialogue.onLineShown = () => void this.game.audio.play('sfx/chime', 0.5); - // Атмосфера локации: пепел на лугах, туман над прудами. + // Атмосфера локации: пепел на лугах, туман над прудами, в интерьере — ничего. this.ash = - area.ambience === 'fog' + area.ambience === 'none' + ? null + : area.ambience === 'fog' ? new ParticleEmitter({ color: 0x7a7a88, rate: 6, @@ -243,8 +266,10 @@ spawnArea: { width: 520, height: 300 }, seed: 20260905 }); - this.ash.position.set(240, 120); - this.game.renderer.worldRoot.addChild(this.ash); + if (this.ash) { + this.ash.position.set(240, 120); + this.game.renderer.worldRoot.addChild(this.ash); + } // Дымка наката: полупрозрачная пелена на весь экран (в низинах без маски — гуще). this.fog = new Graphics().rect(0, 0, 480, 270).fill(0x5a6a78); @@ -300,9 +325,9 @@ exit(): void { this.fauna.exit(); this.combatViews.exit(); - this.ash.clear(); + this.ash?.clear(); this.world.destroy({ children: true }); - this.ash.destroy(); + this.ash?.destroy(); this.fog.destroy(); this.healthBar.destroy({ children: true }); this.hint.destroy({ children: true }); @@ -313,7 +338,7 @@ } update(dt: number): void { - this.ash.update(dt); + this.ash?.update(dt); this.fauna.update(dt); // Кат-сцена: мир на паузе, камера под контролем раннера. if (this.cutscene.active) { @@ -393,6 +418,12 @@ this.actors.setDepth(this.player.view, tile.x, tile.y); this.updateCameraFollow(); this.checkTransitions(tile); + // Подписи объектов: видны вблизи, пока объект не использован (once). + for (const iv of this.interactViews) { + iv.label.visible = + this.inInteractRange(iv.def.tile.x, iv.def.tile.y) && + !(iv.def.once && this.interactables.isUsed(iv.def.id)); + } this.debug.setLines([ `tile ${tile.x},${tile.y}`, @@ -616,6 +647,12 @@ trigger: t.trigger ?? 'step', label: t.label ?? null })); + const interactables: InteractableSnapshot[] = (this.area.interactables ?? []).map((d) => ({ + id: d.id, + tile: d.tile, + label: d.label ?? null, + used: this.interactables.isUsed(d.id) + })); const layer: LocationSnapshot = { scene: 'location', area: this.area.id, @@ -624,6 +661,7 @@ enemies, npcs, transitions, + interactables, dialogue: this.dialogue.agentState, cutscene: { active: this.cutscene.active }, lastToast: this.lastToast @@ -800,6 +838,13 @@ return; } + // Клик по интерактивному объекту (сундук, очаг, прилавок). + const inter = this.interactables.defAt(clicked.x, clicked.y); + if (inter) { + this.requestInteract(inter); + return; + } + // Клик по лунному колокольчику (пруды) — собрать цветок. if (this.area.id === 'ponds' && this.tileId(clicked.x, clicked.y) === TILES.BELLFLOWER) { this.requestCollect(clicked.x, clicked.y); @@ -875,6 +920,20 @@ } } + /** Интерактивный объект: в радиусе — сразу; издалека — подойти и сработать. */ + private requestInteract(def: InteractableDef): void { + this.pendingInteraction = null; + if (this.inInteractRange(def.tile.x, def.tile.y)) { + this.interactables.tryInteract(def); + return; + } + const path = findPathToNeighbor(this.map, this.player.currentTile(), def.tile); + if (path) { + this.pendingInteraction = { kind: 'interact', def }; + this.player.followPath(path); + } + } + /** Сработать отложенным взаимодействием, когда герой остановился. */ private resolvePendingInteraction(): void { if (!this.pendingInteraction || this.player.moving) return; @@ -884,6 +943,10 @@ if (this.inInteractRange(p.def.tile.x, p.def.tile.y)) this.talkTo(p.def); } else if (p.kind === 'flower') { if (this.inInteractRange(p.x, p.y)) this.collectFlower(p.x, p.y); + } else if (p.kind === 'interact') { + if (this.inInteractRange(p.def.tile.x, p.def.tile.y)) { + this.interactables.tryInteract(p.def); + } } else { // Условие могли не выполнить, пока герой шёл — перепроверяем. const pick = resolveTransition( @@ -1017,6 +1080,23 @@ }); } + /** Вьюха интерактивного объекта: плейсхолдер-ящик + подпись (видна вблизи). */ + private makeInteractableView(def: InteractableDef): { view: Container; label: PixelText } { + const view = new Container(); + const p = this.tileCenter(def.tile.x, def.tile.y); + view.position.set(p.x, p.y); + const g = new Graphics(); + g.rect(-5, -12, 10, 12).fill(0x4a3b2a); + g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо + view.addChild(g); + 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); + return { view, label }; + } + private makeNpcView(def: NpcDef): Container { const view = new Container(); const p = this.tileCenter(def.tile.x, def.tile.y); diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts index f353d00..d0a6eb5 100644 --- a/apps/game/src/scenes/MenuScene.ts +++ b/apps/game/src/scenes/MenuScene.ts @@ -135,9 +135,10 @@ if (save) { const norm = normalizeSave(save); this.game.inventory.load({ items: norm.items }); - void this.game.scenes.replace(new LocationScene(this.game, norm, areaOf(norm.area)), { - duration: 0.4 - }); + 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)), { diff --git a/apps/game/src/systems/Interactables.ts b/apps/game/src/systems/Interactables.ts new file mode 100644 index 0000000..5f453d8 --- /dev/null +++ b/apps/game/src/systems/Interactables.ts @@ -0,0 +1,68 @@ +import type { GameState } from '@rpg/engine'; +import type { ItemId } from '../data/items'; +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; +} + +export class Interactables { + constructor( + private defs: InteractableDef[], + private state: GameState, + private sink: InteractSink + ) {} + + all(): InteractableDef[] { + return this.defs; + } + + /** Объект на тайле (или 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(`used:${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); + 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.gives) this.sink.give(r.gives, r.count ?? 1); + if (def.once) this.state.setFlag(`used:${def.id}`); + if (r.sound) this.sink.playSound(r.sound); + this.sink.showToast(r.text); + } +} \ 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 new file mode 100644 index 0000000..01032cf --- /dev/null +++ b/apps/game/src/systems/__tests__/Interactables.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { Interactables, type InteractSink } from '../Interactables'; +import type { GameState } from '@rpg/engine'; +import type { InteractableDef } from '../../data/interactables'; + +/** Фейковый GameState: флаги и вар-числа в памяти. */ +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; +} + +const def = (over: Partial = {}): InteractableDef => ({ + id: 'chest', + 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('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/docs/demo.md b/docs/demo.md index e79385e..8b74da7 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -111,7 +111,11 @@ - ✅ **Батч 1 — единый механизм переходов** (готово): `TransitionDef` (step/click, условия, lockedText), дисарм тайла входа, `returnTo`, сейвы v3, телепорт-колодцы (Звенец ↔ луга), сценарий `tools/checks/transitions.mjs`. -- **Батч 2**: интерьеры (дом Ирвина, лавка Милы) + интерактивные объекты. +- ✅ **Батч 2 — интерьеры + интерактивные объекты** (готово): карты интерьеров + (`buildInteriorMap`: дом Ирвина, лавка Милы), двери-клики внутрь и выход + `kind: 'return'`, `InteractableDef`/`Interactables` (сундук, очаг, записка, + прилавок; реакции по `when`, `once` → флаг `used:`, `gives`), снапшот + `interactables`, сценарий `tools/checks/interact.mjs`. - **Батч 3**: взаимодействие с миром шире (знаки/подбор/рычаг, мот-песок). - **Батч 4**: ИИ врагов (LOS/шум/патруль/wary/flee/return). - Принцип среза: каждая геймплейная система имеет сценарий в `tools/checks/`. diff --git a/docs/engine/agent.md b/docs/engine/agent.md index 01e5813..1048c0b 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -97,13 +97,21 @@ - после входа в локацию: тоже дождаться `!s.transitioning` — fade длится 2 × duration, снапшот `scene: 'location'` появляется уже на swap. +`GameAgent.waitFor` ждёт по **полному** снапшоту игры: в предикатах доступны +не только сценические поля (`s.area`, `s.hero`, `s.interactables`, +`s.lastToast`), но и слой игры — `s.flags`, `s.vars`, `s.inventory` +(массив `{id, count}`). `EngineAgent.waitFor` (движковый) видит только +движковый + сценический слои — для предикатов над прогрессом используй +`agent.waitFor`, а не `engine.waitFor`. + ## Контентная валидация (data/validate.ts) Runtime-валидаторы возвращают `Invariant[]` (`severity: 'error' | 'warn'`, `where` — путь к данным). Вместо JSON Schema: истина одна — TS-типы, валидатор ловит то, что типы не выражают (ссылки, границы, стены). Проверяются: графы диалогов (start/next/choices существуют, сироты — warn), NPC/спавн/враги не в -стенах, exits ведут в существующие области и проходимые entry, hp/speed врагов. +стенах, переходы ведут в существующие области и проходимые entry, интерактивные +объекты в границах карты, hp/speed врагов. Вызывается из `GameAgent.invariants()` (работает и в меню) и из юнит-теста `data/__tests__/validate.test.ts` — битый контент падает сразу в тестах. diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 6826be4..13bb9bc 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -38,6 +38,25 @@ 4. Проверка: `tools/checks/transitions.mjs` — шаг до перехода, клик-переход туда и обратно, снапшот `s.transitions` (триггеры и цели). +## Ситуация: добавляю интерьер / интерактивный объект + +1. Карта интерьера: `buildInteriorMap()` в `data/map.ts` (рамка WALL, пол FLOOR, + дверной проём снизу) — не рисуй рамку руками. +2. Дверь внутрь — `TransitionDef { trigger: 'click' }` в `AreaDef` улицы; + выход — `TransitionDef { target: { kind: 'return' }, trigger: 'step' }` в + интерьере (проём). `returnTo` фиксируется **в момент клика по двери** + (тайл героя), не в момент выхода — в сценарии храни тайл клика и сравнивай + с ним после возврата. +3. Объекты — `InteractableDef` в `INTERACTABLES` (`data/interactables.ts`): + реакции по `when` (первая подходящая), `once` поднимает флаг `used:`, + эффекты — `setFlags`/`setVar`/`gives`, текст виден в `s.lastToast`. +4. Валидатор сам проверит объекты в границах и переходы; полная проверка — + `tools/checks/interact.mjs` (образец: вход → сундук → used → выход → + возврат на тайл клика). +5. `waitFor` моста видит **полный** снапшот игры (`s.flags`, `s.inventory`, + `s.interactables`) — предикаты над прогрессом пиши против него, а не + против сценических полей. + ## Ситуация: добавляю NPC/диалог 1. `NpcDef` в `data/npcs.ts`, граф в `data/dialogues.ts`. diff --git a/tools/agent.mjs b/tools/agent.mjs index 4639188..e47d924 100644 --- a/tools/agent.mjs +++ b/tools/agent.mjs @@ -92,7 +92,8 @@ }) }, { name: 'maps:fresh', run: mapsFresh }, { name: 'agent:invariants', run: agentInvariants }, - { name: 'transitions', run: () => runScenario('transitions', 'tools/checks/transitions.mjs') } + { name: 'transitions', run: () => runScenario('transitions', 'tools/checks/transitions.mjs') }, + { name: 'interact', run: () => runScenario('interact', 'tools/checks/interact.mjs') } ]; const only = args.only ?? all.map((p) => p.name); const skip = args.skip ?? []; diff --git a/tools/checks/interact.mjs b/tools/checks/interact.mjs new file mode 100644 index 0000000..91c822b --- /dev/null +++ b/tools/checks/interact.mjs @@ -0,0 +1,133 @@ +/** + * Сценарий interact — интерьеры и интерактивные объекты. + * 1) дверь: клик по дому Ирвина -> интерьер house_elder; + * 2) сундук (once): предмет в сумке, used:, повтор молчит; + * 3) очаг: реакция зависит от флага quest_bells_done; + * 4) записка (once): флаг read_note; + * 5) выход через проём: step kind 'return' -> Звенец, герой на тайле входа; + * 6) лавка Милы: прилавок меняет реакцию с флагом. + * Запуск: node tools/agent.mjs run tools/checks/interact.mjs + */ +import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; + +export default async function ({ pretty }) { + const c = new Checks('interact'); + const server = await startDevServer(); + let ctx; + let entryTile = null; // тайл, с которого герой кликнул дверь (цель kind 'return') + try { + await c.run('дверь: дом Ирвина -> интерьер', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + await ctx.agent.command('scene:sleepAll'); + // Добираемся до Звенца пешком (луга -> тропа (26,14)). + const w0 = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); + c.expect(w0, 'walkTo(26,14) не дошёл'); + const wz = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); + c.expect(wz.ok, 'не в Звенце после перехода', { area: wz.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + // Подводим героя к дому (тайл дома (7,6)) и кликаем по нему. + const wh = await ctx.agent.walkTo(8, 6, { timeoutTicks: 2000 }); + c.expect(wh, 'не дошёл до дома Ирвина'); + entryTile = (await ctx.agent.snapshot()).hero.tile; + await ctx.agent.tapTile(7, 6); + const w = await ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'клик по дому не открыл интерьер', { area: w.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await 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 ctx.agent.snapshot(); + const had = before.inventory.find((i) => i.id === 'cloth')?.count ?? 0; + // Клик по сундуку (3,3) — герой подходит и открывает. + await ctx.agent.tapTile(3, 3); + const w = await 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 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 ctx.agent.tapTile(3, 3); + await ctx.agent.step(120); + const s2 = await ctx.agent.snapshot(); + c.expect((s2.lastToast?.tick ?? -1) === t0, 'повторное взаимодействие не молчит', s2.lastToast); + return null; + }); + await c.run('очаг: реакция меняется с флагом', async () => { + await ctx.agent.tapTile(8, 0); + await ctx.agent.step(120); + const s1 = await ctx.agent.snapshot(); + const text1 = s1.lastToast?.text ?? ''; + c.expect(text1.includes('щепки'), 'реакция очага до флага не та', text1); + await ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); + await ctx.agent.tapTile(8, 0); + await ctx.agent.step(120); + const s2 = await ctx.agent.snapshot(); + c.expect( + (s2.lastToast?.text ?? '').includes('ровнее'), + 'реакция очага после флага не та', + s2.lastToast + ); + return null; + }); + await c.run('записка: флаг read_note', async () => { + await ctx.agent.tapTile(10, 4); + await ctx.agent.step(120); + const s = await 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 ctx.agent.tapTile(5, 8); + const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'проём не вернул в Звенец', { area: w.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await ctx.agent.snapshot(); + c.expect( + s.hero.tile.x === entryTile.x && s.hero.tile.y === entryTile.y, + 'герой не на тайле, откуда кликнул дверь', + { entry: entryTile, after: s.hero.tile } + ); + return null; + }); + await c.run('лавка Милы: прилавок меняет реакцию', async () => { + // Клик по лавке (21,9) — интерьер; прилавок (5,3). + await ctx.agent.tapTile(21, 9); + const w = await ctx.agent.waitFor('s.area === "shop"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'лавка не открылась', { area: w.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await ctx.agent.tapTile(5, 3); + await ctx.agent.step(120); + const s1 = await ctx.agent.snapshot(); + const text1 = s1.lastToast?.text ?? ''; + c.expect(text1.includes('соль'), 'реакция прилавка до флага не та', text1); + await ctx.agent.tapTile(5, 3); + await ctx.agent.step(120); + const s2 = await ctx.agent.snapshot(); + c.expect( + (s2.lastToast?.text ?? '').includes('цветы'), + 'прилавок не изменил реакцию после флага', + s2.lastToast + ); + return null; + }); + await c.run('снапшот: интерактивные объекты области', async () => { + const s = await 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; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/tools/maps/gen.test.ts b/tools/maps/gen.test.ts index 06e6b61..be6b719 100644 --- a/tools/maps/gen.test.ts +++ b/tools/maps/gen.test.ts @@ -3,7 +3,13 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { encodeMap, parseMap } from '../../packages/engine/src/map/mapFormat'; -import { buildMeadowsMap, buildPondsMap, buildZvenetsMap } from '../../apps/game/src/data/map'; +import { + buildMeadowsMap, + buildPondsMap, + buildZvenetsMap, + buildHouseElderMap, + buildShopMap +} from '../../apps/game/src/data/map'; /** * Генератор карт-файлов: `npm run maps` (vitest run tools/maps/gen.test.ts). @@ -19,7 +25,9 @@ const MAPS: Record> = { meadows: buildMeadowsMap(), ponds: buildPondsMap(), - zvenets: buildZvenetsMap() + zvenets: buildZvenetsMap(), + house_elder: buildHouseElderMap(), + shop: buildShopMap() }; describe('карты-файлы на диске равны генераторам (fresh)', () => {