Dialogs engine
This commit is contained in:
parent
9c50b5d3e9
commit
8fd0d317cf
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { isDialog, type Dialog, type DialogChoice, type DialogNode } from "./types";
|
||||||
|
|
||||||
|
type ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'null' | '~null';
|
||||||
|
type ConditionValue = string | number | boolean | null;
|
||||||
|
|
||||||
|
interface ParsedCondition {
|
||||||
|
variable: string;
|
||||||
|
negate: boolean;
|
||||||
|
operator?: ConditionOperator;
|
||||||
|
value?: ConditionValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCondition(s: string): ParsedCondition {
|
||||||
|
// ~variable — falsy check, nothing else allowed
|
||||||
|
if (s.startsWith('~') && !s.includes(' '))
|
||||||
|
return { variable: s.slice(1), negate: true };
|
||||||
|
|
||||||
|
const spaceIdx = s.indexOf(' ');
|
||||||
|
if (spaceIdx === -1)
|
||||||
|
return { variable: s, negate: false };
|
||||||
|
|
||||||
|
const variable = s.slice(0, spaceIdx);
|
||||||
|
const rest = s.slice(spaceIdx + 1).trim();
|
||||||
|
|
||||||
|
if (rest === 'null') return { variable, negate: false, operator: 'null' };
|
||||||
|
if (rest === '~null') return { variable, negate: false, operator: '~null' };
|
||||||
|
|
||||||
|
const opMatch = rest.match(/^(==|!=|>=|<=|>|<)\s*(.+)$/);
|
||||||
|
if (!opMatch) throw new Error(`Invalid condition: "${s}"`);
|
||||||
|
|
||||||
|
const [, operator, rawValue] = opMatch;
|
||||||
|
let value: ConditionValue;
|
||||||
|
if (rawValue === 'null') value = null;
|
||||||
|
else if (rawValue === 'true') value = true;
|
||||||
|
else if (rawValue === 'false') value = false;
|
||||||
|
else if (!isNaN(Number(rawValue))) value = Number(rawValue);
|
||||||
|
else value = rawValue.replace(/^['"]|['"]$/g, '');
|
||||||
|
|
||||||
|
return { variable, negate: false, operator: operator as ConditionOperator, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function conditionVariable(s: string): string {
|
||||||
|
if (s.startsWith('~')) return s.slice(1);
|
||||||
|
const space = s.indexOf(' ');
|
||||||
|
return space === -1 ? s : s.slice(0, space);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DialogVariables = Record<string, string | number | boolean>;
|
||||||
|
export type DialogActions = Record<string, (arg?: unknown) => Promise<void> | void>;
|
||||||
|
|
||||||
|
export interface DialogRuntimeOptions {
|
||||||
|
variables: DialogVariables;
|
||||||
|
actions: DialogActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogNodeView {
|
||||||
|
speaker: string;
|
||||||
|
text: string;
|
||||||
|
choices?: DialogChoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDialog(
|
||||||
|
dialog: unknown,
|
||||||
|
conditions: string[],
|
||||||
|
actions: string[],
|
||||||
|
speakers: string[],
|
||||||
|
): string[] {
|
||||||
|
if (!isDialog(dialog)) return ['invalid dialog structure'];
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const nodeIds = new Set(dialog.nodes.map(n => n.id));
|
||||||
|
const conditionSet = new Set(conditions);
|
||||||
|
const actionSet = new Set(actions);
|
||||||
|
const speakerSet = new Set(speakers);
|
||||||
|
|
||||||
|
if (!nodeIds.has(dialog.startNodeId))
|
||||||
|
errors.push(`startNodeId '${dialog.startNodeId}' does not match any node id`);
|
||||||
|
|
||||||
|
for (const node of dialog.nodes) {
|
||||||
|
if (!speakerSet.has(node.speaker))
|
||||||
|
errors.push(`node '${node.id}': unknown speaker '${node.speaker}'`);
|
||||||
|
|
||||||
|
if (node.nextNodeId !== undefined && !nodeIds.has(node.nextNodeId))
|
||||||
|
errors.push(`node '${node.id}': nextNodeId '${node.nextNodeId}' does not match any node id`);
|
||||||
|
|
||||||
|
for (const cond of node.conditions ?? []) {
|
||||||
|
const varName = conditionVariable(cond);
|
||||||
|
if (!conditionSet.has(varName))
|
||||||
|
errors.push(`node '${node.id}': unknown condition variable '${varName}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const action of node.actions ?? [])
|
||||||
|
if (!actionSet.has(action.type))
|
||||||
|
errors.push(`node '${node.id}': unknown action type '${action.type}'`);
|
||||||
|
|
||||||
|
for (const choice of node.choices ?? [])
|
||||||
|
if (choice.nextNodeId !== undefined && !nodeIds.has(choice.nextNodeId))
|
||||||
|
errors.push(`node '${node.id}': choice '${choice.text}': nextNodeId '${choice.nextNodeId}' does not match any node id`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidDialog(
|
||||||
|
v: unknown,
|
||||||
|
conditions: string[],
|
||||||
|
actions: string[],
|
||||||
|
speakers: string[],
|
||||||
|
): v is Dialog {
|
||||||
|
return validateDialog(v, conditions, actions, speakers).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DialogEngine {
|
||||||
|
private readonly nodeMap: Map<string, DialogNode>;
|
||||||
|
private readonly startNodeId: string;
|
||||||
|
private currentNode: DialogNode | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
dialog: Dialog,
|
||||||
|
private readonly options: DialogRuntimeOptions,
|
||||||
|
) {
|
||||||
|
this.nodeMap = new Map(dialog.nodes.map(n => [n.id, n]));
|
||||||
|
this.startNodeId = dialog.startNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async advance(nodeId?: string): Promise<DialogNodeView | null> {
|
||||||
|
let targetId = nodeId ?? this.currentNode?.nextNodeId ?? (this.currentNode === null ? this.startNodeId : undefined);
|
||||||
|
|
||||||
|
while (targetId !== undefined) {
|
||||||
|
const node = this.nodeMap.get(targetId);
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
if (!this.evaluateConditions(node.conditions ?? [])) {
|
||||||
|
targetId = node.nextNodeId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentNode = node;
|
||||||
|
|
||||||
|
for (const action of node.actions ?? []) {
|
||||||
|
await this.options.actions[action.type]?.(action.arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const choices = this.filterChoices(node.choices);
|
||||||
|
|
||||||
|
return {
|
||||||
|
speaker: node.speaker,
|
||||||
|
text: node.text,
|
||||||
|
choices,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterChoices(choices?: DialogChoice[]): DialogChoice[] | undefined {
|
||||||
|
if (!choices) return undefined;
|
||||||
|
const filtered = choices.filter(choice => {
|
||||||
|
if (choice.nextNodeId === undefined) return true;
|
||||||
|
const target = this.nodeMap.get(choice.nextNodeId);
|
||||||
|
return target ? this.evaluateConditions(target.conditions ?? []) : false;
|
||||||
|
});
|
||||||
|
return filtered.length > 0 ? filtered : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private evaluateConditions(conditions: string[]): boolean {
|
||||||
|
return conditions.every(c => this.evaluateCondition(parseCondition(c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private evaluateCondition({ variable, negate, operator, value }: ParsedCondition): boolean {
|
||||||
|
const val = this.options.variables[variable];
|
||||||
|
|
||||||
|
if (operator === 'null') return val == null;
|
||||||
|
if (operator === '~null') return val != null;
|
||||||
|
|
||||||
|
if (operator === undefined)
|
||||||
|
return negate ? !Boolean(val) : Boolean(val);
|
||||||
|
|
||||||
|
if (operator === '==') return val === value;
|
||||||
|
if (operator === '!=') return val !== value;
|
||||||
|
|
||||||
|
if (typeof val !== 'number' || typeof value !== 'number') return false;
|
||||||
|
if (operator === '<') return val < value;
|
||||||
|
if (operator === '>') return val > value;
|
||||||
|
if (operator === '<=') return val <= value;
|
||||||
|
if (operator === '>=') return val >= value;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async next() {
|
||||||
|
const nextNode = await this.advance();
|
||||||
|
if (nextNode === null) return { done: true, value: null };
|
||||||
|
return { done: false, value: nextNode };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
export interface DialogAction {
|
||||||
|
type: string;
|
||||||
|
arg?: string | number | boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogChoice {
|
||||||
|
text: string;
|
||||||
|
nextNodeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogNode {
|
||||||
|
id: string;
|
||||||
|
speaker: string;
|
||||||
|
text: string;
|
||||||
|
nextNodeId?: string;
|
||||||
|
choices?: DialogChoice[];
|
||||||
|
conditions?: string[];
|
||||||
|
actions?: DialogAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dialog {
|
||||||
|
nodes: DialogNode[];
|
||||||
|
startNodeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDialogAction(v: unknown): v is DialogAction {
|
||||||
|
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(v => typeof v === 'string')))
|
||||||
|
&& (n.actions === undefined || (Array.isArray(n.actions) && n.actions.every(isDialogAction)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDialog(v: unknown): v is Dialog {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export type Key<T> = keyof T;
|
||||||
|
export type Value<T, K extends Key<T> = Key<T>> = T[K];
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
startNodeId: start
|
||||||
|
|
||||||
|
nodes:
|
||||||
|
- id: start
|
||||||
|
speaker: npc
|
||||||
|
text: "Ah, traveler. I sense you seek passage through my gates."
|
||||||
|
nextNodeId: sword_path
|
||||||
|
|
||||||
|
- id: sword_path
|
||||||
|
speaker: npc
|
||||||
|
text: "I see you carry a blade. Warriors are always welcome here."
|
||||||
|
nextNodeId: no_sword_path
|
||||||
|
conditions:
|
||||||
|
- hasSword
|
||||||
|
|
||||||
|
- id: no_sword_path
|
||||||
|
speaker: npc
|
||||||
|
text: "You come unarmed? Brave, or perhaps foolish. You may still pass."
|
||||||
|
nextNodeId: end
|
||||||
|
conditions:
|
||||||
|
- ~hasSword
|
||||||
|
|
||||||
|
- id: end
|
||||||
|
speaker: npc
|
||||||
|
text: "Safe travels."
|
||||||
|
choices:
|
||||||
|
- text: "Farewell."
|
||||||
|
|
@ -1,34 +1,16 @@
|
||||||
import { gameLoop } from "@common/game";
|
import { DialogEngine, isValidDialog } from "@common/rpg/dialog";
|
||||||
import Input from "@common/input";
|
import dialogYml from './dialog.yml';
|
||||||
|
|
||||||
const setup = () => {
|
export default async function main() {
|
||||||
let x = window.innerWidth / 2 - 16;
|
// console.log(dialogYml);
|
||||||
let y = window.innerHeight / 2 - 16;
|
if (isValidDialog(dialogYml, ['hasSword'], [], ['player', 'npc'])) {
|
||||||
const ball = document.createElement('div');
|
const dialog = new DialogEngine(dialogYml, {
|
||||||
|
variables: { hasSword: false },
|
||||||
ball.style.display = 'block';
|
actions: {},
|
||||||
ball.style.width = '32px';
|
});
|
||||||
ball.style.height = '32px';
|
// console.log(await dialog.advance());
|
||||||
ball.style.borderRadius = '50%';
|
for await (const node of dialog) {
|
||||||
ball.style.backgroundColor = 'red';
|
console.log(node);
|
||||||
ball.style.position = 'absolute';
|
}
|
||||||
|
}
|
||||||
document.body.append(ball);
|
|
||||||
|
|
||||||
const speed = Math.min(window.innerHeight, window.innerWidth);
|
|
||||||
|
|
||||||
return { x, y, speed, ball };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const frame = (dt: number, state: ReturnType<typeof setup>) => {
|
|
||||||
const dx = Input.getHorizontal();
|
|
||||||
const dy = Input.getVertical();
|
|
||||||
|
|
||||||
state.x += state.speed * dx * dt;
|
|
||||||
state.y += state.speed * dy * dt;
|
|
||||||
|
|
||||||
state.ball.style.left = `${state.x}px`;
|
|
||||||
state.ball.style.top = `${state.y}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default gameLoop(setup, frame);
|
|
||||||
Loading…
Reference in New Issue