Newer
Older
rpg / packages / engine / src / map / collision.ts
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;
}