Compare commits
4 Commits
ab68a5ffc6
...
89651f6674
| Author | SHA1 | Date |
|---|---|---|
|
|
89651f6674 | |
|
|
0ad5c60cdd | |
|
|
2d4a59f2b3 | |
|
|
87837754f6 |
|
|
@ -1,74 +0,0 @@
|
||||||
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,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 { RPGComponentBase } 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 RPGComponentBase {
|
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,14 +37,14 @@ export class Inventory extends RPGComponentBase {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
addItem({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
|
add({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
|
||||||
if (amount < 0) return false;
|
if (amount < 0) return false;
|
||||||
if (amount === 0) return true;
|
if (amount === 0) return true;
|
||||||
|
|
||||||
|
|
@ -52,6 +53,7 @@ export class Inventory extends RPGComponentBase {
|
||||||
if (!slot) return false;
|
if (!slot) return false;
|
||||||
if (this.slotRoomFor(slot, itemId) < amount) return false;
|
if (this.slotRoomFor(slot, itemId) < amount) return false;
|
||||||
slot.state = { itemId, amount: (slot.state?.amount ?? 0) + amount };
|
slot.state = { itemId, amount: (slot.state?.amount ?? 0) + amount };
|
||||||
|
this.emit('add', { itemId, amount, slotIds: [slotId] });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,22 +67,31 @@ export class Inventory extends RPGComponentBase {
|
||||||
}
|
}
|
||||||
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;
|
||||||
for (const slot of this.slots.values()) {
|
const slotIds: SlotId[] = [];
|
||||||
|
for (const [id, slot] of this.slots) {
|
||||||
if (slot.state?.itemId === itemId) {
|
if (slot.state?.itemId === itemId) {
|
||||||
const take = Math.min(this.slotRoomFor(slot, itemId), remaining);
|
const take = Math.min(this.slotRoomFor(slot, itemId), remaining);
|
||||||
slot.state.amount += take;
|
slot.state.amount += take;
|
||||||
remaining -= 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) {
|
if (slot.state === null) {
|
||||||
const take = Math.min(this.slotCapFor(slot, itemId), remaining);
|
const take = Math.min(this.slotCapFor(slot, itemId), remaining);
|
||||||
slot.state = { itemId, amount: take };
|
slot.state = { itemId, amount: take };
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
if (remaining === 0) return true;
|
slotIds.push(id);
|
||||||
|
if (remaining === 0) {
|
||||||
|
this.emit('add', { itemId, amount, slotIds });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,7 +99,7 @@ export class Inventory extends RPGComponentBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
removeItem({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
|
remove({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
|
||||||
if (amount < 0) return false;
|
if (amount < 0) return false;
|
||||||
if (amount === 0) return true;
|
if (amount === 0) return true;
|
||||||
|
|
||||||
|
|
@ -98,23 +109,29 @@ export class Inventory extends RPGComponentBase {
|
||||||
if (slot.state.amount < amount) return false;
|
if (slot.state.amount < amount) return false;
|
||||||
slot.state.amount -= amount;
|
slot.state.amount -= amount;
|
||||||
if (slot.state.amount === 0) slot.state = null;
|
if (slot.state.amount === 0) slot.state = null;
|
||||||
|
this.emit('remove', { itemId, amount, slotIds: [slotId] });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.getAmount(itemId) < amount) return false;
|
if (this.getAmount(itemId) < amount) return false;
|
||||||
|
|
||||||
let remaining = amount;
|
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) {
|
if (slot.state?.itemId === itemId) {
|
||||||
const take = Math.min(slot.state.amount, remaining);
|
const take = Math.min(slot.state.amount, remaining);
|
||||||
slot.state.amount -= take;
|
slot.state.amount -= take;
|
||||||
if (slot.state.amount === 0) slot.state = null;
|
if (slot.state.amount === 0) slot.state = null;
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
if (remaining === 0) return true;
|
slotIds.push(id);
|
||||||
|
if (remaining === 0) {
|
||||||
|
this.emit('remove', { itemId, amount, slotIds });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return remaining === 0;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAmount(itemId: string, slotId?: SlotId): number {
|
getAmount(itemId: string, slotId?: SlotId): number {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,51 @@
|
||||||
import { action, variable } from "../decorators";
|
import { action, variable } from "../utils/decorators";
|
||||||
import { RPGComponentBase } from "./entity";
|
import { Component } from "../core/world";
|
||||||
|
|
||||||
export class Stat extends RPGComponentBase {
|
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.value = Math.max(0, this.value + amount);
|
this.set(this.value + amount);
|
||||||
if (this.maxValue) {
|
|
||||||
this.value = Math.min(this.value, this.maxValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 type { RPGVariables } from "../types";
|
||||||
import { action } from "../decorators";
|
import { action } from "../utils/decorators";
|
||||||
import { RPGComponentBase } 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 RPGComponentBase {
|
export class Variables extends Component {
|
||||||
private readonly variables: RPGVariables = {};
|
private readonly variables: RPGVariables = {};
|
||||||
|
|
||||||
override getVariables() {
|
override getVariables() {
|
||||||
|
|
@ -16,13 +16,17 @@ export class Variables extends RPGComponentBase {
|
||||||
|
|
||||||
@action
|
@action
|
||||||
set({ key, value }: Var) {
|
set({ key, value }: Var) {
|
||||||
|
const prev = this.variables[key];
|
||||||
this.variables[key] = value;
|
this.variables[key] = value;
|
||||||
|
this.emit('set', { key, value, prev });
|
||||||
return this.variables;
|
return this.variables;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
unset(key: string) {
|
unset(key: string) {
|
||||||
|
const prev = this.variables[key];
|
||||||
delete this.variables[key];
|
delete this.variables[key];
|
||||||
|
this.emit('unset', { key, prev });
|
||||||
return this.variables;
|
return this.variables;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,7 +34,7 @@ export class Variables extends RPGComponentBase {
|
||||||
increment({ key, value }: Var) {
|
increment({ key, value }: Var) {
|
||||||
const currentValue = this.variables[key] ?? 0;
|
const currentValue = this.variables[key] ?? 0;
|
||||||
if (typeof currentValue === 'number' && typeof value === 'number') {
|
if (typeof currentValue === 'number' && typeof value === 'number') {
|
||||||
this.variables[key] = currentValue + value;
|
this.set({ key, value: currentValue + value });
|
||||||
}
|
}
|
||||||
return this.variables;
|
return this.variables;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -45,6 +48,7 @@ export namespace Dialogs {
|
||||||
actions.add(action.type);
|
actions.add(action.type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return Array.from(actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validate(
|
export function validate(
|
||||||
|
|
@ -218,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);
|
||||||
|
|
||||||
|
|
@ -231,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;
|
||||||
}
|
}
|
||||||
|
|
@ -239,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);
|
||||||
|
|
@ -258,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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,101 +1,79 @@
|
||||||
|
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 RPGCondition = string;
|
||||||
export type RPGVariables = Record<string, string | number | boolean | undefined>;
|
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 RPGActions = Record<string, (arg?: any) => unknown>;
|
||||||
|
export type RPGAction = Static<typeof RPGActionScheme>;
|
||||||
|
|
||||||
export interface DialogChoice {
|
// ── Dialog ────────────────────────────────────────────────────────────────────
|
||||||
text: string;
|
|
||||||
nextNodeId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DialogNode {
|
const DialogChoiceScheme = Type.Object({
|
||||||
id: string;
|
text: Type.String(),
|
||||||
speaker: string;
|
nextNodeId: Type.Optional(Type.String()),
|
||||||
text: string;
|
});
|
||||||
nextNodeId?: string;
|
|
||||||
choices?: DialogChoice[];
|
|
||||||
conditions?: RPGCondition[];
|
|
||||||
actions?: RPGAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Dialog {
|
const DialogNodeScheme = Type.Object({
|
||||||
nodes: DialogNode[];
|
id: Type.String(),
|
||||||
startNodeId: 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 QuestObjective {
|
const DialogScheme = Type.Object({
|
||||||
id: string;
|
nodes: Type.Array(DialogNodeScheme),
|
||||||
description: string;
|
startNodeId: Type.String(),
|
||||||
condition: RPGCondition;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuestStage {
|
export type DialogChoice = Static<typeof DialogChoiceScheme>;
|
||||||
id: string;
|
export type DialogNode = Static<typeof DialogNodeScheme>;
|
||||||
description: string;
|
export type Dialog = Static<typeof DialogScheme>;
|
||||||
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 {
|
export function isDialog(v: unknown): v is Dialog {
|
||||||
if (typeof v !== 'object' || v === null) return false;
|
return Type.Is(DialogScheme, v);
|
||||||
const d = v as Record<string, unknown>;
|
|
||||||
return Array.isArray(d.nodes) && d.nodes.every(isDialogNode)
|
|
||||||
&& typeof d.startNodeId === 'string';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isQuestObjective(v: unknown): v is QuestObjective {
|
// ── Quest ─────────────────────────────────────────────────────────────────────
|
||||||
if (typeof v !== 'object' || v === null) return false;
|
|
||||||
const o = v as Record<string, unknown>;
|
const QuestObjectiveScheme = Type.Object({
|
||||||
return typeof o.id === 'string'
|
id: Type.String(),
|
||||||
&& typeof o.description === 'string'
|
description: Type.String(),
|
||||||
&& typeof o.condition === '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 isQuestStage(v: unknown): v is QuestStage {
|
// ── Inventory ─────────────────────────────────────────────────────────────────
|
||||||
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;
|
export type SlotId = string | number;
|
||||||
|
|
||||||
|
|
@ -109,13 +87,3 @@ export type InventorySlotInput = SlotId | InventorySlotDefinition;
|
||||||
export interface InventoryOptions {
|
export interface InventoryOptions {
|
||||||
maxAmountPerItem?: Record<string, number>;
|
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,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;
|
||||||
}
|
}
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -64,6 +64,14 @@ export namespace Type {
|
||||||
return result;
|
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> {
|
export function Optional<T extends TScheme = TScheme>(scheme: T): TOptional<T> {
|
||||||
const result = { ...scheme };
|
const result = { ...scheme };
|
||||||
GlobalObject.defineProperty(result, optional, { value: true, enumerable: false, writable: false, configurable: false });
|
GlobalObject.defineProperty(result, optional, { value: true, enumerable: false, writable: false, configurable: false });
|
||||||
|
|
@ -158,6 +166,13 @@ export namespace Type {
|
||||||
}
|
}
|
||||||
return errors;
|
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:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -224,17 +239,28 @@ export interface TOptionalObject<T extends TProperties = TProperties> extends TO
|
||||||
[optional]: true;
|
[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> =
|
export type TOptional<T extends TScheme = TScheme> =
|
||||||
T extends TString<infer S> ? TOptionalString<S> :
|
T extends TString<infer S> ? TOptionalString<S> :
|
||||||
T extends TNumber<infer N> ? TOptionalNumber<N> :
|
T extends TNumber<infer N> ? TOptionalNumber<N> :
|
||||||
T extends TBoolean ? TOptionalBoolean :
|
T extends TBoolean ? TOptionalBoolean :
|
||||||
T extends TArray<infer I> ? TOptionalArray<I> :
|
T extends TArray<infer I> ? TOptionalArray<I> :
|
||||||
T extends TObject<infer P> ? TOptionalObject<P> :
|
T extends TObject<infer P> ? TOptionalObject<P> :
|
||||||
|
T extends TUnion<infer U> ? TOptionalUnion<U> :
|
||||||
never;
|
never;
|
||||||
|
|
||||||
export type IsOptional<T> = T extends { [optional]: true } ? true : false;
|
export type IsOptional<T> = T extends { [optional]: true } ? true : false;
|
||||||
|
|
||||||
export type TScheme = TString | TNumber | TBoolean | TArray | TObject | TOptionalString | TOptionalNumber | TOptionalBoolean | TOptionalArray | TOptionalObject;
|
export type TScheme = TString | TNumber | TBoolean | TArray | TObject | TUnion | TOptionalString | TOptionalNumber | TOptionalBoolean | TOptionalArray | TOptionalObject | TOptionalUnion;
|
||||||
|
|
||||||
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
||||||
|
|
||||||
|
|
@ -251,10 +277,16 @@ type StaticObject<T extends TProperties> = Prettify<
|
||||||
{ [K in OptionalKeys<T>]?: Static<T[K]> }
|
{ [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> =
|
export type Static<T extends TScheme> =
|
||||||
T extends TString<infer S> ? S :
|
T extends TString<infer S> ? S :
|
||||||
T extends TNumber<infer N> ? N :
|
T extends TNumber<infer N> ? N :
|
||||||
T extends TBoolean ? boolean :
|
T extends TBoolean ? boolean :
|
||||||
T extends TArray<infer I> ? Static<I>[] :
|
T extends TArray<infer I> ? Static<I>[] :
|
||||||
T extends TObject<infer P> ? StaticObject<P> :
|
T extends TObject<infer P> ? StaticObject<P> :
|
||||||
|
T extends TUnion<infer U> ? StaticUnion<U> :
|
||||||
never;
|
never;
|
||||||
|
|
|
||||||
|
|
@ -1,41 +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();
|
const world = new World();
|
||||||
const player = new RPGEntity();
|
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.addItem({
|
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 });
|
||||||
inventory.addItem({
|
|
||||||
itemId: 'boots',
|
console.log(actions);
|
||||||
amount: 2,
|
console.log(resolveVariables(player));
|
||||||
});
|
|
||||||
inventory.addItem({
|
|
||||||
itemId: 'belt',
|
|
||||||
amount: 1,
|
|
||||||
});
|
|
||||||
console.log(game.getVariables());
|
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue