import { describe, expect, it } from 'vitest';
import { circleFits, gridOf, moveCircle, separateCircles, shadowTilesOf } 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('shadowTilesOf', () => {
/** Карта 6×6: дом (id 2) в (3,2) с тенью, ещё высокий объект (id 3) в (0,0)... вне поля. */
function shadowMap(): TileMapData {
const w = 6;
const h = 6;
const tiles = new Array(w * h).fill(0);
tiles[3 * w + 3] = 2; // дом с tall.shadow
tiles[2 * w + 4] = 3; // высокий объект БЕЗ тени
return {
width: w,
height: h,
tiles,
blocked: [2],
tall: { 2: { height: 1.75, shadow: true }, 3: { height: 1 } }
};
}
it('тень ложится на тайл за объектом и не блокирует ничего больше', () => {
const shadow = shadowTilesOf(shadowMap());
expect([...shadow]).toEqual([2 * 6 + 2]); // (2,2) — тайл «за» домом
const g = gridOf(shadowMap());
expect(g.isWalkable(2, 2)).toBe(false); // тень непроходима
expect(g.isWalkable(3, 3)).toBe(false); // сам дом
expect(g.isWalkable(4, 2)).toBe(true); // высокий без тени не блокирует соседей
expect(g.isWalkable(4, 3)).toBe(true);
});
it('два высоких объекта подряд не создают тень друг на друге', () => {
const data = shadowMap();
data.tiles[2 * 6 + 2] = 3; // высокий сосед прямо в «тени» дома
const shadow = shadowTilesOf(data);
expect([...shadow]).toEqual([]); // сосед высокий — блокируется и без тени
});
});
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('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);
});
});