diff --git a/apps/game/src/data/effects.ts b/apps/game/src/data/effects.ts index 3b57873..08e7463 100644 --- a/apps/game/src/data/effects.ts +++ b/apps/game/src/data/effects.ts @@ -5,7 +5,8 @@ */ export const DIALOGUE_CUSTOM = { plant_flowers: 'plant_flowers', // сдача «Трёх цветков»: кат-сцена посадки - tune_resonator: 'tune_resonator' // сдача резонатора: кат-сцена настройки + tune_resonator: 'tune_resonator', // сдача резонатора: кат-сцена настройки + lull_machine: 'lull_machine' // Старая Машина: кат-сцена правильного звона } as const; export type DialogueCustomId = keyof typeof DIALOGUE_CUSTOM; \ No newline at end of file diff --git a/apps/game/src/data/ids.ts b/apps/game/src/data/ids.ts index 375e7cb..3b18463 100644 --- a/apps/game/src/data/ids.ts +++ b/apps/game/src/data/ids.ts @@ -41,7 +41,9 @@ /** Дерево-механизм №2 прозвенело (интерактив tone_tree_2). */ tone_tree_2: 'tone_tree_2', /** Дерево-механизм №3 прозвенело (интерактив tone_tree_3). */ - tone_tree_3: 'tone_tree_3' + tone_tree_3: 'tone_tree_3', + /** Старая Машина уложена спать правильным звоном (интерактив old_machine). */ + machine_lulled: 'machine_lulled' } as const; export type FlagId = keyof typeof FLAGS; diff --git a/apps/game/src/data/interactables.ts b/apps/game/src/data/interactables.ts index b3efe80..994cf77 100644 --- a/apps/game/src/data/interactables.ts +++ b/apps/game/src/data/interactables.ts @@ -36,6 +36,8 @@ count?: number; /** Ключ звука (sfx/...). */ sound?: string; + /** Сюжетный эффект (DIALOGUE_CUSTOM): сцена запускает катсцену/событие. */ + custom?: string; } /** Вьюха объекта из ассетов: если задана — рисуется вместо плейсхолдера Graphics. */ @@ -338,6 +340,34 @@ } ] } + ], + // --- зал Машины (финал акта 2): усыпить её правильным звоном --- + machine_hall: [ + { + id: 'old_machine', + pos: { x: 6.5, y: 1.5 }, + kind: 'lever', + label: 'Старая Машина', + responses: [ + { + when: { flag: FLAGS.machine_lulled }, + text: 'Машина спит. Угольки в устье погасли — впервые за годы.', + sound: 'sfx/bell_low' + }, + { + when: { notFlag: FLAGS.quest_resonator_done }, + text: 'Устье дышит жаром сквозь рёбра. Резонатор не настроен — Машине не спеть.', + sound: 'sfx/ash_hiss' + }, + { + // Правильный звон: настроенный резонатор + верный тон. + text: 'Резонатор ловит дыхание. Пол-тона вниз — и Машина отвечает сном.', + setFlags: [FLAGS.machine_lulled], + custom: 'lull_machine', + sound: 'sfx/bell_low' + } + ] + } ] }; /** Тайл привязки вычисляется из pos при загрузке модуля. */ diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 48f41a6..4d6f080 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -412,6 +412,7 @@ { tile: { x: 6, y: 10 }, target: { kind: 'return' }, duration: 0.2 } ], atmosphere: 'none', + interactables: INTERACTABLES.machine_hall, // Под землёй темно; единственный свет — холодное тление устья печи. lighting: { ambient: 0x3e4252, diff --git a/apps/game/src/data/validate.ts b/apps/game/src/data/validate.ts index 6b5e780..7885e11 100644 --- a/apps/game/src/data/validate.ts +++ b/apps/game/src/data/validate.ts @@ -229,6 +229,9 @@ for (const f of r.clearFlags ?? []) checkFlag(f, w, 'clearFlags'); if (r.setVar) checkVar(r.setVar.id, w, 'setVar.id'); if (r.addVar) checkVar(r.addVar.id, w, 'addVar.id'); + if (r.custom !== undefined && !(r.custom in DIALOGUE_CUSTOM)) { + out.push({ id: 'custom-unknown', severity: 'error', message: `${obj.id}: custom «${r.custom}» вне реестра DIALOGUE_CUSTOM`, where: w }); + } } } } diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index cdeb6ef..f0b0f4b 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -229,6 +229,7 @@ give: (id, n) => this.game.inventory.add(id, n), playSound: (k) => this.worldAudio.playAt(k, this.player.position, 0.6), showToast: (t) => this.showToast(t), + onCustom: (name) => this.onSceneCustom(name), onConsumed: (def) => this.interactViews.consume(def, this.tileCenter, (s) => this.combatViews.hitBurst(s)) }); for (const def of area.interactables ?? []) { @@ -742,6 +743,50 @@ } } + /** Сюжетные custom-эффекты интерактивов (вне диалогов). */ + private onSceneCustom(name: string): void { + if (name === DIALOGUE_CUSTOM.lull_machine) this.startMachineLullCutscene(); + } + + /** + * Финал акта 2 — «правильный звон»: камера спускается к устью, резонатор + * держит верный тон, Машина засыпает. Резонанс — тот же пульс CombatWorld, + * что и удержание атаки героя (догоняет бодрых сгустков зала). + */ + private startMachineLullCutscene(): void { + const at = tileToWorld(6, 2); // устье печи (свет machine_mouth) + const s = worldToScreen(at.x, at.y); + this.cutscene.play([ + // Спуск камеры к Машине, наезд на устье. + { kind: 'cameraMove', x: at.x, y: at.y, seconds: 1.2, camera: this.game.camera }, + { kind: 'cameraZoom', camera: this.game.camera, z: 2, seconds: 0.8 }, + // Правильный звон: бронза держит тон, тряска, кольцо резонанса. + { + kind: 'burst', + fn: () => { + void this.game.audio.play('sfx/bell_low', 1); + this.game.camera.addShake(2.5, 0.6); + this.combatViews.hitBurst(s); + }, + seconds: 1 + }, + // Тот же резонанс, что у героя: доспать всем, кто не спал. + { kind: 'call', fn: () => this.combat.resonancePulse(at) }, + // Дыхание обрывается: угольки гаснут. + { + kind: 'burst', + fn: () => { + void this.game.audio.play('sfx/ash_die', 0.8); + this.combatViews.hitBurst(s); + }, + seconds: 1.2 + }, + // Отъезд и тост-финал акта. + { kind: 'cameraZoom', camera: this.game.camera, z: 1, seconds: 0.8 }, + { kind: 'call', fn: () => this.showToast('Машина дышит тише. Впервые за годы — тише.') } + ]); + } + /** Всплывающая подсказка: появляется, держится, растворяется вверх (FxLayer). */ private showToast(text: string): void { this.lastToast = { text, tick: this.game.engine.tickCount }; diff --git a/apps/game/src/systems/Interactables.ts b/apps/game/src/systems/Interactables.ts index cc76734..b22e9ae 100644 --- a/apps/game/src/systems/Interactables.ts +++ b/apps/game/src/systems/Interactables.ts @@ -20,6 +20,8 @@ give(id: ItemId, count: number): void; playSound(key: string): void; showToast(text: string): void; + /** Сюжетный эффект реакции (DIALOGUE_CUSTOM): сцена запускает катсцену. */ + onCustom?(name: string): void; /** * Одноразовый объект «поднят» (once, реакция ok): сцена убирает вьюху с * поля (fade) и даёт визуальный отклик. Вызывается после apply. @@ -69,6 +71,7 @@ if (r.gives) this.sink.give(r.gives, r.count ?? 1); if (def.once) this.state.setFlag(usedFlag(def.id)); if (r.sound) this.sink.playSound(r.sound); + if (r.custom) this.sink.onCustom?.(r.custom); this.sink.showToast(r.text); } } \ No newline at end of file diff --git a/apps/game/tools/checks/final.mjs b/apps/game/tools/checks/final.mjs new file mode 100644 index 0000000..c284c00 --- /dev/null +++ b/apps/game/tools/checks/final.mjs @@ -0,0 +1,71 @@ +/** + * Сценарий final — финал акта 2: правильный звон Старой Машины. + * 1) путь акта 2: Звенец -> роща -> разъезд -> люк в зал Машины; + * 2) без настроенного резонатора Машина отказывает (locked-текст); + * 3) чит-настройка (quest_resonator_done): клик по Машине -> реакция ставит + * machine_lulled и запускает катсцену (тост «дышит тише», конец); + * 4) повторный клик: Машина спит; + * 5) инварианты чисты после финала. + * Запуск: node tools/agent.mjs run tools/checks/final.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'final', + async (t) => { + const { c } = t; + await c.run('путь акта 2: спуск через люк в зал Машины', async () => { + await t.boot(); + await t.sleepEnemies(); + await t.goToArea('zvenets', { x: 26, y: 14 }); + await t.sleepEnemies(); + await t.ctx.agent.command('scene:teleport', { x: 25, y: 10 }); + await t.goToArea('rust_grove', { x: 26, y: 10 }); + await t.sleepEnemies(); + await t.goToArea('old_siding', { x: 22, y: 10 }); + await t.sleepEnemies(); + await t.goToArea('machine_hall', { x: 15, y: 15 }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.area === 'machine_hall', 'герой не в зале Машины', s.area); + return null; + }); + await c.run('без резонатора Машина отказывает', async () => { + await t.ctx.agent.tapTile(6, 1); + const w = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("не настроен")', { + timeoutTicks: 1200 + }); + c.expect(w.ok, 'Машина не отказала без резонатора', w.snapshot.lastToast); + c.expect(!w.snapshot.flags.includes('machine_lulled'), 'флаг machine_lulled без настройки'); + return null; + }); + await c.run('правильный звон: катсцена, machine_lulled', async () => { + await t.ctx.agent.command('scene:setFlag', { flag: 'quest_resonator_done' }); + await t.ctx.agent.tapTile(6, 1); + const f = await t.ctx.agent.waitFor('s.flags.includes("machine_lulled")', { + timeoutTicks: 1200 + }); + c.expect(f.ok, 'реакция не подняла machine_lulled', f.snapshot.flags); + const toast = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("дышит тише")', { + timeoutTicks: 1200 + }); + c.expect(toast.ok, 'нет тоста финала', toast.snapshot.lastToast); + await t.ctx.agent.waitFor('!s.cutscene?.active', { timeoutTicks: 1200 }); + return null; + }); + await c.run('повторный клик: Машина спит', async () => { + await t.ctx.agent.tapTile(6, 1); + const w = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("спит")', { + timeoutTicks: 1200 + }); + c.expect(w.ok, 'повторная реакция не про «сон»', w.snapshot.lastToast); + return null; + }); + await c.run('инварианты чисты после финала', async () => { + await t.expectInvariantsClean('после финала акта 2'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/docs/demo.md b/docs/demo.md index 078d331..1aeed8a 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -11,7 +11,7 @@ |---|---|---| | Engine (GameLoop, фиксированный шаг 60 Гц) | весь геймплей, `Game.ts` | готово | | Renderer (480×270, целочисленный масштаб, pixelated) | весь рендер, `main.ts` + `computeScale` | готово | -| Camera (follow, bounds от западного угла, shake) | следование за героем; тряска при резонансе/уроне | готово | +| Camera (follow, bounds от западного угла, shake) | следование за героем; тряска при резонансе/уроне; зум (debug-клавиша + кат-сцены) | готово | | SceneManager (replace/push/pop, fade) | меню → локация → сумка/сейвы; переходы локаций | готово | | InputManager (действия, геймпад, мульти-биндинг) | advance/attack/menu/inventory/debug; геймпад-карта | готово | | VirtualJoystick | тач в локациях (включается при первом касании) | готово | @@ -33,7 +33,7 @@ | ECS (World) | боевые сущности: враги и снаряды на `Entity` | готово | | Panel/Button/MenuList | меню, настройки, сейвы, сумка | готово | | DialogueRunner + DialogueBox | графы `data/dialogues.ts`, ветки квеста | готово | -| CutsceneRunner | кат-сцена сдачи квеста (посадка → камера к башне → колокол → зелень → финал) | готово | +| CutsceneRunner | кат-сцены сдачи квестов (посадка/настройка/финал: `until`, `moveView`, `skip()`, `cameraZoom`) | готово | | DebugOverlay | F3: fps/тайл/позиция/hp | готово | | math/shapes (cone/circle) | конус удара, круг цели, кольца | готово | | Cooldown | кулдауны удара/резонанса/атак врагов | готово | @@ -42,13 +42,27 @@ | data/validate.ts → Invariant[] | runtime-валидация контента (диалоги/NPC/локации/враги) | готово | | apps/game/tools/agent.mjs + packages/engine/tools/agent-lib.mjs | CLI check/run/snapshot/screenshot/dev; смоуки на мосту (каркас — в движке) | готово | -## Сюжетная рамка (акт 1, по docs/world.md) +## Сюжетная рамка (акты 1–2, по docs/world.md) + +Акт 1: 1. Выжженные луга (хаб): Ирвин даёт квест «Три цветка» (флаг `quest_bells_taken`). 2. Мила предупреждает о накате у прудов, даёт вощёное полотно (`got_cloth`). 3. Серые пруды (опасная зона): туман, сгустки, сбор колокольчиков кликом (вар `flowers`). 4. Возврат: сдача Ирвину (`elder_hand_in`) — посадка цветов у тропы, тайлы зеленеют. -5. Крючок: Мила — «Слышишь гул снизу? Это разъезд. Это Машина дышит» (`trader_after`). +5. Крючок: Мила — «Слышишь гул снизу? Это разъезд. Это Машина дышит» (`trader_after`, + флаг `act2_hook`). + +Акт 2: + +6. Ржавая роща (после `act2_hook` появляется Клинт — размещения NPC по флагу, + `NPC_PLACEMENTS`): квест «Голос для резонатора» — звон у деревьев-механизмов + (интерактивы `tone_tree_N`, вар `tones`), сдача Клинту (`clint_tune`), + кат-сцена настройки (наезд камеры на резонатор). +7. Старый разъезд: рельсы, дома-фонари, люк в подземелье (зона наката у люка). +8. Зал Старой Машины: «правильный звон» — с настроенным резонатором клик по + Машине (`old_machine`) ставит `machine_lulled` и запускает финальную + кат-сцену (спуск камеры, резонанс CombatWorld, угольки гаснут). ## Боевая модель (реальное время) diff --git a/docs/world.md b/docs/world.md index 79c3a01..ec22ee1 100644 --- a/docs/world.md +++ b/docs/world.md @@ -94,6 +94,22 @@ 5. **Крючок.** У прудов слышно низкий гул снизу. Мила: «Это разъезд. Это Машина дышит». Акт 2 — Ржавая роща, разъезд, Большой Колокол. +## 6а. Сюжетная арка акта 2 (срез) + +1. **Ржавая роща.** После крючка Милы (флаг `act2_hook`) у тропы стоит Клинт — + ворчит, что его резонатор ржавый и молчит третий месяц. Просит прозвенеть + у деревьев-механизмов: «кто отзовётся — тот и тон даст» + (квест «Голос для резонатора», флаг `quest_resonator_taken`). +2. **Три тона.** Деревья рощи оплетены кабелями — звон колокольчика отвечает + тоном (интерактивы `tone_tree_N`, вар `tones`). Роща слышит. +3. **Настройка.** Три тона — Клинт поддел резонатор под них, «на пол-тона ниже, + чем в книге» (`clint_tune`, флаг `quest_resonator_done`); кат-сцена: резонатор + ловит тон, деревья отвечают в такт. +4. **Старый разъезд.** Рельсы к подземелью; у люка — свежий накат (нужно полотно). +5. **Правильный звон.** В зале Старой Машины настроенный резонатор держит верный + тон — Машина засыпает (флаг `machine_lulled`), угольки в устье гаснут. + Не бой, а усыпление — «механизм, который надо не убить, а уложить спать». + ## 7. Существа и опасности Никаких «монстров» — враг тут не персонаж, а состояние мира.