diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index d0cc433..cfadca2 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -33,7 +33,7 @@ import { areaOf, type AreaDef, type AreaId, type TransitionDef } from '../data/locations'; import { AudioSystem } from '../systems/AudioSystem'; import type { StepSurface } from '../systems/StepVariants'; -import { InteractionRouter, resolvePixelTile } from '../systems/ClickRouting'; +import { InteractionRouter } from '../systems/InteractRouting'; import { SceneAgentView } from '../agent/SceneAgentView'; import type { NpcDef } from '../data/npcs'; import { npcsForArea } from '../data/npcs'; @@ -200,10 +200,8 @@ // Шаги — тихий шум (0.35): бодрых сгустков настораживает, спящих не будит. this.combat.noise(this.player.position, 0.35); }); - // Тела: герой не проходит сквозь NPC (расталкивание через реестр), - // пути строятся по grid с занятыми NPC-тайлами — A* не ведёт сквозь тело. + // Тела: герой не проходит сквозь NPC (расталкивание через реестр). 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 } }); @@ -338,26 +336,21 @@ this.cutscene ); - // Маршрутизация клика/переходов/отложенных взаимодействий. + // Маршрутизация взаимодействия (кнопка действия) и step-переходов. this.router = new InteractionRouter({ game: this.game, map: this.map, area, player: this.player, - combat: this.combat, interactables: this.interactables, objects: this.objects, disarmTile, - worldRootOffset: () => this.game.renderer.worldRoot.position, - worldZoom: () => this.game.renderer.worldRoot.scale.x, - pixelTile: (px, py) => this.pixelTile(px, py), callbacks: { showToast: (t) => this.showToast(t), playUiClick: () => playSfx(this.game.audio, 'sfx/ui_click', 0.4), talkTo: (d) => this.talkTo(d), collectFlower: (x, y) => this.flowers.collectFlower(x, y), - useTransition: (d) => this.useTransition(d), - attackTarget: (from, dir) => this.flow.attackTarget(from, dir) + useTransition: (d) => this.useTransition(d) } }); @@ -586,21 +579,15 @@ this.router.resolveInteract(); } - const pointer = input.getPointer(); - if (pointer.justPressed) { - if (pointer.isTouch) { - // Касание отдаём джойстику (мышь идёт в мир обычным порядком). - this.joystick.eventMode = 'static'; - } else { - this.router.handleWorldClick(pointer.x, pointer.y); - } + // Касание отдаём джойстику (тач-устройство: движение стиком). + if (input.getPointer().justPressed && input.getPointer().isTouch) { + this.joystick.eventMode = 'static'; } // --- боевой мир --- this.combat.update(dt); this.combatViews.sync(dt); this.locationAudio.updateMusic(dt); - this.router.updateTarget(dt); this.flow.update(dt); // Мигание героя при неуязвимости: duty blink-оживителя живой. @@ -612,7 +599,6 @@ } else { this.player.update(dt); } - this.router.resolvePending(); const tile = this.player.currentTile(); this.hud.setMotes(this.game.state.getNumber(VARS.motes)); this.atmosphere.updateHazard(tile); @@ -718,30 +704,6 @@ return 'grass'; } - /** - * Тайл клика «по телу объекта» (RouterDeps.pixelTile): чистый резолвер - * resolvePixelTile с телами вьюх NPC/интерактивов и кликабельностью базы - * высокого тайла (двери-переходы, объекты). - */ - private pixelTile(px: number, py: number): { x: number; y: number } | null { - const bodies = [ - ...this.npcs.map((n) => ({ tile: n.def.tile, body: n.body })), - ...this.interactViews.items.map((iv) => ({ tile: iv.def.tile, body: iv.body })) - ].map(({ tile, body }) => ({ tile, bounds: body.getBounds() })); - return resolvePixelTile( - px, - py, - this.game.renderer.worldRoot.position, - bodies, - this.map.data, - (tile) => - this.area.transitions.some( - (tr) => tr.tile.x === tile.x && tr.tile.y === tile.y && tr.trigger === 'click' - ) || this.interactables.defAt(tile.x, tile.y) !== null, - this.game.renderer.worldRoot.scale.x - ); - } - /** Предикаты мира для условий (сумка героя). */ private dialogueWorld(): DialogueWorld { return { hasItem: (id) => this.game.inventory.count(id as ItemId) > 0 }; diff --git a/apps/game/src/systems/ClickRouting.ts b/apps/game/src/systems/ClickRouting.ts deleted file mode 100644 index 9550ba3..0000000 --- a/apps/game/src/systems/ClickRouting.ts +++ /dev/null @@ -1,576 +0,0 @@ -import { - findPathToNeighbor, - inCircleW, - screenToWorld, - worldDist, - worldNorm, - worldToTile, - tileToWorld, - type Entity, - type IsometricTileMap, - type TileMapData, - type Vec2 -} from '@rpg/engine'; -// inCircleW/worldDist/worldNorm — проекционная линейка: конусы удара и клик по телу врага. -import { TALL_OBJECTS } 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' }; -} - -/** Тело вьюхи для клика «по объекту»: тайл объекта + экранный bbox. */ -export interface PixelBody { - tile: { x: number; y: number }; - bounds: { x: number; y: number; width: number; height: number }; -} - -/** - * Указатель (виртуальные px) -> точка мира (юниты): учёт смещения worldRoot - * и зума камеры (экранные px сжаты в мировые делением на z). - * Общий хелпер клик-роутинга и пиксельного тайла. - */ -export function pointerToWorld(px: number, py: number, offset: Vec2, zoom = 1): Vec2 { - return screenToWorld((px - offset.x) / zoom, (py - offset.y) / zoom); -} - -/** - * Тайл клика «по телу объекта» (RouterDeps.pixelTile): bbox вьюх NPC и - * интерактивов + экранное тело высокого тайла (дом, дерево, башня). - * Экранно объект стоит выше своего ромба-пола, и клик по спрайту - * «проваливается под объект» — отдаём его базовому тайлу. Чистая функция - * (без Pixi) — тестируется в Vitest. - */ -export function resolvePixelTile( - px: number, - py: number, - offset: Vec2, - bodies: PixelBody[], - data: TileMapData, - isClickable: (tile: { x: number; y: number }) => boolean, - zoom = 1 -): { x: number; y: number } | null { - // Тела вьюх (без подписей — они тянутся на тайл выше): границы в - // глобальных виртуальных px (рендер всегда 480×270) — уже с зумом - // (getBounds считает масштаб worldRoot), сами не масштабируются. - for (const b of bodies) { - const { x, y, width, height } = b.bounds; - if (px >= x && px <= x + width && py >= y && py <= y + height) return b.tile; - } - // Высокий тайл: тело стоит на (tx,ty), а занимает на экране (tx-1,ty-1). - // Ремап — только если на базовом тайле есть что кликать (дверь, объект): - // иначе клик по «телу дерева» уводил бы героя к непроходимому тайлу. - const p = pointerToWorld(px, py, offset, zoom); - const t = worldToTile(p.x, p.y, data.width, data.height); - if (!t || t.x + 1 >= data.width || t.y + 1 >= data.height) return null; - const base = { x: t.x + 1, y: t.y + 1 }; - const above = data.tiles[(t.y + 1) * data.width + (t.x + 1)]!; - if (!TALL_OBJECTS[above]) return null; - return isClickable(base) ? base : null; -} - -/** Отложенное взаимодействие: сработает, когда герой подойдёт и остановится. */ -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; - /** Текущий зум камеры (scale worldRoot); нет — 1. */ - worldZoom?(): number; - /** - * Тайл по пикселям указателя (виртуальные px) или null. Сцена отдаёт - * тайл объекта, чьё ТЕЛО (bbox вьюхи / высокий спрайт) накрыто кликом: - * экранно объект стоит выше своего ромба-пола, и по голому тайлу под - * курсором клик «проваливается под объект». Нет — обычный worldToTile. - */ - pixelTile?(px: number, py: number): { 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 = { - 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 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 inInteractRangePos(this.deps.player.position, tx, ty); - } - - /** Клик по миру (виртуальные px указателя). */ - handleWorldClick(px: number, py: number): void { - // Координаты указателя (виртуальные px) -> мировые юниты (камера + зум). - const p = pointerToWorld(px, py, this.deps.worldRootOffset(), this.deps.worldZoom?.() ?? 1); - const clicked = worldToTile(p.x, p.y, this.deps.map.data.width, this.deps.map.data.height); - const probe = (tile: { x: number; y: number } | null) => ({ - world: p, - clicked: tile, - npcAt: (x: number, y: number) => this.npcAt(x, y), - transitionPick: tile - ? resolveTransition(this.deps.area.transitions, tile, 'click', this.transitionCtx(), null) - : null, - interactableAt: (x: number, y: number) => this.deps.objects.interactableAt(x, y), - flowerAt: (x: number, y: number) => this.isFlower(x, y), - enemyAt: (world: Vec2) => this.enemyAt(world) - }); - let action = resolveClick(probe(clicked)); - // «Тело объекта» (bbox вьюх / высокий тайл) — только ФОЛБЭК: если клик - // по своему тайлу ничего не выбрал (голое движение). Иначе ломается - // маршрутизация: подписи и спрайты тянутся на тайл «за» объектом, и - // клики движения рядом с объектом начинали бы говорить/подбирать. - if (action.kind === 'move') { - const body = this.deps.pixelTile?.(px, py); - if (body && (!clicked || body.x !== clicked.x || body.y !== clicked.y)) { - action = resolveClick(probe(body)); - } - } - 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, 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!); - } - } - - /** Взаимодействие с 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.objects.walkGrid(), 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); - } - - /** Сборный тайл области (лунный колокольчик); нет harvestTile — не собирается. */ - private isFlower(x: number, y: number): boolean { - const harvest = this.deps.area.harvestTile; - if (harvest === undefined) return false; - const d = this.deps.map.data; - return d.tiles[y * d.width + x] === harvest; - } - - /** Живой враг под точкой мира (реестр: тело в радиусе + 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) - }; - } -} \ No newline at end of file diff --git a/apps/game/src/systems/InteractRouting.ts b/apps/game/src/systems/InteractRouting.ts new file mode 100644 index 0000000..be4b549 --- /dev/null +++ b/apps/game/src/systems/InteractRouting.ts @@ -0,0 +1,258 @@ +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 = { + 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) + }; + } +} \ No newline at end of file diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index 3986806..cdaf045 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -1,6 +1,5 @@ import { IsometricTileMap, - findPath, worldToScreen, screenToWorld, worldNorm, @@ -14,7 +13,6 @@ Container, Sprite, Texture, - type Grid, type SceneRegistry, type Vec2 } from '@rpg/engine'; @@ -95,12 +93,6 @@ 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; @@ -123,23 +115,7 @@ } } - /** Кликом по миру (юниты) выбран тайл — строим путь A* и начинаем движение. - * Клик за краем карты ведёт к ближайшему краевому тайлу (камера прижата границей). */ - onWorldClick(worldX: number, worldY: number): void { - const w = this.map.data.width; - const h = this.map.data.height; - const clicked = worldToTile(worldX, worldY, w, h); - const tile = clicked ?? { - 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.walkGrid ?? this.map, this.currentTile(), tile, false); - if (path && path.length > 0) { - this.followPath(path); - } - } - - /** Пойти по готовому пути тайлов (например, к краю занятого NPC/объектом тайла). */ + /** Пойти по готовому пути тайлов (мост: scene:walk/approach, кат-сцены). */ followPath(path: { x: number; y: number }[]): void { if (path.length === 0) return; this.path = path; diff --git a/apps/game/src/systems/__tests__/ClickRouting.test.ts b/apps/game/src/systems/__tests__/ClickRouting.test.ts deleted file mode 100644 index deec9aa..0000000 --- a/apps/game/src/systems/__tests__/ClickRouting.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { worldToScreen, type Vec2 } from '@rpg/engine'; -import { - inInteractRangePos, - pickInteract, - pointerToWorld, - resolveClick, - resolvePixelTile, - type ClickProbe, - type InteractCandidate -} from '../ClickRouting'; -import { TILES } from '../../data/map'; -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', - pos: { x: 5.5, y: 5.5 }, - tile: { x: 5, y: 5 }, - flagKey: 'met_elder', - dialogueFirst: 'd1', - dialogueRepeat: 'd2' -}; - -const chest: InteractableDef = { - id: 'chest', - kind: 'container', - pos: { x: 6.5, y: 5.5 }, - 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' }); - }); -}); - -describe('inInteractRangePos — истинная евклидова линейка (юнит = тайл)', () => { - // Центр тайла (5,5) = мир (5.5, 5.5): NPC/объект стоят в центре своего тайла. - - it('соседний тайл по оси — в радиусе (дистанция 1.0)', () => { - expect(inInteractRangePos({ x: 6.5, y: 5.5 }, 5, 5)).toBe(true); - }); - - it('диагональный сосед — в радиусе (дистанция 1.414 < 1.5)', () => { - // findPathToNeighbor может привести героя в диагонального соседа цели. - expect(inInteractRangePos({ x: 6.5, y: 6.5 }, 5, 5)).toBe(true); - }); - - it('два тайла — вне радиуса (дистанция 2.0): дальний клик не срабатывает', () => { - // Регресс бага: проекционная линейка давала «радиус» в 2-3 тайла. - expect(inInteractRangePos({ x: 7.5, y: 5.5 }, 5, 5)).toBe(false); - }); - - it('2 тайла вдоль мировой диагонали (проекция была бы «ближе») — вне радиуса', () => { - expect(inInteractRangePos({ x: 6.5, y: 6.5 }, 4, 4)).toBe(false); - }); -}); - -describe('pickInteract — выбор цели кнопки действия', () => { - /** Кандидат-заглушка: тайл задаёт дистанцию (герой в (0,0)). */ - function cand( - kind: InteractCandidate['kind'], - tile: { x: number; y: number }, - label = 'цель' - ): InteractCandidate { - return { kind, tile, dist: Math.hypot(tile.x, tile.y), label }; - } - - it('ближайший кандидат в радиусе — он', () => { - const c = pickInteract([cand('npc', { x: 2, y: 0 }), cand('interact', { x: 1, y: 0 })]); - expect(c?.label).toBe('цель'); - expect(c?.kind).toBe('interact'); - expect(c?.tile).toEqual({ x: 1, y: 0 }); - }); - - it('вне радиуса (два тайла и дальше) — не выбирается', () => { - expect(pickInteract([cand('npc', { x: 2, y: 0 })])).toBeNull(); - expect(pickInteract([])).toBeNull(); - }); - - it('ничья по дистанции — приоритет NPC выше интерактива', () => { - const c = pickInteract([cand('interact', { x: 1, y: 0 }), cand('npc', { x: 0, y: 1 })]); - expect(c?.kind).toBe('npc'); - }); - - it('почти-ничья (в эпсилоне) — приоритет: переход выше цветка', () => { - const c = pickInteract([cand('flower', { x: 1, y: 1 }), cand('transition', { x: 1.04, y: 0 })]); - expect(c?.kind).toBe('transition'); - }); - - it('заметно ближе — дистанция побеждает приоритет', () => { - const c = pickInteract([cand('npc', { x: 1, y: 0 }), cand('flower', { x: 0.5, y: 0 })]); - expect(c?.kind).toBe('flower'); - }); - - it('заперто — кандидат locked с текстом', () => { - const c: InteractCandidate = { - kind: 'locked', - tile: { x: 1, y: 0 }, - dist: 1, - label: 'дверь', - text: 'Заперто.' - }; - expect(pickInteract([c])?.text).toBe('Заперто.'); - }); -}); - -/** Карта 4×4: весь — трава, базовый тайл (3,3) — высокое дерево. */ -function mapData(treeAt: { x: number; y: number } | null = { x: 3, y: 3 }) { - const tiles = new Array(16).fill(TILES.GRASS); - if (treeAt) tiles[treeAt.y * 4 + treeAt.x] = TILES.TREE; - return { width: 4, height: 4, tiles, blocked: [] as number[] }; -} - -describe('pointerToWorld — указатель в виртуальных px -> юниты', () => { - it('учёт смещения worldRoot и round-trip с worldToScreen', () => { - const offset = { x: 16, y: 20 }; - const p = pointerToWorld(48 + offset.x, 32 + offset.y, offset); - expect(p.x).toBeCloseTo(3.5); - expect(p.y).toBeCloseTo(0.5); - const s = worldToScreen(p.x, p.y); - expect(s.x + offset.x).toBeCloseTo(48 + offset.x); - expect(s.y + offset.y).toBeCloseTo(32 + offset.y); - }); -}); - -describe('resolvePixelTile — тайл клика «по телу объекта»', () => { - const offset = { x: 10, y: 10 }; - - it('клик в bbox тела вьюхи — тайл тела (приоритет над тайлом под ним)', () => { - const bodies = [{ tile: { x: 1, y: 2 }, bounds: { x: 100, y: 50, width: 24, height: 30 } }]; - const t = resolvePixelTile(110 + offset.x, 60 + offset.y, offset, bodies, mapData(), () => false); - expect(t).toEqual({ x: 1, y: 2 }); - }); - - it('мимо тел: высокий тайл с кликабельным базовым — ремап на (t+1, t+1)', () => { - // Указатель (0,40) при offset 0 -> тайл (2,2); тело дерева экранно на (3,3). - const t = resolvePixelTile(0, 40, { x: 0, y: 0 }, [], mapData(), () => true); - expect(t).toEqual({ x: 3, y: 3 }); - }); - - it('высокий тайл с некликабельным базовым — null (не уводить героя к дереву)', () => { - const t = resolvePixelTile(0, 40, { x: 0, y: 0 }, [], mapData(), () => false); - expect(t).toBeNull(); - }); - - it('не высокий тайл — null, даже если базовый «кликабелен»', () => { - const t = resolvePixelTile(0, 40, { x: 0, y: 0 }, [], mapData(null), () => true); - expect(t).toBeNull(); - }); - - it('клик у правого края карты (t+1 за границей) — null', () => { - // Указатель (48,32) при offset {x:0,y:0} -> тайл (3,0), t.x+1 = 4 = width. - const t = resolvePixelTile(48, 32, { x: 0, y: 0 }, [], mapData(), () => true); - expect(t).toBeNull(); - }); - - it('клик за картой — null', () => { - // (0, 300) -> мир (18.75, 18.75), далеко за 4×4. - const t = resolvePixelTile(0, 300, { x: 0, y: 0 }, [], mapData(), () => true); - expect(t).toBeNull(); - }); -}); \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/InteractRouting.test.ts b/apps/game/src/systems/__tests__/InteractRouting.test.ts new file mode 100644 index 0000000..a0947e4 --- /dev/null +++ b/apps/game/src/systems/__tests__/InteractRouting.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { inInteractRangePos, pickInteract, type InteractCandidate } from '../InteractRouting'; + +describe('inInteractRangePos — истинная евклидова линейка (юнит = тайл)', () => { + // Центр тайла (5,5) = мир (5.5, 5.5): NPC/объект стоят в центре своего тайла. + + it('соседний тайл по оси — в радиусе (дистанция 1.0)', () => { + expect(inInteractRangePos({ x: 6.5, y: 5.5 }, 5, 5)).toBe(true); + }); + + it('диагональный сосед — в радиусе (дистанция 1.414 < 1.5)', () => { + // findPathToNeighbor может привести героя в диагонального соседа цели. + expect(inInteractRangePos({ x: 6.5, y: 6.5 }, 5, 5)).toBe(true); + }); + + it('два тайла — вне радиуса (дистанция 2.0): дальний клик не срабатывает', () => { + // Регресс бага: проекционная линейка давала «радиус» в 2-3 тайла. + expect(inInteractRangePos({ x: 7.5, y: 5.5 }, 5, 5)).toBe(false); + }); + + it('2 тайла вдоль мировой диагонали (проекция была бы «ближе») — вне радиуса', () => { + expect(inInteractRangePos({ x: 6.5, y: 6.5 }, 4, 4)).toBe(false); + }); +}); + +describe('pickInteract — выбор цели кнопки действия', () => { + /** Кандидат-заглушка: тайл задаёт дистанцию (герой в (0,0)). */ + function cand( + kind: InteractCandidate['kind'], + tile: { x: number; y: number }, + label = 'цель' + ): InteractCandidate { + return { kind, tile, dist: Math.hypot(tile.x, tile.y), label }; + } + + it('ближайший кандидат в радиусе — он', () => { + const c = pickInteract([cand('npc', { x: 2, y: 0 }), cand('interact', { x: 1, y: 0 })]); + expect(c?.label).toBe('цель'); + expect(c?.kind).toBe('interact'); + expect(c?.tile).toEqual({ x: 1, y: 0 }); + }); + + it('вне радиуса (два тайла и дальше) — не выбирается', () => { + expect(pickInteract([cand('npc', { x: 2, y: 0 })])).toBeNull(); + expect(pickInteract([])).toBeNull(); + }); + + it('ничья по дистанции — приоритет NPC выше интерактива', () => { + const c = pickInteract([cand('interact', { x: 1, y: 0 }), cand('npc', { x: 0, y: 1 })]); + expect(c?.kind).toBe('npc'); + }); + + it('почти-ничья (в эпсилоне) — приоритет: переход выше цветка', () => { + const c = pickInteract([cand('flower', { x: 1, y: 1 }), cand('transition', { x: 1.04, y: 0 })]); + expect(c?.kind).toBe('transition'); + }); + + it('заметно ближе — дистанция побеждает приоритет', () => { + const c = pickInteract([cand('npc', { x: 1, y: 0 }), cand('flower', { x: 0.5, y: 0 })]); + expect(c?.kind).toBe('flower'); + }); + + it('заперто — кандидат locked с текстом', () => { + const c: InteractCandidate = { + kind: 'locked', + tile: { x: 1, y: 0 }, + dist: 1, + label: 'дверь', + text: 'Заперто.' + }; + expect(pickInteract([c])?.text).toBe('Заперто.'); + }); +}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/CombatFlow.ts b/apps/game/src/systems/combat/CombatFlow.ts index f2d0966..af27eeb 100644 --- a/apps/game/src/systems/combat/CombatFlow.ts +++ b/apps/game/src/systems/combat/CombatFlow.ts @@ -106,15 +106,6 @@ } } - /** Удар из зоны авто-атаки (кулдаун внутри PlayerCombat). false — кулдаун. */ - attackTarget(from: Vec2, dir: Vec2): boolean { - if (this.playerCombat.attackCd.trigger()) { - this.deps.combat.playerConeAttack(from, dir); - return true; - } - return false; - } - /** Уровень угрозы 0..1 по состояниям врагов (музыка: бой/настороженность). */ threat(): number { let threat = 0; diff --git a/docs/demo.md b/docs/demo.md index 1aeed8a..82c1eb4 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -15,7 +15,7 @@ | SceneManager (replace/push/pop, fade) | меню → локация → сумка/сейвы; переходы локаций | готово | | InputManager (действия, геймпад, мульти-биндинг) | advance/attack/menu/inventory/debug; геймпад-карта | готово | | VirtualJoystick | тач в локациях (включается при первом касании) | готово | -| IsometricTileMap + A* | карты локаций, движение по клику | готово | +| IsometricTileMap + A* | карты локаций, движение кнопками (мост: scene:walk) | готово | | mapFormat (JSON+RLE) | карты — файлы `assets/maps/*.map`, генерация `npm run maps` | готово | | Tiled-импорт | не задействован (дока + тест есть) | резерв | | EventBus | `combat:hurt/attack/playerHit`, `quest:flower` | готово | @@ -48,7 +48,7 @@ 1. Выжженные луга (хаб): Ирвин даёт квест «Три цветка» (флаг `quest_bells_taken`). 2. Мила предупреждает о накате у прудов, даёт вощёное полотно (`got_cloth`). -3. Серые пруды (опасная зона): туман, сгустки, сбор колокольчиков кликом (вар `flowers`). +3. Серые пруды (опасная зона): туман, сгустки, сбор колокольчиков кнопкой действия E (вар `flowers`). 4. Возврат: сдача Ирвину (`elder_hand_in`) — посадка цветов у тропы, тайлы зеленеют. 5. Крючок: Мила — «Слышишь гул снизу? Это разъезд. Это Машина дышит» (`trader_after`, флаг `act2_hook`). @@ -60,7 +60,7 @@ (интерактивы `tone_tree_N`, вар `tones`), сдача Клинту (`clint_tune`), кат-сцена настройки (наезд камеры на резонатор). 7. Старый разъезд: рельсы, дома-фонари, люк в подземелье (зона наката у люка). -8. Зал Старой Машины: «правильный звон» — с настроенным резонатором клик по +8. Зал Старой Машины: «правильный звон» — с настроенным резонатором действие E по Машине (`old_machine`) ставит `machine_lulled` и запускает финальную кат-сцену (спуск камеры, резонанс CombatWorld, угольки гаснут). @@ -73,7 +73,8 @@ - Враги «пепельные сгустки»: ползун (HP 3, стая), плевун (HP 2, дистанция + плевок), наст (HP 8, замах → удар по площади). Звук — ресурс: удар излучает гул r=4.5 юнита и будит спящих сгустков. -- Ввод: клик по врагу — авто-подход и удар; Space тап/удержание; геймпад X. +- Ввод: подойти и бить конусом по facing (Space тап — удар, удержание ≥0.6 с — + резонанс); геймпад X. Ходьба — WASD/стрелки/геймпад-крестовина/тач-джойстик. ## Архитектура среза @@ -136,7 +137,7 @@ (step/click, условия, lockedText), дисарм тайла входа, `returnTo`, сейвы v3, телепорт-колодцы (Звенец ↔ луга), сценарий `apps/game/tools/checks/transitions.mjs`. - ✅ **Батч 2 — интерьеры + интерактивные объекты** (готово): карты интерьеров - (`buildInteriorMap`: дом Ирвина, лавка Милы), двери-клики внутрь и выход + (`buildInteriorMap`: дом Ирвина, лавка Милы), двери-действия (E у тайла) внутрь и выход `kind: 'return'`, `InteractableDef`/`Interactables` (сундук, очаг, записка, прилавок; реакции по `when`, `once` → флаг `used:`, `gives`), снапшот `interactables`, сценарий `apps/game/tools/checks/interact.mjs`. diff --git a/docs/engine/input.md b/docs/engine/input.md index aac3a96..03b80e6 100644 --- a/docs/engine/input.md +++ b/docs/engine/input.md @@ -21,7 +21,9 @@ ``` Одна клавиша может маппиться на **несколько действий** (например Space = `advance` в -диалоге и `attack` вне его) — повторные `bindActions` не перезаписывают, а дополняют. +диалоге и `attack` вне его, геймпадная A = `advance` и `interact`) — повторные +`bindActions` не перезаписывают, а дополняют. Конфликты разводятся уровнем сцены: +в диалоге действия боя/действия не опрашиваются. Отпускание и удержание нужны для заряжаемых действий (замах, резонанс). Для точных кодов есть `wasKeyPressed(code)` / `wasKeyReleased(code)`; у указателя — @@ -76,8 +78,10 @@ player.moveBy(v.x * speed * dt, v.y * speed * dt); ``` -Джойстик сам обрабатывает pointer-события (`eventMode: 'static'`, hitArea на весь экран) -и скрывает ввод «клик по миру», пока активен. +Джойстик сам обрабатывает pointer-события (`eventMode: 'static'`, hitArea на весь экран). +В демо движение — только кнопками/стиком (мышь в мир не routed): экранный вектор +сцена переводит в мировое направление через `screenToWorld` и ведёт героя +`moveFree` (скольжение по стенам, расталкивание тел, поворот по экранной оси). ## Жизненный цикл тика diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 5771e56..806f19a 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -329,29 +329,28 @@ только к проходимым целям (ждёт героя В целевом тайле) — для клика в стену используй `tapTile` + `waitFor('!s.hero.moving')`. -## Ситуация: меняю клик-роутинг / агентный мост сцены +## Ситуация: меняю роутинг взаимодействия / агентный мост сцены 1. `LocationScene` — только оркестратор вьюх: текстуры, композитинг, ввод, - камера, катсцены. Логика кликов живёт в `systems/ClickRouting.ts` - (чистый `resolveClick(probe)` + рантайм `InteractionRouter` с deps-объектом), - мост — в `agent/SceneAgentView.ts`. В сцену новые ветки кликов не добавлять — - расширяй резолвер (приоритеты: NPC → переход/заперто → интерактив → цветок → - враг → движение) и юнит-тесты к нему (`ClickRouting.test.ts`). + камера, катсцены. Логика взаимодействия живёт в `systems/InteractRouting.ts` + (чистый `pickInteract` + рантайм `InteractionRouter` с deps-объектом), + мост — в `agent/SceneAgentView.ts`. В сцену новые ветки ввода не добавлять — + расширяй кандидаты/пикер (приоритеты кнопки действия: NPC → переход/заперто → + интерактив → цветок) и юнит-тесты к ним (`InteractRouting.test.ts`). 1а. Две линейки мира (не путать): **проекционная** (`inCircleW`/`inConeW`/ - `worldDist`) — для конусов удара и клик-хитбоксов, анизотропна (2:1); - **истинная евклидова** (юнит = 1 тайл, `Math.hypot` позиций) — для радиусов - взаимодействия. Радиус в проекционной линейке даёт зону в 2-3 тайла, - вытянутую по мировой диагонали: взаимодействие «срабатывало издалека». - Эталон — `inInteractRangePos` в `ClickRouting.ts`. -2. Клик-приоритеты меняются только вместе с тестами резолвера и + `worldDist`) — для конусов удара и экранно-проекционных хитбоксов, + анизотропна (2:1); **истинная евклидова** (юнит = 1 тайл, `Math.hypot` + позиций) — для радиусов взаимодействия. Радиус в проекционной линейке даёт + зону в 2-3 тайла, вытянутую по мировой диагонали: взаимодействие + «срабатывало издалека». Эталон — `inInteractRangePos` в `InteractRouting.ts`. +2. Приоритеты кнопки действия меняются только вместе с тестами пикера и `agent check` (проба `transitions` проверяет колодцы, `interact` — объекты). 3. Слои снапшота собираются фабриками из `agent/snapshot.ts` — при новом поле снапшота обнови фабрику и тип в одном месте; сцена/фасад дубликаты не пишут. -4. **Клик-зона объекта — не его ромб-пол**: экранно тело спрайта стоит выше - ромба, и клик «проваливается под объект». `RouterDeps.pixelTile(px, py)` — - сцена отдаёт тайл по bbox вьюх (NPC/интерактивы) + ремап на высокий тайл - (тело дома занимает (tx−1,ty−1), отдаём базовому тайлу — двери кликаются по - спрайту); роутер подставляет его ДО `resolveClick` — чистый резолвер не меняется. +4. Взаимодействие — только по тайлу цели в радиусе (def.tile, евклидова + линейка): никаких хитбоксов тела спрайта. Клик-роутинг удалён (движение — + WASD/стик/джойстик, взаимодействие — кнопкой действия E/A); мышь в мир + не маршрутизируется вовсе. ## Ситуация: добавляю свет / экранное пространство