diff --git a/docs/engine/README.md b/docs/engine/README.md index 1c60c89..f7ffeb3 100644 --- a/docs/engine/README.md +++ b/docs/engine/README.md @@ -15,6 +15,7 @@ | [maps.md](maps.md) | Изометрия, A*, формат карт, импорт из Tiled | | [ui-and-dialogue.md](ui-and-dialogue.md) | UI-кит, PixelText, диалоговые графы | | [cutscene.md](cutscene.md) | CutsceneRunner: кат-сцены из шагов | +| [registry.md](registry.md) | SceneRegistry: объекты сцены, индекс по тайлам, запросы at/near | | [assets-audio-save.md](assets-audio-save.md) | AssetLoader, атласы, AudioManager, сейвы | | [art-pipeline.md](art-pipeline.md) | Генератор пиксель-арта, палитра, замена на рисованный арт | | [recipes.md](recipes.md) | «Как сделать…»: готовые решения типовых задач | diff --git a/docs/engine/registry.md b/docs/engine/registry.md new file mode 100644 index 0000000..d5a6fa0 --- /dev/null +++ b/docs/engine/registry.md @@ -0,0 +1,53 @@ +# Реестр объектов сцены: SceneRegistry + +Жанронезависимый примитив «что-то стоит на карте» + индекс по тайлам. Движок не +знает ни NPC, ни интерактивов, ни врагов: род (`kind`) и ссылка на контент +(`ref`) — строки/ссылки, которые задаёт игра. Чистый модуль без Pixi — +тестируется в Vitest. + +```ts +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`. +- **IsoDepthLayer**: реестр не рисует; вьюхи добавляются в depth-слой сценой, + реестр — только данные и запросы. + +## Правила + +- Идентификаторы — составные (`kind:имя`, `kind:индекс`), чтобы сцена могла + строить id механически. +- Инвариант согласованности реестра с контентом области — в снапшоте агента + (`registry-consistent`): каждый def есть в реестре и наоборот. +- Footprint-объекты (пропы) регистрируются во всех покрытых тайлах — `at` + находит их с любого занятого тайла. \ No newline at end of file diff --git a/docs/llms.txt b/docs/llms.txt index 6e2e041..ac04270 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -19,6 +19,7 @@ - [Карты](engine/maps.md): изометрия, A*, формат rpg-map, Tiled-импорт. - [UI и диалоги](engine/ui-and-dialogue.md): PixelText, Panel, Button, MenuList, DialogueBox, DialogueRunner. - [Кат-сцены](engine/cutscene.md): раннер кат-сцен. +- [Реестр объектов сцены](engine/registry.md): SceneRegistry — «кто на карте», индекс по тайлам. - [Ассеты, аудио, сейвы](engine/assets-audio-save.md): AssetLoader, атласы, шины аудио, SaveManager. - [Пайплайн арта](engine/art-pipeline.md): генератор пиксель-арта, палитра, атласы. - [Рецепты](engine/recipes.md): короткие «как сделать X». diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e2da04d..7ed6452 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -53,6 +53,11 @@ // scene export { SceneManager, type Scene, type SceneTransition } from './scene/SceneManager'; +export { + SceneRegistry, + type SceneObjectDef, + type SceneObject +} from './scene/SceneRegistry'; // ecs export { World, type Entity, type System } from './ecs/ecs'; diff --git a/packages/engine/src/scene/SceneRegistry.ts b/packages/engine/src/scene/SceneRegistry.ts new file mode 100644 index 0000000..f2eee80 --- /dev/null +++ b/packages/engine/src/scene/SceneRegistry.ts @@ -0,0 +1,173 @@ +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(); + /** Индекс по тайлу → множество объектов, чей тайл/footprint покрывает его. */ + private byTile = new Map>(); + + /** Добавить объект; дубликат 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(); + 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 { + 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 { + let set = this.byTile.get(key); + if (!set) { + set = new Set(); + this.byTile.set(key, set); + } + return set; + } +} \ No newline at end of file diff --git a/packages/engine/src/scene/__tests__/SceneRegistry.test.ts b/packages/engine/src/scene/__tests__/SceneRegistry.test.ts new file mode 100644 index 0000000..a7f4889 --- /dev/null +++ b/packages/engine/src/scene/__tests__/SceneRegistry.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { SceneRegistry } from '../SceneRegistry'; + +describe('SceneRegistry', () => { + it('add/get/remove и byKind по роду', () => { + const r = new SceneRegistry(); + const a = r.add({ id: 'npc:1', kind: 'npc', pos: { x: 3.5, y: 4.5 } }); + r.add({ id: 'enemy:1', kind: 'enemy', pos: { x: 8.5, y: 2.5 } }); + expect(r.get('npc:1')).toBe(a); + expect(a.tile).toEqual({ x: 3, y: 4 }); + expect(r.byKind('npc').map((o) => o.id)).toEqual(['npc:1']); + expect(r.size).toBe(2); + r.remove('npc:1'); + expect(r.get('npc:1')).toBeUndefined(); + expect(r.at(3, 4)).toBeNull(); + }); + + it('дубликат id — throw', () => { + const r = new SceneRegistry(); + r.add({ id: 'x', kind: 'npc', pos: { x: 1, y: 1 } }); + expect(() => r.add({ id: 'x', kind: 'npc', pos: { x: 2, y: 2 } })).toThrow('дубликат'); + }); + + it('move переиндексирует тайл (и O(1) внутри тайла)', () => { + const r = new SceneRegistry(); + r.add({ id: 'e', kind: 'enemy', pos: { x: 2.5, y: 2.5 }, radius: 0.3 }); + r.move('e', { x: 2.9, y: 2.9 }); // тот же тайл + expect(r.at(2, 2, 'enemy')).not.toBeNull(); + expect(r.at(3, 3, 'enemy')).toBeNull(); + r.move('e', { x: 5.2, y: 7.8 }); + expect(r.at(2, 2, 'enemy')).toBeNull(); + expect(r.at(5, 7, 'enemy')).not.toBeNull(); + expect(r.get('e')!.pos).toEqual({ x: 5.2, y: 7.8 }); + r.remove('e'); // remove после move не оставляет «призраков» в старом тайле + expect(r.at(5, 7)).toBeNull(); + }); + + it('footprint-объект находится во всех покрытых тайлах', () => { + const r = new SceneRegistry(); + r.add({ + id: 'prop:1', kind: 'prop', pos: { x: 10.5, y: 10.5 }, + footprint: { w: 2, h: 2 } + }); + expect(r.at(10, 10, 'prop')).not.toBeNull(); + expect(r.at(11, 10, 'prop')).not.toBeNull(); + expect(r.at(10, 11, 'prop')).not.toBeNull(); + expect(r.at(11, 11, 'prop')).not.toBeNull(); + expect(r.at(12, 11, 'prop')).toBeNull(); + r.remove('prop:1'); + expect(r.at(11, 11)).toBeNull(); + }); + + it('atWorld находит тело, выступающее из тайла привязки', () => { + const r = new SceneRegistry(); + r.add({ id: 'npc:1', kind: 'npc', pos: { x: 10.9, y: 10.5 }, radius: 0.4 }); + // Точка в соседнем тайле, но внутри радиуса тела. + expect(r.atWorld({ x: 11.2, y: 10.5 }, 'npc')).not.toBeNull(); + // Вне радиуса — промах. + expect(r.atWorld({ x: 12.2, y: 10.5 }, 'npc')).toBeNull(); + }); + + it('near: по радиусу, с фильтром рода и без дублей на границе', () => { + const r = new SceneRegistry(); + r.add({ id: 'a', kind: 'npc', pos: { x: 5, y: 5 } }); + r.add({ id: 'b', kind: 'enemy', pos: { x: 5.5, y: 5.5 } }); + r.add({ id: 'c', kind: 'npc', pos: { x: 7.5, y: 7.5 } }); + const ids = (list: { id: string }[]) => list.map((o) => o.id).sort(); + expect(ids(r.near({ x: 5, y: 5 }, 1))).toEqual(['a', 'b']); + expect(ids(r.near({ x: 5, y: 5 }, 1, 'npc'))).toEqual(['a']); + expect(ids(r.near({ x: 5, y: 5 }, 10))).toEqual(['a', 'b', 'c']); + expect(r.near({ x: 20, y: 20 }, 2)).toEqual([]); + }); +}); \ No newline at end of file