Newer
Older
rpg / apps / game / src / systems / ClickRouting.ts
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)
        };
    }
}