diff --git a/apps/game/src/systems/ClickRouting.ts b/apps/game/src/systems/ClickRouting.ts index c4f2d40..39ade51 100644 --- a/apps/game/src/systems/ClickRouting.ts +++ b/apps/game/src/systems/ClickRouting.ts @@ -11,6 +11,7 @@ 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'; @@ -182,6 +183,18 @@ const INTERACT_RANGE = 1.5; /** + * Радиус взаимодействия: истинная евклидова линейка (юнит = 1 тайл), не + * проекционная. Проекция анизотропна (2:1) — «юниты» в ней вытянуты вдоль + * мировой диагонали, и радиус в inCircleW давал зону в 2-3 тайла: объект + * срабатывал издалека, до подхода героя. Орто-сосед 1.0 ✓, диагональный + * сосед 1.414 ✓ (findPathToNeighbor приводит в него), 2 тайла ✗. + */ +export function inInteractRangePos(playerPos: Vec2, tx: number, ty: number): boolean { + const c = tileToWorld(tx, ty); + return Math.hypot(c.x - playerPos.x, c.y - playerPos.y) <= INTERACT_RANGE; +} + +/** * Рантайм маршрутизации: клик по миру, авто-подход к врагу, отложенные * взаимодействия, step-переходы с дисармом тайла входа. */ @@ -198,7 +211,7 @@ /** Радиус взаимодействия (для подписей объектов в сцене). */ inInteractRange(tx: number, ty: number): boolean { - return inCircleW(this.deps.player.position, INTERACT_RANGE, tileToWorld(tx, ty)); + return inInteractRangePos(this.deps.player.position, tx, ty); } /** Клик по миру (виртуальные px указателя). */ diff --git a/apps/game/src/systems/__tests__/ClickRouting.test.ts b/apps/game/src/systems/__tests__/ClickRouting.test.ts index bc208fa..f178751 100644 --- a/apps/game/src/systems/__tests__/ClickRouting.test.ts +++ b/apps/game/src/systems/__tests__/ClickRouting.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; import { worldToScreen, type Vec2 } from '@rpg/engine'; -import { pointerToWorld, resolveClick, resolvePixelTile, type ClickProbe } from '../ClickRouting'; +import { + inInteractRangePos, + pointerToWorld, + resolveClick, + resolvePixelTile, + type ClickProbe +} from '../ClickRouting'; import { TILES } from '../../data/map'; import type { NpcDef } from '../../data/npcs'; import type { InteractableDef } from '../../data/interactables'; @@ -106,6 +112,28 @@ }); }); +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); + }); +}); + /** Карта 4×4: весь — трава, базовый тайл (3,3) — высокое дерево. */ function mapData(treeAt: { x: number; y: number } | null = { x: 3, y: 3 }) { const tiles = new Array(16).fill(TILES.GRASS); diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 00325e9..5771e56 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -337,6 +337,12 @@ мост — в `agent/SceneAgentView.ts`. В сцену новые ветки кликов не добавлять — расширяй резолвер (приоритеты: NPC → переход/заперто → интерактив → цветок → враг → движение) и юнит-тесты к нему (`ClickRouting.test.ts`). +1а. Две линейки мира (не путать): **проекционная** (`inCircleW`/`inConeW`/ + `worldDist`) — для конусов удара и клик-хитбоксов, анизотропна (2:1); + **истинная евклидова** (юнит = 1 тайл, `Math.hypot` позиций) — для радиусов + взаимодействия. Радиус в проекционной линейке даёт зону в 2-3 тайла, + вытянутую по мировой диагонали: взаимодействие «срабатывало издалека». + Эталон — `inInteractRangePos` в `ClickRouting.ts`. 2. Клик-приоритеты меняются только вместе с тестами резолвера и `agent check` (проба `transitions` проверяет колодцы, `interact` — объекты). 3. Слои снапшота собираются фабриками из `agent/snapshot.ts` — при новом поле