Newer
Older
rpg / packages / engine / src / map / __tests__ / collision.test.ts
import { describe, expect, it } from 'vitest';
import { circleFits, gridOf, moveCircle, pushOutOfWalls, separateCircles } from '../collision';
import type { Grid } from '../pathfinding';
import type { TileMapData } from '../IsometricTileMap';

/** Карта 8×8: трава (0) везде, стена по x=4 (id 1), дом-проп 2×1 в (6,1). */
function testMap(): TileMapData {
    const w = 8;
    const h = 8;
    const tiles = new Array(w * h).fill(0);
    for (let y = 0; y < h; y++) tiles[y * w + 4] = 1; // вертикальная стена
    return {
        width: w,
        height: h,
        tiles,
        blocked: [1],
        props: [{ id: 2, x: 6, y: 1, w: 2, h: 1 }]
    };
}

/** Grid из набора строк-символов: '.' — проходимо, '#' — стена. */
function gridFrom(rows: string[]): Grid {
    return {
        width: rows[0]!.length,
        height: rows.length,
        isWalkable: (x, y) => rows[y]?.[x] === '.'
    };
}

describe('gridOf', () => {
    it('blocked-иды и footprint пропов блокируют тайлы', () => {
        const g = gridOf(testMap());
        expect(g.isWalkable(0, 0)).toBe(true);
        expect(g.isWalkable(4, 3)).toBe(false); // стена
        expect(g.isWalkable(6, 1)).toBe(false); // дом
        expect(g.isWalkable(7, 1)).toBe(false); // дом (вторая клетка footprint)
        expect(g.isWalkable(6, 2)).toBe(true);
        expect(g.isWalkable(-1, 0)).toBe(false);
        expect(g.isWalkable(8, 0)).toBe(false);
    });
});

describe('circleFits', () => {
    it('тело меньше тайла проходит в свободном тайле', () => {
        const g = gridOf(testMap());
        expect(circleFits(g, { x: 2.5, y: 2.5 }, 0.35)).toBe(true);
    });

    it('не заходит в стену с любой стороны', () => {
        const g = gridOf(testMap());
        const r = 0.35;
        expect(circleFits(g, { x: 3.5, y: 2.5 }, r)).toBe(true);
        expect(circleFits(g, { x: 3.66, y: 2.5 }, r)).toBe(false); // x+r = 4.01 > 4
        expect(circleFits(g, { x: 4.34, y: 2.5 }, r)).toBe(false); // x-r = 3.99 < 5
        expect(circleFits(g, { x: 3.65, y: 2.5 }, r)).toBe(true); // касание вплотную
    });

    it('ловит угловой прокол диагонального тайла (выборка точек его пропускает)', () => {
        const g = gridFrom([
            '....',
            '...#',
            '....',
            '....'
        ]);
        // Угол blocked-тайла (3,1) в круге, а крайние точки — в свободных.
        expect(circleFits(g, { x: 2.8, y: 1.8 }, 0.45)).toBe(false);
    });

    it('вне карты — стена', () => {
        const g = gridOf(testMap());
        expect(circleFits(g, { x: 0.2, y: 0.5 }, 0.35)).toBe(false);
    });
});

describe('moveCircle — скольжение вдоль стены', () => {
    it('прижим к стене не мешает движению вдоль неё', () => {
        const g = gridOf(testMap());
        const pos = { x: 3.6, y: 1.0 };
        // Вдоль стены (вниз по y): x-компонента блокирована, y — проходит.
        const moved = moveCircle(g, pos, { x: 0.2, y: 0.5 }, 0.35);
        expect(moved).toBe(true);
        expect(pos.x).toBeCloseTo(3.6); // x не сдвинулся
        expect(pos.y).toBeCloseTo(1.5);
    });

    it('в угол — обе оси блокированы, позиция не меняется', () => {
        const g = gridFrom([
            '.#',
            '#.'
        ]);
        const pos = { x: 0.5, y: 0.5 };
        expect(moveCircle(g, pos, { x: 0.4, y: 0.4 }, 0.3)).toBe(false);
        expect(pos).toEqual({ x: 0.5, y: 0.5 });
    });
});

describe('moveCircle — совместимость с A*-маршрутом', () => {
    it('путь по центрам тайлов проходим телом r ≤ 0.45 (диагональ у стены)', () => {
        const g = gridOf(testMap());
        const path = [{ x: 2, y: 2 }, { x: 3, y: 3 }, { x: 3, y: 4 }];
        const pos = { x: 2.5, y: 2.5 };
        const near = (a: number, b: number) => Math.abs(a - b) < 0.01;
        for (const tile of path) {
            // Шаг к центру следующего тайла — так двигает waypoint-логика.
            const target = { x: tile.x + 0.5, y: tile.y + 0.5 };
            for (let i = 0; i < 200 && !(near(pos.x, target.x) && near(pos.y, target.y)); i++) {
                const dx = Math.sign(target.x - pos.x) * 0.1;
                const dy = Math.sign(target.y - pos.y) * 0.1;
                const before = { ...pos };
                moveCircle(g, pos, { x: dx, y: dy }, 0.35);
                expect(pos).not.toEqual(before); // продвижение есть — путь не застрял
            }
            expect(near(pos.x, target.x) && near(pos.y, target.y)).toBe(true); // дошли
        }
    });
});

describe('pushOutOfWalls', () => {
    it('уже свободен — позиция не тронута', () => {
        const g = gridOf(testMap());
        const pos = { x: 2.31, y: 2.87 };
        pushOutOfWalls(g, pos, 0.35);
        expect(pos).toEqual({ x: 2.31, y: 2.87 });
    });

    it('в стене — выталкивает в центр ближайшего свободного тайла', () => {
        const g = gridOf(testMap());
        const pos = { x: 4.5, y: 3.5 }; // внутри стены
        pushOutOfWalls(g, pos, 0.35);
        expect(g.isWalkable(Math.floor(pos.x), Math.floor(pos.y))).toBe(true);
        expect(circleFits(g, pos, 0.35)).toBe(true);
    });

    it('вне карты — вытолкнут обратно', () => {
        const g = gridOf(testMap());
        const pos = { x: 9.5, y: 3.5 };
        pushOutOfWalls(g, pos, 0.35);
        expect(circleFits(g, pos, 0.35)).toBe(true);
    });
});

describe('separateCircles', () => {
    it('перекрытие — a выталкивается ровно на глубину, b не трогается', () => {
        const a = { x: 1.0, y: 0.0 };
        const b = { x: 1.3, y: 0.0 };
        separateCircles(a, 0.35, b, 0.2);
        // Перекрытие = (0.35+0.2) − 0.3 = 0.25.
        expect(b).toEqual({ x: 1.3, y: 0.0 });
        expect(a.x).toBeCloseTo(0.75);
        expect(a.y).toBeCloseTo(0.0);
    });

    it('без перекрытия — ничего не двигается', () => {
        const a = { x: 0.0, y: 0.0 };
        const b = { x: 2.0, y: 0.0 };
        separateCircles(a, 0.35, b, 0.35);
        expect(a).toEqual({ x: 0.0, y: 0.0 });
    });

    it('слитые в точку — расходятся по +X (детерминированный фолбэк)', () => {
        const a = { x: 5.0, y: 5.0 };
        separateCircles(a, 0.3, { x: 5.0, y: 5.0 }, 0.3);
        expect(a.x).toBeCloseTo(5.6);
        expect(a.y).toBeCloseTo(5.0);
    });
});