diff --git a/src/common/rpg/components/entity.ts b/src/common/rpg/components/entity.ts index 31ae24c..2e6a079 100644 --- a/src/common/rpg/components/entity.ts +++ b/src/common/rpg/components/entity.ts @@ -1,12 +1,61 @@ import type { RPGActions, RPGVariables } from "../types"; import { ACTION_KEYS, VARIABLE_KEYS } from "../decorators"; -export interface RPGComponent { - getVariables: () => RPGVariables; - getActions: () => RPGActions; +export interface RPGEvent { + target: RPGComponent; + data?: T; } -export abstract class RPGComponentBase implements RPGComponent { +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; @@ -40,24 +89,92 @@ export abstract class RPGComponentBase implements RPGComponent { } return actions; } + + tick(_dt: number) {} } -export class RPGEntity implements RPGComponent { +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 { - this.components.delete(id); + 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; } - getVariables(): RPGVariables { + override getVariables(): RPGVariables { const variables: RPGVariables = {}; for (const [componentKey, component] of this.components) { for (const [key, value] of Object.entries(component.getVariables())) { @@ -69,7 +186,7 @@ export class RPGEntity implements RPGComponent { return variables; } - getActions() { + override getActions(): RPGActions { const actions: RPGActions = {}; for (const [componentKey, component] of this.components) { for (const [key, action] of Object.entries(component.getActions())) { @@ -78,4 +195,17 @@ export class RPGEntity implements RPGComponent { } return actions; } -} \ No newline at end of file + + 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 9e30421..2033f57 100644 --- a/src/common/rpg/components/inventory.ts +++ b/src/common/rpg/components/inventory.ts @@ -1,6 +1,6 @@ import type { InventoryOptions, InventorySlotInput, SlotId } from "../types"; import { action } from "../decorators"; -import { RPGComponentBase } from "./entity"; +import { RPGComponent } from "./entity"; interface SlotEntry { readonly slotId: SlotId; @@ -13,7 +13,7 @@ interface SlotUpdateArgs { slotId?: SlotId; } -export class Inventory extends RPGComponentBase { +export class Inventory extends RPGComponent { private readonly slots: Map; private readonly maxAmountPerItem: Record; @@ -43,7 +43,7 @@ export class Inventory extends RPGComponentBase { } @action - addItem({ itemId, amount, slotId }: SlotUpdateArgs): boolean { + add({ itemId, amount, slotId }: SlotUpdateArgs): boolean { if (amount < 0) return false; if (amount === 0) return true; @@ -52,6 +52,7 @@ export class Inventory extends RPGComponentBase { if (!slot) return false; if (this.slotRoomFor(slot, itemId) < amount) return false; slot.state = { itemId, amount: (slot.state?.amount ?? 0) + amount }; + this.emit('add', { itemId, amount, slotIds: [slotId] }); return true; } @@ -67,20 +68,29 @@ export class Inventory extends RPGComponentBase { // Apply remaining = amount; - for (const slot of this.slots.values()) { + const slotIds: SlotId[] = []; + for (const [id, slot] of this.slots) { if (slot.state?.itemId === itemId) { const take = Math.min(this.slotRoomFor(slot, itemId), remaining); slot.state.amount += take; remaining -= take; - if (remaining === 0) return true; + slotIds.push(id); + if (remaining === 0) { + this.emit('add', { itemId, amount, slotIds }); + return true; + } } } - for (const slot of this.slots.values()) { + for (const [id, slot] of this.slots) { if (slot.state === null) { const take = Math.min(this.slotCapFor(slot, itemId), remaining); slot.state = { itemId, amount: take }; remaining -= take; - if (remaining === 0) return true; + slotIds.push(id); + if (remaining === 0) { + this.emit('add', { itemId, amount, slotIds }); + return true; + } } } @@ -88,7 +98,7 @@ export class Inventory extends RPGComponentBase { } @action - removeItem({ itemId, amount, slotId }: SlotUpdateArgs): boolean { + remove({ itemId, amount, slotId }: SlotUpdateArgs): boolean { if (amount < 0) return false; if (amount === 0) return true; @@ -98,23 +108,34 @@ export class Inventory extends RPGComponentBase { if (slot.state.amount < amount) return false; slot.state.amount -= amount; if (slot.state.amount === 0) slot.state = null; + this.emit('remove', { itemId, amount, slotIds: [slotId] }); return true; } if (this.getAmount(itemId) < amount) return false; let remaining = amount; - for (const slot of this.slots.values()) { + const slotIds: SlotId[] = []; + for (const [id, slot] of this.slots) { if (slot.state?.itemId === itemId) { const take = Math.min(slot.state.amount, remaining); slot.state.amount -= take; if (slot.state.amount === 0) slot.state = null; remaining -= take; - if (remaining === 0) return true; + slotIds.push(id); + if (remaining === 0) { + this.emit('remove', { itemId, amount, slotIds }); + return true; + } } } - return remaining === 0; + if (remaining === 0) { + this.emit('remove', { itemId, amount, slotIds }); + return true; + } + + return false; } getAmount(itemId: string, slotId?: SlotId): number { diff --git a/src/common/rpg/components/stat.ts b/src/common/rpg/components/stat.ts index 116e01d..007a73d 100644 --- a/src/common/rpg/components/stat.ts +++ b/src/common/rpg/components/stat.ts @@ -1,7 +1,7 @@ import { action, variable } from "../decorators"; -import { RPGComponentBase } from "./entity"; +import { RPGComponent } from "./entity"; -export class Stat extends RPGComponentBase { +export class Stat extends RPGComponent { @variable private value: number; @variable private maxValue: number | undefined; @@ -13,9 +13,13 @@ export class Stat extends RPGComponentBase { @action update(amount: number) { + const prev = this.value; this.value = Math.max(0, this.value + amount); - if (this.maxValue) { + if (this.maxValue != null) { this.value = Math.min(this.value, this.maxValue); } + if (prev !== this.value) { + this.emit('update', { prev, value: this.value }); + } } } diff --git a/src/common/rpg/components/variables.ts b/src/common/rpg/components/variables.ts index 308f605..855167a 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 { RPGComponentBase } from "./entity"; +import { RPGComponent } from "./entity"; interface Var { key: string; value: RPGVariables[string]; } -export class Variables extends RPGComponentBase { +export class Variables extends RPGComponent { private readonly variables: RPGVariables = {}; override getVariables() { @@ -16,13 +16,19 @@ export class Variables extends RPGComponentBase { @action set({ key, value }: Var) { + const prev = this.variables[key]; this.variables[key] = value; + + this.emit('set', { key, value, prev }); + return this.variables; } @action unset(key: string) { + const prev = this.variables[key]; delete this.variables[key]; + this.emit('unset', { key, prev }); return this.variables; } @@ -30,7 +36,7 @@ export class Variables extends RPGComponentBase { increment({ key, value }: Var) { const currentValue = this.variables[key] ?? 0; if (typeof currentValue === 'number' && typeof value === 'number') { - this.variables[key] = currentValue + value; + this.set({ key, value: currentValue + value }); } return this.variables; } diff --git a/src/common/rpg/dialog.ts b/src/common/rpg/dialog.ts index db080e1..db129b1 100644 --- a/src/common/rpg/dialog.ts +++ b/src/common/rpg/dialog.ts @@ -45,6 +45,7 @@ export namespace Dialogs { actions.add(action.type); } } + return Array.from(actions); } export function validate( diff --git a/src/common/rpg/quest.ts b/src/common/rpg/quest.ts index 70e5b98..1513f9c 100644 --- a/src/common/rpg/quest.ts +++ b/src/common/rpg/quest.ts @@ -1,5 +1,5 @@ -import { RPGComponentBase } from "./components/entity"; -import { evaluateConditions } from "./conditions"; +import { RPGComponent } from "./components/entity"; +import { evaluateCondition, parseCondition } from "./conditions"; import { action, variable } from "./decorators"; import { isQuest, @@ -39,7 +39,7 @@ export namespace Quests { } } -export class QuestEngine extends RPGComponentBase { +export class QuestEngine extends RPGComponent { @variable('status') private _status: QuestStatus = 'inactive'; @variable('stage') private _stageIndex: number = 0; @@ -54,14 +54,23 @@ export class QuestEngine extends RPGComponentBase { 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 evaluateConditions(this.quest.conditions ?? [], this.options.getVariables()); + return this.evaluateConditions(this.quest.conditions); } @action start(): void { this._status = 'active'; this._stageIndex = 0; + this.emit('started'); } @action @@ -71,7 +80,7 @@ export class QuestEngine extends RPGComponentBase { const stage = this.quest.stages[this._stageIndex]; if (!stage) return; - const allDone = evaluateConditions(stage.objectives.map(obj => obj.condition), this.options.getVariables()); + const allDone = this.evaluateConditions(stage.objectives.map(obj => obj.condition)); if (!allDone) return; @@ -82,8 +91,10 @@ export class QuestEngine extends RPGComponentBase { 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'); } } @@ -96,7 +107,7 @@ export class QuestEngine extends RPGComponentBase { } } -export class QuestManager extends RPGComponentBase { +export class QuestManager extends RPGComponent { private readonly engines: Map; constructor(quests: Quest[], options: QuestRuntimeOptions) { @@ -104,6 +115,12 @@ export class QuestManager extends RPGComponentBase { 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(); diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index 402d7a4..3fb03cf 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -5,8 +5,8 @@ import { Variables } from "@common/rpg/components/variables"; import { QuestManager } from "@common/rpg/quest"; export default async function main() { - const game = new RPGEntity(); - const player = new RPGEntity(); + const game = new RPGEntity('game'); + const player = new RPGEntity('player'); const inventory = new Inventory(['head', 'legs']); const quests = new QuestManager([{ id: 'test', @@ -23,7 +23,7 @@ export default async function main() { player.addComponent('health', new Stat(100)); console.log(game.getActions()); - inventory.addItem({ + inventory.add({ itemId: 'helmet', amount: 1, slotId: 'head', @@ -32,7 +32,7 @@ export default async function main() { itemId: 'boots', amount: 2, }); - inventory.addItem({ + inventory.add({ itemId: 'belt', amount: 1, });