Compare commits
No commits in common. "89651f6674a47d0c4f58693487a712cbba40a054" and "ab68a5ffc694b3aaaa65a5a76804b4bd519df9b2" have entirely different histories.
89651f6674
...
ab68a5ffc6
|
|
@ -0,0 +1,74 @@
|
|||
import type { RPGActions, RPGVariables } from "../types";
|
||||
import { ACTION_KEYS, VARIABLE_KEYS } from "../decorators";
|
||||
|
||||
export interface RPGComponent {
|
||||
getVariables: () => RPGVariables;
|
||||
getActions: () => RPGActions;
|
||||
}
|
||||
|
||||
export abstract class RPGComponentBase implements RPGComponent {
|
||||
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 unknown as Record<string, RPGVariables[string]>)[k];
|
||||
if (v != null) {
|
||||
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);
|
||||
actions[k] = (arg?: unknown) => (this as unknown as Record<string, (a?: unknown) => unknown>)[k](arg);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
|
||||
export class RPGEntity implements RPGComponent {
|
||||
private components = new Map<string, RPGComponent>();
|
||||
|
||||
addComponent(id: string, component: RPGComponent): void {
|
||||
this.components.set(id, component);
|
||||
}
|
||||
|
||||
removeComponent(id: string): void {
|
||||
this.components.delete(id);
|
||||
}
|
||||
|
||||
getComponent<T extends RPGComponent>(id: string): T | undefined {
|
||||
return this.components.get(id) as T | undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
getActions() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,19 @@
|
|||
import type { InventoryOptions, InventorySlotInput, SlotId } from "../types";
|
||||
import { action } from "../utils/decorators";
|
||||
import { Component } from "../core/world";
|
||||
import { action } from "../decorators";
|
||||
import { RPGComponentBase } from "./entity";
|
||||
|
||||
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 Component {
|
||||
export class Inventory extends RPGComponentBase {
|
||||
private readonly slots: Map<SlotId, SlotEntry>;
|
||||
private readonly maxAmountPerItem: Record<string, number>;
|
||||
|
||||
|
|
@ -37,14 +36,14 @@ export class Inventory extends Component {
|
|||
return Math.min(limitCap, itemCap);
|
||||
}
|
||||
|
||||
// Remaining space in slot for itemId
|
||||
// Remaining space in slot for itemId (min of slotCap and remaining amount)
|
||||
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);
|
||||
}
|
||||
|
||||
@action
|
||||
add({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
|
||||
addItem({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
|
||||
if (amount < 0) return false;
|
||||
if (amount === 0) return true;
|
||||
|
||||
|
|
@ -53,7 +52,6 @@ export class Inventory extends Component {
|
|||
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,31 +65,22 @@ export class Inventory extends Component {
|
|||
}
|
||||
if (remaining > 0) return false;
|
||||
|
||||
// Apply — fill existing slots for this item first, then empty ones
|
||||
// Apply
|
||||
remaining = amount;
|
||||
const slotIds: SlotId[] = [];
|
||||
for (const [id, slot] of this.slots) {
|
||||
for (const slot of this.slots.values()) {
|
||||
if (slot.state?.itemId === itemId) {
|
||||
const take = Math.min(this.slotRoomFor(slot, itemId), remaining);
|
||||
slot.state.amount += take;
|
||||
remaining -= take;
|
||||
slotIds.push(id);
|
||||
if (remaining === 0) {
|
||||
this.emit('add', { itemId, amount, slotIds });
|
||||
return true;
|
||||
if (remaining === 0) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [id, slot] of this.slots) {
|
||||
for (const slot of this.slots.values()) {
|
||||
if (slot.state === null) {
|
||||
const take = Math.min(this.slotCapFor(slot, itemId), remaining);
|
||||
slot.state = { itemId, amount: take };
|
||||
remaining -= take;
|
||||
slotIds.push(id);
|
||||
if (remaining === 0) {
|
||||
this.emit('add', { itemId, amount, slotIds });
|
||||
return true;
|
||||
}
|
||||
if (remaining === 0) return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +88,7 @@ export class Inventory extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
remove({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
|
||||
removeItem({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
|
||||
if (amount < 0) return false;
|
||||
if (amount === 0) return true;
|
||||
|
||||
|
|
@ -109,29 +98,23 @@ export class Inventory extends Component {
|
|||
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;
|
||||
const slotIds: SlotId[] = [];
|
||||
for (const [id, slot] of this.slots) {
|
||||
for (const slot of this.slots.values()) {
|
||||
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;
|
||||
slotIds.push(id);
|
||||
if (remaining === 0) {
|
||||
this.emit('remove', { itemId, amount, slotIds });
|
||||
return true;
|
||||
}
|
||||
if (remaining === 0) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return remaining === 0;
|
||||
}
|
||||
|
||||
getAmount(itemId: string, slotId?: SlotId): number {
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
import { Component, type EvalContext } from "../core/world";
|
||||
import { isQuest, type Quest, type RPGVariables } from "../types";
|
||||
import { evaluateCondition, parseCondition } from "../utils/conditions";
|
||||
import { action } from "../utils/decorators";
|
||||
|
||||
export type QuestStatus = 'inactive' | 'active' | 'completed';
|
||||
|
||||
export interface QuestRuntimeState {
|
||||
status: QuestStatus;
|
||||
stageIndex: number;
|
||||
}
|
||||
|
||||
export interface QuestEntry {
|
||||
quest: Quest;
|
||||
state: QuestRuntimeState;
|
||||
}
|
||||
|
||||
export class QuestLog extends Component {
|
||||
readonly #quests = new Map<string, QuestEntry>();
|
||||
|
||||
addQuest(quest: Quest): void {
|
||||
if (!this.#quests.has(quest.id)) {
|
||||
this.#quests.set(quest.id, { quest, state: { status: 'inactive', stageIndex: 0 } });
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
start(questId: string): boolean {
|
||||
const entry = this.#quests.get(questId);
|
||||
if (!entry || entry.state.status !== 'inactive') return false;
|
||||
entry.state.status = 'active';
|
||||
entry.state.stageIndex = 0;
|
||||
this.emit('started', { questId });
|
||||
return true;
|
||||
}
|
||||
|
||||
getState(questId: string): QuestRuntimeState | undefined {
|
||||
return this.#quests.get(questId)?.state;
|
||||
}
|
||||
|
||||
isAvailable(questId: string, ctx: EvalContext): boolean {
|
||||
const entry = this.#quests.get(questId);
|
||||
if (!entry) return false;
|
||||
const { quest } = entry;
|
||||
if (!quest.conditions?.length) return true;
|
||||
return quest.conditions.every(c => evaluateCondition(parseCondition(c), ctx));
|
||||
}
|
||||
|
||||
/** @internal used by QuestSystem */
|
||||
entries(): IterableIterator<[string, QuestEntry]> {
|
||||
return this.#quests.entries();
|
||||
}
|
||||
|
||||
/** @internal called by QuestSystem after stage actions complete */
|
||||
_advance(questId: string): void {
|
||||
const entry = this.#quests.get(questId);
|
||||
if (!entry) return;
|
||||
const { quest, state } = entry;
|
||||
if (state.stageIndex + 1 < quest.stages.length) {
|
||||
state.stageIndex++;
|
||||
this.emit('stage', { questId, index: state.stageIndex, stage: quest.stages[state.stageIndex] });
|
||||
} else {
|
||||
state.status = 'completed';
|
||||
this.emit('completed', { questId });
|
||||
}
|
||||
}
|
||||
|
||||
override getVariables(): RPGVariables {
|
||||
const result: RPGVariables = {};
|
||||
for (const [questId, { state }] of this.#quests) {
|
||||
result[`${questId}.status`] = state.status;
|
||||
result[`${questId}.stage`] = state.stageIndex;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Quests {
|
||||
export function validate(quest: unknown, actions: string[]): string[] {
|
||||
if (!isQuest(quest)) return ['invalid quest structure'];
|
||||
|
||||
const errors: string[] = [];
|
||||
const actionSet = new Set(actions);
|
||||
|
||||
for (const stage of quest.stages) {
|
||||
for (const action of stage.actions) {
|
||||
if (!actionSet.has(action.type)) {
|
||||
errors.push(`stage '${stage.id}': unknown action type '${action.type}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function isValid(v: unknown, actions: string[]): v is Quest {
|
||||
return validate(v, actions).length === 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +1,21 @@
|
|||
import { action, variable } from "../utils/decorators";
|
||||
import { Component } from "../core/world";
|
||||
import { action, variable } from "../decorators";
|
||||
import { RPGComponentBase } from "./entity";
|
||||
|
||||
export class Stat extends Component {
|
||||
@variable('.') private value: number;
|
||||
@variable private max: number | undefined;
|
||||
@variable private min: number | undefined;
|
||||
export class Stat extends RPGComponentBase {
|
||||
@variable private value: number;
|
||||
@variable private maxValue: number | undefined;
|
||||
|
||||
constructor(value: number, max?: number, min?: number) {
|
||||
constructor(value: number, maxValue?: number) {
|
||||
super();
|
||||
this.value = value;
|
||||
this.max = max;
|
||||
this.min = min;
|
||||
this.maxValue = maxValue;
|
||||
}
|
||||
|
||||
@action
|
||||
update(amount: number) {
|
||||
this.set(this.value + amount);
|
||||
}
|
||||
|
||||
@action
|
||||
set(value: number) {
|
||||
const prev = this.value;
|
||||
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('set', { prev, value: this.value });
|
||||
this.value = Math.max(0, this.value + amount);
|
||||
if (this.maxValue) {
|
||||
this.value = Math.min(this.value, this.maxValue);
|
||||
}
|
||||
}
|
||||
|
||||
get current(): number {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
export class Health extends Stat {
|
||||
constructor(value: number, max?: number, min = 0) {
|
||||
super(value, max, min);
|
||||
}
|
||||
|
||||
@action
|
||||
kill() {
|
||||
this.set(0);
|
||||
this.emit('killed');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import type { RPGVariables } from "../types";
|
||||
import { action } from "../utils/decorators";
|
||||
import { Component } from "../core/world";
|
||||
import { action } from "../decorators";
|
||||
import { RPGComponentBase } from "./entity";
|
||||
|
||||
interface Var {
|
||||
key: string;
|
||||
value: RPGVariables[string];
|
||||
}
|
||||
|
||||
export class Variables extends Component {
|
||||
export class Variables extends RPGComponentBase {
|
||||
private readonly variables: RPGVariables = {};
|
||||
|
||||
override getVariables() {
|
||||
|
|
@ -16,17 +16,13 @@ export class Variables extends Component {
|
|||
|
||||
@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;
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +30,7 @@ export class Variables extends Component {
|
|||
increment({ key, value }: Var) {
|
||||
const currentValue = this.variables[key] ?? 0;
|
||||
if (typeof currentValue === 'number' && typeof value === 'number') {
|
||||
this.set({ key, value: currentValue + value });
|
||||
this.variables[key] = currentValue + value;
|
||||
}
|
||||
return this.variables;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import type { RPGCondition, RPGVariables } from "../types";
|
||||
import type { EvalContext } from "../core/world";
|
||||
import { resolveVariable } from "./variables";
|
||||
import type { RPGCondition, RPGVariables } from "./types";
|
||||
|
||||
type ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'null' | '~null';
|
||||
type ConditionValue = string | number | boolean | null;
|
||||
|
|
@ -41,7 +39,9 @@ export function parseCondition(s: RPGCondition): ParsedCondition {
|
|||
return { variable, negate: false, operator: operator as ConditionOperator, value };
|
||||
}
|
||||
|
||||
function evalParsed({ negate, operator, value }: ParsedCondition, val: RPGVariables[string]): boolean {
|
||||
export function evaluateCondition({ variable, negate, operator, value }: ParsedCondition, variables: RPGVariables): boolean {
|
||||
const val = variables[variable];
|
||||
|
||||
if (operator === 'null') return val == null;
|
||||
if (operator === '~null') return val != null;
|
||||
|
||||
|
|
@ -60,21 +60,6 @@ function evalParsed({ negate, operator, value }: ParsedCondition, val: RPGVariab
|
|||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
export function evaluateConditions(conditions: RPGCondition[], variables: RPGVariables): boolean {
|
||||
return conditions.every(c => evaluateCondition(parseCondition(c), variables));
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -11,10 +11,7 @@ import {
|
|||
evaluateConditions,
|
||||
parseCondition,
|
||||
type ParsedCondition,
|
||||
} from "./utils/conditions";
|
||||
|
||||
import { isEvalContext, type EvalContext } from "./core/world";
|
||||
import { resolveVariables, executeAction } from "./utils/variables";
|
||||
} from "./conditions";
|
||||
|
||||
export interface DialogRuntimeOptions {
|
||||
variables: RPGVariables;
|
||||
|
|
@ -48,7 +45,6 @@ export namespace Dialogs {
|
|||
actions.add(action.type);
|
||||
}
|
||||
}
|
||||
return Array.from(actions);
|
||||
}
|
||||
|
||||
export function validate(
|
||||
|
|
@ -222,25 +218,12 @@ export class DialogEngine {
|
|||
|
||||
constructor(
|
||||
dialog: Dialog,
|
||||
private readonly options: DialogRuntimeOptions | EvalContext,
|
||||
private readonly options: DialogRuntimeOptions,
|
||||
) {
|
||||
this.nodeMap = new Map(dialog.nodes.map(n => [n.id, n]));
|
||||
this.startNodeId = dialog.startNodeId;
|
||||
}
|
||||
|
||||
private getVariables(): RPGVariables {
|
||||
if (isEvalContext(this.options)) return resolveVariables(this.options.self);
|
||||
return this.options.variables;
|
||||
}
|
||||
|
||||
private async runAction(action: { type: string; arg?: string | number | boolean }): Promise<void> {
|
||||
if (isEvalContext(this.options)) {
|
||||
await executeAction(action, this.options);
|
||||
} else {
|
||||
await this.options.actions[action.type]?.(action.arg);
|
||||
}
|
||||
}
|
||||
|
||||
async advance(nodeId?: string): Promise<DialogNodeView | null> {
|
||||
let targetId = nodeId ?? this.currentNode?.nextNodeId ?? (this.currentNode === null ? this.startNodeId : undefined);
|
||||
|
||||
|
|
@ -248,8 +231,7 @@ export class DialogEngine {
|
|||
const node = this.nodeMap.get(targetId);
|
||||
if (!node) return null;
|
||||
|
||||
const vars = this.getVariables();
|
||||
if (!evaluateConditions(node.conditions ?? [], vars)) {
|
||||
if (!evaluateConditions(node.conditions ?? [], this.options.variables)) {
|
||||
targetId = node.nextNodeId;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -257,7 +239,7 @@ export class DialogEngine {
|
|||
this.currentNode = node;
|
||||
|
||||
for (const action of node.actions ?? []) {
|
||||
await this.runAction(action);
|
||||
await this.options.actions[action.type]?.(action.arg);
|
||||
}
|
||||
|
||||
const choices = this.filterChoices(node.choices);
|
||||
|
|
@ -276,11 +258,10 @@ 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 ?? [], vars) : false;
|
||||
return target ? evaluateConditions(target.conditions ?? [], this.options.variables) : false;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import { RPGComponentBase } from "./components/entity";
|
||||
import { evaluateConditions } 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 RPGComponentBase {
|
||||
@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;
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return evaluateConditions(this.quest.conditions ?? [], this.options.getVariables());
|
||||
}
|
||||
|
||||
@action
|
||||
start(): void {
|
||||
this._status = 'active';
|
||||
this._stageIndex = 0;
|
||||
}
|
||||
|
||||
@action
|
||||
async checkAndAdvance(): Promise<void> {
|
||||
if (this._status !== 'active') return;
|
||||
|
||||
const stage = this.quest.stages[this._stageIndex];
|
||||
if (!stage) return;
|
||||
|
||||
const allDone = evaluateConditions(stage.objectives.map(obj => obj.condition), this.options.getVariables());
|
||||
|
||||
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++;
|
||||
} else {
|
||||
this._status = 'completed';
|
||||
}
|
||||
}
|
||||
|
||||
get currentStage(): QuestStage | null {
|
||||
return this.quest.stages[this._stageIndex] ?? null;
|
||||
}
|
||||
|
||||
get status(): QuestStatus {
|
||||
return this._status;
|
||||
}
|
||||
}
|
||||
|
||||
export class QuestManager extends RPGComponentBase {
|
||||
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)]));
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { System, type Entity, type World } from "../core/world";
|
||||
import { evaluateCondition, parseCondition } from "../utils/conditions";
|
||||
import { executeAction } from "../utils/variables";
|
||||
import { QuestLog } from "../components/questLog";
|
||||
|
||||
export class QuestSystem extends System {
|
||||
override update(world: World, _dt: number): void {
|
||||
for (const [entity] of world.query(QuestLog)) {
|
||||
void this.#checkEntity(entity, world);
|
||||
}
|
||||
}
|
||||
|
||||
async triggerCheck(entity: Entity, world: World): Promise<void> {
|
||||
await this.#checkEntity(entity, world);
|
||||
}
|
||||
|
||||
async #checkEntity(entity: Entity, world: World): Promise<void> {
|
||||
const questLog = entity.get(QuestLog);
|
||||
if (!questLog) return;
|
||||
|
||||
const ctx = { self: entity, world };
|
||||
|
||||
for (const [questId, { quest, state }] of questLog.entries()) {
|
||||
if (state.status !== 'active') continue;
|
||||
|
||||
const stage = quest.stages[state.stageIndex];
|
||||
if (!stage) continue;
|
||||
|
||||
const allDone = stage.objectives.every(obj =>
|
||||
evaluateCondition(parseCondition(obj.condition), ctx)
|
||||
);
|
||||
if (!allDone) continue;
|
||||
|
||||
for (const action of stage.actions) {
|
||||
await executeAction(action, ctx);
|
||||
}
|
||||
|
||||
questLog._advance(questId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +1,101 @@
|
|||
import { Type, type Static } from '../typebox';
|
||||
|
||||
// ── Shared ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const RPGActionScheme = Type.Object({
|
||||
type: Type.String(),
|
||||
arg: Type.Optional(Type.Union([Type.String(), Type.Number(), Type.Boolean()])),
|
||||
});
|
||||
|
||||
export type RPGCondition = string;
|
||||
export type RPGVariables = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
export interface RPGAction {
|
||||
type: string;
|
||||
arg?: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export type RPGActions = Record<string, (arg?: any) => unknown>;
|
||||
export type RPGAction = Static<typeof RPGActionScheme>;
|
||||
|
||||
// ── Dialog ────────────────────────────────────────────────────────────────────
|
||||
export interface DialogChoice {
|
||||
text: string;
|
||||
nextNodeId?: string;
|
||||
}
|
||||
|
||||
const DialogChoiceScheme = Type.Object({
|
||||
text: Type.String(),
|
||||
nextNodeId: Type.Optional(Type.String()),
|
||||
});
|
||||
export interface DialogNode {
|
||||
id: string;
|
||||
speaker: string;
|
||||
text: string;
|
||||
nextNodeId?: string;
|
||||
choices?: DialogChoice[];
|
||||
conditions?: RPGCondition[];
|
||||
actions?: RPGAction[];
|
||||
}
|
||||
|
||||
const DialogNodeScheme = Type.Object({
|
||||
id: Type.String(),
|
||||
speaker: Type.String(),
|
||||
text: Type.String(),
|
||||
nextNodeId: Type.Optional(Type.String()),
|
||||
choices: Type.Optional(Type.Array(DialogChoiceScheme)),
|
||||
conditions: Type.Optional(Type.Array(Type.String())),
|
||||
actions: Type.Optional(Type.Array(RPGActionScheme)),
|
||||
});
|
||||
export interface Dialog {
|
||||
nodes: DialogNode[];
|
||||
startNodeId: string;
|
||||
}
|
||||
|
||||
const DialogScheme = Type.Object({
|
||||
nodes: Type.Array(DialogNodeScheme),
|
||||
startNodeId: Type.String(),
|
||||
});
|
||||
export interface QuestObjective {
|
||||
id: string;
|
||||
description: string;
|
||||
condition: RPGCondition;
|
||||
}
|
||||
|
||||
export type DialogChoice = Static<typeof DialogChoiceScheme>;
|
||||
export type DialogNode = Static<typeof DialogNodeScheme>;
|
||||
export type Dialog = Static<typeof DialogScheme>;
|
||||
export interface QuestStage {
|
||||
id: string;
|
||||
description: string;
|
||||
objectives: QuestObjective[];
|
||||
actions: RPGAction[];
|
||||
}
|
||||
|
||||
export interface Quest {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
conditions?: RPGCondition[];
|
||||
stages: QuestStage[];
|
||||
}
|
||||
|
||||
function isRPGAction(v: unknown): v is RPGAction {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
return typeof (v as Record<string, unknown>).type === 'string';
|
||||
}
|
||||
|
||||
function isDialogChoice(v: unknown): v is DialogChoice {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const c = v as Record<string, unknown>;
|
||||
return typeof c.text === 'string'
|
||||
&& (c.nextNodeId === undefined || typeof c.nextNodeId === 'string');
|
||||
}
|
||||
|
||||
function isDialogNode(v: unknown): v is DialogNode {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const n = v as Record<string, unknown>;
|
||||
return typeof n.id === 'string'
|
||||
&& typeof n.speaker === 'string'
|
||||
&& typeof n.text === 'string'
|
||||
&& (n.nextNodeId === undefined || typeof n.nextNodeId === 'string')
|
||||
&& (n.choices === undefined || (Array.isArray(n.choices) && n.choices.every(isDialogChoice)))
|
||||
&& (n.conditions === undefined || (Array.isArray(n.conditions) && n.conditions.every(c => typeof c === 'string')))
|
||||
&& (n.actions === undefined || (Array.isArray(n.actions) && n.actions.every(isRPGAction)));
|
||||
}
|
||||
|
||||
export function isDialog(v: unknown): v is Dialog {
|
||||
return Type.Is(DialogScheme, v);
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const d = v as Record<string, unknown>;
|
||||
return Array.isArray(d.nodes) && d.nodes.every(isDialogNode)
|
||||
&& typeof d.startNodeId === 'string';
|
||||
}
|
||||
|
||||
// ── Quest ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const QuestObjectiveScheme = Type.Object({
|
||||
id: Type.String(),
|
||||
description: Type.String(),
|
||||
condition: Type.String(),
|
||||
});
|
||||
|
||||
const QuestStageScheme = Type.Object({
|
||||
id: Type.String(),
|
||||
description: Type.String(),
|
||||
objectives: Type.Array(QuestObjectiveScheme),
|
||||
actions: Type.Array(RPGActionScheme),
|
||||
});
|
||||
|
||||
const QuestScheme = Type.Object({
|
||||
id: Type.String(),
|
||||
title: Type.String(),
|
||||
description: Type.String(),
|
||||
conditions: Type.Optional(Type.Array(Type.String())),
|
||||
stages: Type.Array(QuestStageScheme),
|
||||
});
|
||||
|
||||
export type QuestObjective = Static<typeof QuestObjectiveScheme>;
|
||||
export type QuestStage = Static<typeof QuestStageScheme>;
|
||||
export type Quest = Static<typeof QuestScheme>;
|
||||
|
||||
export function isQuest(v: unknown): v is Quest {
|
||||
return Type.Is(QuestScheme, v);
|
||||
function isQuestObjective(v: unknown): v is QuestObjective {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
return typeof o.id === 'string'
|
||||
&& typeof o.description === 'string'
|
||||
&& typeof o.condition === 'string';
|
||||
}
|
||||
|
||||
// ── Inventory ─────────────────────────────────────────────────────────────────
|
||||
function isQuestStage(v: unknown): v is QuestStage {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const s = v as Record<string, unknown>;
|
||||
return typeof s.id === 'string'
|
||||
&& typeof s.description === 'string'
|
||||
&& Array.isArray(s.objectives) && s.objectives.every(isQuestObjective)
|
||||
&& Array.isArray(s.actions) && s.actions.every(isRPGAction);
|
||||
}
|
||||
|
||||
export type SlotId = string | number;
|
||||
|
||||
|
|
@ -87,3 +109,13 @@ export type InventorySlotInput = SlotId | InventorySlotDefinition;
|
|||
export interface InventoryOptions {
|
||||
maxAmountPerItem?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function isQuest(v: unknown): v is Quest {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const q = v as Record<string, unknown>;
|
||||
return typeof q.id === 'string'
|
||||
&& typeof q.title === 'string'
|
||||
&& typeof q.description === 'string'
|
||||
&& Array.isArray(q.stages) && q.stages.every(isQuestStage)
|
||||
&& (q.conditions === undefined || (Array.isArray(q.conditions) && q.conditions.every(c => typeof c === 'string')));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -64,14 +64,6 @@ export namespace Type {
|
|||
return result;
|
||||
}
|
||||
|
||||
export function Union<T extends TScheme[]>(anyOf: [...T], args: { description?: string } = {}): TUnion<T> {
|
||||
const result: TUnion<T> = { type: 'union', anyOf };
|
||||
if (args.description) {
|
||||
result.description = args.description;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function Optional<T extends TScheme = TScheme>(scheme: T): TOptional<T> {
|
||||
const result = { ...scheme };
|
||||
GlobalObject.defineProperty(result, optional, { value: true, enumerable: false, writable: false, configurable: false });
|
||||
|
|
@ -166,13 +158,6 @@ export namespace Type {
|
|||
}
|
||||
return errors;
|
||||
}
|
||||
case 'union': {
|
||||
const s = scheme as TUnion;
|
||||
for (const branch of s.anyOf) {
|
||||
if (check(branch, value, path).length === 0) return [];
|
||||
}
|
||||
return [{ path, message: `Expected union type at ${path}, got ${typeof value}` }];
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
|
@ -239,28 +224,17 @@ export interface TOptionalObject<T extends TProperties = TProperties> extends TO
|
|||
[optional]: true;
|
||||
}
|
||||
|
||||
export interface TUnion<T extends TScheme[] = TScheme[]> {
|
||||
type: 'union';
|
||||
anyOf: T;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TOptionalUnion<T extends TScheme[] = TScheme[]> extends TUnion<T> {
|
||||
[optional]: true;
|
||||
}
|
||||
|
||||
export type TOptional<T extends TScheme = TScheme> =
|
||||
T extends TString<infer S> ? TOptionalString<S> :
|
||||
T extends TNumber<infer N> ? TOptionalNumber<N> :
|
||||
T extends TBoolean ? TOptionalBoolean :
|
||||
T extends TArray<infer I> ? TOptionalArray<I> :
|
||||
T extends TObject<infer P> ? TOptionalObject<P> :
|
||||
T extends TUnion<infer U> ? TOptionalUnion<U> :
|
||||
never;
|
||||
|
||||
export type IsOptional<T> = T extends { [optional]: true } ? true : false;
|
||||
|
||||
export type TScheme = TString | TNumber | TBoolean | TArray | TObject | TUnion | TOptionalString | TOptionalNumber | TOptionalBoolean | TOptionalArray | TOptionalObject | TOptionalUnion;
|
||||
export type TScheme = TString | TNumber | TBoolean | TArray | TObject | TOptionalString | TOptionalNumber | TOptionalBoolean | TOptionalArray | TOptionalObject;
|
||||
|
||||
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
||||
|
||||
|
|
@ -277,16 +251,10 @@ type StaticObject<T extends TProperties> = Prettify<
|
|||
{ [K in OptionalKeys<T>]?: Static<T[K]> }
|
||||
>;
|
||||
|
||||
type StaticUnion<T extends TScheme[]> =
|
||||
T extends [infer First extends TScheme, ...infer Rest extends TScheme[]]
|
||||
? Static<First> | StaticUnion<Rest>
|
||||
: never;
|
||||
|
||||
export type Static<T extends TScheme> =
|
||||
T extends TString<infer S> ? S :
|
||||
T extends TNumber<infer N> ? N :
|
||||
T extends TBoolean ? boolean :
|
||||
T extends TArray<infer I> ? Static<I>[] :
|
||||
T extends TObject<infer P> ? StaticObject<P> :
|
||||
T extends TUnion<infer U> ? StaticUnion<U> :
|
||||
never;
|
||||
|
|
|
|||
|
|
@ -1,37 +1,41 @@
|
|||
import { World } from "@common/rpg/core/world";
|
||||
import { RPGEntity } from "@common/rpg/components/entity";
|
||||
import { Inventory } from "@common/rpg/components/inventory";
|
||||
import { Health } from "@common/rpg/components/stat";
|
||||
import { Stat } from "@common/rpg/components/stat";
|
||||
import { Variables } from "@common/rpg/components/variables";
|
||||
import { QuestLog } from "@common/rpg/components/questLog";
|
||||
import { QuestSystem } from "@common/rpg/systems/questSystem";
|
||||
import { resolveVariables, resolveActions } from "@common/rpg/utils/variables";
|
||||
import { QuestManager } from "@common/rpg/quest";
|
||||
|
||||
|
||||
export default async function main() {
|
||||
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({
|
||||
const game = new RPGEntity();
|
||||
const player = new RPGEntity();
|
||||
const inventory = new Inventory(['head', 'legs']);
|
||||
const quests = new QuestManager([{
|
||||
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());
|
||||
|
||||
inventory.addItem({
|
||||
itemId: 'helmet',
|
||||
amount: 1,
|
||||
slotId: 'head',
|
||||
});
|
||||
|
||||
console.log(resolveVariables(player));
|
||||
|
||||
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));
|
||||
inventory.addItem({
|
||||
itemId: 'boots',
|
||||
amount: 2,
|
||||
});
|
||||
inventory.addItem({
|
||||
itemId: 'belt',
|
||||
amount: 1,
|
||||
});
|
||||
console.log(game.getVariables());
|
||||
}
|
||||
Loading…
Reference in New Issue