diff --git a/docs/plan.md b/docs/plan.md index c2135fb..517acd6 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -713,7 +713,32 @@ живой слой кадра (гейты кадр-равенств зовут setAsh(false) + stepTicks перед скриншотами); интерьеры без nightAmbient — только setDayAmbient; пик золотого часа t = 18 + 0.45×2 = 18:54, не 18:48. -- **Трек 19 — акт 2**: Клинт, ResonatorQuest, тон-деревья, Машина - (machine_hall), фауна (олени); гейт. -- **Трек 20 — сборка**: сейв полного состояния, меню, полный прогон, +- **Трек 19 — акт 2** ✅ (2026-09-10): контент акта 2 уже лёг с треками + 16-17 (диалоги Клинта, квест resonator, интерактивы тон-деревьев и + Машины) — трек добавил рантайм и фауну: Клинт появляется по act2_hook + (спавн при перезаходе), сдача резонатора — катсцена настройки (камера к + резонатору → звон → роща отвечает → тост), эпилог machine_hook, + усыпление Машины — катсцена «правильного звона» (камера к устью), + олени — воксельная FaunaSystem v1 1:1 (брожение по соседним тайлам, + подход на звон, детерминированный Rng). Мост: faunaInfo/startleFauna/ + setCam/cutsceneInfo/tileCenter + тулза `tools/shot.mjs` — оперативный + скриншот участка карты с состоянием сюжета (агентный просмотр). Гейт + `act2.mjs` — 27 проверок. Грабли: questTracker показывает первую + активную стадию первого квеста (bells) — квест резонатора в трекере + не виден до сдачи цветов (v1-faithful); тосты катсцен — в финальном + шаге (гейтам stepTicks до конца); pkill -f убивает свою сессию — + зомби-vite только по PID. Вне скоупа: sfx-звуки реакций (поле sound + v1), боевой звон как триггер оленей (мост startleFauna до переноса + боя). +- **Трек 20 — скрипты как данные + агентный просмотр** (новый, из + практики трека 19): катсцены — третий повтор паттерна «стек шагов + {dur, fn?, cam?}» в main.ts — поднять в движок жанронезависимый + CutsceneRunner (стек шагов, глайд камеры, валидация шагов), игра даёт + данные (data/cutscenes.ts: метки точек камеры + реестр именованных + действий) и валидацию при старте (как validateDialogue); dry-run гейт — + «каждая катсцена доигрывается stepTicks-ом до конца и возвращает камеру + к герою». Полноценную событийную систему (триггеры onEnter/onFlag → + скрипты) не заводить до третьего кейса, который не ложится на + квест-стадии. +- **Трек 21 — сборка**: сейв полного состояния, меню, полный прогон, сравнительные скриншоты с v1 как референсом. diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 203979e..21b2cc4 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -53,6 +53,9 @@ node apps/game/tools/checks/npc.mjs # гейт NPC/квестов игры (/tmp/v2_npc_*.png) 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/shot.mjs --area rust_grove --at 24,16 --flags act2_hook --out /tmp/s.png + # оперативный скриншот участка карты агентом npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 npx vite --port 5315 # игра «Пепельные луга» (в apps/game) ``` @@ -437,6 +440,38 @@ «полдень заката». Вне скоупа (документировано): туман прудов, тёмные ауры наката (darkSpot), виньетка, амбиент-слои (вода/гул) — вернуть в треке полировки или акта 2. + Трек 19 «акт 2: Клинт, резонатор, Машина, фауна»: контент из треков 16-17 + (диалоги Клинта, квест resonator, интерактивы tone_tree/old_machine) уже + был на месте — трек добавил рантайм сюжета и фауну. Игра: `src/deer.ts` + (воксельный олень ~46 вокселей, walk — подъём диагональных пар ног, + ориентация по направлению не крутится — визуальное приближение допустимо); + `src/fauna.ts` (FaunaSystem — перенос v1 1:1: idle→wander по паузе + 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`/ + `setCam` (камера на точку мира БЕЗ телепорта героя — просмотр участка)/ + `cutsceneInfo` (шаг/таймер — числовой дебаг сцен)/`tileCenter`. Тулза + `tools/shot.mjs` — оперативный скриншот участка: состояние сюжета + (область/флаги/вары/время) + камера + снимок одной командой. Гейт + `tools/checks/act2.mjs` (27 проверок): Клинт по флагу, дерево до квеста + молчит, 3 тона + повтор, резонатор до/после, clint_tune → катсцена + настройки (шаги, глайд камеры, тост), clint_repeat → machine_hook, + Машина: «не спеть» → усыплена катсценой → спит, олени: спавны, брожение, + подход к звону. Грабли трека 19: **questTracker показывает первую + активную стадию первого квеста** — пока «Три цветка» не пройден, квест + резонатора в трекере не виден (v1-faithful): проверять флаги/вары, не + трекер; **тосты катсцен уходят в финальный шаг** — гейтам stepTicks + до конца катсцены перед чтением тостов; **pkill -f убивает свою сессию** + (паттерн встречается в её командной строке, exit 144) — зомби-vite убивать + по PID из `ss -tlnp`. Вне скоупа: звуки реакций (поле sound v1 не + переносилось — sfx-спеки отдельно), боевой звон как триггер оленей + (боёвки в игре нет — мост startleFauna). ## Известные грабли (уже собранные) diff --git a/v2/apps/game/src/data/areas.ts b/v2/apps/game/src/data/areas.ts index a0d6e44..998d796 100644 --- a/v2/apps/game/src/data/areas.ts +++ b/v2/apps/game/src/data/areas.ts @@ -38,6 +38,8 @@ hazards?: HazardDef[]; /** Сборный тайл (лунный колокольчик): «E» рядом — сбор. */ harvestTile?: number; + /** Пейзажная фауна (безгласные олени): тайлы спавна, как в v1. */ + fauna?: Tile[]; } export const AREA_DEFS: AreaDef[] = [ @@ -51,6 +53,8 @@ { tile: { x: 54, y: 28 }, target: { area: 'zvenets', entry: { x: 3, y: 20 } } }, { tile: { x: 44, y: 40 }, label: 'Старый колодец', target: { area: 'zvenets', entry: { x: 16, y: 14 } }, trigger: 'click', duration: 0.2 }, ], + // Безгласные олени у берегов прудов — подходят к звону (v1). + fauna: [{ x: 18, y: 36 }, { x: 24, y: 40 }], }, { id: 'ponds', @@ -61,6 +65,8 @@ hazards: [HAZARD_PONDS], // Лунные колокольчики собираются только у прудов (квест «Три цветка»). harvestTile: T_BELLFLOWER, + // Один олень на северо-западном берегу (v1). + fauna: [{ x: 8, y: 12 }], }, { id: 'zvenets', diff --git a/v2/apps/game/src/deer.ts b/v2/apps/game/src/deer.ts new file mode 100644 index 0000000..5b75bc6 --- /dev/null +++ b/v2/apps/game/src/deer.ts @@ -0,0 +1,55 @@ +/** + * Воксельный олень (безгласная фауна v1, воксельная версия): тихая + * серо-бурая живность у прудов. Модель строится по мировому центру (cx, cz) + * вдоль оси X; шаг (step) поднимает диагональные пары ног — грубый, но + * живой walk-цикл. Ориентация по направлению не крутится (визуальное + * приближение допустимо: у v1 спрайт всего с 2 кадрами). + */ +import { GROUND_Y } from './areas'; + +/** Воксель позы: позиция в мире + цвет-строка (как у VoxelCloud). */ +export interface DeerVoxel { + pos: [number, number, number]; + color: string; +} + +const BODY = '#6e6156'; // серо-бурый бок +const DARK = '#54493f'; // копыта, нос, тень брюха +const LIGHT = '#7d7264'; // спина, хвост +const ANTLER = '#8a7a66'; // рога (сухое дерево) + +/** + * Олень в мировых координатах: тело вдоль X, ноги на земле (GROUND_Y), + * высота ~7 вокселей (герой — 10). step — фаза шага (0/1), поднимает + * диагональные пары ног. + */ +export function deerVoxels(cx: number, cz: number, step: boolean): DeerVoxel[] { + const out: DeerVoxel[] = []; + const put = (dx: number, dy: number, dz: number, color: string) => { + out.push({ pos: [cx + dx, dy, cz + dz], color }); + }; + // Ноги: диагональные пары (перед-лево + зад-право) поднимаются на шаге. + const lift = step ? 1 : 0; + for (const [lx, lz] of [[-1, -1], [2, 1], [-1, 1], [2, -1]] as const) { + const up = (lx === -1) === (lz === -1) ? lift : 0; // диагональ шагает + put(lx, GROUND_Y + up, lz, DARK); + put(lx, GROUND_Y + 1 + up, lz, DARK); + } + // Туловище: 5×2×3, спина светлее. + for (let dx = -2; dx <= 2; dx++) + for (let dz = -1; dz <= 1; dz++) { + put(dx, GROUND_Y + 2, dz, dx % 2 === 0 ? BODY : DARK); + put(dx, GROUND_Y + 3, dz, LIGHT); + } + // Шея и голова: вперед-вверх, нос темный, уши-рожки. + put(3, GROUND_Y + 3, 0, BODY); + put(3, GROUND_Y + 4, 0, BODY); + put(4, GROUND_Y + 5, 0, BODY); + put(5, GROUND_Y + 5, 0, DARK); // нос + put(3, GROUND_Y + 6, 0, LIGHT); + put(4, GROUND_Y + 6, -1, ANTLER); + put(4, GROUND_Y + 6, 1, ANTLER); + // Хвост. + put(-3, GROUND_Y + 3, 0, LIGHT); + return out; +} \ No newline at end of file diff --git a/v2/apps/game/src/fauna.ts b/v2/apps/game/src/fauna.ts new file mode 100644 index 0000000..cdb9218 --- /dev/null +++ b/v2/apps/game/src/fauna.ts @@ -0,0 +1,160 @@ +/** + * Пейзажная фауна (перенос v1 FaunaSystem, воксельная версия): безгласные + * олени. Стоят, медленно бродят по соседним тайлам; на громкий звон рядом + * подходят к источнику и стоят у него. В v1 звон — боевой удар героя + * (колокольчик); боёвки в игре v2 пока нет — звон подаёт startleAt + * (гейт зовёт мостом startleFauna, при переносе боя проводим от удара). + */ +import { Rng, type VoxelCloud } from '@rpg/engine'; +import { UNIT, tileCenter } from './areas'; +import { deerVoxels } from './deer'; + +/** Радиус слуха на звон (v1: 7 юнитов = 7 тайлов → воксели). */ +const HEAR_RADIUS = 7 * UNIT; +/** Скорость брожения / подхода (v1: тайлов/сек → вокселей/сек). */ +const WANDER_SPEED = 0.35 * UNIT; +const APPROACH_SPEED = 0.7 * UNIT; +/** Пауза между шагами брожения (сек), как в v1: 1.5 + rng*3. */ +const PAUSE_BASE = 1.5; +const PAUSE_RANGE = 3; + +/** Состояние автомата оленя (снапшот моста). */ +export type DeerState = 'idle' | 'wander' | 'approach'; + +export interface DeerInfo { + /** Позиция ног (мир, воксели). */ + x: number; + z: number; + /** Тайл, на котором стоит (в тайлах v1). */ + tile: { x: number; y: number }; + state: DeerState; +} + +interface Deer { + pos: { x: number; z: number }; + dest: { x: number; z: number } | null; + state: DeerState; + /** Пауза до следующего шага брожения (сек). */ + pause: number; + /** Ключ последнего отрисованного вида (поза/позиция) — без лишних апдейтов. */ + drawn: string; + cloud: VoxelCloud; +} + +export interface FaunaDeps { + /** Спавны в тайлах v1 (олень стоит в центре тайла). */ + spawns: readonly { x: number; y: number }[]; + /** Непроходимость тайла (вода/пропы) — туда олень не идёт. */ + blocked: (tx: number, ty: number) => boolean; + /** Фабрика облаков вокселей (renderer.addVoxelCloud). */ + addCloud: (voxels: ReturnType) => VoxelCloud; +} + +/** Движение к точке с шагом не больше step; true — дошли (снап к точке). */ +function stepTowards( + pos: { x: number; z: number }, + to: { x: number; z: number }, + step: number, +): boolean { + const dx = to.x - pos.x; + const dz = to.z - pos.z; + const d = Math.hypot(dx, dz); + if (d <= step) { + pos.x = to.x; + pos.z = to.z; + return true; + } + pos.x += (dx / d) * step; + pos.z += (dz / d) * step; + return false; +} + +export class FaunaSystem { + private deer: Deer[] = []; + /** Детерминированный ГПСЧ — брожение одинаково между прогонами (v1). */ + private rng = new Rng(20260908); + + constructor(private deps: FaunaDeps) { + for (const s of deps.spawns) this.spawn(s); + } + + /** Заспавнить оленя в центре тайла (стоя, ноги на земле). */ + private spawn(s: { x: number; y: number }): void { + const c = tileCenter(s.x, s.y); + this.deer.push({ + pos: { x: c.x, z: c.z }, + dest: null, + state: 'idle', + pause: PAUSE_BASE + this.rng.next() * PAUSE_RANGE, + drawn: '', + cloud: this.deps.addCloud(deerVoxels(c.x, c.z, false)), + }); + } + + /** Громкий звон рядом: олень подходит к источнику и стоит (v1). */ + startleAt(x: number, z: number): void { + for (const d of this.deer) { + if (Math.hypot(d.pos.x - x, d.pos.z - z) > HEAR_RADIUS) continue; + d.dest = { x, z }; + d.state = 'approach'; + } + } + + /** Шаг брожения: случайный соседний проходимый тайл (v1). */ + private startWander(d: Deer): void { + const tx = Math.floor(d.pos.x / UNIT); + const ty = Math.floor(d.pos.z / UNIT); + const options = [[1, 0], [-1, 0], [0, 1], [0, -1]] + .map(([dx, dy]) => ({ x: tx + dx, y: ty + dy })) + .filter((t) => !this.deps.blocked(t.x, t.y)); + if (options.length === 0) { + d.pause = 2; + return; + } + const t = this.rng.pick(options); + d.dest = tileCenter(t.x, t.y); + d.state = 'wander'; + } + + /** + * Тик фауны: автомат (idle→wander→idle, approach по звону) + отрисовка. + * step — фаза шага (2 шага/сек на ходу, стоя — стоп-кадр), как в v1. + */ + update(dt: number, t: number): void { + for (const d of this.deer) { + if (d.state === 'idle') { + d.pause -= dt; + if (d.pause <= 0) this.startWander(d); + } else if (d.dest && stepTowards(d.pos, d.dest, (d.state === 'approach' ? APPROACH_SPEED : WANDER_SPEED) * dt)) { + d.dest = null; + d.state = 'idle'; + d.pause = PAUSE_BASE + this.rng.next() * PAUSE_RANGE; + } + this.draw(d, t); + } + } + + /** Перерисовать облако, если позиция или фаза шага изменились. */ + private draw(d: Deer, t: number): void { + const step = d.dest !== null && Math.floor(t * 2) % 2 === 0; + const key = `${d.pos.x.toFixed(2)},${d.pos.z.toFixed(2)},${step ? 1 : 0}`; + if (key === d.drawn) return; + d.drawn = key; + d.cloud.update(deerVoxels(d.pos.x, d.pos.z, step)); + } + + /** Снапшот для моста: олени области. */ + info(): DeerInfo[] { + return this.deer.map((d) => ({ + x: d.pos.x, + z: d.pos.z, + tile: { x: Math.floor(d.pos.x / UNIT), y: Math.floor(d.pos.z / UNIT) }, + state: d.state, + })); + } + + dispose(): void { + for (const d of this.deer) d.cloud.dispose(); + this.deer.length = 0; + } +} \ No newline at end of file diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index ab4d3d7..e07174a 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -21,7 +21,7 @@ import { GameState } from './state'; import { DIALOGUES } from './data/dialogues'; import { npcsForArea, type NpcDef } from './data/npcs'; -import { questDialogueFor, questTracker } from './data/quests'; +import { questDialogueFor, questTracker, QUEST_TONE_TILES } from './data/quests'; import { itemName } from './data/items'; import { Interactables, type InteractSink } from './interactables'; import { type InteractableDef } from './data/interactables'; @@ -32,6 +32,7 @@ import { GameLighting, type HeroLampDef } from './lighting'; import { AshParticles } from './ash'; import { GameAudio } from './audio'; +import { FaunaSystem } from './fauna'; // Слоты палитры сцены: арт-библия v1 (1..10) + тайлы областей (11..17) const PALETTE = { @@ -211,9 +212,22 @@ } } +// ---- Пейзажная фауна (акт 2): безгласные олени области ---- +let fauna: FaunaSystem | null = null; + +/** Непроходимый тайл для фауны: колонка над землёй занята (вода/пропы). */ +function tileBlocked(tx: number, ty: number): boolean { + const vx = tx * UNIT + 1; + const vz = ty * UNIT + 1; + for (let y = GROUND_Y; y <= GROUND_Y + 1; y++) { + if (world.get(vx, y, vz) !== 0) return true; + } + return false; +} + /** Сюжетные custom-эффекты интерактивов (вне диалогов, как в v1). */ onCustomEvent = (name: string): void => { - if (name === 'lull_machine') showToast('Машина дышит тише. Впервые за годы — тише.'); + if (name === 'lull_machine') startMachineLullCutscene(); // финал акта 2 }; /** Смена области: новый мир + мешы, герой на входном тайле, NPC, свет, музыка. */ @@ -252,6 +266,12 @@ }); areaAsh = ashEnabled && ATMOSPHERE[currentArea.id] === 'ash'; if (!areaAsh) ash.hide(); // интерьер/туман — облако пепла не рисуется + fauna?.dispose(); // фауна области заново (олени по спавнам v1) + fauna = new FaunaSystem({ + spawns: currentArea.fauna ?? [], + blocked: tileBlocked, + addCloud: (v) => renderer.addVoxelCloud(v), + }); void audio.playTheme(currentArea.id); renderer.setTarget([Math.round(c.x), GROUND_Y, Math.round(c.z)]); } @@ -329,30 +349,31 @@ let currentDialogueId: string | null = null; const runner = new DialogueRunner(state, makeDialogueView(), invWorld); -/** Эффекты сюжета «custom» из графов: тост сразу, посадка — катсцена. */ -const CUSTOM_LINES: Record = { - tune_resonator: 'Резонатор Клинта поймал тон.', -}; - runner.onEffect = (op: DialogueEffectOp) => { // движок только сообщает: исполнение — игра (give/take — сумка) if (op.kind === 'giveItem' && op.id) inv.add(op.id, op.count ?? 1); if (op.kind === 'takeItem' && op.id) inv.remove(op.id, op.count ?? 1); if (op.kind === 'toast' && op.text) showToast(op.text); if (op.kind === 'custom' && op.id) { - // посадка — после реплик (катсцена поверх диалога не идёт, как в v1) + // сюжетные катсцены — после реплик (поверх диалога не идут, как в v1) if (op.id === 'plant_flowers') pendingHandIn = true; - else showToast(CUSTOM_LINES[op.id] ?? op.id); + else if (op.id === 'tune_resonator') pendingTune = true; + else showToast(op.id); } }; -/** Катсцена сдачи отложена до конца диалога (v1: pendingCustom в onFinish). */ +/** Катсцены отложены до конца диалога (v1: pendingCustom в onFinish). */ let pendingHandIn = false; +let pendingTune = false; runner.onFinish = () => { if (pendingHandIn) { pendingHandIn = false; startHandInCutscene(); } + if (pendingTune) { + pendingTune = false; + startTuneCutscene(); // настройка резонатора (акт 2) + } }; /** Какой граф у NPC сейчас: квест-стадия → знакомый repeat → первый. */ @@ -504,6 +525,32 @@ ]); } +/** Катсцена настройки резонатора (акт 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('Машина дышит тише. Впервые за годы — тише.') }, + ]); +} + /** Снапшот диалога для моста: узел/выборы/путь + флаги/вары + журнал. */ function dialogueSnapshot() { const node = runner.node; @@ -600,6 +647,7 @@ elapsed += dt; lighting?.update(); if (areaAsh) ash.update(elapsed, renderer.currentTarget()); + fauna?.update(dt, elapsed); // олени бродят (в фейде/катсцене мир замер) if (fade.active) { fade.t += dt; if (fade.phase === 'out') { @@ -854,6 +902,10 @@ } return false; }, + /** Центр тайла в мировых вокселях (тулза shot.mjs, гейты). */ + tileCenter(tx: number, ty: number): { x: number; z: number } { + return tileCenter(tx, ty); + }, /** Нажать «E» агентом: диалог NPC / click-переход / «дальше» в диалоге. */ interact(): void { interact(); @@ -934,6 +986,25 @@ for (const f of patch.flags ?? []) state.setFlag(f); updateHud(); }, + /** Снапшот фауны: олени области (позиция/состояние) — гейт трека 19. */ + faunaInfo() { + return { area: currentArea.id, deer: fauna?.info() ?? [] }; + }, + /** Громкий звон в точке мира (гейт: олени подходят к источнику). */ + startleFauna(x: number, z: number): void { + fauna?.startleAt(x, z); + }, + /** Камера на точку мира без телепорта героя (агентный просмотр участка). */ + setCam(x: number, z: number): void { + followHero = false; + renderer.setTarget([Math.round(x), GROUND_Y, Math.round(z)]); + }, + /** Снапшот катсцены: играет ли, шаг, таймер (числовой дебаг сцены). */ + cutsceneInfo() { + return cutscene + ? { active: true, step: cutscene.i, of: cutscene.steps.length, t: cutscene.t } + : { active: false, step: null, of: null, t: 0 }; + }, /** Снапшот состояния сюжета (флаги/вары) и сумки. */ stateInfo() { return { ...state.toData(), inv: inv.all.map((s) => ({ id: s.id, count: s.count })) }; diff --git a/v2/apps/game/tools/checks/act2.mjs b/v2/apps/game/tools/checks/act2.mjs new file mode 100644 index 0000000..b0adefd --- /dev/null +++ b/v2/apps/game/tools/checks/act2.mjs @@ -0,0 +1,209 @@ +/** + * Гейт акта 2 v2 (трек 19): Клинт появляется по act2_hook, квест «Голос для + * резонатора» (тон-деревья → тоны 3/3 → сдача с катсценой настройки), + * реакция резонатора до/после, эпилог machine_hook, усыпление Машины с + * катсценой «правильного звона», пейзажная фауна (олени: бродят, подходят + * к звону). Скриншоты: /tmp/v2_act2_*.png. + * + * Запуск: node apps/game/tools/checks/act2.mjs [--port 5324] + */ +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 ?? 5324); + +const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) }); +const report = { name: 'act2', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +/** Центр тайла (в тайлах v1) — как мост tileCenter. */ +const center = (t) => ({ x: t.x * 3 + 1.5, z: t.y * 3 + 1.5 }); +/** Тон-деревья рощи (data/quests). */ +const TREES = [[32, 6], [12, 10], [20, 30]]; + +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('setAsh', false); // кадр-равенства — мир без пепла + await client.call('setClock', 720); // полдень: страница жила в реальном времени + await client.call('stepTicks', 5); + + // ---- Роща до крючка: Клинта нет, тон-дерево молчит ---- + await client.call('gotoArea', 'rust_grove'); + await client.call('stepTicks', 5); + let ni = await client.call('npcInfo'); + check('без act2_hook: Клинта нет в роще', ni.npcs.length === 0, ni.npcs); + + const t2 = center({ x: TREES[1][0], y: TREES[1][1] }); + await client.call('heroTeleport', t2.x + 3, t2.z); + await client.call('stepTicks', 3); + await client.call('interact'); + let di = await client.call('dialogueInfo'); + check('тон-дерево до квеста: «дерево молчит», тоны 0', + di.toasts.some((t) => t.includes('молчит')) && !(di.vars.tones > 0), + { toasts: di.toasts, tones: di.vars.tones }); + + // ---- Клинт появляется, взятие квеста ---- + await client.call('setState', { flags: ['act2_hook'] }); + await client.call('gotoArea', 'rust_grove'); // перезаход — спавн по флагу + await client.call('stepTicks', 5); + ni = await client.call('npcInfo'); + check('act2_hook: Клинт стоит у резонатора (23,17)', + ni.npcs.length === 1 && ni.npcs[0].id === 'clint' + && ni.npcs[0].tile.x === 23 && ni.npcs[0].tile.y === 17, ni.npcs); + + const dC = await client.call('talkTo', 'clint'); + check('первый диалог Клинта — clint_first', dC.graphId === 'clint_first', dC.graphId); + for (let i = 0; i < 6; i++) di = await client.call('dialogueAdvance'); + check('clint_first: met_clint + quest_resonator_taken', + di.flags.includes('met_clint') && di.flags.includes('quest_resonator_taken'), + { flags: di.flags.slice(-2) }); + + // ---- Машина до настройки резонатора ---- + await client.call('gotoArea', 'machine_hall'); + await client.call('stepTicks', 5); + const mach = center({ x: 13, y: 1 }); + await client.call('heroTeleport', mach.x, mach.z + 3); + await client.call('stepTicks', 3); + await client.call('interact'); + di = await client.call('dialogueInfo'); + check('Машина без резонатора: «не спеть»', di.toasts.some((t) => t.includes('не спеть')), di.toasts); + + // ---- Тон-деревья: три тона ---- + await client.call('gotoArea', 'rust_grove'); + await client.call('stepTicks', 5); + for (const [i, [tx, ty]] of TREES.entries()) { + const c = center({ x: tx, y: ty }); + await client.call('heroTeleport', c.x + 3, c.z); + await client.call('stepTicks', 3); + await client.call('interact'); + di = await client.call('dialogueInfo'); + check(`дерево ${i + 1}: тон взят (${i + 1}/3), флаг tone_tree_${i + 1}`, + di.vars.tones === i + 1 && di.flags.includes(`tone_tree_${i + 1}`), + { tones: di.vars.tones, flag: di.flags.includes(`tone_tree_${i + 1}`) }); + } + const c1 = center({ x: TREES[0][0], y: TREES[0][1] }); // повтор дерева 1 + await client.call('heroTeleport', c1.x + 3, c1.z); + await client.call('stepTicks', 3); + await client.call('interact'); + di = await client.call('dialogueInfo'); + check('повтор дерева: «уже прозвенело», тоны не растут', + di.toasts.some((t) => t.includes('уже прозвенело')) && di.vars.tones === 3, + { toasts: di.toasts, tones: di.vars.tones }); + + // ---- Резонатор до настройки: молчит ---- + const rez = center({ x: 24, y: 16 }); + await client.call('heroTeleport', rez.x + 3, rez.z); + await client.call('stepTicks', 3); + await client.call('interact'); + di = await client.call('dialogueInfo'); + check('резонатор до настройки: «ржавый, молчит»', + di.toasts.some((t) => t.includes('молчит')), di.toasts); + + // ---- Сдача: clint_tune → катсцена настройки ---- + const dT = await client.call('talkTo', 'clint'); + check('тоны 3/3 — clint_tune', dT.graphId === 'clint_tune', dT.graphId); + await client.call('dialogueAdvance'); // accept: флаг + custom tune_resonator + di = await client.call('dialogueInfo'); + check('резонатор настроен (quest_resonator_done)', di.flags.includes('quest_resonator_done'), {}); + await client.call('dialogueAdvance'); // ring + di = await client.call('dialogueAdvance'); // конец → катсцена + await client.call('stepTicks', 100); + let cs = await client.call('cutsceneInfo'); + check('катсцена настройки играет', cs.active && cs.step >= 1, cs); + await client.call('stepTicks', 70); // шаг 3 — камера глайдит к дереву + const hi = await client.call('heroInfo'); + cs = await client.call('cutsceneInfo'); + check('катсцена: герой стоит, камера едет к дереву рощи', + cs.active && cs.step === 2 && hi.camTarget[0] > 40 && hi.camTarget[0] < 72 + && hi.tile.x === 25 && hi.tile.y === 16, + { step: cs.step, cam: hi.camTarget, tile: hi.tile }); + await client.call('stepTicks', 300); + di = await client.call('dialogueInfo'); + cs = await client.call('cutsceneInfo'); + const hiEnd = await client.call('heroInfo'); + check('конец катсцены: тост «Роща слышит», камера вернулась к герою', + !cs.active && di.toasts.some((t) => t.includes('Роща слышит')) + && hiEnd.camTarget[0] === Math.round(rez.x + 3), { toasts: di.toasts, cam: hiEnd.camTarget }); + + // ---- После настройки: реакции в лад ---- + await client.call('interact'); // герой ещё у резонатора + di = await client.call('dialogueInfo'); + check('резонатор после настройки: «держит тон ровно»', + di.toasts.some((t) => t.includes('держит тон ровно')), di.toasts); + const dR = await client.call('talkTo', 'clint'); // эпилог + check('эпилог — clint_repeat', dR.graphId === 'clint_repeat', dR.graphId); + for (let i = 0; i < 3; i++) di = await client.call('dialogueAdvance'); + check('clint_repeat: machine_hook', di.flags.includes('machine_hook'), {}); + + // ---- Усыпление Машины: катсцена «правильного звона» ---- + await client.call('gotoArea', 'machine_hall'); + await client.call('stepTicks', 5); + await client.call('heroTeleport', mach.x, mach.z + 3); + await client.call('stepTicks', 3); + await client.call('interact'); + let cs2 = await client.call('cutsceneInfo'); + di = await client.call('dialogueInfo'); + check('правильный звон: machine_lulled + катсцена усыпления', + di.flags.includes('machine_lulled') && cs2.active, { cs: cs2 }); + await client.call('stepTicks', 300); + di = await client.call('dialogueInfo'); + cs2 = await client.call('cutsceneInfo'); + check('конец: тост «Впервые за годы — тише»', + !cs2.active && di.toasts.some((t) => t.includes('Впервые за годы — тише')), di.toasts.slice(-2)); + await client.call('interact'); + di = await client.call('dialogueInfo'); + check('повтор: «Машина спит»', di.toasts.some((t) => t.includes('Машина спит')), di.toasts.slice(-2)); + + // ---- Фауна: олени лугов ---- + await client.call('gotoArea', 'meadows'); + await client.call('stepTicks', 2); + let fi = await client.call('faunaInfo'); + check('луга: два оленя на спавнах (18,36) и (24,40)', + fi.deer.length === 2 + && fi.deer[0].tile.x === 18 && fi.deer[0].tile.y === 36 + && fi.deer[1].tile.x === 24 && fi.deer[1].tile.y === 40, fi.deer); + await client.call('stepTicks', 400); // брожение: 6.7 с — шагают + const fi2 = await client.call('faunaInfo'); + check('олени бродят: тайлы сместились', + fi2.deer.some((d, i) => d.tile.x !== fi.deer[i].tile.x || d.tile.y !== fi.deer[i].tile.y), + { before: fi.deer.map((d) => d.tile), after: fi2.deer.map((d) => d.tile) }); + + // Подход к звону: звон в 1 тайле от первого оленя + const d0 = fi2.deer[0]; + await client.call('startleFauna', d0.x + 3, d0.z); + fi = await client.call('faunaInfo'); + check('звон рядом: олень идёт к источнику (approach)', + fi.deer[0].state === 'approach', fi.deer[0]); + await client.call('stepTicks', 120); // 1 тайл ≈ 1.5 с на скорости подхода + fi = await client.call('faunaInfo'); + check('олень дошёл до звона и стоит', + fi.deer[0].state === 'idle' && Math.abs(fi.deer[0].x - (d0.x + 3)) < 0.1 + && Math.abs(fi.deer[0].z - d0.z) < 0.1, fi.deer[0]); + + // Кадр оленя у воды (визуальный артефакт) + const shot = decodePng(readFileSync(await client.screenshot('/tmp/v2_act2_deer.png'))); + check('кадр с оленем не пустой', shot.rgba.length > 1000, {}); + } finally { + await browser.close(); + } +} catch (e) { + console.error(e); + report.fatal = String(e); +} + +report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok); +console.log(JSON.stringify(report)); +process.exit(report.ok ? 0 : 1); // chromium-хэндлы держат event loop — выходим явно \ No newline at end of file diff --git a/v2/apps/game/tools/checks/interact.mjs b/v2/apps/game/tools/checks/interact.mjs index 26ff870..5122296 100644 --- a/v2/apps/game/tools/checks/interact.mjs +++ b/v2/apps/game/tools/checks/interact.mjs @@ -249,6 +249,7 @@ mach1.toasts.some((t) => t.includes('не спеть')), { toasts: mach1.toasts }); await client.call('setState', { flags: ['quest_resonator_done'] }); await client.call('interact'); + await client.call('stepTicks', 300); // катсцена усыпления — тост в её финале const mach2 = await client.call('dialogueInfo'); check('правильный звон: machine_lulled + тост-финал акта 2', mach2.flags.includes('machine_lulled') diff --git a/v2/apps/game/tools/checks/npc.mjs b/v2/apps/game/tools/checks/npc.mjs index 6ebd1cf..83cc341 100644 --- a/v2/apps/game/tools/checks/npc.mjs +++ b/v2/apps/game/tools/checks/npc.mjs @@ -202,7 +202,8 @@ check('эпилог: machine_hook, все квесты закрыты (трекер пуст)', dClintRepEnd.flags.includes('machine_hook') && dClintRepEnd.questTracker === null, {}); - // Кадр рощи с Клинтом рисуется + // Кадр рощи с Клинтом рисуется (катсцена настройки доиграть — кадр чистый) + await client.call('stepTicks', 500); const shot = decodePng(readFileSync(await client.screenshot('/tmp/v2_npc_grove.png'))); check('кадр рощи с Клинтом не пустой', colorCount(shot.rgba) >= 8, {}); } finally { diff --git a/v2/apps/game/tools/shot.mjs b/v2/apps/game/tools/shot.mjs new file mode 100644 index 0000000..507ebab --- /dev/null +++ b/v2/apps/game/tools/shot.mjs @@ -0,0 +1,77 @@ +/** + * Оперативный просмотр участка карты агентом: headless-браузер, состояние + * сюжета (область/флаги/вары/время суток), камера на точку мира БЕЗ + * телепорта героя, тики, снимок, выход. Для «увидеть» — дебаг и визуальные + * вопросы; для проверок — числовые снапшоты моста (детерминизм). + * + * Запуск (от v2/): + * node apps/game/tools/shot.mjs --area rust_grove --at 24,16 \ + * [--flags act2_hook,rang_tower] [--vars tones=3] [--clock 1134] \ + * [--ash off] [--ticks 10] [--port 5322] --out /tmp/v2_shot.png + */ +import { fileURLToPath } from 'node:url'; +import { AgentClient, launchBrowser, startDevServer, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port ?? 5322); +const out = args.out ?? '/tmp/v2_shot.png'; + +/** «24,16» → {x, y} в тайлах v1. */ +function tile(s) { + const [x, y] = String(s).split(',').map(Number); + if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error(`--at ожидает tx,ty — дано ${s}`); + return { x, y }; +} + +const server = startDevServer({ port, cwd: fileURLToPath(new URL('../', import.meta.url)) }); +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + // Загрузка с диагностикой: pageerror/консоль видны даже при таймауте. + const page = await browser.newPage(); + const pageErrors = []; + page.on('pageerror', (e) => pageErrors.push(String(e))); + page.on('console', (m) => console.error('[console]', m.type(), m.text().slice(0, 200))); + await page.goto(`http://localhost:${port}/`, { waitUntil: 'domcontentloaded', timeout: 60000 }); + try { + await page.waitForFunction('window.__beacon === true', { timeout: 60000 }); + } catch { + console.error('маяк не зажёгся; pageerror:', pageErrors.slice(0, 5)); + for (const l of (page.consoleLogs ?? []).slice(0, 10)) console.error(l); + throw new Error('игра не загрузилась'); + } + await new Promise((r) => setTimeout(r, 1200)); // первый кадр, прогрев + const client = new AgentClient(page); + client.pageErrors = pageErrors; // openGame делает это сам — тут вручную + await client.call('setManual', true); + if (args.ash === 'off') await client.call('setAsh', false); // чистый мир + if (args.clock !== undefined) await client.call('setClock', Number(args.clock)); + if (args.flags || args.vars) { + await client.call('setState', { + flags: args.flags ? String(args.flags).split(',').filter(Boolean) : [], + vars: args.vars + ? Object.fromEntries(String(args.vars).split(',').filter(Boolean).map((kv) => kv.split('='))) + : {}, + }); + } + await client.call('gotoArea', args.area ?? 'meadows'); + // Камера на участок (герой остаётся на спавне, триггеры не трогаем). + if (args.at) { + const t = tile(args.at); + const c = await client.call('tileCenter', t.x, t.y); + await client.call('setCam', c.x, c.z); + } + await client.call('stepTicks', Number(args.ticks ?? 10)); + const path = await client.screenshot(out); + console.log(`снимок: ${path} (область ${args.area ?? 'meadows'}, камера ${args.at ?? 'у спавна'})`); + const errs = client.errors(); + if (errs.length) console.error('pageerror:', errs.slice(0, 3)); + } finally { + await browser.close(); + } +} catch (e) { + console.error(e); + process.exit(1); +} +process.exit(0); // chromium-хэндлы держат event loop — выходим явно \ No newline at end of file