feat: implement core ECS engine with RxJS observability
Initial implementation of an Entity-Component-System (ECS) featuring: - Sparse set-based component storage for efficient access. - Entity lifecycle management with generation-based recycling. - Reactive query system using RxJS for change tracking. - Batched event flushing to support frame-based updates. - Type-safe component definitions via TypeScript inference.
This commit is contained in:
commit
4ede2d7f3b
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "ecs-observable",
|
||||
"version": "0.1.0",
|
||||
"description": "Entity-Component-System for games with an Observable-style API",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rxjs": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"rxjs": "^7.8.1",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"entity-component-system",
|
||||
"rxjs",
|
||||
"observable",
|
||||
"game"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// ── Component ─────────────────────────────────────────
|
||||
/**
|
||||
* A component definition carries both the type shape (via TypeScript inference)
|
||||
* and a unique key used for storage lookup.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const Position = defineComponent({ x: 0, y: 0 });
|
||||
* type Position = typeof Position.type;
|
||||
* ```
|
||||
*/
|
||||
export interface ComponentDef<T extends Record<string, any>> {
|
||||
/** Unique symbol used as the storage key. */
|
||||
readonly _key: symbol;
|
||||
/** Default values applied when a component is first added. */
|
||||
readonly defaults: T;
|
||||
/** Phantom type for inference. */
|
||||
readonly type: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a component type. The argument provides both default values and the
|
||||
* TypeScript shape.
|
||||
*/
|
||||
export function defineComponent<T extends Record<string, any>>(
|
||||
defaults: T
|
||||
): ComponentDef<T> {
|
||||
return {
|
||||
_key: Symbol(),
|
||||
defaults: { ...defaults },
|
||||
type: undefined as unknown as T, // phantom; never read at runtime
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// ── Entity ─────────────────────────────────────────────
|
||||
/** Opaque entity identifier. */
|
||||
export type Entity = number & { readonly __entity: unique symbol };
|
||||
|
||||
/** Creates a new typed Entity from a raw number. */
|
||||
export function makeEntity(index: number, generation: number): Entity {
|
||||
return ((generation << 20) | index) as Entity;
|
||||
}
|
||||
|
||||
/** Extract the index from an Entity. */
|
||||
export function entityIndex(entity: Entity): number {
|
||||
return entity & 0xfffff;
|
||||
}
|
||||
|
||||
/** Extract the generation from an Entity. */
|
||||
export function entityGeneration(entity: Entity): number {
|
||||
return entity >>> 20;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// ── 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';
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import type { ComponentDef } from '../component';
|
||||
import type { Entity } from '../entity';
|
||||
|
||||
// ── World Events ──────────────────────────────────────
|
||||
/**
|
||||
* Discriminated union of all world-level events.
|
||||
* Emitted via `world.events$`.
|
||||
*/
|
||||
export type WorldEvent =
|
||||
| SpawnedEvent
|
||||
| DestroyedEvent
|
||||
| ComponentAddedEvent
|
||||
| ComponentRemovedEvent
|
||||
| ComponentChangedEvent;
|
||||
|
||||
export interface SpawnedEvent {
|
||||
type: 'spawned';
|
||||
entity: Entity;
|
||||
}
|
||||
|
||||
export interface DestroyedEvent {
|
||||
type: 'destroyed';
|
||||
entity: Entity;
|
||||
}
|
||||
|
||||
export interface ComponentAddedEvent {
|
||||
type: 'componentAdded';
|
||||
entity: Entity;
|
||||
component: ComponentDef<any>;
|
||||
}
|
||||
|
||||
export interface ComponentRemovedEvent {
|
||||
type: 'componentRemoved';
|
||||
entity: Entity;
|
||||
component: ComponentDef<any>;
|
||||
}
|
||||
|
||||
export interface ComponentChangedEvent {
|
||||
type: 'componentChanged';
|
||||
entity: Entity;
|
||||
component: ComponentDef<any>;
|
||||
}
|
||||
|
||||
// ── Query Observables ────────────────────────────────
|
||||
/**
|
||||
* Emitted by `world.observe(query)` when the result set changes.
|
||||
*/
|
||||
export interface QueryUpdate {
|
||||
/** Entities that newly match the query this frame. */
|
||||
added: Entity[];
|
||||
/** Entities that no longer match the query this frame. */
|
||||
removed: Entity[];
|
||||
/** Entities still matching that had a `markDirty` this frame. */
|
||||
changed: Entity[];
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { Subject } from "rxjs";
|
||||
import type { Query } from "../query";
|
||||
import type { Entity } from "../entity";
|
||||
import type { WorldEvent, QueryUpdate } from "./events";
|
||||
|
||||
// ── Internal observer state per query ────────────────
|
||||
interface QueryObserverState {
|
||||
query: Query;
|
||||
/** Cached set of entities currently matching the query. */
|
||||
matched: Set<Entity>;
|
||||
subject: Subject<QueryUpdate>;
|
||||
}
|
||||
|
||||
// ── 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[] = [];
|
||||
|
||||
/**
|
||||
* 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),
|
||||
);
|
||||
if (existing) return existing.subject;
|
||||
|
||||
const state: QueryObserverState = {
|
||||
query,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed an event into the observable system.
|
||||
* Called by the World after state mutation.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private _updateObserver(
|
||||
obs: QueryObserverState,
|
||||
event: WorldEvent,
|
||||
queryMatches: (query: Query, e: Entity) => boolean,
|
||||
): void {
|
||||
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":
|
||||
if (wasMatched) {
|
||||
obs.matched.delete(e);
|
||||
obs.subject.next({ added: [], removed: [e], changed: [] });
|
||||
}
|
||||
break;
|
||||
|
||||
case "componentAdded":
|
||||
case "componentRemoved": {
|
||||
if (wasMatched && !nowMatches) {
|
||||
obs.matched.delete(e);
|
||||
obs.subject.next({ added: [], removed: [e], changed: [] });
|
||||
} else if (!wasMatched && nowMatches) {
|
||||
obs.matched.add(e);
|
||||
obs.subject.next({ added: [e], removed: [], changed: [] });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "componentChanged": {
|
||||
if (wasMatched && nowMatches) {
|
||||
obs.subject.next({ added: [], removed: [], changed: [e] });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset all observer state (useful for tests). */
|
||||
reset(): void {
|
||||
for (const obs of this._observers) {
|
||||
obs.subject.complete();
|
||||
obs.matched.clear();
|
||||
}
|
||||
this._observers = [];
|
||||
}
|
||||
|
||||
/** Complete all streams. */
|
||||
complete(): void {
|
||||
this.events$.complete();
|
||||
for (const obs of this._observers) {
|
||||
obs.subject.complete();
|
||||
}
|
||||
this._observers = [];
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
return (
|
||||
a.with.every((c, i) => c === b.with[i]) &&
|
||||
a.not.every((c, i) => c === b.not[i])
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import type { ComponentDef } from "./component";
|
||||
|
||||
// ── Query ─────────────────────────────────────────────
|
||||
/**
|
||||
* Describes a component filter.
|
||||
*
|
||||
* Use `query()` to construct one, optionally chaining `.without()`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* query(Position, Velocity) // entities with both
|
||||
* query(Position).without(Dead) // Position but not Dead
|
||||
* ```
|
||||
*/
|
||||
export class Query {
|
||||
/** Components an entity must have to match. */
|
||||
readonly with: ComponentDef<any>[];
|
||||
/** Components an entity must NOT have to match. */
|
||||
readonly not: ComponentDef<any>[];
|
||||
|
||||
constructor(
|
||||
withComponents: ComponentDef<any>[],
|
||||
withoutComponents: ComponentDef<any>[] = [],
|
||||
) {
|
||||
this.with = withComponents;
|
||||
this.not = withoutComponents;
|
||||
}
|
||||
|
||||
/** Return a new Query that also excludes these components. */
|
||||
without(...components: ComponentDef<any>[]): Query {
|
||||
return new Query(this.with, [...this.not, ...components]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a query matching entities that have all the given components.
|
||||
*/
|
||||
export function query(...components: ComponentDef<any>[]): Query {
|
||||
return new Query(components);
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
// ── SparseSet ─────────────────────────────────────────
|
||||
/**
|
||||
* Type-compatible sparse set. Each entity index maps to a value in the dense
|
||||
* array. The sparse array stores indices into the dense array (or `-1`).
|
||||
*
|
||||
* Inspired by ENTT's storage model.
|
||||
*/
|
||||
export class SparseSet<T> {
|
||||
private sparse: number[] = [];
|
||||
private dense: number[] = []; // entity indices
|
||||
private values: T[] = [];
|
||||
|
||||
/** Number of elements currently stored. */
|
||||
get size(): number {
|
||||
return this.dense.length;
|
||||
}
|
||||
|
||||
/** Returns true if the entity has a value. */
|
||||
has(entityIndex: number): boolean {
|
||||
return (
|
||||
entityIndex < this.sparse.length &&
|
||||
this.sparse[entityIndex] !== -1
|
||||
);
|
||||
}
|
||||
|
||||
/** Get the value for an entity. Throws if absent. */
|
||||
get(entityIndex: number): T {
|
||||
const idx = this.sparse[entityIndex];
|
||||
return this.values[idx];
|
||||
}
|
||||
|
||||
/** Get the value for an entity, or undefined. */
|
||||
tryGet(entityIndex: number): T | undefined {
|
||||
const idx = this.sparse[entityIndex];
|
||||
if (idx === undefined || idx === -1) return undefined;
|
||||
return this.values[idx];
|
||||
}
|
||||
|
||||
/** Insert or replace a value for an entity. */
|
||||
set(entityIndex: number, value: T): void {
|
||||
if (this.has(entityIndex)) {
|
||||
const idx = this.sparse[entityIndex];
|
||||
this.values[idx] = value;
|
||||
} else {
|
||||
this._grow(entityIndex);
|
||||
this.sparse[entityIndex] = this.dense.length;
|
||||
this.dense.push(entityIndex);
|
||||
this.values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the value for an entity. Safe to call even if absent. */
|
||||
remove(entityIndex: number): boolean {
|
||||
if (!this.has(entityIndex)) return false;
|
||||
|
||||
const idx = this.sparse[entityIndex];
|
||||
const lastIdx = this.dense.length - 1;
|
||||
|
||||
if (idx !== lastIdx) {
|
||||
// swap-remove: move the last element into the removed slot
|
||||
const lastEntity = this.dense[lastIdx];
|
||||
this.dense[idx] = lastEntity;
|
||||
this.values[idx] = this.values[lastIdx];
|
||||
this.sparse[lastEntity] = idx;
|
||||
}
|
||||
|
||||
this.dense.pop();
|
||||
this.values.pop();
|
||||
this.sparse[entityIndex] = -1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Iterate entity indices in dense order. */
|
||||
entities(): IterableIterator<number> {
|
||||
return this.dense[Symbol.iterator]();
|
||||
}
|
||||
|
||||
/** Iterate values in dense order. */
|
||||
rawValues(): readonly T[] {
|
||||
return this.values;
|
||||
}
|
||||
|
||||
/** Iterate [entityIndex, value] pairs in dense order. */
|
||||
*entries(): IterableIterator<[number, T]> {
|
||||
for (let i = 0; i < this.dense.length; i++) {
|
||||
yield [this.dense[i], this.values[i]];
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all entries. */
|
||||
clear(): void {
|
||||
this.sparse = [];
|
||||
this.dense = [];
|
||||
this.values = [];
|
||||
}
|
||||
|
||||
private _grow(to: number): void {
|
||||
while (this.sparse.length <= to) {
|
||||
this.sparse.push(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
import type { ComponentDef } from "./component";
|
||||
import type { Entity } from "./entity";
|
||||
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 { Observable } from "rxjs";
|
||||
|
||||
// ── World ─────────────────────────────────────────────
|
||||
/**
|
||||
* The central ECS container.
|
||||
*
|
||||
* Manages entities, components, queries, and change tracking.
|
||||
* Call `flush()` once per frame to emit batched observable events.
|
||||
*/
|
||||
export class World {
|
||||
// ── Entity pools ──────────────────────────────────
|
||||
private _generations: number[] = [];
|
||||
private _free: number[] = [];
|
||||
|
||||
// ── Component storage ─────────────────────────────
|
||||
private _components = new Map<symbol, SparseSet<any>>();
|
||||
private _keyToDef = new Map<symbol, ComponentDef<any>>();
|
||||
|
||||
// ── Change tracking ───────────────────────────────
|
||||
private _dirty = new Map<symbol, Set<number>>();
|
||||
|
||||
// ── Observable layer ──────────────────────────────
|
||||
private _observable = new ObservableLayer();
|
||||
|
||||
/** Global event stream. */
|
||||
get events$(): Observable<any> {
|
||||
return this._observable.events$.asObservable();
|
||||
}
|
||||
|
||||
// ── Entity lifecycle ──────────────────────────────
|
||||
|
||||
/** Create a new (bare) entity. */
|
||||
spawn(): Entity {
|
||||
if (this._free.length > 0) {
|
||||
const idx = this._free.pop()!;
|
||||
const gen = this._generations[idx];
|
||||
const e = makeEntity(idx, gen);
|
||||
this._emit({ type: "spawned", entity: e });
|
||||
return e;
|
||||
}
|
||||
|
||||
const idx = this._generations.length;
|
||||
this._generations.push(1);
|
||||
const e = makeEntity(idx, 1);
|
||||
this._emit({ type: "spawned", entity: e });
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Destroy an entity, removing all its components. */
|
||||
destroy(entity: Entity): void {
|
||||
const idx = entityIndex(entity);
|
||||
if (!this._isAlive(idx, entity)) return;
|
||||
|
||||
for (const [, store] of this._components) {
|
||||
store.remove(idx);
|
||||
}
|
||||
for (const [, dirty] of this._dirty) {
|
||||
dirty.delete(idx);
|
||||
}
|
||||
|
||||
this._generations[idx]++;
|
||||
this._free.push(idx);
|
||||
|
||||
this._emit({ type: "destroyed", entity });
|
||||
}
|
||||
|
||||
/** Returns true if the entity is still alive. */
|
||||
isAlive(entity: Entity): boolean {
|
||||
return this._isAlive(entityIndex(entity), entity);
|
||||
}
|
||||
|
||||
// ── Component operations ──────────────────────────
|
||||
|
||||
/** Add a component to an entity. Returns the live value. */
|
||||
add<T extends Record<string, any>>(
|
||||
entity: Entity,
|
||||
def: ComponentDef<T>,
|
||||
init?: Partial<T>,
|
||||
): T {
|
||||
const idx = entityIndex(entity);
|
||||
this._assertAlive(idx, entity);
|
||||
|
||||
const store = this._getOrCreateStore(def);
|
||||
const value = { ...def.defaults, ...init };
|
||||
store.set(idx, value);
|
||||
|
||||
this._emit({ type: "componentAdded", entity, component: def });
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Remove a component from an entity. */
|
||||
remove(entity: Entity, def: ComponentDef<any>): void {
|
||||
const idx = entityIndex(entity);
|
||||
this._assertAlive(idx, entity);
|
||||
|
||||
const store = this._components.get(def._key);
|
||||
if (!store) return;
|
||||
const removed = store.remove(idx);
|
||||
|
||||
this._dirty.get(def._key)?.delete(idx);
|
||||
|
||||
if (removed) {
|
||||
this._emit({ type: "componentRemoved", entity, component: def });
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
const store = this._components.get(def._key);
|
||||
if (!store || !store.has(idx)) {
|
||||
throw new Error(
|
||||
`Entity ${entity} does not have the requested component.`,
|
||||
);
|
||||
}
|
||||
return store.get(idx);
|
||||
}
|
||||
|
||||
/** Get a mutable reference, or undefined if absent. */
|
||||
tryGet<T extends Record<string, any>>(
|
||||
entity: Entity,
|
||||
def: ComponentDef<T>,
|
||||
): T | undefined {
|
||||
const idx = entityIndex(entity);
|
||||
if (!this._isAlive(idx, entity)) return undefined;
|
||||
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;
|
||||
const store = this._components.get(def._key);
|
||||
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>,
|
||||
value: T,
|
||||
): void {
|
||||
const idx = entityIndex(entity);
|
||||
this._assertAlive(idx, entity);
|
||||
|
||||
const store = this._components.get(def._key);
|
||||
if (!store || !store.has(idx)) {
|
||||
throw new Error(
|
||||
`Entity ${entity} does not have the requested component. ` +
|
||||
`Use add() to add it first.`,
|
||||
);
|
||||
}
|
||||
store.set(idx, value);
|
||||
this.markDirty(entity, def);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
let dirty = this._dirty.get(def._key);
|
||||
if (!dirty) {
|
||||
dirty = new Set();
|
||||
this._dirty.set(def._key, dirty);
|
||||
}
|
||||
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;
|
||||
|
||||
const def = this._keyToDef.get(key);
|
||||
if (!def) {
|
||||
dirtySet.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const idx of dirtySet) {
|
||||
const entity = makeEntity(idx, this._generations[idx]);
|
||||
this._emit({ type: "componentChanged", entity, component: def });
|
||||
}
|
||||
dirtySet.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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));
|
||||
|
||||
if (withStores.some((s) => !s)) return;
|
||||
|
||||
const primary = withStores.reduce((a, b) => (a!.size <= b!.size ? a : b))!;
|
||||
|
||||
for (const idx of primary.entities()) {
|
||||
if (!withStores.every((s) => s!.has(idx))) continue;
|
||||
if (withoutStores.some((s) => s?.has(idx))) continue;
|
||||
|
||||
yield makeEntity(idx, this._generations[idx]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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++) {
|
||||
if (this._generations[i] !== 0 && !this._free.includes(i)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────
|
||||
|
||||
private _emit(event: import("./observable/events").WorldEvent): void {
|
||||
this._observable.onEvent(event, this._queryMatches.bind(this));
|
||||
}
|
||||
|
||||
private _isAlive(idx: number, entity: Entity): boolean {
|
||||
return (
|
||||
idx < this._generations.length &&
|
||||
this._generations[idx] !== 0 &&
|
||||
entityGeneration(entity) === this._generations[idx]
|
||||
);
|
||||
}
|
||||
|
||||
private _assertAlive(idx: number, entity: Entity): void {
|
||||
if (!this._isAlive(idx, entity)) {
|
||||
throw new Error(`Entity ${entity} is not alive.`);
|
||||
}
|
||||
}
|
||||
|
||||
private _getOrCreateStore<T extends Record<string, any>>(
|
||||
def: ComponentDef<T>,
|
||||
): SparseSet<T> {
|
||||
let store = this._components.get(def._key);
|
||||
if (!store) {
|
||||
store = new SparseSet<T>();
|
||||
this._components.set(def._key, store);
|
||||
this._keyToDef.set(def._key, def);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
private _queryMatches(query: Query, entity: Entity): boolean {
|
||||
const idx = entityIndex(entity);
|
||||
if (!this._isAlive(idx, entity)) return false;
|
||||
|
||||
return (
|
||||
query.with.every(
|
||||
(d) => this._components.get(d._key)?.has(idx) ?? false,
|
||||
) &&
|
||||
query.not.every((d) => !(this._components.get(d._key)?.has(idx) ?? false))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import {
|
||||
World,
|
||||
defineComponent,
|
||||
query,
|
||||
QueryUpdate,
|
||||
WorldEvent,
|
||||
} from "../src/index";
|
||||
|
||||
// ── Define components ─────────────────────────────────
|
||||
const Position = defineComponent({ x: 0, y: 0 });
|
||||
const Velocity = defineComponent({ vx: 0, vy: 0 });
|
||||
const Health = defineComponent({ current: 100, max: 100 });
|
||||
const Dead = defineComponent({ timestamp: 0 });
|
||||
|
||||
// Type inference check
|
||||
const _p: { x: number; y: number } = Position.defaults;
|
||||
|
||||
// ── World setup ──────────────────────────────────────
|
||||
const world = new World();
|
||||
|
||||
let events: WorldEvent[] = [];
|
||||
world.events$.subscribe((e) => events.push(e));
|
||||
|
||||
// ── Entity lifecycle ─────────────────────────────────
|
||||
const player = world.spawn();
|
||||
const enemy = world.spawn();
|
||||
|
||||
console.assert(events.length === 2, "spawn events");
|
||||
console.assert(events[0].type === "spawned" && events[0].entity === player);
|
||||
console.assert(events[1].type === "spawned" && events[1].entity === enemy);
|
||||
|
||||
console.assert(world.isAlive(player), "player alive");
|
||||
console.assert(world.isAlive(enemy), "enemy alive");
|
||||
console.assert(world.entityCount === 2, "two entities");
|
||||
|
||||
// ── Add components ───────────────────────────────────
|
||||
const pos = world.add(player, Position, { x: 10, y: 20 });
|
||||
world.add(player, Velocity, { vx: 1, vy: 0 });
|
||||
world.add(enemy, Position, { x: 50, y: 0 });
|
||||
world.add(enemy, Health, { current: 50 });
|
||||
|
||||
console.assert(pos.x === 10 && pos.y === 20, "add with init");
|
||||
console.assert(world.has(player, Position), "has Position");
|
||||
console.assert(world.has(player, Velocity), "has Velocity");
|
||||
console.assert(!world.has(player, Health), "no Health");
|
||||
console.assert(events.length === 6, "component add events");
|
||||
|
||||
// ── Sync query ───────────────────────────────────────
|
||||
const movable = [...world.query(query(Position, Velocity))];
|
||||
console.assert(movable.length === 1, "player only in movable");
|
||||
console.assert(movable[0] === player);
|
||||
|
||||
const allPos = [...world.query(query(Position))];
|
||||
console.assert(allPos.length === 2, "both have Position");
|
||||
|
||||
// ── Observable query ─────────────────────────────────
|
||||
const queryLog: QueryUpdate[] = [];
|
||||
world.observe(query(Position, Velocity)).subscribe((u) => {
|
||||
if (u.added.length || u.removed.length || u.changed.length) {
|
||||
queryLog.push(u);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mutation + change tracking ───────────────────────
|
||||
world.get(player, Position).x += 5;
|
||||
world.markDirty(player, Position);
|
||||
world.get(player, Velocity).vx *= 2;
|
||||
world.markDirty(player, Velocity);
|
||||
|
||||
// flush should emit componentChanged events and update queries
|
||||
world.flush();
|
||||
|
||||
console.assert(
|
||||
events.some((e) => e.type === "componentChanged"),
|
||||
"change events",
|
||||
);
|
||||
|
||||
// The query observer should have received changed: [player]
|
||||
const lastUpdate = queryLog[queryLog.length - 1];
|
||||
console.assert(
|
||||
lastUpdate.changed.length === 1 && lastUpdate.changed[0] === player,
|
||||
"player in changed",
|
||||
);
|
||||
|
||||
// ── Remove component ─────────────────────────────────
|
||||
world.remove(player, Velocity);
|
||||
|
||||
console.assert(!world.has(player, Velocity), "Velocity removed");
|
||||
const movableAfter = [...world.query(query(Position, Velocity))];
|
||||
console.assert(movableAfter.length === 0, "no one movable after remove");
|
||||
|
||||
// The observer should have emitted {removed: [player]}
|
||||
const remUpdate = queryLog[queryLog.length - 1];
|
||||
console.assert(
|
||||
remUpdate.removed.length === 1 && remUpdate.removed[0] === player,
|
||||
"player removed from query",
|
||||
);
|
||||
|
||||
// ── Destroy ──────────────────────────────────────────
|
||||
world.destroy(enemy);
|
||||
console.assert(!world.isAlive(enemy), "enemy destroyed");
|
||||
console.assert(world.entityCount === 1, "one entity left");
|
||||
|
||||
// ── componentChanged query update ────────────────────
|
||||
// Add enemy back, observe query(Health).without(Dead)
|
||||
const enemy2 = world.spawn();
|
||||
world.add(enemy2, Health, { current: 75 });
|
||||
|
||||
const healthLog: QueryUpdate[] = [];
|
||||
world.observe(query(Health).without(Dead)).subscribe((u) => {
|
||||
healthLog.push(u);
|
||||
});
|
||||
|
||||
// Enemy won't be in the initial seed yet (subscribe happened after spawn)
|
||||
// Let's add Dead to trigger the removal
|
||||
world.add(enemy2, Dead, { timestamp: 123 });
|
||||
world.flush();
|
||||
|
||||
console.assert(
|
||||
healthLog.some((u) => u.removed[0] === enemy2),
|
||||
"enemy removed from health-not-dead query after gaining Dead",
|
||||
);
|
||||
|
||||
// ── Entity recycling ─────────────────────────────────
|
||||
world.destroy(player);
|
||||
const recycled = world.spawn();
|
||||
|
||||
console.assert(recycled !== player, "recycled entity has new generation");
|
||||
console.assert(world.isAlive(recycled), "recycled entity is alive");
|
||||
console.assert(!world.isAlive(player), "old handle is dead");
|
||||
|
||||
console.log("✅ All smoke tests passed.");
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
treeshake: true,
|
||||
});
|
||||
Loading…
Reference in New Issue