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