1
0
Fork 0

ECS rewrite

This commit is contained in:
Pabloader 2026-04-28 18:52:02 +00:00
parent 0ad5c60cdd
commit 89651f6674
13 changed files with 591 additions and 430 deletions

View File

@ -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;
}
}
}

View File

@ -1,19 +1,20 @@
import type { InventoryOptions, InventorySlotInput, SlotId } from "../types"; import type { InventoryOptions, InventorySlotInput, SlotId } from "../types";
import { action } from "../decorators"; import { action } from "../utils/decorators";
import { RPGComponent } from "./entity"; import { Component } from "../core/world";
interface SlotEntry { interface SlotEntry {
readonly slotId: SlotId; readonly slotId: SlotId;
readonly limit: number | undefined; readonly limit: number | undefined;
state: { itemId: string; amount: number } | null; state: { itemId: string; amount: number } | null;
} }
interface SlotUpdateArgs { interface SlotUpdateArgs {
itemId: string; itemId: string;
amount: number; amount: number;
slotId?: SlotId; slotId?: SlotId;
} }
export class Inventory extends RPGComponent { export class Inventory extends Component {
private readonly slots: Map<SlotId, SlotEntry>; private readonly slots: Map<SlotId, SlotEntry>;
private readonly maxAmountPerItem: Record<string, number>; private readonly maxAmountPerItem: Record<string, number>;
@ -36,7 +37,7 @@ export class Inventory extends RPGComponent {
return Math.min(limitCap, itemCap); 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 { private slotRoomFor(slot: SlotEntry, itemId: string): number {
if (slot.state !== null && slot.state.itemId !== itemId) return 0; if (slot.state !== null && slot.state.itemId !== itemId) return 0;
return this.slotCapFor(slot, itemId) - (slot.state?.amount ?? 0); return this.slotCapFor(slot, itemId) - (slot.state?.amount ?? 0);
@ -66,7 +67,7 @@ export class Inventory extends RPGComponent {
} }
if (remaining > 0) return false; if (remaining > 0) return false;
// Apply // Apply — fill existing slots for this item first, then empty ones
remaining = amount; remaining = amount;
const slotIds: SlotId[] = []; const slotIds: SlotId[] = [];
for (const [id, slot] of this.slots) { 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; return false;
} }

View File

@ -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;
}
}

View File

@ -1,25 +1,51 @@
import { action, variable } from "../decorators"; import { action, variable } from "../utils/decorators";
import { RPGComponent } from "./entity"; import { Component } from "../core/world";
export class Stat extends RPGComponent { export class Stat extends Component {
@variable private value: number; @variable('.') private value: number;
@variable private maxValue: number | undefined; @variable private max: number | undefined;
@variable private min: number | undefined;
constructor(value: number, maxValue?: number) { constructor(value: number, max?: number, min?: number) {
super(); super();
this.value = value; this.value = value;
this.maxValue = maxValue; this.max = max;
this.min = min;
} }
@action @action
update(amount: number) { update(amount: number) {
this.set(this.value + amount);
}
@action
set(value: number) {
const prev = this.value; const prev = this.value;
this.value = Math.max(0, this.value + amount); this.value = value;
if (this.maxValue != null) { if (this.min != null) {
this.value = Math.min(this.value, this.maxValue); this.value = Math.max(this.min, this.value);
}
if (this.max != null) {
this.value = Math.min(this.value, this.max);
} }
if (prev !== this.value) { 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');
}
} }

View File

@ -1,13 +1,13 @@
import type { RPGVariables } from "../types"; import type { RPGVariables } from "../types";
import { action } from "../decorators"; import { action } from "../utils/decorators";
import { RPGComponent } from "./entity"; import { Component } from "../core/world";
interface Var { interface Var {
key: string; key: string;
value: RPGVariables[string]; value: RPGVariables[string];
} }
export class Variables extends RPGComponent { export class Variables extends Component {
private readonly variables: RPGVariables = {}; private readonly variables: RPGVariables = {};
override getVariables() { override getVariables() {
@ -18,9 +18,7 @@ export class Variables extends RPGComponent {
set({ key, value }: Var) { set({ key, value }: Var) {
const prev = this.variables[key]; const prev = this.variables[key];
this.variables[key] = value; this.variables[key] = value;
this.emit('set', { key, value, prev }); this.emit('set', { key, value, prev });
return this.variables; return this.variables;
} }

View File

@ -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;
}

View File

@ -11,7 +11,10 @@ import {
evaluateConditions, evaluateConditions,
parseCondition, parseCondition,
type ParsedCondition, type ParsedCondition,
} from "./conditions"; } from "./utils/conditions";
import { isEvalContext, type EvalContext } from "./core/world";
import { resolveVariables, executeAction } from "./utils/variables";
export interface DialogRuntimeOptions { export interface DialogRuntimeOptions {
variables: RPGVariables; variables: RPGVariables;
@ -219,12 +222,25 @@ export class DialogEngine {
constructor( constructor(
dialog: Dialog, dialog: Dialog,
private readonly options: DialogRuntimeOptions, private readonly options: DialogRuntimeOptions | EvalContext,
) { ) {
this.nodeMap = new Map(dialog.nodes.map(n => [n.id, n])); this.nodeMap = new Map(dialog.nodes.map(n => [n.id, n]));
this.startNodeId = dialog.startNodeId; 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> { async advance(nodeId?: string): Promise<DialogNodeView | null> {
let targetId = nodeId ?? this.currentNode?.nextNodeId ?? (this.currentNode === null ? this.startNodeId : undefined); 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); const node = this.nodeMap.get(targetId);
if (!node) return null; if (!node) return null;
if (!evaluateConditions(node.conditions ?? [], this.options.variables)) { const vars = this.getVariables();
if (!evaluateConditions(node.conditions ?? [], vars)) {
targetId = node.nextNodeId; targetId = node.nextNodeId;
continue; continue;
} }
@ -240,7 +257,7 @@ export class DialogEngine {
this.currentNode = node; this.currentNode = node;
for (const action of node.actions ?? []) { for (const action of node.actions ?? []) {
await this.options.actions[action.type]?.(action.arg); await this.runAction(action);
} }
const choices = this.filterChoices(node.choices); const choices = this.filterChoices(node.choices);
@ -259,10 +276,11 @@ export class DialogEngine {
private filterChoices(choices?: DialogChoice[]): DialogChoice[] | undefined { private filterChoices(choices?: DialogChoice[]): DialogChoice[] | undefined {
if (!choices) return undefined; if (!choices) return undefined;
const vars = this.getVariables();
return choices.filter(choice => { return choices.filter(choice => {
if (choice.nextNodeId === undefined) return true; if (choice.nextNodeId === undefined) return true;
const target = this.nodeMap.get(choice.nextNodeId); const target = this.nodeMap.get(choice.nextNodeId);
return target ? evaluateConditions(target.conditions ?? [], this.options.variables) : false; return target ? evaluateConditions(target.conditions ?? [], vars) : false;
}); });
} }

View File

@ -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;
}
}

View File

@ -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);
}
}
}

View File

@ -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 ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'null' | '~null';
type ConditionValue = string | number | boolean | 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 }; return { variable, negate: false, operator: operator as ConditionOperator, value };
} }
export function evaluateCondition({ variable, negate, operator, value }: ParsedCondition, variables: RPGVariables): boolean { function evalParsed({ negate, operator, value }: ParsedCondition, val: RPGVariables[string]): boolean {
const val = variables[variable];
if (operator === 'null') return val == null; if (operator === 'null') return val == null;
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; return false;
} }
export function evaluateConditions(conditions: RPGCondition[], variables: RPGVariables): boolean { export function evaluateCondition(parsed: ParsedCondition, variables: RPGVariables): boolean;
return conditions.every(c => evaluateCondition(parseCondition(c), variables)); 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;
} }

View File

@ -1,4 +1,4 @@
import type { RPGVariables } from "./types"; import type { RPGVariables } from "../types";
export const ACTION_KEYS = Symbol('rpg.actions'); export const ACTION_KEYS = Symbol('rpg.actions');
export const VARIABLE_KEYS = Symbol('rpg.variables'); export const VARIABLE_KEYS = Symbol('rpg.variables');

View File

@ -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);
}

View File

@ -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 { 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 { 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() { export default async function main() {
const game = new RPGEntity('game'); const world = new World();
const player = new RPGEntity('player'); world.addSystem(new QuestSystem());
const inventory = new Inventory(['head', 'legs']);
const quests = new QuestManager([{ 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', id: 'test',
description: 'Test quest', description: 'Test quest',
title: 'Test', title: 'Test',
stages: [], stages: [],
}], game); });
const vars = new Variables();
game.addComponent('variables', vars); console.log(resolveVariables(player));
game.addComponent('player', player);
game.addComponent('quests', quests);
player.addComponent('inventory', inventory);
player.addComponent('health', new Stat(100));
console.log(game.getActions());
inventory.add({ const inventory = player.get(Inventory)!;
itemId: 'helmet', inventory.add({ itemId: 'helmet', amount: 1, slotId: 'head' });
amount: 1,
slotId: 'head', const actions = resolveActions(player);
}); actions['inventory.add']({ itemId: 'boots', amount: 2 });
game.getActions()['player.inventory.addItem']({
itemId: 'boots', console.log(actions);
amount: 2, console.log(resolveVariables(player));
});
inventory.add({
itemId: 'belt',
amount: 1,
});
console.log(game.getVariables());
} }