diff --git a/docs/plan.md b/docs/plan.md index 9d66cae..077a17c 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -659,10 +659,36 @@ гейта существенен; подстроки имён с падежами («Мила» в «Милой») в проверках текста ненадёжны. Экспортирован `evalConditions` из движка (ready-условия стадий — те же предикаты, что в графах). -- **Трек 17 — интерактив, пикапы, квест «Три цветка»**: INTERACTABLES - (сундуки, записка, колодцы, прилавок, bell_rope), сбор цветов/мотов, - FlowerQuest (взять → собрать в прудах → посадить → сдать с кат-сценой), - предметы (cloth от Милы), накат-зоны (без cloth — голос); гейт. +- **Трек 17 — интерактив, пикапы, квест «Три цветка»** ✅ (2026-09-10, + коммит): контент v1 1:1 — `src/data/interactables.ts` (столбы у троп, + моты, деревья-механизмы тонов, резонатор, сундук/записка/очаг, прилавок, + верёвка колокола, Старая Машина; реакции по `when` с флагами/vars, + requiresItem → lockedText, once → used:), `src/data/hazards.ts` + (накат прудов/разъезда), `src/areas.ts` + interactables/hazards/ + harvestTile (цветки — только пруды). Рантайм: `src/interactables.ts` + (resolveInteractable → apply через sink: флаги → вар → предмет → used → + custom → тост с прогрессом), `src/props.ts` (пропы строго в тайл 3×3: + мот — искра, столб/сундук/записка/верёвка/резонатор; clearProp при once), + `src/flowers.ts` (FlowerQuest: сбор/посадка/пересев/зелень; правки — + воксель в центре тайла + журнал FlowerEdits по областям, переживает + пересборку мира в setArea). В main.ts: кандидаты «E» с приоритетом v1 + (npc → click-переход → интерактив → цветок; радиус 4.5 вокселя, цветы — + окно ±2 тайла по isFlower), накат — тост при входе + ход ×0.5 без + полотна (масштабирование вектора движения), катсцены — стек шагов + {dur, fn?, cam?} с глайдом камеры (мир замер, детерминизм в stepTicks); + custom-эффекты: диалоговые — plant_flowers отложен до onFinish (v1 + pendingCustom), интерактивные — sink.onCustom (lull_machine → тост). + Мост: interactInfo (kind/near/target/flower), voxAt, flowerTiles. Гейт + `tools/checks/interact.mjs` — 27 проверок (реакции/ветки объектов, накат + ×2 с полотном, сбор цветка, Машина: заперто → усыплена → спит, катсцена + сдачи: герой стоит, камера у башни на середине глайда, 3 тайла цветут, + кадр изменился). Уроки: квест надо взять ДО сдачи (questDialogueFor по + активной стадии — без elder_first сдача не открывается, зависший выбор + блокирует interact); vars.<ключ> до первой записи undefined (не `=== 0`); + setTimeout-тосты в headless не тикают и копятся (чистые скриншоты — + отдельным прогоном); тайлы зелени башни — тропа (PATH=1), а + raiseTowerGreen сажает только на GRASS/ASH (v1-faithful — зелень у башни + не всходит и в v1). - **Трек 18 — время суток и атмосфера**: GameClock (день/вечер/ночь), источники света (колокольчики ночью), частицы пепла/тумана, темы областей (renderMusic) + локальные амбиент-слои (спек-лупы вместо WAV); diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index e1cdc53..cc2cb70 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -51,6 +51,7 @@ 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) +node apps/game/tools/checks/interact.mjs # гейт интерактива/цветков игры (/tmp/v2_interact_*.png) npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 npx vite --port 5315 # игра «Пепельные луга» (в apps/game) ``` @@ -359,6 +360,41 @@ trader_after: сдача квеста закрывает окно первого диалога этого NPC; **«Мила» ≠ «Милой»** — подстроки имён с падежными окончаниями в проверках трекера ненадёжны, сверять по устойчивому куску текста стадии. + Трек 17 «интерактив, пикапы, квест „Три цветка“»: `src/data/interactables.ts` + (контент v1 1:1 — столбы, моты, деревья-механизмы, сундук, записка, очаг, + прилавок, верёвка колокола, резонатор, Старая Машина; реакции по `when`, + requiresItem → lockedText, once → used:), `src/interactables.ts` + (рантайм: resolveInteractable + apply через sink; порядок эффектов: флаги → + вар → предмет → used → custom → тост), `src/data/hazards.ts` (накат прудов/ + разъезда, requiresItem cloth), `src/props.ts` (воксельные пропсы строго в + тайл 3×3: мот — искра, столбы/сундук/записка/верёвка/резонатор; clearProp + снимает штамп after once), `src/flowers.ts` (FlowerQuest: сбор/посадка/ + пересев/зелень башни; правки — воксель цветка в центре тайла + журнал + FlowerEdits по областям, переживает пересборку мира в setArea). В main.ts: + кандидаты «E» с приоритетом v1 (npc → click-переход → интерактив → цветок, + радиус 4.5 вокселя = 1.5 юнита; цветы — скан окна ±2 тайла по isFlower), + накат — тост при входе + ход ×0.5 без полотна, катсцены — стек шагов + {dur, fn?, cam?} с глайдом камеры (мир замер); custom-эффекты: диалоговые — + через runner.onEffect (plant_flowers откладывается до onFinish, как в v1 + pendingCustom), интерактивные — через sink.onCustom (lull_machine → тост). + Мост: `interactInfo` (kind/near/target/flower + label/hint)/`voxAt`/ + `flowerTiles`. Гейт `tools/checks/interact.mjs` (27 проверок): столбы/моты/ + записка/сундук (проп убран), верёвка — две ветки, очаг/прилавок — до и + после сдачи, накат — тосты и ход ×2 с полотном (dx 1 vs 2), сбор цветка + (вар 1/3, воксель снят), Машина — «не спеть» → machine_lulled → «спит», + катсцена сдачи (герой стоит, камера у башни на середине глайда, поляна + цветёт — 3 тайла + воксели, кадр изменился). + Грабли трека 17: **квест надо ВЗЯТЬ до сдачи** — questDialogueFor выбирает + по активной стадии: без elder_first (знакомство) сдача при flowers=3 не + открывается, диалог «висит» на выборе и блокирует interact (гейт шёл + дальше с открытым диалогом — Машина «не interact-илась»); **vars.<ключ> + может не существовать** — в снапшоте моста vars.motes undefined до первой + записи, сверять `!(vars.motes > 0)`, не `=== 0`; **setTimeout-тосты в + headless не тикают** — копятся весь прогон (проверки `some()` зелёные, + но кадр завален тостами: чистые скриншоты — отдельным прогоном с минимумом + действий); **тайлы зелени башни — тропа (PATH=1)** — raiseTowerGreen ставит + цветок только на GRASS/ASH (v1-faithful), у башни зелень не всходит, не + считать это багом конвертации. ## Известные грабли (уже собранные) diff --git a/v2/apps/game/src/data/areas.ts b/v2/apps/game/src/data/areas.ts index fc630d6..a0d6e44 100644 --- a/v2/apps/game/src/data/areas.ts +++ b/v2/apps/game/src/data/areas.ts @@ -4,10 +4,15 @@ * Переходы: step — наступил на тайл, click — «E» рядом (двери, колодцы); * 'return' — назад, откуда вошли (стек переходов). */ +import { INTERACTABLES, type InteractableDef } from './interactables'; +import { HAZARD_PONDS, HAZARD_SIDING, type HazardDef } from './hazards'; /** Тайл v1 (юниты v1). */ export interface Tile { x: number; y: number } +/** Тайл раскладки v1 «лунный колокольчик» (сборный — квест «Три цветка»). */ +const T_BELLFLOWER = 5; + export interface TransitionDef { /** Тайл-триггер. */ tile: Tile; @@ -27,12 +32,19 @@ /** Стартовый тайл (вход в область по умолчанию). */ spawn: Tile; transitions: TransitionDef[]; + /** Интерактивные объекты области (из data/interactables.ts). */ + interactables?: InteractableDef[]; + /** Зоны наката (низины: без полотна еле идёшь). */ + hazards?: HazardDef[]; + /** Сборный тайл (лунный колокольчик): «E» рядом — сбор. */ + harvestTile?: number; } export const AREA_DEFS: AreaDef[] = [ { id: 'meadows', name: 'Выжженные луга', + interactables: INTERACTABLES.meadows, spawn: { x: 16, y: 28 }, transitions: [ { tile: { x: 2, y: 2 }, target: { area: 'ponds', entry: { x: 4, y: 4 } } }, @@ -45,10 +57,15 @@ name: 'Серые пруды', spawn: { x: 4, y: 4 }, transitions: [{ tile: { x: 2, y: 2 }, target: { area: 'meadows', entry: { x: 3, y: 3 } } }], + interactables: INTERACTABLES.ponds, + hazards: [HAZARD_PONDS], + // Лунные колокольчики собираются только у прудов (квест «Три цветка»). + harvestTile: T_BELLFLOWER, }, { id: 'zvenets', name: 'Звенец', + interactables: INTERACTABLES.zvenets, spawn: { x: 28, y: 20 }, transitions: [ { tile: { x: 2, y: 20 }, target: { area: 'meadows', entry: { x: 53, y: 28 } } }, @@ -61,6 +78,7 @@ { id: 'rust_grove', name: 'Ржавая роща', + interactables: INTERACTABLES.rust_grove, spawn: { x: 2, y: 20 }, transitions: [ { tile: { x: 1, y: 20 }, target: { area: 'zvenets', entry: { x: 53, y: 20 } } }, @@ -75,22 +93,26 @@ { tile: { x: 1, y: 20 }, target: { area: 'rust_grove', entry: { x: 45, y: 20 } } }, { tile: { x: 28, y: 31 }, label: 'Люк в подземелье', target: { area: 'machine_hall', entry: { x: 13, y: 19 } }, duration: 0.6 }, ], + hazards: [HAZARD_SIDING], }, { id: 'machine_hall', name: 'Зал Старой Машины', + interactables: INTERACTABLES.machine_hall, spawn: { x: 13, y: 19 }, transitions: [{ tile: { x: 13, y: 21 }, target: 'return', duration: 0.2 }], }, { id: 'house_elder', name: 'Дом Ирвина', + interactables: INTERACTABLES.house_elder, spawn: { x: 5, y: 7 }, transitions: [{ tile: { x: 5, y: 8 }, target: 'return', duration: 0.2 }], }, { id: 'shop', name: 'Лавка Милы', + interactables: INTERACTABLES.shop, spawn: { x: 5, y: 7 }, transitions: [{ tile: { x: 5, y: 8 }, target: 'return', duration: 0.2 }], }, diff --git a/v2/apps/game/src/data/hazards.ts b/v2/apps/game/src/data/hazards.ts new file mode 100644 index 0000000..5ffaecc --- /dev/null +++ b/v2/apps/game/src/data/hazards.ts @@ -0,0 +1,33 @@ +/** + * Зоны наката (перенос v1): свежий пепел низин — без вощёного полотна + * герой еле идёт (полотно — из диалога Милы или сундука Ирвина). + */ + +/** Зона наката: тайлы + предмет маски. */ +export interface HazardDef { + name: string; + requiresItem: string; + tiles: { x: number; y: number }[]; +} + +/** Зона наката прудов (низины у берегов). */ +export const HAZARD_PONDS: HazardDef = { + name: 'Свежий накат', + requiresItem: 'cloth', + tiles: [ + { x: 10, y: 32 }, { x: 12, y: 34 }, { x: 14, y: 35 }, { x: 16, y: 36 }, + { x: 18, y: 36 }, { x: 20, y: 37 }, { x: 22, y: 37 }, { x: 24, y: 38 }, + { x: 26, y: 37 }, { x: 28, y: 36 }, { x: 30, y: 35 }, { x: 32, y: 34 }, + { x: 8, y: 18 }, { x: 6, y: 16 }, { x: 30, y: 12 }, { x: 32, y: 10 } + ] +}; + +/** Зона наката разъезда (наметало у рельсов, у люка). */ +export const HAZARD_SIDING: HazardDef = { + name: 'Накат у рельсов', + requiresItem: 'cloth', + tiles: [ + { x: 26, y: 29 }, { x: 27, y: 29 }, { x: 29, y: 29 }, + { x: 28, y: 28 }, { x: 29, y: 30 }, { x: 30, y: 29 } + ] +}; \ No newline at end of file diff --git a/v2/apps/game/src/data/interactables.ts b/v2/apps/game/src/data/interactables.ts new file mode 100644 index 0000000..8e025be --- /dev/null +++ b/v2/apps/game/src/data/interactables.ts @@ -0,0 +1,314 @@ +/** + * Интерактивные объекты области (перенос v1 1:1): столбы у троп, подборные + * моты, записки, сундуки, прилавок, верёвка колокола, деревья-механизмы. + * Первая подходящая реакция (по `when`) срабатывает; эффекты применяет + * рантайм src/interactables.ts через sink. + */ +import { FLAGS, VARS } from './ids'; +import { QUEST_TONES, QUEST_TONE_TILES } from './quests'; +import type { ItemId } from './items'; + +/** Условие реакции: все указанные поля должны выполняться. */ +export interface InteractWhen { + /** Флаг поднят. */ + flag?: string; + /** Флаг не поднят. */ + notFlag?: string; + /** Вар не меньше значения. */ + varAtLeast?: { id: string; value: number }; +} + +/** Реакция объекта: текст (тост) + необязательные эффекты. */ +export interface InteractResponse { + /** Первая реакция с выполненным условием срабатывает (без when — всегда). */ + when?: InteractWhen; + text: string; + setFlags?: string[]; + clearFlags?: string[]; + setVar?: { id: string; value: number }; + /** Прибавить к вару (подбор мотов: +1 за каждый нарост). */ + addVar?: { id: string; by: number }; + /** Выдать предмет (в сумку). */ + gives?: ItemId; + /** Сколько штук gives (по умолчанию 1). */ + count?: number; + /** Тост вместо text (после эффектов); с varKey — дописывает « (N/goal)». */ + toast?: { text: string; varKey?: string; goal?: number }; + /** Сюжетный эффект (custom): сцена запускает событие. */ + custom?: string; +} + +/** Объект мира, с которым герой взаимодействует (E в радиусе). */ +export interface InteractableDef { + id: string; + /** Позиция в тайлах v1 (центр тайла = координата + 0.5). */ + pos: { x: number; y: number }; + /** Тайл привязки (floor от pos; вычисляется — в контенте не писать). */ + tile: { x: number; y: number }; + kind: 'examine' | 'pickup' | 'container' | 'lever' | 'signpost'; + /** Подпись при близости (и в снапшоте). */ + label?: string; + /** Одноразовый: помечается флагом used: и больше не реагирует. */ + once?: boolean; + /** Без предмета — отказ с lockedText. */ + requiresItem?: ItemId; + lockedText?: string; + responses: InteractResponse[]; +} + +/** Что резолверу нужно знать о герое и мире (подставляется из состояния/сумки). */ +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 }; +} + +/** Моты лугов: общий шаблон подборного сгустка (3 штуки, разный текст). */ +function mote(id: string, pos: { x: number; y: number }, text: string): Omit { + return { + id, + pos, + kind: 'pickup', + label: 'Пепельный мот', + once: true, + responses: [ + { + text, + gives: 'mote', + addVar: { id: VARS.motes, by: 1 } + } + ] + }; +} + +/** Контент по областям (см. docs/world.md): интерьеры и улицы (pos — первичен). */ +const INTERACTABLES_RAW: Record[]> = { + // --- луга: столбы у троп и подборные пепельные моты (зачаток экономики) --- + meadows: [ + { + id: 'sign_meadows_east', + pos: { x: 48.5, y: 27.5 }, + kind: 'signpost', + label: 'Столб у тропы', + responses: [ + { text: '«Звенец» — стёрто, но различимо. Дальше на восток: иди на звон.' } + ] + }, + { + id: 'sign_meadows_west', + pos: { x: 2.5, y: 3.5 }, + kind: 'signpost', + label: 'Столб у тропы', + responses: [ + { text: '«Серые пруды» — стрелка на северо-запад. Ниже приписка: «туман лжёт».' } + ] + }, + mote('mote_1', { x: 13.5, y: 28.5 }, 'Сгусток серой земли с тёплой искрой. Мот — то, ради чего сажают колокольчики.'), + mote('mote_2', { x: 16.5, y: 38.5 }, 'Ещё один мот. Земля здесь отдаёт их неохотно.'), + mote('mote_3', { x: 16.5, y: 20.5 }, 'Третий мот за день. Сумка тихо позвякивает.') + ], + // --- роща (акт 2): деревья-механизмы для звона и резонатор Клинта --- + // Реакция дерева-механизма: до квеста молчит; с квестом — звон и тон + // (свой флаг на дерево — addVar без повтора); после настройки гудит в лад. + rust_grove: [ + ...(['tone_tree_1', 'tone_tree_2', 'tone_tree_3'] as const).map((id, i): Omit => { + const [tx, ty] = QUEST_TONE_TILES[i]; + return { + id, + pos: { x: tx + 0.5, y: ty + 0.5 }, + kind: 'examine', + label: 'Дерево-механизм', + responses: [ + { + when: { flag: FLAGS.quest_resonator_done }, + text: 'Дерево гудит в лад с резонатором. Кабели тёплые.' + }, + { + when: { notFlag: FLAGS.quest_resonator_taken }, + text: 'Кабели оплетают ствол, но дерево молчит. Не сейчас.' + }, + { + when: { notFlag: FLAGS[id] }, + text: 'Звон колокольчика — и кабели отвечают: дерево отдаёт свой тон.', + toast: { text: 'Тон', varKey: VARS.tones, goal: QUEST_TONES }, + setFlags: [FLAGS[id]], + addVar: { id: VARS.tones, by: 1 } + }, + { text: 'Это дерево уже прозвенело. Тон унесён.' } + ] + }; + }), + { + id: 'clint_resonator', + pos: { x: 24.5, y: 16.5 }, + kind: 'lever', + label: 'Резонатор Клинта', + responses: [ + { + when: { flag: FLAGS.quest_resonator_done }, + text: 'Резонатор держит тон ровно. Клинт поддел его под пол-тона.' + }, + { + text: 'Медные колокола на стальной раме. Ржавый, молчит. Клинту нужен верный звон.' + } + ] + } + ], + ponds: [ + { + id: 'sign_ponds', + pos: { x: 5.5, y: 3.5 }, + kind: 'signpost', + label: 'Столб у берега', + responses: [ + { text: '«Не дышать накатом» — буквы вросли в ржавчину. Кто-то дописал: «голос дороже соли».' } + ] + } + ], + zvenets: [ + { + id: 'sign_zvenets', + pos: { x: 3.5, y: 19.5 }, + kind: 'signpost', + label: 'Столб у входа', + responses: [ + { text: '«Звенец. Мастерская мира». Ниже чужим почерком: «кузня чинит резонаторы, лавка меняет соль».' } + ] + }, + { + id: 'bell_rope', + pos: { x: 27.5, y: 13.5 }, + kind: 'lever', + label: 'Верёвка колокола', + responses: [ + { + when: { notFlag: FLAGS.rang_tower }, + text: 'Верёвка в ладони. Колокол качнулся — низкий тон ушёл в землю. Кто-то услышит.', + setFlags: [FLAGS.rang_tower] + }, + { + text: 'Колокол качнулся снова. Звон тот же: пол-тона ниже, чем в звонной книге.' + } + ] + } + ], + house_elder: [ + { + id: 'chest_elder', + pos: { x: 3.5, y: 3.5 }, + kind: 'container', + label: 'Сундук', + once: true, + responses: [ + { text: 'В сундуке — запас вощёного полотна.', gives: 'cloth' } + ] + }, + { + id: 'hearth_elder', + pos: { x: 8.5, y: 0.5 }, + kind: 'examine', + label: 'Очаг', + responses: [ + { + when: { flag: FLAGS.quest_bells_done }, + text: 'Очаг горит ровнее. Пепел отступает — и тепло возвращается.' + }, + { text: 'В очаге тлеют щепки. Тепло — редкость теперь.' } + ] + }, + { + id: 'note_elder', + pos: { x: 10.5, y: 4.5 }, + kind: 'examine', + label: 'Записка', + once: true, + responses: [ + { + text: '«Тоны уходят вниз. Если колокол смолкнет — не буди Молчание».', + setFlags: [FLAGS.read_note] + } + ] + } + ], + shop: [ + { + id: 'counter_shop', + pos: { x: 5.5, y: 3.5 }, + kind: 'examine', + label: 'Прилавок', + responses: [ + { + when: { flag: FLAGS.quest_bells_done }, + text: 'На прилавке — соль и цветы в плошке. Поляна гудит — Мила звонит чаще.' + }, + { + text: 'Соль, вощёное полотно, вода в бутлях. Цен Мила не пишет — звонит.' + } + ] + } + ], + // --- зал Машины (финал акта 2): усыпить её правильным звоном --- + machine_hall: [ + { + id: 'old_machine', + pos: { x: 13.5, y: 1.5 }, + kind: 'lever', + label: 'Старая Машина', + responses: [ + { + when: { flag: FLAGS.machine_lulled }, + text: 'Машина спит. Угольки в устье погасли — впервые за годы.' + }, + { + when: { notFlag: FLAGS.quest_resonator_done }, + text: 'Устье дышит жаром сквозь рёбра. Резонатор не настроен — Машине не спеть.' + }, + { + // Правильный звон: настроенный резонатор + верный тон. + text: 'Резонатор ловит дыхание. Пол-тона вниз — и Машина отвечает сном.', + setFlags: [FLAGS.machine_lulled], + custom: 'lull_machine' + } + ] + } + ] +}; + +/** Тайл привязки вычисляется из pos при загрузке модуля. */ +export const INTERACTABLES: Record = Object.fromEntries( + Object.entries(INTERACTABLES_RAW).map(([area, defs]) => [ + area, + defs.map((d) => ({ ...d, tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } })) + ]) +); \ No newline at end of file diff --git a/v2/apps/game/src/flowers.ts b/v2/apps/game/src/flowers.ts new file mode 100644 index 0000000..8ebe8e1 --- /dev/null +++ b/v2/apps/game/src/flowers.ts @@ -0,0 +1,117 @@ +/** + * Квест «Три цветка» (перенос v1 FlowerQuest на воксельный мир): сбор + * лунных колокольчиков у прудов, посадка поляны у тропы Звенца с + * пересевом на соседние тайлы. Изменения тайлов — правки вокселя цветка + * + журнал правок (переживает пересборку мира при переходе областей). + */ +import type { VoxelWorld } from '@rpg/engine'; +import { GROUND_Y, UNIT, type AreaLayout } from './areas'; +import { QUEST_FLOWERS, QUEST_PLANT_TILES, QUEST_TOWER_GREEN, QUEST_TOWER_TILE } from './data/quests'; +import { VARS } from './data/ids'; +import type { GameState } from './state'; + +/** Тайл раскладки v1: цветок / трава / пепел. */ +const T_BELLFLOWER = 5, T_GRASS = 0, T_ASH = 4; +/** Слот вокселя цветка (B1 — тёплая жизнь). */ +const FLOWER = 9; + +/** Воксель цветка тайла (центр, как stampBellflower в areas.ts). */ +function flowerVoxel(tx: number, ty: number): [number, number, number] { + return [tx * UNIT + 1, GROUND_Y, ty * UNIT + 1]; +} + +/** Правки цветков области: тайл → стоит ли цветок (переживает пересборку). */ +export type FlowerEdits = Map>; + +/** Тайлы раскладки области (RLE → плоский массив). */ +function tilesOf(l: AreaLayout): number[] { + const out: number[] = []; + for (const [id, run] of l.tiles) for (let k = 0; k < run; k++) out.push(id); + while (out.length < l.width * l.height) out.push(T_ASH); + return out; +} + +/** Тайтл цветка: тайл раскладки — сборный и правка не снята. */ +export class FlowerQuest { + /** Правки по областям: areaId → (индекс тайла → стоит ли цветок). */ + readonly edits: FlowerEdits = new Map(); + + constructor( + private state: GameState, + /** Взять воксельный мир текущей области (он пересоздаётся на переходах). */ + private world: () => VoxelWorld, + private layout: () => AreaLayout, + private areaId: () => string, + private showToast: (text: string) => void, + ) {} + + /** Цветок стоит на тайле? (раскладка + правки) */ + isFlower(tx: number, ty: number): boolean { + const l = this.layout(); + if (tx < 0 || ty < 0 || tx >= l.width || ty >= l.height) return false; + const idx = ty * l.width + tx; + const edit = this.edits.get(this.areaId())?.get(idx); + if (edit !== undefined) return edit; + return tilesOf(l)[idx] === T_BELLFLOWER; + } + + /** Записать правку тайла (стоит/не стоит цветок). */ + private setFlower(tx: number, ty: number, present: boolean): void { + let per = this.edits.get(this.areaId()); + if (!per) this.edits.set(this.areaId(), (per = new Map())); + per.set(ty * this.layout().width + tx, present); + const [x, y, z] = flowerVoxel(tx, ty); + this.world().set(x, y, z, present ? FLOWER : 0); + } + + /** Восстановить цветки области после пересборки мира. */ + restore(): void { + const per = this.edits.get(this.areaId()); + if (!per) return; + for (const [idx, present] of per) { + const [x, y, z] = flowerVoxel(idx % this.layout().width, Math.floor(idx / this.layout().width)); + this.world().set(x, y, z, present ? FLOWER : 0); + } + } + + /** Сбор лунного колокольчика: цветок — в сумку, прогресс — в vars. */ + collect(tx: number, ty: number): void { + this.setFlower(tx, ty, false); + this.state.setVar(VARS.flowers, this.state.getNumber(VARS.flowers) + 1); + const n = this.state.getNumber(VARS.flowers); + this.showToast(`Лунный колокольчик (${Math.min(n, QUEST_FLOWERS)}/${QUEST_FLOWERS})`); + } + + /** Посадка: поляна у тропы гудит и расползается на соседние тайлы. */ + plantFlowers(): void { + const left = this.state.getNumber(VARS.flowers) - QUEST_FLOWERS; + this.state.setVar(VARS.flowers, Math.max(0, left)); + for (const [tx, ty] of QUEST_PLANT_TILES) this.setFlower(tx, ty, true); + // Пересев: поляна расползается на соседние тайлы (якорь «надежда растёт»). + const tiles = tilesOf(this.layout()); + const l = this.layout(); + for (const [tx, ty] of QUEST_PLANT_TILES) { + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { + const x = tx + dx, y = ty + dy; + const t = tiles[y * l.width + x]; + if ((t === T_GRASS || t === T_ASH) && !this.isFlower(x, y)) this.setFlower(x, y, true); + } + } + this.showToast('Цветы в земле. Поляна гудит.'); + } + + /** Зелень у подножия башни: визуальный финал акта 1. */ + raiseTowerGreen(): void { + const tiles = tilesOf(this.layout()); + const l = this.layout(); + for (const [tx, ty] of QUEST_TOWER_GREEN) { + const t = tiles[ty * l.width + tx]; + if ((t === T_GRASS || t === T_ASH) && !this.isFlower(tx, ty)) this.setFlower(tx, ty, true); + } + } + + /** Тайл башни Звенца (камера кат-сцены сдачи идёт сюда). */ + get towerTile(): { x: number; y: number } { + return QUEST_TOWER_TILE; + } +} \ No newline at end of file diff --git a/v2/apps/game/src/interactables.ts b/v2/apps/game/src/interactables.ts new file mode 100644 index 0000000..b20e873 --- /dev/null +++ b/v2/apps/game/src/interactables.ts @@ -0,0 +1,82 @@ +/** + * Рантайм интерактивных объектов области (перенос v1 1:1): флаги + * used:, применение эффектов реакции через sink (сцена подставляет + * сумку/тост/сюжетное событие). + */ +import { usedFlag } from './data/ids'; +import { + resolveInteractable, + type InteractableDef, + type InteractCtx, + type InteractPick, + type InteractResponse, +} from './data/interactables'; +import type { GameState } from './state'; + +/** Эффекты реакции — мост между чистым резолвером и игрой. */ +export interface InteractSink { + hasItem(id: string): boolean; + give(id: string, count: number): void; + showToast(text: string): void; + /** Сюжетный эффект реакции (custom): сцена запускает событие. */ + onCustom?(name: string): void; + /** + * Одноразовый объект «поднят» (once, реакция ok): сцена убирает проп + * с поля. Вызывается после apply. + */ + onConsumed?(def: InteractableDef, response: InteractResponse): void; +} + +export class Interactables { + constructor( + private defs: InteractableDef[], + private state: GameState, + private sink: InteractSink, + ) {} + + /** Объект на тайле (или null). */ + defAt(x: number, y: number): InteractableDef | null { + return this.defs.find((d) => d.tile.x === x && d.tile.y === y) ?? null; + } + + /** Одноразовый объект уже использован (флаг used:). */ + isUsed(id: string): boolean { + return this.state.hasFlag(usedFlag(id)); + } + + /** Взаимодействие: выбор реакции + применение эффектов. */ + tryInteract(def: InteractableDef): InteractPick { + const ctx: InteractCtx = { + hasFlag: (f) => this.state.hasFlag(f), + hasItem: (i) => this.sink.hasItem(i), + getVar: (id) => this.state.getNumber(id), + used: (id) => this.isUsed(id), + }; + const pick = resolveInteractable(def, ctx); + if (pick?.ok) { + this.apply(def, pick.response); + if (def.once) this.sink.onConsumed?.(def, pick.response); + } + return pick; + } + + /** Применить эффекты реакции (порядок: флаги → вар → предмет → тост). */ + private apply(def: InteractableDef, r: InteractResponse): void { + for (const f of r.setFlags ?? []) this.state.setFlag(f); + for (const f of r.clearFlags ?? []) this.state.clearFlag(f); + if (r.setVar) this.state.setVar(r.setVar.id, r.setVar.value); + if (r.addVar) this.state.setVar(r.addVar.id, this.state.getNumber(r.addVar.id) + r.addVar.by); + if (r.gives) this.sink.give(r.gives, r.count ?? 1); + if (def.once) this.state.setFlag(usedFlag(def.id)); + if (r.custom) this.sink.onCustom?.(r.custom); + if (r.toast) { + // Тост с прогрессом (как у тонов): «Тон (2/3)». + const n = r.toast.varKey !== undefined ? this.state.getNumber(r.toast.varKey) : null; + const progress = + n !== null && r.toast.goal !== undefined ? ` (${Math.min(n, r.toast.goal)}/${r.toast.goal})` : ''; + this.sink.showToast(r.toast.text + progress); + } else { + this.sink.showToast(r.text); + } + } +} \ No newline at end of file diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index e492828..9ccab8e 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -23,6 +23,10 @@ import { npcsForArea, type NpcDef } from './data/npcs'; import { questDialogueFor, questTracker } from './data/quests'; import { itemName } from './data/items'; +import { Interactables, type InteractSink } from './interactables'; +import { type InteractableDef } from './data/interactables'; +import { stampProp, clearProp } from './props'; +import { FlowerQuest } from './flowers'; // Слоты палитры сцены: арт-библия v1 (1..10) + тайлы областей (11..17) const PALETTE = { @@ -144,6 +148,40 @@ let layout = AREA_LAYOUTS.meadows!; const returnStack: { area: string; entry: Tile }[] = []; +// ---- Интерактивы области: реакции + воксельные пропсы ---- +/** Сюжетные события custom (кат-сцены) — подключаются ниже, в сюжете. */ +let onCustomEvent: ((name: string) => void) | null = null; +const interactSink: InteractSink = { + hasItem: (id) => inv.has(id), + give: (id, count) => inv.add(id, count), + showToast, + onCustom: (name) => onCustomEvent?.(name), + // одноразовый объект поднят — проп убираем с поля + onConsumed: (def) => clearProp(world, def), +}; +let interactables: Interactables = new Interactables([], state, interactSink); + +// ---- Квест «Три цветка»: сбор у прудов, посадка, зелень у башни ---- +const flowers = new FlowerQuest( + state, + () => world, + () => layout, + () => currentArea.id, + showToast, +); + +function stampAreaProps(): void { + for (const def of currentArea.interactables ?? []) { + if (def.once && interactables.isUsed(def.id)) continue; // поднято — не возрождается + stampProp(world, def); + } +} + +/** Сюжетные custom-эффекты интерактивов (вне диалогов, как в v1). */ +onCustomEvent = (name: string): void => { + if (name === 'lull_machine') showToast('Машина дышит тише. Впервые за годы — тише.'); +}; + /** Смена области: новый мир + мешы, герой на входном тайле, NPC заново. */ function setArea(id: string, entry: Tile): void { currentArea = areaOf(id); @@ -151,6 +189,9 @@ disposeMeshes(); world = buildAreaWorld(layout); remeshDirty(); + interactables = new Interactables(currentArea.interactables ?? [], state, interactSink); + flowers.restore(); // правки цветков переживают пересборку мира + stampAreaProps(); spawnNpcs(); const c = tileCenter(entry.x, entry.y); hero.body.pos = [c.x, GROUND_Y, c.z]; @@ -159,6 +200,9 @@ prevTile = { ...entry }; // входной тайл — не «только что вошёл» (без триггера) nearClick = null; // подсказка старой области не должна пережить переход nearNpc = null; + nearInter = null; + nearFlower = null; + inHazard = null; showHint(''); renderer.setTarget([Math.round(c.x), GROUND_Y, Math.round(c.z)]); } @@ -182,6 +226,12 @@ let nearClick: TransitionDef | null = null; /** Ближайший NPC в радиусе подхода (для «E — Поговорить»). */ let nearNpc: NpcEntity | null = null; +/** Ближайший интерактивный объект (для «E — подпись»). */ +let nearInter: InteractableDef | null = null; +/** Ближайший собираемый цветок (тайл). */ +let nearFlower: { x: number; y: number } | null = null; +/** Зона наката под ногами в прошлом тике (тост при входе — один раз). */ +let inHazard: string | null = null; /** Тайл героя с прошлого тика (origin возврата + «только что вошёл»). */ let prevTile: Tile | null = null; @@ -230,9 +280,8 @@ let currentDialogueId: string | null = null; const runner = new DialogueRunner(state, makeDialogueView(), invWorld); -/** Эффекты сюжета «custom» из графов: посадка/настройка — трек 17, тост сейчас. */ +/** Эффекты сюжета «custom» из графов: тост сразу, посадка — катсцена. */ const CUSTOM_LINES: Record = { - plant_flowers: 'Поляна у тропы приняла цветы.', tune_resonator: 'Резонатор Клинта поймал тон.', }; @@ -242,7 +291,18 @@ 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); + // посадка — после реплик (катсцена поверх диалога не идёт, как в v1) + if (op.id === 'plant_flowers') pendingHandIn = true; + else showToast(CUSTOM_LINES[op.id] ?? op.id); + } +}; + +/** Катсцена сдачи отложена до конца диалога (v1: pendingCustom в onFinish). */ +let pendingHandIn = false; +runner.onFinish = () => { + if (pendingHandIn) { + pendingHandIn = false; + startHandInCutscene(); } }; @@ -264,7 +324,7 @@ updateHud(); } -/** Нажатие «E»: в диалоге — дальше; иначе NPC, иначе click-переход. */ +/** Нажатие «E»: диалог → NPC → click-переход → интерактив → цветок (v1). */ function interact(): void { if (runner.active) { if (!runner.waitingForChoice) runner.advance(); @@ -275,7 +335,20 @@ talkTo(nearNpc.def.id); return; } - if (nearClick && !fade.active) startTransition(nearClick, heroTile()); + if (nearClick && !fade.active) { + startTransition(nearClick, heroTile()); + return; + } + if (nearInter) { + const pick = interactables.tryInteract(nearInter); + if (pick && !pick.ok) showToast(pick.lockedText); // заперто: сундук/машина/тон + updateHud(); + return; + } + if (nearFlower) { + flowers.collect(nearFlower.x, nearFlower.y); + updateHud(); + } } // ---- HUD: журнал квестов (трекер) + сумка + тосты ---- @@ -317,6 +390,71 @@ setTimeout(() => el.remove(), 2600); } +// ---- Кат-сцены: стек шагов {dur, fn?, cam?}, мир замер, камера глайдом ---- +interface CutsceneStep { + /** Длительность шага (сек ручного времени цикла — детерминизм гейта). */ + dur: number; + /** Разовое действие в начале шага. */ + fn?: () => void; + /** Цель камеры (мир): глайд от точки старта шага. */ + cam?: [number, number, number]; +} +interface CutsceneState { + steps: CutsceneStep[]; + i: number; + t: number; + /** Откуда глайдит камера (позиция на старте шага). */ + from: [number, number, number] | null; +} +let cutscene: CutsceneState | null = null; + +/** Начать кат-сцену: первый шаг стартует сразу. */ +function playCutscene(steps: CutsceneStep[]): void { + cutscene = { steps, i: 0, t: 0, from: null }; + steps[0]?.fn?.(); +} + +/** Тик кат-сцены: таймер шага + глайд камеры; true — ещё играет. */ +function tickCutscene(dt: number): boolean { + if (!cutscene) return false; + const step = cutscene.steps[cutscene.i]!; + if (step.cam) { + if (!cutscene.from) cutscene.from = renderer.currentTarget(); + const f = cutscene.from!; + const s = step.cam; + const k = Math.min(1, cutscene.t / step.dur); + renderer.setTarget([ + Math.round(f[0] + (s[0] - f[0]) * k), + Math.round(f[1] + (s[1] - f[1]) * k), + Math.round(f[2] + (s[2] - f[2]) * k), + ]); + } + cutscene.t += dt; + if (cutscene.t < step.dur) return true; + cutscene.i += 1; + cutscene.t = 0; + cutscene.from = null; + const next = cutscene.steps[cutscene.i]; + if (!next) { + cutscene = null; + return false; + } + next.fn?.(); + return true; +} + +/** Катсцена сдачи цветов: посадка → камера к башне → зелень → финал акта 1. */ +function startHandInCutscene(): void { + const t = flowers.towerTile; + const tower: [number, number, number] = [t.x * UNIT + 1.5, GROUND_Y, t.y * UNIT + 1.5]; + playCutscene([ + { dur: 0.8, fn: () => flowers.plantFlowers() }, + { dur: 1.2, cam: tower }, + { dur: 1.2, fn: () => flowers.raiseTowerGreen() }, + { dur: 1.0, fn: () => showToast('Пепел отступил у тропы. Машина дышит тише... но дышит.') }, + ]); +} + /** Снапшот диалога для моста: узел/выборы/путь + флаги/вары + журнал. */ function dialogueSnapshot() { const node = runner.node; @@ -403,7 +541,10 @@ return { x: Math.floor(b[0] / UNIT), y: Math.floor(b[2] / UNIT) }; } -/** Один шаг логики: фейд → диалог/ввод → герой → камера → переходы → подсказка. */ +/** Радиус взаимодействия с интерактивами/цветками: 1.5 юнита v1 = 4.5 вокселя. */ +const INTERACT_RANGE = 4.5; + +/** Один шаг логики: фейд → катсцена → движение/накат → цели «E» → подсказка. */ function tick(dt: number): void { if (fade.active) { fade.t += dt; @@ -423,10 +564,44 @@ } return; // во время перехода мир замер } + if (cutscene) { + // катсцена: герой стоит, камера глайдит по шагам + if (!tickCutscene(dt)) { + const b = hero.body.pos; // конец: камера возвращается к герою + renderer.setTarget([Math.round(b[0]), GROUND_Y, Math.round(b[2])]); + } + heroCloud.update(hero.visual()); + showHint(''); + return; + } const talking = runner.active; // в диалоге герой стоит (как в v1: мир не тикается под репликой) const move: readonly [number, number] = talking ? [0, 0] : (agentInput ?? keyboardMove()); - hero.setMove(screenMoveToWorld(move, ISO_YAW)); + const wm = screenMoveToWorld(move, ISO_YAW); + const tile = heroTile(); + if (!talking) { + // зона наката под ногами: без полотна еле идём, вход — тост (один раз) + const hazard = + (currentArea.hazards ?? []).find((h) => + h.tiles.some((t) => t.x === tile.x && t.y === tile.y), + ) ?? null; + const masked = hazard !== null && inv.has(hazard.requiresItem); + if (hazard !== null && inHazard === null) { + showToast( + masked + ? `${hazard.name}: полотно держит — но пепел у самых губ.` + : `${hazard.name}! Дышать поверх — потерять голос.`, + ); + } + inHazard = hazard?.name ?? null; + if (hazard !== null && !masked) { + hero.setMove([wm[0] * 0.5, wm[1] * 0.5]); // ползём по свежему пеплу + } else { + hero.setMove(wm); + } + } else { + hero.setMove(wm); + } hero.step(world, dt); heroCloud.update(hero.visual()); if (followHero) { @@ -436,12 +611,44 @@ // 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; + nearInter = null; + nearFlower = null; + if (!talking) { + // интерактив рядом: ближайший в радиусе (поднятые once — мимо) + const b = hero.body.pos; + let best = Infinity; + for (const def of currentArea.interactables ?? []) { + if (def.once && interactables.isUsed(def.id)) continue; + const c = tileCenter(def.tile.x, def.tile.y); + const d = Math.hypot(c.x - b[0], c.z - b[2]); + if (d <= INTERACT_RANGE && d < best) { + best = d; + nearInter = def; + } + } + // цветы: скан окна ±2 тайла вокруг героя по сборному тайлу области + if (currentArea.harvestTile !== undefined) { + best = Infinity; + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + const tx = tile.x + dx; + const ty = tile.y + dy; + if (!flowers.isFlower(tx, ty)) continue; + const c = tileCenter(tx, ty); + const d = Math.hypot(c.x - b[0], c.z - b[2]); + if (d <= INTERACT_RANGE && d < best) { + best = d; + nearFlower = { x: tx, y: ty }; + } + } + } + } + } if (talking) { showHint(''); return; // диалог: без переходов и подсказок двери } // step-переход: только что вошёл на тайл-триггер - const tile = heroTile(); const justEntered = prevTile !== null && (tile.x !== prevTile.x || tile.y !== prevTile.y); if (justEntered) { const tr = currentArea.transitions.find( @@ -453,14 +660,20 @@ } } prevTile = tile; - // приоритет подсказки: 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(nearNpc ? 'E — Поговорить' : nearClick ? `E — ${nearClick.label ?? 'перейти'}` : ''); + // приоритет подсказки: NPC → переход → объект → цветок (как в v1) + showHint( + nearNpc ? 'E — Поговорить' + : nearClick ? `E — ${nearClick.label ?? 'перейти'}` + : nearInter ? `E — ${nearInter.label ?? nearInter.id}` + : nearFlower ? 'E — Сорвать лунный колокольчик' + : '', + ); } // ---- Цикл: фиксированный шаг 60 Гц, ручной режим для гейтов ---- @@ -561,9 +774,34 @@ interact(): void { interact(); }, - /** Снапшот взаимодействия: ближайшая дверь и подсказка. */ + /** Снапшот взаимодействия: ближайшая цель (приоритет v1) и подсказка. */ interactInfo() { - return { label: nearClick?.label ?? null, hint: hintText }; + const kind = + nearNpc ? 'npc' : nearClick ? 'transition' : nearInter ? 'interact' : nearFlower ? 'flower' : null; + return { + label: nearClick?.label ?? null, + hint: hintText, + kind, + near: nearNpc?.def.id ?? nearInter?.id ?? (nearFlower ? 'flower' : null), + target: nearInter + ? { id: nearInter.id, kind: nearInter.kind, tile: nearInter.tile } + : null, + flower: nearFlower, + }; + }, + /** Слот вокселя мира (гейт: пропы, цветы, вода). */ + voxAt(x: number, y: number, z: number): number { + return world.get(Math.floor(x), Math.floor(y), Math.floor(z)); + }, + /** Тайлы области с лунными колокольчиками (гейт квеста цветов). */ + flowerTiles(): { x: number; y: number }[] { + const out: { x: number; y: number }[] = []; + for (let y = 0; y < layout.height; y++) { + for (let x = 0; x < layout.width; x++) { + if (flowers.isFlower(x, y)) out.push({ x, y }); + } + } + return out; }, /** NPC области: кто стоит, тайлы, кто ближайший для «E». */ npcInfo() { diff --git a/v2/apps/game/src/props.ts b/v2/apps/game/src/props.ts new file mode 100644 index 0000000..50f5c17 --- /dev/null +++ b/v2/apps/game/src/props.ts @@ -0,0 +1,70 @@ +/** + * Воксельные пропсы интерактивов (визуал на месте «вьюх» v1): столбы у + * троп, моты (тёплая искра — жизнь!), сундук, записка, прилавок, верёвка + * колокола, резонатор. Штамп строго в тайл 3×3 (урок трека 15: вылезать + * на соседей нельзя). clearProp снимает проп и возвращает землю. + */ +import type { VoxelWorld } from '@rpg/engine'; +import type { InteractableDef } from './data/interactables'; +import { GROUND_Y, UNIT } from './areas'; + +// Слоты палитры сцены (общие с main.ts) +const WOOD = 4; // R1 — дерево тёмное +const WOOD_L = 8; // R2 — светлое дерево +const METAL = 10; // M1 — металл +const SPARK = 9; // B1 — тёплая искра (жизнь!) +const PAPER = 7; // P6 — свет камня (бумага) + +/** Проп существует? (моты — искра; остальное — телеса) */ +export function hasProp(def: InteractableDef): boolean { + return def.kind === 'pickup' || def.kind === 'signpost' || def.kind === 'container' + || def.id === 'bell_rope' || def.id === 'note_elder' || def.id === 'clint_resonator'; +} + +/** Поставить проп интерактива в его тайл (детерминированно, без ГПСЧ). */ +export function stampProp(world: VoxelWorld, def: InteractableDef): void { + if (!hasProp(def)) return; + const bx = def.tile.x * UNIT + 1; // центр тайла + const bz = def.tile.y * UNIT + 1; + if (def.kind === 'pickup') { + world.set(bx, GROUND_Y, bz, SPARK); // мот: одна тёплая искра + return; + } + if (def.kind === 'signpost') { + world.set(bx, GROUND_Y, bz, WOOD); + world.set(bx, GROUND_Y + 1, bz, WOOD_L); // доска + return; + } + if (def.kind === 'container') { + // сундук: деревянный ящик с металлической кромкой + world.set(bx, GROUND_Y, bz, WOOD); + world.set(bx, GROUND_Y + 1, bz, METAL); + return; + } + if (def.id === 'note_elder') { + world.set(bx, GROUND_Y, bz, PAPER); // записка: светлый клочок на полу + return; + } + if (def.id === 'bell_rope') { + // верёвка у башни: столбик с деревянной ручкой + world.set(bx, GROUND_Y, bz, METAL); + world.set(bx, GROUND_Y + 1, bz, WOOD); + return; + } + if (def.id === 'clint_resonator') { + // резонатор: медный короб на стальной раме + world.set(bx, GROUND_Y, bz, METAL); + world.set(bx, GROUND_Y + 1, bz, WOOD_L); + world.set(bx + 1, GROUND_Y, bz, METAL); + return; + } +} + +/** Убрать проп (подбор мота/сундука): снять штамп, вернуть землю. */ +export function clearProp(world: VoxelWorld, def: InteractableDef): void { + if (!hasProp(def)) return; + for (let dz = 0; dz < UNIT; dz++) + for (let dx = 0; dx < UNIT; dx++) + for (let y = GROUND_Y; y <= GROUND_Y + 1; y++) + world.set(def.tile.x * UNIT + dx, y, def.tile.y * UNIT + dz, 0); +} \ No newline at end of file diff --git a/v2/apps/game/tools/checks/interact.mjs b/v2/apps/game/tools/checks/interact.mjs new file mode 100644 index 0000000..26ff870 --- /dev/null +++ b/v2/apps/game/tools/checks/interact.mjs @@ -0,0 +1,280 @@ +/** + * Скриншот-гейт интерактива и квеста «Три цветка» v2 (трек 17): столбы у + * троп, подборные моты (once → проп убран, кандидат исчез), верёвка + * колокола (ветки notFlag), сундук/записка/очаг дома, Старая Машина + * (заперто → усыплена), зоны наката (без полотна — тост и ползём, с + * полотном — полный ход), сбор колокольчиков у прудов, катсцена сдачи + * (посадка поляны у тропы, камера к башне). Скриншоты: /tmp/v2_interact_*.png. + * + * Запуск: node apps/game/tools/checks/interact.mjs [--port 5316] + */ +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 ?? 5316); + +const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) }); +const report = { name: 'interact', 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; +} + +/** Центр тайла v1 в вокселях (tile*3 + 1.5). */ +const center = (t) => ({ x: t.x * 3 + 1.5, z: t.y * 3 + 1.5 }); +/** Воксель цветка/мота тайла ([tx*3+1, GROUND_Y, ty*3+1]). */ +const spotVoxel = (t) => [t.x * 3 + 1, 2, t.y * 3 + 1]; + +/** Прошагать с зажатым стиком и вернуть пройденную дистанцию по x. */ +async function walkX(client, sx, sy, ticks) { + const a = await client.call('heroInfo'); + await client.call('setInput', sx, sy); + await client.call('stepTicks', ticks); + await client.call('setInput', null, null); + const b = await client.call('heroInfo'); + return b.pos[0] - a.pos[0]; +} + +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); + + // ---- Луга: столб у тропы (подсказка + реакция-тост) ---- + const signC = center({ x: 48, y: 27 }); + await client.call('heroTeleport', signC.x - 3, signC.z); + await client.call('stepTicks', 3); + const ii1 = await client.call('interactInfo'); + check('столб у тропы: кандидат «interact», подсказка с подписью', + ii1.kind === 'interact' && ii1.near === 'sign_meadows_east' + && ii1.target.kind === 'signpost' && ii1.hint === 'E — Столб у тропы', + { kind: ii1.kind, near: ii1.near, hint: ii1.hint }); + await client.call('interact'); + const st1 = await client.call('dialogueInfo'); + check('столб: тост-реакция («иди на звон»)', + st1.toasts.some((t) => t.includes('Звенец') && t.includes('звон')), { toasts: st1.toasts }); + + // ---- Луга: мот (pickup, once) ---- + const moteTile = { x: 13, y: 28 }; + const moteC = center(moteTile); + const mv = spotVoxel(moteTile); + const motBefore = await client.call('voxAt', mv[0], mv[1], mv[2]); + await client.call('heroTeleport', moteC.x + 3, moteC.z); + await client.call('stepTicks', 3); + const mv2 = await client.call('dialogueInfo'); + await client.call('interact'); + const motAfter = await client.call('dialogueInfo'); + const mvVox = await client.call('voxAt', mv[0], mv[1], mv[2]); + check('мот: искра на поле, подбор дал предмет и мот', + motBefore === 9 && !(mv2.vars.motes > 0) && motAfter.vars.motes === 1 + && motAfter.inv.some((s) => s.id === 'mote') && mvVox === 0 + && motAfter.toasts.some((t) => t.includes('Мот') || t.includes('мот')), + { before: motBefore, after: mvVox, motes: motAfter.vars.motes }); + await client.call('stepTicks', 3); + const ii2 = await client.call('interactInfo'); + check('мот поднят: кандидат исчез (once-used вне радиуса-списка)', + ii2.kind === null && ii2.flower === null, ii2.kind); + + // ---- Пруды: столб, цветок, накат ---- + await client.call('gotoArea', 'ponds'); + await client.call('stepTicks', 5); + const pSignC = center({ x: 5, y: 3 }); + await client.call('heroTeleport', pSignC.x + 3, pSignC.z); + await client.call('stepTicks', 3); + await client.call('interact'); + const st2 = await client.call('dialogueInfo'); + check('столб у берега: тост с припиской', st2.toasts.some((t) => t.includes('голос дороже соли')), {}); + + // цветок: сборный тайл (12,34) — «E — Сорвать», воксель снят, вар +1 + const flTile = { x: 12, y: 34 }; + const flC = center(flTile); + const fv = spotVoxel(flTile); + const flBefore = await client.call('voxAt', fv[0], fv[1], fv[2]); + await client.call('heroTeleport', flC.x + 3, flC.z); + await client.call('stepTicks', 3); + const ii3 = await client.call('interactInfo'); + check('цветок: кандидат «flower» с тайлом и подсказкой', + ii3.kind === 'flower' && ii3.flower.x === 12 && ii3.flower.y === 34 + && ii3.hint.includes('Сорвать'), + { kind: ii3.kind, flower: ii3.flower, hint: ii3.hint }); + await client.call('interact'); + const flAfter = await client.call('dialogueInfo'); + const flVox = await client.call('voxAt', fv[0], fv[1], fv[2]); + const flTiles = await client.call('flowerTiles'); + check('сбор: вар 1/3, воксель снят, тайл больше не цветок', + flBefore === 9 && flAfter.vars.flowers === 1 && flVox === 0 + && !flTiles.some((t) => t.x === 12 && t.y === 34) + && flAfter.toasts.some((t) => t.includes('Лунный колокольчик (1/3)')), + { before: flBefore, after: flVox, flowers: flAfter.vars.flowers }); + + // накат без полотна: тост при входе + ползём вдвое медленнее + const hzC = center({ x: 10, y: 32 }); + await client.call('heroTeleport', hzC.x, hzC.z); + await client.call('stepTicks', 3); + const hz1 = await client.call('dialogueInfo'); + check('вход в накат: тост-предупреждение (без полотна)', + hz1.toasts.some((t) => t.includes('Свежий накат') && t.includes('потерять голос')), + { toasts: hz1.toasts }); + const dxSlow = Math.abs(await walkX(client, -1, -1, 30)); + + // ---- Дом Ирвина: сундук (полотно), записка, очаг до сдачи ---- + await client.call('gotoArea', 'house_elder'); + await client.call('stepTicks', 5); + const chestC = center({ x: 3, y: 3 }); + await client.call('heroTeleport', chestC.x + 3, chestC.z); + await client.call('stepTicks', 3); + await client.call('interact'); + const chest = await client.call('dialogueInfo'); + check('сундук: полотно в сумке, проп убран', + chest.inv.some((s) => s.id === 'cloth') + && (await client.call('voxAt', chestC.x - 0.5, 2, chestC.z - 0.5)) === 0, + { inv: chest.inv.map((s) => s.id) }); + const noteC = center({ x: 10, y: 4 }); + await client.call('heroTeleport', noteC.x + 3, noteC.z); + await client.call('stepTicks', 3); + await client.call('interact'); + const note = await client.call('dialogueInfo'); + check('записка: read_note + тост «не буди Молчание»', + note.flags.includes('read_note') + && note.toasts.some((t) => t.includes('Молчание')), {}); + const hearthC = center({ x: 8, y: 0 }); + await client.call('heroTeleport', hearthC.x + 3, hearthC.z + 1.5); + await client.call('stepTicks', 3); + await client.call('interact'); + const hearth = await client.call('dialogueInfo'); + check('очаг до сдачи: «тлеют щепки» (ветка без флага)', + hearth.toasts.some((t) => t.includes('тлеют щепки')), { toasts: hearth.toasts }); + + // ---- Пруды с полотном: маскированный тост + полный ход ---- + await client.call('gotoArea', 'ponds'); + await client.call('stepTicks', 5); + await client.call('heroTeleport', hzC.x, hzC.z); + await client.call('stepTicks', 3); + const hz2 = await client.call('dialogueInfo'); + const dxFast = Math.abs(await walkX(client, -1, -1, 30)); + check('накат с полотном: тост «полотно держит», ход в ~2 раза быстрее', + hz2.toasts.some((t) => t.includes('полотно держит')) && dxFast > dxSlow * 1.7, + { dxSlow: Math.round(dxSlow * 100) / 100, dxFast: Math.round(dxFast * 100) / 100 }); + + // ---- Звенец: верёвка колокола (две ветки) ---- + await client.call('gotoArea', 'zvenets'); + await client.call('stepTicks', 5); + const ropeC = center({ x: 27, y: 13 }); + await client.call('heroTeleport', ropeC.x + 3, ropeC.z); + await client.call('stepTicks', 3); + await client.call('interact'); + const rope1 = await client.call('dialogueInfo'); + check('верёвка: первый звон ставит rang_tower', + rope1.flags.includes('rang_tower') + && rope1.toasts.some((t) => t.includes('ушёл в землю')), {}); + await client.call('interact'); + const rope2 = await client.call('dialogueInfo'); + check('верёвка: повторный звон — ветка «пол-тона ниже»', + rope2.toasts.some((t) => t.includes('пол-тона ниже')), { toasts: rope2.toasts }); + + // ---- Сдача цветов: катсцена (посадка, камера к башне, зелень) ---- + const plantTile = { x: 24, y: 22 }; + const plantC = center(plantTile); + await client.call('heroTeleport', plantC.x, plantC.z); + await client.call('stepTicks', 3); + const shotBefore = await client.screenshot('/tmp/v2_interact_before.png'); + // квест ещё не взят (знакомств не было) — первый диалог Ирвина + const dQ = await client.call('talkTo', 'elder'); + check('до знакомства — elder_first (окно сдачи закрыто)', + dQ.graphId === 'elder_first', dQ.graphId); + await client.call('dialogueAdvance'); // ask + await client.call('dialogueAdvance'); // task (выборы) + await client.call('dialoguePick', 0); // → give (флаги, часы/карта) + await client.call('dialogueAdvance'); // ring + const dQEnd = await client.call('dialogueAdvance'); // конец + check('квест взят (elder_first пройден до конца)', + !dQEnd.active && dQEnd.path.length === 5, { path: dQEnd.path }); + 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 → pendingHandIn) + await client.call('dialogueAdvance'); // ring + await client.call('dialogueAdvance'); // конец → onFinish → катсцена + await client.call('stepTicks', 84); // 0.8 с посадка + половина глайда + const camMid = await client.call('heroInfo'); + const heroMid = camMid.tile; + check('катсцена: герой стоит, камера глайдит к башне', + heroMid.x === 24 && heroMid.y === 22 && camMid.camTarget[0] > 78 + && camMid.camTarget[2] < 58, + { tile: heroMid, cam: camMid.camTarget }); + await client.call('stepTicks', 260); // конец катсцены + возврат камеры + const fin = await client.call('dialogueInfo'); + const finTiles = await client.call('flowerTiles'); + const pv = spotVoxel(plantTile); + const plantVox = await client.call('voxAt', pv[0], pv[1], pv[2]); + check('сдача: цветы посажены (вар 0, тайлы цветут, воксель на месте)', + fin.vars.flowers === 0 + && [[24, 22], [25, 23], [24, 24]].every(([x, y]) => finTiles.some((t) => t.x === x && t.y === y)) + && plantVox === 9, + { flowers: fin.vars.flowers, planted: finTiles.length, vox: plantVox }); + check('финал акта 1 в тостах', + fin.toasts.some((t) => t.includes('Цветы в земле')) + && fin.toasts.some((t) => t.includes('Пепел отступил у тропы')), + { toasts: fin.toasts }); + const shotAfter = decodePng(readFileSync(await client.screenshot('/tmp/v2_interact_after.png'))).rgba; + const plantDiff = countDiff(decodePng(readFileSync(shotBefore)).rgba, shotAfter); + check('поляна рисуется (кадр изменился)', plantDiff > 100, { plantDiff }); + + // ---- Зал Машины: заперто → настроен → усыплена ---- + await client.call('gotoArea', 'machine_hall'); + await client.call('stepTicks', 5); + const machC = center({ x: 13, y: 1 }); + await client.call('heroTeleport', machC.x, machC.z + 3); + await client.call('stepTicks', 3); + await client.call('interact'); + const mach1 = await client.call('dialogueInfo'); + check('Машина без резонатора: «не спеть»', + mach1.toasts.some((t) => t.includes('не спеть')), { toasts: mach1.toasts }); + await client.call('setState', { flags: ['quest_resonator_done'] }); + await client.call('interact'); + const mach2 = await client.call('dialogueInfo'); + check('правильный звон: machine_lulled + тост-финал акта 2', + mach2.flags.includes('machine_lulled') + && mach2.toasts.some((t) => t.includes('Впервые за годы — тише')), {}); + await client.call('interact'); + const mach3 = await client.call('dialogueInfo'); + check('Машина спит (ветка machine_lulled)', + mach3.toasts.some((t) => t.includes('Машина спит')), {}); + + // ---- Очаг после сдачи: ветка с флагом ---- + await client.call('gotoArea', 'house_elder'); + await client.call('stepTicks', 5); + await client.call('heroTeleport', hearthC.x + 3, hearthC.z + 1.5); + await client.call('stepTicks', 3); + await client.call('interact'); + const hearth2 = await client.call('dialogueInfo'); + check('очаг после сдачи: «тепло возвращается»', + hearth2.toasts.some((t) => t.includes('тепло возвращается')), {}); + } 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