diff --git a/docs/plan.md b/docs/plan.md index 5aad660..55aed6d 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -730,14 +730,22 @@ зомби-vite только по PID. Вне скоупа: sfx-звуки реакций (поле sound v1), боевой звон как триггер оленей (мост startleFauna до переноса боя). -- **Трек 20 — скрипты как данные + агентный просмотр** (новый, из - практики трека 19): катсцены — третий повтор паттерна «стек шагов - {dur, fn?, cam?}» в main.ts — поднять в движок жанронезависимый - CutsceneRunner (стек шагов, глайд камеры, валидация шагов), игра даёт - данные (data/cutscenes.ts: метки точек камеры + реестр именованных - действий) и валидацию при старте (как validateDialogue); dry-run гейт — - «каждая катсцена доигрывается stepTicks-ом до конца и возвращает камеру - к герою». Полноценную событийную систему (триггеры onEnter/onFlag → +- **Трек 20 — скрипты как данные + агентный просмотр** (ЗАКРЫТ + 2026-09-10): катсцены — третий повтор паттерна «стек шагов {dur, fn?, + cam?}» в main.ts — поднят в движок жанронезависимо + (`packages/engine/src/core/cutscene.ts`: `CutsceneRunner` — стек шагов, + глайд камеры по инъекции `CutsceneCamera`, глайд по времени после + инкремента — последний тик достигает цели точно; действие шага может + перезапустить сцену; `validateCutscenes` — числовая валидация каталога, + 8 юнит-тестов). Игра даёт данные (`src/data/cutscenes.ts`: метки точек + камеры `CUTSCENE_CAMS` тайлами v1, реестр `CUTSCENES` — hand_in / tune / + machine_lull) и валидацию при старте (`resolveCutscenes(actions)` — + неизвестная метка/действие → throw, как validateDialogue). Мост: + `playCutscene(id)` + каталог в `cutsceneInfo`; dry-run гейт + `tools/checks/cutscenes.mjs` (13 проверок: каждая сцена доигрывается + stepTicks-ом до конца и возвращает камеру к герою). Грабли: 40 тиков = + 0.67 с — ещё первый шаг (dur 0.8–1.2 с): прогресс проверять `t > 0`, не + `step >= 1`. Полноценную событийную систему (триггеры onEnter/onFlag → скрипты) не заводить до третьего кейса, который не ложится на квест-стадии. - **Трек 21 — сборка**: сейв полного состояния, меню (подтверждено diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 763856d..deee692 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -54,6 +54,7 @@ node apps/game/tools/checks/interact.mjs # гейт интерактива/цветков игры (/tmp/v2_interact_*.png) node apps/game/tools/checks/atmosphere.mjs # гейт времени суток/света/музыки игры (/tmp/v2_atmosphere_*.png) node apps/game/tools/checks/act2.mjs # гейт акта 2: резонатор/Машина/олени (/tmp/v2_act2_*.png) +node apps/game/tools/checks/cutscenes.mjs # гейт катсцен: dry-run сцен, камера к герою (/tmp/v2_cutscene_*.png) node apps/game/tools/shot.mjs --area rust_grove --at 24,16 --flags act2_hook --out /tmp/s.png # оперативный скриншот участка карты агентом npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 @@ -155,6 +156,17 @@ DOM-слой, тесты — заглушка); параллельные переходы глотаются (pending-проверка), enter новой сцены не ожидается (void); update тикает текущую сцену и во время перехода, render рисует весь стек; + - `src/core/cutscene.ts` — `CutsceneRunner` (катсцены как данные, + жанронезависимо): стек шагов `{dur, fn?, cam?}` (dur — сек ручного + времени, fn — действие на старте шага, cam — цель камеры), глайд + камеры по инъекции `CutsceneCamera {get, set}` (рендер знает только + рантайм — игра), глайд по времени ПОСЛЕ инкремента (последний тик + достигает цели точно, k=1); действие шага может перезапустить сцену + (start/abort) — тик принадлежит новой сцене; `validateCutscenes` + (числовая валидация каталога агентом: пустых нет, dur конечен > 0, + cam — конечная тройка); `info()` — снапшот для моста; мир снаружи + замер (update вместо тика мира), возврат камеры к герою — политика + игры; - `src/combat/` — боевая система (перенос v1, чистые юниты; **1 боевой юнит = 3 вокселя** демки, конвертация только на границе демки): `shapes.ts` (horDist/horNorm/inCircle/inCone), `los.ts` (hasLineOfSight @@ -449,14 +461,19 @@ 1.5+rng×3 с, подход на звон HEAR_RADIUS 7 тайлов, скорости 0.35/0.7 тайла/с → воксели ×UNIT, Rng(20260908) детерминирован; blocked-тайлы от main через `tileBlocked` — колонка над землёй); спавны в `AreaDef.fauna` - (meadows 2, ponds 1 — v1 1:1). Катсцены в main.ts: `startTuneCutscene` - (камера к резонатору → звон (startle фауны) → камера к дереву рощи → - тост «Роща слышит») и `startMachineLullCutscene` (камера к устью Машины → - звон → дыхание обрывается → тост) — custom-эффекты диалогов идут через - pendingTune/pendingHandIn в onFinish (катсцена поверх диалога не идёт), - интерактивный lull_machine — сразу. Мост: `faunaInfo`/`startleFauna`/ + (meadows 2, ponds 1 — v1 1:1). Катсцены — данные + движковый рантайм + (трек 20): `src/data/cutscenes.ts` (метки точек камеры `CUTSCENE_CAMS` + тайлами v1, шаги в данных `{dur, action?, text?, cam?, at?}`, реестр + `CUTSCENES`: hand_in / tune / machine_lull) + `resolveCutscenes(actions)` + в main.ts (именованные действия: plant_flowers / raise_tower_green / + fauna_startle / toast; неизвестная метка/действие — throw при старте, + как validateDialogue) → `CutsceneRunner` (камера — инъекция рендера, + мир замер, по окончании камера к герою). Custom-эффекты диалогов идут + через pendingTune/pendingHandIn в onFinish (катсцена поверх диалога не + идёт), интерактивный lull_machine — сразу. Мост: `faunaInfo`/`startleFauna`/ `setCam` (камера на точку мира БЕЗ телепорта героя — просмотр участка)/ - `cutsceneInfo` (шаг/таймер — числовой дебаг сцен)/`tileCenter`/ + `cutsceneInfo` (шаг/таймер/каталог — числовой дебаг сцен)/`playCutscene(id)` + (играть сцену по id — агентный dry-run)/`tileCenter`/ `setFauna` (выключить живых оленей для кадр-равенств, как setAsh). Тулза `tools/shot.mjs` — оперативный скриншот участка: состояние сюжета (область/флаги/вары/время) + камера + снимок одной командой. Гейт @@ -474,6 +491,22 @@ переносилось — sfx-спеки отдельно), боевой звон как триггер оленей (боёвки в игре нет — мост startleFauna). +**Трек 20 «катсцены как данные + CutsceneRunner»** (2026-09-10): третий +повтор паттерна «стек шагов {dur, fn?, cam?}» в main.ts поднят в движок +жанронезависимо (`packages/engine/src/core/cutscene.ts` — см. структуру; +глайд по времени после инкремента — последний тик достигает цели точно, +действие шага может перезапустить сцену). Игра: `src/data/cutscenes.ts` +(метки точек камеры тайлами v1 + именованные действия + реестр 3 сцен), +`resolveCutscenes` валидирует при старте (неизвестная метка/действие — +throw, как validateDialogue). Мост: `playCutscene(id)` + каталог в +`cutsceneInfo`. Гейт `tools/checks/cutscenes.mjs` (13 проверок): каталог +в мосте, неизвестный id молча не играет, dry-run каждой сцены в своей +области (шаг играет, герой стоит, камера к герою в финале), кадр середины +не пустой. Грабли: **40 тиков = 0.67 с — ещё первый шаг** (dur 0.8–1.2 с): +гейту проверять прогресс `t > 0`, а не `step >= 1`; событийную систему +(триггеры onEnter/onFlag → скрипты) не заводить до третьего кейса, не +ложащегося на квест-стадии. + **Фидбек-раунд (2026-09-10)**: канвас тянется под окно (CSS 16:9, бэкинг-стор 960×540), камера следует за героем по float-цели (без округления — дёрганье ушло), олень поворачивается по ходу движения diff --git a/v2/apps/game/src/data/cutscenes.ts b/v2/apps/game/src/data/cutscenes.ts new file mode 100644 index 0000000..8fcfc69 --- /dev/null +++ b/v2/apps/game/src/data/cutscenes.ts @@ -0,0 +1,94 @@ +/** + * Катсцены игры как данные (сюжетные сцены трека 19): метки точек камеры + * (тайлы v1), именованные действия и реестр сцен. Рантайм — движковый + * CutsceneRunner; resolveCutscenes связывает данные с действиями и + * валидирует при старте (как validateDialogue — битый каталог падает сразу). + */ +import { UNIT, GROUND_Y } from '../areas'; +import { QUEST_TOWER_TILE, QUEST_TONE_TILES } from './quests'; +import type { CutsceneStep } from '@rpg/engine'; + +/** Шаг катсцены в данных: длительность + метки действия/камеры. */ +export interface CutsceneStepData { + /** Длительность шага (сек ручного времени цикла — детерминизм гейта). */ + dur: number; + /** Именованное действие (ключ реестра действий main.ts). */ + action?: string; + /** Аргумент действия (текст тоста). */ + text?: string; + /** Метка точки камеры для глайда (ключ CUTSCENE_CAMS). */ + cam?: string; + /** Точка для действия без глайда шага (ключ CUTSCENE_CAMS). */ + at?: string; +} + +/** Метки точек камеры (тайлы v1; мир — центр тайла, ноги на земле). */ +export const CUTSCENE_CAMS: Record = { + tower: QUEST_TOWER_TILE, // башня Звенца (сдача цветов) + clint_resonator: { x: 24, y: 16 }, // резонатор Клинта (clint_resonator) + tone_tree: { x: QUEST_TONE_TILES[1]![0], y: QUEST_TONE_TILES[1]![1] }, // дерево-механизм рощи + machine_mouth: { x: 13, y: 1 }, // устье печи (свет machine_mouth, проп old_machine) +}; + +/** Действие катсцены: точка (если задана at/cam) и аргумент (текст). */ +export type CutsceneAction = (point: { x: number; z: number } | null, text: string | undefined) => void; + +/** Реестр сюжетных катсцен: id → шаги в данных. */ +export const CUTSCENES: Record = { + /** Сдача цветов: посадка → камера к башне → зелень → финал акта 1. */ + hand_in: [ + { dur: 0.8, action: 'plant_flowers' }, + { dur: 1.2, cam: 'tower' }, + { dur: 1.2, action: 'raise_tower_green' }, + { dur: 1.0, action: 'toast', text: 'Пепел отступил у тропы. Машина дышит тише... но дышит.' }, + ], + /** Настройка резонатора (акт 2): камера к резонатору, звон, + * роща отвечает (камера к прозвеневшему дереву), тост-финал квеста. */ + tune: [ + { dur: 1.0, cam: 'clint_resonator' }, + { dur: 1.0, action: 'fauna_startle', at: 'clint_resonator' }, + { dur: 1.2, cam: 'tone_tree' }, + { dur: 0.8, action: 'toast', text: 'Резонатор держит тон. Роща слышит.' }, + ], + /** Финал акта 2 — «правильный звон»: камера спускается к устью Машины, + * резонатор держит тон, Машина засыпает (тост — финальный шаг). */ + machine_lull: [ + { dur: 1.2, cam: 'machine_mouth' }, + { dur: 1.0 }, // правильный звон держит + { dur: 1.2 }, // дыхание обрывается — угольки гаснут + { dur: 0.8, action: 'toast', text: 'Машина дышит тише. Впервые за годы — тише.' }, + ], +}; + +/** Мир-точка метки: центр тайла, ноги на земле (граница тайлы → воксели). */ +export function cutscenePoint(label: string): [number, number, number] { + const t = CUTSCENE_CAMS[label]; + if (!t) throw new Error(`cutscenes: неизвестная точка камеры «${label}»`); + return [t.x * UNIT + UNIT / 2, GROUND_Y, t.y * UNIT + UNIT / 2]; +} + +/** Связать каталог с действиями: шаги для движкового CutsceneRunner. + * Неизвестная метка/действие — throw (валидация при старте). */ +export function resolveCutscenes(actions: Record): Record { + const out: Record = {}; + for (const [id, steps] of Object.entries(CUTSCENES)) { + if (!steps.length) throw new Error(`cutscenes: сцена «${id}» пуста`); + out[id] = steps.map((s) => { + if (s.cam) cutscenePoint(s.cam); // метка камеры валидна + const act = s.action ? actions[s.action] : undefined; + if (s.action && !act) throw new Error(`cutscenes: неизвестное действие «${s.action}» (сцена ${id})`); + const pointLabel = s.at ?? s.cam; + return { + dur: s.dur, + cam: s.cam ? cutscenePoint(s.cam) : undefined, + fn: act + ? () => { + const p = pointLabel ? cutscenePoint(pointLabel) : null; + act(p ? { x: p[0], z: p[2] } : null, s.text); + } + : undefined, + }; + }); + } + return out; +} \ No newline at end of file diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 1e2619e..131ee13 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -9,7 +9,7 @@ import { VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW, mannequin, walkClip, Character, GameLoop, screenMoveToWorld, - DialogueRunner, Inventory, + DialogueRunner, Inventory, CutsceneRunner, validateCutscenes, } from '@rpg/engine'; import type { VoxelCloud, DialogueGraph, DialogueView, @@ -21,11 +21,12 @@ import { GameState } from './state'; import { DIALOGUES } from './data/dialogues'; import { npcsForArea, type NpcDef } from './data/npcs'; -import { questDialogueFor, questTracker, QUEST_TONE_TILES } from './data/quests'; +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 { resolveCutscenes, type CutsceneAction } from './data/cutscenes'; import { FlowerQuest } from './flowers'; import { GameClock } from './clock'; import { LIGHTING, ATMOSPHERE, type Atmosphere, type LightingDef } from './data/lighting'; @@ -65,6 +66,21 @@ const canvas = document.getElementById('scene') as HTMLCanvasElement; const renderer = new VoxelRenderer(canvas, { target: [40, 2, 60], width: 960, height: 540, viewSize: 24 }); +// ---- Катсцены: данные + движковый рантайм (камера — инъекция рендера) ---- +const cutsceneActions: Record = { + plant_flowers: () => flowers.plantFlowers(), + raise_tower_green: () => flowers.raiseTowerGreen(), + fauna_startle: (p) => { if (p) fauna?.startleAt(p.x, p.z); }, // звон разлетается + toast: (_p, text) => showToast(text ?? ''), +}; +const CUTSCENE_STEPS = resolveCutscenes(cutsceneActions); +const cutsceneErrs = validateCutscenes(CUTSCENE_STEPS); +if (cutsceneErrs.length) throw new Error(`Катсцены: ${cutsceneErrs.join('; ')}`); // битый каталог падает сразу +const cutscenes = new CutsceneRunner({ + get: () => renderer.currentTarget(), + set: (p) => renderer.setTarget(p), +}); + let world = new VoxelWorld(20); const chunkMeshes = new Map(); @@ -288,7 +304,7 @@ /** Сюжетные custom-эффекты интерактивов (вне диалогов, как в v1). */ onCustomEvent = (name: string): void => { - if (name === 'lull_machine') startMachineLullCutscene(); // финал акта 2 + if (name === 'lull_machine') cutscenes.start(CUTSCENE_STEPS.machine_lull!); // финал акта 2 }; /** Смена области: новый мир + мешы, герой на входном тайле, NPC, свет, музыка. */ @@ -480,11 +496,11 @@ runner.onFinish = () => { if (pendingHandIn) { pendingHandIn = false; - startHandInCutscene(); + cutscenes.start(CUTSCENE_STEPS.hand_in!); // сдача цветов (акт 1) } if (pendingTune) { pendingTune = false; - startTuneCutscene(); // настройка резонатора (акт 2) + cutscenes.start(CUTSCENE_STEPS.tune!); // настройка резонатора (акт 2) } }; @@ -514,7 +530,7 @@ return; } // катсцена/переход: цели «E» не сканируются (мир замер) — нажатия глотаем - if (cutscene || fade.active) return; + if (cutscenes.active || fade.active) return; const pick = nearPick; if (!pick) return; if (pick.kind === 'npc') { @@ -576,96 +592,9 @@ 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([ - f[0] + (s[0] - f[0]) * k, - f[1] + (s[1] - f[1]) * k, - 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('Пепел отступил у тропы. Машина дышит тише... но дышит.') }, - ]); -} - -/** Катсцена настройки резонатора (акт 2): камера к резонатору, звон, - * роща отвечает (камера к прозвеневшему дереву), тост-финал квеста. */ -function startTuneCutscene(): void { - const r = tileCenter(24, 16); // резонатор Клинта (clint_resonator) - const [tx, ty] = QUEST_TONE_TILES[1]!; // дерево-механизм у рощи - const tree = tileCenter(tx, ty); - playCutscene([ - { dur: 1.0, cam: [r.x, GROUND_Y, r.z] }, - { dur: 1.0, fn: () => fauna?.startleAt(r.x, r.z) }, // звон разлетается - { dur: 1.2, cam: [tree.x, GROUND_Y, tree.z] }, // роща отвечает - { dur: 0.8, fn: () => showToast('Резонатор держит тон. Роща слышит.') }, - ]); -} - -/** Финал акта 2 — «правильный звон»: камера спускается к устью Машины, - * резонатор держит тон, Машина засыпает (тост — финальный шаг). */ -function startMachineLullCutscene(): void { - const m = tileCenter(13, 1); // устье печи (свет machine_mouth, проп old_machine) - playCutscene([ - { dur: 1.2, cam: [m.x, GROUND_Y, m.z] }, - { dur: 1.0 }, // правильный звон держит - { dur: 1.2 }, // дыхание обрывается — угольки гаснут - { dur: 0.8, fn: () => showToast('Машина дышит тише. Впервые за годы — тише.') }, - ]); -} +// ---- Кат-сцены: данные (data/cutscenes.ts) + движковый CutsceneRunner ---- +// мир замер, герой стоит, камера глайдит по шагам; по окончании камера +// возвращается к герою (политика игры — в tick). /** Снапшот диалога для моста: узел/выборы/путь + флаги/вары + журнал. */ function dialogueSnapshot() { @@ -863,9 +792,9 @@ } return; // во время перехода мир замер } - if (cutscene) { + if (cutscenes.active) { // катсцена: герой стоит, камера глайдит по шагам - if (!tickCutscene(dt)) { + if (!cutscenes.update(dt)) { const b = hero.body.pos; // конец: камера возвращается к герою renderer.setTarget([b[0], GROUND_Y, b[2]]); } @@ -1225,11 +1154,14 @@ followHero = false; renderer.setTarget([x, GROUND_Y, z]); }, - /** Снапшот катсцены: играет ли, шаг, таймер (числовой дебаг сцены). */ + /** Снапшот катсцены: играет ли, шаг, таймер, каталог сцен. */ cutsceneInfo() { - return cutscene - ? { active: true, step: cutscene.i, of: cutscene.steps.length, t: cutscene.t } - : { active: false, step: null, of: null, t: 0 }; + return { ...cutscenes.info(), catalog: Object.keys(CUTSCENE_STEPS) }; + }, + /** Играть катсцену по id (агентный dry-run: гейт доигрывает до конца). */ + playCutscene(id: string) { + cutscenes.start(CUTSCENE_STEPS[id] ?? []); + return cutscenes.info(); }, /** Снапшот состояния сюжета (флаги/вары) и сумки. */ stateInfo() { diff --git a/v2/apps/game/tools/checks/cutscenes.mjs b/v2/apps/game/tools/checks/cutscenes.mjs new file mode 100644 index 0000000..4f2f115 --- /dev/null +++ b/v2/apps/game/tools/checks/cutscenes.mjs @@ -0,0 +1,97 @@ +/** + * Скриншот-гейт катсцен (трек 20): каталог сцен валиден при старте + * (validateCutscenes + resolveCutscenes — битый каталог роняет страницу), + * каждая сюжетная сцена доигрывается stepTicks-ом до конца (шаги идут, + * герой стоит), камера глайдит к точке шага и ВОЗВРАЩАЕТСЯ к герою в + * финале. Сцены играются через мост playCutscene(id) в «своих» областях + * (точки камеры — тайлы конкретной области). Скриншоты: /tmp/v2_cutscene_*.png. + * + * Запуск: node apps/game/tools/checks/cutscenes.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: 'cutscenes', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +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; +} + +/** Сухой прогон одной сцены: мост playCutscene → mid-проверка → доиграть + * до конца → камера вернулась к герою (camTarget = герой на земле). */ +async function dryRun(client, id, area, of) { + await client.call('gotoArea', area); + await client.call('stepTicks', 5); + const hero0 = await client.call('heroInfo'); // герой на спавне области + const started = await client.call('playCutscene', id); + check(`${id}: старт (шагов ${of})`, started.active && started.of === of, started); + await client.call('stepTicks', 40); // ~0.66 с — внутри первого шага (dur 0.8–1.2) + const mid = await client.call('cutsceneInfo'); + const hero1 = await client.call('heroInfo'); + check(`${id}: шаг играет, герой стоит`, + mid.active && mid.t > 0 && hero1.tile.x === hero0.tile.x && hero1.tile.y === hero0.tile.y, + { step: mid.step, t: mid.t, tile: hero1.tile }); + await client.call('stepTicks', 400); // до конца с запасом + const end = await client.call('cutsceneInfo'); + const hero2 = await client.call('heroInfo'); + const camBack = Math.round(hero2.camTarget[0]) === Math.round(hero2.pos[0]) + && Math.round(hero2.camTarget[2]) === Math.round(hero2.pos[2]); + check(`${id}: конец — камера вернулась к герою`, !end.active && camBack, { cam: hero2.camTarget, pos: hero2.pos }); +} + +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/` }); + await client.call('startGame', false); // страница стартует в меню (меню/автосейв) + const errs = client.errors(); + check('нет pageerror при загрузке (каталог валиден, иначе throw при старте)', errs.length === 0, errs.slice(0, 3)); + + await client.call('setManual', true); + await client.call('stepTicks', 10); + + // Каталог сцен виден мосту; неизвестный id молча не играет + const info = await client.call('cutsceneInfo'); + check('каталог катсцен в мосте (3 сцены)', + Array.isArray(info.catalog) && info.catalog.includes('hand_in') + && info.catalog.includes('tune') && info.catalog.includes('machine_lull'), info.catalog); + const ghost = await client.call('playCutscene', 'no_such_scene'); + check('неизвестный id не играет', !ghost.active, ghost); + + // Dry-run всех трёх сцен: шаги, герой стоит, камера к герою + await dryRun(client, 'hand_in', 'meadows', 4); // сдача цветов: посадка → башня → зелень → тост + await dryRun(client, 'tune', 'rust_grove', 4); // настройка резонатора (акт 2) + await dryRun(client, 'machine_lull', 'machine_hall', 4); // усыпление Машины + + // Скриншот середины dry-run tune: камера у дерева рощи — кадр не пустой + await client.call('playCutscene', 'tune'); + await client.call('stepTicks', 90); // шаг 3: камера глайдит к дереву + const shot = decodePng(readFileSync(await client.screenshot('/tmp/v2_cutscene_tune.png'))); + check('кадр середины tune не пустой', colorCount(shot.rgba) >= 8, {}); + await client.call('stepTicks', 400); + } 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/core/__tests__/cutscene.test.ts b/v2/packages/engine/src/core/__tests__/cutscene.test.ts new file mode 100644 index 0000000..b022c5d --- /dev/null +++ b/v2/packages/engine/src/core/__tests__/cutscene.test.ts @@ -0,0 +1,117 @@ +/** Тесты CutsceneRunner: стек шагов, глайд камеры, действие на старте + * шага, перезапуск из действия, валидация каталога (чистая математика + * без рендера — камера фейковая). */ +import { describe, it, expect } from 'vitest'; +import { CutsceneRunner, validateCutscenes, type CutsceneCamera } from '../cutscene'; + +/** Фейковая камера: записывает цели, старт — (0,0,0). */ +function fakeCamera(): CutsceneCamera & { targets: [number, number, number][] } { + const targets: [number, number, number][] = []; + return { + targets, + get: () => targets[targets.length - 1] ?? ([0, 0, 0] as const), + set: (p) => targets.push([...p] as [number, number, number]), + }; +} + +describe('CutsceneRunner', () => { + it('не активна на старте и тик без катсцены — false', () => { + const r = new CutsceneRunner(fakeCamera()); + expect(r.active).toBe(false); + expect(r.info()).toEqual({ active: false, step: 0, of: 0, t: 0 }); + expect(r.update(1 / 60)).toBe(false); + }); + + it('шаги идут по dur, действие каждого шага — на его старте', () => { + const cam = fakeCamera(); + const r = new CutsceneRunner(cam); + const fired: string[] = []; + r.start([ + { dur: 0.1, fn: () => fired.push('a') }, + { dur: 0.1, fn: () => fired.push('b') }, + { dur: 0.1, fn: () => fired.push('c') }, + ]); + // действие первого шага — сразу при start + expect(fired).toEqual(['a']); + expect(r.info()).toEqual({ active: true, step: 0, of: 3, t: 0 }); + r.update(0.05); + r.update(0.05); // 0.05+0.05 = 0.1 точно — шаг 0 завершён + expect(fired).toEqual(['a', 'b']); + expect(r.info()).toEqual({ active: true, step: 1, of: 3, t: expect.closeTo(0, 5) }); + r.update(0.05); + r.update(0.05); + expect(fired).toEqual(['a', 'b', 'c']); + r.update(0.05); + r.update(0.05); // третий шаг завершён + expect(r.active).toBe(false); + }); + + it('глайд камеры: от точки старта шага к цели, последний тик — k=1', () => { + const cam = fakeCamera(); + cam.set([10, 2, 20]); + const r = new CutsceneRunner(cam); + r.start([{ dur: 1, cam: [20, 2, 40] }]); + // глайд по времени ПОСЛЕ инкремента: цель достигается точно + // на последнем тике шага (k=1), а не «в dt от неё» + r.update(0.5); + expect(cam.targets[1]).toEqual([15, 2, 30]); + r.update(0.5); + expect(cam.targets[2]).toEqual([20, 2, 40]); + expect(r.active).toBe(false); + }); + + it('шаг без cam камеру не трогает; duration — сумма шагов', () => { + const cam = fakeCamera(); + cam.set([1, 2, 3]); + const r = new CutsceneRunner(cam); + const n = cam.targets.length; + r.start([{ dur: 0.5 }, { dur: 0.25, cam: [5, 2, 5] }]); + expect(r.duration).toBeCloseTo(0.75); + r.update(0.1); + expect(cam.targets.length).toBe(n); // первый шаг без cam + }); + + it('действие, перезапускающее катсцену, не теряет новый шаг', () => { + const cam = fakeCamera(); + const r = new CutsceneRunner(cam); + r.start([ + { dur: 0.1, fn: () => r.start([{ dur: 0.2, fn: () => void 0 }]) }, + ]); + // start из действия: старая сцена снята, новая играет с нуля + expect(r.info()).toEqual({ active: true, step: 0, of: 1, t: 0 }); + r.update(0.1); + expect(r.info()).toEqual({ active: true, step: 0, of: 1, t: expect.closeTo(0.1, 5) }); + r.update(0.1); + expect(r.active).toBe(false); + }); + + it('abort снимает играющую катсцену', () => { + const r = new CutsceneRunner(fakeCamera()); + const fired: string[] = []; + r.start([ + { dur: 1, fn: () => fired.push('a') }, + { dur: 1, fn: () => fired.push('b') }, + ]); + r.abort(); + expect(r.active).toBe(false); + r.update(2); + expect(fired).toEqual(['a']); // действие второго шага не сработало + }); +}); + +describe('validateCutscenes', () => { + it('зелёный каталог — ошибок нет', () => { + expect(validateCutscenes({ s: [{ dur: 1 }, { dur: 0.5, cam: [1, 2, 3] }] })).toEqual([]); + }); + it('ловит: пустая сцена, dur <= 0/NaN, неконечная cam', () => { + const errs = validateCutscenes({ + empty: [], + bad: [{ dur: 0 }, { dur: 0.5, cam: [1, NaN, 3] }, { dur: Number.POSITIVE_INFINITY }], + }); + expect(errs).toHaveLength(4); + expect(errs[0]).toContain('empty'); + expect(errs[1]).toContain('bad[0]'); + expect(errs[2]).toContain('bad[1]'); + expect(errs[3]).toContain('bad[2]'); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/core/cutscene.ts b/v2/packages/engine/src/core/cutscene.ts new file mode 100644 index 0000000..b9cf61c --- /dev/null +++ b/v2/packages/engine/src/core/cutscene.ts @@ -0,0 +1,120 @@ +/** + * Катсцены как данные: жанронезависимый рантайм (третий повтор паттерна + * «стек шагов» в игре — поднят в ядро). Шаг {dur, fn?, cam?}: dur — секунды + * ручного времени цикла (детерминизм гейтов), fn — разовое действие в + * начале шага, cam — цель камеры (глайд от точки старта шага). Рантайм + * про рендер не знает: камера — инъекция (get/set), мир снаружи замер + * (update звать вместо тика мира); по окончании снаружи возвращает камеру + * к герою (политика игры, не движка). + */ + +/** Шаг катсцены: длительность, действие на старте, цель камеры. */ +export interface CutsceneStep { + /** Длительность шага (сек ручного времени цикла). */ + dur: number; + /** Разовое действие в начале шага. */ + fn?: () => void; + /** Цель камеры (мир): глайд от точки старта шага. */ + cam?: readonly [number, number, number]; +} + +/** Снапшот для моста: играет ли, шаг, таймер (числовой дебаг сцен). */ +export interface CutsceneSnapshot { + active: boolean; + /** Индекс шага (0-базный) и общее число шагов (0 вне катсцены). */ + step: number; + of: number; + /** Время внутри шага (сек). */ + t: number; +} + +/** Камера-носитель: точка старта глайда и установка цели. */ +export interface CutsceneCamera { + get(): readonly [number, number, number]; + set(p: readonly [number, number, number]): void; +} + +export class CutsceneRunner { + private steps: readonly CutsceneStep[] = []; + private i = 0; + private t = 0; + /** Откуда глайдит камера (позиция на старте шага). */ + private from: readonly [number, number, number] | null = null; + + constructor(private readonly camera: CutsceneCamera) {} + + /** Играет ли катсцена (пустой стек = нет). */ + get active(): boolean { + return this.i < this.steps.length; + } + + /** Суммарная длительность текущей катсцены (гейтам: сколько тиков ждать). */ + get duration(): number { + return this.steps.reduce((s, st) => s + st.dur, 0); + } + + /** Начать катсцену: первый шаг стартует сразу; прерывает играющую. */ + start(steps: readonly CutsceneStep[]): void { + this.abort(); + this.steps = steps; + this.i = 0; + this.t = 0; + this.from = null; + steps[0]?.fn?.(); // действие первого шага — сразу (посадка/тост) + } + + /** Снять играющую катсцену (прерывание: переход области, новый диалог). */ + abort(): void { + this.steps = []; + this.i = 0; + this.t = 0; + this.from = null; + } + + /** Тик: таймер шага + глайд камеры; true — ещё играет. Глайд по времени + * ПОСЛЕ инкремента — на последнем тике шага камера достигает цели + * точно (k = 1). Действие шага может перезапустить катсцену + * (start/abort) — тогда этот тик уже принадлежит новой сцене. */ + update(dt: number): boolean { + if (!this.active) return false; + const token = this.steps; + const step = this.steps[this.i]!; + this.t += dt; + if (step.cam) { + if (!this.from) this.from = this.camera.get(); + const f = this.from; + const s = step.cam; + const k = Math.min(1, this.t / step.dur); + this.camera.set([f[0] + (s[0] - f[0]) * k, f[1] + (s[1] - f[1]) * k, f[2] + (s[2] - f[2]) * k]); + } + if (this.t < step.dur) return true; + this.i += 1; + this.t = 0; + this.from = null; + const next = this.steps[this.i]; + if (next) next.fn?.(); + else this.steps = []; // конец: active = false + return this.steps === token && this.active; + } + + /** Снапшот для моста/гейтов (числовой). */ + info(): CutsceneSnapshot { + return this.active + ? { active: true, step: this.i, of: this.steps.length, t: this.t } + : { active: false, step: 0, of: 0, t: 0 }; + } +} + +/** Валидация каталога катсцен агентом (как validateDialogue): пустых нет, + * dur конечен и > 0, cam — конечная тройка. Возвращает список ошибок. */ +export function validateCutscenes(scenes: Record): string[] { + const errs: string[] = []; + for (const [id, steps] of Object.entries(scenes)) { + if (!steps.length) errs.push(`${id}: катсцена пуста`); + steps.forEach((s, i) => { + if (!Number.isFinite(s.dur) || s.dur <= 0) errs.push(`${id}[${i}]: dur ${s.dur} — нужен конечный > 0`); + if (s.cam && s.cam.some((v) => !Number.isFinite(v))) errs.push(`${id}[${i}]: cam не конечная тройка`); + }); + } + return errs; +} \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index 1b0aa89..b6c57c2 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -58,6 +58,8 @@ export type { ControlState } from './core/controls'; export { SceneManager } from './core/scene'; export type { Scene, FadeOverlay, SceneTransition } from './core/scene'; +export { CutsceneRunner, validateCutscenes } from './core/cutscene'; +export type { CutsceneStep, CutsceneSnapshot, CutsceneCamera } from './core/cutscene'; export { ISO_YAW } from './render/voxelRenderer'; export { validateDialogue, evalConditions } from './dialogue/graph'; export type {