import { describe, expect, it } from 'vitest';
import { worldToScreen, type Vec2 } from '@rpg/engine';
import { pointerToWorld, resolveClick, resolvePixelTile, type ClickProbe } 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' });
});
});
/** Карта 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();
});
});