import { GameState } from '../core/GameState';
import {
hasConditions,
hasText,
evalConditions,
type DialogueChoice,
type DialogueConditions,
type DialogueEffects,
type DialogueEffectOp,
type DialogueGraph,
type DialogueHooks,
type DialogueNode,
type DialogueResult
} from './graph';
/**
* Рантайм обхода диалоговых графов, отделённый от отрисовки и от модели
* (типы графа и чистые предикаты — dialogue/graph.ts). Игра рисует диалог
* своим view (например DialogueBox) и вызывает advance()/pick().
*/
/** Вид: игра рисует реплику и варианты своими средствами. */
export interface DialogueView {
/** Показать реплику. choices пуст, если выбора нет. */
show(node: {
speaker?: string;
text: string;
mood?: string;
tags?: string[];
choices: { index: number; text: string }[];
}): void;
hide(): void;
}
const MAX_STEPS = 1000; // защита от циклов в графе без текста
export class DialogueRunner {
/** Вызывается по завершении диалога: граф + как он был пройден. */
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;
private currentId: string | null = null;
constructor(
private state: GameState,
view?: DialogueView,
hooks?: DialogueHooks
) {
this.view = view ?? null;
this.hooks = hooks;
}
/** Подключить/заменить view (например, после пересоздания UI). */
setView(view: DialogueView): void {
this.view = view;
}
/** Подключить/заменить хуки мира (сумка, локализация строк). */
setHooks(hooks: DialogueHooks): void {
this.hooks = hooks;
}
get active(): boolean {
return this.graph !== null;
}
// --- для агентного моста/отладки: раскрытие текущего состояния ---
/** id текущего узла (или null, если диалог не активен/завершается). */
get nodeId(): string | null {
return this.currentId;
}
/** Текущий узел (для снапшота моста). */
get node(): DialogueNode | null {
if (!this.graph || this.currentId === null) return null;
return this.graph.nodes[this.currentId] ?? null;
}
/** Показанные варианты выбора (индексы — в узел графа). */
get choices(): { index: number; text: string }[] {
return this.shownChoices;
}
/** Ожидается ли выбор варианта. */
get waitingForChoice(): boolean {
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);
}
/**
* «Дальше»: показывает следующий узел. Во время выбора варианта игнорируется.
*/
advance(): void {
if (!this.graph || this.awaitingChoice) return;
const next = this.pendingNext;
if (next === undefined) {
this.finish();
return;
}
this.enterNode(next);
}
/** Выбрать вариант ответа. */
pick(index: number): void {
if (!this.graph || !this.awaitingChoice) return;
const shown = this.shownChoices[index];
if (!shown) return;
const choice = this.node?.choices?.[shown.index];
if (!choice) return;
const atNode = this.currentId;
this.awaitingChoice = false;
this.shownChoices = [];
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 {
this.enterNode(choice.next);
}
}
/** Завершить диалог досрочно. */
finish(): void {
const graph = this.graph;
this.graph = null;
this.awaitingChoice = false;
this.shownChoices = [];
this.pendingNext = undefined;
this.view?.hide();
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 enterNode(id: string): void {
if (!this.graph) return;
const node = this.graph.nodes[id];
if (!node) {
// несуществующий next трактуем как конец
this.finish();
return;
}
// Шагаем через «пустые» узлы (не прошедшие условия или без текста),
// пока не встретим реплику с выбором, реплику с ожиданием или конец.
let current: { id: string; node: DialogueNode } | null = { id, node };
for (let steps = 0; current && steps < MAX_STEPS; steps++) {
const n: DialogueNode = current.node;
if (hasConditions(n) && !this.checkConditions(n)) {
// условие не прошло — уходим по next или заканчиваем
if (n.next === undefined) {
this.currentId = null;
this.finish();
return;
}
const nextNode = this.graph.nodes[n.next];
if (!nextNode) {
this.currentId = null;
this.finish();
return;
}
current = { id: n.next, node: nextNode };
continue;
}
this.currentId = current.id;
this.applyEffects(n, this.currentId);
if (n.choices && n.choices.length > 0) {
const choices: { index: number; text: string }[] = n.choices
.map((c: DialogueChoice, index: number) => ({ c, index }))
.filter(({ c }) => this.checkConditions(c))
.map(({ c, index }) => ({ index, text: c.text }));
if (hasText(n)) {
this.show(n, choices);
} else if (choices.length > 0) {
// выбор без реплики: показываем только варианты
this.show({ text: '' }, choices);
} else {
// нет ни текста, ни доступных вариантов — конец
this.currentId = null;
this.finish();
return;
}
return;
}
if (hasText(n)) {
this.pendingNext = n.next;
this.show(n, []);
return;
}
// Действие без текста: продолжаем по next или заканчиваем
if (n.next === undefined || n.end) {
this.currentId = null;
this.finish();
return;
}
const nextNode = this.graph.nodes[n.next];
if (!nextNode) {
this.currentId = null;
this.finish();
return;
}
current = { id: n.next, node: nextNode };
}
// Защита сработала — принудительный конец
this.currentId = null;
this.finish();
}
/** Текст реплики: textKey резолвится хуком (fallback — сам ключ). */
private resolveText(n: { text?: string; textKey?: string }): string {
if (n.textKey !== undefined) return this.hooks?.resolve?.(n.textKey) ?? n.textKey;
return n.text ?? '';
}
private resolveSpeaker(n: { speaker?: string; speakerKey?: string }): string | undefined {
if (n.speakerKey !== undefined) return this.hooks?.resolve?.(n.speakerKey) ?? n.speakerKey;
return n.speaker;
}
private show(
n: DialogueNode,
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({
speaker: this.resolveSpeaker(n),
text: textlessChoice ? '' : this.resolveText(n),
mood: n.mood,
tags: n.tags,
choices
});
}
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.hooks?.world);
}
}