1
0
Fork 0
This commit is contained in:
Pabloader 2026-04-28 14:46:23 +00:00
parent 2d4a59f2b3
commit 0ad5c60cdd
7 changed files with 215 additions and 36 deletions

View File

@ -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<T = unknown> {
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<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;
@ -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<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;
}
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;
}
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,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<SlotId, SlotEntry>;
private readonly maxAmountPerItem: Record<string, number>;
@ -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 {

View File

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

View File

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

View File

@ -45,6 +45,7 @@ export namespace Dialogs {
actions.add(action.type);
}
}
return Array.from(actions);
}
export function validate(

View File

@ -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<string, QuestEngine>;
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();

View File

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