diff --git a/apps/game/src/agent/GameAgent.ts b/apps/game/src/agent/GameAgent.ts index f5adcc6..f0662e5 100644 --- a/apps/game/src/agent/GameAgent.ts +++ b/apps/game/src/agent/GameAgent.ts @@ -1,7 +1,6 @@ import { EngineAgent, dayNightFactor, - worldToScreen, type AgentHost, type Invariant, type JsonValue, @@ -14,7 +13,6 @@ gameLayer, type DialogueSnapshot, type GameSnapshot, - type HeroSnapshot, type InteractableSnapshot } from './snapshot'; import { validateContent } from '../data/validate'; @@ -38,8 +36,6 @@ pred: string, opts?: { timeoutTicks?: number; render?: boolean } ): Promise; - /** Клик по тайлу (в юнитах; не зависит от видимости тайла на канвасе). */ - tapTile(tx: number, ty: number): void; /** Клик в виртуальных пикселях (480×270). */ tapVirtual(vx: number, vy: number): void; /** Реальный DOM-клик (Pixi-кнопки меню). */ @@ -50,8 +46,10 @@ key(code: string): void; /** Whitelist-команда сцены (scene:sleepAll, scene:teleport, ...). */ command(name: string, args?: JsonValue): JsonValue; - /** Пойти в тайл по маршруту A* (в сцене). false — путь не найден. */ + /** Пойти в тайл по маршруту A* (в сцене). false — путь не найден/не дошёл. */ walkTo(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise; + /** Подойти к тайлу (цель может быть непроходима). null — соседей нет/не дошёл. */ + approach(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise<{ x: number; y: number } | null>; /** Дождаться и долистать активный диалог. false — диалог не открылся. */ runDialogue(timeoutTicks?: number): Promise; /** Дождаться выбора в диалоге и выбрать вариант по тексту. false — не дождались/нет варианта. */ @@ -175,14 +173,6 @@ return { ok: false, snapshot: this.snapshot(), ticks: limit }; } - /** Клик по тайлу: юниты -> экран (виртуальные px, с зумом) -> инъекция. */ - tapTile(tx: number, ty: number): void { - const w = worldToScreen(tx + 0.5, ty + 0.5); - const root = this.game.worldRoot; - const z = root.scale.x; - this.tapVirtual(w.x * z + root.position.x, w.y * z + root.position.y); - } - tapVirtual(vx: number, vy: number): void { this.engineAgent.tapVirtual(vx, vy); } @@ -204,29 +194,39 @@ } /** - * Идти в тайл: маршрут просит сцену (scene:route), кликает по узлам по очереди. - * Узел, клик в который сработал как взаимодействие (мот/цветок/NPC на пути), - * пропускается: герой останавливается рядом, идём дальше со следующего. + * Идти в тайл: сцена строит A* и ведёт героя сама (scene:walk), мост ждёт + * тайла цели. Без `!moving`: step-переход срабатывает, как только округлённый + * тайл героя становится целевым (сцена замирает ещё в moving), — окна «на + * тайле и стоит» может не быть. false — пути нет. */ async walkTo(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise { - const route = this.command('scene:route', { x: tx, y: ty }); - if (!Array.isArray(route) || route.length === 0) return false; - let misses = 0; - for (const node of route as { x: number; y: number }[]) { - this.tapTile(node.x, node.y); - const wait = await this.waitFor( - `s.hero && s.hero.tile && s.hero.tile.x === ${node.x} && s.hero.tile.y === ${node.y}`, - { timeoutTicks: opts?.timeoutTicks ?? 240 } - ); - if (!wait.ok) { - const hero = this.snapshot().hero as HeroSnapshot | undefined; - // Герой ещё идёт или что-то пошло не так — не угадываем. - if (!hero?.tile || hero.moving || ++misses > 4) return false; - continue; - } - } - const hero = this.snapshot().hero as HeroSnapshot | undefined; - return hero?.tile?.x === tx && hero?.tile?.y === ty; + const started = this.command('scene:walk', { x: tx, y: ty }); + if (started !== true) return false; + const wait = await this.waitFor( + `s.hero && s.hero.tile && s.hero.tile.x === ${tx} && s.hero.tile.y === ${ty}`, + { timeoutTicks: opts?.timeoutTicks ?? 600 } + ); + return wait.ok; + } + + /** + * Подойти к тайлу (цель может быть непроходима: стены, объекты, NPC-тайлы, + * тела врагов). Сцена ведёт к ближайшему проходимому соседу (scene:approach). + * Возврат — тайл прибытия или null (соседей нет / не дошёл). + */ + async approach( + tx: number, + ty: number, + opts?: { timeoutTicks?: number } + ): Promise<{ x: number; y: number } | null> { + const dest = this.command('scene:approach', { x: tx, y: ty }); + const d = dest as { x?: unknown; y?: unknown } | null; + if (!d || typeof d.x !== 'number' || typeof d.y !== 'number') return null; + const wait = await this.waitFor( + `s.hero && s.hero.tile && s.hero.tile.x === ${d.x} && s.hero.tile.y === ${d.y} && !s.hero.moving`, + { timeoutTicks: opts?.timeoutTicks ?? 600 } + ); + return wait.ok ? { x: d.x, y: d.y } : null; } /** Долистать активный диалог: жать advance, пока он открыт. */ @@ -283,13 +283,13 @@ invariants: () => agent.invariants(), step: (n, opts) => agent.step(n, opts), waitFor: (pred, opts) => agent.waitFor(pred, opts), - tapTile: (tx, ty) => agent.tapTile(tx, ty), tapVirtual: (vx, vy) => agent.tapVirtual(vx, vy), uiTap: (vx, vy) => agent.uiTap(vx, vy), press: (action, holdTicks) => agent.press(action, holdTicks), key: (code) => agent.key(code), command: (name, args) => agent.command(name, args), walkTo: (tx, ty, opts) => agent.walkTo(tx, ty, opts), + approach: (tx, ty, opts) => agent.approach(tx, ty, opts), runDialogue: (timeoutTicks) => agent.runDialogue(timeoutTicks), pickChoiceByText: (text, timeoutTicks) => agent.pickChoiceByText(text, timeoutTicks), newGame: () => agent.newGame(), diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 05770eb..278c1d7 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -1,6 +1,7 @@ import { dayNightFactor, findPath, + findPathToNeighbor, checkFinite, checkRange, checkWalkable, @@ -131,6 +132,24 @@ // Маршрут как у героя: тайлы NPC заняты, иначе мост ведёт сквозь тело. return findPath(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }, false) ?? null; }, + 'scene:walk': (d, a) => { + // Пойти в тайл (как герой): A* + followPath, без инъекции клика. + if (!hasXY(a)) return null; + const path = findPath(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }, false); + if (!path || path.length === 0) return false; + d.player.followPath(path); + return true; + }, + 'scene:approach': (d, a) => { + // Подойти к краю (не)проходимого тайла: цель может быть стеной/объектом/NPC. + // Возврат — тайл прибытия ({x, y}); уже рядом — текущий тайл героя. + if (!hasXY(a)) return null; + const path = findPathToNeighbor(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }); + if (path === null) return null; + const dest = path.length > 0 ? path[path.length - 1]! : d.player.currentTile(); + if (path.length > 0) d.player.followPath(path); + return { x: dest.x, y: dest.y }; + }, 'scene:walkable': (d, a) => { // Проходим ли тайл (юниты моста — тайлы; стены, вода и footprint пропов блокируют). if (!hasXY(a)) return null; diff --git a/apps/game/tools/checks/collision.mjs b/apps/game/tools/checks/collision.mjs index ae5bc19..0ade8d1 100644 --- a/apps/game/tools/checks/collision.mjs +++ b/apps/game/tools/checks/collision.mjs @@ -3,7 +3,7 @@ * 1) слой collision в снапшоте: стены/вода/пропы, тайл героя свободен. * 2) scene:walkable: трава проходима, вода и дерево — нет. * 3) scene:raycast: дерево перекрывает луч, вода прозрачна (плевок летит над прудом). - * 4) walkTo в непроходимый тайл: герой остаётся в проходимом. + * 4) scene:walk в непроходимый — false; approach подводит к соседу. * 5) расталкивание тел: враг выталкивает себя из героя. * Запуск: node tools/agent.mjs run tools/checks/collision.mjs */ @@ -55,17 +55,24 @@ c.expect((await ray({ x: 18, y: 8 }, { x: 24, y: 8 })) === true, 'вода перекрыла луч'); return null; }); - await c.run('клик в непроходимый тайл: герой остаётся в проходимом', async () => { + await c.run('scene:walk в непроходимый — false, approach — к соседу', async () => { await t.sleepEnemies(); // Ближе к дереву: маршрут от (2,3) не задевает спящих ползунов у тропы // (сам (2,2) — тайл перехода на пруды, туда телепортировать нельзя). await t.ctx.agent.command('scene:teleport', { x: 2, y: 3 }); await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); - // Клик по дереву (0,4): A* ведёт к проходимому соседу. - await t.ctx.agent.tapTile(0, 4); - const w = await t.ctx.agent.waitFor('s.hero && !s.hero.moving', { timeoutTicks: 1200 }); - c.expect(w.ok, 'герой не остановился после клика по дереву'); + // Путь в дерево (0,4) не строится: scene:walk — false. + const walked = await t.ctx.agent.command('scene:walk', { x: 0, y: 4 }); + c.expect(walked === false, 'scene:walk провёл героя в дерево', walked); + // approach подводит к проходимому соседу дерева. + const dest = await t.ctx.agent.approach(0, 4); + c.expect(dest !== null, 'approach к дереву не нашёл соседа'); const s = await t.ctx.agent.snapshot(); + c.expect( + s.hero.tile.x === dest.x && s.hero.tile.y === dest.y, + 'герой не на тайле прибытия approach', + { dest, after: s.hero.tile } + ); const col = s.collision; const idx = s.hero.tile.y * col.width + s.hero.tile.x; c.expect(col.blocked[idx] === 0, 'герой стоит в блоке', s.hero.tile); diff --git a/apps/game/tools/checks/final.mjs b/apps/game/tools/checks/final.mjs index c284c00..648be6a 100644 --- a/apps/game/tools/checks/final.mjs +++ b/apps/game/tools/checks/final.mjs @@ -2,9 +2,9 @@ * Сценарий final — финал акта 2: правильный звон Старой Машины. * 1) путь акта 2: Звенец -> роща -> разъезд -> люк в зал Машины; * 2) без настроенного резонатора Машина отказывает (locked-текст); - * 3) чит-настройка (quest_resonator_done): клик по Машине -> реакция ставит + * 3) чит-настройка (quest_resonator_done): действие E у Машины -> реакция ставит * machine_lulled и запускает катсцену (тост «дышит тише», конец); - * 4) повторный клик: Машина спит; + * 4) повторное действие: Машина спит; * 5) инварианты чисты после финала. * Запуск: node tools/agent.mjs run tools/checks/final.mjs */ @@ -31,7 +31,8 @@ return null; }); await c.run('без резонатора Машина отказывает', async () => { - await t.ctx.agent.tapTile(6, 1); + const used = await t.useInteractable('old_machine'); + c.expect(used, 'не подошли к Машине'); const w = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("не настроен")', { timeoutTicks: 1200 }); @@ -41,7 +42,8 @@ }); await c.run('правильный звон: катсцена, machine_lulled', async () => { await t.ctx.agent.command('scene:setFlag', { flag: 'quest_resonator_done' }); - await t.ctx.agent.tapTile(6, 1); + const used = await t.useInteractable('old_machine'); + c.expect(used, 'не подошли к Машине'); const f = await t.ctx.agent.waitFor('s.flags.includes("machine_lulled")', { timeoutTicks: 1200 }); @@ -53,8 +55,9 @@ await t.ctx.agent.waitFor('!s.cutscene?.active', { timeoutTicks: 1200 }); return null; }); - await c.run('повторный клик: Машина спит', async () => { - await t.ctx.agent.tapTile(6, 1); + await c.run('повторное действие: Машина спит', async () => { + const used = await t.useInteractable('old_machine'); + c.expect(used, 'не подошли к Машине'); const w = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("спит")', { timeoutTicks: 1200 }); diff --git a/apps/game/tools/checks/interact-world.mjs b/apps/game/tools/checks/interact-world.mjs index f9a90df..98212fa 100644 --- a/apps/game/tools/checks/interact-world.mjs +++ b/apps/game/tools/checks/interact-world.mjs @@ -19,8 +19,9 @@ await c.run('столб у тропы: тост с текстом знака', async () => { await t.boot(); await t.sleepEnemies(); - // Столб (25,13) у тропы в Звенец — клик издалека: герой подойдёт сам. - await t.ctx.agent.tapTile(25, 13); + // Столб у тропы в Звенец (луга, восток) — подходим и жмём E. + const opened = await t.useInteractable('sign_meadows_east'); + c.expect(opened, 'не подошли к столбу'); const w = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("Звенец")', { timeoutTicks: 1500 }); @@ -28,7 +29,8 @@ return null; }); await c.run('мот 1: предмет + вар + used', async () => { - await t.ctx.agent.tapTile(11, 14); + const opened = await t.useInteractable('mote_1'); + c.expect(opened, 'не подошли к моту'); const w = await t.ctx.agent.waitFor( '(s.inventory.find((i) => i.id === "mote")?.count ?? 0) === 1 && s.vars.motes === 1', { timeoutTicks: 1500 } @@ -43,18 +45,21 @@ return null; }); await c.run('моты 2 и 3: вар копится (addVar)', async () => { - await t.ctx.agent.tapTile(14, 20); + let opened = await t.useInteractable('mote_2'); + c.expect(opened, 'не подошли ко второму моту'); const w2 = await t.ctx.agent.waitFor('s.vars.motes === 2', { timeoutTicks: 1500 }); c.expect(w2.ok, 'второй мот не поднял вар до 2', w2.snapshot.vars); - await t.ctx.agent.tapTile(14, 6); + opened = await t.useInteractable('mote_3'); + c.expect(opened, 'не подошли к третьему моту'); const w3 = await t.ctx.agent.waitFor('s.vars.motes === 3', { timeoutTicks: 1500 }); c.expect(w3.ok, 'третий мот не поднял вар до 3', w3.snapshot.vars); return null; }); await c.run('знак наката у прудов', async () => { - // Тропа на пруды (2,2): step-переход; у берега — столб (3,2). + // Тропа на пруды (2,2): step-переход; у берега — столб. await t.goToArea('ponds', { x: 2, y: 2 }); - await t.ctx.agent.tapTile(3, 2); + const opened = await t.useInteractable('sign_ponds'); + c.expect(opened, 'не подошли к знаку наката'); const ws = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("накатом")', { timeoutTicks: 900 }); @@ -64,9 +69,10 @@ return null; }); await c.run('верёвка колокола: флаг + повтор с другим текстом', async () => { - // Звенец через тропу (26,14); верёвка у башни (13,7) — клик издалека. + // Звенец через тропу (26,14); верёвка у башни — подходим и звоним. await t.goToArea('zvenets', { x: 26, y: 14 }); - await t.ctx.agent.tapTile(13, 7); + const opened = await t.useInteractable('bell_rope'); + c.expect(opened, 'не подошли к верёвке колокола'); const wr = await t.ctx.agent.waitFor('s.flags.includes("rang_tower")', { timeoutTicks: 1500 }); @@ -74,7 +80,7 @@ const s1 = await t.ctx.agent.snapshot(); const text1 = s1.lastToast?.text ?? ''; c.expect(text1.includes('ушёл в землю'), 'первый звон — не тот текст', text1); - await t.ctx.agent.tapTile(13, 7); + await t.useInteractable('bell_rope'); await t.ctx.agent.step(150); const s2 = await t.ctx.agent.snapshot(); const text2 = s2.lastToast?.text ?? ''; @@ -87,14 +93,14 @@ }); await c.run('Мила: знакомство -> повтор с веткой по мотам', async () => { // Первое знакомство (trader_first): полотно. - await t.ctx.agent.tapTile(17, 11); + const talked = await t.talkTo('trader'); + c.expect(talked, 'первый диалог с Милой не открылся'); const d1 = await t.ctx.agent.runDialogue(900); c.expect(d1, 'первый диалог с Милой не завершился'); // Повтор (trader_repeat): при моты >= 1 после первой реплики идёт // ветка о мота́х — листаем вручную и ловим её текст. - await t.ctx.agent.tapTile(17, 11); - const o = await t.ctx.agent.waitFor('s.dialogue != null', { timeoutTicks: 900 }); - c.expect(o.ok, 'повторный диалог не открылся'); + const talked2 = await t.talkTo('trader'); + c.expect(talked2, 'повторный диалог не открылся'); await t.ctx.agent.press('advance'); // догнать печать (typewriter) await t.ctx.agent.press('advance'); // следующая реплика const w = await t.ctx.agent.waitFor( diff --git a/apps/game/tools/checks/interact.mjs b/apps/game/tools/checks/interact.mjs index 747cbbc..49c9a86 100644 --- a/apps/game/tools/checks/interact.mjs +++ b/apps/game/tools/checks/interact.mjs @@ -1,19 +1,19 @@ /** * Сценарий interact — интерьеры и интерактивные объекты. - * 1) дверь: клик по дому Ирвина -> интерьер house_elder; + * 1) дверь: действие E у дома Ирвина -> интерьер house_elder; * 2) сундук (once): предмет в сумке, used:, повтор молчит; * 3) очаг: реакция зависит от флага quest_bells_done; * 4) записка (once): флаг read_note; * 5) выход через проём: step kind 'return' -> Звенец, герой на тайле входа; * 6) лавка Милы: прилавок меняет реакцию с флагом; * 7) размещение NPC по флагу: act2_hook -> Клинт появляется в Ржавой роще, - * клик открывает его диалог. + * действие E у него открывает диалог. * Запуск: node tools/agent.mjs run tools/checks/interact.mjs */ import { withChecks } from '../lib.mjs'; export default async function ({ pretty }) { - // Тайл, с которого герой кликнул дверь (цель kind 'return'). + // Тайл, с которого герой нажал E у двери (цель kind 'return'). let entryTile = null; return withChecks( 'interact', @@ -24,13 +24,12 @@ await t.sleepEnemies(); // Добираемся до Звенца пешком (луга -> тропа (26,14)). await t.goToArea('zvenets', { x: 26, y: 14 }); - // Подводим героя к дому (тайл дома (7,6)) и кликаем по нему. - const wh = await t.ctx.agent.walkTo(8, 6, { timeoutTicks: 2000 }); - c.expect(wh, 'не дошёл до дома Ирвина'); + // Подходим к дому (дверь (7,6)) и жмём действие. + const opened = await t.interactAt(7, 6); + c.expect(opened, 'не подошли к дому Ирвина'); entryTile = (await t.ctx.agent.snapshot()).hero.tile; - await t.ctx.agent.tapTile(7, 6); const w = await t.ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'клик по дому не открыл интерьер', { area: w.snapshot.area }); + c.expect(w.ok, 'действие у двери не открыло интерьер', { area: w.snapshot.area }); await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); const s = await t.ctx.agent.snapshot(); c.expect(s.hero.tile.x === 5 && s.hero.tile.y === 7, 'герой не у проёма (5,7)', s.hero.tile); @@ -39,8 +38,9 @@ await c.run('сундук: предмет + used, повтор молчит', async () => { const before = await t.ctx.agent.snapshot(); const had = before.inventory.find((i) => i.id === 'cloth')?.count ?? 0; - // Клик по сундуку (3,3) — герой подходит и открывает. - await t.ctx.agent.tapTile(3, 3); + // Подход к сундуку и действие — герой открывает. + const opened = await t.useInteractable('chest_elder'); + c.expect(opened, 'не подошли к сундуку'); const w = await t.ctx.agent.waitFor( `(s.inventory.find((i) => i.id === 'cloth')?.count ?? 0) === ${had + 1}`, { timeoutTicks: 1200 } @@ -52,20 +52,20 @@ c.expect((s.flags ?? []).includes('used:chest_elder'), 'нет флага used:chest_elder'); // Повтор: тик последнего тоста не меняется. const t0 = s.lastToast?.tick ?? -1; - await t.ctx.agent.tapTile(3, 3); + await t.useInteractable('chest_elder'); await t.ctx.agent.step(120); const s2 = await t.ctx.agent.snapshot(); c.expect((s2.lastToast?.tick ?? -1) === t0, 'повторное взаимодействие не молчит', s2.lastToast); return null; }); await c.run('очаг: реакция меняется с флагом', async () => { - await t.ctx.agent.tapTile(8, 0); + await t.useInteractable('hearth_elder'); await t.ctx.agent.step(120); const s1 = await t.ctx.agent.snapshot(); const text1 = s1.lastToast?.text ?? ''; c.expect(text1.includes('щепки'), 'реакция очага до флага не та', text1); await t.ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); - await t.ctx.agent.tapTile(8, 0); + await t.useInteractable('hearth_elder'); await t.ctx.agent.step(120); const s2 = await t.ctx.agent.snapshot(); c.expect( @@ -76,38 +76,39 @@ return null; }); await c.run('записка: флаг read_note', async () => { - await t.ctx.agent.tapTile(10, 4); + await t.useInteractable('note_elder'); await t.ctx.agent.step(120); const s = await t.ctx.agent.snapshot(); c.expect((s.flags ?? []).includes('read_note'), 'записка не подняла read_note', s.flags); return null; }); await c.run('выход через проём: возврат в Звенец', async () => { - // Клик по тайлу проёма (5,8) — герой доходит, step-переход kind 'return'. - await t.ctx.agent.tapTile(5, 8); + // Пешком в проём (5,8) — step-переход kind 'return' срабатывает на шаге. + await t.ctx.agent.walkTo(5, 8, { timeoutTicks: 300 }); const w = await t.ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); c.expect(w.ok, 'проём не вернул в Звенец', { area: w.snapshot.area }); await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); const s = await t.ctx.agent.snapshot(); c.expect( s.hero.tile.x === entryTile.x && s.hero.tile.y === entryTile.y, - 'герой не на тайле, откуда кликнул дверь', + 'герой не на тайле, откуда нажал E у двери', { entry: entryTile, after: s.hero.tile } ); return null; }); await c.run('лавка Милы: прилавок меняет реакцию', async () => { - // Клик по лавке (21,9) — интерьер; прилавок (5,3). - await t.ctx.agent.tapTile(21, 9); + // Действие у двери лавки (21,9) — интерьер; прилавок внутри. + const opened = await t.interactAt(21, 9); + c.expect(opened, 'не подошли к двери лавки'); const w = await t.ctx.agent.waitFor('s.area === "shop"', { timeoutTicks: 1200 }); c.expect(w.ok, 'лавка не открылась', { area: w.snapshot.area }); await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await t.ctx.agent.tapTile(5, 3); + await t.useInteractable('counter_shop'); await t.ctx.agent.step(120); const s1 = await t.ctx.agent.snapshot(); const text1 = s1.lastToast?.text ?? ''; c.expect(text1.includes('соль'), 'реакция прилавка до флага не та', text1); - await t.ctx.agent.tapTile(5, 3); + await t.useInteractable('counter_shop'); await t.ctx.agent.step(120); const s2 = await t.ctx.agent.snapshot(); c.expect( @@ -126,7 +127,7 @@ }); await c.run('размещение NPC по флагу: Клинт в роще', async () => { // Выходим из лавки обратно в Звенец (проём (5,8)). - await t.ctx.agent.tapTile(5, 8); + await t.ctx.agent.walkTo(5, 8, { timeoutTicks: 300 }); const back = await t.ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); c.expect(back.ok, 'не вернулись из лавки в Звенец', { area: back.snapshot.area }); await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); @@ -139,11 +140,12 @@ const clint = (s.npcs ?? []).find((n) => n.id === 'clint'); c.expect(clint !== undefined, 'Клинта нет в роще после act2_hook', s.npcs); if (!clint) return null; - // Клик по Клинту — сцена подводит героя и открывает диалог - // (путь через рощу небыстрый — таймаут с запасом). - await t.ctx.agent.tapTile(clint.tile.x, clint.tile.y); + // Действие у Клинта — сцена ждёт подхода героя (E работает только + // в радиусе); путь через рощу небыстрый — таймаут с запасом. + const talked = await t.talkTo('clint'); + c.expect(talked, 'диалог Клинта не открылся'); const d = await t.ctx.agent.runDialogue(2400); - c.expect(d, 'диалог Клинта не открылся'); + c.expect(d, 'диалог Клинта не завершился'); const s2 = await t.ctx.agent.snapshot(); c.expect((s2.flags ?? []).includes('met_clint'), 'диалог не поднял met_clint', s2.flags); return null; diff --git a/apps/game/tools/checks/lighting.mjs b/apps/game/tools/checks/lighting.mjs index bd45c86..14551c4 100644 --- a/apps/game/tools/checks/lighting.mjs +++ b/apps/game/tools/checks/lighting.mjs @@ -70,7 +70,8 @@ return null; }); await c.run('дом Ирвина: тёмный интерьер, hearth_low, лампа героя', async () => { - await t.ctx.agent.tapTile(7, 6); // дверь дома — клик-переход + const opened = await t.interactAt(7, 6); // дверь дома — действие E + c.expect(opened, 'не подошли к двери дома Ирвина'); const w = await t.ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); c.expect(w.ok, 'не вошли в дом Ирвина'); await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); diff --git a/apps/game/tools/checks/perf.mjs b/apps/game/tools/checks/perf.mjs index 0dcde6e..272be04 100644 --- a/apps/game/tools/checks/perf.mjs +++ b/apps/game/tools/checks/perf.mjs @@ -7,7 +7,7 @@ * числа одинаковых прогонов (до/после), абсолютные значения — не про-player. * 1) старт: страница+ассеты до маяка (включая фиксированные 1,5 с сна моста — * вычитать при сравнении), затем newGame() → location; - * 2) путь: время scene:route (один A*) и walkTo через луга (клики по узлам); + * 2) путь: время scene:route (один A*) и walkTo через луга (один scene:walk); * 3) бой: погоня + урон + эффекты — fps/heap после боя; * 4) fps меряется в реальном времени (rAF), не в ручных шагах моста: * окно ~0,9 с реального времени, затем снапшот + performance.memory. diff --git a/apps/game/tools/checks/quest-bells.mjs b/apps/game/tools/checks/quest-bells.mjs index 60382ed..b9b527f 100644 --- a/apps/game/tools/checks/quest-bells.mjs +++ b/apps/game/tools/checks/quest-bells.mjs @@ -23,9 +23,10 @@ await c.run('Ирвин: elder_first, выбор по тексту, флаги квеста', async () => { await t.boot(); await t.sleepEnemies(); - // NPC в Звенце: тропа на востоке (26,14), затем Ирвин (12,9). + // NPC в Звенце: тропа на востоке (26,14), затем Ирвин. await t.goToArea('zvenets', { x: 26, y: 14 }); - await t.ctx.agent.tapTile(12, 9); // Ирвин — клик издалека + const talked = await t.talkTo('elder'); // подойти и нажать E + c.expect(talked, 'не подошли к Ирвину'); const o = await t.ctx.agent.waitFor('s.dialogue?.id === "elder_first"', { timeoutTicks: 900 }); c.expect(o.ok, 'диалог elder_first не открылся', o.snapshot.dialogue); // Листаем до выбора: advance при выборе активирует курсор — проверяем снапшот. @@ -47,9 +48,11 @@ return null; }); await c.run('сдача: elder_hand_in, кат-сцена посадки, поляна зеленеет', async () => { - // Чит-цветы: сам сбор (пруды) покрыт клик-роутингом, здесь — сдача. + // Чит-цветы: сам сбор (пруды) покрыт сценарием quest-bells/пруды, + // здесь — сдача. await t.ctx.agent.command('scene:setVar', { id: 'flowers', value: 3 }); - await t.ctx.agent.tapTile(12, 9); + const talked = await t.talkTo('elder'); + c.expect(talked, 'не подошли к Ирвину для сдачи'); const o = await t.ctx.agent.waitFor('s.dialogue?.id === "elder_hand_in"', { timeoutTicks: 900 }); c.expect(o.ok, 'квест-стадия не дала elder_hand_in', o.snapshot.dialogue); const d = await t.ctx.agent.runDialogue(900); @@ -62,7 +65,7 @@ flags: w.snapshot.flags, vars: w.snapshot.vars }); - // Кат-сцена посадки: тост и конец (клики блокируются до её конца). + // Кат-сцена посадки: тост и конец (действие блокируется до её конца). const toast = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("гудит")', { timeoutTicks: 900 }); @@ -71,7 +74,8 @@ return null; }); await c.run('Мила: trader_first (полотно) -> trader_after (эпилог)', async () => { - await t.ctx.agent.tapTile(17, 11); + const talked = await t.talkTo('trader'); + c.expect(talked, 'не подошли к Миле'); const o = await t.ctx.agent.waitFor('s.dialogue?.id === "trader_first"', { timeoutTicks: 900 }); c.expect(o.ok, 'первый диалог Милы не открылся', o.snapshot.dialogue); const d1 = await t.ctx.agent.runDialogue(900); @@ -82,7 +86,8 @@ ); c.expect(inv.ok, 'полотно (do[] giveItem) не в сумке', inv.snapshot.inventory); // Повторный разговор: квест-стадия-эпилог -> trader_after. - await t.ctx.agent.tapTile(17, 11); + const talked2 = await t.talkTo('trader'); + c.expect(talked2, 'не подошли к Миле для эпилога'); const o2 = await t.ctx.agent.waitFor('s.dialogue?.id === "trader_after"', { timeoutTicks: 900 }); c.expect(o2.ok, 'эпилог trader_after не открылся', o2.snapshot.dialogue); const d2 = await t.ctx.agent.runDialogue(600); diff --git a/apps/game/tools/checks/resonator.mjs b/apps/game/tools/checks/resonator.mjs index 80e45a5..377a134 100644 --- a/apps/game/tools/checks/resonator.mjs +++ b/apps/game/tools/checks/resonator.mjs @@ -11,14 +11,8 @@ */ import { withChecks } from '../lib.mjs'; -/** Тайлы деревьев-механизмов (data/quests.ts QUEST_TONE_TILES). */ -const TREES = [ - [16, 3], - [6, 5], - [10, 15] -]; -/** Тайл Клинта в роще (npcs.ts placement). */ -const CLINT = { x: 11, y: 9 }; +/** Деревья-механизмы (data/quests.ts QUEST_TONE_TILES → интерактивы tone_tree_N). */ +const TREES = ['tone_tree_1', 'tone_tree_2', 'tone_tree_3']; export default async function ({ pretty }) { return withChecks( @@ -41,7 +35,8 @@ return null; }); await c.run('Клинт: clint_first, флаги знакомства и взятия квеста', async () => { - await t.ctx.agent.tapTile(CLINT.x, CLINT.y); + const talked = await t.talkTo('clint'); // подойти и нажать E + c.expect(talked, 'не подошли к Клинту'); const o = await t.ctx.agent.waitFor('s.dialogue?.id === "clint_first"', { timeoutTicks: 2400 }); c.expect(o.ok, 'диалог clint_first не открылся', o.snapshot.dialogue); const d = await t.ctx.agent.runDialogue(900); @@ -54,13 +49,14 @@ return null; }); await c.run('звон у деревьев-механизмов: tones 0 -> 3', async () => { - for (const [i, [tx, ty]] of TREES.entries()) { - await t.ctx.agent.tapTile(tx, ty); + for (const [i, id] of TREES.entries()) { + const used = await t.useInteractable(id); + c.expect(used, `не подошли к ${id}`); const w = await t.ctx.agent.waitFor( `s.vars.tones === ${i + 1} && s.flags.includes("tone_tree_${i + 1}")`, { timeoutTicks: 2400 } ); - c.expect(w.ok, `дерево (${tx},${ty}) не отдало тон`, { vars: w.snapshot.vars }); + c.expect(w.ok, `${id} не отдало тон`, { vars: w.snapshot.vars }); } return null; }); @@ -68,7 +64,8 @@ // Звон у деревьев шумит: краулер у (10,15) просыпается и может // убить героя по дороге к Клинту — усыпляем перед сдачей. await t.sleepEnemies(); - await t.ctx.agent.tapTile(CLINT.x, CLINT.y); + const talked = await t.talkTo('clint'); + c.expect(talked, 'не подошли к Клинту для сдачи'); const o = await t.ctx.agent.waitFor('s.dialogue?.id === "clint_tune"', { timeoutTicks: 2400 }); c.expect(o.ok, 'квест-стадия не дала clint_tune', o.snapshot.dialogue); const d = await t.ctx.agent.runDialogue(900); @@ -77,7 +74,7 @@ timeoutTicks: 300 }); c.expect(w.ok, 'нет флага сдачи резонатора', w.snapshot.flags); - // Кат-сцена настройки: тост и конец (клики блокируются до её конца). + // Кат-сцена настройки: тост и конец (действие блокируется до её конца). const toast = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("держит тон")', { timeoutTicks: 900 }); diff --git a/apps/game/tools/checks/transitions.mjs b/apps/game/tools/checks/transitions.mjs index bc209c4..398bd3c 100644 --- a/apps/game/tools/checks/transitions.mjs +++ b/apps/game/tools/checks/transitions.mjs @@ -23,7 +23,9 @@ }); await c.run('клик-переход: колодец Звенца -> луга', async () => { // Колодец (9,6) у дома Ирвина; entry на лугах — (22,21). - await t.ctx.agent.tapTile(9, 6); + // Подходим к колодцу и жмём действие E. + const used = await t.interactAt(9, 6); + c.expect(used, 'не подошли к колодцу Звенца'); const w = await t.ctx.agent.waitFor('s.area === "meadows"', { timeoutTicks: 1200 }); c.expect(w.ok, 'колодец не телепортировал на луга', { area: w.snapshot.area }); await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); @@ -32,8 +34,9 @@ return null; }); await c.run('обратный колодец: луга -> Звенец', async () => { - // Луговой колодец (22,20) стоит рядом с entry — кликаем по нему. - await t.ctx.agent.tapTile(22, 20); + // Луговой колодец (22,20) стоит рядом с entry — подходим и жмём E. + const used = await t.interactAt(22, 20); + c.expect(used, 'не подошли к луговому колодцу'); const w = await t.ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); c.expect(w.ok, 'луговой колодец не вернул в Звенец', { area: w.snapshot.area }); await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); diff --git a/apps/game/tools/checks/zoom.mjs b/apps/game/tools/checks/zoom.mjs index f997e0d..5618d4e 100644 --- a/apps/game/tools/checks/zoom.mjs +++ b/apps/game/tools/checks/zoom.mjs @@ -1,8 +1,8 @@ /** - * Сценарий zoom — зум камеры: клики, ходьба и диалог не ломаются при z=2. + * Сценарий zoom — зум камеры: действие, ходьба и диалог не ломаются при z=2. * Проверки в Звенце (там NPC): 1) клавиша debugZoom: worldRoot.scale = 2; - * 2) tapTile по NPC при зуме открывает диалог (инверсия клика /z); - * 3) walkTo при зуме доходит (окно клика и culling /z); + * 2) действие E у NPC при зуме открывает диалог; + * 3) walkTo при зуме доходит (culling /z); * 4) возврат на z=1: масштаб как до зума. * Запуск: node tools/agent.mjs run tools/checks/zoom.mjs */ @@ -27,12 +27,13 @@ c.expect(after.scale === 2, 'после debugZoom scale не 2', after.scale); return null; }); - await c.run('tapTile по NPC при зуме открывает диалог', async () => { + await c.run('действие E у NPC при зуме открывает диалог', async () => { const s = await t.ctx.agent.snapshot(); const npc = s.npcs?.[0]; c.expect(!!npc, 'в области нет NPC', s.npcs); - // Сцена сама подводит героя и открывает диалог (как в smoke-act1). - await t.ctx.agent.tapTile(npc.tile.x, npc.tile.y); + // Герой подходит сам (interactAt) и открывает диалог. + const talked = await t.talkTo(npc.id); + c.expect(talked, 'не подошли к NPC при зуме'); const d = await t.ctx.agent.runDialogue(900); c.expect(d, 'диалог с NPC при зуме не открылся'); return null; diff --git a/apps/game/tools/final-smoke.mjs b/apps/game/tools/final-smoke.mjs index 08fd74c..3d30c69 100644 --- a/apps/game/tools/final-smoke.mjs +++ b/apps/game/tools/final-smoke.mjs @@ -4,7 +4,7 @@ * после повторных переходов. Скриншоты в /tmp/rpg_final_*.png. * Запуск: node apps/game/tools/final-smoke.mjs [url] */ -import { openGame, startDevServer } from './lib.mjs'; +import { fightNearest, openGame, startDevServer } from './lib.mjs'; const server = await startDevServer(); let ctx; @@ -21,7 +21,8 @@ const z = await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); if (!z.ok) fail.push(`поездка ${n}: не в Звенце`); await agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await agent.tapTile(9, 6); // колодец у дома Ирвина + if (await agent.approach(9, 6)) agent.press('interact'); // колодец у дома Ирвина + else fail.push(`поездка ${n}: не подошли к колодцу`); const m = await agent.waitFor('s.area === "meadows"', { timeoutTicks: 1200 }); if (!m.ok) fail.push(`поездка ${n}: колодец не вернул на луга`); await agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); @@ -31,32 +32,18 @@ for (let n = 1; n <= 3; n++) await roundTrip(n); console.log('3 поездки туда-обратно: ок'); - // Тост знака у тропы (25,13): текст виден, тик не дублируется. - await agent.tapTile(25, 13); + // Тост знака у тропы (25,13): подходим и жмём действие. + if (await agent.approach(25, 13)) agent.press('interact'); + else fail.push('не подошли к знаку у тропы'); const t = await agent.waitFor('(s.lastToast?.text ?? "").includes("Звенец")', { timeoutTicks: 1500 }); console.log('тост знака:', t.ok ? t.snapshot.lastToast?.text : 'НЕ ПОКАЗАН'); if (!t.ok) fail.push('тост знака не показан'); - // Бой: клик по ближайшему живому врагу (авто-подход + удар конусом). - // Позиция врага плавает (сам идёт на героя) — предикат по минимуму hp живых. - const before = await agent.snapshot(); - const alive = (s) => (s.enemies ?? []).filter((e) => !e.dead && e.hp > 0); - const minHp = (s) => Math.min(...alive(s).map((e) => e.hp)); - const foe = alive(before).sort( - (a, b) => Math.hypot(a.pos.x - before.hero.pos.x, a.pos.y - before.hero.pos.y) - - Math.hypot(b.pos.x - before.hero.pos.x, b.pos.y - before.hero.pos.y) - )[0]; - if (foe) { - await agent.tapTile(Math.floor(foe.pos.x), Math.floor(foe.pos.y)); // worldToTile = floor - const hit = await agent.waitFor( - `(s.enemies.filter((e) => !e.dead && e.hp > 0).length) < ${alive(before).length} || (Math.min(...s.enemies.filter((e) => !e.dead && e.hp > 0).map((e) => e.hp))) < ${minHp(before)}`, - { timeoutTicks: 1200 } - ); - console.log('бой:', hit.ok ? `враг получил урон (мин hp ${minHp(before)} -> ${minHp(hit.snapshot)})` : 'УРОНА НЕТ'); - if (!hit.ok) fail.push('враг не получил урон'); - } else { - console.log('бой: живых врагов рядом нет — пропускаю'); - } + // Бой: подойти к ближайшему живому врагу, доворот facing, конус + // (fightNearest — см. lib.mjs; позиция врага плавает, до 3 попыток). + const hit = await fightNearest(agent); + console.log('бой:', hit ? 'враг получил урон' : 'УРОНА НЕТ'); + if (!hit) fail.push('враг не получил урон'); await page.screenshot({ path: '/tmp/rpg_final_combat.png' }); // Наслоение звука: после 3-го возврата амбиент не должен перезапуститься @@ -77,10 +64,8 @@ const snap = await agent.snapshot(); const dupIds = (arr) => arr.length - new Set(arr.map((x) => x.id)).size; const dup = dupIds(snap.interactables ?? []); - const dupEnemies = (snap.enemies ?? []).length - before.enemies?.length; - console.log('дубли id объектов:', dup, '; врагов было/стало:', before.enemies?.length, '/', snap.enemies?.length); + console.log('дубли id объектов:', dup); if (dup > 0) fail.push(`дубли в снапшоте: ${dup}`); - if (dupEnemies > 0) fail.push(`врагов стало больше: ${before.enemies?.length} -> ${snap.enemies?.length}`); const errs = ((await agent.invariants()) ?? []).filter((i) => i.severity === 'error'); if (errs.length) fail.push(`инварианты: ${JSON.stringify(errs)}`); diff --git a/apps/game/tools/lib.mjs b/apps/game/tools/lib.mjs index a411c40..6ddc5d3 100644 --- a/apps/game/tools/lib.mjs +++ b/apps/game/tools/lib.mjs @@ -25,6 +25,48 @@ export const openGame = (opts = {}) => engineOpenGame({ beacon: BEACON, ...opts }); /** + * Удар по ближайшему живому врагу через агентный мост. Возврат — урон прошёл. + * Подход задаёт facing только последним отрезком пути — он может прийти + * перпендикулярно врагу, поэтому после подхода герой делает короткий шаг в + * его сторону (клавиши движения — экранный вектор: facing совпадёт с + * направлением на врага) и бьёт конусом. Враг подвижен — до 3 попыток со + * свежей позицией. + */ +export const fightNearest = async (agent) => { + const snap = await agent.snapshot(); + const hero = snap.hero; + const foes = (snap.enemies ?? []).filter((e) => !e.dead && e.hp > 0); + if (foes.length === 0) return true; // бить некого — не провал + const d2 = (e) => (e.pos.x - hero.pos.x) ** 2 + (e.pos.y - hero.pos.y) ** 2; + const foe = foes.reduce((b, e) => (d2(e) < d2(b) ? e : b)); + const hurt = + `(s.enemies.filter((e) => !e.dead).length) < ${foes.length} || ` + + `s.enemies.some((e) => !e.dead && e.hp < ${Math.min(...foes.map((e) => e.hp))})`; + for (let i = 0; i < 3; i++) { + const cur = await agent.snapshot(); + // Свежая позиция цели: враг сам идёт на героя, пока мы подходили. + const live = (cur.enemies ?? []).find( + (e) => !e.dead && e.hp > 0 && Math.hypot(e.pos.x - foe.pos.x, e.pos.y - foe.pos.y) < 3 + ); + if (!live) return true; // добита (нашим прошлым тапом или сплэшем) + const dest = await agent.approach(Math.floor(live.pos.x), Math.floor(live.pos.y)); + if (dest) { + const dx = live.pos.x - cur.hero.pos.x; + const dy = live.pos.y - cur.hero.pos.y; + // Экранные оси — мировые диагонали: sx = dx − dy, sy = dx + dy. + const sx = dx - dy; + const sy = dx + dy; + const dir = Math.abs(sx) >= Math.abs(sy) ? (sx >= 0 ? 'right' : 'left') : sy >= 0 ? 'down' : 'up'; + agent.press(dir, 5); // шаг к врагу задаёт facing + agent.press('attack'); + } + const w = await agent.waitFor(hurt, { timeoutTicks: 120 }); + if (w.ok) return true; + } + return false; +}; + +/** * Каркас чек-сценария: поднимает dev-сервер, даёт помощники (boot, goToArea, * sleepEnemies, readAudioLog, waitAudioLog, expectInvariantsClean) и в любом * исходе гасит браузер и сервер. Сценарий оставляет только шаги проверки. @@ -61,7 +103,7 @@ sleepEnemies: () => h.ctx.agent.command('scene:sleepAll'), /** * Пешком к тайлу-триггеру step-перехода (via) и дождаться области - * (клик-переходы вроде дверей — явным tapTile в сценарии). + * (двери-переходы — явным interactAt в сценарии). */ goToArea: async (area, via) => { const walked = await h.ctx.agent.walkTo(via.x, via.y, { timeoutTicks: 3000 }); @@ -71,6 +113,40 @@ await h.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); return w; }, + /** + * Подойти к тайлу и нажать действие (E): walkTo на проходимый (цветок, + * мот), approach на непроходимый (стены, объекты, NPC-тайлы — путь в + * занятый тайл не строится). Возврат — дошли ли. + */ + interactAt: async (tx, ty) => { + const a = h.ctx.agent; + const ok = + (await a.walkTo(tx, ty)) || (await a.approach(tx, ty)) !== null; + if (ok) a.press('interact'); + return ok; + }, + /** Подойти к NPC по id (координаты — из снапшота) и заговорить. */ + talkTo: async (npcId) => { + const snap = await h.ctx.agent.snapshot(); + const npc = (snap.npcs ?? []).find((n) => n.id === npcId); + c.expect(npc, `NPC «${npcId}» не найден в снапшоте`, null); + if (!(await t.interactAt(npc.tile.x, npc.tile.y))) return false; + const w = await h.ctx.agent.waitFor('s.dialogue != null', { timeoutTicks: 300 }); + return w.ok; + }, + /** Подойти к интерактивному объекту по id и нажать действие. */ + useInteractable: async (id) => { + const snap = await h.ctx.agent.snapshot(); + const it = (snap.interactables ?? []).find((o) => o.id === id); + c.expect(it, `интерактив «${id}» не найден в снапшоте`, null); + return t.interactAt(it.tile.x, it.tile.y); + }, + /** Удар по ближайшему живому врагу: см. fightNearest выше. */ + fightNearest: async () => { + const ok = await fightNearest(h.ctx.agent); + c.expect(ok, 'враг не получил урон', null); + return ok; + }, /** DEV-лог аудио страницы (window.__gameAudioLog) или null вне DEV. */ readAudioLog: () => h.ctx.page.evaluate(() => window.__gameAudioLog ?? null), /** diff --git a/apps/game/tools/smoke-act1.mjs b/apps/game/tools/smoke-act1.mjs index 68e4a25..c0cc64a 100644 --- a/apps/game/tools/smoke-act1.mjs +++ b/apps/game/tools/smoke-act1.mjs @@ -28,9 +28,10 @@ await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); await sleepAll(); // переход мог вернуть героя в бой -// 2-3) Клик по Ирвину (12,9): сцена сама подведёт героя и откроет диалог. +// 2-3) Подойти к Ирвину (12,9) и нажать действие E — диалог откроется в радиусе. console.log('шаг 2: квест у Ирвина'); -await agent.tapTile(12, 9); +if (await agent.approach(12, 9)) agent.press('interact'); +else console.log(' ! не подошли к Ирвину'); if (!(await agent.runDialogue())) console.log(' ! диалог с Ирвином не открылся'); // 4) Сбор цветов — эмуляция var'ом (переход в пруды проверяется checks/transitions). @@ -39,7 +40,8 @@ // 5) Сдача Ирвину -> кат-сцена. console.log('шаг 5: сдача -> кат-сцена'); -await agent.tapTile(12, 9); +if (await agent.approach(12, 9)) agent.press('interact'); +else console.log(' ! не подошли к Ирвину для сдачи'); if (!(await agent.runDialogue())) console.log(' ! диалог сдачи не открылся'); // 6) Кат-сцена: крутим шаги, пока активна (мост перематывает без реального ожидания). diff --git a/docs/engine/agent.md b/docs/engine/agent.md index 2e16248..def626b 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -59,7 +59,11 @@ `scene:sleepAll`, `scene:give {id}`, `scene:setVar {id, value}`, `scene:setFlag {flag}`, `scene:setTime {hours}` (перемотка времени суток — пиновать перед каждым ambient-ассертом: время идёт и после пина, точные равенства неустойчивы), -`scene:teleport {x, y}`, `scene:route {x, y}` (маршрут A*), `scene:pickChoice {index}`, +`scene:teleport {x, y}`, `scene:route {x, y}` (маршрут A* без движения), +`scene:walk {x, y}` (пойти в тайл: A* + followPath, без инъекции клика; false — +пути нет), `scene:approach {x, y}` (подойти к краю (не)проходимого тайла — цель +может быть стеной/объектом/NPC; возврат — тайл прибытия, null — соседей нет), +`scene:pickChoice {index}`, `scene:pickChoiceByText {text}` (выбор варианта диалога по тексту реплики — порядок вариантов знать не надо; false — такого варианта нет), `scene:skipCutscene`, `scene:noise {x, y, level}` (шум в тайле: 0.35 — бодрые @@ -106,10 +110,11 @@ invariants(): Invariant[]; // движковые + сценические + контентные step(n?, {render}?): {tick}; // n фиксированных шагов (60 Гц), рендер опционален waitFor(pred: string, opts?): Promise<{ok, snapshot, ticks}>; - tapTile(tx, ty); tapVirtual(vx, vy); uiTap(vx, vy); + tapVirtual(vx, vy); uiTap(vx, vy); press(action, holdTicks?); key(code); command(name, args?): JsonValue; - walkTo(tx, ty, opts?): Promise; // маршрут A* + клики по узлам + walkTo(tx, ty, opts?): Promise; // scene:walk (A* без кликов) + ожидание тайла + approach(tx, ty, opts?): Promise<{x,y}|null>; // scene:approach: к краю непроходимого runDialogue(timeoutTicks?): Promise;// листает диалог до конца pickChoiceByText(text, timeoutTicks?): Promise; // ждёт выбор, берёт по тексту newGame(): Promise; @@ -140,11 +145,12 @@ Отсюда **главное правило**: инъекция ввода и шаг, который её видит, должны быть атомарны (один JS-стек). `tapVirtual/press/key` внутри делают инъекцию + один `stepTick` — между `evaluate`-вызовами rAF успел бы очистить «just pressed». -`walkTo` тоже атомарен по узлам: клик + ожидание тайла. Если клик по узлу -маршрута сработал как взаимодействие (мот/цветок/NPC на пути) — герой -останавливается рядом, и такой узел **пропускается**: идём дальше со -следующего. Поэтому `walkTo` возвращает true, только если герой стоит на -целевом тайле; узлы-объекты на пути не ломают маршрут. +`walkTo` атомарен иначе: команда `scene:walk` стартует путь одним вызовом, +дальше мост только ждёт прибытия (тайл цели и `!s.hero.moving` — точный снап +прихода; возврат true — герой стоит на целевом тайле). Взаимодействие — не по +клику, а кнопкой действия: `approach(tx, ty)` ведёт к краю непроходимой цели и +возвращает тайл прибытия; после него `press('interact')` (в `apps/game/tools/lib.mjs` +это завёрнуто в `t.interactAt/t.talkTo/t.useInteractable/t.fightNearest`). Семантика «just pressed» не меняется: инъекция между тиками видна сцене ровно один тик (очистка в `endTick`). @@ -206,7 +212,10 @@ гасит dev-сервер, а сценарий оставляет только шаги. Второй аргумент — `async (t) => {...}` с помощниками: `t.boot()` (новая игра + выход из fade), `t.c`/`t.ctx` (Checks и контекст), `t.sleepEnemies()`, `t.goToArea(area, via)` (пешком к step-переходу), `t.readAudioLog()`/ -`t.waitAudioLog(pred)`, `t.expectInvariantsClean(what)`. Контекст — **только через +`t.waitAudioLog(pred)`, `t.expectInvariantsClean(what)`, взаимодействие — +`t.interactAt(tx, ty)` (подойти к тайлу и нажать E; walkable → walkTo, иначе +approach), `t.talkTo(npcId)` / `t.useInteractable(id)` (координаты из снапшота), +`t.fightNearest()` (подход к ближайшему врагу + удар). Контекст — **только через `t.ctx`** (деструктуризация в начале снимет геттер до `boot` и даст `null`). **startDevServer и cwd**: `startDevServer({ port, cwd })` поднимает vite из каталога diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 806f19a..d81458a 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -85,7 +85,7 @@ (422 на ошибках, mtime-гард от конкурентной правки), dry-run прямо в UI. Правила одни для всех трёх точек (валидатор, CLI, редактор) — они в `dialogueRules.ts`; сервер редактора спавнит `tools/dialogues/probe.ts`. -3. Проверка через мост: `walkTo` до соседнего тайла → `tapTile(NPC)` → +3. Проверка через мост: `t.talkTo(npcId)` (подойти и нажать E) → `runDialogue()` → прочитать `flags`/`dialogue.text` из снапшота (`s.dialogue.path` — какие узлы реально показаны). Нюанс typewriter: `advance` при открытой реплике сначала **догоняет печать**, а при @@ -112,19 +112,24 @@ 1. **Действие проглочено fade-переходом**: `begin()` молча отбрасывает параллельный переход, сцена игнорирует клики при `transitioning`. Перед действием — `waitFor('!s.transitioning')`. -2. **Клик по тайлу вне экрана**: в смоук-скриптах с реальными кликами цель - за краем канваса (виртуальный y > 270) клик мимо. Через мост не актуально - (`tapTile` в юнитах), но в старых скриптах — целься в промежуточный тайл. -3. **walkTo в занятый тайл** (NPC, высокий объект): тайл непроходим, маршрут - не найден. Кликни в сам NPC-тайл (`tapTile`) — сцена подведёт героя сама. - **Интерактив на ПУТИ**: `walkTo` кликает по узлам маршрута, и клик в тайл - мота/цветка срабатывает как взаимодействие — герой останавливается рядом. - Начиная с батча 3 такие узлы пропускаются автоматически; в старых сценариях - веди героя через промежуточные точки в стороне от объектов. +2. **Цель вне радиуса действия**: взаимодействие работает только в радиусе + (евклидовы юниты, ~1.5) — сначала `walkTo`/`approach`, потом + `press('interact')`; в lib.mjs это `t.interactAt` и обёртки над ним. +3. **walkTo в непроходимый тайл** (NPC, высокий объект): `scene:walk` вернёт + false — путь не строится. Для таких целей есть `approach(tx, ty)` + (scene:approach ведёт к ближайшему проходимому соседу и возвращает тайл + прибытия) — после него и жми действие. 4. **Гонка rAF и инъекции**: инъекция и шаг должны быть в одном JS-стеке — - высокоуровневые методы моста (`tapTile/press/key/walkTo`) уже атомарны; + высокоуровневые методы моста (`press/key/walkTo/approach`) уже атомарны; сырые `inject*` из страницы + отдельный `step` — гонка с rAF. -5. `s.error` в снапшоте — мост поймал исключение, читай текст. +5. **Бой: facing после `approach` не смотрит на врага** — конус бьёт по + facing, а подход задаёт его только последним отрезком пути (может прийти + перпендикулярно). Доворот: короткий `press(направление, 5)` в сторону врага + перед `press('attack')`. Направление — по экранным компонентам смещения + герой→враг (`sx = dx − dy`, `sy = dx + dy`); клавиши движения — экранный + вектор (`moveFree` делает `screenToWorld`), так что одиночное направление + даёт верный facing. Хелпер — `fightNearest` в `apps/game/tools/lib.mjs`. +6. `s.error` в снапшоте — мост поймал исключение, читай текст. ## Ситуация: добавляю флаг / вар / предмет @@ -214,8 +219,8 @@ `c.finish({pretty}).ok ? 0 : 1` — CLI `agent.mjs run` ждёт числовой код. 2. Проверка: возвращает `false` или кидает исключение при провале (строка — это детали успеха!); удобнее `c.expect(cond, msg, details)`. -3. Ввод — через мост в юнитах (`walkTo`/`tapTile`/`press`), не через реальные - клики по CSS-пикселям. +3. Ввод — через мост (`walkTo`/`approach`/`press`), не через реальные клики + по CSS-пикселям; взаимодействие — кнопкой действия после подхода. 4. Текст реакций мира читай из `s.lastToast` (текст + тик) — не из скриншотов. 5. Опечатка в ключе предиката `waitFor` больше не тихий таймаут: мост сразу вернёт `{ ok: false, error: 'неизвестный ключ…' }` (список — @@ -322,12 +327,13 @@ точечные вопросы — `scene:walkable {x, y}`, прямая видимость — `scene:raycast {from, to}` (см. `docs/engine/agent.md`). 3. Проба-эталон — `apps/game/tools/checks/collision.mjs`: слой валиден, - walkable/raycast на конкретных тайлах, клик в непроходимый тайл, телепорт - в тело спящего врага (расталкивание). Расширяя коллизии — добавляй шаг туда. + walkable/raycast на конкретных тайлах, scene:walk в непроходимый — false и + approach к его соседу, телепорт в тело спящего врага (расталкивание). + Расширяя коллизии — добавляй шаг туда. 4. Грабли проб: `scene:teleport` на тайл step-перехода запускает переход (сцена сменится — телепортируйся на соседний тайл); `walkTo` применим - только к проходимым целям (ждёт героя В целевом тайле) — для клика в стену - используй `tapTile` + `waitFor('!s.hero.moving')`. + только к проходимым целям (ждёт героя В целевом тайле) — для непроходимых + используй `approach(tx, ty)` (вернёт тайл прибытия). ## Ситуация: меняю роутинг взаимодействия / агентный мост сцены diff --git a/docs/plan.md b/docs/plan.md index 2ec9eff..40efb14 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -33,6 +33,7 @@ | 12 | UI: скролл списков (`ListScroll` + `MenuList.visibleRows`, hover→курсор, value-слот кнопки), инвентарь/настройки на скролле | ✅ сделано (2026-09-07): движок + eb1596b (игра) | | 13 | Зум камеры: `Camera.setZoom/zoomTo`, шаг `cameraZoom`, интеграция в игре (клики/culling/свет/мост), debug-клавиша Z, чек `zoom.mjs` | ✅ сделано (2026-09-07): 878355c, 8730614 | | 14 | Акт 2 (контент): карты Ржавая роща/Старый разъезд, Клинт + размещения NPC по флагу, квест резонатора, Старая Машина и финальная катсцена | ✅ сделано (2026-09-08): A1 fa38ee7 (карты/переходы); A2 58a8164 (Клинт, `NPC_PLACEMENTS`); A3 6068646 (квест резонатора, чек `resonator.mjs`); A4 ccc3ab3 (правильный звон, чек `final.mjs`, docs) | +| 15 | Уход от кликов: фикс радиуса взаимодействия (евклидова линейка вместо проекционной), ходьба WASD/стик/джойстик, взаимодействие кнопкой действия E/A в радиусе, подпись `[E]`, удаление клик-роутинга (`InteractRouting.ts`), мост `scene:walk`/`scene:approach` + переписывание чеков и смоуков | ✅ сделано (2026-09-08): C1 fde88b6, C2 50cfc15, C3, C4; тач-кнопки действий — позже (джойстик даёт только движение) | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт diff --git a/packages/engine/src/agent/EngineAgent.ts b/packages/engine/src/agent/EngineAgent.ts index 77b6416..1fb0776 100644 --- a/packages/engine/src/agent/EngineAgent.ts +++ b/packages/engine/src/agent/EngineAgent.ts @@ -156,7 +156,7 @@ /** * Реальный DOM-клик по канвасу (виртуальные px → CSS): нужен для Pixi-кнопок, - * если сцена слушает DOM напрямую. В геймплее предпочитай tapVirtual/tapTile. + * если сцена слушает DOM напрямую. В геймплее предпочитай действия (press). */ uiTap(vx: number, vy: number): void { this.safe('uiTap', () => { diff --git a/packages/engine/tools/agent-lib.mjs b/packages/engine/tools/agent-lib.mjs index 2032689..b1e342a 100644 --- a/packages/engine/tools/agent-lib.mjs +++ b/packages/engine/tools/agent-lib.mjs @@ -114,12 +114,12 @@ invariants() { return this.call('invariants'); } step(n = 1, opts) { return this.call('step', n, opts); } waitFor(pred, opts) { return this.call('waitFor', pred, opts); } - tapTile(x, y) { return this.call('tapTile', x, y); } tapVirtual(x, y) { return this.call('tapVirtual', x, y); } press(action, holdTicks) { return this.call('press', action, holdTicks); } key(code) { return this.call('key', code); } command(name, args) { return this.call('command', name, args); } walkTo(x, y, opts) { return this.call('walkTo', x, y, opts); } + approach(x, y, opts) { return this.call('approach', x, y, opts); } runDialogue(timeoutTicks) { return this.call('runDialogue', timeoutTicks); } pickChoiceByText(text, timeoutTicks) { return this.call('pickChoiceByText', text, timeoutTicks); } newGame() { return this.call('newGame'); }