diff --git a/docs/engine/practices.md b/docs/engine/practices.md index b70ee71..a66dbc3 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -77,10 +77,14 @@ Условия ветки: `when`/`whenNot` — флаги, `whenVars` — несколько переменных (AND), `hasItem` — предметы. Предметы раннер знает только через -`DialogueWorld` (игра передаёт `setWorld`) — без него `hasItem`-ветки -скрыты. Условие нужно **вне диалога** (квест-стадия, фильтр контента) — -не пиши замыкание `ready: (s) => ...`: вызывай чистый `evalConditions(c, -state, world)` — то же правило, что проверяет граф, и валидатор его видит. +`DialogueWorld` (игра передаёт `setHooks({ world })`) — без него +`hasItem`-ветки скрыты. Условие нужно **вне диалога** (квест-стадия, +фильтр контента) — не пиши замыкание `ready: (s) => ...`: вызывай чистый +`evalConditions(c, state, world)` — то же правило, что проверяет граф, и +валидатор его видит. Сюжетные действия («дал предмет», «звук», «поляна +зазеленела») — не код в `onFinish`, а данные `do: [{ kind, ... }]` в +узле/выборе: игра получает их через `runner.onEffect` (см. +ui-and-dialogue.md «Игровые эффекты»), валидатор проверяет id. ## Ситуация: упал смоук / проверка diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index ac5dbe4..721533a 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -143,7 +143,7 @@ ```ts const runner = new DialogueRunner(gameState, dialogueBoxView); -runner.onFinish = (graph) => { /* квесты, сейв, сцены */ }; +runner.onFinish = (graph, result) => { /* квесты, сейв, сцены */ }; runner.start(graphs['elder_first']); // показать первый узел // в update: по клику/Space @@ -151,6 +151,8 @@ runner.pick(choiceIndex); // выбор варианта (из view) runner.active; // идёт ли диалог +runner.path; // показанные узлы по порядку +runner.result; // { lastNodeId, path, picks } после завершения ``` ### Механика узлов @@ -184,6 +186,38 @@ и при выборе варианта. - Циклы из узлов без текста обрываются безопасно (MAX_STEPS). +### Игровые эффекты (do[]) и результат onFinish + +`setFlags`/`setVars` движок применяет сам (это про GameState). Всё, что +знает только конкретная игра — дать предмет, звук, всплывашку, сюжетное +действие — пишется в графе данными (`do[]`), а движок лишь **эмитит** +операции; исполнение — на стороне игры (EffectSink). Никаких Inventory в +движке: + +```ts +// в графе: узел или выбор может нести do[] +{ text: 'Держи ткань.', do: [{ kind: 'giveItem', id: 'cloth', count: 1 }] } + +// в игре — приёмник эффектов +runner.onEffect = (op, at) => sink.apply(op, at); +// kind: 'giveItem'|'takeItem'|'sound'|'toast'|'custom' + id/count/text/payload +``` + +Порядок применения эффектов: `setFlags` → `clearFlags` → `setVars` → +`do[]` (каждый op — отдельный вызов `onEffect`). Неизвестный kind — ошибка +валидатора контента, рантайм его игнорирует. + +Завершение диалога отдаёт и **результат** — как граф был пройден: + +```ts +runner.onFinish = (graph, result) => { + result.lastNodeId; // последний показанный узел + result.path; // ids показанных узлов по порядку + result.picks; // [{ nodeId, index, text }] — выборы игрока +}; +runner.result; // то же самое остаётся доступным после завершения +``` + ### View Любой объект с двумя методами — например, обёртка над DialogueBox: diff --git a/packages/engine/src/dialogue/DialogueRunner.ts b/packages/engine/src/dialogue/DialogueRunner.ts index 0b21553..cff0238 100644 --- a/packages/engine/src/dialogue/DialogueRunner.ts +++ b/packages/engine/src/dialogue/DialogueRunner.ts @@ -18,6 +18,42 @@ setFlags?: string[]; clearFlags?: string[]; setVars?: Record; + /** + * Игровые эффекты как данные: движок только эмитит их через onEffect, + * исполнение — на стороне игры (EffectSink). Неизвестный kind — ошибка + * валидатора, рантайм его игнорирует. + */ + do?: DialogueEffectOp[]; +} + +/** Одна игровая операция эффекта (что именно она значит — знает игра). */ +export interface DialogueEffectOp { + kind: 'giveItem' | 'takeItem' | 'sound' | 'toast' | 'custom'; + /** id предмета (give/take), звука (sound) или имя сюжетного эффекта (custom). */ + id?: string; + /** Количество (give/take), по умолчанию 1. */ + count?: number; + /** Текст всплывашки (toast). */ + text?: string; + /** Свободные параметры (custom). */ + payload?: Record; +} + +/** Итог пройденного диалога: где остановились, как дошли, что выбрали. */ +export interface DialogueResult { + /** id последнего показанного узла (null — ни одного). */ + lastNodeId: string | null; + /** ids узлов по порядку показа. */ + path: string[]; + /** Выборы игрока: узел, индекс в узле, текст варианта. */ + picks: { nodeId: string; index: number; text: string }[]; +} + +/** Хуки мира: предикаты условий и резолв ключей строк (локализация). */ +export interface DialogueHooks { + world?: DialogueWorld; + /** Ключ строки → текст (textKey); нет резолва — ключ и есть текст. */ + resolve?: (key: string) => string; } /** @@ -127,21 +163,32 @@ const MAX_STEPS = 1000; // защита от циклов в графе без текста export class DialogueRunner { - /** Вызывается по завершении диалога. */ - onFinish: ((graph: DialogueGraph) => void) | null = null; + /** Вызывается по завершении диалога: граф + как он был пройден. */ + onFinish: ((graph: DialogueGraph, result: DialogueResult) => void) | null = null; + /** + * Игровой эффект из `do[]`: движок только сообщает, исполнение — игра + * (EffectSink). `at` — где сработал: узел или выбор в узле. + */ + onEffect: ((op: DialogueEffectOp, at: { nodeId: string; choice?: number }) => void) | null = + null; private graph: DialogueGraph | null = null; private view: DialogueView | null = null; + private hooks: DialogueHooks | undefined; private shownChoices: { index: number; text: string }[] = []; private pendingNext: string | undefined; private awaitingChoice = false; + private walkedPath: string[] = []; + private madePicks: { nodeId: string; index: number; text: string }[] = []; + private walkedResult: DialogueResult | null = null; constructor( private state: GameState, view?: DialogueView, - private world?: DialogueWorld + hooks?: DialogueHooks ) { this.view = view ?? null; + this.hooks = hooks; } /** Подключить/заменить view (например, после пересоздания UI). */ @@ -149,9 +196,9 @@ this.view = view; } - /** Подключить/заменить предикаты мира (сумка и т.п.). */ - setWorld(world: DialogueWorld): void { - this.world = world; + /** Подключить/заменить хуки мира (сумка, локализация строк). */ + setHooks(hooks: DialogueHooks): void { + this.hooks = hooks; } get active(): boolean { @@ -180,11 +227,24 @@ return this.awaitingChoice; } + /** Показанные узлы по порядку (текущий прогон). */ + get path(): string[] { + return this.walkedPath; + } + + /** Итог последнего завершённого диалога (null — не завершался). */ + get result(): DialogueResult | null { + return this.walkedResult; + } + /** Запустить диалог. */ start(graph: DialogueGraph): void { this.graph = graph; this.awaitingChoice = false; this.pendingNext = undefined; + this.walkedPath = []; + this.madePicks = []; + this.walkedResult = null; this.enterNode(graph.start); } @@ -209,9 +269,13 @@ const choice = this.currentNode?.choices?.[shown.index]; if (!choice) return; + const atNode = this.currentId; this.awaitingChoice = false; this.shownChoices = []; - this.applyEffects(choice); + if (atNode !== null) { + this.madePicks.push({ nodeId: atNode, index: shown.index, text: shown.text }); + } + this.applyEffects(choice, atNode, shown.index); if (choice.next === undefined) { this.finish(); } else { @@ -227,7 +291,14 @@ this.shownChoices = []; this.pendingNext = undefined; this.view?.hide(); - if (graph) this.onFinish?.(graph); + if (graph) { + this.walkedResult = { + lastNodeId: this.walkedPath.length > 0 ? this.walkedPath[this.walkedPath.length - 1] : null, + path: [...this.walkedPath], + picks: [...this.madePicks] + }; + this.onFinish?.(graph, this.walkedResult); + } } private get currentNode(): DialogueNode | null { @@ -273,7 +344,7 @@ } this.currentId = current.id; - this.applyEffects(n); + this.applyEffects(n, this.currentId); if (n.choices && n.choices.length > 0) { const choices: { index: number; text: string }[] = n.choices @@ -325,6 +396,7 @@ choices: { index: number; text: string }[], textlessChoice = false ): void { + if (this.currentId !== null) this.walkedPath.push(this.currentId); this.shownChoices = choices; this.awaitingChoice = choices.length > 0; this.view?.show({ @@ -334,13 +406,16 @@ }); } - private applyEffects(e: DialogueEffects): void { + private applyEffects(e: DialogueEffects, atNode: string | null, choice?: number): void { for (const f of e.setFlags ?? []) this.state.setFlag(f); for (const f of e.clearFlags ?? []) this.state.clearFlag(f); for (const [k, v] of Object.entries(e.setVars ?? {})) this.state.setVar(k, v); + if (atNode === null) return; + const at = { nodeId: atNode, choice }; + for (const op of e.do ?? []) this.onEffect?.(op, at); } private checkConditions(c: DialogueConditions): boolean { - return evalConditions(c, this.state, this.world); + return evalConditions(c, this.state, this.hooks?.world); } } \ No newline at end of file diff --git a/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts b/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts index 977fcdb..86af597 100644 --- a/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts +++ b/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts @@ -274,7 +274,7 @@ const state = new GameState(); const world = { hasItem: (id: string) => id === 'cloth' }; const { view, shown } = makeView(); - const dr = new DialogueRunner(state, view, world); + const dr = new DialogueRunner(state, view, { world }); dr.start({ start: 'q', nodes: { @@ -331,4 +331,81 @@ expect(evalConditions({ hasItem: ['cloth'] }, state)).toBe(false); // нет world expect(evalConditions({}, state)).toBe(true); // пустые условия — истина }); + + it('do[] эмитится через onEffect в порядке следования, у узла и у выбора', () => { + const state = new GameState(); + const { view } = makeView(); + const dr = new DialogueRunner(state, view); + const emitted: { kind: string; at: string; choice?: number }[] = []; + dr.onEffect = (op, at) => emitted.push({ kind: op.kind, at: at.nodeId, choice: at.choice }); + + dr.start({ + start: 'hub', + nodes: { + hub: { + do: [{ kind: 'sound', id: 'bell' }], + text: 'Возьми?', + choices: [ + { text: 'Да', next: 'ok', do: [{ kind: 'giveItem', id: 'cloth' }, { kind: 'toast', text: 'Ткань' }] } + ] + }, + ok: { text: 'Держи.', do: [{ kind: 'custom', id: 'plant' }] } + } + }); + // эффекты узла при входе + expect(emitted).toEqual([{ kind: 'sound', at: 'hub' }]); + dr.pick(0); + // сначала эффекты выбора, потом эффекты узла ok + expect(emitted.map((e) => e.kind)).toEqual(['sound', 'giveItem', 'toast', 'custom']); + expect(emitted[3]).toEqual({ kind: 'custom', at: 'ok' }); + expect(emitted[1].choice).toBe(0); + }); + + it('do[] без подписчика и с неизвестным kind не падают', () => { + const state = new GameState(); + const dr = new DialogueRunner(state, makeView().view); + dr.start({ + start: 'a', + nodes: { a: { text: 'Тишина.', do: [{ kind: 'custom' as const, id: 'unknown_thing' }] } } + }); + dr.advance(); + expect(dr.active).toBe(false); + expect(state.hasFlag('nothing')).toBe(false); + }); + + it('path/result: путь показанных узлов и выборы; onFinish получает результат', () => { + const state = new GameState(); + const { view } = makeView(); + const dr = new DialogueRunner(state, view); + let finishedResult: unknown = null; + dr.onFinish = (_graph, result) => { + finishedResult = result; + }; + + dr.start({ + start: 'q', + nodes: { + q: { text: 'Вопрос?', next: 'mid' }, + mid: { text: 'Выбирай.', choices: [{ text: 'Ок', next: 'end' }, { text: 'Нет' }] }, + end: { text: 'Конец.' } + } + }); + expect(dr.path).toEqual(['q']); + dr.advance(); + dr.pick(0); + dr.advance(); + expect(dr.active).toBe(false); + + const expected = { + lastNodeId: 'end', + path: ['q', 'mid', 'end'], + picks: [{ nodeId: 'mid', index: 0, text: 'Ок' }] + }; + expect(dr.result).toEqual(expected); + expect(finishedResult).toEqual(expected); + // result переживает новый запуск и сбрасывается + dr.start({ start: 'a', nodes: { a: { text: 'Заново' } } }); + expect(dr.result).toBeNull(); + expect(dr.path).toEqual(['a']); + }); }); \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 6883426..9219732 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -172,6 +172,9 @@ type DialogueWorld, type DialogueConditions, type DialogueEffects, + type DialogueEffectOp, + type DialogueResult, + type DialogueHooks, type VarCondition } from './dialogue/DialogueRunner';