import { tileToWorld, type IsometricTileMap, type TileMapData, type Vec2 } from '@rpg/engine';
import type { AreaDef, TransitionDef } from '../data/locations';
import { resolveTransition, type TransitionCtx } 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 { PlayerController } from './PlayerController';
import type { Game } from '../Game';
/**
* Маршрутизация взаимодействия: кнопка действия (ближайшая цель в радиусе)
* и step-переходы с дисармом тайла входа. Чистый пикер (pickInteract)
* тестируется в Vitest без Pixi; InteractionRouter — рантайм поверх него.
* Сцена остаётся оркестратором вьюх. (Клик-роутинг удалён: движение —
* WASD/стик/джойстик, взаимодействие — кнопкой действия.)
*/
/** Колбэки роутера в сцену: всё, что трогает вьюхи/катсцены/замену сцен. */
export interface RouterCallbacks {
showToast(text: string): void;
playUiClick(): void;
talkTo(def: NpcDef): void;
collectFlower(x: number, y: number): void;
useTransition(def: TransitionDef): void;
}
/** Зависимости рантайма роутера (всё уже собрано сценой). */
export interface RouterDeps {
game: Game;
map: IsometricTileMap;
area: AreaDef;
player: PlayerController;
interactables: Interactables;
/** Реестр объектов сцены: «кто на тайле» (NPC/интерактивы, grid путей). */
objects: SceneObjects;
/** Тайл входа в область (step-переходы на нём дисармованы). */
disarmTile: { x: number; y: number } | null;
callbacks: RouterCallbacks;
}
/** Радиус взаимодействия (юнитов): от ног героя до центра тайла цели. */
const INTERACT_RANGE = 1.5;
/** Дистанция до центра тайла цели: истинная евклидова линейка (юнит = 1 тайл). */
function tileDist(playerPos: Vec2, tx: number, ty: number): number {
const c = tileToWorld(tx, ty);
return Math.hypot(c.x - playerPos.x, c.y - playerPos.y);
}
/**
* Радиус взаимодействия: истинная евклидова линейка (юнит = 1 тайл), не
* проекционная. Проекция анизотропна (2:1) — «юниты» в ней вытянуты вдоль
* мировой диагонали, и радиус в inCircleW давал зону в 2-3 тайла: объект
* срабатывал издалека, до подхода героя. Орто-сосед 1.0 ✓, диагональный
* сосед 1.414 ✓ (findPathToNeighbor приводит в него), 2 тайла ✗.
*/
export function inInteractRangePos(playerPos: Vec2, tx: number, ty: number): boolean {
return tileDist(playerPos, tx, ty) <= INTERACT_RANGE;
}
/** Кандидат кнопки действия: то, с чем можно взаимодействовать в радиусе. */
export interface InteractCandidate {
kind: 'npc' | 'transition' | 'locked' | 'interact' | 'flower';
tile: { x: number; y: number };
/** Дистанция от героя до центра тайла (юниты) — решает выбор. */
dist: number;
/** Человекочитаемое имя для подписи «[E] …». */
label: string;
npc?: NpcDef;
inter?: InteractableDef;
trans?: TransitionDef;
/** Текст «заперто» для kind 'locked'. */
text?: string;
/** Тайл цветка (kind 'flower'). */
x?: number;
y?: number;
}
/** Приоритет при ничьей по дистанции: NPC важнее перехода, тот — объекта, объект — цветка. */
const PICK_PRIORITY: Record<InteractCandidate['kind'], number> = {
npc: 0,
transition: 1,
locked: 1,
interact: 2,
flower: 3
};
/** Эпсилон ничьей по дистанции (юниты): ближе — выигрывает дистанция, иначе приоритет. */
const PICK_EPS = 0.05;
/**
* Выбор цели кнопки действия: ближайший кандидат в радиусе; при ничьей
* (~эпсилон) — по приоритету kind. Чистая функция — тестируется в Vitest.
*/
export function pickInteract(candidates: InteractCandidate[]): InteractCandidate | null {
let best: InteractCandidate | null = null;
for (const c of candidates) {
if (c.dist > INTERACT_RANGE) continue;
if (
best === null ||
c.dist < best.dist - PICK_EPS ||
(c.dist <= best.dist + PICK_EPS && PICK_PRIORITY[c.kind] < PICK_PRIORITY[best.kind])
) {
best = c;
}
}
return best;
}
/**
* Рантайм маршрутизации: кнопка действия (ближайшая цель в радиусе) и
* step-переходы с дисармом тайла входа.
*/
export class InteractionRouter {
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 inInteractRangePos(this.deps.player.position, tx, ty);
}
/**
* Единый механизм переходов (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, click-переходы, интерактивы, цветы. */
interactCandidates(): InteractCandidate[] {
const player = this.deps.player.position;
const heroTile = this.deps.player.currentTile();
const out: InteractCandidate[] = [];
for (const def of this.deps.objects.npcs()) {
out.push({
kind: 'npc',
npc: def,
tile: def.tile,
dist: tileDist(player, def.tile.x, def.tile.y),
label: def.name
});
}
for (const t of this.deps.area.transitions) {
if ((t.trigger ?? 'step') !== 'click') continue;
const pick = resolveTransition(this.deps.area.transitions, t.tile, 'click', this.transitionCtx(), null);
if (!pick) continue;
out.push({
kind: pick.ok ? 'transition' : 'locked',
trans: pick.ok ? pick.def : undefined,
text: pick.ok ? undefined : pick.lockedText,
tile: t.tile,
dist: tileDist(player, t.tile.x, t.tile.y),
label: t.label ?? 'проход'
});
}
for (const def of this.deps.area.interactables ?? []) {
if (def.once && this.deps.interactables.isUsed(def.id)) continue;
out.push({
kind: 'interact',
inter: def,
tile: def.tile,
dist: tileDist(player, def.tile.x, def.tile.y),
label: def.label ?? def.id
});
}
// Цветы: скан окна ±2 тайла вокруг героя по сборному тайлу области.
if (this.deps.area.harvestTile !== undefined) {
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
const x = heroTile.x + dx;
const y = heroTile.y + dy;
if (!this.isFlower(x, y)) continue;
out.push({ kind: 'flower', x, y, tile: { x, y }, dist: tileDist(player, x, y), label: 'лунный колокольчик' });
}
}
}
return out;
}
/** Ближайшая цель кнопки действия — для подписи «[E] имя» и снапшота моста. */
interactTarget(): { kind: InteractCandidate['kind']; label: string; tile: { x: number; y: number } } | null {
const c = pickInteract(this.interactCandidates());
return c && { kind: c.kind, label: c.label, tile: c.tile };
}
/** Кнопка действия: сработать ближайшей целью в радиусе (вне радиуса — ничего). */
resolveInteract(): void {
const c = pickInteract(this.interactCandidates());
if (!c) return;
switch (c.kind) {
case 'npc':
this.deps.callbacks.talkTo(c.npc!);
return;
case 'transition':
this.deps.callbacks.useTransition(c.trans!);
return;
case 'locked':
this.deps.callbacks.showToast(c.text ?? 'Не пускает.');
this.deps.callbacks.playUiClick();
return;
case 'interact':
this.deps.interactables.tryInteract(c.inter!);
return;
case 'flower':
this.deps.callbacks.collectFlower(c.x!, c.y!);
}
}
/** Сборный тайл области (лунный колокольчик); нет harvestTile — не собирается. */
private isFlower(x: number, y: number): boolean {
const harvest = this.deps.area.harvestTile;
if (harvest === undefined) return false;
const d: TileMapData = this.deps.map.data;
return d.tiles[y * d.width + x] === harvest;
}
/** Контекст условий перехода: флаги GameState, сумка героя. */
private transitionCtx(): TransitionCtx {
return {
hasFlag: (f) => this.deps.game.state.hasFlag(f),
hasItem: (id) => this.deps.game.inventory.has(id)
};
}
}