From 89651f6674a47d0c4f58693487a712cbba40a054 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Tue, 28 Apr 2026 18:52:02 +0000 Subject: [PATCH] ECS rewrite --- src/common/rpg/components/entity.ts | 211 ------------------ src/common/rpg/components/inventory.ts | 16 +- src/common/rpg/components/questLog.ts | 99 +++++++++ src/common/rpg/components/stat.ts | 48 +++- src/common/rpg/components/variables.ts | 8 +- src/common/rpg/core/world.ts | 267 +++++++++++++++++++++++ src/common/rpg/dialog.ts | 28 ++- src/common/rpg/quest.ts | 151 ------------- src/common/rpg/systems/questSystem.ts | 41 ++++ src/common/rpg/{ => utils}/conditions.ts | 27 ++- src/common/rpg/{ => utils}/decorators.ts | 2 +- src/common/rpg/utils/variables.ts | 66 ++++++ src/games/playground/index.tsx | 57 +++-- 13 files changed, 591 insertions(+), 430 deletions(-) delete mode 100644 src/common/rpg/components/entity.ts create mode 100644 src/common/rpg/components/questLog.ts create mode 100644 src/common/rpg/core/world.ts delete mode 100644 src/common/rpg/quest.ts create mode 100644 src/common/rpg/systems/questSystem.ts rename src/common/rpg/{ => utils}/conditions.ts (65%) rename src/common/rpg/{ => utils}/decorators.ts (97%) create mode 100644 src/common/rpg/utils/variables.ts diff --git a/src/common/rpg/components/entity.ts b/src/common/rpg/components/entity.ts deleted file mode 100644 index 2e6a079..0000000 --- a/src/common/rpg/components/entity.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { RPGActions, RPGVariables } from "../types"; -import { ACTION_KEYS, VARIABLE_KEYS } from "../decorators"; - -export interface RPGEvent { - 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(event: string, data?: T): void { - this._ctx?.dispatch(this.resolve(event), { target: this, data }); - } - - on(event: string, handler: (payload: RPGEvent) => void): () => void { - return this._ctx?.on(this.resolve(event), handler as (e: RPGEvent) => void) ?? (() => {}); - } - - off(event: string, handler: (payload: RPGEvent) => 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 | undefined; - if (!keys) return {}; - const vars: RPGVariables = {}; - for (const [methodKey, exportName] of keys) { - const k = String(methodKey); - const v = (this as Record)[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 | undefined; - if (!keys) return {}; - const actions: RPGActions = {}; - for (const key of keys) { - const k = String(key); - const fn = (this as Record)[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 void>>(); - private readonly wildcardHandlers = new Map void>>(); - private components = new Map(); - - 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(event: string, handler: (payload: RPGEvent) => 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(event: string, handler: (payload: RPGEvent) => 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(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; - } - } -} diff --git a/src/common/rpg/components/inventory.ts b/src/common/rpg/components/inventory.ts index 2033f57..7649354 100644 --- a/src/common/rpg/components/inventory.ts +++ b/src/common/rpg/components/inventory.ts @@ -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; private readonly maxAmountPerItem: Record; @@ -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; } diff --git a/src/common/rpg/components/questLog.ts b/src/common/rpg/components/questLog.ts new file mode 100644 index 0000000..21faf01 --- /dev/null +++ b/src/common/rpg/components/questLog.ts @@ -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(); + + 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; + } +} diff --git a/src/common/rpg/components/stat.ts b/src/common/rpg/components/stat.ts index 007a73d..8d0cab6 100644 --- a/src/common/rpg/components/stat.ts +++ b/src/common/rpg/components/stat.ts @@ -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'); + } +} \ No newline at end of file diff --git a/src/common/rpg/components/variables.ts b/src/common/rpg/components/variables.ts index 855167a..157cd79 100644 --- a/src/common/rpg/components/variables.ts +++ b/src/common/rpg/components/variables.ts @@ -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; } diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts new file mode 100644 index 0000000..ec69727 --- /dev/null +++ b/src/common/rpg/core/world.ts @@ -0,0 +1,267 @@ +import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators'; +import type { RPGActions, RPGVariables } from '../types'; + +type Class = 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 | undefined; + if (!keys) return {}; + const vars: RPGVariables = {}; + for (const [methodKey, exportName] of keys) { + const v = (this as Record)[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 | undefined; + if (!keys) return {}; + const actions: RPGActions = {}; + for (const key of keys) { + const fn = (this as Record)[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(); + + constructor( + readonly id: string, + private readonly world: World, + ) {} + + add(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(key: string): T | undefined; + get(ctor: Class): T | undefined; + get(ctor: Class, key: string): T | undefined; + get(ctorOrKey: Class | 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(ctor: Class): boolean; + has(ctor: Class, key: string): boolean; + has(ctorOrKey: Class | 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(ctor: Class): void; + remove(ctor: Class, key: string): void; + remove(ctorOrKey: Class | 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(); + readonly #handlers = new Map>(); + readonly #globalHandlers = new Map>(); + 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(ctor: Class): 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>, 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; +} diff --git a/src/common/rpg/dialog.ts b/src/common/rpg/dialog.ts index db129b1..c83df93 100644 --- a/src/common/rpg/dialog.ts +++ b/src/common/rpg/dialog.ts @@ -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 { + if (isEvalContext(this.options)) { + await executeAction(action, this.options); + } else { + await this.options.actions[action.type]?.(action.arg); + } + } + async advance(nodeId?: string): Promise { 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; }); } diff --git a/src/common/rpg/quest.ts b/src/common/rpg/quest.ts deleted file mode 100644 index 1513f9c..0000000 --- a/src/common/rpg/quest.ts +++ /dev/null @@ -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 { - 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; - - 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 { - 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; - } -} diff --git a/src/common/rpg/systems/questSystem.ts b/src/common/rpg/systems/questSystem.ts new file mode 100644 index 0000000..004a362 --- /dev/null +++ b/src/common/rpg/systems/questSystem.ts @@ -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 { + await this.#checkEntity(entity, world); + } + + async #checkEntity(entity: Entity, world: World): Promise { + 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); + } + } +} diff --git a/src/common/rpg/conditions.ts b/src/common/rpg/utils/conditions.ts similarity index 65% rename from src/common/rpg/conditions.ts rename to src/common/rpg/utils/conditions.ts index d1298c0..8bd4a0e 100644 --- a/src/common/rpg/conditions.ts +++ b/src/common/rpg/utils/conditions.ts @@ -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; } diff --git a/src/common/rpg/decorators.ts b/src/common/rpg/utils/decorators.ts similarity index 97% rename from src/common/rpg/decorators.ts rename to src/common/rpg/utils/decorators.ts index 386c007..0e4ee4d 100644 --- a/src/common/rpg/decorators.ts +++ b/src/common/rpg/utils/decorators.ts @@ -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'); diff --git a/src/common/rpg/utils/variables.ts b/src/common/rpg/utils/variables.ts new file mode 100644 index 0000000..f54a5d7 --- /dev/null +++ b/src/common/rpg/utils/variables.ts @@ -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 { + 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); +} diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index 3fb03cf..171c167 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -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()); -} \ No newline at end of file + 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)); +}