From 8fd0d317cf074da94619973ab5ba7014346423fb Mon Sep 17 00:00:00 2001 From: Pabloader Date: Fri, 24 Apr 2026 16:53:18 +0000 Subject: [PATCH] Dialogs engine --- src/common/rpg/dialog.ts | 200 ++++++++++++++++++++++++++++++++ src/common/rpg/types.ts | 55 +++++++++ src/common/types.ts | 2 + src/games/playground/dialog.yml | 27 +++++ src/games/playground/index.tsx | 48 +++----- 5 files changed, 299 insertions(+), 33 deletions(-) create mode 100644 src/common/rpg/dialog.ts create mode 100644 src/common/rpg/types.ts create mode 100644 src/common/types.ts create mode 100644 src/games/playground/dialog.yml diff --git a/src/common/rpg/dialog.ts b/src/common/rpg/dialog.ts new file mode 100644 index 0000000..21dce61 --- /dev/null +++ b/src/common/rpg/dialog.ts @@ -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; +export type DialogActions = Record Promise | 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; + 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 { + 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 }; + } +} \ No newline at end of file diff --git a/src/common/rpg/types.ts b/src/common/rpg/types.ts new file mode 100644 index 0000000..1a1228d --- /dev/null +++ b/src/common/rpg/types.ts @@ -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).type === 'string'; +} + +function isDialogChoice(v: unknown): v is DialogChoice { + if (typeof v !== 'object' || v === null) return false; + const c = v as Record; + 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; + 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; + return Array.isArray(d.nodes) && d.nodes.every(isDialogNode) + && typeof d.startNodeId === 'string'; +} diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 0000000..b670602 --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,2 @@ +export type Key = keyof T; +export type Value = Key> = T[K]; \ No newline at end of file diff --git a/src/games/playground/dialog.yml b/src/games/playground/dialog.yml new file mode 100644 index 0000000..e287902 --- /dev/null +++ b/src/games/playground/dialog.yml @@ -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." diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index 38684fc..eb3eedf 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -1,34 +1,16 @@ -import { gameLoop } from "@common/game"; -import Input from "@common/input"; +import { DialogEngine, isValidDialog } from "@common/rpg/dialog"; +import dialogYml from './dialog.yml'; -const setup = () => { - let x = window.innerWidth / 2 - 16; - let y = window.innerHeight / 2 - 16; - const ball = document.createElement('div'); - - ball.style.display = 'block'; - ball.style.width = '32px'; - ball.style.height = '32px'; - ball.style.borderRadius = '50%'; - ball.style.backgroundColor = 'red'; - 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) => { - 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); \ No newline at end of file +export default async function main() { + // console.log(dialogYml); + if (isValidDialog(dialogYml, ['hasSword'], [], ['player', 'npc'])) { + const dialog = new DialogEngine(dialogYml, { + variables: { hasSword: false }, + actions: {}, + }); + // console.log(await dialog.advance()); + for await (const node of dialog) { + console.log(node); + } + } +} \ No newline at end of file