import type { Grid } from './pathfinding';
import type { TileMapData } from './IsometricTileMap';
import type { Vec2 } from '../math/Vec2';
/**
* Круговые коллизии поверх тайловой сетки (чистая математика, без Pixi).
* Актор — круг радиуса r в мировых юнитах (тайл = 1×1); правило стиля:
* r < 0.5 — тело уже тайла, тогда путь A* по центрам тайлов остаётся
* проходимым и проверка сводится к соседним с центром тайлам.
* Проверка круга — точная (ближайшая точка квадрата тайла), не выборка
* точек: выборка пропускает угловой прокол Blocked-тайла по диагонали.
*/
/**
* Тайлы «тени» высоких объектов: спрайт тайла (tx, ty) с tall.shadow
* экранно накрывает тайл (tx-1, ty-1) — герой не должен заходить в тело
* (дом и т.п.). Сосед в (tx-1, ty-1) сам высокий — он блокируется и без тени.
*/
export function shadowTilesOf(data: TileMapData): Set<number> {
const out = new Set<number>();
for (let ty = 1; ty < data.height; ty++) {
for (let tx = 1; tx < data.width; tx++) {
const spec = data.tall?.[data.tiles[ty * data.width + tx]!];
if (typeof spec !== 'object' || !spec.shadow) continue;
const behind = data.tiles[(ty - 1) * data.width + (tx - 1)]!;
if (data.tall?.[behind] !== undefined) continue;
out.add((ty - 1) * data.width + (tx - 1));
}
}
return out;
}
/**
* Grid над сырыми данными карты (без Pixi): blocked-иды + footprint пропов +
* тени высоких объектов. Единый источник проходимости: IsometricTileMap
* делегирует сюда, headless-потребители (валидатор, фауна, снапшот) берут
* через gridOf. data.tiles читается на каждый вызов — setTile виден сразу.
*/
export class StaticGrid implements Grid {
private blockedSet: Set<number>;
private propBlocked = new Set<number>();
private shadowSet: Set<number>;
constructor(private data: TileMapData) {
this.blockedSet = new Set(data.blocked);
this.shadowSet = shadowTilesOf(data);
for (const p of data.props ?? []) {
const w = p.w ?? 1;
const h = p.h ?? 1;
for (let ty = p.y; ty < p.y + h; ty++) {
for (let tx = p.x; tx < p.x + w; tx++) this.propBlocked.add(ty * data.width + tx);
}
}
}
get width(): number {
return this.data.width;
}
get height(): number {
return this.data.height;
}
isWalkable(x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return false;
if (this.propBlocked.has(y * this.data.width + x)) return false;
if (this.shadowSet.has(y * this.data.width + x)) return false;
return !this.blockedSet.has(this.data.tiles[y * this.data.width + x]!);
}
/** Тайл накрыт footprint'ом пропа. */
isPropBlocked(x: number, y: number): boolean {
return this.propBlocked.has(y * this.data.width + x);
}
/** id тайла — из набора блокирующих (для плейсхолдеров рендера). */
isBlockedId(id: number): boolean {
return this.blockedSet.has(id);
}
}
/** Grid-адаптер над сырыми данными карты. */
export function gridOf(data: TileMapData): Grid {
return new StaticGrid(data);
}
/** Тайл блокирует движение (вне карты — тоже стена). */
function isBlocked(grid: Grid, tx: number, ty: number): boolean {
return tx < 0 || ty < 0 || tx >= grid.width || ty >= grid.height || !grid.isWalkable(tx, ty);
}
/** Помещается ли круг в позицию (точное пересечение с blocked-тайлами bbox). */
export function circleFits(grid: Grid, pos: Vec2, r: number): boolean {
if (r <= 0) {
return !isBlocked(grid, Math.floor(pos.x), Math.floor(pos.y));
}
// r < 0.5 — bbox накрывает не более 2×2 тайлов.
const x0 = Math.floor(pos.x - r);
const x1 = Math.floor(pos.x + r);
const y0 = Math.floor(pos.y - r);
const y1 = Math.floor(pos.y + r);
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (!isBlocked(grid, tx, ty)) continue;
// Ближайшая к центру круга точка квадрата тайла.
const nx = Math.max(tx, Math.min(pos.x, tx + 1));
const ny = Math.max(ty, Math.min(pos.y, ty + 1));
const dx = pos.x - nx;
const dy = pos.y - ny;
if (dx * dx + dy * dy < r * r) return false; // касание стеной разрешено
}
}
return true;
}
/**
* Сдвинуть круг на delta со скольжением вдоль стен: оси проверяются
* раздельно (X -> Y), прижатая к стене ось не блокирует движение по другой.
* Мутирует pos. Возвращает true, если хоть одна ось сместилась.
*/
export function moveCircle(grid: Grid, pos: Vec2, delta: Vec2, r: number): boolean {
let moved = false;
const nx = pos.x + delta.x;
if (circleFits(grid, { x: nx, y: pos.y }, r)) {
pos.x = nx;
moved = moved || delta.x !== 0;
}
const ny = pos.y + delta.y;
if (circleFits(grid, { x: pos.x, y: ny }, r)) {
pos.y = ny;
moved = moved || delta.y !== 0;
}
return moved;
}
/**
* Растолкать круг a из круга b: a сдвигается на глубину перекрытия
* (двигаем только a — каждый актор выталкивает себя сам). Слившиеся
* в точку круги расходятся по +X (детерминированный фолбэк).
*/
export function separateCircles(a: Vec2, ra: number, b: Vec2, rb: number): void {
const dx = a.x - b.x;
const dy = a.y - b.y;
const d = Math.hypot(dx, dy);
const overlap = ra + rb - d;
if (overlap <= 0) return;
if (d < 1e-6) {
a.x += overlap;
return;
}
const k = overlap / d;
a.x += dx * k;
a.y += dy * k;
}