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-адаптер над сырыми данными карты: blocked-иды + footprint пропов. */
export function gridOf(data: TileMapData): Grid {
    const blockedSet = new Set(data.blocked);
    const propBlocked = new Set<number>();
    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++) propBlocked.add(ty * data.width + tx);
        }
    }
    const shadowSet = shadowTilesOf(data);
    return {
        width: data.width,
        height: data.height,
        isWalkable: (x, y) =>
            x >= 0 && y >= 0 && x < data.width && y < data.height &&
            !propBlocked.has(y * data.width + x) &&
            !shadowSet.has(y * data.width + x) &&
            !blockedSet.has(data.tiles[y * data.width + x]!)
    };
}

/** Тайл блокирует движение (вне карты — тоже стена). */
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;
}

/**
 * Вытолкнуть круг из стены (после отброса/телепорта): ищет ближайший по
 * спирали тайл, в чей центр круг помещается, и ставит pos в его центр.
 * Свободного в радиусе 3 тайлов нет — позиция не меняется.
 */
export function pushOutOfWalls(grid: Grid, pos: Vec2, r: number): void {
    if (circleFits(grid, pos, r)) return;
    const cx = Math.floor(pos.x);
    const cy = Math.floor(pos.y);
    for (let ring = 0; ring <= 3; ring++) {
        for (let ty = cy - ring; ty <= cy + ring; ty++) {
            for (let tx = cx - ring; tx <= cx + ring; tx++) {
                // Кольцо: периметр квадрата (внутренние уже проверены).
                const onRing = Math.max(Math.abs(tx - cx), Math.abs(ty - cy)) === ring;
                if (!onRing) continue;
                const center = { x: tx + 0.5, y: ty + 0.5 };
                if (circleFits(grid, center, r)) {
                    pos.x = center.x;
                    pos.y = center.y;
                    return;
                }
            }
        }
    }
}

/**
 * Растолкать круг 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;
}