ECS rewrite
This commit is contained in:
parent
0ad5c60cdd
commit
89651f6674
|
|
@ -1,211 +0,0 @@
|
|||
import type { RPGActions, RPGVariables } from "../types";
|
||||
import { ACTION_KEYS, VARIABLE_KEYS } from "../decorators";
|
||||
|
||||
export interface RPGEvent<T = unknown> {
|
||||
target: RPGComponent;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface RPGContext {
|
||||
dispatch(event: string, payload: RPGEvent): void;
|
||||
on(event: string, handler: (payload: RPGEvent) => void): () => void;
|
||||
off(event: string, handler: (payload: RPGEvent) => void): void;
|
||||
}
|
||||
|
||||
export abstract class RPGComponent {
|
||||
#path: string = '';
|
||||
protected _ctx: RPGContext | null = null;
|
||||
|
||||
get path(): string {
|
||||
return this.#path;
|
||||
}
|
||||
|
||||
set path(path: string) {
|
||||
this.#path = path;
|
||||
}
|
||||
|
||||
attach(ctx: RPGContext, path: string): void {
|
||||
this.path = path;
|
||||
this._ctx = ctx;
|
||||
this.onAttach();
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.onDetach();
|
||||
this.path = '';
|
||||
this._ctx = null;
|
||||
}
|
||||
|
||||
protected onAttach(): void {}
|
||||
protected onDetach(): void {}
|
||||
|
||||
protected emit<T = unknown>(event: string, data?: T): void {
|
||||
this._ctx?.dispatch(this.resolve(event), { target: this, data });
|
||||
}
|
||||
|
||||
on<T = unknown>(event: string, handler: (payload: RPGEvent<T>) => void): () => void {
|
||||
return this._ctx?.on(this.resolve(event), handler as (e: RPGEvent) => void) ?? (() => {});
|
||||
}
|
||||
|
||||
off<T = unknown>(event: string, handler: (payload: RPGEvent<T>) => void): void {
|
||||
this._ctx?.off(this.resolve(event), handler as (e: RPGEvent) => void);
|
||||
}
|
||||
|
||||
protected resolve(name: string): string {
|
||||
if (name.startsWith('$.')) return name.slice(2);
|
||||
return this.path ? `${this.path}.${name}` : name;
|
||||
}
|
||||
|
||||
getVariables(): RPGVariables {
|
||||
const meta = (this.constructor as Function)[Symbol.metadata];
|
||||
const keys = meta?.[VARIABLE_KEYS] as Map<string | symbol, string> | undefined;
|
||||
if (!keys) return {};
|
||||
const vars: RPGVariables = {};
|
||||
for (const [methodKey, exportName] of keys) {
|
||||
const k = String(methodKey);
|
||||
const v = (this as Record<string, unknown>)[k];
|
||||
if (
|
||||
typeof v === 'number'
|
||||
|| typeof v === 'string'
|
||||
|| typeof v === 'boolean'
|
||||
) {
|
||||
vars[exportName] = v;
|
||||
}
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
getActions(): RPGActions {
|
||||
const meta = (this.constructor as Function)[Symbol.metadata];
|
||||
const keys = meta?.[ACTION_KEYS] as Set<string | symbol> | undefined;
|
||||
if (!keys) return {};
|
||||
const actions: RPGActions = {};
|
||||
for (const key of keys) {
|
||||
const k = String(key);
|
||||
const fn = (this as Record<string, unknown>)[k];
|
||||
if (typeof fn === 'function') {
|
||||
actions[k] = fn.bind(this);
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
tick(_dt: number) {}
|
||||
}
|
||||
|
||||
function matchesWildcard(event: string, pattern: string): boolean {
|
||||
const ep = event.split('.');
|
||||
const pp = pattern.split('.');
|
||||
return (function match(ei: number, pi: number): boolean {
|
||||
if (pi === pp.length) return ei === ep.length;
|
||||
if (pp[pi] === '*') {
|
||||
for (let skip = 0; ei + skip <= ep.length; skip++) {
|
||||
if (match(ei + skip, pi + 1)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return ei < ep.length && pp[pi] === ep[ei] && match(ei + 1, pi + 1);
|
||||
})(0, 0);
|
||||
}
|
||||
|
||||
export class RPGEntity extends RPGComponent implements RPGContext {
|
||||
private readonly handlers = new Map<string, Set<(payload: RPGEvent) => void>>();
|
||||
private readonly wildcardHandlers = new Map<string, Set<(payload: RPGEvent) => void>>();
|
||||
private components = new Map<string, RPGComponent>();
|
||||
|
||||
constructor(public readonly id: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
// RPGContext: raw dispatch into this entity's handler map
|
||||
dispatch(event: string, payload: RPGEvent): void {
|
||||
this.handlers.get(event)?.forEach(h => h(payload));
|
||||
for (const [pattern, handlers] of this.wildcardHandlers) {
|
||||
if (matchesWildcard(event, pattern)) handlers.forEach(h => h(payload));
|
||||
}
|
||||
}
|
||||
|
||||
// RPGContext: register locally if root, delegate up if nested
|
||||
override on<T = unknown>(event: string, handler: (payload: RPGEvent<T>) => void): () => void {
|
||||
const resolved = this.resolve(event);
|
||||
const h = handler as (payload: RPGEvent) => void;
|
||||
if (this._ctx) {
|
||||
return this._ctx.on(resolved, h);
|
||||
}
|
||||
const map = resolved.includes('*') ? this.wildcardHandlers : this.handlers;
|
||||
if (!map.has(resolved)) map.set(resolved, new Set());
|
||||
map.get(resolved)!.add(h);
|
||||
return () => map.get(resolved)?.delete(h);
|
||||
}
|
||||
|
||||
override off<T = unknown>(event: string, handler: (payload: RPGEvent<T>) => void): void {
|
||||
const resolved = this.resolve(event);
|
||||
const h = handler as (payload: RPGEvent) => void;
|
||||
if (this._ctx) {
|
||||
this._ctx.off(resolved, h);
|
||||
return;
|
||||
}
|
||||
const map = resolved.includes('*') ? this.wildcardHandlers : this.handlers;
|
||||
map.get(resolved)?.delete(h);
|
||||
}
|
||||
|
||||
override attach(ctx: RPGContext, path: string): void {
|
||||
super.attach(ctx, path);
|
||||
for (const [key, component] of this.components) {
|
||||
component.attach(ctx, `${path}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
addComponent(id: string, component: RPGComponent): void {
|
||||
this.components.set(id, component);
|
||||
const path = this.path ? `${this.path}.${id}` : id;
|
||||
component.attach(this._ctx ?? this, path);
|
||||
}
|
||||
|
||||
removeComponent(id: string): void {
|
||||
const component = this.components.get(id);
|
||||
if (component) {
|
||||
component.detach();
|
||||
this.components.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
getComponent<T extends RPGComponent>(id: string): T | undefined {
|
||||
return this.components.get(id) as T | undefined;
|
||||
}
|
||||
|
||||
override getVariables(): RPGVariables {
|
||||
const variables: RPGVariables = {};
|
||||
for (const [componentKey, component] of this.components) {
|
||||
for (const [key, value] of Object.entries(component.getVariables())) {
|
||||
if (value != null) {
|
||||
variables[`${componentKey}.${key}`] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
override getActions(): RPGActions {
|
||||
const actions: RPGActions = {};
|
||||
for (const [componentKey, component] of this.components) {
|
||||
for (const [key, action] of Object.entries(component.getActions())) {
|
||||
actions[`${componentKey}.${key}`] = action;
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
override tick(dt: number) {
|
||||
for (const component of this.components.values()) {
|
||||
component.tick(dt);
|
||||
}
|
||||
}
|
||||
|
||||
override set path(value: string) {
|
||||
super.path = value;
|
||||
for (const [key, component] of this.components) {
|
||||
component.path = value ? `${value}.${key}` : key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,20 @@
|
|||
import type { InventoryOptions, InventorySlotInput, SlotId } from "../types";
|
||||
import { action } from "../decorators";
|
||||
import { RPGComponent } from "./entity";
|
||||
import { action } from "../utils/decorators";
|
||||
import { Component } from "../core/world";
|
||||
|
||||
interface SlotEntry {
|
||||
readonly slotId: SlotId;
|
||||
readonly limit: number | undefined;
|
||||
state: { itemId: string; amount: number } | null;
|
||||
}
|
||||
|
||||
interface SlotUpdateArgs {
|
||||
itemId: string;
|
||||
amount: number;
|
||||
slotId?: SlotId;
|
||||
}
|
||||
|
||||
export class Inventory extends RPGComponent {
|
||||
export class Inventory extends Component {
|
||||
private readonly slots: Map<SlotId, SlotEntry>;
|
||||
private readonly maxAmountPerItem: Record<string, number>;
|
||||
|
||||
|
|
@ -36,7 +37,7 @@ export class Inventory extends RPGComponent {
|
|||
return Math.min(limitCap, itemCap);
|
||||
}
|
||||
|
||||
// Remaining space in slot for itemId (min of slotCap and remaining amount)
|
||||
// Remaining space in slot for itemId
|
||||
private slotRoomFor(slot: SlotEntry, itemId: string): number {
|
||||
if (slot.state !== null && slot.state.itemId !== itemId) return 0;
|
||||
return this.slotCapFor(slot, itemId) - (slot.state?.amount ?? 0);
|
||||
|
|
@ -66,7 +67,7 @@ export class Inventory extends RPGComponent {
|
|||
}
|
||||
if (remaining > 0) return false;
|
||||
|
||||
// Apply
|
||||
// Apply — fill existing slots for this item first, then empty ones
|
||||
remaining = amount;
|
||||
const slotIds: SlotId[] = [];
|
||||
for (const [id, slot] of this.slots) {
|
||||
|
|
@ -130,11 +131,6 @@ export class Inventory extends RPGComponent {
|
|||
}
|
||||
}
|
||||
|
||||
if (remaining === 0) {
|
||||
this.emit('remove', { itemId, amount, slotIds });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import { Component, type EvalContext } from "../core/world";
|
||||
import { isQuest, type Quest, type RPGVariables } from "../types";
|
||||
import { evaluateCondition, parseCondition } from "../utils/conditions";
|
||||
import { action } from "../utils/decorators";
|
||||
|
||||
export type QuestStatus = 'inactive' | 'active' | 'completed';
|
||||
|
||||
export interface QuestRuntimeState {
|
||||
status: QuestStatus;
|
||||
stageIndex: number;
|
||||
}
|
||||
|
||||
export interface QuestEntry {
|
||||
quest: Quest;
|
||||
state: QuestRuntimeState;
|
||||
}
|
||||
|
||||
export class QuestLog extends Component {
|
||||
readonly #quests = new Map<string, QuestEntry>();
|
||||
|
||||
addQuest(quest: Quest): void {
|
||||
if (!this.#quests.has(quest.id)) {
|
||||
this.#quests.set(quest.id, { quest, state: { status: 'inactive', stageIndex: 0 } });
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
start(questId: string): boolean {
|
||||
const entry = this.#quests.get(questId);
|
||||
if (!entry || entry.state.status !== 'inactive') return false;
|
||||
entry.state.status = 'active';
|
||||
entry.state.stageIndex = 0;
|
||||
this.emit('started', { questId });
|
||||
return true;
|
||||
}
|
||||
|
||||
getState(questId: string): QuestRuntimeState | undefined {
|
||||
return this.#quests.get(questId)?.state;
|
||||
}
|
||||
|
||||
isAvailable(questId: string, ctx: EvalContext): boolean {
|
||||
const entry = this.#quests.get(questId);
|
||||
if (!entry) return false;
|
||||
const { quest } = entry;
|
||||
if (!quest.conditions?.length) return true;
|
||||
return quest.conditions.every(c => evaluateCondition(parseCondition(c), ctx));
|
||||
}
|
||||
|
||||
/** @internal used by QuestSystem */
|
||||
entries(): IterableIterator<[string, QuestEntry]> {
|
||||
return this.#quests.entries();
|
||||
}
|
||||
|
||||
/** @internal called by QuestSystem after stage actions complete */
|
||||
_advance(questId: string): void {
|
||||
const entry = this.#quests.get(questId);
|
||||
if (!entry) return;
|
||||
const { quest, state } = entry;
|
||||
if (state.stageIndex + 1 < quest.stages.length) {
|
||||
state.stageIndex++;
|
||||
this.emit('stage', { questId, index: state.stageIndex, stage: quest.stages[state.stageIndex] });
|
||||
} else {
|
||||
state.status = 'completed';
|
||||
this.emit('completed', { questId });
|
||||
}
|
||||
}
|
||||
|
||||
override getVariables(): RPGVariables {
|
||||
const result: RPGVariables = {};
|
||||
for (const [questId, { state }] of this.#quests) {
|
||||
result[`${questId}.status`] = state.status;
|
||||
result[`${questId}.stage`] = state.stageIndex;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Quests {
|
||||
export function validate(quest: unknown, actions: string[]): string[] {
|
||||
if (!isQuest(quest)) return ['invalid quest structure'];
|
||||
|
||||
const errors: string[] = [];
|
||||
const actionSet = new Set(actions);
|
||||
|
||||
for (const stage of quest.stages) {
|
||||
for (const action of stage.actions) {
|
||||
if (!actionSet.has(action.type)) {
|
||||
errors.push(`stage '${stage.id}': unknown action type '${action.type}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function isValid(v: unknown, actions: string[]): v is Quest {
|
||||
return validate(v, actions).length === 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +1,51 @@
|
|||
import { action, variable } from "../decorators";
|
||||
import { RPGComponent } from "./entity";
|
||||
import { action, variable } from "../utils/decorators";
|
||||
import { Component } from "../core/world";
|
||||
|
||||
export class Stat extends RPGComponent {
|
||||
@variable private value: number;
|
||||
@variable private maxValue: number | undefined;
|
||||
export class Stat extends Component {
|
||||
@variable('.') private value: number;
|
||||
@variable private max: number | undefined;
|
||||
@variable private min: number | undefined;
|
||||
|
||||
constructor(value: number, maxValue?: number) {
|
||||
constructor(value: number, max?: number, min?: number) {
|
||||
super();
|
||||
this.value = value;
|
||||
this.maxValue = maxValue;
|
||||
this.max = max;
|
||||
this.min = min;
|
||||
}
|
||||
|
||||
@action
|
||||
update(amount: number) {
|
||||
this.set(this.value + amount);
|
||||
}
|
||||
|
||||
@action
|
||||
set(value: number) {
|
||||
const prev = this.value;
|
||||
this.value = Math.max(0, this.value + amount);
|
||||
if (this.maxValue != null) {
|
||||
this.value = Math.min(this.value, this.maxValue);
|
||||
this.value = value;
|
||||
if (this.min != null) {
|
||||
this.value = Math.max(this.min, this.value);
|
||||
}
|
||||
if (this.max != null) {
|
||||
this.value = Math.min(this.value, this.max);
|
||||
}
|
||||
if (prev !== this.value) {
|
||||
this.emit('update', { prev, value: this.value });
|
||||
this.emit('set', { prev, value: this.value });
|
||||
}
|
||||
}
|
||||
|
||||
get current(): number {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
export class Health extends Stat {
|
||||
constructor(value: number, max?: number, min = 0) {
|
||||
super(value, max, min);
|
||||
}
|
||||
|
||||
@action
|
||||
kill() {
|
||||
this.set(0);
|
||||
this.emit('killed');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import type { RPGVariables } from "../types";
|
||||
import { action } from "../decorators";
|
||||
import { RPGComponent } from "./entity";
|
||||
import { action } from "../utils/decorators";
|
||||
import { Component } from "../core/world";
|
||||
|
||||
interface Var {
|
||||
key: string;
|
||||
value: RPGVariables[string];
|
||||
}
|
||||
|
||||
export class Variables extends RPGComponent {
|
||||
export class Variables extends Component {
|
||||
private readonly variables: RPGVariables = {};
|
||||
|
||||
override getVariables() {
|
||||
|
|
@ -18,9 +18,7 @@ export class Variables extends RPGComponent {
|
|||
set({ key, value }: Var) {
|
||||
const prev = this.variables[key];
|
||||
this.variables[key] = value;
|
||||
|
||||
this.emit('set', { key, value, prev });
|
||||
|
||||
return this.variables;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators';
|
||||
import type { RPGActions, RPGVariables } from '../types';
|
||||
|
||||
type Class<T> = abstract new (...args: any[]) => T;
|
||||
type EventHandler = (data: unknown) => void;
|
||||
|
||||
export interface EvalContext {
|
||||
self: Entity;
|
||||
world: World;
|
||||
}
|
||||
|
||||
export abstract class Component {
|
||||
entity!: Entity;
|
||||
key!: string;
|
||||
|
||||
protected emit(event: string, data?: unknown): void {
|
||||
this.entity.emit(`${this.key}.${event}`, data);
|
||||
}
|
||||
|
||||
onAdd(): void {}
|
||||
onRemove(): void {}
|
||||
|
||||
getVariables(): RPGVariables {
|
||||
const meta = (this.constructor as Function)[Symbol.metadata];
|
||||
const keys = meta?.[VARIABLE_KEYS] as Map<string | symbol, string> | undefined;
|
||||
if (!keys) return {};
|
||||
const vars: RPGVariables = {};
|
||||
for (const [methodKey, exportName] of keys) {
|
||||
const v = (this as Record<string, unknown>)[String(methodKey)];
|
||||
if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') {
|
||||
vars[exportName] = v;
|
||||
}
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
getActions(): RPGActions {
|
||||
const meta = (this.constructor as Function)[Symbol.metadata];
|
||||
const keys = meta?.[ACTION_KEYS] as Set<string | symbol> | undefined;
|
||||
if (!keys) return {};
|
||||
const actions: RPGActions = {};
|
||||
for (const key of keys) {
|
||||
const fn = (this as Record<string, unknown>)[String(key)];
|
||||
if (typeof fn === 'function') {
|
||||
actions[String(key)] = fn.bind(this);
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class System {
|
||||
onAdd(_world: World): void {}
|
||||
onRemove(_world: World): void {}
|
||||
abstract update(world: World, dt: number): void;
|
||||
}
|
||||
|
||||
export class Entity {
|
||||
readonly #components = new Map<string, Component>();
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
private readonly world: World,
|
||||
) {}
|
||||
|
||||
add<T extends Component>(key: string, component: T): T {
|
||||
const existing = this.#components.get(key);
|
||||
if (existing) existing.onRemove();
|
||||
component.entity = this;
|
||||
component.key = key;
|
||||
this.#components.set(key, component);
|
||||
component.onAdd();
|
||||
return component;
|
||||
}
|
||||
|
||||
get<T extends Component>(key: string): T | undefined;
|
||||
get<T extends Component>(ctor: Class<T>): T | undefined;
|
||||
get<T extends Component>(ctor: Class<T>, key: string): T | undefined;
|
||||
get<T extends Component>(ctorOrKey: Class<T> | string, key?: string): T | undefined {
|
||||
if (typeof ctorOrKey === 'string') {
|
||||
return this.#components.get(ctorOrKey) as T | undefined;
|
||||
}
|
||||
if (key !== undefined) {
|
||||
const c = this.#components.get(key);
|
||||
return c instanceof ctorOrKey ? c as T : undefined;
|
||||
}
|
||||
for (const c of this.#components.values()) {
|
||||
if (c instanceof ctorOrKey) return c as T;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
has(key: string): boolean;
|
||||
has<T extends Component>(ctor: Class<T>): boolean;
|
||||
has<T extends Component>(ctor: Class<T>, key: string): boolean;
|
||||
has<T extends Component>(ctorOrKey: Class<T> | string, key?: string): boolean {
|
||||
if (typeof ctorOrKey === 'string') return this.#components.has(ctorOrKey);
|
||||
if (key !== undefined) return this.#components.get(key) instanceof ctorOrKey;
|
||||
for (const c of this.#components.values()) {
|
||||
if (c instanceof ctorOrKey) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
remove(key: string): void;
|
||||
remove<T extends Component>(ctor: Class<T>): void;
|
||||
remove<T extends Component>(ctor: Class<T>, key: string): void;
|
||||
remove<T extends Component>(ctorOrKey: Class<T> | string, key?: string): void {
|
||||
if (typeof ctorOrKey === 'string') {
|
||||
this.#removeByKey(ctorOrKey);
|
||||
return;
|
||||
}
|
||||
if (key !== undefined) {
|
||||
if (this.#components.get(key) instanceof ctorOrKey) this.#removeByKey(key);
|
||||
return;
|
||||
}
|
||||
for (const [k, c] of this.#components) {
|
||||
if (c instanceof ctorOrKey) { this.#removeByKey(k); return; }
|
||||
}
|
||||
}
|
||||
|
||||
#removeByKey(key: string): void {
|
||||
const c = this.#components.get(key);
|
||||
if (c) { c.onRemove(); this.#components.delete(key); }
|
||||
}
|
||||
|
||||
emit(event: string, data?: unknown): void {
|
||||
this.world.emit(this.id, event, data);
|
||||
}
|
||||
|
||||
emitGlobal(event: string, data?: unknown): void {
|
||||
this.world.emitGlobal(event, data);
|
||||
}
|
||||
|
||||
on(event: string, handler: EventHandler): () => void {
|
||||
return this.world.on(this.id, event, handler);
|
||||
}
|
||||
|
||||
off(event: string, handler: EventHandler): void {
|
||||
this.world.off(this.id, event, handler);
|
||||
}
|
||||
|
||||
once(event: string, handler: EventHandler): () => void {
|
||||
return this.world.once(this.id, event, handler);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.world.destroyEntity(this);
|
||||
}
|
||||
|
||||
/** @internal used by World.query and resolveVariables */
|
||||
[Symbol.iterator](): IterableIterator<[string, Component]> {
|
||||
return this.#components.entries();
|
||||
}
|
||||
|
||||
/** @internal called by World.destroyEntity */
|
||||
_destroy(): void {
|
||||
for (const c of this.#components.values()) c.onRemove();
|
||||
this.#components.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class World {
|
||||
/** World-level variables, accessible in conditions via the $. prefix */
|
||||
readonly globals: RPGVariables = {};
|
||||
|
||||
readonly #entities = new Map<string, Entity>();
|
||||
readonly #handlers = new Map<string, Set<EventHandler>>();
|
||||
readonly #globalHandlers = new Map<string, Set<EventHandler>>();
|
||||
readonly #systems: System[] = [];
|
||||
#entityCounter = 0;
|
||||
|
||||
createEntity(id?: string): Entity {
|
||||
const entityId = id ?? `entity_${++this.#entityCounter}`;
|
||||
if (this.#entities.has(entityId)) throw new Error(`Entity '${entityId}' already exists`);
|
||||
const entity = new Entity(entityId, this);
|
||||
this.#entities.set(entityId, entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
getEntity(id: string): Entity | undefined {
|
||||
return this.#entities.get(id);
|
||||
}
|
||||
|
||||
destroyEntity(entity: Entity): void {
|
||||
entity._destroy();
|
||||
this.#entities.delete(entity.id);
|
||||
for (const key of this.#handlers.keys()) {
|
||||
if (key.startsWith(`${entity.id}\0`)) this.#handlers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
*query<T extends Component>(ctor: Class<T>): Generator<[Entity, string, T]> {
|
||||
for (const entity of this.#entities.values()) {
|
||||
for (const [key, component] of entity) {
|
||||
if (component instanceof ctor) yield [entity, key, component as T];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addSystem(system: System): void {
|
||||
this.#systems.push(system);
|
||||
system.onAdd(this);
|
||||
}
|
||||
|
||||
removeSystem(system: System): void {
|
||||
const idx = this.#systems.indexOf(system);
|
||||
if (idx !== -1) { this.#systems.splice(idx, 1); system.onRemove(this); }
|
||||
}
|
||||
|
||||
tick(dt: number): void {
|
||||
for (const system of this.#systems) system.update(this, dt);
|
||||
}
|
||||
|
||||
emit(entityId: string, event: string, data?: unknown): void {
|
||||
this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h(data));
|
||||
}
|
||||
|
||||
emitGlobal(event: string, data?: unknown): void {
|
||||
this.#globalHandlers.get(event)?.forEach(h => h(data));
|
||||
}
|
||||
|
||||
on(event: string, handler: EventHandler): () => void;
|
||||
on(entityId: string, event: string, handler: EventHandler): () => void;
|
||||
on(arg1: string, arg2: EventHandler | string, arg3?: EventHandler): () => void {
|
||||
if (typeof arg2 === 'string') {
|
||||
return this.#addHandler(this.#handlers, `${arg1}\0${arg2}`, arg3!);
|
||||
}
|
||||
return this.#addHandler(this.#globalHandlers, arg1, arg2);
|
||||
}
|
||||
|
||||
off(event: string, handler: EventHandler): void;
|
||||
off(entityId: string, event: string, handler: EventHandler): void;
|
||||
off(arg1: string, arg2: EventHandler | string, arg3?: EventHandler): void {
|
||||
if (typeof arg2 === 'string') {
|
||||
this.#handlers.get(`${arg1}\0${arg2}`)?.delete(arg3!);
|
||||
} else {
|
||||
this.#globalHandlers.get(arg1)?.delete(arg2);
|
||||
}
|
||||
}
|
||||
|
||||
once(event: string, handler: EventHandler): () => void;
|
||||
once(entityId: string, event: string, handler: EventHandler): () => void;
|
||||
once(arg1: string, arg2: EventHandler | string, arg3?: EventHandler): () => void {
|
||||
if (typeof arg2 === 'string') {
|
||||
const wrapped: EventHandler = data => { unsub(); arg3!(data); };
|
||||
const unsub = this.on(arg1, arg2, wrapped);
|
||||
return unsub;
|
||||
}
|
||||
const h = arg2;
|
||||
const wrapped: EventHandler = data => { unsub(); h(data); };
|
||||
const unsub = this.on(arg1, wrapped);
|
||||
return unsub;
|
||||
}
|
||||
|
||||
#addHandler(map: Map<string, Set<EventHandler>>, key: string, handler: EventHandler): () => void {
|
||||
if (!map.has(key)) map.set(key, new Set());
|
||||
map.get(key)!.add(handler);
|
||||
return () => map.get(key)?.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
export function isEvalContext(v: unknown): v is EvalContext {
|
||||
return typeof v === 'object' && v != null
|
||||
&& (v as EvalContext).self instanceof Entity
|
||||
&& (v as EvalContext).world instanceof World;
|
||||
}
|
||||
|
|
@ -11,7 +11,10 @@ import {
|
|||
evaluateConditions,
|
||||
parseCondition,
|
||||
type ParsedCondition,
|
||||
} from "./conditions";
|
||||
} from "./utils/conditions";
|
||||
|
||||
import { isEvalContext, type EvalContext } from "./core/world";
|
||||
import { resolveVariables, executeAction } from "./utils/variables";
|
||||
|
||||
export interface DialogRuntimeOptions {
|
||||
variables: RPGVariables;
|
||||
|
|
@ -219,12 +222,25 @@ export class DialogEngine {
|
|||
|
||||
constructor(
|
||||
dialog: Dialog,
|
||||
private readonly options: DialogRuntimeOptions,
|
||||
private readonly options: DialogRuntimeOptions | EvalContext,
|
||||
) {
|
||||
this.nodeMap = new Map(dialog.nodes.map(n => [n.id, n]));
|
||||
this.startNodeId = dialog.startNodeId;
|
||||
}
|
||||
|
||||
private getVariables(): RPGVariables {
|
||||
if (isEvalContext(this.options)) return resolveVariables(this.options.self);
|
||||
return this.options.variables;
|
||||
}
|
||||
|
||||
private async runAction(action: { type: string; arg?: string | number | boolean }): Promise<void> {
|
||||
if (isEvalContext(this.options)) {
|
||||
await executeAction(action, this.options);
|
||||
} else {
|
||||
await this.options.actions[action.type]?.(action.arg);
|
||||
}
|
||||
}
|
||||
|
||||
async advance(nodeId?: string): Promise<DialogNodeView | null> {
|
||||
let targetId = nodeId ?? this.currentNode?.nextNodeId ?? (this.currentNode === null ? this.startNodeId : undefined);
|
||||
|
||||
|
|
@ -232,7 +248,8 @@ export class DialogEngine {
|
|||
const node = this.nodeMap.get(targetId);
|
||||
if (!node) return null;
|
||||
|
||||
if (!evaluateConditions(node.conditions ?? [], this.options.variables)) {
|
||||
const vars = this.getVariables();
|
||||
if (!evaluateConditions(node.conditions ?? [], vars)) {
|
||||
targetId = node.nextNodeId;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -240,7 +257,7 @@ export class DialogEngine {
|
|||
this.currentNode = node;
|
||||
|
||||
for (const action of node.actions ?? []) {
|
||||
await this.options.actions[action.type]?.(action.arg);
|
||||
await this.runAction(action);
|
||||
}
|
||||
|
||||
const choices = this.filterChoices(node.choices);
|
||||
|
|
@ -259,10 +276,11 @@ export class DialogEngine {
|
|||
|
||||
private filterChoices(choices?: DialogChoice[]): DialogChoice[] | undefined {
|
||||
if (!choices) return undefined;
|
||||
const vars = this.getVariables();
|
||||
return choices.filter(choice => {
|
||||
if (choice.nextNodeId === undefined) return true;
|
||||
const target = this.nodeMap.get(choice.nextNodeId);
|
||||
return target ? evaluateConditions(target.conditions ?? [], this.options.variables) : false;
|
||||
return target ? evaluateConditions(target.conditions ?? [], vars) : false;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
import { RPGComponent } from "./components/entity";
|
||||
import { evaluateCondition, parseCondition } from "./conditions";
|
||||
import { action, variable } from "./decorators";
|
||||
import {
|
||||
isQuest,
|
||||
type Quest,
|
||||
type QuestStage,
|
||||
type RPGActions,
|
||||
type RPGVariables,
|
||||
} from "./types";
|
||||
|
||||
export type QuestStatus = 'inactive' | 'active' | 'completed';
|
||||
|
||||
export interface QuestRuntimeOptions {
|
||||
getVariables(): RPGVariables;
|
||||
getActions(): RPGActions;
|
||||
}
|
||||
|
||||
export namespace Quests {
|
||||
export function validate(quest: unknown, actions: string[]): string[] {
|
||||
if (!isQuest(quest)) return ['invalid quest structure'];
|
||||
|
||||
const errors: string[] = [];
|
||||
const actionSet = new Set(actions);
|
||||
|
||||
for (const stage of quest.stages) {
|
||||
for (const action of stage.actions) {
|
||||
if (!actionSet.has(action.type)) {
|
||||
errors.push(`stage '${stage.id}': unknown action type '${action.type}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function isValid(v: unknown, actions: string[]): v is Quest {
|
||||
return validate(v, actions).length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class QuestEngine extends RPGComponent {
|
||||
@variable('status') private _status: QuestStatus = 'inactive';
|
||||
@variable('stage') private _stageIndex: number = 0;
|
||||
|
||||
constructor(
|
||||
private readonly quest: Quest,
|
||||
private readonly options: QuestRuntimeOptions,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.quest.id;
|
||||
}
|
||||
|
||||
private evaluateConditions(conditions: string[] | undefined) {
|
||||
const variables = this.options.getVariables();
|
||||
return (conditions ?? []).every(condition => {
|
||||
const parsed = parseCondition(condition);
|
||||
return evaluateCondition({ ...parsed, variable: this.resolve(parsed.variable) }, variables);
|
||||
});
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.evaluateConditions(this.quest.conditions);
|
||||
}
|
||||
|
||||
@action
|
||||
start(): void {
|
||||
this._status = 'active';
|
||||
this._stageIndex = 0;
|
||||
this.emit('started');
|
||||
}
|
||||
|
||||
@action
|
||||
async checkAndAdvance(): Promise<void> {
|
||||
if (this._status !== 'active') return;
|
||||
|
||||
const stage = this.quest.stages[this._stageIndex];
|
||||
if (!stage) return;
|
||||
|
||||
const allDone = this.evaluateConditions(stage.objectives.map(obj => obj.condition));
|
||||
|
||||
if (!allDone) return;
|
||||
|
||||
const actions = this.options.getActions();
|
||||
for (const action of stage.actions) {
|
||||
await actions[action.type]?.(action.arg);
|
||||
}
|
||||
|
||||
if (this._stageIndex + 1 < this.quest.stages.length) {
|
||||
this._stageIndex++;
|
||||
this.emit('stage', { index: this._stageIndex, stage: this.quest.stages[this._stageIndex] });
|
||||
} else {
|
||||
this._status = 'completed';
|
||||
this.emit('completed');
|
||||
}
|
||||
}
|
||||
|
||||
get currentStage(): QuestStage | null {
|
||||
return this.quest.stages[this._stageIndex] ?? null;
|
||||
}
|
||||
|
||||
get status(): QuestStatus {
|
||||
return this._status;
|
||||
}
|
||||
}
|
||||
|
||||
export class QuestManager extends RPGComponent {
|
||||
private readonly engines: Map<string, QuestEngine>;
|
||||
|
||||
constructor(quests: Quest[], options: QuestRuntimeOptions) {
|
||||
super();
|
||||
this.engines = new Map(quests.map(q => [q.id, new QuestEngine(q, options)]));
|
||||
}
|
||||
|
||||
protected override onAttach(): void {
|
||||
for (const [questId, engine] of this.engines) {
|
||||
engine.attach(this._ctx!, `${this.path}.${questId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
start(questId: string): void {
|
||||
this.engines.get(questId)?.start();
|
||||
}
|
||||
|
||||
@action
|
||||
async checkAndAdvance(): Promise<void> {
|
||||
for (const engine of this.engines.values()) {
|
||||
await engine.checkAndAdvance();
|
||||
}
|
||||
}
|
||||
|
||||
getEngine(questId: string): QuestEngine | undefined {
|
||||
return this.engines.get(questId);
|
||||
}
|
||||
|
||||
override getVariables(): RPGVariables {
|
||||
const result: RPGVariables = {};
|
||||
for (const [key, engine] of this.engines) {
|
||||
for (const [varKey, value] of Object.entries(engine.getVariables())) {
|
||||
if (value != null) {
|
||||
result[`${key}.${varKey}`] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { System, type Entity, type World } from "../core/world";
|
||||
import { evaluateCondition, parseCondition } from "../utils/conditions";
|
||||
import { executeAction } from "../utils/variables";
|
||||
import { QuestLog } from "../components/questLog";
|
||||
|
||||
export class QuestSystem extends System {
|
||||
override update(world: World, _dt: number): void {
|
||||
for (const [entity] of world.query(QuestLog)) {
|
||||
void this.#checkEntity(entity, world);
|
||||
}
|
||||
}
|
||||
|
||||
async triggerCheck(entity: Entity, world: World): Promise<void> {
|
||||
await this.#checkEntity(entity, world);
|
||||
}
|
||||
|
||||
async #checkEntity(entity: Entity, world: World): Promise<void> {
|
||||
const questLog = entity.get(QuestLog);
|
||||
if (!questLog) return;
|
||||
|
||||
const ctx = { self: entity, world };
|
||||
|
||||
for (const [questId, { quest, state }] of questLog.entries()) {
|
||||
if (state.status !== 'active') continue;
|
||||
|
||||
const stage = quest.stages[state.stageIndex];
|
||||
if (!stage) continue;
|
||||
|
||||
const allDone = stage.objectives.every(obj =>
|
||||
evaluateCondition(parseCondition(obj.condition), ctx)
|
||||
);
|
||||
if (!allDone) continue;
|
||||
|
||||
for (const action of stage.actions) {
|
||||
await executeAction(action, ctx);
|
||||
}
|
||||
|
||||
questLog._advance(questId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import type { RPGCondition, RPGVariables } from "./types";
|
||||
import type { RPGCondition, RPGVariables } from "../types";
|
||||
import type { EvalContext } from "../core/world";
|
||||
import { resolveVariable } from "./variables";
|
||||
|
||||
type ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'null' | '~null';
|
||||
type ConditionValue = string | number | boolean | null;
|
||||
|
|
@ -39,9 +41,7 @@ export function parseCondition(s: RPGCondition): ParsedCondition {
|
|||
return { variable, negate: false, operator: operator as ConditionOperator, value };
|
||||
}
|
||||
|
||||
export function evaluateCondition({ variable, negate, operator, value }: ParsedCondition, variables: RPGVariables): boolean {
|
||||
const val = variables[variable];
|
||||
|
||||
function evalParsed({ negate, operator, value }: ParsedCondition, val: RPGVariables[string]): boolean {
|
||||
if (operator === 'null') return val == null;
|
||||
if (operator === '~null') return val != null;
|
||||
|
||||
|
|
@ -60,6 +60,21 @@ export function evaluateCondition({ variable, negate, operator, value }: ParsedC
|
|||
return false;
|
||||
}
|
||||
|
||||
export function evaluateConditions(conditions: RPGCondition[], variables: RPGVariables): boolean {
|
||||
return conditions.every(c => evaluateCondition(parseCondition(c), variables));
|
||||
export function evaluateCondition(parsed: ParsedCondition, variables: RPGVariables): boolean;
|
||||
export function evaluateCondition(parsed: ParsedCondition, ctx: EvalContext): boolean;
|
||||
export function evaluateCondition(parsed: ParsedCondition, variablesOrCtx: RPGVariables | EvalContext): boolean {
|
||||
const val = isEvalContext(variablesOrCtx)
|
||||
? resolveVariable(parsed.variable, variablesOrCtx)
|
||||
: variablesOrCtx[parsed.variable];
|
||||
return evalParsed(parsed, val);
|
||||
}
|
||||
|
||||
export function evaluateConditions(conditions: RPGCondition[], variables: RPGVariables): boolean;
|
||||
export function evaluateConditions(conditions: RPGCondition[], ctx: EvalContext): boolean;
|
||||
export function evaluateConditions(conditions: RPGCondition[], variablesOrCtx: RPGVariables | EvalContext): boolean {
|
||||
return conditions.every(c => evaluateCondition(parseCondition(c), variablesOrCtx as RPGVariables));
|
||||
}
|
||||
|
||||
function isEvalContext(v: RPGVariables | EvalContext): v is EvalContext {
|
||||
return 'self' in v && 'world' in v;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { RPGVariables } from "./types";
|
||||
import type { RPGVariables } from "../types";
|
||||
|
||||
export const ACTION_KEYS = Symbol('rpg.actions');
|
||||
export const VARIABLE_KEYS = Symbol('rpg.variables');
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import type { RPGAction, RPGActions, RPGVariables } from "../types";
|
||||
import type { EvalContext, Entity } from "../core/world";
|
||||
|
||||
export function resolveVariables(entity: Entity): RPGVariables {
|
||||
const result: RPGVariables = {};
|
||||
for (const [key, component] of entity) {
|
||||
for (const [varKey, value] of Object.entries(component.getVariables())) {
|
||||
if (value != null) {
|
||||
if (varKey && varKey !== '.') {
|
||||
result[`${key}.${varKey}`] = value;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolveVariable(name: string, ctx: EvalContext): RPGVariables[string] {
|
||||
// $. prefix → world globals
|
||||
if (name.startsWith('$.')) {
|
||||
return ctx.world.globals[name.slice(2)];
|
||||
}
|
||||
// @entityId.component.variable → another entity
|
||||
if (name.startsWith('@')) {
|
||||
const dotIdx = name.indexOf('.', 1);
|
||||
if (dotIdx === -1) return undefined;
|
||||
const entityId = name.slice(1, dotIdx);
|
||||
const varName = name.slice(dotIdx + 1);
|
||||
const entity = ctx.world.getEntity(entityId);
|
||||
if (!entity) return undefined;
|
||||
return resolveVariables(entity)[varName];
|
||||
}
|
||||
// bare name → self entity
|
||||
return resolveVariables(ctx.self)[name];
|
||||
}
|
||||
|
||||
export function resolveActions(entity: Entity): RPGActions {
|
||||
const result: RPGActions = {};
|
||||
for (const [key, component] of entity) {
|
||||
for (const [actionKey, fn] of Object.entries(component.getActions())) {
|
||||
result[`${key}.${actionKey}`] = fn;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function executeAction(action: RPGAction, ctx: EvalContext): Promise<unknown> {
|
||||
let entity = ctx.self;
|
||||
let actionType = action.type;
|
||||
|
||||
// @entityId.component.action → dispatch to another entity
|
||||
if (action.type.startsWith('@')) {
|
||||
const dotIdx = action.type.indexOf('.', 1);
|
||||
if (dotIdx === -1) return;
|
||||
const entityId = action.type.slice(1, dotIdx);
|
||||
const found = ctx.world.getEntity(entityId);
|
||||
if (!found) return;
|
||||
entity = found;
|
||||
actionType = action.type.slice(dotIdx + 1);
|
||||
}
|
||||
|
||||
const actions = resolveActions(entity);
|
||||
return actions[actionType]?.(action.arg);
|
||||
}
|
||||
|
|
@ -1,40 +1,37 @@
|
|||
import { RPGEntity } from "@common/rpg/components/entity";
|
||||
import { World } from "@common/rpg/core/world";
|
||||
import { Inventory } from "@common/rpg/components/inventory";
|
||||
import { Stat } from "@common/rpg/components/stat";
|
||||
import { Health } from "@common/rpg/components/stat";
|
||||
import { Variables } from "@common/rpg/components/variables";
|
||||
import { QuestManager } from "@common/rpg/quest";
|
||||
import { QuestLog } from "@common/rpg/components/questLog";
|
||||
import { QuestSystem } from "@common/rpg/systems/questSystem";
|
||||
import { resolveVariables, resolveActions } from "@common/rpg/utils/variables";
|
||||
|
||||
export default async function main() {
|
||||
const game = new RPGEntity('game');
|
||||
const player = new RPGEntity('player');
|
||||
const inventory = new Inventory(['head', 'legs']);
|
||||
const quests = new QuestManager([{
|
||||
const world = new World();
|
||||
world.addSystem(new QuestSystem());
|
||||
|
||||
const player = world.createEntity('player');
|
||||
player.add('inventory', new Inventory(['head', 'legs']));
|
||||
player.add('health', new Health(100, 100));
|
||||
player.add('vars', new Variables());
|
||||
player.add('quests', new QuestLog());
|
||||
|
||||
const quests = player.get(QuestLog)!;
|
||||
quests.addQuest({
|
||||
id: 'test',
|
||||
description: 'Test quest',
|
||||
title: 'Test',
|
||||
stages: [],
|
||||
}], game);
|
||||
const vars = new Variables();
|
||||
});
|
||||
|
||||
game.addComponent('variables', vars);
|
||||
game.addComponent('player', player);
|
||||
game.addComponent('quests', quests);
|
||||
player.addComponent('inventory', inventory);
|
||||
player.addComponent('health', new Stat(100));
|
||||
console.log(game.getActions());
|
||||
console.log(resolveVariables(player));
|
||||
|
||||
inventory.add({
|
||||
itemId: 'helmet',
|
||||
amount: 1,
|
||||
slotId: 'head',
|
||||
});
|
||||
game.getActions()['player.inventory.addItem']({
|
||||
itemId: 'boots',
|
||||
amount: 2,
|
||||
});
|
||||
inventory.add({
|
||||
itemId: 'belt',
|
||||
amount: 1,
|
||||
});
|
||||
console.log(game.getVariables());
|
||||
}
|
||||
const inventory = player.get(Inventory)!;
|
||||
inventory.add({ itemId: 'helmet', amount: 1, slotId: 'head' });
|
||||
|
||||
const actions = resolveActions(player);
|
||||
actions['inventory.add']({ itemId: 'boots', amount: 2 });
|
||||
|
||||
console.log(actions);
|
||||
console.log(resolveVariables(player));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue