Newer
Older
rpg / packages / engine / src / scene / SceneRegistry.ts
import type { Vec2 } from '../math/Vec2';

/**
 * Реестр объектов сцены — жанронезависимый примитив «что-то стоит на карте»:
 * позиция в мировых юнитах + радиус тела + footprint в тайлах + непрозрачная
 * ссылка на контент. Движок не знает ни NPC, ни интерактивов: kind и ref
 * задаёт игра. Индекс по тайлам даёт запросы «кто здесь / кто рядом» без
 * перебора всей сцены.
 */

/** Описание объекта при добавлении. pos — ноги объекта, мутабельно. */
export interface SceneObjectDef {
    /** Уникальный в реестре идентификатор ('npc:elder', 'enemy:7', 'prop:14,9'). */
    id: string;
    /** Род объекта (строки задаёт игра: 'npc', 'interactable', 'enemy', ...). */
    kind: string;
    /** Позиция в мировых юнитах (точка контакта с землёй). */
    pos: Vec2;
    /** Радиус тела в юнитах (0 — точка без тела). */
    radius?: number;
    /** Занимаемые тайлы от тайла pos (пропы: footprint w×h от низа-центра). */
    footprint?: { w: number; h: number };
    /** Ссылка на контент игры (NpcDef / InteractableDef / сущность ECS) — движок не читает. */
    ref?: unknown;
}

/** Объект в реестре: def + вычисляемый тайл привязки. */
export interface SceneObject extends SceneObjectDef {
    readonly radius: number;
    /** Тайл, к которому привязан объект (floor от pos). */
    tile: { x: number; y: number };
}

/** Ключ индекса: тайлы упаковываются в одно число (карты до 4096×4096). */
const tileKey = (tx: number, ty: number): number => ty * 4096 + tx;

export class SceneRegistry {
    private byId = new Map<string, SceneObject>();
    /** Индекс по тайлу → множество объектов, чей тайл/footprint покрывает его. */
    private byTile = new Map<number, Set<SceneObject>>();

    /** Добавить объект; дубликат id — ошибка (ловится инвариантом сцены). */
    add(def: SceneObjectDef): SceneObject {
        if (this.byId.has(def.id)) {
            throw new Error(`SceneRegistry: дубликат id ${def.id}`);
        }
        const obj: SceneObject = {
            ...def,
            radius: def.radius ?? 0,
            tile: { x: Math.floor(def.pos.x), y: Math.floor(def.pos.y) }
        };
        this.byId.set(obj.id, obj);
        for (const key of this.coveredKeys(obj)) {
            this.tileSet(key).add(obj);
        }
        return obj;
    }

    remove(id: string): void {
        const obj = this.byId.get(id);
        if (!obj) return;
        for (const key of this.coveredKeys(obj)) {
            this.tileSet(key).delete(obj);
        }
        this.byId.delete(id);
    }

    get(id: string): SceneObject | undefined {
        return this.byId.get(id);
    }

    /** Все объекты рода kind (порядок добавления). */
    byKind(kind: string): readonly SceneObject[] {
        return this.all.filter((o) => o.kind === kind);
    }

    get size(): number {
        return this.byId.size;
    }

    /** Переместить объект (юниты); переиндексация — O(1), если тайл не сменился. */
    move(id: string, pos: Vec2): void {
        const obj = this.byId.get(id);
        if (!obj) return;
        const tx = Math.floor(pos.x);
        const ty = Math.floor(pos.y);
        obj.pos.x = pos.x;
        obj.pos.y = pos.y;
        if (tx === obj.tile.x && ty === obj.tile.y) return;
        for (const key of this.coveredKeys(obj)) {
            this.tileSet(key).delete(obj);
        }
        obj.tile.x = tx;
        obj.tile.y = ty;
        for (const key of this.coveredKeys(obj)) {
            this.tileSet(key).add(obj);
        }
    }

    /** Объект, привязанный к тайлу (точка тайла или footprint, накрывающий его). */
    at(tx: number, ty: number, kind?: string): SceneObject | null {
        for (const obj of this.byTile.get(tileKey(tx, ty)) ?? []) {
            if (!kind || obj.kind === kind) return obj;
        }
        return null;
    }

    /** Объект в мировой точке: тайл точки или ближайший тайл с телом рядом. */
    atWorld(p: Vec2, kind?: string): SceneObject | null {
        const hit = this.at(Math.floor(p.x), Math.floor(p.y), kind);
        if (hit) return hit;
        // Тело может выступать из тайла привязки — ищем в соседних 3×3.
        for (let dy = -1; dy <= 1; dy++) {
            for (let dx = -1; dx <= 1; dx++) {
                if (dx === 0 && dy === 0) continue;
                for (const obj of this.byTile.get(tileKey(Math.floor(p.x) + dx, Math.floor(p.y) + dy)) ?? []) {
                    if (obj.radius > 0 && (!kind || obj.kind === kind)) {
                        const ddx = p.x - obj.pos.x;
                        const ddy = p.y - obj.pos.y;
                        if (ddx * ddx + ddy * ddy <= obj.radius * obj.radius) return obj;
                    }
                }
            }
        }
        return null;
    }

    /** Объекты в радиусе (юниты) от точки; фильтр по роду опционален. */
    near(p: Vec2, radius: number, kind?: string): SceneObject[] {
        const found: SceneObject[] = [];
        const seen = new Set<SceneObject>();
        const x0 = Math.floor(p.x - radius - 1);
        const x1 = Math.floor(p.x + radius + 1);
        const y0 = Math.floor(p.y - radius - 1);
        const y1 = Math.floor(p.y + radius + 1);
        for (let ty = y0; ty <= y1; ty++) {
            for (let tx = x0; tx <= x1; tx++) {
                for (const obj of this.byTile.get(tileKey(tx, ty)) ?? []) {
                    if (seen.has(obj) || (kind && obj.kind !== kind)) continue;
                    seen.add(obj);
                    const dx = p.x - obj.pos.x;
                    const dy = p.y - obj.pos.y;
                    if (dx * dx + dy * dy <= radius * radius) found.push(obj);
                }
            }
        }
        return found;
    }

    private get all(): SceneObject[] {
        return [...this.byId.values()];
    }

    /** Ключи тайлов, покрываемых объектом: сам тайл + footprint. */
    private *coveredKeys(obj: SceneObject): Iterable<number> {
        const fw = obj.footprint?.w ?? 1;
        const fh = obj.footprint?.h ?? 1;
        for (let dy = 0; dy < fh; dy++) {
            for (let dx = 0; dx < fw; dx++) {
                yield tileKey(obj.tile.x + dx, obj.tile.y + dy);
            }
        }
    }

    private tileSet(key: number): Set<SceneObject> {
        let set = this.byTile.get(key);
        if (!set) {
            set = new Set();
            this.byTile.set(key, set);
        }
        return set;
    }
}