From 32f8f2991274be7b09e58f3dc65087184f7eb98b Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 31 May 2026 15:54:21 +0800 Subject: [PATCH] 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. --- src/index.ts | 24 +++-- src/observable/events.ts | 43 ++++++-- src/observable/observe.ts | 171 +++++++++++++++++++++++--------- src/world.ts | 201 ++++++++++++++++++++++++++++++++++---- 4 files changed, 361 insertions(+), 78 deletions(-) diff --git a/src/index.ts b/src/index.ts index c42a438..070395b 100644 --- a/src/index.ts +++ b/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"; diff --git a/src/observable/events.ts b/src/observable/events.ts index 3ea58fd..594c2c5 100644 --- a/src/observable/events.ts +++ b/src/observable/events.ts @@ -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; } export interface ComponentRemovedEvent { - type: 'componentRemoved'; + type: "componentRemoved"; entity: Entity; component: ComponentDef; } export interface ComponentChangedEvent { - type: 'componentChanged'; + type: "componentChanged"; entity: Entity; component: ComponentDef; } +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 }[]; +} diff --git a/src/observable/observe.ts b/src/observable/observe.ts index bac3eeb..eb7f4f2 100644 --- a/src/observable/observe.ts +++ b/src/observable/observe.ts @@ -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; subject: Subject; } +interface RelationshipObserverState { + rel: RelationshipDef; + edges: Set; + subject: Subject; +} + // ── 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(); - /** 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 { 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(), }; - - // 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 { + 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(), + }; + 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(); + 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; + } + + 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; + } } - this._observers = []; } - /** Complete all streams. */ - complete(): void { - this.events$.complete(); - for (const obs of this._observers) { - obs.subject.complete(); + // ── 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}`; +} diff --git a/src/world.ts b/src/world.ts index 398d479..ad75882 100644 --- a/src/world.ts +++ b/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>(); private _keyToDef = new Map>(); + // ── Relationship storage ────────────────────────── + /** Forward: relationship._key → SparseSet (keyed by source index). */ + private _relForward = new Map>(); + /** Reverse: relationship._key → Map>. */ + private _relReverse = new Map>>(); + /** Key → RelationshipDef for event emission. */ + private _relKeyToDef = new Map(); + // ── Change tracking ─────────────────────────────── private _dirty = new Map>(); @@ -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>( entity: Entity, def: ComponentDef, @@ -95,7 +126,6 @@ export class World { return value; } - /** Remove a component from an entity. */ remove(entity: Entity, def: ComponentDef): 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>(entity: Entity, def: ComponentDef): 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>( entity: Entity, def: ComponentDef, @@ -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): 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>( entity: Entity, def: ComponentDef, @@ -165,7 +191,6 @@ export class World { // ── Change tracking ─────────────────────────────── - /** Mark entity's component as dirty. Not emitted until `flush()`. */ markDirty(entity: Entity, def: ComponentDef): 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 { + 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 { 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 { 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>( def: ComponentDef, ): SparseSet { @@ -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()); + 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, + }); + } }