Жанронезависимый примитив «что-то стоит на карте» + индекс по тайлам. Движок не знает ни NPC, ни интерактивов, ни врагов: род (kind) и ссылка на контент (ref) — строки/ссылки, которые задаёт игра. Чистый модуль без Pixi — тестируется в Vitest.
import { SceneRegistry } from '@rpg/engine';
const registry = new SceneRegistry();
registry.add({
id: 'npc:elder', // уникален; дубликат — throw (ловится инвариантом сцены)
kind: 'npc',
pos: { x: 14.5, y: 9.5 }, // ноги, мировые юниты
radius: 0.35, // тело в юнитах (0 — точка)
ref: npcDef // ссылка на контент игры — движок не читает
});
registry.add({
id: 'prop:house', kind: 'prop',
pos: { x: 20.5, y: 12.5 },
footprint: { w: 2, h: 2 } // объект накрывает тайлы [20..21]×[12..13]
});
registry.get('npc:elder'); // SceneObject | undefined
registry.byKind('enemy'); // все объекты рода
registry.at(20, 12, 'prop'); // объект, привязанный к тайлу (вкл. footprint)
registry.atWorld(p, 'enemy'); // тайл точки или тело в радиусе из 3×3
registry.near(p, radius, kind); // объекты в радиусе (для расталкивания тел)
registry.move('enemy:7', pos); // переиндексация; O(1), если тайл не сменился
registry.remove('enemy:7'); // при смерти/выходе
SceneObject = описание + tile (floor от pos) и нормализованный radius (дефолт 0). pos мутабельно — можно держать ссылку на чужой объект состояния (например, EnemyComp.pos из ECS), тогда move() вызывается после чужого шага.
at/atWorld вместо перебора списков (приоритеты клика остаются в игре, см. practices.md).near + расталкивание — см. «Коллизии» в maps.md.kind:имя, kind:индекс), чтобы сцена могла строить id механически.registry-consistent): каждый def есть в реестре и наоборот.at находит их с любого занятого тайла.