diff --git a/docs/plan.md b/docs/plan.md index 577cb00..9d66cae 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -638,8 +638,27 @@ Уроки: экранная ось (1,0) — диагональ мира (триггер срезается углом — чистые оси комбинированным стиком); штампы шире тайла запирают проходы — контент карты строго в тайл 3×3; solidAt — колонка над землёй (y≥2). -- **Трек 16 — NPC и квестовый каркас**: NPC-дефы (появление по флагам), - перенос 9 диалоговых графов v1, флаги/вары, тосты; гейт. +- **Трек 16 — NPC и квестовый каркас** ✅ (2026-09-10, коммит): контент v1 + 1:1 — `src/data/ids.ts` (FLAGS 17/VARS/usedFlag), `src/data/items.ts` + (7 предметов), `src/data/npcs.ts` (Ирвин/Мила статично в Звенце, Клинт — + NPC_PLACEMENTS по флагу act2_hook), 9 диалоговых графов v1 в + `src/data/dialogues/*.json` + реестр с validateDialogue при старте, + `src/data/quests.ts` (стадии с doneFlag/ready/progressKey, + questDialogueFor/questLog/questTracker), `src/state.ts` (GameState — + DialogueState движка, сериализуема под сейв). В main.ts: NPC-манекены + (Character speed 0) + Interactable-точки (radius 5), выбор графа — + квест-стадия → repeat → dialogueFirst; в диалоге мир замер; onEffect — + giveItem/takeItem в Inventory, toast/custom → тосты; HUD: трекер/сумка/ + тосты; E = диалог/дальше, цифры = выбор варианта. Мост: npcInfo/talkTo/ + dialogueInfo/dialoguePick/dialogueAdvance/dialogueAbort/setState/stateInfo. + Гейт `tools/checks/npc.mjs` — 27 проверок (раскладка NPC, появление Клинта + по флагу, выбор графа по стадиям включая сдачи flowers/tones ≥3, выбор + ответа, сумка, флаги, трекер, motes_note по whenVar, мир замер, панель). + Уроки: после сдачи квеста активная стадия (npc trader) перекрывает + dialogueFirst этого NPC графом эпилога — порядок знакомств в сценарии + гейта существенен; подстроки имён с падежами («Мила» в «Милой») в + проверках текста ненадёжны. Экспортирован `evalConditions` из движка + (ready-условия стадий — те же предикаты, что в графах). - **Трек 17 — интерактив, пикапы, квест «Три цветка»**: INTERACTABLES (сундуки, записка, колодцы, прилавок, bell_rope), сбор цветов/мотов, FlowerQuest (взять → собрать в прудах → посадить → сдать с кат-сценой), diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index d896e53..e1cdc53 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -50,6 +50,7 @@ node apps/demo/tools/checks/combat.mjs # скриншот-гейт боя (/tmp/v2_combat_*.png) node apps/demo/tools/checks/audio.mjs # гейт аудио (DEV-шпион, /tmp/v2_audio_*.png) node apps/game/tools/checks/areas.mjs # гейт областей игры (/tmp/v2_areas_*.png) +node apps/game/tools/checks/npc.mjs # гейт NPC/квестов игры (/tmp/v2_npc_*.png) npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 npx vite --port 5315 # игра «Пепельные луга» (в apps/game) ``` @@ -330,6 +331,34 @@ (луга→Звенец, луга⇄пруды) с фейдом и запоминанием возврата, люк→зал→return по стеку, click-переход «E» (подсказка у двери, вдали тишина), интерьер рисуется. + Трек 16 «NPC и квестовый каркас»: `src/data/ids.ts` (реестры FLAGS/VARS/ + usedFlag — флаги сюжета только через них), `src/data/items.ts` (7 предметов, + itemName), `src/data/npcs.ts` (NpcDef: статичные Ирвин/Мила в Звенце + + NPC_PLACEMENTS — Клинт в роще по флагу act2_hook; npcsForArea), 9 диалоговых + графов v1 в `src/data/dialogues/*.json` + реестр `src/data/dialogues.ts` + (validateDialogue при старте — битый граф падает сразу), `src/data/quests.ts` + (квест = стадии с doneFlag/ready/progressKey; questDialogueFor — выбор графа + по активной стадии; questLog/questTracker) и `src/state.ts` (GameState — + флаги/вары, DialogueState движка, сериализуема). В main.ts: NPC — манекены + (Character speed 0) с точками Interactable (radius 5), выбор графа — + квест-стадия → repeat по flagKey → dialogueFirst; в диалоге мир замер + (герой стоит, переходы/подсказки не тикаются); onEffect исполняет игру + (giveItem/takeItem → Inventory, toast/custom → тосты); HUD — трекер `#quest` + (первая активная стадия), сумка `#inv` (по inv.onChange), тосты `#toasts`. + Клавиши: E = диалог/дальше/переход, цифры 1-9 = выбор варианта. Мост: + `npcInfo` (кто стоит, tile, dialogue, near)/`talkTo(id?)`/`dialogueInfo` + (узел/выборы/path/result/флаги/вары/трекер/сумка)/`dialoguePick`/ + `dialogueAdvance`/`dialogueAbort`/`setState({vars,flags})` (гейт стадий)/ + `stateInfo`. Гейт `tools/checks/npc.mjs` (27 проверок): раскладка NPC v1, + появление Клинта только с act2_hook, выбор графа по стадиям (первый → + repeat → сдача при flowers/tones ≥3 → эпилоги), выбор варианта ответа, + giveItem → сумка, флаги/вары, трекер с прогрессом, motes_note по whenVar, + мир замер в диалоге, панель диалога на скриншоте. + Грабли трека 16: **порядок знакомств существенен** — после сдачи цветов + активная стадия «bells» (npc trader) перекрывает trader_first графом + trader_after: сдача квеста закрывает окно первого диалога этого NPC; + **«Мила» ≠ «Милой»** — подстроки имён с падежными окончаниями в проверках + трекера ненадёжны, сверять по устойчивому куску текста стадии. ## Известные грабли (уже собранные) diff --git a/v2/apps/game/index.html b/v2/apps/game/index.html index d7a12eb..cacd7fa 100644 --- a/v2/apps/game/index.html +++ b/v2/apps/game/index.html @@ -35,12 +35,94 @@ opacity: 0; pointer-events: none; } + /* Трекер квестов: первая активная стадия, левый верхний угол */ + #quest { + position: fixed; + top: 12px; + left: 12px; + padding: 8px 12px; + background: rgba(23, 23, 29, 0.85); + color: #d99a32; + font: 15px/1.5 monospace; + border: 1px solid #44454e; + border-radius: 4px; + white-space: pre; + } + /* Сумка: под трекером */ + #inv { + position: fixed; + top: 56px; + left: 12px; + padding: 8px 12px; + background: rgba(23, 23, 29, 0.85); + color: #c9c9d2; + font: 15px/1.5 monospace; + border: 1px solid #44454e; + border-radius: 4px; + white-space: pre; + } + #inv .title { color: #626c4c; } + #inv .item { color: #d99a32; } + /* Тосты: столбик всплывашек по центру сверху */ + #toasts { + position: fixed; + top: 52px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 4px; + align-items: center; + pointer-events: none; + } + #toasts div { + padding: 5px 12px; + background: rgba(23, 23, 29, 0.85); + color: #d99a32; + font: 15px monospace; + border: 1px solid #44454e; + border-radius: 4px; + white-space: nowrap; + } + /* Диалог: панель внизу экрана, реплика + варианты (кнопки) */ + #dialog { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + width: 640px; + max-width: 92vw; + padding: 12px 16px; + background: rgba(23, 23, 29, 0.92); + color: #c9c9d2; + font: 16px/1.5 monospace; + border: 1px solid #44454e; + border-radius: 6px; + } + #dialog .speaker { color: #d99a32; font-weight: bold; } + #dialog .choices { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; } + #dialog .choices button { + text-align: left; + padding: 4px 10px; + background: #23232c; + color: #c9c9d2; + border: 1px solid #44454e; + border-radius: 3px; + font: inherit; + cursor: pointer; + } + #dialog .choices button:hover { border-color: #d99a32; color: #fff; } + #dialog .continue { margin-top: 8px; color: #626c4c; font-size: 13px; }
+ + +
+ \ No newline at end of file diff --git a/v2/apps/game/src/data/dialogues.ts b/v2/apps/game/src/data/dialogues.ts new file mode 100644 index 0000000..ab82b88 --- /dev/null +++ b/v2/apps/game/src/data/dialogues.ts @@ -0,0 +1,35 @@ +/** + * Реестр диалоговых графов «Пепельных луга» (перенос v1 1:1): 9 графов — + * знакомства и повторные у Ирвина/Милы, сдача квестов, Клинт (акт 2). + * Графы — данные (JSON); ошибки структуры ловит validateDialogue при + * старте модуля (падаем сразу, а не посреди диалога). + */ +import { validateDialogue, type DialogueGraph } from '@rpg/engine'; +import clintFirst from './dialogues/clint_first.json'; +import clintRepeat from './dialogues/clint_repeat.json'; +import clintTune from './dialogues/clint_tune.json'; +import elderFirst from './dialogues/elder_first.json'; +import elderHandIn from './dialogues/elder_hand_in.json'; +import elderRepeat from './dialogues/elder_repeat.json'; +import traderAfter from './dialogues/trader_after.json'; +import traderFirst from './dialogues/trader_first.json'; +import traderRepeat from './dialogues/trader_repeat.json'; + +/** Все графы по id (id = имя файла, как в v1). */ +export const DIALOGUES: Record = { + clint_first: clintFirst, + clint_repeat: clintRepeat, + clint_tune: clintTune, + elder_first: elderFirst, + elder_hand_in: elderHandIn, + elder_repeat: elderRepeat, + trader_after: traderAfter, + trader_first: traderFirst, + trader_repeat: traderRepeat, +} as unknown as Record; + +// графы — данные: битый граф лучше падает при загрузке, чем в диалоге +const graphErrors = Object.entries(DIALOGUES).flatMap(([id, g]) => + validateDialogue(g).map((e) => `${id}: ${e}`), +); +if (graphErrors.length > 0) throw new Error(`Диалоги: ошибки графов\n${graphErrors.join('\n')}`); \ No newline at end of file diff --git a/v2/apps/game/src/data/dialogues/clint_first.json b/v2/apps/game/src/data/dialogues/clint_first.json new file mode 100644 index 0000000..0179533 --- /dev/null +++ b/v2/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/v2/apps/game/src/data/dialogues/clint_repeat.json b/v2/apps/game/src/data/dialogues/clint_repeat.json new file mode 100644 index 0000000..c2694d8 --- /dev/null +++ b/v2/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/v2/apps/game/src/data/dialogues/clint_tune.json b/v2/apps/game/src/data/dialogues/clint_tune.json new file mode 100644 index 0000000..e0a64fc --- /dev/null +++ b/v2/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/v2/apps/game/src/data/dialogues/elder_first.json b/v2/apps/game/src/data/dialogues/elder_first.json new file mode 100644 index 0000000..7fddc97 --- /dev/null +++ b/v2/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/v2/apps/game/src/data/dialogues/elder_hand_in.json b/v2/apps/game/src/data/dialogues/elder_hand_in.json new file mode 100644 index 0000000..8b5a334 --- /dev/null +++ b/v2/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/v2/apps/game/src/data/dialogues/elder_repeat.json b/v2/apps/game/src/data/dialogues/elder_repeat.json new file mode 100644 index 0000000..f0b962e --- /dev/null +++ b/v2/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/v2/apps/game/src/data/dialogues/trader_after.json b/v2/apps/game/src/data/dialogues/trader_after.json new file mode 100644 index 0000000..9943f91 --- /dev/null +++ b/v2/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/v2/apps/game/src/data/dialogues/trader_first.json b/v2/apps/game/src/data/dialogues/trader_first.json new file mode 100644 index 0000000..fff3661 --- /dev/null +++ b/v2/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/v2/apps/game/src/data/dialogues/trader_repeat.json b/v2/apps/game/src/data/dialogues/trader_repeat.json new file mode 100644 index 0000000..1e6ce5c --- /dev/null +++ b/v2/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/v2/apps/game/src/data/ids.ts b/v2/apps/game/src/data/ids.ts new file mode 100644 index 0000000..ae6c06c --- /dev/null +++ b/v2/apps/game/src/data/ids.ts @@ -0,0 +1,71 @@ +/** + * Реестры строковых сущностей сюжета: флаги и вары состояния игры. + * Единственная точка правды: контент (диалоги, квесты) и код сцены + * ссылаются на них через константы — опечатка падает при типчеке, + * а не молча создаёт новый флаг. Перенос v1 1:1. + */ + +/** Флаги сюжета: ключ = значение = id (строка в сейве). */ +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; + +/** Вары прогресса (числовые; значение живёт в состоянии игры): ключ = 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/v2/apps/game/src/data/items.ts b/v2/apps/game/src/data/items.ts new file mode 100644 index 0000000..6bea276 --- /dev/null +++ b/v2/apps/game/src/data/items.ts @@ -0,0 +1,54 @@ +/** + * Реестр предметов (контент по docs/world.md). Id — стабильные ключи сейва. + * Описания — стиль сеттинга: кратко, меланхолично. Перенос v1 1:1. + */ +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; +} \ No newline at end of file diff --git a/v2/apps/game/src/data/npcs.ts b/v2/apps/game/src/data/npcs.ts new file mode 100644 index 0000000..8294b5f --- /dev/null +++ b/v2/apps/game/src/data/npcs.ts @@ -0,0 +1,90 @@ +/** + * NPC «Пепельных луга»: определения (id/имя/позиция v1/диалоги/флаг + * знакомства), размещения по флагу сюжета (Клинт появляется в роще только + * после крючка Милы — контент не живёт вне своей главы). Позиция v1 — + * в тайлах (центр тайла = координата); в воксели конвертирует сцена + * (tileCenter). Перенос v1, тайл-мост тот же. + */ +import { FLAGS, type FlagId } from './ids'; + +/** NPC локации: диалоги и флаг сюжета после первого разговора. */ +export interface NpcDef { + id: string; + name: string; + /** id области (v1: статичные жили в городе; в v2 область — явно). */ + area: string; + /** Позиция ног в тайлах v1 (центр тайла = координата + 0.5). */ + pos: { x: number; y: number }; + /** Тайл привязки (floor от pos; вычисляется — в контенте не писать). */ + tile: { x: number; y: number }; + /** Первый диалог (когда флаг ещё не поднят). */ + dialogueFirst: string; + /** Повторный диалог. */ + dialogueRepeat: string; + /** Флаг, поднимаемый после первого диалога (ставит сам граф). */ + flagKey: FlagId; +} + +/** NPC из raw-определения (тайл-мост общий для статичных и размещений). */ +function npcFrom(d: Omit): NpcDef { + return { ...d, tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } }; +} + +/** Статичные NPC: старейшина и торговка — в Звенце. */ +export const NPCS: NpcDef[] = [ + npcFrom({ + id: 'elder', + name: 'Старейшина Ирвин', + area: 'zvenets', + // Не вплотную ни к двери дома (13,12), ни к колодцу (16,13): кнопка + // действия берёт ближайшую цель, E у двери/колодца не должен звать + // Ирвина — он стоит юго-западнее, вне радиуса их тайлов подхода. + pos: { x: 12.5, y: 15.5 }, + dialogueFirst: 'elder_first', + dialogueRepeat: 'elder_repeat', + flagKey: FLAGS.met_elder + }), + npcFrom({ + id: 'trader', + name: 'Торговка Мила', + area: 'zvenets', + // Не вплотную к двери лавки (43,19): E у двери не должен звать Милу. + pos: { x: 40.5, y: 20.5 }, + dialogueFirst: 'trader_first', + dialogueRepeat: 'trader_repeat', + flagKey: FLAGS.met_mila + }) +]; + +/** + * Размещения NPC по флагу: персонаж появляется в области, когда флаг сюжета + * поднят (и пропадает, если сценарий его снимет). + */ +export interface NpcPlacement { + /** Флаг, при котором NPC стоит на карте. */ + flag: FlagId; + def: NpcDef; +} + +export const NPC_PLACEMENTS: NpcPlacement[] = [ + { + // Кузнец-починщик стоит у своего резонатора в роще после крючка Милы. + flag: FLAGS.act2_hook, + def: npcFrom({ + id: 'clint', + name: 'Клинт', + area: 'rust_grove', + pos: { x: 23.5, y: 17.5 }, + dialogueFirst: 'clint_first', + dialogueRepeat: 'clint_repeat', + flagKey: FLAGS.met_clint + }) + } +]; + +/** NPC области: статичные + размещения по поднятым флагам. */ +export function npcsForArea(area: string, hasFlag: (f: FlagId) => boolean): NpcDef[] { + const statics = NPCS.filter((n) => n.area === area); + const placed = NPC_PLACEMENTS.filter((p) => p.def.area === area && hasFlag(p.flag)).map((p) => p.def); + return [...statics, ...placed]; +} \ No newline at end of file diff --git a/v2/apps/game/src/data/quests.ts b/v2/apps/game/src/data/quests.ts new file mode 100644 index 0000000..8a57c38 --- /dev/null +++ b/v2/apps/game/src/data/quests.ts @@ -0,0 +1,194 @@ +/** + * Квестовая система (контент по docs/world.md, перенос v1 1:1). Квест — + * декларативный набор стадий; состояние живёт во флагах/варах GameState + * (ставятся графами диалогов), статус выводится из состояния. Готовность + * стадии — те же условия, что и в графах диалогов (данные, не замыкания). + * Сцена спрашивает только «какой диалог у NPC». + */ +import { evalConditions, type DialogueConditions, type DialogueState, type DialogueWorld } from '@rpg/engine'; +import { FLAGS, VARS, type FlagId, type VarId } from './ids'; + +/** Квест «Три цветка» (акт 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: DialogueState, 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: DialogueState, 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: DialogueState): 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(Number(state.getVar(stage.progressKey) ?? 0), stage.goal)}/${stage.goal})` + : ''; + entries.push({ done, text: `${stage.text}${progress}` }); + } + if (!done) reached = false; + }); + } + return entries; +} + +/** Трекер HUD: текст первой активной стадии с прогрессом (null — активных нет). */ +export function questTracker(state: DialogueState): 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(Number(state.getVar(stage.progressKey) ?? 0), stage.goal)}/${stage.goal})` + : ''; + return `${stage.text}${progress}`; + } + return null; +} \ No newline at end of file diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index a1fcf5c..e492828 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -1,17 +1,28 @@ /** - * «Пепельные луга» на v2 (трек 15 «карты и области»): мир областей из + * «Пепельные луга» на v2 (трек 16 «NPC и квестовый каркас»): мир областей из * раскладки v1 (maps.json → buildAreaWorld), герой-манекен, переходы - * step/click с фейдом и стек возврата, агентный мост для гейтов. - * Позиции v1 (спавны/переходы, в тайлах) конвертируются на границе: - * tileCenter (tile*UNIT + 1.5). Референс — демка v1. + * step/click с фейдом и стек возврата, NPC с диалогами по квест-стадиям, + * сумка, журнал квестов, агентный мост для гейтов. Позиции v1 (спавны/ + * переходы/NPC, в тайлах) конвертируются на границе: tileCenter + * (tile*UNIT + 1.5). Референс — демка v1. */ import { VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW, mannequin, walkClip, Character, GameLoop, screenMoveToWorld, + DialogueRunner, nearestInteractable, Inventory, +} from '@rpg/engine'; +import type { + VoxelCloud, DialogueGraph, DialogueView, + DialogueEffectOp, Interactable, } from '@rpg/engine'; import * as THREE from 'three'; import { AREA_LAYOUTS, UNIT, GROUND_Y, buildAreaWorld, tileCenter } from './areas'; import { areaOf, type AreaDef, type TransitionDef, type Tile } from './data/areas'; +import { GameState } from './state'; +import { DIALOGUES } from './data/dialogues'; +import { npcsForArea, type NpcDef } from './data/npcs'; +import { questDialogueFor, questTracker } from './data/quests'; +import { itemName } from './data/items'; // Слоты палитры сцены: арт-библия v1 (1..10) + тайлы областей (11..17) const PALETTE = { @@ -78,24 +89,76 @@ }); const heroCloud = renderer.addVoxelCloud(hero.visual()); +// ---- Состояние сюжета, сумка: диалоги и квест-стадии пишут прямо сюда ---- +const state = new GameState(); +const inv = new Inventory({ maxSlots: 8 }); +const invWorld = { hasItem: (id: string) => inv.has(id) }; + +// ---- NPC: статичные по области + размещения по флагам (Клинт) ---- +const NPC_RADIUS = 5; // радиус подхода (v1: 1.5 юнита = 4.5 вокселя, с запасом) +interface NpcEntity { + def: NpcDef; + /** Точка взаимодействия для движкового nearestInteractable. */ + spot: Interactable; + char: Character; + cloud: VoxelCloud; +} +const npcEntities: NpcEntity[] = []; + +/** Поставить NPC-манекен в центр его тайла (поза покоя, без walk-цикла). */ +function spawnNpc(def: NpcDef): NpcEntity { + const c = tileCenter(def.tile.x, def.tile.y); + const char = new Character(MAN_RIG, MAN, WALK, [c.x, c.z], { + size: BODY_SIZE, speed: 0, strideLength: 3.2, + }); + char.body.pos = [c.x, GROUND_Y, c.z]; + return { + def, + spot: { + id: def.id, + pos: [c.x, GROUND_Y, c.z], + radius: NPC_RADIUS, + label: 'Поговорить', + dialogueId: def.id, + }, + char, + cloud: renderer.addVoxelCloud(char.visual()), + }; +} + +function disposeNpcs(): void { + for (const e of npcEntities) e.cloud.dispose(); + npcEntities.length = 0; +} + +/** Пересобрать NPC области (вызывается из setArea и при смене флагов). */ +function spawnNpcs(): void { + disposeNpcs(); + for (const def of npcsForArea(currentArea.id, (f) => state.hasFlag(f))) { + npcEntities.push(spawnNpc(def)); + } +} + // ---- Области: текущая, стек возврата, смена области ---- let currentArea: AreaDef = areaOf('meadows'); let layout = AREA_LAYOUTS.meadows!; const returnStack: { area: string; entry: Tile }[] = []; -/** Смена области: новый мир + мешы, герой на входном тайле. */ +/** Смена области: новый мир + мешы, герой на входном тайле, NPC заново. */ function setArea(id: string, entry: Tile): void { currentArea = areaOf(id); layout = AREA_LAYOUTS[currentArea.id]!; disposeMeshes(); world = buildAreaWorld(layout); remeshDirty(); + spawnNpcs(); const c = tileCenter(entry.x, entry.y); hero.body.pos = [c.x, GROUND_Y, c.z]; hero.body.vel = [0, 0, 0]; heroCloud.update(hero.visual()); prevTile = { ...entry }; // входной тайл — не «только что вошёл» (без триггера) nearClick = null; // подсказка старой области не должна пережить переход + nearNpc = null; showHint(''); renderer.setTarget([Math.round(c.x), GROUND_Y, Math.round(c.z)]); } @@ -117,6 +180,8 @@ let hintText = ''; /** Ближайший click-переход (для «E» и подсказки). */ let nearClick: TransitionDef | null = null; +/** Ближайший NPC в радиусе подхода (для «E — Поговорить»). */ +let nearNpc: NpcEntity | null = null; /** Тайл героя с прошлого тика (origin возврата + «только что вошёл»). */ let prevTile: Tile | null = null; @@ -126,6 +191,152 @@ hintEl.textContent = text; } +// ---- Диалоги: выбор графа по квест-стадиям → рантайм движка → DOM-view ---- +const dialogEl = document.getElementById('dialog') as HTMLDivElement; + +/** View поверх DOM-панели: реплика + кнопки вариантов (как в демке). */ +function makeDialogueView(): DialogueView { + return { + show({ speaker, text, choices }) { + const sp = document.createElement('div'); + sp.className = 'speaker'; + sp.textContent = speaker ? `${speaker}:` : ''; + const tx = document.createElement('div'); + tx.textContent = text; + const box = document.createElement('div'); + box.className = 'choices'; + for (const c of choices) { + const btn = document.createElement('button'); + btn.textContent = c.text; + btn.addEventListener('click', () => { + runner.pick(c.index); + updateHud(); + }); + box.append(btn); + } + const cont = document.createElement('div'); + cont.className = 'continue'; + cont.textContent = choices.length ? '' : 'E — дальше'; + dialogEl.replaceChildren(sp, tx, box, cont); + dialogEl.hidden = false; + }, + hide() { + dialogEl.hidden = true; + }, + }; +} + +/** id открытого диалога (runner граф не помнит — для снапшота моста). */ +let currentDialogueId: string | null = null; +const runner = new DialogueRunner(state, makeDialogueView(), invWorld); + +/** Эффекты сюжета «custom» из графов: посадка/настройка — трек 17, тост сейчас. */ +const CUSTOM_LINES: Record = { + plant_flowers: 'Поляна у тропы приняла цветы.', + tune_resonator: 'Резонатор Клинта поймал тон.', +}; + +runner.onEffect = (op: DialogueEffectOp) => { + // движок только сообщает: исполнение — игра (give/take — сумка) + if (op.kind === 'giveItem' && op.id) inv.add(op.id, op.count ?? 1); + if (op.kind === 'takeItem' && op.id) inv.remove(op.id, op.count ?? 1); + if (op.kind === 'toast' && op.text) showToast(op.text); + if (op.kind === 'custom' && op.id) { + showToast(CUSTOM_LINES[op.id] ?? op.id); + } +}; + +/** Какой граф у NPC сейчас: квест-стадия → знакомый repeat → первый. */ +function dialogueIdFor(npc: NpcDef): string { + return questDialogueFor(state, npc.id, invWorld) + ?? (state.hasFlag(npc.flagKey) ? npc.dialogueRepeat : npc.dialogueFirst); +} + +/** Начать разговор с NPC (id или ближайший). */ +function talkTo(id: string): void { + const e = npcEntities.find((n) => n.def.id === id); + if (!e || runner.active) return; + const graphId = dialogueIdFor(e.def); + const graph: DialogueGraph | undefined = DIALOGUES[graphId]; + if (!graph) return; + currentDialogueId = graphId; + runner.start(graph); + updateHud(); +} + +/** Нажатие «E»: в диалоге — дальше; иначе NPC, иначе click-переход. */ +function interact(): void { + if (runner.active) { + if (!runner.waitingForChoice) runner.advance(); + updateHud(); + return; + } + if (nearNpc) { + talkTo(nearNpc.def.id); + return; + } + if (nearClick && !fade.active) startTransition(nearClick, heroTile()); +} + +// ---- HUD: журнал квестов (трекер) + сумка + тосты ---- +const questEl = document.getElementById('quest') as HTMLDivElement; +const invEl = document.getElementById('inv') as HTMLDivElement; +const toastsEl = document.getElementById('toasts') as HTMLDivElement; + +function updateHud(): void { + // трекер: первая активная стадия (скрыт, когда квестов нет) + const tr = questTracker(state); + questEl.hidden = tr === null; + if (tr !== null) questEl.textContent = tr; + // сумка: «Имя ×N» по слотам + if (inv.empty) { + invEl.hidden = true; + invEl.replaceChildren(); + return; + } + invEl.replaceChildren(); + const title = document.createElement('div'); + title.className = 'title'; + title.textContent = 'Сумка'; + invEl.append(title); + for (const slot of inv.all) { + const el = document.createElement('div'); + el.textContent = `${itemName(slot.id)} ×${slot.count}`; + invEl.append(el); + } + invEl.hidden = false; +} +inv.onChange(updateHud); +updateHud(); + +/** Тост: всплывашка эффекта, уходит сама (реальное время). */ +function showToast(text: string): void { + const el = document.createElement('div'); + el.textContent = text; + toastsEl.append(el); + setTimeout(() => el.remove(), 2600); +} + +/** Снапшот диалога для моста: узел/выборы/путь + флаги/вары + журнал. */ +function dialogueSnapshot() { + const node = runner.node; + return { + active: runner.active, + graphId: currentDialogueId, + nodeId: runner.nodeId, + speaker: node?.speaker ?? null, + text: node?.text ?? null, + choices: runner.choices, + waitingForChoice: runner.waitingForChoice, + path: runner.path, + result: runner.result, + questTracker: questTracker(state), + toasts: [...toastsEl.children].map((c) => c.textContent), + ...state.toData(), + inv: inv.all.map((s) => ({ id: s.id, count: s.count })), + }; +} + /** Начать переход (фейд out, середина — смена области). */ function startTransition(tr: TransitionDef, origin: Tile): void { if (fade.active) return; // параллельные переходы глотаются (как в v1) @@ -161,9 +372,15 @@ heldKeys.add(e.code); e.preventDefault(); } - if (e.code === 'KeyE' && nearClick) { + if (e.code === 'KeyE') { e.preventDefault(); - startTransition(nearClick, heroTile()); + interact(); + } + // выбор варианта ответа — цифрами (позиция в показанном списке) + if (runner.active && runner.waitingForChoice && /^Digit[1-9]$/.test(e.code)) { + e.preventDefault(); + runner.pick(Number(e.code.slice(5)) - 1); + updateHud(); } }); window.addEventListener('keyup', (e) => { @@ -186,7 +403,7 @@ return { x: Math.floor(b[0] / UNIT), y: Math.floor(b[2] / UNIT) }; } -/** Один шаг логики: фейд → ввод → герой → камера → переходы → подсказка. */ +/** Один шаг логики: фейд → диалог/ввод → герой → камера → переходы → подсказка. */ function tick(dt: number): void { if (fade.active) { fade.t += dt; @@ -206,7 +423,9 @@ } return; // во время перехода мир замер } - const move = agentInput ?? keyboardMove(); + const talking = runner.active; + // в диалоге герой стоит (как в v1: мир не тикается под репликой) + const move: readonly [number, number] = talking ? [0, 0] : (agentInput ?? keyboardMove()); hero.setMove(screenMoveToWorld(move, ISO_YAW)); hero.step(world, dt); heroCloud.update(hero.visual()); @@ -214,6 +433,13 @@ const b = hero.body.pos; renderer.setTarget([Math.round(b[0]), GROUND_Y, Math.round(b[2])]); } + // NPC рядом: ближайший в радиусе подхода (движковый чистый nearest) + const hit = talking ? null : nearestInteractable(npcEntities.map((e) => e.spot), hero.body.pos); + nearNpc = hit ? npcEntities.find((e) => e.spot === hit) ?? null : null; + if (talking) { + showHint(''); + return; // диалог: без переходов и подсказок двери + } // step-переход: только что вошёл на тайл-триггер const tile = heroTile(); const justEntered = prevTile !== null && (tile.x !== prevTile.x || tile.y !== prevTile.y); @@ -227,14 +453,14 @@ } } prevTile = tile; - // click-переход рядом: подсказка «E — …» + // приоритет подсказки: NPC → click-переход nearClick = currentArea.transitions.find( (t) => t.trigger === 'click' && Math.max(Math.abs(t.tile.x - tile.x), Math.abs(t.tile.y - tile.y)) <= 1, ) ?? null; - showHint(nearClick ? `E — ${nearClick.label ?? 'перейти'}` : ''); + showHint(nearNpc ? 'E — Поговорить' : nearClick ? `E — ${nearClick.label ?? 'перейти'}` : ''); } // ---- Цикл: фиксированный шаг 60 Гц, ручной режим для гейтов ---- @@ -331,14 +557,65 @@ } return false; }, - /** Нажать «E» агентом: активировать click-переход. */ + /** Нажать «E» агентом: диалог NPC / click-переход / «дальше» в диалоге. */ interact(): void { - if (nearClick && !fade.active) startTransition(nearClick, heroTile()); + interact(); }, /** Снапшот взаимодействия: ближайшая дверь и подсказка. */ interactInfo() { return { label: nearClick?.label ?? null, hint: hintText }; }, + /** NPC области: кто стоит, тайлы, кто ближайший для «E». */ + npcInfo() { + return { + area: currentArea.id, + npcs: npcEntities.map((e) => ({ + id: e.def.id, + name: e.def.name, + tile: e.def.tile, + pos: [...e.char.body.pos], + dialogue: dialogueIdFor(e.def), + })), + near: nearNpc?.def.id ?? null, + }; + }, + /** Начать разговор (id NPC; без аргумента — ближайший). */ + talkTo(id?: string): ReturnType { + const target = id ?? nearNpc?.def.id; + if (target) talkTo(target); + return dialogueSnapshot(); + }, + /** Снапшот диалога/квестов: узел, выборы, путь, флаги, вары, журнал. */ + dialogueInfo(): ReturnType { + return dialogueSnapshot(); + }, + /** Выбрать вариант ответа (индекс в показанном списке). */ + dialoguePick(index: number): ReturnType { + runner.pick(index); + updateHud(); + return dialogueSnapshot(); + }, + /** «Дальше» в диалоге. */ + dialogueAdvance(): ReturnType { + if (runner.active && !runner.waitingForChoice) runner.advance(); + updateHud(); + return dialogueSnapshot(); + }, + /** Прервать диалог досрочно (гейт/сейв). */ + dialogueAbort(): void { + runner.abort(); + updateHud(); + }, + /** Правка состояния агентом (гейт квест-стадий): вары и флаги. */ + setState(patch: { vars?: Record; flags?: string[] }): void { + for (const [k, v] of Object.entries(patch.vars ?? {})) state.setVar(k, v); + for (const f of patch.flags ?? []) state.setFlag(f); + updateHud(); + }, + /** Снапшот состояния сюжета (флаги/вары) и сумки. */ + stateInfo() { + return { ...state.toData(), inv: inv.all.map((s) => ({ id: s.id, count: s.count })) }; + }, }; (window as unknown as Record).__agent = agent; (window as unknown as Record).__beacon = true; \ No newline at end of file diff --git a/v2/apps/game/src/state.ts b/v2/apps/game/src/state.ts new file mode 100644 index 0000000..5ab6d82 --- /dev/null +++ b/v2/apps/game/src/state.ts @@ -0,0 +1,59 @@ +/** + * Состояние игры «Пепельных луга»: флаги сюжета и числовые вары. + * Реализует DialogueState движка (диалоги и квест-стадии пишут прямо сюда); + * данные сериализуемы (toData/fromData) — автосейв подхватит в треке сейвов. + * Ключи — только из реестров data/ids.ts. + */ +import type { DialogueState } from '@rpg/engine'; + +export type VarValue = number | string | boolean; + +/** Данные для сейва: какие флаги подняты + значения варов. */ +export interface GameStateData { + flags: string[]; + vars: Record; +} + +export class GameState implements DialogueState { + private readonly flags = new Set(); + private readonly vars = new Map(); + + hasFlag(flag: string): boolean { + return this.flags.has(flag); + } + + setFlag(flag: string): void { + this.flags.add(flag); + } + + clearFlag(flag: string): void { + this.flags.delete(flag); + } + + getVar(key: string): VarValue | undefined { + return this.vars.get(key); + } + + setVar(key: string, value: VarValue): void { + this.vars.set(key, value); + } + + /** Числовой вар (для прогресса N/M); нет/не число — 0. */ + getNumber(key: string): number { + const v = this.vars.get(key); + return typeof v === 'number' ? v : 0; + } + + toData(): GameStateData { + return { flags: [...this.flags], vars: Object.fromEntries(this.vars) }; + } + + /** Заменить состояние (загрузка сейва); пусто — новая игра. */ + fromData(data: GameStateData | null): void { + this.flags.clear(); + this.vars.clear(); + if (!data) return; + for (const f of data.flags) this.flags.add(f); + for (const [k, v] of Object.entries(data.vars)) this.vars.set(k, v); + } +} \ No newline at end of file diff --git a/v2/apps/game/tools/checks/npc.mjs b/v2/apps/game/tools/checks/npc.mjs new file mode 100644 index 0000000..6ebd1cf --- /dev/null +++ b/v2/apps/game/tools/checks/npc.mjs @@ -0,0 +1,218 @@ +/** + * Скриншот-гейт NPC и квестового каркаса v2 (трек 16): NPC стоят по + * раскладке v1 (Ирвин/Мила в Звенце, Клинт — по флагу act2_hook), + * выбор графа по квест-стадиям (первый → repeat → сдача), эффекты графов + * (giveItem → сумка, setFlags → состояние), журнал/трекер квестов, мир + * замер в диалоге, панель диалога рисуется. Скриншоты: /tmp/v2_npc_*.png. + * + * Запуск: node apps/game/tools/checks/npc.mjs [--port 5314] + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; +import { decodePng } from '@rpg/engine/tools/png.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port ?? 5314); + +const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) }); +const report = { name: 'npc', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +function countDiff(a, b) { + let n = 0; + for (let i = 0; i < a.length; i += 4) { + if (Math.abs(a[i] - b[i]) > 8 || Math.abs(a[i + 1] - b[i + 1]) > 8 || Math.abs(a[i + 2] - b[i + 2]) > 8) n++; + } + return n; +} +function colorCount(rgba) { + const seen = new Set(); + for (let i = 0; i < rgba.length; i += 4) { + seen.add((rgba[i] >> 3) + (rgba[i + 1] >> 3) * 32 + (rgba[i + 2] >> 3) * 1024); + } + return seen.size; +} + +/** Воксельный центр тайла v1 (как tileCenter: tile*3 + 1.5). */ +const center = (t) => ({ x: t.x * 3 + 1.5, z: t.y * 3 + 1.5 }); + +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/` }); + const errs = client.errors(); + check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); + + await client.call('setManual', true); + await client.call('stepTicks', 10); + + // NPC Звенца по раскладке v1; Клинта в роще ещё нет (act2_hook не поднят) + const zn = await client.call('gotoArea', 'zvenets'); + await client.call('stepTicks', 5); + const info = await client.call('npcInfo'); + const byId = Object.fromEntries(info.npcs.map((n) => [n.id, n])); + check('Звенец: стоят Ирвин (12,15) и Мила (40,20), Клинта нет', + info.area === 'zvenets' && info.npcs.length === 2 + && byId.elder && byId.elder.tile.x === 12 && byId.elder.tile.y === 15 + && byId.trader && byId.trader.tile.x === 40 && byId.trader.tile.y === 20, + { npcs: info.npcs.map((n) => n.id) }); + check('графы по стадиям: первый диалог у обоих, Клинт — clint_first', + byId.elder.dialogue === 'elder_first' && byId.trader.dialogue === 'trader_first', + { elder: byId.elder.dialogue, trader: byId.trader.dialogue }); + const grove0 = await client.call('gotoArea', 'rust_grove'); + await client.call('stepTicks', 5); + const groveInfo = await client.call('npcInfo'); + check('роща без act2_hook: Клинта нет', groveInfo.area === 'rust_grove' && groveInfo.npcs.length === 0, + { npcs: groveInfo.npcs.map((n) => n.id) }); + await client.call('gotoArea', 'zvenets'); + await client.call('stepTicks', 5); + + // Подход к Ирвину: радиус 5 вокселей → near + подсказка «E — Поговорить» + const elderC = center({ x: 12, y: 15 }); + await client.call('heroTeleport', elderC.x - 3, elderC.z); + await client.call('stepTicks', 3); + const near1 = await client.call('npcInfo'); + const ii1 = await client.call('interactInfo'); + check('у Ирвина: near = elder, подсказка «E — Поговорить»', + near1.near === 'elder' && ii1.hint.includes('E') && ii1.hint.includes('оговорить'), + { near: near1.near, hint: ii1.hint }); + + // Мир замер в диалоге: стик не двигает героя + const hiBefore = await client.call('heroInfo'); + await client.call('interact'); + await client.call('setInput', 1, 1); + await client.call('stepTicks', 10); + await client.call('setInput', null, null); + const hiLocked = await client.call('heroInfo'); + const dlg = await client.call('dialogueInfo'); + const moved = Math.hypot(hiLocked.pos[0] - hiBefore.pos[0], hiLocked.pos[2] - hiBefore.pos[2]); + check('диалог открыт (greet), герой стоит на месте', + dlg.active && dlg.nodeId === 'greet' && moved < 0.01, + { nodeId: dlg.nodeId, moved: Math.round(moved * 100) / 100 }); + + // Панель диалога рисуется: кадр с репликой отличается от пустого + const withPanel = await client.screenshot('/tmp/v2_npc_panel.png'); + await client.call('dialogueAbort'); + await client.call('stepTicks', 3); + const withoutPanel = await client.screenshot('/tmp/v2_npc_nopanel.png'); + const panelDiff = countDiff(decodePng(readFileSync(withPanel)).rgba, + decodePng(readFileSync(withoutPanel)).rgba); + check('панель диалога видна (кадры различаются)', panelDiff > 3000, { panelDiff }); + + // elder_first: выбор на «task» → give (флаги + часы и карта) → ring + const d1 = await client.call('talkTo', 'elder'); + check('взятие квеста: greet, выбор варианта', d1.nodeId === 'greet' && d1.waitingForChoice === false, d1.nodeId); + await client.call('dialogueAdvance'); // ask + await client.call('dialogueAdvance'); // task (выборы) + const dTask = await client.call('dialogueInfo'); + check('на «task» показан выбор', dTask.nodeId === 'task' && dTask.waitingForChoice && dTask.choices.length === 1, + { choices: dTask.choices.map((c) => c.text) }); + const dGive = await client.call('dialoguePick', 0); // → give (флаги, предметы) + const invAfter = dGive.inv.map((s) => `${s.id}:${s.count}`).join(','); + check('give: флаги met_elder+quest_bells_taken, часы и карта в сумке', + dGive.flags.includes('met_elder') && dGive.flags.includes('quest_bells_taken') + && invAfter === 'clock:1,map_scroll:1', { inv: invAfter }); + check('трекер: стадия сбора (0/3)', + dGive.questTracker !== null && dGive.questTracker.includes('колокольчик') && dGive.questTracker.includes('0/3'), + { tracker: dGive.questTracker }); + await client.call('dialogueAdvance'); // ring + const dEnd = await client.call('dialogueAdvance'); + check('диалог завершился', !dEnd.active && dEnd.result !== null && dEnd.path.length === 5, + { path: dEnd.path }); + + // Повторный разговор: квест-стадия 2 не готова (цветов 0) → repeat + const dRep = await client.call('talkTo', 'elder'); + check('не готово — repeat-диалог Ирвина', dRep.graphId === 'elder_repeat', dRep.graphId); + await client.call('dialogueAdvance'); + + // Мила: знакомство trader_first (полотно) — до сдачи цветов: после + // сдачи активная стадия «bells» переключит Милу на trader_after + const traderC = center({ x: 40, y: 20 }); + await client.call('heroTeleport', traderC.x - 3, traderC.z); + await client.call('stepTicks', 3); + const dMila = await client.call('talkTo', 'trader'); + check('Мила: знакомство trader_first', dMila.graphId === 'trader_first', dMila.graphId); + for (let i = 0; i < 4; i++) await client.call('dialogueAdvance'); // stop→warn→gift→(give_cloth)→reply + const dMilaEnd = await client.call('dialogueAdvance'); + check('Мила: met_mila+got_cloth, полотно в сумке', + dMilaEnd.flags.includes('met_mila') && dMilaEnd.flags.includes('got_cloth') + && dMilaEnd.inv.some((s) => s.id === 'cloth'), + { flags: dMilaEnd.flags.length, inv: dMilaEnd.inv.map((s) => s.id) }); + + // Сдача: цветы 3/3 → elder_hand_in (custom plant_flowers) → квест закрыт + await client.call('setState', { vars: { flowers: 3 } }); + const dHand = await client.call('talkTo', 'elder'); + check('3/3 — открывается сдача elder_hand_in', dHand.graphId === 'elder_hand_in', dHand.graphId); + await client.call('dialogueAdvance'); // accept (флаг + custom) + const dHand2 = await client.call('dialogueInfo'); + check('сдача: quest_bells_done, стадия 3 — «поговорить с Милой»', + dHand2.flags.includes('quest_bells_done') && dHand2.questTracker !== null + && dHand2.questTracker.includes('тропы гудит'), { tracker: dHand2.questTracker }); + await client.call('dialogueAdvance'); // ring + await client.call('dialogueAdvance'); // конец + + // trader_after (эпилог «bells») → trader_repeat: motes_note по вару + const dAfter = await client.call('talkTo', 'trader'); + check('стадия 3 «bells» — trader_after', dAfter.graphId === 'trader_after', dAfter.graphId); + await client.call('dialogueAdvance'); + const dHook = await client.call('dialogueAdvance'); + check('trader_after: act2_hook (крючок акта 2)', dHook.flags.includes('act2_hook'), {}); + const dRep2 = await client.call('talkTo', 'trader'); + check('без мотов motes_note скрыт (диалог короткий)', + dRep2.graphId === 'trader_repeat' && !dRep2.path.includes('motes_note'), { path: dRep2.path }); + await client.call('dialogueAbort'); + await client.call('setState', { vars: { motes: 2 } }); + await client.call('talkTo', 'trader'); + const dRep3 = await client.call('dialogueAdvance'); // quiet → motes_note + check('моты есть — motes_note виден', dRep3.path.includes('motes_note'), { path: dRep3.path }); + await client.call('dialogueAbort'); + + // Клинт появился в роще (act2_hook), квест резонатора: взятие → сдача + await client.call('gotoArea', 'rust_grove'); + await client.call('stepTicks', 5); + const clintInfo = await client.call('npcInfo'); + check('act2_hook: Клинт стоит в роще (23,17)', + clintInfo.npcs.length === 1 && clintInfo.npcs[0].id === 'clint' + && clintInfo.npcs[0].tile.x === 23 && clintInfo.npcs[0].tile.y === 17, + { npcs: clintInfo.npcs.map((n) => n.id) }); + const clintC = center({ x: 23, y: 17 }); + await client.call('heroTeleport', clintC.x - 3, clintC.z); + await client.call('stepTicks', 3); + const dClint = await client.call('talkTo', 'clint'); + for (let i = 0; i < 5; i++) await client.call('dialogueAdvance'); // grunt→…→reply + const dClintEnd = await client.call('dialogueAdvance'); + check('Клинт: met_clint+quest_resonator_taken', + dClintEnd.flags.includes('met_clint') && dClintEnd.flags.includes('quest_resonator_taken'), {}); + await client.call('setState', { vars: { tones: 3 } }); + const dTune = await client.call('talkTo', 'clint'); + check('тоны 3/3 — clint_tune', dTune.graphId === 'clint_tune', dTune.graphId); + await client.call('dialogueAdvance'); // accept (флаг + custom) + const dTune2 = await client.call('dialogueInfo'); + check('резонатор настроен (quest_resonator_done)', dTune2.flags.includes('quest_resonator_done'), {}); + await client.call('dialogueAdvance'); // ring + await client.call('dialogueAdvance'); // конец + const dClintRep = await client.call('talkTo', 'clint'); + for (let i = 0; i < 2; i++) await client.call('dialogueAdvance'); // grumble→hint→half_tone + const dClintRepEnd = await client.call('dialogueAdvance'); + check('эпилог: machine_hook, все квесты закрыты (трекер пуст)', + dClintRepEnd.flags.includes('machine_hook') && dClintRepEnd.questTracker === null, {}); + + // Кадр рощи с Клинтом рисуется + const shot = decodePng(readFileSync(await client.screenshot('/tmp/v2_npc_grove.png'))); + check('кадр рощи с Клинтом не пустой', colorCount(shot.rgba) >= 8, {}); + } finally { + await browser.close(); + } +} catch (e) { + report.error = String(e && e.stack ? e.stack : e); + console.error(report.error); +} finally { + server.stop(); +} +report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok); +console.log(JSON.stringify(report)); \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index 765a8b4..d4fbb7d 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -59,7 +59,7 @@ export { SceneManager } from './core/scene'; export type { Scene, FadeOverlay, SceneTransition } from './core/scene'; export { ISO_YAW } from './render/voxelRenderer'; -export { validateDialogue } from './dialogue/graph'; +export { validateDialogue, evalConditions } from './dialogue/graph'; export type { DialogueGraph, DialogueNode, DialogueChoice, DialogueConditions, VarCondition, DialogueEffects, DialogueEffectOp, DialogueResult, DialogueState, DialogueWorld,