diff --git a/apps/game/src/data/dialogues.ts b/apps/game/src/data/dialogues.ts index fd6d35a..c9860f8 100644 --- a/apps/game/src/data/dialogues.ts +++ b/apps/game/src/data/dialogues.ts @@ -7,6 +7,7 @@ import traderAfter from './dialogues/trader_after.json'; import clintFirst from './dialogues/clint_first.json'; import clintRepeat from './dialogues/clint_repeat.json'; +import clintTune from './dialogues/clint_tune.json'; /** * Диалоги NPC как графы (по docs/world.md, акты 1–2). Источник истины — JSON @@ -23,5 +24,6 @@ trader_repeat: traderRepeat as unknown as DialogueGraph, trader_after: traderAfter as unknown as DialogueGraph, clint_first: clintFirst as unknown as DialogueGraph, - clint_repeat: clintRepeat as unknown as DialogueGraph + clint_repeat: clintRepeat as unknown as DialogueGraph, + clint_tune: clintTune as unknown as DialogueGraph }; \ No newline at end of file diff --git a/apps/game/src/data/dialogues/clint_first.json b/apps/game/src/data/dialogues/clint_first.json index b57dba0..f30a8fb 100644 --- a/apps/game/src/data/dialogues/clint_first.json +++ b/apps/game/src/data/dialogues/clint_first.json @@ -24,12 +24,17 @@ }, "offer": { "speaker": "Клинт", - "text": "Постучишь, если доживу до починки? Звон любит терпеливых.", + "text": "Деревья в роще — как механизмы, кабелями оплетены. Прозвени их: кто отзовётся — тот и тон даст.", + "setFlags": ["quest_resonator_taken"], "next": "reply" }, "reply": { "speaker": "Звонарь", "text": "*кивает*" + }, + "reply": { + "speaker": "Звонарь", + "text": "*кивает*" } } } \ No newline at end of file diff --git a/apps/game/src/data/dialogues/clint_tune.json b/apps/game/src/data/dialogues/clint_tune.json new file mode 100644 index 0000000..e0a64fc --- /dev/null +++ b/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/apps/game/src/data/effects.ts b/apps/game/src/data/effects.ts index 87304c9..3b57873 100644 --- a/apps/game/src/data/effects.ts +++ b/apps/game/src/data/effects.ts @@ -4,7 +4,8 @@ * — общие и сюда не входят. */ export const DIALOGUE_CUSTOM = { - plant_flowers: 'plant_flowers' // сдача «Трёх цветков»: кат-сцена посадки + plant_flowers: 'plant_flowers', // сдача «Трёх цветков»: кат-сцена посадки + tune_resonator: 'tune_resonator' // сдача резонатора: кат-сцена настройки } as const; export type DialogueCustomId = keyof typeof DIALOGUE_CUSTOM; \ No newline at end of file diff --git a/apps/game/src/data/ids.ts b/apps/game/src/data/ids.ts index 209ff7d..375e7cb 100644 --- a/apps/game/src/data/ids.ts +++ b/apps/game/src/data/ids.ts @@ -30,8 +30,18 @@ hint_bells: 'hint_bells', /** Акт 2 начался: Мила указала на разъезд («Машина дышит», trader_after). */ act2_hook: 'act2_hook', - /** Познакомились с Клинтом (диалог clint_first). */ - met_clint: 'met_clint' + /** Познакомились с Клинтом (диалог 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', + /** Дерево-механизм №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' } as const; export type FlagId = keyof typeof FLAGS; @@ -49,7 +59,9 @@ /** Смерти героя (статистика). */ deaths: 'deaths', /** Резонансы башни (кат-сцена, статистика). */ - resonances: 'resonances' + resonances: 'resonances', + /** Собранные тоны деревьев-механизмов (квест резонатора). */ + tones: 'tones' } as const; export type VarId = keyof typeof VARS; diff --git a/apps/game/src/data/interactables.ts b/apps/game/src/data/interactables.ts index 51402f8..b3efe80 100644 --- a/apps/game/src/data/interactables.ts +++ b/apps/game/src/data/interactables.ts @@ -2,6 +2,7 @@ import type { ItemId } from './items'; import type { AreaId } from './locations'; import { FLAGS, VARS } from './ids'; +import { QUEST_TONE_TILES } from './quests'; /** * Интерактивные объекты области: сундуки, очаги, записки, прилавки, столбы @@ -177,6 +178,57 @@ ] } ], + // --- роща (акт 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: 'Дерево гудит в лад с резонатором. Кабели тёплые.', + sound: 'sfx/bell_low' + }, + { + when: { notFlag: FLAGS.quest_resonator_taken }, + text: 'Кабели оплетают ствол, но дерево молчит. Не сейчас.', + sound: 'sfx/ash_hiss' + }, + { + when: { notFlag: FLAGS[id] }, + text: 'Звон колокольчика — и кабели отвечают: дерево отдаёт свой тон.', + setFlags: [FLAGS[id]], + addVar: { id: VARS.tones, by: 1 }, + sound: 'sfx/bell_hit' + }, + { text: 'Это дерево уже прозвенело. Тон унесён.', sound: 'sfx/note' } + ] + }; + }), + { + id: 'clint_resonator', + pos: { x: 12.5, y: 8.5 }, + kind: 'lever', + label: 'Резонатор Клинта', + responses: [ + { + when: { flag: FLAGS.quest_resonator_done }, + text: 'Резонатор держит тон ровно. Клинт поддел его под пол-тона.', + sound: 'sfx/bell_low' + }, + { + text: 'Медные колокола на стальной раме. Ржавый, молчит. Клинту нужен верный звон.', + sound: 'sfx/ash_hiss' + } + ] + } + ], ponds: [ { id: 'sign_ponds', diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 0e10653..48f41a6 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -341,6 +341,7 @@ { tile: { x: 22, y: 10 }, target: { kind: 'area', area: 'old_siding', entry: { x: 2, y: 10 } } } ], atmosphere: 'ash', + interactables: INTERACTABLES.rust_grove, // Роща темнее лугов: мёртвый лес глушит свет; вечером — глухая синева. lighting: { ambient: 0xb0b4c0, nightAmbient: 0x565c74 }, // Ветер в проводах: гул башни не слышен, только кабельный звон. diff --git a/apps/game/src/data/quests.ts b/apps/game/src/data/quests.ts index 960b921..a58ebaa 100644 --- a/apps/game/src/data/quests.ts +++ b/apps/game/src/data/quests.ts @@ -30,6 +30,16 @@ [14, 9] ]; +/** Квест резонатора (акт 2): сколько тонов собрать у деревьев-механизмов. */ +export const QUEST_TONES = 3; + +/** WIRE-деревья рощи для звона (тайлы интерактивов tone_tree_1..3; достижимы от входа). */ +export const QUEST_TONE_TILES: readonly (readonly [number, number])[] = [ + [16, 3], + [6, 5], + [10, 15] +]; + /** Одна стадия квеста. */ export interface QuestStage { /** Чей диалог ведёт стадию (id из NpcDef). */ @@ -83,6 +93,36 @@ dialogue: 'trader_after' } ] + }, + { + // Акт 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' + } + ] } ]; diff --git a/apps/game/src/data/validate.ts b/apps/game/src/data/validate.ts index 51dab4e..6b5e780 100644 --- a/apps/game/src/data/validate.ts +++ b/apps/game/src/data/validate.ts @@ -237,7 +237,8 @@ for (const [i, stage] of quest.stages.entries()) { if (stage.doneFlag !== undefined) checkFlag(stage.doneFlag, where, `стадия#${i}.doneFlag`); if (stage.progressKey !== undefined) checkVar(stage.progressKey, where, `стадия#${i}.progressKey`); - if (!NPCS.some((n) => n.id === stage.npc)) { + // NPC квеста — из основной шпаргалки или размещений по флагам. + if (![...NPCS, ...NPC_PLACEMENTS.map((p) => p.def)].some((n) => n.id === stage.npc)) { out.push({ id: 'npc-ref', severity: 'error', message: `стадия#${i}: NPC «${stage.npc}» не существует`, where }); } if (stage.dialogue !== undefined && !(stage.dialogue in DIALOGUES)) { diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 9425ed4..cdeb6ef 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -58,6 +58,7 @@ import { LocationHud } from '../systems/LocationHud'; import { Atmosphere } from '../systems/Atmosphere'; import { FlowerQuest } from '../systems/quest/FlowerQuest'; +import { ResonatorQuest } from '../systems/quest/ResonatorQuest'; import { enemyTextures, heroTextures, tileTextures } from '../data/assetSets'; /** Лампа героя: тёплый, тихий, слегка мерцает (сумерки мира). */ @@ -83,6 +84,8 @@ private atmosphere: Atmosphere; /** Квест «Три цветка»: сбор/посадка/кат-сцена сдачи. */ private flowers: FlowerQuest; + /** Квест резонатора (акт 2): кат-сцена настройки у Клинта. */ + private resonator: ResonatorQuest; /** Вьюхи интерактивных объектов и NPC (фабрики + растворение). */ private interactViews: InteractableViews; /** Мигание при неуязвимости — процедурный blink (duty живой). */ @@ -322,6 +325,15 @@ this.cutscene ); + // Квест резонатора (акт 2): сбор тона — интерактивы рощи, сдача — катсцена. + this.resonator = new ResonatorQuest( + this.game, + (tx, ty) => this.tileCenter(tx, ty), + (s) => this.combatViews.hitBurst(s), + (t) => this.showToast(t), + this.cutscene + ); + // Маршрутизация клика/переходов/отложенных взаимодействий. this.router = new InteractionRouter({ game: this.game, @@ -724,6 +736,10 @@ this.pendingCustom.delete(DIALOGUE_CUSTOM.plant_flowers); this.flowers.startHandInCutscene(); } + if (this.pendingCustom.has(DIALOGUE_CUSTOM.tune_resonator)) { + this.pendingCustom.delete(DIALOGUE_CUSTOM.tune_resonator); + this.resonator.startTuneCutscene(); + } } /** Всплывающая подсказка: появляется, держится, растворяется вверх (FxLayer). */ diff --git a/apps/game/src/systems/quest/ResonatorQuest.ts b/apps/game/src/systems/quest/ResonatorQuest.ts new file mode 100644 index 0000000..82f2477 --- /dev/null +++ b/apps/game/src/systems/quest/ResonatorQuest.ts @@ -0,0 +1,57 @@ +import { CutsceneRunner, tileToWorld, worldToScreen } from '@rpg/engine'; +import { Game } from '../../Game'; +import { FLAGS, type FlagId } from '../../data/ids'; +import { QUEST_TONE_TILES } from '../../data/quests'; + +/** + * Квест «Голос для резонатора» (docs/world.md, акт 2): звон у деревьев- + * механизмов рощи (интерактивы, тон — варом через реакции) и кат-сцена + * настройки — резонатор ловит собранные тоны, роща отвечает. + */ +export class ResonatorQuest { + constructor( + private game: Game, + /** Экранный центр ромба тайла (с Origin карты). */ + private tileCenter: (tx: number, ty: number) => { x: number; y: number }, + /** Всплеск пеплинок в точке (CombatViews.hitBurst). */ + private burst: (s: { x: number; y: number }) => void, + private showToast: (text: string) => void, + private cutscene: CutsceneRunner + ) {} + + /** Кат-сцена настройки: камера к резонатору Клинта, звон, ответ рощи. */ + startTuneCutscene(): void { + // Резонатор у поляны Клинта (интерактив clint_resonator). + const at = tileToWorld(12, 8); + const s = worldToScreen(at.x, at.y); + this.cutscene.play([ + // Камера подходит и наезжает: настройка — крупный план. + { kind: 'cameraMove', x: at.x, y: at.y, seconds: 1, camera: this.game.camera }, + { kind: 'cameraZoom', camera: this.game.camera, z: 2, seconds: 0.8 }, + // Резонатор ловит тон: звон, тряска, всплеск. + { + kind: 'burst', + fn: () => { + void this.game.audio.play('sfx/bell_low', 1); + this.game.camera.addShake(2, 0.4); + this.burst(s); + }, + seconds: 1 + }, + // Роща отвечает: прозвеневшие деревья вспыхивают в такт. + { + kind: 'burst', + fn: () => { + QUEST_TONE_TILES.forEach(([tx, ty], i) => { + if (!this.game.state.hasFlag(FLAGS[`tone_tree_${i + 1}` as FlagId])) return; + this.burst(this.tileCenter(tx, ty)); + }); + }, + seconds: 1.2 + }, + // Отъезд и тост-финал квеста. + { kind: 'cameraZoom', camera: this.game.camera, z: 1, seconds: 0.6 }, + { kind: 'call', fn: () => this.showToast('Резонатор держит тон. Роща слышит.') } + ]); + } +} \ No newline at end of file diff --git a/apps/game/tools/checks/resonator.mjs b/apps/game/tools/checks/resonator.mjs new file mode 100644 index 0000000..80e45a5 --- /dev/null +++ b/apps/game/tools/checks/resonator.mjs @@ -0,0 +1,95 @@ +/** + * Сценарий resonator — полный цикл квеста «Голос для резонатора» (акт 2). + * 1) крючок акта 2: флаг act2_hook -> Клинт в Ржавой роще; + * 2) Клинт: clint_first -> флаги met_clint + quest_resonator_taken; + * 3) звон у трёх деревьев-механизмов (интерактивы tone_tree_N): + * vars.tones растёт до 3, деревья помечены флагами; + * 4) сдача: clint_tune (квест-стадия ready) -> quest_resonator_done, + * кат-сцена настройки: тост «Резонатор держит тон», конец; + * 5) инварианты чисты после полного квеста. + * Запуск: node tools/agent.mjs run tools/checks/resonator.mjs + */ +import { withChecks } from '../lib.mjs'; + +/** Тайлы деревьев-механизмов (data/quests.ts QUEST_TONE_TILES). */ +const TREES = [ + [16, 3], + [6, 5], + [10, 15] +]; +/** Тайл Клинта в роще (npcs.ts placement). */ +const CLINT = { x: 11, y: 9 }; + +export default async function ({ pretty }) { + return withChecks( + 'resonator', + async (t) => { + const { c } = t; + await c.run('крючок акта 2: Клинт появляется в роще', async () => { + await t.boot(); + await t.sleepEnemies(); + await t.goToArea('zvenets', { x: 26, y: 14 }); + await t.sleepEnemies(); + await t.ctx.agent.command('scene:setFlag', { flag: 'act2_hook' }); + // К восточному переходу — телепортом в Звенце: пешком из юго-восточного + // спавна маршрут уводит через город и мост не доживает. + await t.ctx.agent.command('scene:teleport', { x: 25, y: 10 }); + await t.goToArea('rust_grove', { x: 26, y: 10 }); + await t.sleepEnemies(); + const s = await t.ctx.agent.snapshot(); + c.expect((s.npcs ?? []).some((n) => n.id === 'clint'), 'Клинта нет в роще после act2_hook', s.npcs); + return null; + }); + await c.run('Клинт: clint_first, флаги знакомства и взятия квеста', async () => { + await t.ctx.agent.tapTile(CLINT.x, CLINT.y); + const o = await t.ctx.agent.waitFor('s.dialogue?.id === "clint_first"', { timeoutTicks: 2400 }); + c.expect(o.ok, 'диалог clint_first не открылся', o.snapshot.dialogue); + const d = await t.ctx.agent.runDialogue(900); + c.expect(d, 'диалог clint_first не завершился'); + const w = await t.ctx.agent.waitFor( + 's.flags.includes("met_clint") && s.flags.includes("quest_resonator_taken")', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'граф не поднял флаги знакомства и взятия квеста', w.snapshot.flags); + return null; + }); + await c.run('звон у деревьев-механизмов: tones 0 -> 3', async () => { + for (const [i, [tx, ty]] of TREES.entries()) { + await t.ctx.agent.tapTile(tx, ty); + const w = await t.ctx.agent.waitFor( + `s.vars.tones === ${i + 1} && s.flags.includes("tone_tree_${i + 1}")`, + { timeoutTicks: 2400 } + ); + c.expect(w.ok, `дерево (${tx},${ty}) не отдало тон`, { vars: w.snapshot.vars }); + } + return null; + }); + await c.run('сдача: clint_tune, кат-сцена настройки, флаг done', async () => { + // Звон у деревьев шумит: краулер у (10,15) просыпается и может + // убить героя по дороге к Клинту — усыпляем перед сдачей. + await t.sleepEnemies(); + await t.ctx.agent.tapTile(CLINT.x, CLINT.y); + const o = await t.ctx.agent.waitFor('s.dialogue?.id === "clint_tune"', { timeoutTicks: 2400 }); + c.expect(o.ok, 'квест-стадия не дала clint_tune', o.snapshot.dialogue); + const d = await t.ctx.agent.runDialogue(900); + c.expect(d, 'диалог clint_tune не завершился'); + const w = await t.ctx.agent.waitFor('s.flags.includes("quest_resonator_done")', { + timeoutTicks: 300 + }); + c.expect(w.ok, 'нет флага сдачи резонатора', w.snapshot.flags); + // Кат-сцена настройки: тост и конец (клики блокируются до её конца). + const toast = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("держит тон")', { + timeoutTicks: 900 + }); + c.expect(toast.ok, 'нет тоста настройки', toast.snapshot.lastToast); + await t.ctx.agent.waitFor('!s.cutscene?.active', { timeoutTicks: 900 }); + return null; + }); + await c.run('инварианты чисты после полного квеста', async () => { + await t.expectInvariantsClean('после квеста резонатора'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file