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;
}
}