diff --git a/CLAUDE.md b/CLAUDE.md index 9c01773..61df4e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,7 @@ - **Одна клавиша маппится на несколько действий** (`bindActions` дополняет, а не перезаписывает): Space = `advance` в диалоге и `attack` вне его — не развести обратно нельзя, гардится уровнем сцены. - **Координаты указателя и worldRoot — виртуальные пиксели** (480×270), экранные CSS-пиксели — это виртуальные × масштаб: `экран = мир + worldRoot.position` (перевод в CSS — домножением на scale). Цепочка клика по миру: `pointer px − worldRoot.position → screenToWorld → юниты → worldToTile`. Не смешивай пространства: позиция героя/врагов — юниты, вью — px. - **Поворот героя (facing) — по экранным компонентам мирового смещения** (`worldToScreen(delta)`), не по мировым: экранные оси — мировые диагонали, кадры down/up/side соответствуют экранному направлению. +- **Клик по тайлу вне экрана не работает**: если целевой тайл за краем канваса (виртуальный y > 270 или x < 0), клик уходит мимо канваса — герой не двигается. В смоук-скриптах целиться в промежуточный тайл рядом с героем по направлению к цели (шаг по доминирующей оси). - Мир/арт сверять с `docs/world.md` и `docs/art-style.md`: тёплые цвета — только жизнь; палитра — только из `tools/pixelart/palette.mjs`. ## Рабочие привычки diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index b1bf055..ce3c8f1 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -34,6 +34,7 @@ import type { EnemyKindId } from '../data/enemies'; import { PlayerController, type HeroTextures } from '../systems/PlayerController'; import { FaunaSystem } from '../systems/fauna/FaunaSystem'; +import { CutsceneRunner } from '@rpg/engine'; import { DialogueSystem } from '../systems/DialogueSystem'; import { CombatWorld } from '../systems/combat/CombatWorld'; import { CombatViews } from '../systems/combat/CombatViews'; @@ -77,6 +78,8 @@ private fog: Graphics; /** Пейзажная фауна (безгласные олени). */ private fauna: FaunaSystem; + /** Кат-сцены (раннер шагов; на время сцены геймплей на паузе). */ + private cutscene = new CutsceneRunner(); /** Зона наката, в которой герой был в прошлом кадре (для тоста при входе). */ private inHazard: HazardDef | null = null; /** Тач-джойстик (активен только для касаний). */ @@ -277,6 +280,11 @@ update(dt: number): void { this.ash.update(dt); this.fauna.update(dt); + // Кат-сцена: мир на паузе, камера под контролем раннера. + if (this.cutscene.active) { + this.cutscene.update(dt); + return; + } const input = this.game.engine.input; if (this.dialogue.active) { // Во время диалога клик/пробел только листают реплики. @@ -659,24 +667,78 @@ private onDialogueFinished(id: string): void { // Побочные эффекты — из квест-стадий реестра. const effect = questEffectFor(id); - if (effect === 'plant_flowers') this.plantFlowers(); + if (effect === 'plant_flowers') this.startHandInCutscene(); if (effect === 'give_cloth') this.game.inventory.add('cloth'); } - /** Посадка цветов у тропы: тайлы гудят колокольчиками (крючок акта 1). */ + /** Посадка цветов у тропы: поляна гудит колокольчиками и разрастается. */ private plantFlowers(): void { const left = this.game.state.getNumber('flowers') - QUEST_FLOWERS; this.game.state.setVar('flowers', Math.max(0, left)); - for (const [tx, ty] of [ + const planted: [number, number][] = [ [15, 13], [16, 14], [15, 15] - ]) { + ]; + for (const [tx, ty] of planted) { this.map.setTile(tx, ty, TILES.BELLFLOWER); this.combatViews.hitBurst(this.tileCenter(tx, ty)); } + // Пересев: поляна расползается на соседние тайлы (якорь «надежда растёт»). + for (const [tx, ty] of planted) { + for (const [dx, dy] of [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1] + ]) { + const id = this.tileId(tx + dx, ty + dy); + if (id === TILES.GRASS || id === TILES.ASH) this.map.setTile(tx + dx, ty + dy, TILES.BELLFLOWER); + } + } void this.game.audio.play('sfx/bell_low', 0.8); - this.showToast('Гул снизу стал громче... Машина дышит.'); + this.showToast('Цветы в земле. Поляна гудит.'); + } + + /** Кат-сцена сдачи: посадка, камера к башне, удар колокола, зелень — финал акта 1. */ + private startHandInCutscene(): void { + const tower = tileToWorld(14, 7); // башня Звенца (юниты) + const s = worldToScreen(tower.x, tower.y); + this.cutscene.play([ + // Пересев: поляна гудит, герой стоит. + { kind: 'call', fn: () => this.plantFlowers(), seconds: 0.8 }, + // Камера уходит к башне. + { kind: 'cameraMove', x: tower.x, y: tower.y, seconds: 1.2, camera: this.game.camera }, + // Удар колокола: низкий звон, тряска, кольцо резонанса. + { + kind: 'burst', + fn: () => { + void this.game.audio.play('sfx/bell_low', 1); + this.game.camera.addShake(2.5, 0.4); + this.combatViews.resonanceRing(s); + }, + seconds: 1 + }, + // Зелень поднимается у подножия башни (визуальный финал акта). + { + kind: 'burst', + fn: () => { + for (const [tx, ty] of [ + [13, 8], + [15, 8], + [14, 9] + ]) { + if (this.tileId(tx, ty) === TILES.GRASS || this.tileId(tx, ty) === TILES.ASH) { + this.map.setTile(tx, ty, TILES.BELLFLOWER); + this.combatViews.hitBurst(this.tileCenter(tx, ty)); + } + } + }, + seconds: 1.2 + }, + // Тост-финал акта 1. + { kind: 'call', fn: () => this.showToast('Пепел отступил у тропы. Машина дышит тише... но дышит.') } + ]); } /** Всплывающая подсказка: появляется и растворяется над UI. */ diff --git a/docs/demo.md b/docs/demo.md index 077b70d..53ea9b4 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -23,12 +23,13 @@ | SaveManager (слоты) | autosave (Esc) + 3 слота, `SaveSlotsScene` | готово | | Settings + onChange | громкости master/music/sfx, `SettingsScene` | готово | | AudioManager (шины) | музыка локаций с кроссфейдом, sfx боя/шагов/UI | готово | -| Tween + easing | хит-флэш, кольца резонанса, всплывающие тосты | готово | +| Tween + easing | хит-флэш, кольца резонанса, всплывающие тосты, кат-сцены | готово | | Particles (атмосфера + burst) | пепел лугов, туман прудов, удары/смерти сгустков | готово | | StateMachine | `EnemyBrain` — ИИ сгустков (dormant/rise/chase/…/sleep/dead) | готово | | ECS (World) | боевые сущности: враги и снаряды на `Entity` | готово | | Panel/Button/MenuList | меню, настройки, сейвы, сумка | готово | | DialogueRunner + DialogueBox | графы `data/dialogues.ts`, ветки квеста | готово | +| CutsceneRunner | кат-сцена сдачи квеста (посадка → камера к башне → колокол → зелень → финал) | готово | | DebugOverlay | F3: fps/тайл/позиция/hp | готово | | math/shapes (cone/circle) | конус удара, круг цели, кольца | готово | | Cooldown | кулдауны удара/резонанса/атак врагов | готово | @@ -79,10 +80,24 @@ - `map/IsometricTileMap.setTile` — изменение тайла с перерисовкой ячейки. - `map/pathfinding.findPathToNeighbor` — путь к краю занятого/непроходимого тайла (гейтинг взаимодействий по дистанции в демо). +- `cutscene/CutsceneRunner.ts` — view-агностичный раннер шагов (`call/cameraMove/burst/wait/setFlag`), + шаг с `seconds: 0` — параллельный; сцена ставит геймплей на паузу по `active`. ## Дорожная карта (что осталось за срезом) -- Акт 1 до конца: финальная кат-сцена с Tween-камерой к башне (зоны наката у прудов готовы: дымка, замедление, полотно-маска; фауна готова: безгласные олени на мини-StateMachine, мотыли над потревоженным пеплом). +**Следующий срез — строительный набор систем (запрос игрока):** + +- **Телепортация**: точки перехода внутри локации (порталы/колодцы) и между + локациями без «пешего» перехода (витрина SceneManager + fade). +- **Здания с интерьерами**: вход в дома (двери-переходы), интерьерные карты, + объекты внутри (сундук, лавка). +- **Взаимодействие с миром шире**: интерактивные объекты (не только NPC/цветки), + осмотр/подбор предметов, реакция мира (вары/флаги). +- **Системы и ИИ врагов**: расширение EnemyBrain (патруль/агро-радиус/отступление), + поведение вне боя — спящие стаи, реакция на звук без боя (витрина StateMachine). + +**Остальное (по мере готовности базиса):** + - Экономика Милы: торговля (соль/моты), мот-песок как валюта. - Звонная книга Ирвина: журнал заданий расширяется за пределы одного квеста. - Tiled-импорт: альтернативный путь производства карт (для больших локаций). diff --git a/docs/engine/README.md b/docs/engine/README.md index 370ef06..4c5c7b9 100644 --- a/docs/engine/README.md +++ b/docs/engine/README.md @@ -13,6 +13,7 @@ | [input.md](input.md) | Действия, клавиатура, мышь/тач, геймпад, виртуальный джойстик | | [maps.md](maps.md) | Изометрия, A*, формат карт, импорт из Tiled | | [ui-and-dialogue.md](ui-and-dialogue.md) | UI-кит, PixelText, диалоговые графы | +| [cutscene.md](cutscene.md) | CutsceneRunner: кат-сцены из шагов | | [assets-audio-save.md](assets-audio-save.md) | AssetLoader, атласы, AudioManager, сейвы | | [art-pipeline.md](art-pipeline.md) | Генератор пиксель-арта, палитра, замена на рисованный арт | | [recipes.md](recipes.md) | «Как сделать…»: готовые решения типовых задач | @@ -64,6 +65,7 @@ map/ IsometricTileMap, pathfinding (A*), mapFormat (JSON+RLE), tiled-импортёр math/ Vec2, изометрия, seeded RNG dialogue/ DialogueRunner (графы диалогов, view-агностик) + cutscene/ CutsceneRunner (кат-сцены из шагов, view-агностик) ui/ DialogueBox, Panel, Button, MenuList, PixelText anim/ FrameAnimation audio/ AudioManager (шины master/music/sfx, кроссфейд) diff --git a/docs/engine/cutscene.md b/docs/engine/cutscene.md new file mode 100644 index 0000000..42b46a4 --- /dev/null +++ b/docs/engine/cutscene.md @@ -0,0 +1,58 @@ +# Кат-сцены: CutsceneRunner + +`packages/engine/src/cutscene/CutsceneRunner.ts` — view-агностичный раннер кат-сцен. +Сцена — это список шагов; раннер исполняет их последовательно и сообщает `active`, +чтобы игра поставила геймплей на паузу. Никакой привязки к Pixi/вью — только +эффекты и камера, передаваемые в шагах. + +## Шаги + +```ts +import { CutsceneRunner, type CutsceneStep } from '@rpg/engine'; + +const steps: CutsceneStep[] = [ + // Произвольный вызов (диалог, тост, логика сцены) + длительность: + { kind: 'call', fn: () => scene.showToast('Пепел отступил.'), seconds: 1 }, + + // Плавное перемещение камеры (в мировых юнитах) к точке: + { kind: 'cameraMove', x: tower.x, y: tower.y, seconds: 1.2, camera: scene.camera }, + + // Разовый эффект (звук, кольцо резонанса, вспышка частиц): + { kind: 'burst', fn: () => scene.ringAt(tower), seconds: 1 }, + + // Поднять флаг прохождения (мгновенно): + { kind: 'setFlag', flag: 'act1_done', state: game.state }, + + // Просто пауза: + { kind: 'wait', seconds: 0.5 }, +]; + +const runner = new CutsceneRunner(); +runner.onComplete = () => console.log('сцена закончилась'); +runner.play(steps); +``` + +## Семантика + +- **Последовательность.** Каждый шаг при старте выполняет свой эффект; раннер + ждёт `seconds` и переходит к следующему. +- **Параллельные шаги.** Шаг с `seconds: 0` (или `undefined` у `burst`/`call`) + не блокирует: следующий стартует в том же тике. Так делают «проверку воздуха» — + кольцо и звук в один кадр с продолжением сцены. +- **Пауза геймплея** — ответственность сцены: в `update` проверять `runner.active`, + обновлять раннер и выходить (см. `LocationScene.update`). +- **Движение камеры** — по `sineInOut`; точка назначения достигается точно. + +## Наблюдение за шагами + +Конструктор принимает наблюдателя — удобно для отладки: + +```ts +const runner = new CutsceneRunner((step) => console.log('шаг:', step.kind)); +``` + +## Тесты + +`cutscene/__tests__/CutsceneRunner.test.ts` — порядок шагов, паузы, интерполяция +камеры (точное попадание в точку в конце), флаги, порядок наблюдателя. +Конструируется без DOM: `new Camera(viewWidth, viewHeight)`. \ No newline at end of file diff --git a/packages/engine/src/cutscene/CutsceneRunner.ts b/packages/engine/src/cutscene/CutsceneRunner.ts new file mode 100644 index 0000000..7879d38 --- /dev/null +++ b/packages/engine/src/cutscene/CutsceneRunner.ts @@ -0,0 +1,114 @@ +import { sineInOut, type EaseFn } from '../core/easing'; +import { GameState } from '../core/GameState'; +import type { Camera } from '../render/Camera'; +import type { Vec2 } from '../math/Vec2'; + +/** + * Раннер кат-сцен: view-агностичная последовательность шагов. + * Каждый шаг при старте выполняет свой эффект; раннер ждёт его длительность + * (0 — шаг «параллельный», следующий стартует сразу) и переходит к следующему. + * Сцена на время сцены ставит геймплей на паузу — раннер только сообщает `active`. + */ + +export type CutsceneStep = + /** Подождать N секунд (ничего не делая). */ + | { kind: 'wait'; seconds: number } + /** Плавно переместить камеру (в юнитах) к точке. */ + | { kind: 'cameraMove'; x: number; y: number; seconds: number; camera: Camera } + /** Поднять флаг прохождения (мгновенно). */ + | { kind: 'setFlag'; flag: string; state: GameState } + /** Разовый эффект (звук, кольцо, вспышка частиц). */ + | { kind: 'burst'; fn: () => void; seconds?: number } + /** Произвольный вызов (диалог, посадка, тост и т.п.). */ + | { kind: 'call'; fn: () => void; seconds?: number }; + +interface RunningStep { + seconds: number; + /** Движение камеры: from -> to по easing. */ + cameraMove?: { from: Vec2; to: Vec2; camera: Camera }; +} + +/** Плавность движения камеры по умолчанию. */ +const EASE: EaseFn = sineInOut; + +export class CutsceneRunner { + private queue: CutsceneStep[] = []; + private current: RunningStep | null = null; + private t = 0; + /** Вызывается, когда все шаги дошли до конца. */ + onComplete: (() => void) | null = null; + + constructor( + /** Наблюдатель: вызывается при старте каждого шага (лог, отладка). */ + private onStep?: (step: CutsceneStep) => void + ) {} + + /** Запустить последовательность шагов (немедленно). */ + play(steps: CutsceneStep[]): void { + this.queue = [...steps]; + this.t = 0; + this.next(); + } + + /** Идёт ли сейчас сцена (геймплей на паузе). */ + get active(): boolean { + return this.current !== null; + } + + /** Секунд прошло с начала текущего шага. */ + get time(): number { + return this.t; + } + + private next(): void { + const step = this.queue.shift(); + if (!step) { + this.current = null; + this.onComplete?.(); + return; + } + this.onStep?.(step); + switch (step.kind) { + case 'wait': + this.current = { seconds: step.seconds }; + break; + case 'cameraMove': + this.current = { + seconds: step.seconds, + cameraMove: { + from: { x: step.camera.x, y: step.camera.y }, + to: { x: step.x, y: step.y }, + camera: step.camera + } + }; + break; + case 'setFlag': + step.state.setFlag(step.flag); + this.current = { seconds: 0 }; + break; + case 'burst': + case 'call': + step.fn(); + this.current = { seconds: step.seconds ?? 0 }; + break; + } + // Параллельный шаг (0 секунд) — сразу к следующему. + if (this.current.seconds <= 0) this.next(); + } + + update(dt: number): void { + if (!this.current) return; + this.t += dt; + const move = this.current.cameraMove; + if (move) { + const k = Math.min(1, this.t / this.current.seconds); + const e = EASE(k); + move.camera.x = move.from.x + (move.to.x - move.from.x) * e; + move.camera.y = move.from.y + (move.to.y - move.from.y) * e; + } + if (this.t >= this.current.seconds) { + this.t = 0; + this.next(); + } + } +} \ No newline at end of file diff --git a/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts b/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts new file mode 100644 index 0000000..b0e4618 --- /dev/null +++ b/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { GameState } from '../../core/GameState'; +import { Camera } from '../../render/Camera'; +import { CutsceneRunner } from '../CutsceneRunner'; + +/** Продвинуть раннер: dt N раз. */ +function tick(runner: CutsceneRunner, dt: number, times: number): void { + for (let i = 0; i < times; i++) runner.update(dt); +} + +/** Фиктивный «взгляд»: камера с заданным центром. */ +function cameraAt(x: number, y: number): Camera { + const cam = new Camera(480, 270); + cam.x = x; + cam.y = y; + return cam; +} + +describe('CutsceneRunner — последовательность шагов', () => { + it('шаги выполняются по порядку, wait держит паузу', () => { + const runner = new CutsceneRunner(); + const order: string[] = []; + runner.play([ + { kind: 'call', fn: () => order.push('a') }, + { kind: 'wait', seconds: 0.5 }, + { kind: 'call', fn: () => order.push('b') } + ]); + // Первый шаг стартовал сразу, второй ещё ждёт. + expect(order).toEqual(['a']); + expect(runner.active).toBe(true); + tick(runner, 0.1, 4); // 0.4с — всё ещё wait + expect(order).toEqual(['a']); + tick(runner, 0.2, 1); // 0.6с — wait кончился + expect(order).toEqual(['a', 'b']); + expect(runner.active).toBe(false); + }); + + it('шаг без seconds параллелен: следующий стартует сразу', () => { + const runner = new CutsceneRunner(); + const order: string[] = []; + runner.play([ + { kind: 'burst', fn: () => order.push('burst') }, + { kind: 'call', fn: () => order.push('call') } + ]); + // Шаги без длительности не блокируют: оба стартовали в том же тике. + expect(order).toEqual(['burst', 'call']); + expect(runner.active).toBe(false); + }); + + it('cameraMove плавно ведёт камеру к точке', () => { + const runner = new CutsceneRunner(); + const cam = cameraAt(0, 0); + runner.play([{ kind: 'cameraMove', x: 10, y: -20, seconds: 1, camera: cam }]); + tick(runner, 0.25, 2); // половина пути + expect(cam.x).toBeGreaterThan(0); + expect(cam.x).toBeLessThan(10); + expect(cam.y).toBeLessThan(0); + tick(runner, 0.25, 3); // время вышло — точно в точке + expect(cam.x).toBe(10); + expect(cam.y).toBe(-20); + expect(runner.active).toBe(false); + }); + + it('setFlag поднимает флаги, onComplete вызывается в конце', () => { + const runner = new CutsceneRunner(); + const state = new GameState(); + let done = 0; + runner.onComplete = () => done++; + runner.play([ + { kind: 'setFlag', flag: 'flag1', state }, + { kind: 'setFlag', flag: 'flag2', state } + ]); + expect(state.hasFlag('flag1')).toBe(true); + expect(state.hasFlag('flag2')).toBe(true); + expect(done).toBe(1); + }); + + it('onStep наблюдает порядок шагов', () => { + const seen: string[] = []; + const runner = new CutsceneRunner((s) => seen.push(s.kind)); + runner.play([ + { kind: 'call', fn: () => undefined, seconds: 0.1 }, + { kind: 'wait', seconds: 0.1 } + ]); + // Первый шаг стартовал; второй — после его длительности. + expect(seen).toEqual(['call']); + tick(runner, 0.2, 1); + expect(seen).toEqual(['call', 'wait']); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 64dcb03..b9df9d4 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -28,6 +28,8 @@ // inventory export { Inventory, inventoryFromData, type InventoryData } from './inventory/Inventory'; +// cutscene +export { CutsceneRunner, type CutsceneStep } from './cutscene/CutsceneRunner'; // scene export { SceneManager, type Scene, type SceneTransition } from './scene/SceneManager'; diff --git a/tools/smoke-act1.mjs b/tools/smoke-act1.mjs new file mode 100644 index 0000000..04ae281 --- /dev/null +++ b/tools/smoke-act1.mjs @@ -0,0 +1,158 @@ +/** + * Полный прогон акта 1: луга -> Звенец -> квест у Ирвина -> (сбор цветов + * эмулируется var'ом) -> сдача -> кат-сцена с колоколом. + * Запуск: node tools/smoke-act1.mjs [url] [скриншот] + */ +import puppeteer from 'puppeteer-core'; + +const url = process.argv[2] ?? 'http://localhost:5199/'; +const shot = process.argv[3] ?? '/tmp/rpg_act1.png'; + +const browser = await puppeteer.launch({ + executablePath: '/usr/bin/chromium', + headless: true, + // autoplay-policy: в headless ctx.resume() без флага может не резолвиться, + // и навигация, завязанная на звук, зависает. + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', + '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] +}); +const page = await browser.newPage(); +await page.setViewport({ width: 960, height: 540 }); + +page.on('console', (msg) => console.log(`[консоль] ${msg.text()}`)); +page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); + +const booted = new Promise((resolve) => { + page.on('console', (msg) => { + if (msg.text().includes('[boot] ассеты загружены')) resolve(); + }); +}); + +await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); +await booted; +await new Promise((r) => setTimeout(r, 1500)); + +// «Новая игра» +await page.mouse.click(480, 283); +await new Promise((r) => setTimeout(r, 2500)); + +// Усыпить сгустков на лугах: путь героя не должен прерываться уроном. +await page.evaluate(() => { + const g = window.__game; + for (const [, en] of g.scenes.current.combat.enemies) en.brain.putToSleep(9999); +}); + +// Клик в тайл (tx,ty) текущей локации. +async function clickTile(tx, ty) { + const p = await page.evaluate((tx, ty) => { + const g = window.__game; + const rect = document.querySelector('canvas').getBoundingClientRect(); + const wr = g.renderer.worldRoot.position; + const wx = (tx - ty) * 16; + const wy = (tx + ty) * 8 + 8; + return { x: rect.left + ((wx + wr.x) / 480) * rect.width, + y: rect.top + ((wy + wr.y) / 270) * rect.height }; + }, tx, ty); + await page.mouse.click(p.x, p.y); +} + +// Пошагово к цели: кликаем в соседний тайл от героя по направлению к цели — +// он всегда на экране (цель может уходить за нижний край канваса). +async function walkTo(tx, ty) { + for (let i = 0; i < 60; i++) { + const at = await page.evaluate((tx, ty) => { + const t = window.__game.scenes.current?.player?.currentTile(); + return t && t.x === tx && t.y === ty; + }, tx, ty); + if (at) return true; + const step = await page.evaluate((tx, ty) => { + const g = window.__game; + const t = g.scenes.current?.player?.currentTile(); + if (!t) return null; + // шаг по доминирующей оси к цели + const nx = Math.abs(tx - t.x) >= Math.abs(ty - t.y) + ? t.x + Math.sign(tx - t.x) : t.x; + const ny = nx === t.x ? t.y + Math.sign(ty - t.y) : t.y; + const rect = document.querySelector('canvas').getBoundingClientRect(); + const wr = g.renderer.worldRoot.position; + const wx = (nx - ny) * 16; + const wy = (nx + ny) * 8 + 8; + return { x: rect.left + ((wx + wr.x) / 480) * rect.width, + y: rect.top + ((wy + wr.y) / 270) * rect.height, nx, ny }; + }, tx, ty); + if (!step) return false; + if (i % 10 === 9) { + const t = await page.evaluate(() => { + const sc = window.__game.scenes.current; + return { tile: sc.player?.currentTile(), hp: sc.playerCombat?.hp }; + }); + console.log(` ...к (${tx},${ty}): герой в`, JSON.stringify(t)); + } + await page.mouse.click(step.x, step.y); + await new Promise((r) => setTimeout(r, 700)); + } + return false; +} + +// 1) Луга -> тропа в Звенец (26,14) — триггер срабатывает на самом тайле. +console.log('шаг 1: луга -> Звенец'); +await walkTo(26, 14); +await new Promise((r) => setTimeout(r, 1500)); +// на всякий случай снова усыпить (переход мог вернуть героя в бой) +await page.evaluate(() => { + const g = window.__game; + for (const [, en] of g.scenes.current.combat?.enemies ?? []) en.brain.putToSleep(9999); +}); + +// 2) Звенец: к Ирвину (12,9). +console.log('шаг 2: к Ирвину'); +await walkTo(11, 9); +await new Promise((r) => setTimeout(r, 500)); + +// Листаем диалог до конца: ждём, пока он откроется (герой мог идти к NPC), +// затем жмём Space, пока не закроется. Лишние клики по миру сбрасывают +// отложенный разговор — поэтому именно Space, а не клики. +async function runDialogue() { + let open = false; + for (let i = 0; i < 25; i++) { + if (await page.evaluate(() => window.__game.scenes.current?.dialogue?.active === true)) { + open = true; + break; + } + await new Promise((r) => setTimeout(r, 200)); + } + if (!open) { console.log(' ! диалог не открылся'); return; } + for (let i = 0; i < 12; i++) { + if (!(await page.evaluate(() => window.__game.scenes.current?.dialogue?.active === true))) break; + await page.keyboard.press('Space'); + await new Promise((r) => setTimeout(r, 350)); + } +} + +// 3) Диалог с Ирвином: квест «Три цветка». +console.log('шаг 3: квест у Ирвина'); +await clickTile(12, 9); +await runDialogue(); + +// 4) Сбор цветов — эмуляция var'ом (вылазка проверяется smoke-ponds). +console.log('шаг 4: цветы собраны (эмуляция)'); +await page.evaluate(() => { + const g = window.__game; + g.state.setVar('flowers', 3); +}); + +// 5) Сдача Ирвину -> кат-сцена. +console.log('шаг 5: сдача -> кат-сцена'); +await clickTile(12, 9); +await runDialogue(); + +// 6) Даём кат-сцене (~4с) доиграть до финального тоста. +await new Promise((r) => setTimeout(r, 5000)); +const state = await page.evaluate(() => { + const g = window.__game; + return { vars: g.state.serialize().vars, flags: g.state.allFlags }; +}); +console.log('итог:', JSON.stringify(state)); +await page.screenshot({ path: shot }); +console.log(`Скриншот: ${shot}`); +await browser.close(); \ No newline at end of file