import { GameState } from '../core/GameState';
/**
* Рантайм диалоговых графов, отделённый от отрисовки.
* Граф — данные (TS/JSON): узлы с репликами, выборами и условиями по GameState.
* Игра рисует диалог своим view (например DialogueBox) и вызывает advance()/pick().
*/
/** Условие по переменной состояния. */
export interface VarCondition {
key: string;
op: 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le';
value: number | string;
}
/** Действия, применяемые к GameState при входе в узел/выборе. */
export interface DialogueEffects {
setFlags?: string[];
clearFlags?: string[];
setVars?: Record<string, number | string | boolean>;
}
/** Условия показа узла/варианта. */
export interface DialogueConditions {
/** Все эти флаги должны быть установлены. */
when?: string[];
/** Ни один из этих флагов не должен быть установлен. */
whenNot?: string[];
/** Условие по переменной. */
whenVar?: VarCondition;
}
export interface DialogueChoice extends DialogueEffects, DialogueConditions {
text: string;
/** Следующий узел (по умолчанию — конец диалога). */
next?: string;
}
export interface DialogueNode extends DialogueEffects, DialogueConditions {
/** Имя говорящего (опционально). */
speaker?: string;
/** Текст реплики. Узел без текста — «действие»: применяет эффекты и уходит в next. */
text?: string;
/** Варианты ответа игрока. */
choices?: DialogueChoice[];
/** Следующий узел. */
next?: string;
/** Явный конец диалога (для конечных узлов без choices/next). */
end?: boolean;
}
export interface DialogueGraph {
/** id стартового узла. */
start: string;
nodes: Record<string, DialogueNode>;
}
/** Вид: игра рисует реплику и варианты своими средствами. */
export interface DialogueView {
/** Показать реплику. choices пуст, если выбора нет. */
show(node: { speaker?: string; text: string; choices: { index: number; text: string }[] }): void;
hide(): void;
}
const MAX_STEPS = 1000; // защита от циклов в графе без текста
export class DialogueRunner {
/** Вызывается по завершении диалога. */
onFinish: ((graph: DialogueGraph) => void) | null = null;
private graph: DialogueGraph | null = null;
private view: DialogueView | null = null;
private shownChoices: { index: number; text: string }[] = [];
private pendingNext: string | undefined;
private waitingForChoice = false;
constructor(
private state: GameState,
view?: DialogueView
) {
this.view = view ?? null;
}
/** Подключить/заменить view (например, после пересоздания UI). */
setView(view: DialogueView): void {
this.view = view;
}
get active(): boolean {
return this.graph !== null;
}
/** Запустить диалог. */
start(graph: DialogueGraph): void {
this.graph = graph;
this.waitingForChoice = false;
this.pendingNext = undefined;
this.enterNode(graph.start);
}
/**
* «Дальше»: показывает следующий узел. Во время выбора варианта игнорируется.
*/
advance(): void {
if (!this.graph || this.waitingForChoice) return;
const next = this.pendingNext;
if (next === undefined) {
this.finish();
return;
}
this.enterNode(next);
}
/** Выбрать вариант ответа. */
pick(index: number): void {
if (!this.graph || !this.waitingForChoice) return;
const shown = this.shownChoices[index];
if (!shown) return;
const choice = this.currentNode?.choices?.[shown.index];
if (!choice) return;
this.waitingForChoice = false;
this.shownChoices = [];
this.applyEffects(choice);
if (choice.next === undefined) {
this.finish();
} else {
this.enterNode(choice.next);
}
}
/** Завершить диалог досрочно. */
finish(): void {
const graph = this.graph;
this.graph = null;
this.waitingForChoice = false;
this.shownChoices = [];
this.pendingNext = undefined;
this.view?.hide();
if (graph) this.onFinish?.(graph);
}
private get currentNode(): DialogueNode | null {
if (!this.graph || this.currentId === null) return null;
return this.graph.nodes[this.currentId] ?? null;
}
private currentId: string | null = null;
/** Войти в узел: проверить условия, применить эффекты, показать или продолжить. */
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 (n.when || n.whenNot || n.whenVar) {
if (!this.checkConditions(n)) {
// условие не прошло — уходим по next или заканчиваем
if (n.next !== undefined) {
const nextNode = this.graph.nodes[n.next];
if (!nextNode) {
this.currentId = null;
this.finish();
return;
}
current = { id: n.next, node: nextNode };
continue;
}
this.currentId = null;
this.finish();
return;
}
}
this.currentId = current.id;
this.applyEffects(n);
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 (n.text !== undefined) {
this.show(n, choices);
} else if (choices.length > 0) {
// выбор без реплики: показываем только варианты
this.show({ text: '' }, choices);
} else {
// нет ни текста, ни доступных вариантов — конец
this.currentId = null;
this.finish();
return;
}
return;
}
if (n.text !== undefined) {
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();
}
private show(
n: { speaker?: string; text?: string },
choices: { index: number; text: string }[],
textlessChoice = false
): void {
this.shownChoices = choices;
this.waitingForChoice = choices.length > 0;
this.view?.show({
speaker: n.speaker,
text: textlessChoice ? '' : (n.text ?? ''),
choices
});
}
private applyEffects(e: DialogueEffects): 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);
}
private checkConditions(c: DialogueConditions): boolean {
for (const f of c.when ?? []) {
if (!this.state.hasFlag(f)) return false;
}
for (const f of c.whenNot ?? []) {
if (this.state.hasFlag(f)) return false;
}
if (c.whenVar) {
const v = this.state.getVar(c.whenVar.key);
const { op, value } = c.whenVar;
switch (op) {
case 'eq':
if (v !== value) return false;
break;
case 'ne':
if (v === value) return false;
break;
case 'gt':
if (!(Number(v) > Number(value))) return false;
break;
case 'lt':
if (!(Number(v) < Number(value))) return false;
break;
case 'ge':
if (!(Number(v) >= Number(value))) return false;
break;
case 'le':
if (!(Number(v) <= Number(value))) return false;
break;
}
}
return true;
}
}