feat: add CommandQueue for pull-based command processing
Introduces a `CommandQueue` to handle command components. It allows registering handlers that are executed when `execute()` is called, automatically removing the command component and cleaning up empty entities. Includes an interruption mechanism to pause processing during asynchronous operations.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { query as makeQuery } from "../query";
|
||||
import type { World, Entity, ComponentDef, Query } from "../index";
|
||||
|
||||
// ── Types ────────────────────────────────────────────
|
||||
/** A handler that processes a command extracted from an entity. */
|
||||
export type CommandHandler<T extends Record<string, any>> = (
|
||||
command: T,
|
||||
entity?: Entity,
|
||||
) => void;
|
||||
|
||||
/** Pending work: entity, its command data, and the handler to invoke. */
|
||||
interface Pending<T extends Record<string, any> = any> {
|
||||
entity: Entity;
|
||||
handler: CommandHandler<T>;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/** Registered handler bookkeeping. */
|
||||
interface Registration<T extends Record<string, any> = any> {
|
||||
def: ComponentDef<T>;
|
||||
query: Query;
|
||||
handler: CommandHandler<T>;
|
||||
}
|
||||
|
||||
// ── CommandQueue ─────────────────────────────────────
|
||||
/**
|
||||
* Pull-based command system.
|
||||
*
|
||||
* Register handlers for command component types, then call `execute()` each
|
||||
* frame. It scans the world for entities carrying command components, removes
|
||||
* them, dispatches to handlers, and destroys entities that become empty.
|
||||
*
|
||||
* Interruptions pause processing — while any tracked promise is unresolved,
|
||||
* `execute()` is a no-op.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const Damage = defineComponent('damage', { amount: 0 });
|
||||
*
|
||||
* const queue = new CommandQueue(world);
|
||||
* queue.handle(Damage, (cmd, entity) => {
|
||||
* const hp = world.get(entity!, Health);
|
||||
* hp.current -= cmd.amount;
|
||||
* });
|
||||
*
|
||||
* // each frame:
|
||||
* queue.execute();
|
||||
* ```
|
||||
*/
|
||||
export class CommandQueue {
|
||||
private _world: World;
|
||||
private _registrations: Registration[] = [];
|
||||
private _pendingPromises = new Set<Promise<any>>();
|
||||
private _interrupted = false;
|
||||
|
||||
constructor(world: World) {
|
||||
this._world = world;
|
||||
}
|
||||
|
||||
// ── Registration ─────────────────────────────────
|
||||
|
||||
/** Register a handler for `def`. Each handler is called once per entity per frame. */
|
||||
handle<T extends Record<string, any>>(
|
||||
def: ComponentDef<T>,
|
||||
handler: CommandHandler<T>,
|
||||
): this {
|
||||
this._registrations.push({ def, query: makeQuery(def), handler });
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Interruption ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Track a promise. While any tracked promise is unresolved,
|
||||
* `execute()` skips command processing.
|
||||
*
|
||||
* Once all tracked promises have settled, processing resumes.
|
||||
*/
|
||||
interrupt(promise: Promise<any>): void {
|
||||
this._pendingPromises.add(promise);
|
||||
this._interrupted = true;
|
||||
|
||||
const remove = () => {
|
||||
this._pendingPromises.delete(promise);
|
||||
if (this._pendingPromises.size === 0) {
|
||||
this._interrupted = false;
|
||||
}
|
||||
};
|
||||
|
||||
promise.then(remove, remove);
|
||||
}
|
||||
|
||||
/** True while at least one interruption promise is pending. */
|
||||
get isInterrupted(): boolean {
|
||||
return this._interrupted;
|
||||
}
|
||||
|
||||
// ── Execution ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drain all command components from the world and dispatch to handlers.
|
||||
*
|
||||
* For each registered component type, every matching entity has the
|
||||
* component removed. The handler receives the entity and the command
|
||||
* data. If the entity has no components left after removal, it is
|
||||
* destroyed.
|
||||
*
|
||||
* If `isInterrupted` is true, this method is a no-op.
|
||||
*/
|
||||
execute(): void {
|
||||
if (this._interrupted) return;
|
||||
|
||||
const pending: Pending[] = [];
|
||||
|
||||
// 1. Snapshot + remove command components
|
||||
for (const reg of this._registrations) {
|
||||
// Snapshot into array; sparse-set iteration is not mutation-safe
|
||||
const entities = [...this._world.query(reg.query)];
|
||||
for (const entity of entities) {
|
||||
const data = this._world.tryGet(entity, reg.def);
|
||||
if (data !== undefined) {
|
||||
this._world.remove(entity, reg.def);
|
||||
pending.push({ entity, handler: reg.handler, data });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Destroy entities that became empty after command removal
|
||||
for (const p of pending) {
|
||||
if (
|
||||
this._world.isAlive(p.entity) &&
|
||||
!this._world.hasAnyComponent(p.entity)
|
||||
) {
|
||||
this._world.destroy(p.entity);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Dispatch handlers (after cleanup so handlers see consistent state)
|
||||
for (const p of pending) {
|
||||
p.handler(p.data, p.entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CommandQueue } from "./command-queue";
|
||||
export type { CommandHandler } from "./command-queue";
|
||||
@@ -110,6 +110,16 @@ export class World {
|
||||
return this._isAlive(entityIndex(entity), entity);
|
||||
}
|
||||
|
||||
/** Returns true if the entity holds at least one component of any type. */
|
||||
hasAnyComponent(entity: Entity): boolean {
|
||||
const idx = entityIndex(entity);
|
||||
if (!this._isAlive(idx, entity)) return false;
|
||||
for (const store of this._components.values()) {
|
||||
if (store.has(idx)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Component operations ──────────────────────────
|
||||
|
||||
add<T extends Record<string, any>>(
|
||||
|
||||
Reference in New Issue
Block a user