feat: add relationship support and observability
Introduces relationship management to the World, including storage for forward and reverse relationships. Adds new observable events and a `RelationshipUpdate` stream to track when relationships are added or removed.
This commit is contained in:
parent
ba4a688f57
commit
32f8f29912
24
src/index.ts
24
src/index.ts
|
|
@ -1,10 +1,16 @@
|
|||
// ── Public API ─────────────────────────────────────────
|
||||
export { World } from './world';
|
||||
export { defineComponent } from './component';
|
||||
export type { ComponentDef } from './component';
|
||||
export { query } from './query';
|
||||
export { Query } from './query';
|
||||
export type { Entity } from './entity';
|
||||
export { makeEntity, entityIndex, entityGeneration } from './entity';
|
||||
export { SparseSet } from './storage/sparse-set';
|
||||
export type { WorldEvent, QueryUpdate } from './observable/events';
|
||||
export { World } from "./world";
|
||||
export { defineComponent } from "./component";
|
||||
export type { ComponentDef } from "./component";
|
||||
export { defineRelationship } from "./relationship";
|
||||
export type { RelationshipDef } from "./relationship";
|
||||
export { query } from "./query";
|
||||
export { Query } from "./query";
|
||||
export type { Entity } from "./entity";
|
||||
export { makeEntity, entityIndex, entityGeneration } from "./entity";
|
||||
export { SparseSet } from "./storage/sparse-set";
|
||||
export type {
|
||||
WorldEvent,
|
||||
QueryUpdate,
|
||||
RelationshipUpdate,
|
||||
} from "./observable/events";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ComponentDef } from '../component';
|
||||
import type { Entity } from '../entity';
|
||||
import type { ComponentDef } from "../component";
|
||||
import type { RelationshipDef } from "../relationship";
|
||||
import type { Entity } from "../entity";
|
||||
|
||||
// ── World Events ──────────────────────────────────────
|
||||
/**
|
||||
|
|
@ -11,36 +12,52 @@ export type WorldEvent =
|
|||
| DestroyedEvent
|
||||
| ComponentAddedEvent
|
||||
| ComponentRemovedEvent
|
||||
| ComponentChangedEvent;
|
||||
| ComponentChangedEvent
|
||||
| RelAddedEvent
|
||||
| RelRemovedEvent;
|
||||
|
||||
export interface SpawnedEvent {
|
||||
type: 'spawned';
|
||||
type: "spawned";
|
||||
entity: Entity;
|
||||
}
|
||||
|
||||
export interface DestroyedEvent {
|
||||
type: 'destroyed';
|
||||
type: "destroyed";
|
||||
entity: Entity;
|
||||
}
|
||||
|
||||
export interface ComponentAddedEvent {
|
||||
type: 'componentAdded';
|
||||
type: "componentAdded";
|
||||
entity: Entity;
|
||||
component: ComponentDef<any>;
|
||||
}
|
||||
|
||||
export interface ComponentRemovedEvent {
|
||||
type: 'componentRemoved';
|
||||
type: "componentRemoved";
|
||||
entity: Entity;
|
||||
component: ComponentDef<any>;
|
||||
}
|
||||
|
||||
export interface ComponentChangedEvent {
|
||||
type: 'componentChanged';
|
||||
type: "componentChanged";
|
||||
entity: Entity;
|
||||
component: ComponentDef<any>;
|
||||
}
|
||||
|
||||
export interface RelAddedEvent {
|
||||
type: "relationshipAdded";
|
||||
source: Entity;
|
||||
target: Entity;
|
||||
relationship: RelationshipDef;
|
||||
}
|
||||
|
||||
export interface RelRemovedEvent {
|
||||
type: "relationshipRemoved";
|
||||
source: Entity;
|
||||
target: Entity;
|
||||
relationship: RelationshipDef;
|
||||
}
|
||||
|
||||
// ── Query Observables ────────────────────────────────
|
||||
/**
|
||||
* Emitted by `world.observe(query)` when the result set changes.
|
||||
|
|
@ -53,3 +70,13 @@ export interface QueryUpdate {
|
|||
/** Entities still matching that had a `markDirty` this frame. */
|
||||
changed: Entity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted by `world.observeRelated(rel)` when relationships change.
|
||||
*/
|
||||
export interface RelationshipUpdate {
|
||||
/** Newly established relationships. */
|
||||
added: { source: Entity; target: Entity }[];
|
||||
/** Broken relationships. */
|
||||
removed: { source: Entity; target: Entity }[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,31 @@
|
|||
import { Subject } from "rxjs";
|
||||
import type { Query } from "../query";
|
||||
import type { Entity } from "../entity";
|
||||
import type { WorldEvent, QueryUpdate } from "./events";
|
||||
import type { WorldEvent, QueryUpdate, RelationshipUpdate } from "./events";
|
||||
import type { RelationshipDef } from "../relationship";
|
||||
|
||||
// ── Internal observer state per query ────────────────
|
||||
// ── Internal state ───────────────────────────────────
|
||||
interface QueryObserverState {
|
||||
query: Query;
|
||||
/** Cached set of entities currently matching the query. */
|
||||
matched: Set<Entity>;
|
||||
subject: Subject<QueryUpdate>;
|
||||
}
|
||||
|
||||
interface RelationshipObserverState {
|
||||
rel: RelationshipDef;
|
||||
edges: Set<string>;
|
||||
subject: Subject<RelationshipUpdate>;
|
||||
}
|
||||
|
||||
// ── Observable layer ─────────────────────────────────
|
||||
/**
|
||||
* Manages observable subscriptions for a World.
|
||||
* Kept separate from the World class for clarity.
|
||||
*/
|
||||
export class ObservableLayer {
|
||||
/** Raw event stream. */
|
||||
readonly events$ = new Subject<WorldEvent>();
|
||||
|
||||
/** Active query observers. */
|
||||
private _observers: QueryObserverState[] = [];
|
||||
private _relObservers: RelationshipObserverState[] = [];
|
||||
|
||||
// ── Query observers ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Get or create a Subject for a query.
|
||||
* If this is the first subscription, seed the matched set using
|
||||
* the provided queryMatches callback.
|
||||
*/
|
||||
observe(query: Query): Subject<QueryUpdate> {
|
||||
const existing = this._observers.find(
|
||||
(o) => o.query === query || queriesEqual(o.query, query),
|
||||
|
|
@ -39,57 +37,76 @@ export class ObservableLayer {
|
|||
matched: new Set(),
|
||||
subject: new Subject<QueryUpdate>(),
|
||||
};
|
||||
|
||||
// Seeding is handled by World (we don't have entity iteration here).
|
||||
// The World's observe() method seeds via a separate path.
|
||||
this._observers.push(state);
|
||||
return state.subject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the initial matched set for an observer.
|
||||
* Called once by World.observe() with all currently-matching entities.
|
||||
*/
|
||||
seed(query: Query, entities: Entity[]): void {
|
||||
const obs = this._observers.find(
|
||||
(o) => o.query === query || queriesEqual(o.query, query),
|
||||
);
|
||||
if (!obs) return;
|
||||
for (const e of entities) obs.matched.add(e);
|
||||
}
|
||||
|
||||
for (const e of entities) {
|
||||
obs.matched.add(e);
|
||||
// ── Relationship observers ───────────────────────
|
||||
|
||||
observeRelated(rel: RelationshipDef): Subject<RelationshipUpdate> {
|
||||
const existing = this._relObservers.find((o) => o.rel._key === rel._key);
|
||||
if (existing) return existing.subject;
|
||||
|
||||
const state: RelationshipObserverState = {
|
||||
rel,
|
||||
edges: new Set(),
|
||||
subject: new Subject<RelationshipUpdate>(),
|
||||
};
|
||||
this._relObservers.push(state);
|
||||
return state.subject;
|
||||
}
|
||||
|
||||
seedRelated(
|
||||
rel: RelationshipDef,
|
||||
edges: { source: Entity; target: Entity }[],
|
||||
): void {
|
||||
const obs = this._relObservers.find((o) => o.rel._key === rel._key);
|
||||
if (!obs) return;
|
||||
for (const { source, target } of edges) {
|
||||
obs.edges.add(edgeKey(source, target));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed an event into the observable system.
|
||||
* Called by the World after state mutation.
|
||||
*/
|
||||
// ── Event dispatch ───────────────────────────────
|
||||
|
||||
onEvent(
|
||||
event: WorldEvent,
|
||||
queryMatches: (query: Query, e: Entity) => boolean,
|
||||
): void {
|
||||
// Forward to the global stream
|
||||
this.events$.next(event);
|
||||
|
||||
// Update each observer
|
||||
for (const observer of this._observers) {
|
||||
this._updateObserver(observer, event, queryMatches);
|
||||
for (const o of this._observers) {
|
||||
this._updateObserver(o, event, queryMatches);
|
||||
}
|
||||
for (const o of this._relObservers) {
|
||||
this._updateRelObserver(o, event);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────
|
||||
|
||||
private _updateObserver(
|
||||
obs: QueryObserverState,
|
||||
event: WorldEvent,
|
||||
queryMatches: (query: Query, e: Entity) => boolean,
|
||||
): void {
|
||||
const e = event.entity;
|
||||
// Only entity-bearing events affect queries
|
||||
if (!("entity" in event)) return;
|
||||
|
||||
const e = event.entity!;
|
||||
const wasMatched = obs.matched.has(e);
|
||||
const nowMatches = queryMatches(obs.query, e);
|
||||
|
||||
switch (event.type) {
|
||||
case "spawned":
|
||||
// Entity is bare; won't match unless components added later
|
||||
break;
|
||||
|
||||
case "destroyed":
|
||||
|
|
@ -120,25 +137,87 @@ export class ObservableLayer {
|
|||
}
|
||||
}
|
||||
|
||||
/** Reset all observer state (useful for tests). */
|
||||
reset(): void {
|
||||
for (const obs of this._observers) {
|
||||
obs.subject.complete();
|
||||
obs.matched.clear();
|
||||
}
|
||||
this._observers = [];
|
||||
private _updateRelObserver(
|
||||
obs: RelationshipObserverState,
|
||||
event: WorldEvent,
|
||||
): void {
|
||||
switch (event.type) {
|
||||
case "relationshipAdded": {
|
||||
if (event.relationship._key !== obs.rel._key) break;
|
||||
const key = edgeKey(event.source, event.target);
|
||||
if (obs.edges.has(key)) break;
|
||||
obs.edges.add(key);
|
||||
obs.subject.next({
|
||||
added: [{ source: event.source, target: event.target }],
|
||||
removed: [],
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
/** Complete all streams. */
|
||||
complete(): void {
|
||||
this.events$.complete();
|
||||
for (const obs of this._observers) {
|
||||
obs.subject.complete();
|
||||
case "relationshipRemoved": {
|
||||
if (event.relationship._key !== obs.rel._key) break;
|
||||
const key = edgeKey(event.source, event.target);
|
||||
if (!obs.edges.has(key)) break;
|
||||
obs.edges.delete(key);
|
||||
obs.subject.next({
|
||||
added: [],
|
||||
removed: [{ source: event.source, target: event.target }],
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "destroyed": {
|
||||
// World emits relationshipRemoved for each edge before destroy,
|
||||
// so those fire first. Then we arrive here — just clean up.
|
||||
const removed: { source: Entity; target: Entity }[] = [];
|
||||
for (const key of obs.edges) {
|
||||
const [si, ti] = key.split(":").map(Number);
|
||||
const idx = event.entity & 0xfffff;
|
||||
if (si === idx || ti === idx) {
|
||||
removed.push({
|
||||
source: si as Entity,
|
||||
target: ti as Entity,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const r of removed) {
|
||||
obs.edges.delete(edgeKey(r.source, r.target));
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
obs.subject.next({ added: [], removed });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Teardown ─────────────────────────────────────
|
||||
|
||||
reset(): void {
|
||||
for (const o of this._observers) {
|
||||
o.subject.complete();
|
||||
o.matched.clear();
|
||||
}
|
||||
this._observers = [];
|
||||
|
||||
for (const o of this._relObservers) {
|
||||
o.subject.complete();
|
||||
o.edges.clear();
|
||||
}
|
||||
this._relObservers = [];
|
||||
}
|
||||
|
||||
complete(): void {
|
||||
this.events$.complete();
|
||||
for (const o of this._observers) o.subject.complete();
|
||||
this._observers = [];
|
||||
for (const o of this._relObservers) o.subject.complete();
|
||||
this._relObservers = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────
|
||||
|
||||
function queriesEqual(a: Query, b: Query): boolean {
|
||||
if (a.with.length !== b.with.length) return false;
|
||||
if (a.not.length !== b.not.length) return false;
|
||||
|
|
@ -147,3 +226,7 @@ function queriesEqual(a: Query, b: Query): boolean {
|
|||
a.not.every((c, i) => c === b.not[i])
|
||||
);
|
||||
}
|
||||
|
||||
function edgeKey(source: Entity, target: Entity): string {
|
||||
return `${source & 0xfffff}:${target & 0xfffff}`;
|
||||
}
|
||||
|
|
|
|||
201
src/world.ts
201
src/world.ts
|
|
@ -4,14 +4,15 @@ import { makeEntity, entityIndex, entityGeneration } from "./entity";
|
|||
import type { Query } from "./query";
|
||||
import { SparseSet } from "./storage/sparse-set";
|
||||
import { ObservableLayer } from "./observable/observe";
|
||||
import type { QueryUpdate } from "./observable/events";
|
||||
import type { QueryUpdate, RelationshipUpdate } from "./observable/events";
|
||||
import type { RelationshipDef } from "./relationship";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
// ── World ─────────────────────────────────────────────
|
||||
/**
|
||||
* The central ECS container.
|
||||
*
|
||||
* Manages entities, components, queries, and change tracking.
|
||||
* Manages entities, components, relationships, queries, and change tracking.
|
||||
* Call `flush()` once per frame to emit batched observable events.
|
||||
*/
|
||||
export class World {
|
||||
|
|
@ -23,6 +24,14 @@ export class World {
|
|||
private _components = new Map<symbol, SparseSet<any>>();
|
||||
private _keyToDef = new Map<symbol, ComponentDef<any>>();
|
||||
|
||||
// ── Relationship storage ──────────────────────────
|
||||
/** Forward: relationship._key → SparseSet<target entity> (keyed by source index). */
|
||||
private _relForward = new Map<symbol, SparseSet<Entity>>();
|
||||
/** Reverse: relationship._key → Map<target index, Set<source index>>. */
|
||||
private _relReverse = new Map<symbol, Map<number, Set<number>>>();
|
||||
/** Key → RelationshipDef for event emission. */
|
||||
private _relKeyToDef = new Map<symbol, RelationshipDef>();
|
||||
|
||||
// ── Change tracking ───────────────────────────────
|
||||
private _dirty = new Map<symbol, Set<number>>();
|
||||
|
||||
|
|
@ -53,11 +62,34 @@ export class World {
|
|||
return e;
|
||||
}
|
||||
|
||||
/** Destroy an entity, removing all its components. */
|
||||
/** Destroy an entity, removing all its components and relationships. */
|
||||
destroy(entity: Entity): void {
|
||||
const idx = entityIndex(entity);
|
||||
if (!this._isAlive(idx, entity)) return;
|
||||
|
||||
// Clean relationships before components
|
||||
for (const [key] of this._relForward) {
|
||||
// Entity as source
|
||||
const fwd = this._relForward.get(key)!;
|
||||
if (fwd.has(idx)) {
|
||||
const target = fwd.get(idx);
|
||||
const rel = this._relKeyToDef.get(key)!;
|
||||
this._relRemoveEdge(entity, target, rel);
|
||||
}
|
||||
|
||||
// Entity as target
|
||||
const rev = this._relReverse.get(key)!;
|
||||
const sources = rev.get(idx);
|
||||
if (sources) {
|
||||
for (const si of [...sources]) {
|
||||
const source = makeEntity(si, this._generations[si]);
|
||||
const rel = this._relKeyToDef.get(key)!;
|
||||
this._relRemoveEdge(source, entity, rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove components
|
||||
for (const [, store] of this._components) {
|
||||
store.remove(idx);
|
||||
}
|
||||
|
|
@ -78,7 +110,6 @@ export class World {
|
|||
|
||||
// ── Component operations ──────────────────────────
|
||||
|
||||
/** Add a component to an entity. Returns the live value. */
|
||||
add<T extends Record<string, any>>(
|
||||
entity: Entity,
|
||||
def: ComponentDef<T>,
|
||||
|
|
@ -95,7 +126,6 @@ export class World {
|
|||
return value;
|
||||
}
|
||||
|
||||
/** Remove a component from an entity. */
|
||||
remove(entity: Entity, def: ComponentDef<any>): void {
|
||||
const idx = entityIndex(entity);
|
||||
this._assertAlive(idx, entity);
|
||||
|
|
@ -111,7 +141,6 @@ export class World {
|
|||
}
|
||||
}
|
||||
|
||||
/** Get a mutable reference to a component. Throws if absent. */
|
||||
get<T extends Record<string, any>>(entity: Entity, def: ComponentDef<T>): T {
|
||||
const idx = entityIndex(entity);
|
||||
this._assertAlive(idx, entity);
|
||||
|
|
@ -125,7 +154,6 @@ export class World {
|
|||
return store.get(idx);
|
||||
}
|
||||
|
||||
/** Get a mutable reference, or undefined if absent. */
|
||||
tryGet<T extends Record<string, any>>(
|
||||
entity: Entity,
|
||||
def: ComponentDef<T>,
|
||||
|
|
@ -135,7 +163,6 @@ export class World {
|
|||
return this._components.get(def._key)?.tryGet(idx);
|
||||
}
|
||||
|
||||
/** Check if an entity has a component. */
|
||||
has(entity: Entity, def: ComponentDef<any>): boolean {
|
||||
const idx = entityIndex(entity);
|
||||
if (!this._isAlive(idx, entity)) return false;
|
||||
|
|
@ -143,7 +170,6 @@ export class World {
|
|||
return store?.has(idx) ?? false;
|
||||
}
|
||||
|
||||
/** Replace a component value. Sets the value and marks dirty. */
|
||||
set<T extends Record<string, any>>(
|
||||
entity: Entity,
|
||||
def: ComponentDef<T>,
|
||||
|
|
@ -165,7 +191,6 @@ export class World {
|
|||
|
||||
// ── Change tracking ───────────────────────────────
|
||||
|
||||
/** Mark entity's component as dirty. Not emitted until `flush()`. */
|
||||
markDirty(entity: Entity, def: ComponentDef<any>): void {
|
||||
const idx = entityIndex(entity);
|
||||
this._assertAlive(idx, entity);
|
||||
|
|
@ -178,7 +203,6 @@ export class World {
|
|||
dirty.add(idx);
|
||||
}
|
||||
|
||||
/** Emit all pending change events. Call once per frame. */
|
||||
flush(): void {
|
||||
for (const [key, dirtySet] of this._dirty) {
|
||||
if (dirtySet.size === 0) continue;
|
||||
|
|
@ -197,9 +221,116 @@ export class World {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Relationships ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a directed relationship from `source` to `target`.
|
||||
* Each source can only have one target per relationship type.
|
||||
* If a relationship already exists, it is replaced.
|
||||
*/
|
||||
relate(source: Entity, rel: RelationshipDef, target: Entity): void {
|
||||
const si = entityIndex(source);
|
||||
const ti = entityIndex(target);
|
||||
this._assertAlive(si, source);
|
||||
this._assertAlive(ti, target);
|
||||
|
||||
// If source already has this relationship, remove it first
|
||||
const existing = this.getRelated(source, rel);
|
||||
if (existing !== undefined) {
|
||||
this._relRemoveEdge(source, existing, rel);
|
||||
}
|
||||
|
||||
this._relEnsureMaps(rel);
|
||||
|
||||
// Forward
|
||||
this._relForward.get(rel._key)!.set(si, target);
|
||||
|
||||
// Reverse
|
||||
let rev = this._relReverse.get(rel._key)!.get(ti);
|
||||
if (!rev) {
|
||||
rev = new Set();
|
||||
this._relReverse.get(rel._key)!.set(ti, rev);
|
||||
}
|
||||
rev.add(si);
|
||||
|
||||
this._emit({
|
||||
type: "relationshipAdded",
|
||||
source,
|
||||
target,
|
||||
relationship: rel,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the relationship from `source`.
|
||||
* No-op if no such relationship exists.
|
||||
*/
|
||||
unrelate(source: Entity, rel: RelationshipDef): void {
|
||||
const si = entityIndex(source);
|
||||
if (!this._isAlive(si, source)) return;
|
||||
|
||||
const target = this.getRelated(source, rel);
|
||||
if (target === undefined) return;
|
||||
|
||||
this._relRemoveEdge(source, target, rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target entity for a relationship, or undefined.
|
||||
*/
|
||||
getRelated(source: Entity, rel: RelationshipDef): Entity | undefined {
|
||||
const si = entityIndex(source);
|
||||
if (!this._isAlive(si, source)) return undefined;
|
||||
|
||||
const fwd = this._relForward.get(rel._key);
|
||||
if (!fwd) return undefined;
|
||||
return fwd.tryGet(si);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all source entities that point to `target` via this relationship.
|
||||
*/
|
||||
getRelatedTo(target: Entity, rel: RelationshipDef): Entity[] {
|
||||
const ti = entityIndex(target);
|
||||
if (!this._isAlive(ti, target)) return [];
|
||||
|
||||
const rev = this._relReverse.get(rel._key);
|
||||
if (!rev) return [];
|
||||
|
||||
const sources = rev.get(ti);
|
||||
if (!sources) return [];
|
||||
|
||||
return [...sources].map((si) => makeEntity(si, this._generations[si]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe relationship changes for a given type.
|
||||
*
|
||||
* ```ts
|
||||
* world.observeRelated(ChildOf).subscribe(update => {
|
||||
* // update.added, update.removed
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
observeRelated(rel: RelationshipDef): Observable<RelationshipUpdate> {
|
||||
const subject = this._observable.observeRelated(rel);
|
||||
|
||||
// Seed with current edges
|
||||
const edges: { source: Entity; target: Entity }[] = [];
|
||||
const fwd = this._relForward.get(rel._key);
|
||||
if (fwd) {
|
||||
for (const [si, target] of fwd.entries()) {
|
||||
const source = makeEntity(si, this._generations[si]);
|
||||
edges.push({ source, target });
|
||||
}
|
||||
}
|
||||
this._observable.seedRelated(rel, edges);
|
||||
|
||||
return subject.asObservable();
|
||||
}
|
||||
|
||||
// ── Queries ───────────────────────────────────────
|
||||
|
||||
/** Iterate all entities matching a query synchronously. */
|
||||
*query(q: Query): IterableIterator<Entity> {
|
||||
const withStores = q.with.map((d) => this._components.get(d._key));
|
||||
const withoutStores = q.not.map((d) => this._components.get(d._key));
|
||||
|
|
@ -216,18 +347,13 @@ export class World {
|
|||
}
|
||||
}
|
||||
|
||||
/** Observe changes to a query's result set. */
|
||||
observe(q: Query): Observable<QueryUpdate> {
|
||||
const subject = this._observable.observe(q);
|
||||
|
||||
// Seed with currently-matching entities
|
||||
const existing = [...this.query(q)];
|
||||
this._observable.seed(q, existing);
|
||||
|
||||
return subject.asObservable();
|
||||
}
|
||||
|
||||
/** Total number of *alive* entities. */
|
||||
get entityCount(): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < this._generations.length; i++) {
|
||||
|
|
@ -258,6 +384,8 @@ export class World {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Component storage helpers ────────────────────
|
||||
|
||||
private _getOrCreateStore<T extends Record<string, any>>(
|
||||
def: ComponentDef<T>,
|
||||
): SparseSet<T> {
|
||||
|
|
@ -281,4 +409,43 @@ export class World {
|
|||
query.not.every((d) => !(this._components.get(d._key)?.has(idx) ?? false))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Relationship helpers ─────────────────────────
|
||||
|
||||
private _relEnsureMaps(rel: RelationshipDef): void {
|
||||
if (!this._relForward.has(rel._key)) {
|
||||
this._relForward.set(rel._key, new SparseSet<Entity>());
|
||||
this._relReverse.set(rel._key, new Map());
|
||||
this._relKeyToDef.set(rel._key, rel);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove an edge internally and emit the event. */
|
||||
private _relRemoveEdge(
|
||||
source: Entity,
|
||||
target: Entity,
|
||||
rel: RelationshipDef,
|
||||
): void {
|
||||
const si = entityIndex(source);
|
||||
const ti = entityIndex(target);
|
||||
|
||||
const fwd = this._relForward.get(rel._key);
|
||||
if (fwd) fwd.remove(si);
|
||||
|
||||
const rev = this._relReverse.get(rel._key);
|
||||
if (rev) {
|
||||
const sources = rev.get(ti);
|
||||
if (sources) {
|
||||
sources.delete(si);
|
||||
if (sources.size === 0) rev.delete(ti);
|
||||
}
|
||||
}
|
||||
|
||||
this._emit({
|
||||
type: "relationshipRemoved",
|
||||
source,
|
||||
target,
|
||||
relationship: rel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue