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 { SceneObjects } from './SceneObjects';
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;
/** Реестр объектов сцены: «кто на тайле» для NPC/интерактивов/врагов. */
objects: SceneObjects;
/** Тайл входа в область (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.objects.interactableAt(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.objects.npcAt(x, y);
}
/** Лунный колокольчик: сборный тайл, только в прудах (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;
}
/** Живой враг под точкой мира (реестр: тело в радиусе + 0.15 клик-запаса). */
private enemyAt(world: Vec2): Entity | null {
// Выборка с запасом (крупные виды) — точная метрика ниже.
const hits = this.deps.objects.registry.near(world, 1.0, 'enemy');
for (const hit of hits) {
const e = hit.ref as Entity;
const en = this.deps.combat.enemies.get(e);
if (!en || en.brain.dead) continue;
// Точная метрика как раньше: радиус тела + клик-запас.
if (inCircleW(hit.pos, hit.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)
};
}
}