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