diff --git a/CLAUDE.md b/CLAUDE.md index bcca811..6fec5a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,8 +57,8 @@ - `apps/game/src/data/` — весь контент: карта (`map.ts`), переходы и области (`locations.ts` — `TransitionDef`/`AREAS`), интерактивные объекты (`interactables.ts` — `INTERACTABLES`), диалоги (`dialogues.ts` — графы `DialogueGraph`), NPC (`npcs.ts`); `validate.ts` — runtime-валидация контента → `Invariant[]` (проверяется в `agent:invariants` и юнит-тестах). - `apps/game/src/agent/` — контентный слой агентного моста: `snapshot.ts` (сборка слоёв), `GameAgent.ts` (`window.__agent`, только DEV). - `apps/game/tools/` — все тулзы игры (перенесены из корневого `tools/`): `agent.mjs`/`agent-lib.mjs` (агентный CLI), `checks/` (сценарии проверок), `pixelart/`, `maps/`, `audio/`, смоуки; `guards/boundary.mjs` — гвард границы движок/игра (`npm run guard`, входит в `agent:check`). -- `apps/game/src/scenes/` — BootScene (грузит ассеты и шрифт) → MenuScene (MenuList) → LocationScene; сцены меняются через `SceneManager.replace/push/pop` (опционально с fade). -- `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация из атласа), диалоги (обёртка над DialogueRunner + DialogueBox), интерактивные объекты (`Interactables.ts` — резолв реакций, used-флаги, `InteractSink`). +- `apps/game/src/scenes/` — BootScene (грузит ассеты и шрифт) → MenuScene (MenuList) → LocationScene; сцены меняются через `SceneManager.replace/push/pop` (опционально с fade). LocationScene — только оркестратор вьюх: клик-роутинг вынесен в `systems/ClickRouting.ts` (`resolveClick` — чистый резолвер приоритетов + `InteractionRouter`), агентный мост — в `agent/SceneAgentView.ts`. +- `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация из атласа), диалоги (обёртка над DialogueRunner + DialogueBox), интерактивные объекты (`Interactables.ts` — резолв реакций, used-флаги, `InteractSink`), маршрутизация кликов по миру (`ClickRouting.ts` — приоритеты: NPC → переход/заперто → интерактив → цветок → враг → движение). - Флаги/переменные сюжета — в `GameState` (`game.state`), сериализуются в автосейв `autosave` (Esc в локации); настройки — `game.settings` (отдельный слот, не в сейвах). **Флаги/вары — только через реестры `data/ids.ts`** (`FLAGS`/`VARS`, типы `FlagId`/`VarId`, `usedFlag(id)` для used:): строковое упоминание вне реестра ловится `validateReferences()` как error, незадействованный ключ — как warn. ### Пиксель-арт diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts new file mode 100644 index 0000000..5bc8dc6 --- /dev/null +++ b/apps/game/src/agent/SceneAgentView.ts @@ -0,0 +1,212 @@ +import { + findPath, + checkFinite, + checkRange, + checkWalkable, + mergeInvariants, + tileToWorld, + type Invariant, + type IsometricTileMap, + type JsonValue, + type SceneAgent, + type SnapshotLayer +} from '@rpg/engine'; +import type { CutsceneRunner } from '@rpg/engine'; +import type { Game } from '../Game'; +import type { AreaDef, HazardDef } from '../data/locations'; +import type { NpcDef } from '../data/npcs'; +import { PLAYER_COMBAT } from '../systems/combat/stats'; +import type { CombatWorld } from '../systems/combat/CombatWorld'; +import type { PlayerCombat } from '../systems/combat/PlayerCombat'; +import type { PlayerController } from '../systems/PlayerController'; +import type { Interactables } from '../systems/Interactables'; +import type { DialogueSystem } from '../systems/DialogueSystem'; +import type { + HeroSnapshot, + EnemySnapshot, + NpcSnapshot, + TransitionSnapshot, + LocationSnapshot, + InteractableSnapshot +} from './snapshot'; +import { heroLayer, enemiesLayer, npcsLayer, dialogueLayer } from './snapshot'; + +/** + * Агентный фасад сцены локации: слой снапшота, инварианты, whitelist-команды. + * Вынесен из LocationScene (SceneAgent), чтобы сцена оставалась оркестратором + * вьюх, а мосту хватало узкого deps-объекта. Слои снапшота собираются теми же + * фабриками, что тестируются в snapshot.test.ts, — расхождение невозможно. + */ +export interface SceneAgentDeps { + game: Game; + map: IsometricTileMap; + area: AreaDef; + player: PlayerController; + playerCombat: PlayerCombat; + combat: CombatWorld; + /** Определения NPC области (вьюхи не нужны мосту). */ + npcs: readonly NpcDef[]; + interactables: Interactables; + dialogue: DialogueSystem; + cutscene: CutsceneRunner; + lastToast(): { text: string; tick: number } | null; + inHazard(): HazardDef | null; + /** Камера в ногах героя (snap — телепорты). */ + followCamera(snap: boolean): void; +} + +export class SceneAgentView implements SceneAgent { + constructor(private deps: SceneAgentDeps) {} + + /** Контентный слой снапшота — см. snapshot.ts. */ + agentSnapshot(): SnapshotLayer { + const d = this.deps; + const hero: HeroSnapshot = { + tile: d.player.currentTile(), + pos: d.player.position, + hp: d.playerCombat.hp, + maxHp: PLAYER_COMBAT.maxHp, + facing: d.player.dir, + moving: d.player.moving, + invuln: d.playerCombat.invuln, + inHazard: d.inHazard()?.name ?? null + }; + const enemies: EnemySnapshot[] = []; + for (const [, en] of d.combat.enemies) { + enemies.push({ + kind: en.kind.id, + state: en.brain.state, + hp: en.hp, + pos: en.pos, + asleep: en.brain.asleep, + dead: en.brain.dead + }); + } + const npcs: NpcSnapshot[] = d.npcs.map((def) => ({ + id: def.id, + name: def.name, + tile: def.tile, + met: d.game.state.hasFlag(def.flagKey) + })); + const transitions: TransitionSnapshot[] = d.area.transitions.map((t) => ({ + tile: t.tile, + to: t.target.kind === 'area' ? t.target.area : '', + trigger: t.trigger ?? 'step', + label: t.label ?? null + })); + const interactables: InteractableSnapshot[] = (d.area.interactables ?? []).map((def) => ({ + id: def.id, + tile: def.tile, + label: def.label ?? null, + used: d.interactables.isUsed(def.id) + })); + const layer: LocationSnapshot = { + scene: 'location', + area: d.area.id, + areaName: d.area.name, + hero: heroLayer(hero).hero as unknown as HeroSnapshot, + enemies: enemiesLayer(enemies).enemies as unknown as EnemySnapshot[], + npcs: npcsLayer(npcs).npcs as unknown as NpcSnapshot[], + transitions, + interactables, + dialogue: dialogueLayer(d.dialogue.agentState).dialogue as LocationSnapshot['dialogue'], + cutscene: { active: d.cutscene.active }, + lastToast: d.lastToast() + }; + return layer as unknown as SnapshotLayer; + } + + /** Инварианты сцены: валидность контента + целостность героя/врагов. */ + agentInvariants(): Invariant[] { + const where = 'scene/LocationScene'; + const d = this.deps; + const heroTile = d.player.currentTile(); + const heroPos = d.player.position; + const enemyPos: Record = {}; + const enemyChecks: Invariant[] = []; + for (const [e, en] of d.combat.enemies) { + enemyPos[`enemy#${e}.x`] = en.pos.x; + enemyPos[`enemy#${e}.y`] = en.pos.y; + if (!en.brain.dead && !d.map.isWalkable(Math.floor(en.pos.x), Math.floor(en.pos.y))) { + enemyChecks.push({ + id: 'enemy-in-wall', + severity: 'error', + message: `${en.kind.id} в непроходимом тайле (${en.pos.x},${en.pos.y})`, + where + }); + } + } + return mergeInvariants( + checkFinite( + { 'hero.pos.x': heroPos.x, 'hero.pos.y': heroPos.y, ...enemyPos }, + where + ), + checkRange('hero.hp', d.playerCombat.hp, 0, PLAYER_COMBAT.maxHp, where), + checkWalkable('герой', heroTile, d.map, where), + enemyChecks, + d.combat.agentInvariants() + ); + } + + /** 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 }; + switch (name) { + case 'scene:sleepAll': + for (const [, en] of d.combat.enemies) en.brain.putToSleep(9999); + return true; + case 'scene:noise': { + // Шум в тайле (юниты моста — тайлы): уровень 0.35 слышат бодрые, 0.7+ будит спящих. + if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; + d.combat.noise(tileToWorld(a.x, a.y), typeof a.level === 'number' ? a.level : 1); + return true; + } + case 'scene:damageEnemy': { + // Урон живому сгустку (проверки отступления); id — фильтр по виду. + if (typeof a.value !== 'number') return null; + for (const [e, en] of d.combat.enemies) { + if (en.brain.dead) continue; + if (typeof a.id === 'string' && en.kind.id !== a.id) continue; + d.combat.damageEnemy(e, en, a.value, d.player.position); + return true; + } + return false; + } + case 'scene:give': + if (typeof a.id !== 'string') return null; + d.game.inventory.add(a.id); + return true; + case 'scene:setVar': + if (typeof a.id !== 'string') return null; + d.game.state.setVar(a.id, a.value ?? 0); + return true; + case 'scene:setFlag': + if (typeof a.flag !== 'string') return null; + d.game.state.setFlag(a.flag); + return true; + case 'scene:teleport': { + if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; + d.player.teleportTo({ x: a.x, y: a.y }); + d.followCamera(true); + return true; + } + 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); + return path ?? null; + } + case 'scene:pickChoice': + if (typeof a.index !== 'number') return null; + d.dialogue.pickChoice(a.index); + return true; + case 'scene:skipCutscene': { + if (!d.cutscene.active) return false; + while (d.cutscene.active) d.cutscene.update(0.5); + return true; + } + default: + return null; + } + } +} \ No newline at end of file diff --git a/apps/game/src/agent/snapshot.ts b/apps/game/src/agent/snapshot.ts index 0791e7d..b2907f8 100644 --- a/apps/game/src/agent/snapshot.ts +++ b/apps/game/src/agent/snapshot.ts @@ -49,6 +49,8 @@ export interface TransitionSnapshot { tile: { x: number; y: number }; to: string; + /** Как срабатывает: шагом на тайл или кликом. */ + trigger: 'step' | 'click'; label: string | null; } diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 1fab3df..c011949 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -8,18 +8,8 @@ Texture, IsometricTileMap, worldToScreen, - screenToWorld, - worldDist, worldNorm, - worldToTile, tileToWorld, - inCircleW, - findPath, - findPathToNeighbor, - checkFinite, - checkRange, - checkWalkable, - mergeInvariants, DebugOverlay, SpriteDebugView, VirtualJoystick, @@ -42,7 +32,8 @@ type HazardDef, type TransitionDef } from '../data/locations'; -import { resolveTransition, type TransitionCtx } from '../data/transitions'; +import { InteractionRouter } from '../systems/ClickRouting'; +import { SceneAgentView } from '../agent/SceneAgentView'; import type { NpcDef } from '../data/npcs'; import { DIALOGUES } from '../data/dialogues'; import { QUEST_FLOWERS, questDialogueFor, questEffectFor } from '../data/quests'; @@ -57,14 +48,6 @@ import { PlayerCombat } from '../systems/combat/PlayerCombat'; import { HealthBar } from '../systems/combat/HealthBar'; import { PLAYER_COMBAT } from '../systems/combat/stats'; -import type { - HeroSnapshot, - EnemySnapshot, - NpcSnapshot, - TransitionSnapshot, - LocationSnapshot, - InteractableSnapshot -} from '../agent/snapshot'; import { Interactables } from '../systems/Interactables'; import type { InteractableDef } from '../data/interactables'; @@ -91,16 +74,8 @@ private healthBar: HealthBar; /** Индикатор заряда резонанса (под героем). */ private chargeRing: Graphics; - /** Цель, выбранная кликом по врагу (авто-подход и удар). */ - private target: Entity | null = null; - private repathTimer = 0; - /** Отложенное взаимодействие: сработает, когда герой подойдёт. */ - private pendingInteraction: - | { kind: 'talk'; def: NpcDef } - | { kind: 'flower'; x: number; y: number } - | { kind: 'interact'; def: InteractableDef } - | { kind: 'transition'; def: TransitionDef; tile: { x: number; y: number } } - | null = null; + /** Маршрутизация клика/переходов/отложенных взаимодействий. */ + private router: InteractionRouter; /** Интерактивные объекты области (сундуки, очаги, прилавки). */ private interactables: Interactables; private interactViews: { def: InteractableDef; label: PixelText }[] = []; @@ -120,13 +95,8 @@ private charDebug: SpriteDebugView; /** Последний тост (текст + тик) — канал текста для агентного моста. */ private lastToast: { text: string; tick: number } | null = null; - /** - * Тайл входа в область: step-переходы на нём глушатся, пока герой - * с него не ушёл (иначе вошёл в дверь — и немедленно вылетел обратно). - */ - private disarmTile: { x: number; y: number } | null = null; - /** Предыдущий тайл героя (чтобы тост «заперто» не спамил каждый тик). */ - private prevStepTile: { x: number; y: number } | null = null; + /** Агентный мост сцены (снапшот/инварианты/команды). */ + private agentView: SceneAgentView; constructor( private game: Game, @@ -144,7 +114,9 @@ this.game.renderer.worldRoot.addChild(this.world); const startTile = entry ?? save?.pos ?? area.spawn; - this.disarmTile = entry ? { ...startTile } : null; + // Тайл входа: step-переходы на нём глушатся, пока герой с него не ушёл + // (иначе вошёл в дверь — и немедленно вылетел обратно). + const disarmTile = entry ? { ...startTile } : null; if (save?.state) this.game.state.load(save.state); this.player = new PlayerController(this.map, this.heroTextures(), startTile, () => { void this.game.audio.play('sfx/step', 0.35); @@ -246,6 +218,43 @@ this.dialogue.onDialogueFinished = (id) => this.onDialogueFinished(id); this.dialogue.onLineShown = () => void this.game.audio.play('sfx/chime', 0.5); + // Маршрутизация клика/переходов/отложенных взаимодействий. + this.router = new InteractionRouter({ + game: this.game, + map: this.map, + area, + player: this.player, + combat: this.combat, + interactables: this.interactables, + disarmTile, + worldRootOffset: () => this.game.renderer.worldRoot.position, + callbacks: { + showToast: (t) => this.showToast(t), + playUiClick: () => void this.game.audio.play('sfx/ui_click', 0.4), + talkTo: (d) => this.talkTo(d), + collectFlower: (x, y) => this.collectFlower(x, y), + useTransition: (d) => this.useTransition(d), + attackTarget: (from, dir) => this.attackTarget(from, dir) + } + }); + + // Агентный мост сцены (снапшот/инварианты/команды). + this.agentView = new SceneAgentView({ + game: this.game, + map: this.map, + area, + player: this.player, + playerCombat: this.playerCombat, + combat: this.combat, + npcs: this.npcs.map((n) => n.def), + interactables: this.interactables, + dialogue: this.dialogue, + cutscene: this.cutscene, + lastToast: () => this.lastToast, + inHazard: () => this.inHazard, + followCamera: (snap) => this.updateCameraFollow(snap) + }); + // Атмосфера локации: пепел на лугах, туман над прудами, в интерьере — ничего. this.ash = area.ambience === 'none' @@ -393,7 +402,7 @@ // Касание отдаём джойстику (мышь идёт в мир обычным порядком). this.joystick.eventMode = 'static'; } else { - this.handleWorldClick(pointer.x, pointer.y); + this.router.handleWorldClick(pointer.x, pointer.y); } } @@ -401,7 +410,7 @@ this.combat.update(dt); this.combatViews.sync(dt); this.playerCombat.update(dt); - this.updateTarget(dt); + this.router.updateTarget(dt); this.updateChargeRing(); this.healthBar.setHp(this.playerCombat.hp); @@ -415,16 +424,16 @@ } else { this.player.update(dt); } - this.resolvePendingInteraction(); + this.router.resolvePending(); const tile = this.player.currentTile(); this.updateHazard(tile); this.actors.setDepth(this.player.view, tile.x, tile.y); this.updateCameraFollow(); - this.checkTransitions(tile); + this.router.checkTransitions(tile); // Подписи объектов: видны вблизи, пока объект не использован (once). for (const iv of this.interactViews) { iv.label.visible = - this.inInteractRange(iv.def.tile.x, iv.def.tile.y) && + this.router.inInteractRange(iv.def.tile.x, iv.def.tile.y) && !(iv.def.once && this.interactables.isUsed(iv.def.id)); } @@ -462,14 +471,6 @@ this.fog.alpha = hazard === null ? 0 : masked ? 0.12 : 0.28; } - /** Контекст условий перехода: флаги GameState, сумка героя. */ - private transitionCtx(): TransitionCtx { - return { - hasFlag: (f) => this.game.state.hasFlag(f), - hasItem: (id) => this.game.inventory.has(id) - }; - } - /** Куда ведёт переход: область + точка входа ('return' — откуда вошли). */ private targetEntry(def: TransitionDef): { area: AreaDef; entry: { x: number; y: number } } { const t = def.target; @@ -492,40 +493,6 @@ ); } - /** - * Единый механизм переходов (step-триггер): герой наступил на тайл. - * На тайле входа step-переходы дисармованы, пока герой с него не ушёл. - */ - private checkTransitions(tile: { x: number; y: number }): void { - const fresh = - this.prevStepTile === null || - this.prevStepTile.x !== tile.x || - this.prevStepTile.y !== tile.y; - this.prevStepTile = tile; - // Ушёл с тайла входа — дисарм снят, переходы снова работают. - if (this.disarmTile && (tile.x !== this.disarmTile.x || tile.y !== this.disarmTile.y)) { - this.disarmTile = null; - } - - const pick = resolveTransition( - this.area.transitions, - tile, - 'step', - this.transitionCtx(), - this.disarmTile - ); - if (!pick) return; - if (!pick.ok) { - // «Заперто» — не спамим: только при приходе на тайл. - if (fresh) { - this.showToast(pick.lockedText); - void this.game.audio.play('sfx/ui_click', 0.4); - } - return; - } - this.useTransition(pick.def); - } - render(): void {} // ---------- бой ---------- @@ -551,31 +518,13 @@ } } - /** Авто-подход к выбранной цели и удар при входе в конус. */ - private updateTarget(dt: number): void { - if (this.target === null) return; - const en = this.combat.enemies.get(this.target); - if (!en || en.brain.dead) { - this.target = null; - return; + /** Удар из зоны авто-атаки (кулдаун внутри PlayerCombat). */ + private attackTarget(from: Vec2, dir: Vec2): boolean { + if (this.playerCombat.attackCd.trigger()) { + this.combat.playerConeAttack(from, dir); + return true; } - const from = this.player.position; - const dist = worldDist(from, en.pos); - if (dist <= PLAYER_COMBAT.attackStop) { - // В зоне — стоим и бьём по кулдауну - this.player.stop(); - if (this.playerCombat.attackCd.trigger()) { - this.combat.playerConeAttack(from, worldNorm(en.pos.x - from.x, en.pos.y - from.y)); - } - this.target = null; // цель «снята» ударом; дальше игрок решает сам - return; - } - // Перестраиваем путь к цели пару раз в секунду - this.repathTimer -= dt; - if (this.repathTimer <= 0) { - this.repathTimer = 0.5; - this.player.onWorldClick(en.pos.x, en.pos.y); - } + return false; } /** Индикатор заряда резонанса под героем. */ @@ -613,155 +562,21 @@ } } - // ---------- агентный мост (SceneAgent) ---------- + // ---------- агентный мост (SceneAgent → SceneAgentView) ---------- /** Контентный слой снапшота — см. apps/game/src/agent/snapshot.ts. */ agentSnapshot(): SnapshotLayer { - const hero: HeroSnapshot = { - tile: this.player.currentTile(), - pos: this.player.position, - hp: this.playerCombat.hp, - maxHp: PLAYER_COMBAT.maxHp, - facing: this.player.dir, - moving: this.player.moving, - invuln: this.playerCombat.invuln, - inHazard: this.inHazard?.name ?? null - }; - const enemies: EnemySnapshot[] = []; - for (const [, en] of this.combat.enemies) { - enemies.push({ - kind: en.kind.id, - state: en.brain.state, - hp: en.hp, - pos: en.pos, - asleep: en.brain.asleep, - dead: en.brain.dead - }); - } - const npcs: NpcSnapshot[] = this.npcs.map(({ def }) => ({ - id: def.id, - name: def.name, - tile: def.tile, - met: this.game.state.hasFlag(def.flagKey) - })); - const transitions: TransitionSnapshot[] = this.area.transitions.map((t) => ({ - tile: t.tile, - to: t.target.kind === 'area' ? t.target.area : '', - trigger: t.trigger ?? 'step', - label: t.label ?? null - })); - const interactables: InteractableSnapshot[] = (this.area.interactables ?? []).map((d) => ({ - id: d.id, - tile: d.tile, - label: d.label ?? null, - used: this.interactables.isUsed(d.id) - })); - const layer: LocationSnapshot = { - scene: 'location', - area: this.area.id, - areaName: this.area.name, - hero, - enemies, - npcs, - transitions, - interactables, - dialogue: this.dialogue.agentState, - cutscene: { active: this.cutscene.active }, - lastToast: this.lastToast - }; - return layer as unknown as SnapshotLayer; + return this.agentView.agentSnapshot(); } /** Инварианты сцены: валидность контента + целостность героя/врагов. */ agentInvariants(): Invariant[] { - const where = 'scene/LocationScene'; - const heroTile = this.player.currentTile(); - const heroPos = this.player.position; - const enemyPos: Record = {}; - const enemyChecks: Invariant[] = []; - for (const [e, en] of this.combat.enemies) { - enemyPos[`enemy#${e}.x`] = en.pos.x; - enemyPos[`enemy#${e}.y`] = en.pos.y; - if (!en.brain.dead && !this.map.isWalkable(Math.floor(en.pos.x), Math.floor(en.pos.y))) { - enemyChecks.push({ - id: 'enemy-in-wall', - severity: 'error', - message: `${en.kind.id} в непроходимом тайле (${en.pos.x},${en.pos.y})`, - where - }); - } - } - return mergeInvariants( - checkFinite( - { 'hero.pos.x': heroPos.x, 'hero.pos.y': heroPos.y, ...enemyPos }, - where - ), - checkRange('hero.hp', this.playerCombat.hp, 0, PLAYER_COMBAT.maxHp, where), - checkWalkable('герой', heroTile, this.map, where), - enemyChecks, - this.combat.agentInvariants() - ); + return this.agentView.agentInvariants(); } /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */ agentCommand(name: string, args?: JsonValue): JsonValue { - const a = (args ?? {}) as { x?: number; y?: number; level?: number; id?: string; value?: number | string | boolean; flag?: string; index?: number }; - switch (name) { - case 'scene:sleepAll': - for (const [, en] of this.combat.enemies) en.brain.putToSleep(9999); - return true; - case 'scene:noise': { - // Шум в тайле (юниты моста — тайлы): уровень 0.35 слышат бодрые, 0.7+ будит спящих. - if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; - this.combat.noise(tileToWorld(a.x, a.y), typeof a.level === 'number' ? a.level : 1); - return true; - } - case 'scene:damageEnemy': { - // Урон живому сгустку (проверки отступления); id — фильтр по виду. - if (typeof a.value !== 'number') return null; - for (const [e, en] of this.combat.enemies) { - if (en.brain.dead) continue; - if (typeof a.id === 'string' && en.kind.id !== a.id) continue; - this.combat.damageEnemy(e, en, a.value, this.player.position); - return true; - } - return false; - } - case 'scene:give': - if (typeof a.id !== 'string') return null; - this.game.inventory.add(a.id); - return true; - case 'scene:setVar': - if (typeof a.id !== 'string') return null; - this.game.state.setVar(a.id, a.value ?? 0); - return true; - case 'scene:setFlag': - if (typeof a.flag !== 'string') return null; - this.game.state.setFlag(a.flag); - return true; - case 'scene:teleport': { - if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; - this.player.teleportTo({ x: a.x, y: a.y }); - this.updateCameraFollow(true); - return true; - } - case 'scene:route': { - if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; - const path = findPath(this.map, this.player.currentTile(), { x: a.x, y: a.y }, false); - return path ?? null; - } - case 'scene:pickChoice': - if (typeof a.index !== 'number') return null; - this.dialogue.pickChoice(a.index); - return true; - case 'scene:skipCutscene': { - if (!this.cutscene.active) return false; - while (this.cutscene.active) this.cutscene.update(0.5); - return true; - } - default: - return null; - } + return this.agentView.agentCommand(name, args); } // ---------- остальное ---------- @@ -819,168 +634,6 @@ return worldToScreen(u.x, u.y); } - private handleWorldClick(px: number, py: number): void { - // Координаты указателя (виртуальные px) -> мировые юниты (учёт камеры). - const p = screenToWorld( - px - this.game.renderer.worldRoot.position.x, - py - this.game.renderer.worldRoot.position.y - ); - const worldX = p.x; - const worldY = p.y; - - const clicked = worldToTile(worldX, worldY, this.map.data.width, this.map.data.height); - - // Клик по тайлу NPC — диалог (в радиусе сразу, издалека — подходим). - if (clicked) { - const npc = this.npcs.find( - (n) => n.def.tile.x === clicked.x && n.def.tile.y === clicked.y - ); - if (npc) { - this.requestTalk(npc.def); - return; - } - - // Клик-переходы (колодцы, двери) — до маршрутизации движения; - // издалека герой сначала подходит к тайлу-триггеру. - const pick = resolveTransition( - this.area.transitions, - clicked, - 'click', - this.transitionCtx(), - null - ); - if (pick) { - if (!pick.ok) { - this.showToast(pick.lockedText); - void this.game.audio.play('sfx/ui_click', 0.4); - } else { - this.requestTransition(pick.def); - } - return; - } - - // Клик по интерактивному объекту (сундук, очаг, прилавок). - const inter = this.interactables.defAt(clicked.x, clicked.y); - if (inter) { - this.requestInteract(inter); - return; - } - - // Клик по лунному колокольчику (пруды) — собрать цветок. - if (this.area.id === 'ponds' && this.tileId(clicked.x, clicked.y) === TILES.BELLFLOWER) { - this.requestCollect(clicked.x, clicked.y); - return; - } - } - - // Клик по сгустку — выбрать цель (авто-подход и удар). - const world = { x: worldX, y: worldY }; - for (const [e, en] of this.combat.enemies) { - if (en.brain.dead) continue; - if (inCircleW(en.pos, en.kind.radius + 0.15, world)) { - this.target = e; - this.repathTimer = 0; - return; - } - } - - this.target = null; - this.pendingInteraction = null; - this.player.onWorldClick(worldX, worldY); - } - - /** Радиус взаимодействия (юнитов): от ног героя до центра тайла цели. */ - private static readonly INTERACT_RANGE = 1.5; - - private inInteractRange(tx: number, ty: number): boolean { - return inCircleW(this.player.position, LocationScene.INTERACT_RANGE, tileToWorld(tx, ty)); - } - - /** - * Взаимодействие с NPC: в радиусе — сразу; издалека — герой идёт к краю тайла, - * диалог начнётся на месте. force — сюжетное исключение без подхода. - */ - private requestTalk(def: NpcDef, force = false): void { - this.pendingInteraction = null; - if (force || this.inInteractRange(def.tile.x, def.tile.y)) { - this.talkTo(def); - return; - } - const path = findPathToNeighbor(this.map, this.player.currentTile(), def.tile); - if (path) { - this.pendingInteraction = { kind: 'talk', def }; - this.player.followPath(path); - } - } - - /** Сбор цветка: в радиусе — сразу, издалека — подойти и собрать. */ - private requestCollect(x: number, y: number): void { - this.pendingInteraction = null; - if (this.inInteractRange(x, y)) { - this.collectFlower(x, y); - return; - } - const path = findPathToNeighbor(this.map, this.player.currentTile(), { x, y }); - if (path) { - this.pendingInteraction = { kind: 'flower', x, y }; - this.player.followPath(path); - } - } - - /** Клик-переход: в радиусе — сразу; издалека — подойти и сработать. */ - private requestTransition(def: TransitionDef): void { - this.pendingInteraction = null; - if (this.inInteractRange(def.tile.x, def.tile.y)) { - this.useTransition(def); - return; - } - const path = findPathToNeighbor(this.map, this.player.currentTile(), def.tile); - if (path) { - this.pendingInteraction = { kind: 'transition', def, tile: def.tile }; - this.player.followPath(path); - } - } - - /** Интерактивный объект: в радиусе — сразу; издалека — подойти и сработать. */ - private requestInteract(def: InteractableDef): void { - this.pendingInteraction = null; - if (this.inInteractRange(def.tile.x, def.tile.y)) { - this.interactables.tryInteract(def); - return; - } - const path = findPathToNeighbor(this.map, this.player.currentTile(), def.tile); - if (path) { - this.pendingInteraction = { kind: 'interact', def }; - this.player.followPath(path); - } - } - - /** Сработать отложенным взаимодействием, когда герой остановился. */ - private resolvePendingInteraction(): void { - if (!this.pendingInteraction || this.player.moving) return; - const p = this.pendingInteraction; - this.pendingInteraction = null; - if (p.kind === 'talk') { - if (this.inInteractRange(p.def.tile.x, p.def.tile.y)) this.talkTo(p.def); - } else if (p.kind === 'flower') { - if (this.inInteractRange(p.x, p.y)) this.collectFlower(p.x, p.y); - } else if (p.kind === 'interact') { - if (this.inInteractRange(p.def.tile.x, p.def.tile.y)) { - this.interactables.tryInteract(p.def); - } - } else { - // Условие могли не выполнить, пока герой шёл — перепроверяем. - const pick = resolveTransition( - this.area.transitions, - p.tile, - 'click', - this.transitionCtx(), - null - ); - if (pick?.ok && this.inInteractRange(p.tile.x, p.tile.y)) this.useTransition(pick.def); - } - } - /** id тайла карты (для кликов по сборным объектам). */ private tileId(x: number, y: number): number { return this.map.data.tiles[y * this.map.data.width + x]; diff --git a/apps/game/src/systems/ClickRouting.ts b/apps/game/src/systems/ClickRouting.ts new file mode 100644 index 0000000..bde3ca4 --- /dev/null +++ b/apps/game/src/systems/ClickRouting.ts @@ -0,0 +1,348 @@ +import { + findPathToNeighbor, + inCircleW, + screenToWorld, + worldDist, + worldNorm, + worldToTile, + tileToWorld, + type Entity, + type IsometricTileMap, + type Vec2 +} from '@rpg/engine'; +import { TILES } from '../data/map'; +import type { AreaDef, TransitionDef } from '../data/locations'; +import { resolveTransition, type TransitionCtx, type TransitionPick } from '../data/transitions'; +import type { NpcDef } from '../data/npcs'; +import type { InteractableDef } from '../data/interactables'; +import type { Interactables } from './Interactables'; +import type { CombatWorld } from './combat/CombatWorld'; +import { PLAYER_COMBAT } from './combat/stats'; +import type { PlayerController } from './PlayerController'; +import type { Game } from '../Game'; + +/** + * Маршрутизация клика по миру и отложенных взаимодействий. + * Чистый резолвер приоритетов (resolveClick) тестируется в Vitest без Pixi; + * InteractionRouter — рантайм поверх него (путь, отложенное действие, + * step-переходы). Сцена остаётся оркестратором вьюх. + */ + +/** Что выбрано кликом по миру (приоритеты — в resolveClick). */ +export type ClickAction = + | { kind: 'talk'; def: NpcDef } + | { kind: 'transition'; def: TransitionDef } + | { kind: 'locked'; text: string } + | { kind: 'interact'; def: InteractableDef } + | { kind: 'flower'; x: number; y: number } + | { kind: 'enemy'; entity: Entity } + | { kind: 'move' }; + +/** Данные для резолва клика (все колбэки чистые, без Pixi). */ +export interface ClickProbe { + /** Точка мира под курсором (юниты). */ + world: Vec2; + /** Тайл под курсором (null — клик за картой). */ + clicked: { x: number; y: number } | null; + /** NPC на тайле. */ + npcAt(x: number, y: number): NpcDef | null; + /** Резолв click-переходов по тайлу (resolveTransition с контекстом). */ + transitionPick: TransitionPick; + /** Интерактивный объект на тайле. */ + interactableAt(x: number, y: number): InteractableDef | null; + /** Сборный объект на тайле (лунный колокольчик). */ + flowerAt(x: number, y: number): boolean; + /** Живой враг под точкой мира (юниты). */ + enemyAt(world: Vec2): Entity | null; +} + +/** + * Приоритеты клика по миру: NPC -> click-переход (заперто -> тост) -> + * интерактив -> цветок -> враг -> движение. Клик за картой — движение + * к краевому тайлу (зажимает PlayerController.onWorldClick). + */ +export function resolveClick(probe: ClickProbe): ClickAction { + const t = probe.clicked; + if (t) { + const npc = probe.npcAt(t.x, t.y); + if (npc) return { kind: 'talk', def: npc }; + + // Клик-переходы (колодцы, двери) — до маршрутизации движения; + // издалека герой сначала подходит к тайлу-триггеру. + const pick = probe.transitionPick; + if (pick) return pick.ok ? { kind: 'transition', def: pick.def } : { kind: 'locked', text: pick.lockedText }; + + const inter = probe.interactableAt(t.x, t.y); + if (inter) return { kind: 'interact', def: inter }; + + if (probe.flowerAt(t.x, t.y)) return { kind: 'flower', x: t.x, y: t.y }; + } + const enemy = probe.enemyAt(probe.world); + if (enemy !== null) return { kind: 'enemy', entity: enemy }; + return { kind: 'move' }; +} + +/** Отложенное взаимодействие: сработает, когда герой подойдёт и остановится. */ +export type PendingInteraction = + | { kind: 'talk'; def: NpcDef } + | { kind: 'flower'; x: number; y: number } + | { kind: 'interact'; def: InteractableDef } + | { kind: 'transition'; def: TransitionDef; tile: { x: number; y: number } }; + +/** Колбэки роутера в сцену: всё, что трогает вьюхи/катсцены/замену сцен. */ +export interface RouterCallbacks { + showToast(text: string): void; + playUiClick(): void; + talkTo(def: NpcDef): void; + collectFlower(x: number, y: number): void; + useTransition(def: TransitionDef): void; + /** Удар по цели из зоны (кулдаун внутри); true — удар состоялся, цель снята. */ + attackTarget(from: Vec2, dir: Vec2): boolean; +} + +/** Зависимости рантайма роутера (всё уже собрано сценой). */ +export interface RouterDeps { + game: Game; + map: IsometricTileMap; + area: AreaDef; + player: PlayerController; + combat: CombatWorld; + interactables: Interactables; + /** Тайл входа в область (step-переходы на нём дисармованы). */ + disarmTile: { x: number; y: number } | null; + /** Смещение worldRoot (виртуальные px) — перевод клика в юниты. */ + worldRootOffset(): Vec2; + callbacks: RouterCallbacks; +} + +/** Радиус взаимодействия (юнитов): от ног героя до центра тайла цели. */ +const INTERACT_RANGE = 1.5; + +/** + * Рантайм маршрутизации: клик по миру, авто-подход к врагу, отложенные + * взаимодействия, step-переходы с дисармом тайла входа. + */ +export class InteractionRouter { + private target: Entity | null = null; + private repathTimer = 0; + private pendingInteraction: PendingInteraction | null = null; + private disarmTile: { x: number; y: number } | null; + private prevStepTile: { x: number; y: number } | null = null; + + constructor(private deps: RouterDeps) { + this.disarmTile = deps.disarmTile; + } + + /** Радиус взаимодействия (для подписей объектов в сцене). */ + inInteractRange(tx: number, ty: number): boolean { + return inCircleW(this.deps.player.position, INTERACT_RANGE, tileToWorld(tx, ty)); + } + + /** Клик по миру (виртуальные px указателя). */ + handleWorldClick(px: number, py: number): void { + const off = this.deps.worldRootOffset(); + // Координаты указателя (виртуальные px) -> мировые юниты (учёт камеры). + const p = screenToWorld(px - off.x, py - off.y); + const clicked = worldToTile(p.x, p.y, this.deps.map.data.width, this.deps.map.data.height); + const action = resolveClick({ + world: p, + clicked, + npcAt: (x, y) => this.npcAt(x, y), + transitionPick: clicked + ? resolveTransition(this.deps.area.transitions, clicked, 'click', this.transitionCtx(), null) + : null, + interactableAt: (x, y) => this.deps.interactables.defAt(x, y), + flowerAt: (x, y) => this.isFlower(x, y), + enemyAt: (world) => this.enemyAt(world) + }); + switch (action.kind) { + case 'talk': + this.requestTalk(action.def); + return; + case 'transition': + this.requestTransition(action.def); + return; + case 'locked': + this.deps.callbacks.showToast(action.text); + this.deps.callbacks.playUiClick(); + return; + case 'interact': + this.requestInteract(action.def); + return; + case 'flower': + this.requestCollect(action.x, action.y); + return; + case 'enemy': + this.target = action.entity; + this.repathTimer = 0; + return; + case 'move': + this.target = null; + this.pendingInteraction = null; + this.deps.player.onWorldClick(p.x, p.y); + } + } + + /** Авто-подход к выбранной цели и удар при входе в конус. */ + updateTarget(dt: number): void { + if (this.target === null) return; + const en = this.deps.combat.enemies.get(this.target); + if (!en || en.brain.dead) { + this.target = null; + return; + } + const player = this.deps.player; + const from = player.position; + const dist = worldDist(from, en.pos); + if (dist <= PLAYER_COMBAT.attackStop) { + // В зоне — стоим и бьём по кулдауну + player.stop(); + if (this.deps.callbacks.attackTarget(from, worldNorm(en.pos.x - from.x, en.pos.y - from.y))) { + this.target = null; // цель «снята» ударом; дальше игрок решает сам + } + return; + } + // Перестраиваем путь к цели пару раз в секунду + this.repathTimer -= dt; + if (this.repathTimer <= 0) { + this.repathTimer = 0.5; + player.onWorldClick(en.pos.x, en.pos.y); + } + } + + /** Сработать отложенным взаимодействием, когда герой остановился. */ + resolvePending(): void { + if (!this.pendingInteraction || this.deps.player.moving) return; + const p = this.pendingInteraction; + this.pendingInteraction = null; + if (p.kind === 'talk') { + if (this.inInteractRange(p.def.tile.x, p.def.tile.y)) this.deps.callbacks.talkTo(p.def); + } else if (p.kind === 'flower') { + if (this.inInteractRange(p.x, p.y)) this.deps.callbacks.collectFlower(p.x, p.y); + } else if (p.kind === 'interact') { + if (this.inInteractRange(p.def.tile.x, p.def.tile.y)) { + this.deps.interactables.tryInteract(p.def); + } + } else { + // Условие могли не выполнить, пока герой шёл — перепроверяем. + const pick = resolveTransition( + this.deps.area.transitions, + p.tile, + 'click', + this.transitionCtx(), + null + ); + if (pick?.ok && this.inInteractRange(p.tile.x, p.tile.y)) this.deps.callbacks.useTransition(pick.def); + } + } + + /** + * Единый механизм переходов (step-триггер): герой наступил на тайл. + * На тайле входа step-переходы дисармованы, пока герой с него не ушёл. + */ + checkTransitions(tile: { x: number; y: number }): void { + const fresh = + this.prevStepTile === null || + this.prevStepTile.x !== tile.x || + this.prevStepTile.y !== tile.y; + this.prevStepTile = tile; + // Ушёл с тайла входа — дисарм снят, переходы снова работают. + if (this.disarmTile && (tile.x !== this.disarmTile.x || tile.y !== this.disarmTile.y)) { + this.disarmTile = null; + } + const pick = resolveTransition( + this.deps.area.transitions, + tile, + 'step', + this.transitionCtx(), + this.disarmTile + ); + if (!pick) return; + if (!pick.ok) { + // «Заперто» — не спамим: только при приходе на тайл. + if (fresh) { + this.deps.callbacks.showToast(pick.lockedText); + this.deps.callbacks.playUiClick(); + } + return; + } + this.deps.callbacks.useTransition(pick.def); + } + + /** Взаимодействие с NPC: в радиусе — сразу; издалека — герой идёт к краю тайла. */ + requestTalk(def: NpcDef): void { + this.pendingInteraction = null; + if (this.inInteractRange(def.tile.x, def.tile.y)) { + this.deps.callbacks.talkTo(def); + return; + } + this.walkTo(def.tile, { kind: 'talk', def }); + } + + /** Сбор цветка: в радиусе — сразу, издалека — подойти и собрать. */ + requestCollect(x: number, y: number): void { + this.pendingInteraction = null; + if (this.inInteractRange(x, y)) { + this.deps.callbacks.collectFlower(x, y); + return; + } + this.walkTo({ x, y }, { kind: 'flower', x, y }); + } + + /** Клик-переход: в радиусе — сразу; издалека — подойти и сработать. */ + requestTransition(def: TransitionDef): void { + this.pendingInteraction = null; + if (this.inInteractRange(def.tile.x, def.tile.y)) { + this.deps.callbacks.useTransition(def); + return; + } + this.walkTo(def.tile, { kind: 'transition', def, tile: def.tile }); + } + + /** Интерактивный объект: в радиусе — сразу; издалека — подойти и сработать. */ + requestInteract(def: InteractableDef): void { + this.pendingInteraction = null; + if (this.inInteractRange(def.tile.x, def.tile.y)) { + this.deps.interactables.tryInteract(def); + return; + } + this.walkTo(def.tile, { kind: 'interact', def }); + } + + /** Путь к краю тайла цели; нет пути — тихий no-op. */ + private walkTo(goal: { x: number; y: number }, pending: PendingInteraction): void { + const path = findPathToNeighbor(this.deps.map, this.deps.player.currentTile(), goal); + if (path) { + this.pendingInteraction = pending; + this.deps.player.followPath(path); + } + } + + private npcAt(x: number, y: number): NpcDef | null { + return this.deps.area.npcs.find((n) => n.tile.x === x && n.tile.y === y) ?? null; + } + + /** Лунный колокольчик: сборный тайл, только в прудах (id области). */ + private isFlower(x: number, y: number): boolean { + if (this.deps.area.id !== 'ponds') return false; + const d = this.deps.map.data; + return d.tiles[y * d.width + x] === TILES.BELLFLOWER; + } + + /** Живой враг под точкой мира (с запасом на радиус тела). */ + private enemyAt(world: Vec2): Entity | null { + for (const [e, en] of this.deps.combat.enemies) { + if (en.brain.dead) continue; + if (inCircleW(en.pos, en.kind.radius + 0.15, world)) return e; + } + return null; + } + + /** Контекст условий перехода: флаги GameState, сумка героя. */ + private transitionCtx(): TransitionCtx { + return { + hasFlag: (f) => this.deps.game.state.hasFlag(f), + hasItem: (id) => this.deps.game.inventory.has(id) + }; + } +} \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/ClickRouting.test.ts b/apps/game/src/systems/__tests__/ClickRouting.test.ts new file mode 100644 index 0000000..3347617 --- /dev/null +++ b/apps/game/src/systems/__tests__/ClickRouting.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import type { Vec2 } from '@rpg/engine'; +import { resolveClick, type ClickProbe } from '../ClickRouting'; +import type { NpcDef } from '../../data/npcs'; +import type { InteractableDef } from '../../data/interactables'; +import type { TransitionDef } from '../../data/locations'; + +const npc: NpcDef = { + id: 'elder', + name: 'Ирвин', + sprite: 'elder', + tile: { x: 5, y: 5 }, + flagKey: 'met_elder', + dialogueFirst: 'd1', + dialogueRepeat: 'd2' +}; + +const chest: InteractableDef = { + id: 'chest', + kind: 'container', + tile: { x: 6, y: 5 }, + responses: [] +}; + +const well: TransitionDef = { + tile: { x: 7, y: 5 }, + label: 'колодец', + target: { kind: 'area', area: 'meadows', entry: { x: 1, y: 1 } } +}; + +/** Проба клика: тайл и мир задаются тестом, остальное — заглушки. */ +function probe(opts: { + clicked?: { x: number; y: number } | null; + world?: Vec2; + npcAt?: ClickProbe['npcAt']; + transitionPick?: ClickProbe['transitionPick']; + interactableAt?: ClickProbe['interactableAt']; + flowerAt?: ClickProbe['flowerAt']; + enemyAt?: ClickProbe['enemyAt']; +}): ClickProbe { + return { + world: opts.world ?? { x: 0, y: 0 }, + clicked: opts.clicked ?? null, + npcAt: opts.npcAt ?? (() => null), + transitionPick: opts.transitionPick ?? null, + interactableAt: opts.interactableAt ?? (() => null), + flowerAt: opts.flowerAt ?? (() => false), + enemyAt: opts.enemyAt ?? (() => null) + }; +} + +describe('resolveClick — приоритеты клика по миру', () => { + it('NPC на тайле — talk (выше перехода и интерактива)', () => { + const a = resolveClick( + probe({ + clicked: { x: 5, y: 5 }, + npcAt: () => npc, + transitionPick: { ok: true, def: well }, + interactableAt: () => chest + }) + ); + expect(a).toEqual({ kind: 'talk', def: npc }); + }); + + it('клик-переход: открытый — transition', () => { + const a = resolveClick(probe({ clicked: { x: 7, y: 5 }, transitionPick: { ok: true, def: well } })); + expect(a).toEqual({ kind: 'transition', def: well }); + }); + + it('клик-переход: запертый — locked с текстом (не движение)', () => { + const a = resolveClick( + probe({ + clicked: { x: 7, y: 5 }, + transitionPick: { ok: false, lockedText: 'Заперто.' } + }) + ); + expect(a).toEqual({ kind: 'locked', text: 'Заперто.' }); + }); + + it('интерактивный объект на тайле — interact', () => { + const a = resolveClick(probe({ clicked: { x: 6, y: 5 }, interactableAt: () => chest })); + expect(a).toEqual({ kind: 'interact', def: chest }); + }); + + it('сборный тайл (цветок) — flower', () => { + const a = resolveClick( + probe({ clicked: { x: 3, y: 3 }, flowerAt: (x, y) => x === 3 && y === 3 }) + ); + expect(a).toEqual({ kind: 'flower', x: 3, y: 3 }); + }); + + it('враг под точкой мира — enemy (тайл может быть за картой)', () => { + const e = { id: 1 } as never; + const a = resolveClick( + probe({ clicked: null, world: { x: 10, y: 20 }, enemyAt: (w) => (w.x === 10 ? e : null) }) + ); + expect(a).toEqual({ kind: 'enemy', entity: e }); + }); + + it('клик за картой без врага — move', () => { + expect(resolveClick(probe({ clicked: null }))).toEqual({ kind: 'move' }); + expect(resolveClick(probe({ clicked: { x: 9, y: 9 } }))).toEqual({ kind: 'move' }); + }); +}); \ No newline at end of file diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 961aef2..057c491 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -166,6 +166,19 @@ 4. Позицию врага в юнит-тестах двигай руками: мозг чистый, сенсор `pos` — это то, что подал тест (см. `EnemyAI.test.ts`). +## Ситуация: меняю клик-роутинг / агентный мост сцены + +1. `LocationScene` — только оркестратор вьюх: текстуры, композитинг, ввод, + камера, катсцены. Логика кликов живёт в `systems/ClickRouting.ts` + (чистый `resolveClick(probe)` + рантайм `InteractionRouter` с deps-объектом), + мост — в `agent/SceneAgentView.ts`. В сцену новые ветки кликов не добавлять — + расширяй резолвер (приоритеты: NPC → переход/заперто → интерактив → цветок → + враг → движение) и юнит-тесты к нему (`ClickRouting.test.ts`). +2. Клик-приоритеты меняются только вместе с тестами резолвера и + `agent check` (проба `transitions` проверяет колодцы, `interact` — объекты). +3. Слои снапшота собираются фабриками из `agent/snapshot.ts` — при новом поле + снапшота обнови фабрику и тип в одном месте; сцена/фасад дубликаты не пишут. + ## Грабли среды (кратко, подробности в CLAUDE.md) - **Тулзы живут в `apps/game/tools/` (3 уровня ниже корня)**: скриптам,