Skip to content

Commit 025c4ea

Browse files
committed
✨ feat(avg): 实现AVG对话树引擎
【feat】 - 新增AVG对话树引擎 (`avgDialogueEngine.ts`),用于管理对话树的解析、执行和状态。 - 引入节点解析器 (`nodeResolver.ts`),负责处理不同类型的对话节点和分支逻辑。 - 实现条件求值器 (`conditionEvaluator.ts`),根据游戏上下文动态判断对话可达性。 - 定义对话树数据模型 (`dialogueTree.ts`),包括节点、选项、条件和动作。 【test】 - 为AVG对话树引擎、节点解析器和条件求值器添加了全面的单元测试 (`phase11.test.ts`)。 - 测试覆盖了条件评估、节点流转、玩家选择和引擎状态管理等核心功能。 【docs】 - 更新架构迁移计划文档,标记AVG对话树引擎阶段(11.1-11.4)为已完成。
1 parent fb14782 commit 025c4ea

6 files changed

Lines changed: 1309 additions & 4 deletions

File tree

docs/plans/2026-05-12-slg-rpg-avg-ai-architecture-migration.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,10 +1462,10 @@ assets/presets/
14621462

14631463
### 阶段十一:AVG 对话树引擎
14641464

1465-
- [ ] 11.1 AVG 对话引擎
1466-
- [ ] 11.2 对话树结构
1467-
- [ ] 11.3 节点解析器 + 条件求值
1468-
- [ ] 11.4 单元测试
1465+
- [x] 11.1 AVG 对话引擎`hooks/useGame/engine/avgDialogueEngine.ts`
1466+
- [x] 11.2 对话树结构`models/avg/dialogueTree.ts`
1467+
- [x] 11.3 节点解析器 + 条件求值`hooks/useGame/avg/dialogue/nodeResolver.ts` + `conditionEvaluator.ts`
1468+
- [x] 11.4 单元测试`hooks/useGame/phase11.test.ts`,52 tests 通过
14691469
- [ ] 11.5 build 通过
14701470

14711471
### 阶段十二:AVG 关系图谱/好感度(+ Galgame 扩展)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* AVG 对话树 — 条件求值器
3+
*
4+
* 基于当前游戏状态判断对话分支可达性。
5+
*/
6+
7+
import type { DialogueCondition, DialogueChoice } from '../../../../models/avg/dialogueTree';
8+
9+
export interface GameContext {
10+
stats: Record<string, number>;
11+
intimacy: Record<string, number>;
12+
tasks: Record<string, string>;
13+
items: string[];
14+
flags: Record<string, boolean>;
15+
}
16+
17+
export class ConditionEvaluator {
18+
private context: GameContext;
19+
20+
constructor(context: GameContext) {
21+
this.context = context;
22+
}
23+
24+
updateContext(context: Partial<GameContext>): void {
25+
if (context.stats) this.context.stats = { ...this.context.stats, ...context.stats };
26+
if (context.intimacy) this.context.intimacy = { ...this.context.intimacy, ...context.intimacy };
27+
if (context.tasks) this.context.tasks = { ...this.context.tasks, ...context.tasks };
28+
if (context.items) this.context.items = [...new Set([...this.context.items, ...context.items])];
29+
if (context.flags) this.context.flags = { ...this.context.flags, ...context.flags };
30+
}
31+
32+
evaluate(condition: DialogueCondition): boolean {
33+
switch (condition.type) {
34+
case 'always_true':
35+
return true;
36+
case 'always_false':
37+
return false;
38+
case 'stat_check':
39+
return this._checkStat(condition);
40+
case 'intimacy_check':
41+
return this._checkIntimacy(condition);
42+
case 'task_check':
43+
return this._checkTask(condition);
44+
case 'item_check':
45+
return this._checkItem(condition);
46+
case 'flag_check':
47+
return this._checkFlag(condition);
48+
default:
49+
return false;
50+
}
51+
}
52+
53+
evaluateChoices(choices: DialogueChoice[]): DialogueChoice[] {
54+
return choices.filter((choice) => {
55+
if (!choice.condition) return true;
56+
return this.evaluate(choice.condition);
57+
});
58+
}
59+
60+
private _checkStat(condition: DialogueCondition): boolean {
61+
if (!condition.field) return false;
62+
const value = this.context.stats[condition.field];
63+
if (value === undefined) return false;
64+
return this._compare(value, condition);
65+
}
66+
67+
private _checkIntimacy(condition: DialogueCondition): boolean {
68+
if (!condition.field) return false;
69+
const value = this.context.intimacy[condition.field];
70+
if (value === undefined) return false;
71+
return this._compare(value, condition);
72+
}
73+
74+
private _checkTask(condition: DialogueCondition): boolean {
75+
if (!condition.field) return false;
76+
const value = this.context.tasks[condition.field];
77+
if (value === undefined) return false;
78+
if (condition.operator === 'eq') return value === condition.value;
79+
if (condition.operator === 'neq') return value !== condition.value;
80+
return false;
81+
}
82+
83+
private _checkItem(condition: DialogueCondition): boolean {
84+
const hasItem = this.context.items.includes(condition.value as string);
85+
return condition.operator === 'has' ? hasItem : hasItem === (condition.value as boolean);
86+
}
87+
88+
private _checkFlag(condition: DialogueCondition): boolean {
89+
if (!condition.field) return false;
90+
const value = this.context.flags[condition.field];
91+
if (value === undefined) return false;
92+
if (condition.operator === 'eq') return value === condition.value;
93+
return false;
94+
}
95+
96+
private _compare(actual: number, condition: DialogueCondition): boolean {
97+
const expected = typeof condition.value === 'number' ? condition.value : Number(condition.value);
98+
switch (condition.operator) {
99+
case 'gte':
100+
return actual >= expected;
101+
case 'lte':
102+
return actual <= expected;
103+
case 'eq':
104+
return actual === expected;
105+
case 'neq':
106+
return actual !== expected;
107+
default:
108+
return false;
109+
}
110+
}
111+
}
112+
113+
export function createConditionEvaluator(context: GameContext): ConditionEvaluator {
114+
return new ConditionEvaluator(context);
115+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* AVG 对话树 — 节点解析器
3+
*
4+
* 解析对话树节点,处理类型分发和执行动作。
5+
*/
6+
7+
import type {
8+
DialogueNode,
9+
DialogueAction,
10+
DialogueState,
11+
DialogueHistoryEntry,
12+
} from '../../../../models/avg/dialogueTree';
13+
import { ConditionEvaluator } from './conditionEvaluator';
14+
import type { GameContext } from './conditionEvaluator';
15+
16+
export interface NodeResolveResult {
17+
node: DialogueNode;
18+
state: DialogueState;
19+
actions: DialogueAction[];
20+
availableChoices: { id: string; text: string; consequenceHint?: string }[];
21+
historyEntry: DialogueHistoryEntry;
22+
}
23+
24+
export class NodeResolver {
25+
private evaluator: ConditionEvaluator;
26+
27+
constructor(context: GameContext) {
28+
this.evaluator = new ConditionEvaluator(context);
29+
}
30+
31+
updateContext(context: Partial<GameContext>): void {
32+
this.evaluator.updateContext(context);
33+
}
34+
35+
resolve(
36+
treeNodes: DialogueNode[],
37+
state: DialogueState,
38+
choiceId?: string
39+
): NodeResolveResult | null {
40+
const nodeMap = this._buildNodeMap(treeNodes);
41+
const currentNode = nodeMap.get(state.currentNodeId);
42+
if (!currentNode) return null;
43+
44+
if (currentNode.condition && !this.evaluator.evaluate(currentNode.condition)) {
45+
return null;
46+
}
47+
48+
const newState = { ...state, visitedNodeIds: [...state.visitedNodeIds, currentNode.id] };
49+
let nextNodeId: string | undefined;
50+
51+
if (currentNode.type === 'choice' && currentNode.choices) {
52+
const availableChoices = this.evaluator.evaluateChoices(currentNode.choices);
53+
54+
if (choiceId) {
55+
const chosen = availableChoices.find((c) => c.id === choiceId);
56+
if (chosen) {
57+
nextNodeId = chosen.targetNodeId;
58+
this._executeActions(chosen.actions);
59+
newState.history = [
60+
...state.history,
61+
this._makeHistoryEntry(currentNode, choiceId),
62+
];
63+
} else {
64+
return null;
65+
}
66+
} else {
67+
newState.history = [
68+
...state.history,
69+
this._makeHistoryEntry(currentNode, undefined),
70+
];
71+
}
72+
73+
const simplifiedChoices = availableChoices.map((c) => ({
74+
id: c.id,
75+
text: c.text,
76+
consequenceHint: c.consequenceHint,
77+
}));
78+
79+
newState.currentNodeId = nextNodeId ?? currentNode.id;
80+
81+
return {
82+
node: currentNode,
83+
state: newState,
84+
actions: choiceId ? (currentNode.choices?.find((c) => c.id === choiceId)?.actions ?? []) : [],
85+
availableChoices: simplifiedChoices,
86+
historyEntry: this._makeHistoryEntry(currentNode, choiceId),
87+
};
88+
}
89+
90+
if (currentNode.type === 'condition' && currentNode.choices) {
91+
const availableChoices = this.evaluator.evaluateChoices(currentNode.choices);
92+
if (availableChoices.length > 0) {
93+
const first = availableChoices[0];
94+
nextNodeId = first.targetNodeId;
95+
this._executeActions(first.actions);
96+
}
97+
}
98+
99+
if (currentNode.type === 'action') {
100+
this._executeActions(currentNode.actions);
101+
}
102+
103+
if (currentNode.type === 'jump' && currentNode.nextNodeId) {
104+
nextNodeId = currentNode.nextNodeId;
105+
}
106+
107+
if (!nextNodeId && currentNode.nextNodeId) {
108+
nextNodeId = currentNode.nextNodeId;
109+
}
110+
111+
if (nextNodeId) {
112+
newState.currentNodeId = nextNodeId;
113+
} else if (!nextNodeId && !currentNode.choices && !currentNode.nextNodeId) {
114+
newState.isComplete = true;
115+
}
116+
117+
this._executeActions(currentNode.actions);
118+
119+
return {
120+
node: currentNode,
121+
state: newState,
122+
actions: currentNode.actions,
123+
availableChoices: currentNode.choices
124+
? this.evaluator.evaluateChoices(currentNode.choices).map((c) => ({
125+
id: c.id,
126+
text: c.text,
127+
consequenceHint: c.consequenceHint,
128+
}))
129+
: [],
130+
historyEntry: this._makeHistoryEntry(currentNode, choiceId),
131+
};
132+
}
133+
134+
private _buildNodeMap(nodes: DialogueNode[]): Map<string, DialogueNode> {
135+
const map = new Map<string, DialogueNode>();
136+
nodes.forEach((node) => map.set(node.id, node));
137+
return map;
138+
}
139+
140+
private _executeActions(actions: DialogueAction[]): void {
141+
for (const action of actions) {
142+
this.evaluator.updateContext(this._actionToContext(action));
143+
}
144+
}
145+
146+
private _actionToContext(action: DialogueAction): Partial<GameContext> {
147+
switch (action.type) {
148+
case 'intimacy_change':
149+
return { intimacy: { [action.target]: action.value as number } };
150+
case 'flag_set':
151+
return { flags: { [action.target]: action.value as boolean } };
152+
case 'item_change':
153+
return { items: [action.value as string] };
154+
case 'task_update':
155+
return { tasks: { [action.target]: action.value as string } };
156+
default:
157+
return {};
158+
}
159+
}
160+
161+
private _makeHistoryEntry(node: DialogueNode, chosenChoiceId?: string): DialogueHistoryEntry {
162+
return {
163+
nodeId: node.id,
164+
speaker: node.speaker,
165+
text: node.text,
166+
chosenChoiceId,
167+
timestamp: Date.now(),
168+
};
169+
}
170+
}
171+
172+
export function createNodeResolver(context: GameContext): NodeResolver {
173+
return new NodeResolver(context);
174+
}

0 commit comments

Comments
 (0)