Fixes and improvements
This commit is contained in:
parent
89651f6674
commit
b75dc078f9
|
|
@ -1,9 +1,9 @@
|
||||||
import { Component, type EvalContext } from "../core/world";
|
import { Component, type EvalContext } from "../core/world";
|
||||||
import { isQuest, type Quest, type RPGVariables } from "../types";
|
import { isQuest, type Quest, type QuestStage, type RPGVariables } from "../types";
|
||||||
import { evaluateCondition, parseCondition } from "../utils/conditions";
|
import { evaluateCondition, parseCondition } from "../utils/conditions";
|
||||||
import { action } from "../utils/decorators";
|
import { action } from "../utils/decorators";
|
||||||
|
|
||||||
export type QuestStatus = 'inactive' | 'active' | 'completed';
|
export type QuestStatus = 'inactive' | 'active' | 'completed' | 'failed';
|
||||||
|
|
||||||
export interface QuestRuntimeState {
|
export interface QuestRuntimeState {
|
||||||
status: QuestStatus;
|
status: QuestStatus;
|
||||||
|
|
@ -46,11 +46,57 @@ export class QuestLog extends Component {
|
||||||
return quest.conditions.every(c => evaluateCondition(parseCondition(c), ctx));
|
return quest.conditions.every(c => evaluateCondition(parseCondition(c), ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
fail(questId: string): boolean {
|
||||||
|
const entry = this.#quests.get(questId);
|
||||||
|
if (!entry || entry.state.status !== 'active') return false;
|
||||||
|
entry.state.status = 'failed';
|
||||||
|
this.emit('failed', { questId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
abandon(questId: string): boolean {
|
||||||
|
const entry = this.#quests.get(questId);
|
||||||
|
if (!entry || entry.state.status !== 'active') return false;
|
||||||
|
entry.state.status = 'inactive';
|
||||||
|
entry.state.stageIndex = 0;
|
||||||
|
this.emit('abandoned', { questId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStage(questId: string): QuestStage | undefined {
|
||||||
|
const entry = this.#quests.get(questId);
|
||||||
|
if (!entry || entry.state.status !== 'active') return undefined;
|
||||||
|
return entry.quest.stages[entry.state.stageIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
getObjectiveProgress(
|
||||||
|
questId: string,
|
||||||
|
ctx: EvalContext,
|
||||||
|
): Array<{ id: string; description: string; done: boolean }> | undefined {
|
||||||
|
const stage = this.getStage(questId);
|
||||||
|
if (!stage) return undefined;
|
||||||
|
return stage.objectives.map(obj => ({
|
||||||
|
id: obj.id,
|
||||||
|
description: obj.description,
|
||||||
|
done: evaluateCondition(parseCondition(obj.condition), ctx),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal used by QuestSystem */
|
/** @internal used by QuestSystem */
|
||||||
entries(): IterableIterator<[string, QuestEntry]> {
|
entries(): IterableIterator<[string, QuestEntry]> {
|
||||||
return this.#quests.entries();
|
return this.#quests.entries();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal called by QuestSystem when a fail condition is met */
|
||||||
|
_fail(questId: string): void {
|
||||||
|
const entry = this.#quests.get(questId);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.state.status = 'failed';
|
||||||
|
this.emit('failed', { questId });
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal called by QuestSystem after stage actions complete */
|
/** @internal called by QuestSystem after stage actions complete */
|
||||||
_advance(questId: string): void {
|
_advance(questId: string): void {
|
||||||
const entry = this.#quests.get(questId);
|
const entry = this.#quests.get(questId);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators';
|
import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators';
|
||||||
import type { RPGActions, RPGVariables } from '../types';
|
import type { RPGActions, RPGVariables } from '../types';
|
||||||
|
|
||||||
|
interface EntityEvent<T = unknown> {
|
||||||
|
target: Entity;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
interface WorldEvent<T = unknown> {
|
||||||
|
target: World;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
type Class<T> = abstract new (...args: any[]) => T;
|
type Class<T> = abstract new (...args: any[]) => T;
|
||||||
type EventHandler = (data: unknown) => void;
|
type EntityEventHandler = <T>(event: EntityEvent<T>) => void;
|
||||||
|
type WorldEventHandler = <T>(event: WorldEvent<T>) => void;
|
||||||
|
|
||||||
export interface EvalContext {
|
export interface EvalContext {
|
||||||
self: Entity;
|
self: Entity;
|
||||||
|
|
@ -132,15 +142,15 @@ export class Entity {
|
||||||
this.world.emitGlobal(event, data);
|
this.world.emitGlobal(event, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: string, handler: EventHandler): () => void {
|
on(event: string, handler: EntityEventHandler): () => void {
|
||||||
return this.world.on(this.id, event, handler);
|
return this.world.on(this.id, event, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
off(event: string, handler: EventHandler): void {
|
off(event: string, handler: EntityEventHandler): void {
|
||||||
this.world.off(this.id, event, handler);
|
this.world.off(this.id, event, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
once(event: string, handler: EventHandler): () => void {
|
once(event: string, handler: EntityEventHandler): () => void {
|
||||||
return this.world.once(this.id, event, handler);
|
return this.world.once(this.id, event, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,8 +175,8 @@ export class World {
|
||||||
readonly globals: RPGVariables = {};
|
readonly globals: RPGVariables = {};
|
||||||
|
|
||||||
readonly #entities = new Map<string, Entity>();
|
readonly #entities = new Map<string, Entity>();
|
||||||
readonly #handlers = new Map<string, Set<EventHandler>>();
|
readonly #handlers = new Map<string, Set<EntityEventHandler>>();
|
||||||
readonly #globalHandlers = new Map<string, Set<EventHandler>>();
|
readonly #globalHandlers = new Map<string, Set<WorldEventHandler>>();
|
||||||
readonly #systems: System[] = [];
|
readonly #systems: System[] = [];
|
||||||
#entityCounter = 0;
|
#entityCounter = 0;
|
||||||
|
|
||||||
|
|
@ -205,33 +215,42 @@ export class World {
|
||||||
|
|
||||||
removeSystem(system: System): void {
|
removeSystem(system: System): void {
|
||||||
const idx = this.#systems.indexOf(system);
|
const idx = this.#systems.indexOf(system);
|
||||||
if (idx !== -1) { this.#systems.splice(idx, 1); system.onRemove(this); }
|
if (idx !== -1) {
|
||||||
|
this.#systems.splice(idx, 1);
|
||||||
|
system.onRemove(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(dt: number): void {
|
update(dt: number): void {
|
||||||
for (const system of this.#systems) system.update(this, dt);
|
for (const system of this.#systems) system.update(this, dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(entityId: string, event: string, data?: unknown): void {
|
emit(entityId: string, event: string, data?: unknown): void {
|
||||||
this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h(data));
|
const entity = this.getEntity(entityId);
|
||||||
|
if (!entity) return;
|
||||||
|
|
||||||
|
this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h({
|
||||||
|
target: entity,
|
||||||
|
data,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
emitGlobal(event: string, data?: unknown): void {
|
emitGlobal(event: string, data?: unknown): void {
|
||||||
this.#globalHandlers.get(event)?.forEach(h => h(data));
|
this.#globalHandlers.get(event)?.forEach(h => h({ target: this, data }));
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: string, handler: EventHandler): () => void;
|
on(event: string, handler: WorldEventHandler): () => void;
|
||||||
on(entityId: string, event: string, handler: EventHandler): () => void;
|
on(entityId: string, event: string, handler: EntityEventHandler): () => void;
|
||||||
on(arg1: string, arg2: EventHandler | string, arg3?: EventHandler): () => void {
|
on(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void {
|
||||||
if (typeof arg2 === 'string') {
|
if (typeof arg2 === 'string') {
|
||||||
return this.#addHandler(this.#handlers, `${arg1}\0${arg2}`, arg3!);
|
return this.#addHandler(this.#handlers, `${arg1}\0${arg2}`, arg3!);
|
||||||
}
|
}
|
||||||
return this.#addHandler(this.#globalHandlers, arg1, arg2);
|
return this.#addHandler(this.#globalHandlers, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
off(event: string, handler: EventHandler): void;
|
off(event: string, handler: WorldEventHandler): void;
|
||||||
off(entityId: string, event: string, handler: EventHandler): void;
|
off(entityId: string, event: string, handler: EntityEventHandler): void;
|
||||||
off(arg1: string, arg2: EventHandler | string, arg3?: EventHandler): void {
|
off(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): void {
|
||||||
if (typeof arg2 === 'string') {
|
if (typeof arg2 === 'string') {
|
||||||
this.#handlers.get(`${arg1}\0${arg2}`)?.delete(arg3!);
|
this.#handlers.get(`${arg1}\0${arg2}`)?.delete(arg3!);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -239,21 +258,21 @@ export class World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
once(event: string, handler: EventHandler): () => void;
|
once(event: string, handler: WorldEventHandler): () => void;
|
||||||
once(entityId: string, event: string, handler: EventHandler): () => void;
|
once(entityId: string, event: string, handler: EntityEventHandler): () => void;
|
||||||
once(arg1: string, arg2: EventHandler | string, arg3?: EventHandler): () => void {
|
once(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void {
|
||||||
if (typeof arg2 === 'string') {
|
if (typeof arg2 === 'string') {
|
||||||
const wrapped: EventHandler = data => { unsub(); arg3!(data); };
|
const wrapped: EntityEventHandler = data => { unsub(); arg3!(data); };
|
||||||
const unsub = this.on(arg1, arg2, wrapped);
|
const unsub = this.on(arg1, arg2, wrapped);
|
||||||
return unsub;
|
return unsub;
|
||||||
}
|
}
|
||||||
const h = arg2;
|
const h = arg2;
|
||||||
const wrapped: EventHandler = data => { unsub(); h(data); };
|
const wrapped: WorldEventHandler = data => { unsub(); h(data); };
|
||||||
const unsub = this.on(arg1, wrapped);
|
const unsub = this.on(arg1, wrapped);
|
||||||
return unsub;
|
return unsub;
|
||||||
}
|
}
|
||||||
|
|
||||||
#addHandler(map: Map<string, Set<EventHandler>>, key: string, handler: EventHandler): () => void {
|
#addHandler<T extends WorldEventHandler | EntityEventHandler>(map: Map<string, Set<T>>, key: string, handler: T): () => void {
|
||||||
if (!map.has(key)) map.set(key, new Set());
|
if (!map.has(key)) map.set(key, new Set());
|
||||||
map.get(key)!.add(handler);
|
map.get(key)!.add(handler);
|
||||||
return () => map.get(key)?.delete(handler);
|
return () => map.get(key)?.delete(handler);
|
||||||
|
|
|
||||||
|
|
@ -243,8 +243,15 @@ export class DialogEngine {
|
||||||
|
|
||||||
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);
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
while (targetId !== undefined) {
|
while (targetId !== undefined) {
|
||||||
|
if (visited.has(targetId)) {
|
||||||
|
console.warn(`[DialogEngine] cycle detected at node '${targetId}', stopping`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
visited.add(targetId);
|
||||||
|
|
||||||
const node = this.nodeMap.get(targetId);
|
const node = this.nodeMap.get(targetId);
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,20 @@ export class QuestSystem extends System {
|
||||||
if (state.status !== 'active') continue;
|
if (state.status !== 'active') continue;
|
||||||
|
|
||||||
const stage = quest.stages[state.stageIndex];
|
const stage = quest.stages[state.stageIndex];
|
||||||
if (!stage) continue;
|
if (!stage) {
|
||||||
|
console.warn(`[QuestSystem] quest '${questId}' is active but has no stage at index ${state.stageIndex}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage.failConditions?.length) {
|
||||||
|
const failed = stage.failConditions.some(c =>
|
||||||
|
evaluateCondition(parseCondition(c), ctx)
|
||||||
|
);
|
||||||
|
if (failed) {
|
||||||
|
questLog._fail(questId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const allDone = stage.objectives.every(obj =>
|
const allDone = stage.objectives.every(obj =>
|
||||||
evaluateCondition(parseCondition(obj.condition), ctx)
|
evaluateCondition(parseCondition(obj.condition), ctx)
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ const QuestStageScheme = Type.Object({
|
||||||
description: Type.String(),
|
description: Type.String(),
|
||||||
objectives: Type.Array(QuestObjectiveScheme),
|
objectives: Type.Array(QuestObjectiveScheme),
|
||||||
actions: Type.Array(RPGActionScheme),
|
actions: Type.Array(RPGActionScheme),
|
||||||
|
failConditions: Type.Optional(Type.Array(Type.String())),
|
||||||
});
|
});
|
||||||
|
|
||||||
const QuestScheme = Type.Object({
|
const QuestScheme = Type.Object({
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@ export function resolveVariable(name: string, ctx: EvalContext): RPGVariables[st
|
||||||
const entityId = name.slice(1, dotIdx);
|
const entityId = name.slice(1, dotIdx);
|
||||||
const varName = name.slice(dotIdx + 1);
|
const varName = name.slice(dotIdx + 1);
|
||||||
const entity = ctx.world.getEntity(entityId);
|
const entity = ctx.world.getEntity(entityId);
|
||||||
if (!entity) return undefined;
|
if (!entity) {
|
||||||
|
console.warn(`[resolveVariable] entity '${entityId}' not found (referenced in '${name}')`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return resolveVariables(entity)[varName];
|
return resolveVariables(entity)[varName];
|
||||||
}
|
}
|
||||||
// bare name → self entity
|
// bare name → self entity
|
||||||
|
|
@ -53,14 +56,24 @@ export async function executeAction(action: RPGAction, ctx: EvalContext): Promis
|
||||||
// @entityId.component.action → dispatch to another entity
|
// @entityId.component.action → dispatch to another entity
|
||||||
if (action.type.startsWith('@')) {
|
if (action.type.startsWith('@')) {
|
||||||
const dotIdx = action.type.indexOf('.', 1);
|
const dotIdx = action.type.indexOf('.', 1);
|
||||||
if (dotIdx === -1) return;
|
if (dotIdx === -1) {
|
||||||
|
console.warn(`[executeAction] malformed cross-entity action '${action.type}': missing '.' after entity id`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const entityId = action.type.slice(1, dotIdx);
|
const entityId = action.type.slice(1, dotIdx);
|
||||||
const found = ctx.world.getEntity(entityId);
|
const found = ctx.world.getEntity(entityId);
|
||||||
if (!found) return;
|
if (!found) {
|
||||||
|
console.warn(`[executeAction] entity '${entityId}' not found (action '${action.type}')`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
entity = found;
|
entity = found;
|
||||||
actionType = action.type.slice(dotIdx + 1);
|
actionType = action.type.slice(dotIdx + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = resolveActions(entity);
|
const actions = resolveActions(entity);
|
||||||
return actions[actionType]?.(action.arg);
|
if (!(actionType in actions)) {
|
||||||
|
console.warn(`[executeAction] action '${actionType}' not found on entity '${entity.id}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return actions[actionType](action.arg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue