diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 31a014e..52a8954 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -6,6 +6,7 @@ mergeInvariants, tileToWorld, type Invariant, + type Grid, type IsometricTileMap, type JsonValue, type SceneAgent, @@ -30,7 +31,8 @@ LocationSnapshot, InteractableSnapshot } from './snapshot'; -import { heroLayer, enemiesLayer, npcsLayer, dialogueLayer } from './snapshot'; +import { heroLayer, enemiesLayer, npcsLayer, dialogueLayer, collisionLayer } from './snapshot'; +import { hasLineOfSight } from '../systems/combat/los'; /** * Агентный фасад сцены локации: слой снапшота, инварианты, whitelist-команды. @@ -50,6 +52,8 @@ interactables: Interactables; /** Реестр объектов сцены (для инварианта согласованности). */ registry: SceneRegistry; + /** Grid путей с занятыми NPC-тайлами (для scene:route — как у героя). */ + walkGrid(): Grid; dialogue: DialogueSystem; cutscene: CutsceneRunner; lastToast(): { text: string; tick: number } | null; @@ -112,6 +116,7 @@ npcs: npcsLayer(npcs).npcs as unknown as NpcSnapshot[], transitions, interactables, + collision: collisionLayer(d.map.data).collision as unknown as LocationSnapshot['collision'], dialogue: dialogueLayer(d.dialogue.agentState).dialogue as LocationSnapshot['dialogue'], cutscene: { active: d.cutscene.active }, lastToast: d.lastToast() @@ -180,7 +185,17 @@ /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */ agentCommand(name: string, args?: JsonValue): JsonValue { const d = this.deps; - const a = (args ?? {}) as { x?: number; y?: number; level?: number; id?: string; value?: number | string | boolean; flag?: string; index?: number }; + const a = (args ?? {}) as { + x?: number; + y?: number; + level?: number; + id?: string; + value?: number | string | boolean; + flag?: string; + index?: number; + from?: { x?: number; y?: number }; + to?: { x?: number; y?: number }; + }; switch (name) { case 'scene:sleepAll': for (const [, en] of d.combat.enemies) en.brain.putToSleep(9999); @@ -222,9 +237,24 @@ } case 'scene:route': { if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; - const path = findPath(d.map, d.player.currentTile(), { x: a.x, y: a.y }, false); + // Маршрут как у героя: тайлы NPC заняты, иначе мост ведёт сквозь тело. + const path = findPath(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }, false); return path ?? null; } + case 'scene:walkable': { + // Проходим ли тайл (юниты моста — тайлы; стены, вода и footprint пропов блокируют). + if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; + return d.map.isWalkable(Math.floor(a.x), Math.floor(a.y)); + } + case 'scene:raycast': { + // Прямая видимость между тайлами: true — чисто, false — стена/дом на отрезке. + const f = a.from as { x?: number; y?: number } | undefined; + const t = a.to as { x?: number; y?: number } | undefined; + if (!f || !t || typeof f.x !== 'number' || typeof f.y !== 'number' || typeof t.x !== 'number' || typeof t.y !== 'number') { + return null; + } + return hasLineOfSight(tileToWorld(f.x, f.y), tileToWorld(t.x, t.y), d.combat.opaque); + } case 'scene:pickChoice': if (typeof a.index !== 'number') return null; d.dialogue.pickChoice(a.index); diff --git a/apps/game/src/agent/__tests__/snapshot.test.ts b/apps/game/src/agent/__tests__/snapshot.test.ts index 4c934bb..b32829a 100644 --- a/apps/game/src/agent/__tests__/snapshot.test.ts +++ b/apps/game/src/agent/__tests__/snapshot.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { AGENT_SNAPSHOT_KEYS, + collisionLayer, dialogueLayer, enemiesLayer, gameLayer, @@ -49,6 +50,23 @@ ]); }); + it('слой коллизий: blocked по тайлам, пропы отдельным списком', () => { + const data = { + width: 2, + height: 2, + tiles: [0, 1, 0, 0], + blocked: [1], + props: [{ id: 2, x: 0, y: 1, w: 2, h: 1 }] + }; + const layer = collisionLayer(data); + expect(layer.collision).toEqual({ + width: 2, + height: 2, + blocked: [0, 1, 1, 1], // стена (1,0) + весь footprint пропа + props: [{ x: 0, y: 1, w: 2, h: 1 }] + }); + }); + it('NPC: копия с met, без ссылок на источник', () => { const n: NpcSnapshot = { id: 'elder', name: 'Ирвин', tile: { x: 20, y: 12 }, met: false }; const layer = npcsLayer([n]); @@ -98,6 +116,7 @@ npcs: [], transitions: [], interactables: [], + collision: { width: 1, height: 1, blocked: [0], props: [] }, dialogue: null, cutscene: null, lastToast: null diff --git a/apps/game/src/agent/snapshot.ts b/apps/game/src/agent/snapshot.ts index c1f82ca..e97d747 100644 --- a/apps/game/src/agent/snapshot.ts +++ b/apps/game/src/agent/snapshot.ts @@ -1,4 +1,4 @@ -import { ENGINE_SNAPSHOT_KEYS, type SnapshotLayer, type JsonValue } from '@rpg/engine'; +import { gridOf, ENGINE_SNAPSHOT_KEYS, type SnapshotLayer, type JsonValue, type TileMapData } from '@rpg/engine'; /** * Контентный слой снапшота агентного моста. Чистые функции — тестируются @@ -17,6 +17,7 @@ 'npcs', 'transitions', 'interactables', + 'collision', 'dialogue', 'cutscene', 'lastToast', @@ -83,6 +84,16 @@ used: boolean; } +/** Карта коллизий для агента: стены/вода по тайлам + крупные пропы. */ +export interface CollisionSnapshot { + width: number; + height: number; + /** Проходимость по тайлам, построчно: 0 — свободен, 1 — блок (включая footprint пропов). */ + blocked: number[]; + /** Крупные объекты с footprint'ом (дома и т.п.). */ + props: { x: number; y: number; w: number; h: number }[]; +} + /** Контентный слой сцены локации. */ export interface LocationSnapshot { scene: 'location'; @@ -93,6 +104,7 @@ npcs: NpcSnapshot[]; transitions: TransitionSnapshot[]; interactables: InteractableSnapshot[]; + collision: CollisionSnapshot; dialogue: DialogueSnapshot | null; cutscene: { active: boolean } | null; /** Последний тост (текст + тик) — единственный канал текста реакций. */ @@ -115,6 +127,24 @@ return Math.round(v * 1000) / 1000; } +/** Слой карты коллизий: blocked по тайлам (gridOf учитывает footprint пропов). */ +export function collisionLayer(data: TileMapData): SnapshotLayer { + const grid = gridOf(data); + const blocked: number[] = []; + for (let y = 0; y < data.height; y++) { + for (let x = 0; x < data.width; x++) { + blocked.push(grid.isWalkable(x, y) ? 0 : 1); + } + } + const collision: CollisionSnapshot = { + width: data.width, + height: data.height, + blocked, + props: (data.props ?? []).map((p) => ({ x: p.x, y: p.y, w: p.w ?? 1, h: p.h ?? 1 })) + }; + return { collision: collision as unknown as JsonValue }; +} + /** Слой героя. */ export function heroLayer(o: HeroSnapshot): SnapshotLayer { return { diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 1d07bb2..8b1d803 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -141,6 +141,10 @@ // Шаги — тихий шум (0.35): бодрых сгустков настораживает, спящих не будит. this.combat.noise(this.player.position, 0.35); }); + // Тела: герой не проходит сквозь NPC (расталкивание через реестр), + // пути строятся по grid с занятыми NPC-тайлами — A* не ведёт сквозь тело. + this.player.setBodies(this.objects.registry); + this.player.setWalkGrid(this.objects.walkGrid()); // Мигание героя при неуязвимости — процедурный blink (duty меняется на лету). this.heroMotion = new SpriteMotion(this.player.view, { blink: { period: 0.25, duty: 1 } }); @@ -279,6 +283,7 @@ npcs: this.npcs.map((n) => n.def), interactables: this.interactables, registry: this.objects.registry, + walkGrid: () => this.objects.walkGrid(), dialogue: this.dialogue, cutscene: this.cutscene, lastToast: () => this.lastToast, diff --git a/apps/game/src/systems/ClickRouting.ts b/apps/game/src/systems/ClickRouting.ts index 5f92f89..6f4b9b5 100644 --- a/apps/game/src/systems/ClickRouting.ts +++ b/apps/game/src/systems/ClickRouting.ts @@ -314,7 +314,7 @@ /** Путь к краю тайла цели; нет пути — тихий no-op. */ private walkTo(goal: { x: number; y: number }, pending: PendingInteraction): void { - const path = findPathToNeighbor(this.deps.map, this.deps.player.currentTile(), goal); + const path = findPathToNeighbor(this.deps.objects.walkGrid(), this.deps.player.currentTile(), goal); if (path) { this.pendingInteraction = pending; this.deps.player.followPath(path); diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index 700b7fd..d9c09fe 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -9,14 +9,20 @@ moveTowardsW, moveCircle, circleFits, + separateCircles, SpriteAnimator, Container, Sprite, Texture, + type Grid, + type SceneRegistry, type Vec2 } from '@rpg/engine'; import { PLAYER_COMBAT } from './combat/stats'; +/** Максимальный радиус тел, из которых выталкивается герой (NPC — 0.35, запас). */ +const BODY_PROBE = 0.5; + /** Кадры героя по направлениям. side смотрит влево; вправо — флип. */ export interface HeroTextures { down: [Texture, Texture]; @@ -47,6 +53,8 @@ private speed = 1.75; /** Множитель скорости (зоны наката без маски — 0.5). Меняется сценой каждый кадр. */ speedMul = 1; + /** Реестр объектов сцены (тела NPC) — задаётся сценой после создания. */ + private bodies: SceneRegistry | null = null; constructor( private map: IsometricTileMap, @@ -76,6 +84,25 @@ return this.facing; } + /** Подключить реестр тел — герой не проходит сквозь NPC. */ + setBodies(registry: SceneRegistry | null): void { + this.bodies = registry; + } + + /** Grid путей (тайлы NPC заняты) — без него A* ведёт сквозь тело. */ + private walkGrid: Grid | null = null; + setWalkGrid(grid: Grid | null): void { + this.walkGrid = grid; + } + + /** Вытолкнуть себя из тел (каждый актор двигает только себя). */ + private separateBodies(): void { + if (!this.bodies) return; + for (const hit of this.bodies.near(this.pos, PLAYER_COMBAT.radius + BODY_PROBE, 'npc')) { + separateCircles(this.pos, PLAYER_COMBAT.radius, hit.pos, hit.radius); + } + } + /** Вектор направления взгляда в мировых юнитах (экранные оси — мировые диагонали). */ get dirVector(): Vec2 { switch (this.facing) { @@ -100,7 +127,7 @@ x: Math.min(w - 1, Math.max(0, Math.floor(worldX))), y: Math.min(h - 1, Math.max(0, Math.floor(worldY))) }; - const path = findPath(this.map, this.currentTile(), tile, false); + const path = findPath(this.walkGrid ?? this.map, this.currentTile(), tile, false); if (path && path.length > 0) { this.followPath(path); } @@ -114,6 +141,8 @@ } update(dt: number): void { + // Тела: даже стоячий герой выталкивается (враг мог втолкнуть его в NPC). + this.separateBodies(); if (!this.waypoint) { this.playIdle(); return; @@ -155,6 +184,7 @@ const step = this.speed * this.speedMul * dt; // Круг тела скользит вдоль стен (оси раздельно). const moved = moveCircle(this.map, this.pos, { x: n.x * step, y: n.y * step }, PLAYER_COMBAT.radius); + this.separateBodies(); if (moved) { // Поворот — по экранным компонентам смещения (кадры down/up/side). const proj = worldToScreen(n.x, n.y); diff --git a/apps/game/src/systems/SceneObjects.ts b/apps/game/src/systems/SceneObjects.ts index 0158b7a..076b26f 100644 --- a/apps/game/src/systems/SceneObjects.ts +++ b/apps/game/src/systems/SceneObjects.ts @@ -1,4 +1,4 @@ -import { SceneRegistry, tileToWorld, type IsometricTileMap, type SceneObject } from '@rpg/engine'; +import { SceneRegistry, tileToWorld, type Grid, type IsometricTileMap, type SceneObject } from '@rpg/engine'; import type { AreaDef } from '../data/locations'; import type { NpcDef } from '../data/npcs'; import type { InteractableDef } from '../data/interactables'; @@ -17,7 +17,7 @@ export class SceneObjects { readonly registry = new SceneRegistry(); - constructor(area: AreaDef, map: IsometricTileMap) { + constructor(area: AreaDef, private map: IsometricTileMap) { for (const def of area.npcs) { this.registry.add({ id: `npc:${def.id}`, @@ -61,4 +61,27 @@ byKind(kind: string): readonly SceneObject[] { return this.registry.byKind(kind); } + + /** Ключ тайла (как в реестре; NPC не двигаются — кэш навсегда). */ + private npcTiles: Set | null = null; + + /** + * Grid для построения путей: тайлы NPC считаются занятыми — A* не ведёт + * героя сквозь тело, где его остановит расталкивание. Остальная карта — + * живая (IsometricTileMap учитывает стены и пропы). + */ + walkGrid(): Grid { + if (!this.npcTiles) { + this.npcTiles = new Set(); + for (const npc of this.registry.byKind('npc')) { + this.npcTiles.add(npc.tile.y * 4096 + npc.tile.x); + } + } + const npcTiles = this.npcTiles; + return { + width: this.map.width, + height: this.map.height, + isWalkable: (x, y) => this.map.isWalkable(x, y) && !npcTiles.has(y * 4096 + x) + }; + } } \ No newline at end of file diff --git a/apps/game/src/systems/combat/CombatWorld.ts b/apps/game/src/systems/combat/CombatWorld.ts index ecdb012..92c5732 100644 --- a/apps/game/src/systems/combat/CombatWorld.ts +++ b/apps/game/src/systems/combat/CombatWorld.ts @@ -10,6 +10,7 @@ inConeW, tileToWorld, moveCircle, + separateCircles, type Entity, type SceneRegistry, type System, @@ -89,6 +90,9 @@ moveCircle(map, pos, { x: dir.x * speed * dt, y: dir.y * speed * dt }, radius); } +/** Максимальный радиус тел в реестре (NPC 0.35, враги до 0.3 — с запасом). */ +const BODY_PROBE = 0.5; + // ---------- системы ECS ---------- /** ИИ и движение сгустков. */ @@ -164,6 +168,17 @@ default: break; } + + // Тела: каждый враг выталкивает себя из героя, NPC и других врагов + // (сепарация после шага — толкучка не отменяет намерение ИИ). + separateCircles(en.pos, en.kind.radius, playerPos, PLAYER_COMBAT.radius); + const reg = this.combat.deps.registry; + if (reg) { + for (const hit of reg.near(en.pos, en.kind.radius + BODY_PROBE)) { + if (hit.id === `enemy:${e}` || (hit.kind !== 'enemy' && hit.kind !== 'npc')) continue; + separateCircles(en.pos, en.kind.radius, hit.pos, hit.radius); + } + } } } } diff --git a/apps/game/tools/agent.mjs b/apps/game/tools/agent.mjs index bc9f797..e53c01e 100644 --- a/apps/game/tools/agent.mjs +++ b/apps/game/tools/agent.mjs @@ -103,6 +103,7 @@ { name: 'transitions', run: () => runScenario('transitions', 'apps/game/tools/checks/transitions.mjs') }, { name: 'interact', run: () => runScenario('interact', 'apps/game/tools/checks/interact.mjs') }, { name: 'interact-world', run: () => runScenario('interact-world', 'apps/game/tools/checks/interact-world.mjs') }, + { name: 'collision', run: () => runScenario('collision', 'apps/game/tools/checks/collision.mjs') }, { name: 'ai', run: () => runScenario('ai', 'apps/game/tools/checks/ai.mjs') } ]; const only = args.only ?? all.map((p) => p.name); diff --git a/apps/game/tools/checks/collision.mjs b/apps/game/tools/checks/collision.mjs new file mode 100644 index 0000000..1d962be --- /dev/null +++ b/apps/game/tools/checks/collision.mjs @@ -0,0 +1,93 @@ +/** + * Сценарий collision — коллизии и карта коллизий агенту. + * 1) слой collision в снапшоте: стены/вода/пропы, тайл героя свободен. + * 2) scene:walkable: трава проходима, вода и дерево — нет. + * 3) scene:raycast: дерево перекрывает луч, вода прозрачна (плевок летит над прудом). + * 4) walkTo в непроходимый тайл: герой остаётся в проходимом. + * 5) расталкивание тел: враг выталкивает себя из героя. + * Запуск: node tools/agent.mjs run tools/checks/collision.mjs + */ +import { startDevServer, openGame, Checks } from '../lib.mjs'; + +export default async function ({ pretty }) { + const c = new Checks('collision'); + const server = await startDevServer(); + let ctx; + try { + await c.run('снапшот: слой collision валиден', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + const s = await ctx.agent.snapshot(); + const col = s.collision; + c.expect(col && col.width > 0 && col.height > 0, 'нет карты коллизий', col); + c.expect( + col.blocked.length === col.width * col.height, + 'blocked не накрывает карту', + { len: col.blocked.length, w: col.width, h: col.height } + ); + c.expect( + col.blocked.every((v) => v === 0 || v === 1), + 'blocked не бинарный', + col.blocked.filter((v) => v !== 0 && v !== 1) + ); + c.expect( + col.props.every((p) => p.w >= 1 && p.h >= 1), + 'проп без footprint', + col.props + ); + const idx = s.hero.tile.y * col.width + s.hero.tile.x; + c.expect(col.blocked[idx] === 0, 'герой заспавнился в блоке', s.hero.tile); + return null; + }); + await c.run('scene:walkable: вода и дерево непроходимы', async () => { + const walk = (x, y) => ctx.agent.command('scene:walkable', { x, y }); + const s = await ctx.agent.snapshot(); + const heroWalk = await walk(s.hero.tile.x, s.hero.tile.y); + c.expect(heroWalk === true, 'тайл героя непроходим', s.hero.tile); + c.expect((await walk(19, 8)) === false, 'вода проходима?'); + c.expect((await walk(0, 4)) === false, 'дерево проходимо?'); + return null; + }); + await c.run('scene:raycast: дерево перекрывает, вода прозрачна', async () => { + const ray = (from, to) => ctx.agent.command('scene:raycast', { from, to }); + // Вертикальный луч (3,8)->(3,10) задевает дерево (3,9). + c.expect((await ray({ x: 3, y: 8 }, { x: 3, y: 10 })) === false, 'дерево не перекрыло луч'); + // Берег-берег через пруд: вода прозрачна, луч чист. + c.expect((await ray({ x: 18, y: 8 }, { x: 24, y: 8 })) === true, 'вода перекрыла луч'); + return null; + }); + await c.run('клик в непроходимый тайл: герой остаётся в проходимом', async () => { + await ctx.agent.command('scene:sleepAll'); + // Ближе к дереву: маршрут от (2,3) не задевает спящих ползунов у тропы + // (сам (2,2) — тайл перехода на пруды, туда телепортировать нельзя). + await ctx.agent.command('scene:teleport', { x: 2, y: 3 }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); + // Клик по дереву (0,4): A* ведёт к проходимому соседу. + await ctx.agent.tapTile(0, 4); + const w = await ctx.agent.waitFor('s.hero && !s.hero.moving', { timeoutTicks: 1200 }); + c.expect(w.ok, 'герой не остановился после клика по дереву'); + const s = await ctx.agent.snapshot(); + 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); + return null; + }); + await c.run('расталкивание: враг выталкивает себя из героя', async () => { + await ctx.agent.command('scene:sleepAll'); + // Ползун спит в (10.5,7.5); телепортируемся внутрь его тела. + await ctx.agent.command('scene:teleport', { x: 10, y: 7 }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); + const s = await ctx.agent.snapshot(); + const en = (s.enemies ?? []).find( + (e) => !e.dead && Math.hypot(e.pos.x - 10.5, e.pos.y - 7.5) < 1.2 + ); + c.expect(!!en, 'ползун у тропы не найден', s.enemies); + const gap = Math.hypot(en.pos.x - s.hero.pos.x, en.pos.y - s.hero.pos.y); + c.expect(gap >= 0.4, 'враг остался внутри тела героя', { gap, en: en.pos, hero: s.hero.pos }); + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/docs/engine/agent.md b/docs/engine/agent.md index 311f9d3..da38cac 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -41,15 +41,19 @@ invuln, inHazard}`, `enemies [{kind, state, hp, pos, asleep, dead}]`, `npcs`, `transitions`, `dialogue {id, nodeId, text, choices, waitingForChoice} | null`, `cutscene`, `lastToast {text, tick}` (единственный канал текста реакций — иначе -агенту нужен OCR). `MenuScene` отдаёт `{scene: 'menu'}` и команду `menu:newGame`. +агенту нужен OCR), `collision {width, height, blocked (0/1 по тайлам, включает +footprint пропов), props}` — карта коллизий для проверки движения. `MenuScene` +отдаёт `{scene: 'menu'}` и команду `menu:newGame`. Whitelist-команды `LocationScene.agentCommand` (для перемоток в проверках): `scene:sleepAll`, `scene:give {id}`, `scene:setVar {id, value}`, `scene:setFlag {flag}`, `scene:teleport {x, y}`, `scene:route {x, y}` (маршрут A*), `scene:pickChoice {index}`, `scene:skipCutscene`, `scene:noise {x, y, level}` (шум в тайле: 0.35 — бодрые слышат в hearRadius, 0.7+ — будит спящих), `scene:damageEnemy {id, value}` -(урон сгустку — проверки отступления). Команда вне whitelist возвращает -`null` — расширять осознанно. +(урон сгустку — проверки отступления), `scene:walkable {x, y}` (проходим ли +тайл: стены, вода, footprint пропов), `scene:raycast {from, to}` (прямая +видимость между тайлами: false — высокий объект на отрезке). Команда вне +whitelist возвращает `null` — расширять осознанно. Состояние врага читается из снапшота: `s.enemies[i].{kind, state, hp, pos, asleep, dead}` — состояния `dormant/rise/patrol/wary/chase/windup/attack/ diff --git a/docs/engine/practices.md b/docs/engine/practices.md index e8738dd..6961bf7 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -173,6 +173,23 @@ 4. Позицию врага в юнит-тестах двигай руками: мозг чистый, сенсор `pos` — это то, что подал тест (см. `EnemyAI.test.ts`). +## Ситуация: проверяю коллизии / движение + +1. Тела — круги в юнитах: герой 0.35, враги/NPC из kind-дефов; правило + **радиус < 0.5** (тело уже тайла — A*-путь по центрам остаётся проходимым). + Скольжение стен даёт `moveCircle` (оси раздельно), расталкивание — + `separateCircles` через `registry.near` (каждый актор двигает только себя). +2. Карта коллизий агенту — слой `s.collision {width, height, blocked, props}`; + точечные вопросы — `scene:walkable {x, y}`, прямая видимость — + `scene:raycast {from, to}` (см. `docs/engine/agent.md`). +3. Проба-эталон — `apps/game/tools/checks/collision.mjs`: слой валиден, + walkable/raycast на конкретных тайлах, клик в непроходимый тайл, телепорт + в тело спящего врага (расталкивание). Расширяя коллизии — добавляй шаг туда. +4. Грабли проб: `scene:teleport` на тайл step-перехода запускает переход + (сцена сменится — телепортируйся на соседний тайл); `walkTo` применим + только к проходимым целям (ждёт героя В целевом тайле) — для клика в стену + используй `tapTile` + `waitFor('!s.hero.moving')`. + ## Ситуация: меняю клик-роутинг / агентный мост сцены 1. `LocationScene` — только оркестратор вьюх: текстуры, композитинг, ввод,