diff --git a/packages/behavior-tree/src/Runtime/Executors/InverterExecutor.ts b/packages/behavior-tree/src/Runtime/Executors/InverterExecutor.ts index ded98fef..9dfc2f12 100644 --- a/packages/behavior-tree/src/Runtime/Executors/InverterExecutor.ts +++ b/packages/behavior-tree/src/Runtime/Executors/InverterExecutor.ts @@ -12,7 +12,11 @@ import { NodeExecutorMetadata } from '../NodeMetadata'; nodeType: NodeType.Decorator, displayName: '反转', description: '反转子节点的执行结果', - category: 'Decorator' + category: 'Decorator', + childrenConstraints: { + min: 1, + max: 1 + } }) export class InverterExecutor implements INodeExecutor { execute(context: NodeExecutionContext): TaskStatus { diff --git a/packages/behavior-tree/src/Runtime/Executors/ParallelExecutor.ts b/packages/behavior-tree/src/Runtime/Executors/ParallelExecutor.ts index 40d6d552..f9f01fd9 100644 --- a/packages/behavior-tree/src/Runtime/Executors/ParallelExecutor.ts +++ b/packages/behavior-tree/src/Runtime/Executors/ParallelExecutor.ts @@ -26,6 +26,9 @@ import { NodeExecutorMetadata } from '../NodeMetadata'; description: '失败策略', options: ['all', 'one'] } + }, + childrenConstraints: { + min: 2 } }) export class ParallelExecutor implements INodeExecutor { diff --git a/packages/behavior-tree/src/Runtime/Executors/RandomSequenceExecutor.ts b/packages/behavior-tree/src/Runtime/Executors/RandomSequenceExecutor.ts index 270bde3f..59a7cf3f 100644 --- a/packages/behavior-tree/src/Runtime/Executors/RandomSequenceExecutor.ts +++ b/packages/behavior-tree/src/Runtime/Executors/RandomSequenceExecutor.ts @@ -12,7 +12,10 @@ import { NodeExecutorMetadata } from '../NodeMetadata'; nodeType: NodeType.Composite, displayName: '随机序列', description: '随机顺序执行子节点,全部成功才成功', - category: 'Composite' + category: 'Composite', + childrenConstraints: { + min: 1 + } }) export class RandomSequenceExecutor implements INodeExecutor { execute(context: NodeExecutionContext): TaskStatus { diff --git a/packages/behavior-tree/src/Runtime/Executors/RepeaterExecutor.ts b/packages/behavior-tree/src/Runtime/Executors/RepeaterExecutor.ts index e1bb393b..327cc73c 100644 --- a/packages/behavior-tree/src/Runtime/Executors/RepeaterExecutor.ts +++ b/packages/behavior-tree/src/Runtime/Executors/RepeaterExecutor.ts @@ -25,6 +25,10 @@ import { NodeExecutorMetadata } from '../NodeMetadata'; default: false, description: '子节点失败时是否结束' } + }, + childrenConstraints: { + min: 1, + max: 1 } }) export class RepeaterExecutor implements INodeExecutor { diff --git a/packages/behavior-tree/src/Runtime/Executors/SelectorExecutor.ts b/packages/behavior-tree/src/Runtime/Executors/SelectorExecutor.ts index 88e90a87..7036a157 100644 --- a/packages/behavior-tree/src/Runtime/Executors/SelectorExecutor.ts +++ b/packages/behavior-tree/src/Runtime/Executors/SelectorExecutor.ts @@ -12,7 +12,10 @@ import { NodeExecutorMetadata } from '../NodeMetadata'; nodeType: NodeType.Composite, displayName: '选择器', description: '按顺序执行子节点,任一成功则成功', - category: 'Composite' + category: 'Composite', + childrenConstraints: { + min: 1 + } }) export class SelectorExecutor implements INodeExecutor { execute(context: NodeExecutionContext): TaskStatus { diff --git a/packages/behavior-tree/src/Runtime/Executors/SequenceExecutor.ts b/packages/behavior-tree/src/Runtime/Executors/SequenceExecutor.ts index 90f0c815..a4590a71 100644 --- a/packages/behavior-tree/src/Runtime/Executors/SequenceExecutor.ts +++ b/packages/behavior-tree/src/Runtime/Executors/SequenceExecutor.ts @@ -12,7 +12,10 @@ import { NodeExecutorMetadata } from '../NodeMetadata'; nodeType: NodeType.Composite, displayName: '序列', description: '按顺序执行子节点,全部成功才成功', - category: 'Composite' + category: 'Composite', + childrenConstraints: { + min: 1 + } }) export class SequenceExecutor implements INodeExecutor { execute(context: NodeExecutionContext): TaskStatus { diff --git a/packages/behavior-tree/src/Runtime/NodeMetadata.ts b/packages/behavior-tree/src/Runtime/NodeMetadata.ts index e0a67ba2..ca5a8e2e 100644 --- a/packages/behavior-tree/src/Runtime/NodeMetadata.ts +++ b/packages/behavior-tree/src/Runtime/NodeMetadata.ts @@ -14,6 +14,15 @@ export interface ConfigFieldDefinition { allowMultipleConnections?: boolean; } +/** + * 子节点约束配置 + */ +export interface ChildrenConstraints { + min?: number; + max?: number; + required?: boolean; +} + /** * 节点元数据 */ @@ -24,6 +33,26 @@ export interface NodeMetadata { description?: string; category?: string; configSchema?: Record; + childrenConstraints?: ChildrenConstraints; +} + +/** + * 节点元数据默认值 + */ +export class NodeMetadataDefaults { + static getDefaultConstraints(nodeType: NodeType): ChildrenConstraints | undefined { + switch (nodeType) { + case NodeType.Composite: + return { min: 1 }; + case NodeType.Decorator: + return { min: 1, max: 1 }; + case NodeType.Action: + case NodeType.Condition: + return { max: 0 }; + default: + return undefined; + } + } } /** diff --git a/packages/behavior-tree/src/Serialization/NodeTemplates.ts b/packages/behavior-tree/src/Serialization/NodeTemplates.ts index 4f52da65..df2af9c8 100644 --- a/packages/behavior-tree/src/Serialization/NodeTemplates.ts +++ b/packages/behavior-tree/src/Serialization/NodeTemplates.ts @@ -1,5 +1,5 @@ import { NodeType } from '../Types/TaskStatus'; -import { NodeMetadataRegistry, ConfigFieldDefinition } from '../Runtime/NodeMetadata'; +import { NodeMetadataRegistry, ConfigFieldDefinition, NodeMetadata } from '../Runtime/NodeMetadata'; /** * 节点数据JSON格式 @@ -48,7 +48,7 @@ export const PropertyType = { * type: 'curve-editor' * ``` */ -export type PropertyType = typeof PropertyType[keyof typeof PropertyType] | string; +export type PropertyType = (typeof PropertyType)[keyof typeof PropertyType] | string; /** * 属性定义(用于编辑器) @@ -114,7 +114,7 @@ export interface PropertyDefinition { /** 验证失败的提示信息 */ message?: string; /** 自定义验证函数 */ - validator?: string; // 函数字符串,编辑器会解析 + validator?: string; // 函数字符串,编辑器会解析 /** 最小长度(字符串) */ minLength?: number; /** 最大长度(字符串) */ @@ -141,6 +141,8 @@ export interface NodeTemplate { className?: string; componentClass?: Function; requiresChildren?: boolean; + minChildren?: number; + maxChildren?: number; defaultConfig: Partial; properties: PropertyDefinition[]; } @@ -183,7 +185,7 @@ export class NodeTemplates { /** * 将NodeMetadata转换为NodeTemplate */ - private static convertMetadataToTemplate(metadata: any): NodeTemplate { + private static convertMetadataToTemplate(metadata: NodeMetadata): NodeTemplate { const properties = this.convertConfigSchemaToProperties(metadata.configSchema || {}); const defaultConfig: Partial = { @@ -217,7 +219,10 @@ export class NodeTemplates { // 根据节点类型生成默认颜色和图标 const { icon, color } = this.getIconAndColorByType(metadata.nodeType, metadata.category || ''); - return { + // 应用子节点约束 + const constraints = metadata.childrenConstraints || this.getDefaultConstraintsByNodeType(metadata.nodeType); + + const template: NodeTemplate = { type: metadata.nodeType, displayName: metadata.displayName, category: metadata.category || this.getCategoryByNodeType(metadata.nodeType), @@ -228,6 +233,35 @@ export class NodeTemplates { defaultConfig, properties }; + + if (constraints) { + if (constraints.min !== undefined) { + template.minChildren = constraints.min; + template.requiresChildren = constraints.min > 0; + } + if (constraints.max !== undefined) { + template.maxChildren = constraints.max; + } + } + + return template; + } + + /** + * 获取节点类型的默认约束 + */ + private static getDefaultConstraintsByNodeType(nodeType: NodeType): { min?: number; max?: number } | undefined { + switch (nodeType) { + case NodeType.Composite: + return { min: 1 }; + case NodeType.Decorator: + return { min: 1, max: 1 }; + case NodeType.Action: + case NodeType.Condition: + return { max: 0 }; + default: + return undefined; + } } /** diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index 1797e964..c475b5ed 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -75,15 +75,15 @@ function App() { const [isProfilerMode, setIsProfilerMode] = useState(false); const [errorDialog, setErrorDialog] = useState<{ title: string; message: string } | null>(null); const [confirmDialog, setConfirmDialog] = useState<{ - title: string; - message: string; - confirmText: string; - cancelText: string; - onConfirm: () => void; - } | null>(null); + title: string; + message: string; + confirmText: string; + cancelText: string; + onConfirm: () => void; + } | null>(null); useEffect(() => { - // 禁用默认右键菜单 + // 禁用默认右键菜单 const handleContextMenu = (e: MouseEvent) => { e.preventDefault(); }; @@ -267,7 +267,7 @@ function App() { } else { await sceneManagerService.newScene(); } - } catch (error) { + } catch { await sceneManagerService.newScene(); } } @@ -479,22 +479,6 @@ function App() { } }; - const _handleExportScene = async () => { - if (!sceneManager) { - console.error('SceneManagerService not available'); - return; - } - - try { - await sceneManager.exportScene(); - const sceneState = sceneManager.getSceneState(); - setStatus(locale === 'zh' ? `已导出场景: ${sceneState.sceneName}` : `Scene exported: ${sceneState.sceneName}`); - } catch (error) { - console.error('Failed to export scene:', error); - setStatus(locale === 'zh' ? '导出场景失败' : 'Failed to export scene'); - } - }; - const handleCloseProject = async () => { if (pluginManager) { await pluginLoaderRef.current.unloadProjectPlugins(pluginManager); diff --git a/packages/editor-app/src/application/commands/BaseCommand.ts b/packages/editor-app/src/application/commands/BaseCommand.ts new file mode 100644 index 00000000..843aa824 --- /dev/null +++ b/packages/editor-app/src/application/commands/BaseCommand.ts @@ -0,0 +1,25 @@ +import { ICommand } from './ICommand'; + +/** + * 命令基类 + * 提供默认实现,具体命令继承此类 + */ +export abstract class BaseCommand implements ICommand { + abstract execute(): void; + abstract undo(): void; + abstract getDescription(): string; + + /** + * 默认不支持合并 + */ + canMergeWith(_other: ICommand): boolean { + return false; + } + + /** + * 默认抛出错误 + */ + mergeWith(_other: ICommand): ICommand { + throw new Error(`${this.constructor.name} 不支持合并操作`); + } +} diff --git a/packages/editor-app/src/application/commands/CommandManager.ts b/packages/editor-app/src/application/commands/CommandManager.ts new file mode 100644 index 00000000..0d20f900 --- /dev/null +++ b/packages/editor-app/src/application/commands/CommandManager.ts @@ -0,0 +1,203 @@ +import { ICommand } from './ICommand'; + +/** + * 命令历史记录配置 + */ +export interface CommandManagerConfig { + /** + * 最大历史记录数量 + */ + maxHistorySize?: number; + + /** + * 是否自动合并相似命令 + */ + autoMerge?: boolean; +} + +/** + * 命令管理器 + * 管理命令的执行、撤销、重做以及历史记录 + */ +export class CommandManager { + private undoStack: ICommand[] = []; + private redoStack: ICommand[] = []; + private readonly config: Required; + private isExecuting = false; + + constructor(config: CommandManagerConfig = {}) { + this.config = { + maxHistorySize: config.maxHistorySize ?? 100, + autoMerge: config.autoMerge ?? true + }; + } + + /** + * 执行命令 + */ + execute(command: ICommand): void { + if (this.isExecuting) { + throw new Error('不能在命令执行过程中执行新命令'); + } + + this.isExecuting = true; + + try { + command.execute(); + + if (this.config.autoMerge && this.undoStack.length > 0) { + const lastCommand = this.undoStack[this.undoStack.length - 1]; + if (lastCommand && lastCommand.canMergeWith(command)) { + const mergedCommand = lastCommand.mergeWith(command); + this.undoStack[this.undoStack.length - 1] = mergedCommand; + this.redoStack = []; + return; + } + } + + this.undoStack.push(command); + this.redoStack = []; + + if (this.undoStack.length > this.config.maxHistorySize) { + this.undoStack.shift(); + } + } finally { + this.isExecuting = false; + } + } + + /** + * 撤销上一个命令 + */ + undo(): void { + if (this.isExecuting) { + throw new Error('不能在命令执行过程中撤销'); + } + + const command = this.undoStack.pop(); + if (!command) { + return; + } + + this.isExecuting = true; + + try { + command.undo(); + this.redoStack.push(command); + } catch (error) { + this.undoStack.push(command); + throw error; + } finally { + this.isExecuting = false; + } + } + + /** + * 重做上一个被撤销的命令 + */ + redo(): void { + if (this.isExecuting) { + throw new Error('不能在命令执行过程中重做'); + } + + const command = this.redoStack.pop(); + if (!command) { + return; + } + + this.isExecuting = true; + + try { + command.execute(); + this.undoStack.push(command); + } catch (error) { + this.redoStack.push(command); + throw error; + } finally { + this.isExecuting = false; + } + } + + /** + * 检查是否可以撤销 + */ + canUndo(): boolean { + return this.undoStack.length > 0; + } + + /** + * 检查是否可以重做 + */ + canRedo(): boolean { + return this.redoStack.length > 0; + } + + /** + * 获取撤销栈的描述列表 + */ + getUndoHistory(): string[] { + return this.undoStack.map((cmd) => cmd.getDescription()); + } + + /** + * 获取重做栈的描述列表 + */ + getRedoHistory(): string[] { + return this.redoStack.map((cmd) => cmd.getDescription()); + } + + /** + * 清空所有历史记录 + */ + clear(): void { + this.undoStack = []; + this.redoStack = []; + } + + /** + * 批量执行命令(作为单一操作,可以一次撤销) + */ + executeBatch(commands: ICommand[]): void { + if (commands.length === 0) { + return; + } + + const batchCommand = new BatchCommand(commands); + this.execute(batchCommand); + } +} + +/** + * 批量命令 + * 将多个命令组合为一个命令 + */ +class BatchCommand implements ICommand { + constructor(private readonly commands: ICommand[]) {} + + execute(): void { + for (const command of this.commands) { + command.execute(); + } + } + + undo(): void { + for (let i = this.commands.length - 1; i >= 0; i--) { + const command = this.commands[i]; + if (command) { + command.undo(); + } + } + } + + getDescription(): string { + return `批量操作 (${this.commands.length} 个命令)`; + } + + canMergeWith(): boolean { + return false; + } + + mergeWith(): ICommand { + throw new Error('批量命令不支持合并'); + } +} diff --git a/packages/editor-app/src/application/commands/ICommand.ts b/packages/editor-app/src/application/commands/ICommand.ts new file mode 100644 index 00000000..a8f56aa1 --- /dev/null +++ b/packages/editor-app/src/application/commands/ICommand.ts @@ -0,0 +1,31 @@ +/** + * 命令接口 + * 实现命令模式,支持撤销/重做功能 + */ +export interface ICommand { + /** + * 执行命令 + */ + execute(): void; + + /** + * 撤销命令 + */ + undo(): void; + + /** + * 获取命令描述(用于显示历史记录) + */ + getDescription(): string; + + /** + * 检查命令是否可以合并 + * 用于优化撤销/重做历史,例如连续的移动操作可以合并为一个 + */ + canMergeWith(other: ICommand): boolean; + + /** + * 与另一个命令合并 + */ + mergeWith(other: ICommand): ICommand; +} diff --git a/packages/editor-app/src/application/commands/ITreeState.ts b/packages/editor-app/src/application/commands/ITreeState.ts new file mode 100644 index 00000000..bd67cdc3 --- /dev/null +++ b/packages/editor-app/src/application/commands/ITreeState.ts @@ -0,0 +1,17 @@ +import { BehaviorTree } from '../../domain/models/BehaviorTree'; + +/** + * 行为树状态接口 + * 命令通过此接口操作状态 + */ +export interface ITreeState { + /** + * 获取当前行为树 + */ + getTree(): BehaviorTree; + + /** + * 设置行为树 + */ + setTree(tree: BehaviorTree): void; +} diff --git a/packages/editor-app/src/application/commands/index.ts b/packages/editor-app/src/application/commands/index.ts new file mode 100644 index 00000000..5c60beeb --- /dev/null +++ b/packages/editor-app/src/application/commands/index.ts @@ -0,0 +1,5 @@ +export type { ICommand } from './ICommand'; +export { BaseCommand } from './BaseCommand'; +export { CommandManager } from './CommandManager'; +export type { ITreeState } from './ITreeState'; +export * from './tree'; diff --git a/packages/editor-app/src/application/commands/tree/AddConnectionCommand.ts b/packages/editor-app/src/application/commands/tree/AddConnectionCommand.ts new file mode 100644 index 00000000..f09d416d --- /dev/null +++ b/packages/editor-app/src/application/commands/tree/AddConnectionCommand.ts @@ -0,0 +1,36 @@ +import { Connection } from '../../../domain/models/Connection'; +import { BaseCommand } from '../BaseCommand'; +import { ITreeState } from '../ITreeState'; + +/** + * 添加连接命令 + */ +export class AddConnectionCommand extends BaseCommand { + constructor( + private readonly state: ITreeState, + private readonly connection: Connection + ) { + super(); + } + + execute(): void { + const tree = this.state.getTree(); + const newTree = tree.addConnection(this.connection); + this.state.setTree(newTree); + } + + undo(): void { + const tree = this.state.getTree(); + const newTree = tree.removeConnection( + this.connection.from, + this.connection.to, + this.connection.fromProperty, + this.connection.toProperty + ); + this.state.setTree(newTree); + } + + getDescription(): string { + return `添加连接: ${this.connection.from} -> ${this.connection.to}`; + } +} diff --git a/packages/editor-app/src/application/commands/tree/CreateNodeCommand.ts b/packages/editor-app/src/application/commands/tree/CreateNodeCommand.ts new file mode 100644 index 00000000..9245fbd3 --- /dev/null +++ b/packages/editor-app/src/application/commands/tree/CreateNodeCommand.ts @@ -0,0 +1,34 @@ +import { Node } from '../../../domain/models/Node'; +import { BaseCommand } from '../BaseCommand'; +import { ITreeState } from '../ITreeState'; + +/** + * 创建节点命令 + */ +export class CreateNodeCommand extends BaseCommand { + private createdNodeId: string; + + constructor( + private readonly state: ITreeState, + private readonly node: Node + ) { + super(); + this.createdNodeId = node.id; + } + + execute(): void { + const tree = this.state.getTree(); + const newTree = tree.addNode(this.node); + this.state.setTree(newTree); + } + + undo(): void { + const tree = this.state.getTree(); + const newTree = tree.removeNode(this.createdNodeId); + this.state.setTree(newTree); + } + + getDescription(): string { + return `创建节点: ${this.node.template.displayName}`; + } +} diff --git a/packages/editor-app/src/application/commands/tree/DeleteNodeCommand.ts b/packages/editor-app/src/application/commands/tree/DeleteNodeCommand.ts new file mode 100644 index 00000000..e4c4f8e0 --- /dev/null +++ b/packages/editor-app/src/application/commands/tree/DeleteNodeCommand.ts @@ -0,0 +1,38 @@ +import { Node } from '../../../domain/models/Node'; +import { BaseCommand } from '../BaseCommand'; +import { ITreeState } from '../ITreeState'; + +/** + * 删除节点命令 + */ +export class DeleteNodeCommand extends BaseCommand { + private deletedNode: Node | null = null; + + constructor( + private readonly state: ITreeState, + private readonly nodeId: string + ) { + super(); + } + + execute(): void { + const tree = this.state.getTree(); + this.deletedNode = tree.getNode(this.nodeId); + const newTree = tree.removeNode(this.nodeId); + this.state.setTree(newTree); + } + + undo(): void { + if (!this.deletedNode) { + throw new Error('无法撤销:未保存已删除的节点'); + } + + const tree = this.state.getTree(); + const newTree = tree.addNode(this.deletedNode); + this.state.setTree(newTree); + } + + getDescription(): string { + return `删除节点: ${this.deletedNode?.template.displayName ?? this.nodeId}`; + } +} diff --git a/packages/editor-app/src/application/commands/tree/MoveNodeCommand.ts b/packages/editor-app/src/application/commands/tree/MoveNodeCommand.ts new file mode 100644 index 00000000..1b1746cb --- /dev/null +++ b/packages/editor-app/src/application/commands/tree/MoveNodeCommand.ts @@ -0,0 +1,75 @@ +import { Position } from '../../../domain/value-objects/Position'; +import { BaseCommand } from '../BaseCommand'; +import { ITreeState } from '../ITreeState'; +import { ICommand } from '../ICommand'; + +/** + * 移动节点命令 + * 支持合并连续的移动操作 + */ +export class MoveNodeCommand extends BaseCommand { + private oldPosition: Position; + + constructor( + private readonly state: ITreeState, + private readonly nodeId: string, + private readonly newPosition: Position + ) { + super(); + const tree = this.state.getTree(); + const node = tree.getNode(nodeId); + this.oldPosition = node.position; + } + + execute(): void { + const tree = this.state.getTree(); + const newTree = tree.updateNode(this.nodeId, (node) => + node.moveToPosition(this.newPosition) + ); + this.state.setTree(newTree); + } + + undo(): void { + const tree = this.state.getTree(); + const newTree = tree.updateNode(this.nodeId, (node) => + node.moveToPosition(this.oldPosition) + ); + this.state.setTree(newTree); + } + + getDescription(): string { + return `移动节点: ${this.nodeId}`; + } + + /** + * 移动命令可以合并 + */ + canMergeWith(other: ICommand): boolean { + if (!(other instanceof MoveNodeCommand)) { + return false; + } + return this.nodeId === other.nodeId; + } + + /** + * 合并移动命令 + * 保留初始位置,更新最终位置 + */ + mergeWith(other: ICommand): ICommand { + if (!(other instanceof MoveNodeCommand)) { + throw new Error('只能与 MoveNodeCommand 合并'); + } + + if (this.nodeId !== other.nodeId) { + throw new Error('只能合并同一节点的移动命令'); + } + + const merged = new MoveNodeCommand( + this.state, + this.nodeId, + other.newPosition + ); + merged.oldPosition = this.oldPosition; + return merged; + } +} diff --git a/packages/editor-app/src/application/commands/tree/RemoveConnectionCommand.ts b/packages/editor-app/src/application/commands/tree/RemoveConnectionCommand.ts new file mode 100644 index 00000000..8bf387bc --- /dev/null +++ b/packages/editor-app/src/application/commands/tree/RemoveConnectionCommand.ts @@ -0,0 +1,50 @@ +import { Connection } from '../../../domain/models/Connection'; +import { BaseCommand } from '../BaseCommand'; +import { ITreeState } from '../ITreeState'; + +/** + * 移除连接命令 + */ +export class RemoveConnectionCommand extends BaseCommand { + private removedConnection: Connection | null = null; + + constructor( + private readonly state: ITreeState, + private readonly from: string, + private readonly to: string, + private readonly fromProperty?: string, + private readonly toProperty?: string + ) { + super(); + } + + execute(): void { + const tree = this.state.getTree(); + + const connection = tree.connections.find((c) => + c.matches(this.from, this.to, this.fromProperty, this.toProperty) + ); + + if (!connection) { + throw new Error(`连接不存在: ${this.from} -> ${this.to}`); + } + + this.removedConnection = connection; + const newTree = tree.removeConnection(this.from, this.to, this.fromProperty, this.toProperty); + this.state.setTree(newTree); + } + + undo(): void { + if (!this.removedConnection) { + throw new Error('无法撤销:未保存已删除的连接'); + } + + const tree = this.state.getTree(); + const newTree = tree.addConnection(this.removedConnection); + this.state.setTree(newTree); + } + + getDescription(): string { + return `移除连接: ${this.from} -> ${this.to}`; + } +} diff --git a/packages/editor-app/src/application/commands/tree/UpdateNodeDataCommand.ts b/packages/editor-app/src/application/commands/tree/UpdateNodeDataCommand.ts new file mode 100644 index 00000000..42b830a4 --- /dev/null +++ b/packages/editor-app/src/application/commands/tree/UpdateNodeDataCommand.ts @@ -0,0 +1,40 @@ +import { BaseCommand } from '../BaseCommand'; +import { ITreeState } from '../ITreeState'; + +/** + * 更新节点数据命令 + */ +export class UpdateNodeDataCommand extends BaseCommand { + private oldData: Record; + + constructor( + private readonly state: ITreeState, + private readonly nodeId: string, + private readonly newData: Record + ) { + super(); + const tree = this.state.getTree(); + const node = tree.getNode(nodeId); + this.oldData = node.data; + } + + execute(): void { + const tree = this.state.getTree(); + const newTree = tree.updateNode(this.nodeId, (node) => + node.updateData(this.newData) + ); + this.state.setTree(newTree); + } + + undo(): void { + const tree = this.state.getTree(); + const newTree = tree.updateNode(this.nodeId, (node) => + node.updateData(this.oldData) + ); + this.state.setTree(newTree); + } + + getDescription(): string { + return `更新节点数据: ${this.nodeId}`; + } +} diff --git a/packages/editor-app/src/application/commands/tree/index.ts b/packages/editor-app/src/application/commands/tree/index.ts new file mode 100644 index 00000000..8701f46a --- /dev/null +++ b/packages/editor-app/src/application/commands/tree/index.ts @@ -0,0 +1,6 @@ +export { CreateNodeCommand } from './CreateNodeCommand'; +export { DeleteNodeCommand } from './DeleteNodeCommand'; +export { AddConnectionCommand } from './AddConnectionCommand'; +export { RemoveConnectionCommand } from './RemoveConnectionCommand'; +export { MoveNodeCommand } from './MoveNodeCommand'; +export { UpdateNodeDataCommand } from './UpdateNodeDataCommand'; diff --git a/packages/editor-app/src/application/hooks/useContextMenu.ts b/packages/editor-app/src/application/hooks/useContextMenu.ts new file mode 100644 index 00000000..475bbb96 --- /dev/null +++ b/packages/editor-app/src/application/hooks/useContextMenu.ts @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore'; + +interface ContextMenuState { + visible: boolean; + position: { x: number; y: number }; + nodeId: string | null; +} + +export function useContextMenu() { + const [contextMenu, setContextMenu] = useState({ + visible: false, + position: { x: 0, y: 0 }, + nodeId: null + }); + + const handleNodeContextMenu = (e: React.MouseEvent, node: BehaviorTreeNode) => { + e.preventDefault(); + e.stopPropagation(); + + // 不允许对Root节点右键 + if (node.id === ROOT_NODE_ID) { + return; + } + + setContextMenu({ + visible: true, + position: { x: e.clientX, y: e.clientY }, + nodeId: node.id + }); + }; + + const closeContextMenu = () => { + setContextMenu({ ...contextMenu, visible: false }); + }; + + return { + contextMenu, + setContextMenu, + handleNodeContextMenu, + closeContextMenu + }; +} diff --git a/packages/editor-app/src/application/hooks/useQuickCreateMenu.ts b/packages/editor-app/src/application/hooks/useQuickCreateMenu.ts new file mode 100644 index 00000000..b239ce93 --- /dev/null +++ b/packages/editor-app/src/application/hooks/useQuickCreateMenu.ts @@ -0,0 +1,210 @@ +import { useState, RefObject } from 'react'; +import { NodeTemplate } from '@esengine/behavior-tree'; +import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore'; +import { Node } from '../../domain/models/Node'; +import { Position } from '../../domain/value-objects/Position'; +import { useNodeOperations } from '../../presentation/hooks/useNodeOperations'; +import { useConnectionOperations } from '../../presentation/hooks/useConnectionOperations'; + +interface QuickCreateMenuState { + visible: boolean; + position: { x: number; y: number }; + searchText: string; + selectedIndex: number; + mode: 'create' | 'replace'; + replaceNodeId: string | null; +} + +type ExecutionMode = 'idle' | 'running' | 'paused' | 'step'; + +interface UseQuickCreateMenuParams { + nodeOperations: ReturnType; + connectionOperations: ReturnType; + canvasRef: RefObject; + canvasOffset: { x: number; y: number }; + canvasScale: number; + connectingFrom: string | null; + connectingFromProperty: string | null; + clearConnecting: () => void; + nodes: BehaviorTreeNode[]; + setNodes: (nodes: BehaviorTreeNode[]) => void; + connections: Connection[]; + executionMode: ExecutionMode; + onStop: () => void; + onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void; + showToast?: (message: string, type: 'success' | 'error' | 'info') => void; +} + +export function useQuickCreateMenu(params: UseQuickCreateMenuParams) { + const { + nodeOperations, + connectionOperations, + canvasRef, + canvasOffset, + canvasScale, + connectingFrom, + connectingFromProperty, + clearConnecting, + nodes, + setNodes, + connections, + executionMode, + onStop, + onNodeCreate, + showToast + } = params; + + const [quickCreateMenu, setQuickCreateMenu] = useState({ + visible: false, + position: { x: 0, y: 0 }, + searchText: '', + selectedIndex: 0, + mode: 'create', + replaceNodeId: null + }); + + const handleReplaceNode = (newTemplate: NodeTemplate) => { + const nodeToReplace = nodes.find((n) => n.id === quickCreateMenu.replaceNodeId); + if (!nodeToReplace) return; + + // 如果行为树正在执行,先停止 + if (executionMode !== 'idle') { + onStop(); + } + + // 合并数据:新模板的默认配置 + 保留旧节点中同名属性的值 + const newData = { ...newTemplate.defaultConfig }; + + // 获取新模板的属性名列表 + const newPropertyNames = new Set(newTemplate.properties.map((p) => p.name)); + + // 遍历旧节点的 data,保留新模板中也存在的属性 + for (const [key, value] of Object.entries(nodeToReplace.data)) { + // 跳过节点类型相关的字段 + if (key === 'nodeType' || key === 'compositeType' || key === 'decoratorType' || + key === 'actionType' || key === 'conditionType') { + continue; + } + + // 如果新模板也有这个属性,保留旧值(包括绑定信息) + if (newPropertyNames.has(key)) { + newData[key] = value; + } + } + + // 创建新节点,保留原节点的位置和连接 + const newNode = new Node( + nodeToReplace.id, + newTemplate, + newData, + nodeToReplace.position, + Array.from(nodeToReplace.children) + ); + + // 替换节点 + setNodes(nodes.map((n) => n.id === newNode.id ? newNode : n)); + + // 删除所有指向该节点的属性连接,让用户重新连接 + const propertyConnections = connections.filter((conn) => + conn.connectionType === 'property' && conn.to === newNode.id + ); + propertyConnections.forEach((conn) => { + connectionOperations.removeConnection( + conn.from, + conn.to, + conn.fromProperty, + conn.toProperty + ); + }); + + // 关闭快速创建菜单 + closeQuickCreateMenu(); + + // 显示提示 + showToast?.(`已将节点替换为 ${newTemplate.displayName}`, 'success'); + }; + + const handleQuickCreateNode = (template: NodeTemplate) => { + // 如果是替换模式,直接调用替换函数 + if (quickCreateMenu.mode === 'replace') { + handleReplaceNode(template); + return; + } + + // 创建模式:需要连接 + if (!connectingFrom) { + return; + } + + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) { + return; + } + + const posX = (quickCreateMenu.position.x - rect.left - canvasOffset.x) / canvasScale; + const posY = (quickCreateMenu.position.y - rect.top - canvasOffset.y) / canvasScale; + + const newNode = nodeOperations.createNode( + template, + new Position(posX, posY), + template.defaultConfig + ); + + const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom); + if (fromNode) { + if (connectingFromProperty) { + // 属性连接 + connectionOperations.addConnection( + connectingFrom, + newNode.id, + 'property', + connectingFromProperty, + undefined + ); + } else { + // 节点连接 + connectionOperations.addConnection(connectingFrom, newNode.id, 'node'); + } + } + + closeQuickCreateMenu(); + + onNodeCreate?.(template, { x: posX, y: posY }); + }; + + const openQuickCreateMenu = ( + position: { x: number; y: number }, + mode: 'create' | 'replace', + replaceNodeId?: string | null + ) => { + setQuickCreateMenu({ + visible: true, + position, + searchText: '', + selectedIndex: 0, + mode, + replaceNodeId: replaceNodeId || null + }); + }; + + const closeQuickCreateMenu = () => { + setQuickCreateMenu({ + visible: false, + position: { x: 0, y: 0 }, + searchText: '', + selectedIndex: 0, + mode: 'create', + replaceNodeId: null + }); + clearConnecting(); + }; + + return { + quickCreateMenu, + setQuickCreateMenu, + handleQuickCreateNode, + handleReplaceNode, + openQuickCreateMenu, + closeQuickCreateMenu + }; +} diff --git a/packages/editor-app/src/application/index.ts b/packages/editor-app/src/application/index.ts new file mode 100644 index 00000000..d03fee31 --- /dev/null +++ b/packages/editor-app/src/application/index.ts @@ -0,0 +1,3 @@ +export * from './commands'; +export * from './use-cases'; +export * from './state'; diff --git a/packages/editor-app/src/application/interfaces/IEditorExtensions.ts b/packages/editor-app/src/application/interfaces/IEditorExtensions.ts new file mode 100644 index 00000000..8fcbc792 --- /dev/null +++ b/packages/editor-app/src/application/interfaces/IEditorExtensions.ts @@ -0,0 +1,250 @@ +import { NodeTemplate } from '@esengine/behavior-tree'; +import { BehaviorTreeNode } from '../../stores/behaviorTreeStore'; +import { LucideIcon } from 'lucide-react'; +import React from 'react'; + +export interface INodeRenderer { + canRender(node: BehaviorTreeNode): boolean; + + render(node: BehaviorTreeNode, context: NodeRenderContext): React.ReactElement; +} + +export interface NodeRenderContext { + isSelected: boolean; + isExecuting: boolean; + onNodeClick: (e: React.MouseEvent, node: BehaviorTreeNode) => void; + onContextMenu: (e: React.MouseEvent, node: BehaviorTreeNode) => void; +} + +export interface IPropertyEditor { + canEdit(propertyType: string): boolean; + + render(property: PropertyEditorProps): React.ReactElement; +} + +export type PropertyValue = string | number | boolean | object | null | undefined; + +export interface PropertyEditorProps { + propertyName: string; + propertyType: string; + value: T; + onChange: (value: T) => void; + config?: Record; +} + +export interface INodeProvider { + getNodeTemplates(): NodeTemplate[]; + + getCategory(): string; + + getIcon(): string | LucideIcon; +} + +export interface IToolbarButton { + id: string; + label: string; + icon: LucideIcon; + tooltip?: string; + onClick: () => void; + isVisible?: () => boolean; + isEnabled?: () => boolean; +} + +export interface IPanelProvider { + id: string; + title: string; + icon?: LucideIcon; + + render(): React.ReactElement; + + canActivate?(): boolean; +} + +export interface IValidator { + name: string; + + validate(nodes: BehaviorTreeNode[]): ValidationResult[]; +} + +export interface ValidationResult { + severity: 'error' | 'warning' | 'info'; + nodeId?: string; + message: string; + code?: string; +} + +export interface ICommandProvider { + getCommandId(): string; + + getCommandName(): string; + + getShortcut?(): string; + + canExecute?(): boolean; + + execute(context: CommandExecutionContext): void | Promise; +} + +export interface CommandExecutionContext { + selectedNodeIds: string[]; + nodes: BehaviorTreeNode[]; + currentFile?: string; +} + +export class EditorExtensionRegistry { + private nodeRenderers: Set = new Set(); + private propertyEditors: Set = new Set(); + private nodeProviders: Set = new Set(); + private toolbarButtons: Set = new Set(); + private panelProviders: Set = new Set(); + private validators: Set = new Set(); + private commandProviders: Set = new Set(); + + registerNodeRenderer(renderer: INodeRenderer): void { + this.nodeRenderers.add(renderer); + } + + unregisterNodeRenderer(renderer: INodeRenderer): void { + this.nodeRenderers.delete(renderer); + } + + getNodeRenderer(node: BehaviorTreeNode): INodeRenderer | undefined { + for (const renderer of this.nodeRenderers) { + if (renderer.canRender(node)) { + return renderer; + } + } + return undefined; + } + + registerPropertyEditor(editor: IPropertyEditor): void { + this.propertyEditors.add(editor); + } + + unregisterPropertyEditor(editor: IPropertyEditor): void { + this.propertyEditors.delete(editor); + } + + getPropertyEditor(propertyType: string): IPropertyEditor | undefined { + for (const editor of this.propertyEditors) { + if (editor.canEdit(propertyType)) { + return editor; + } + } + return undefined; + } + + registerNodeProvider(provider: INodeProvider): void { + this.nodeProviders.add(provider); + } + + unregisterNodeProvider(provider: INodeProvider): void { + this.nodeProviders.delete(provider); + } + + getAllNodeTemplates(): NodeTemplate[] { + const templates: NodeTemplate[] = []; + this.nodeProviders.forEach((provider) => { + templates.push(...provider.getNodeTemplates()); + }); + return templates; + } + + registerToolbarButton(button: IToolbarButton): void { + this.toolbarButtons.add(button); + } + + unregisterToolbarButton(button: IToolbarButton): void { + this.toolbarButtons.delete(button); + } + + getToolbarButtons(): IToolbarButton[] { + return Array.from(this.toolbarButtons).filter((btn) => { + return btn.isVisible ? btn.isVisible() : true; + }); + } + + registerPanelProvider(provider: IPanelProvider): void { + this.panelProviders.add(provider); + } + + unregisterPanelProvider(provider: IPanelProvider): void { + this.panelProviders.delete(provider); + } + + getPanelProviders(): IPanelProvider[] { + return Array.from(this.panelProviders).filter((panel) => { + return panel.canActivate ? panel.canActivate() : true; + }); + } + + registerValidator(validator: IValidator): void { + this.validators.add(validator); + } + + unregisterValidator(validator: IValidator): void { + this.validators.delete(validator); + } + + async validateTree(nodes: BehaviorTreeNode[]): Promise { + const results: ValidationResult[] = []; + for (const validator of this.validators) { + try { + const validationResults = validator.validate(nodes); + results.push(...validationResults); + } catch (error) { + console.error(`Error in validator ${validator.name}:`, error); + results.push({ + severity: 'error', + message: `Validator ${validator.name} failed: ${error}`, + code: 'VALIDATOR_ERROR' + }); + } + } + return results; + } + + registerCommandProvider(provider: ICommandProvider): void { + this.commandProviders.add(provider); + } + + unregisterCommandProvider(provider: ICommandProvider): void { + this.commandProviders.delete(provider); + } + + getCommandProvider(commandId: string): ICommandProvider | undefined { + for (const provider of this.commandProviders) { + if (provider.getCommandId() === commandId) { + return provider; + } + } + return undefined; + } + + getAllCommandProviders(): ICommandProvider[] { + return Array.from(this.commandProviders); + } + + clear(): void { + this.nodeRenderers.clear(); + this.propertyEditors.clear(); + this.nodeProviders.clear(); + this.toolbarButtons.clear(); + this.panelProviders.clear(); + this.validators.clear(); + this.commandProviders.clear(); + } +} + +let globalExtensionRegistry: EditorExtensionRegistry | null = null; + +export function getGlobalExtensionRegistry(): EditorExtensionRegistry { + if (!globalExtensionRegistry) { + globalExtensionRegistry = new EditorExtensionRegistry(); + } + return globalExtensionRegistry; +} + +export function resetGlobalExtensionRegistry(): void { + globalExtensionRegistry = null; +} diff --git a/packages/editor-app/src/application/interfaces/IExecutionHooks.ts b/packages/editor-app/src/application/interfaces/IExecutionHooks.ts new file mode 100644 index 00000000..51d8e85f --- /dev/null +++ b/packages/editor-app/src/application/interfaces/IExecutionHooks.ts @@ -0,0 +1,249 @@ +import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore'; +import { ExecutionLog } from '../../utils/BehaviorTreeExecutor'; +import { BlackboardValue } from '../../domain/models/Blackboard'; + +type BlackboardVariables = Record; +type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure'; + +export interface ExecutionContext { + nodes: BehaviorTreeNode[]; + connections: Connection[]; + blackboardVariables: BlackboardVariables; + rootNodeId: string; + tickCount: number; +} + +export interface NodeStatusChangeEvent { + nodeId: string; + status: NodeExecutionStatus; + previousStatus?: NodeExecutionStatus; + timestamp: number; +} + +export interface IExecutionHooks { + beforePlay?(context: ExecutionContext): void | Promise; + + afterPlay?(context: ExecutionContext): void | Promise; + + beforePause?(): void | Promise; + + afterPause?(): void | Promise; + + beforeResume?(): void | Promise; + + afterResume?(): void | Promise; + + beforeStop?(): void | Promise; + + afterStop?(): void | Promise; + + beforeStep?(deltaTime: number): void | Promise; + + afterStep?(deltaTime: number): void | Promise; + + onTick?(tickCount: number, deltaTime: number): void | Promise; + + onNodeStatusChange?(event: NodeStatusChangeEvent): void | Promise; + + onExecutionComplete?(logs: ExecutionLog[]): void | Promise; + + onBlackboardUpdate?(variables: BlackboardVariables): void | Promise; + + onError?(error: Error, context?: string): void | Promise; +} + +export class ExecutionHooksManager { + private hooks: Set = new Set(); + + register(hook: IExecutionHooks): void { + this.hooks.add(hook); + } + + unregister(hook: IExecutionHooks): void { + this.hooks.delete(hook); + } + + clear(): void { + this.hooks.clear(); + } + + async triggerBeforePlay(context: ExecutionContext): Promise { + for (const hook of this.hooks) { + if (hook.beforePlay) { + try { + await hook.beforePlay(context); + } catch (error) { + console.error('Error in beforePlay hook:', error); + } + } + } + } + + async triggerAfterPlay(context: ExecutionContext): Promise { + for (const hook of this.hooks) { + if (hook.afterPlay) { + try { + await hook.afterPlay(context); + } catch (error) { + console.error('Error in afterPlay hook:', error); + } + } + } + } + + async triggerBeforePause(): Promise { + for (const hook of this.hooks) { + if (hook.beforePause) { + try { + await hook.beforePause(); + } catch (error) { + console.error('Error in beforePause hook:', error); + } + } + } + } + + async triggerAfterPause(): Promise { + for (const hook of this.hooks) { + if (hook.afterPause) { + try { + await hook.afterPause(); + } catch (error) { + console.error('Error in afterPause hook:', error); + } + } + } + } + + async triggerBeforeResume(): Promise { + for (const hook of this.hooks) { + if (hook.beforeResume) { + try { + await hook.beforeResume(); + } catch (error) { + console.error('Error in beforeResume hook:', error); + } + } + } + } + + async triggerAfterResume(): Promise { + for (const hook of this.hooks) { + if (hook.afterResume) { + try { + await hook.afterResume(); + } catch (error) { + console.error('Error in afterResume hook:', error); + } + } + } + } + + async triggerBeforeStop(): Promise { + for (const hook of this.hooks) { + if (hook.beforeStop) { + try { + await hook.beforeStop(); + } catch (error) { + console.error('Error in beforeStop hook:', error); + } + } + } + } + + async triggerAfterStop(): Promise { + for (const hook of this.hooks) { + if (hook.afterStop) { + try { + await hook.afterStop(); + } catch (error) { + console.error('Error in afterStop hook:', error); + } + } + } + } + + async triggerBeforeStep(deltaTime: number): Promise { + for (const hook of this.hooks) { + if (hook.beforeStep) { + try { + await hook.beforeStep(deltaTime); + } catch (error) { + console.error('Error in beforeStep hook:', error); + } + } + } + } + + async triggerAfterStep(deltaTime: number): Promise { + for (const hook of this.hooks) { + if (hook.afterStep) { + try { + await hook.afterStep(deltaTime); + } catch (error) { + console.error('Error in afterStep hook:', error); + } + } + } + } + + async triggerOnTick(tickCount: number, deltaTime: number): Promise { + for (const hook of this.hooks) { + if (hook.onTick) { + try { + await hook.onTick(tickCount, deltaTime); + } catch (error) { + console.error('Error in onTick hook:', error); + } + } + } + } + + async triggerOnNodeStatusChange(event: NodeStatusChangeEvent): Promise { + for (const hook of this.hooks) { + if (hook.onNodeStatusChange) { + try { + await hook.onNodeStatusChange(event); + } catch (error) { + console.error('Error in onNodeStatusChange hook:', error); + } + } + } + } + + async triggerOnExecutionComplete(logs: ExecutionLog[]): Promise { + for (const hook of this.hooks) { + if (hook.onExecutionComplete) { + try { + await hook.onExecutionComplete(logs); + } catch (error) { + console.error('Error in onExecutionComplete hook:', error); + } + } + } + } + + async triggerOnBlackboardUpdate(variables: BlackboardVariables): Promise { + for (const hook of this.hooks) { + if (hook.onBlackboardUpdate) { + try { + await hook.onBlackboardUpdate(variables); + } catch (error) { + console.error('Error in onBlackboardUpdate hook:', error); + } + } + } + } + + async triggerOnError(error: Error, context?: string): Promise { + for (const hook of this.hooks) { + if (hook.onError) { + try { + await hook.onError(error, context); + } catch (err) { + console.error('Error in onError hook:', err); + } + } + } + } +} diff --git a/packages/editor-app/src/application/services/BlackboardManager.ts b/packages/editor-app/src/application/services/BlackboardManager.ts new file mode 100644 index 00000000..58291c98 --- /dev/null +++ b/packages/editor-app/src/application/services/BlackboardManager.ts @@ -0,0 +1,42 @@ +import { BlackboardValue } from '../../domain/models/Blackboard'; + +type BlackboardVariables = Record; + +export class BlackboardManager { + private initialVariables: BlackboardVariables = {}; + private currentVariables: BlackboardVariables = {}; + + setInitialVariables(variables: BlackboardVariables): void { + this.initialVariables = JSON.parse(JSON.stringify(variables)) as BlackboardVariables; + } + + getInitialVariables(): BlackboardVariables { + return { ...this.initialVariables }; + } + + setCurrentVariables(variables: BlackboardVariables): void { + this.currentVariables = { ...variables }; + } + + getCurrentVariables(): BlackboardVariables { + return { ...this.currentVariables }; + } + + updateVariable(key: string, value: BlackboardValue): void { + this.currentVariables[key] = value; + } + + restoreInitialVariables(): BlackboardVariables { + this.currentVariables = { ...this.initialVariables }; + return this.getInitialVariables(); + } + + hasChanges(): boolean { + return JSON.stringify(this.currentVariables) !== JSON.stringify(this.initialVariables); + } + + clear(): void { + this.initialVariables = {}; + this.currentVariables = {}; + } +} diff --git a/packages/editor-app/src/application/services/ExecutionController.ts b/packages/editor-app/src/application/services/ExecutionController.ts new file mode 100644 index 00000000..20b206dd --- /dev/null +++ b/packages/editor-app/src/application/services/ExecutionController.ts @@ -0,0 +1,369 @@ +import { BehaviorTreeExecutor, ExecutionStatus, ExecutionLog } from '../../utils/BehaviorTreeExecutor'; +import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore'; +import { BlackboardValue } from '../../domain/models/Blackboard'; +import { DOMCache } from '../../presentation/utils/DOMCache'; +import { EditorEventBus, EditorEvent } from '../../infrastructure/events/EditorEventBus'; +import { ExecutionHooksManager } from '../interfaces/IExecutionHooks'; + +export type ExecutionMode = 'idle' | 'running' | 'paused' | 'step'; +type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure'; +type BlackboardVariables = Record; + +interface ExecutionControllerConfig { + rootNodeId: string; + projectPath: string | null; + onLogsUpdate: (logs: ExecutionLog[]) => void; + onBlackboardUpdate: (variables: BlackboardVariables) => void; + onTickCountUpdate: (count: number) => void; + eventBus?: EditorEventBus; + hooksManager?: ExecutionHooksManager; +} + +export class ExecutionController { + private executor: BehaviorTreeExecutor | null = null; + private mode: ExecutionMode = 'idle'; + private animationFrameId: number | null = null; + private lastTickTime: number = 0; + private speed: number = 1.0; + private tickCount: number = 0; + + private domCache: DOMCache = new DOMCache(); + private eventBus?: EditorEventBus; + private hooksManager?: ExecutionHooksManager; + + private config: ExecutionControllerConfig; + private currentNodes: BehaviorTreeNode[] = []; + private currentConnections: Connection[] = []; + private currentBlackboard: BlackboardVariables = {}; + + constructor(config: ExecutionControllerConfig) { + this.config = config; + this.executor = new BehaviorTreeExecutor(); + this.eventBus = config.eventBus; + this.hooksManager = config.hooksManager; + } + + getMode(): ExecutionMode { + return this.mode; + } + + getTickCount(): number { + return this.tickCount; + } + + getSpeed(): number { + return this.speed; + } + + setSpeed(speed: number): void { + this.speed = speed; + } + + async play( + nodes: BehaviorTreeNode[], + blackboardVariables: BlackboardVariables, + connections: Connection[] + ): Promise { + if (this.mode === 'running') return; + + this.currentNodes = nodes; + this.currentConnections = connections; + this.currentBlackboard = blackboardVariables; + + const context = { + nodes, + connections, + blackboardVariables, + rootNodeId: this.config.rootNodeId, + tickCount: 0 + }; + + try { + await this.hooksManager?.triggerBeforePlay(context); + + this.mode = 'running'; + this.tickCount = 0; + this.lastTickTime = 0; + + if (!this.executor) { + this.executor = new BehaviorTreeExecutor(); + } + + this.executor.buildTree( + nodes, + this.config.rootNodeId, + blackboardVariables, + connections, + this.handleExecutionStatusUpdate.bind(this) + ); + + this.executor.start(); + this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this)); + + this.eventBus?.emit(EditorEvent.EXECUTION_STARTED, context); + await this.hooksManager?.triggerAfterPlay(context); + } catch (error) { + console.error('Error in play:', error); + await this.hooksManager?.triggerOnError(error as Error, 'play'); + throw error; + } + } + + async pause(): Promise { + try { + if (this.mode === 'running') { + await this.hooksManager?.triggerBeforePause(); + + this.mode = 'paused'; + + if (this.executor) { + this.executor.pause(); + } + + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + + this.eventBus?.emit(EditorEvent.EXECUTION_PAUSED); + await this.hooksManager?.triggerAfterPause(); + } else if (this.mode === 'paused') { + await this.hooksManager?.triggerBeforeResume(); + + this.mode = 'running'; + this.lastTickTime = 0; + + if (this.executor) { + this.executor.resume(); + } + + this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this)); + + this.eventBus?.emit(EditorEvent.EXECUTION_RESUMED); + await this.hooksManager?.triggerAfterResume(); + } + } catch (error) { + console.error('Error in pause/resume:', error); + await this.hooksManager?.triggerOnError(error as Error, 'pause'); + throw error; + } + } + + async stop(): Promise { + try { + await this.hooksManager?.triggerBeforeStop(); + + this.mode = 'idle'; + this.tickCount = 0; + this.lastTickTime = 0; + + this.domCache.clearAllStatusTimers(); + this.domCache.clearStatusCache(); + + this.domCache.forEachNode((node) => { + node.classList.remove('running', 'success', 'failure', 'executed'); + }); + + this.domCache.forEachConnection((path) => { + const connectionType = path.getAttribute('data-connection-type'); + if (connectionType === 'property') { + path.setAttribute('stroke', '#9c27b0'); + } else { + path.setAttribute('stroke', '#0e639c'); + } + path.setAttribute('stroke-width', '2'); + }); + + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + + if (this.executor) { + this.executor.stop(); + } + + this.eventBus?.emit(EditorEvent.EXECUTION_STOPPED); + await this.hooksManager?.triggerAfterStop(); + } catch (error) { + console.error('Error in stop:', error); + await this.hooksManager?.triggerOnError(error as Error, 'stop'); + throw error; + } + } + + async reset(): Promise { + await this.stop(); + + if (this.executor) { + this.executor.cleanup(); + } + } + + step(): void { + // 单步执行功能预留 + } + + updateBlackboardVariable(key: string, value: BlackboardValue): void { + if (this.executor && this.mode !== 'idle') { + this.executor.updateBlackboardVariable(key, value); + } + } + + getBlackboardVariables(): BlackboardVariables { + if (this.executor) { + return this.executor.getBlackboardVariables(); + } + return {}; + } + + clearDOMCache(): void { + this.domCache.clearAll(); + } + + destroy(): void { + this.stop(); + + if (this.executor) { + this.executor.destroy(); + this.executor = null; + } + } + + private tickLoop(currentTime: number): void { + if (this.mode !== 'running') { + return; + } + + if (!this.executor) { + return; + } + + const baseTickInterval = 16.67; + const tickInterval = baseTickInterval / this.speed; + + if (this.lastTickTime === 0 || (currentTime - this.lastTickTime) >= tickInterval) { + const deltaTime = 0.016; + + this.executor.tick(deltaTime); + + this.tickCount = this.executor.getTickCount(); + this.config.onTickCountUpdate(this.tickCount); + + this.lastTickTime = currentTime; + } + + this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this)); + } + + private handleExecutionStatusUpdate( + statuses: ExecutionStatus[], + logs: ExecutionLog[], + runtimeBlackboardVars?: BlackboardVariables + ): void { + this.config.onLogsUpdate([...logs]); + + if (runtimeBlackboardVars) { + this.config.onBlackboardUpdate(runtimeBlackboardVars); + } + + const statusMap: Record = {}; + + statuses.forEach((s) => { + statusMap[s.nodeId] = s.status; + + if (!this.domCache.hasStatusChanged(s.nodeId, s.status)) { + return; + } + this.domCache.setLastStatus(s.nodeId, s.status); + + const nodeElement = this.domCache.getNode(s.nodeId); + if (!nodeElement) { + return; + } + + this.domCache.removeNodeClasses(s.nodeId, 'running', 'success', 'failure', 'executed'); + + if (s.status === 'running') { + this.domCache.addNodeClasses(s.nodeId, 'running'); + } else if (s.status === 'success') { + this.domCache.addNodeClasses(s.nodeId, 'success'); + + this.domCache.clearStatusTimer(s.nodeId); + + const timer = window.setTimeout(() => { + this.domCache.removeNodeClasses(s.nodeId, 'success'); + this.domCache.addNodeClasses(s.nodeId, 'executed'); + this.domCache.clearStatusTimer(s.nodeId); + }, 2000); + + this.domCache.setStatusTimer(s.nodeId, timer); + } else if (s.status === 'failure') { + this.domCache.addNodeClasses(s.nodeId, 'failure'); + + this.domCache.clearStatusTimer(s.nodeId); + + const timer = window.setTimeout(() => { + this.domCache.removeNodeClasses(s.nodeId, 'failure'); + this.domCache.clearStatusTimer(s.nodeId); + }, 2000); + + this.domCache.setStatusTimer(s.nodeId, timer); + } + }); + + this.updateConnectionStyles(statusMap); + } + + private updateConnectionStyles( + statusMap: Record, + connections?: Connection[] + ): void { + if (!connections) return; + + connections.forEach((conn) => { + const connKey = `${conn.from}-${conn.to}`; + + const pathElement = this.domCache.getConnection(connKey); + if (!pathElement) { + return; + } + + const fromStatus = statusMap[conn.from]; + const toStatus = statusMap[conn.to]; + const isActive = fromStatus === 'running' || toStatus === 'running'; + + if (conn.connectionType === 'property') { + this.domCache.setConnectionAttribute(connKey, 'stroke', '#9c27b0'); + this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2'); + } else if (isActive) { + this.domCache.setConnectionAttribute(connKey, 'stroke', '#ffa726'); + this.domCache.setConnectionAttribute(connKey, 'stroke-width', '3'); + } else { + const isExecuted = this.domCache.hasNodeClass(conn.from, 'executed') && + this.domCache.hasNodeClass(conn.to, 'executed'); + + if (isExecuted) { + this.domCache.setConnectionAttribute(connKey, 'stroke', '#4caf50'); + this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2.5'); + } else { + this.domCache.setConnectionAttribute(connKey, 'stroke', '#0e639c'); + this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2'); + } + } + }); + } + + setConnections(connections: Connection[]): void { + if (this.mode !== 'idle') { + const currentStatuses: Record = {}; + connections.forEach((conn) => { + const fromStatus = this.domCache.getLastStatus(conn.from); + const toStatus = this.domCache.getLastStatus(conn.to); + if (fromStatus) currentStatuses[conn.from] = fromStatus; + if (toStatus) currentStatuses[conn.to] = toStatus; + }); + this.updateConnectionStyles(currentStatuses, connections); + } + } +} diff --git a/packages/editor-app/src/application/state/BehaviorTreeDataStore.ts b/packages/editor-app/src/application/state/BehaviorTreeDataStore.ts new file mode 100644 index 00000000..27c8ee75 --- /dev/null +++ b/packages/editor-app/src/application/state/BehaviorTreeDataStore.ts @@ -0,0 +1,65 @@ +import { create } from 'zustand'; +import { BehaviorTree } from '../../domain/models/BehaviorTree'; +import { ITreeState } from '../commands/ITreeState'; +import { useBehaviorTreeStore } from '../../stores/behaviorTreeStore'; +import { Blackboard } from '../../domain/models/Blackboard'; +import { createRootNode, ROOT_NODE_ID } from '../../domain/constants/RootNode'; + +const createInitialTree = (): BehaviorTree => { + const rootNode = createRootNode(); + return new BehaviorTree([rootNode], [], Blackboard.empty(), ROOT_NODE_ID); +}; + +/** + * 行为树数据状态 + * 管理核心业务数据 + */ +interface BehaviorTreeDataState { + /** + * 当前行为树 + */ + tree: BehaviorTree; + + /** + * 设置行为树 + */ + setTree: (tree: BehaviorTree) => void; + + /** + * 重置为空树 + */ + reset: () => void; +} + +/** + * 行为树数据 Store + * 实现 ITreeState 接口,供命令使用 + */ +export const useBehaviorTreeDataStore = create((set) => ({ + tree: createInitialTree(), + + setTree: (tree: BehaviorTree) => set({ tree }), + + reset: () => set({ tree: createInitialTree() }) +})); + +/** + * TreeState 适配器 + * 将 Zustand Store 适配为 ITreeState 接口 + * 同步更新领域层和表现层的状态 + */ +export class TreeStateAdapter implements ITreeState { + getTree(): BehaviorTree { + return useBehaviorTreeDataStore.getState().tree; + } + + setTree(tree: BehaviorTree): void { + useBehaviorTreeDataStore.getState().setTree(tree); + + const nodes = Array.from(tree.nodes); + const connections = Array.from(tree.connections); + + useBehaviorTreeStore.getState().setNodes(nodes); + useBehaviorTreeStore.getState().setConnections(connections); + } +} diff --git a/packages/editor-app/src/application/state/EditorStore.ts b/packages/editor-app/src/application/state/EditorStore.ts new file mode 100644 index 00000000..634754cf --- /dev/null +++ b/packages/editor-app/src/application/state/EditorStore.ts @@ -0,0 +1,88 @@ +import { create } from 'zustand'; + +/** + * 编辑器交互状态 + * 管理编辑器的交互状态(连接、框选、菜单等) + */ +interface EditorState { + /** + * 正在连接的源节点ID + */ + connectingFrom: string | null; + + /** + * 正在连接的源属性 + */ + connectingFromProperty: string | null; + + /** + * 连接目标位置(鼠标位置) + */ + connectingToPos: { x: number; y: number } | null; + + /** + * 是否正在框选 + */ + isBoxSelecting: boolean; + + /** + * 框选起始位置 + */ + boxSelectStart: { x: number; y: number } | null; + + /** + * 框选结束位置 + */ + boxSelectEnd: { x: number; y: number } | null; + + // Actions + setConnectingFrom: (nodeId: string | null) => void; + setConnectingFromProperty: (propertyName: string | null) => void; + setConnectingToPos: (pos: { x: number; y: number } | null) => void; + clearConnecting: () => void; + + setIsBoxSelecting: (isSelecting: boolean) => void; + setBoxSelectStart: (pos: { x: number; y: number } | null) => void; + setBoxSelectEnd: (pos: { x: number; y: number } | null) => void; + clearBoxSelect: () => void; +} + +/** + * Editor Store + */ +export const useEditorStore = create((set) => ({ + connectingFrom: null, + connectingFromProperty: null, + connectingToPos: null, + + isBoxSelecting: false, + boxSelectStart: null, + boxSelectEnd: null, + + setConnectingFrom: (nodeId: string | null) => set({ connectingFrom: nodeId }), + + setConnectingFromProperty: (propertyName: string | null) => + set({ connectingFromProperty: propertyName }), + + setConnectingToPos: (pos: { x: number; y: number } | null) => set({ connectingToPos: pos }), + + clearConnecting: () => + set({ + connectingFrom: null, + connectingFromProperty: null, + connectingToPos: null + }), + + setIsBoxSelecting: (isSelecting: boolean) => set({ isBoxSelecting: isSelecting }), + + setBoxSelectStart: (pos: { x: number; y: number } | null) => set({ boxSelectStart: pos }), + + setBoxSelectEnd: (pos: { x: number; y: number } | null) => set({ boxSelectEnd: pos }), + + clearBoxSelect: () => + set({ + isBoxSelecting: false, + boxSelectStart: null, + boxSelectEnd: null + }) +})); diff --git a/packages/editor-app/src/application/state/UIStore.ts b/packages/editor-app/src/application/state/UIStore.ts new file mode 100644 index 00000000..1af7b0a1 --- /dev/null +++ b/packages/editor-app/src/application/state/UIStore.ts @@ -0,0 +1,131 @@ +import { create } from 'zustand'; + +/** + * UI 状态 + * 管理UI相关的状态(选中、拖拽、画布) + */ +interface UIState { + /** + * 选中的节点ID列表 + */ + selectedNodeIds: string[]; + + /** + * 正在拖拽的节点ID + */ + draggingNodeId: string | null; + + /** + * 拖拽起始位置映射 + */ + dragStartPositions: Map; + + /** + * 是否正在拖拽节点 + */ + isDraggingNode: boolean; + + /** + * 拖拽偏移量 + */ + dragDelta: { dx: number; dy: number }; + + /** + * 画布偏移 + */ + canvasOffset: { x: number; y: number }; + + /** + * 画布缩放 + */ + canvasScale: number; + + /** + * 是否正在平移画布 + */ + isPanning: boolean; + + /** + * 平移起始位置 + */ + panStart: { x: number; y: number }; + + // Actions + setSelectedNodeIds: (nodeIds: string[]) => void; + toggleNodeSelection: (nodeId: string) => void; + clearSelection: () => void; + + startDragging: (nodeId: string, startPositions: Map) => void; + stopDragging: () => void; + setIsDraggingNode: (isDragging: boolean) => void; + setDragDelta: (delta: { dx: number; dy: number }) => void; + + setCanvasOffset: (offset: { x: number; y: number }) => void; + setCanvasScale: (scale: number) => void; + setIsPanning: (isPanning: boolean) => void; + setPanStart: (panStart: { x: number; y: number }) => void; + resetView: () => void; +} + +/** + * UI Store + */ +export const useUIStore = create((set, get) => ({ + selectedNodeIds: [], + draggingNodeId: null, + dragStartPositions: new Map(), + isDraggingNode: false, + dragDelta: { dx: 0, dy: 0 }, + + canvasOffset: { x: 0, y: 0 }, + canvasScale: 1, + isPanning: false, + panStart: { x: 0, y: 0 }, + + setSelectedNodeIds: (nodeIds: string[]) => set({ selectedNodeIds: nodeIds }), + + toggleNodeSelection: (nodeId: string) => { + const { selectedNodeIds } = get(); + if (selectedNodeIds.includes(nodeId)) { + set({ selectedNodeIds: selectedNodeIds.filter((id) => id !== nodeId) }); + } else { + set({ selectedNodeIds: [...selectedNodeIds, nodeId] }); + } + }, + + clearSelection: () => set({ selectedNodeIds: [] }), + + startDragging: (nodeId: string, startPositions: Map) => + set({ + draggingNodeId: nodeId, + dragStartPositions: startPositions, + isDraggingNode: true + }), + + stopDragging: () => + set({ + draggingNodeId: null, + dragStartPositions: new Map(), + isDraggingNode: false, + dragDelta: { dx: 0, dy: 0 } + }), + + setIsDraggingNode: (isDragging: boolean) => set({ isDraggingNode: isDragging }), + + setDragDelta: (delta: { dx: number; dy: number }) => set({ dragDelta: delta }), + + setCanvasOffset: (offset: { x: number; y: number }) => set({ canvasOffset: offset }), + + setCanvasScale: (scale: number) => set({ canvasScale: scale }), + + setIsPanning: (isPanning: boolean) => set({ isPanning }), + + setPanStart: (panStart: { x: number; y: number }) => set({ panStart }), + + resetView: () => + set({ + canvasOffset: { x: 0, y: 0 }, + canvasScale: 1, + isPanning: false + }) +})); diff --git a/packages/editor-app/src/application/state/index.ts b/packages/editor-app/src/application/state/index.ts new file mode 100644 index 00000000..78115054 --- /dev/null +++ b/packages/editor-app/src/application/state/index.ts @@ -0,0 +1,3 @@ +export { useBehaviorTreeDataStore, TreeStateAdapter } from './BehaviorTreeDataStore'; +export { useUIStore } from './UIStore'; +export { useEditorStore } from './EditorStore'; diff --git a/packages/editor-app/src/application/use-cases/AddConnectionUseCase.ts b/packages/editor-app/src/application/use-cases/AddConnectionUseCase.ts new file mode 100644 index 00000000..5860fe50 --- /dev/null +++ b/packages/editor-app/src/application/use-cases/AddConnectionUseCase.ts @@ -0,0 +1,42 @@ +import { Connection, ConnectionType } from '../../domain/models/Connection'; +import { CommandManager } from '../commands/CommandManager'; +import { AddConnectionCommand } from '../commands/tree/AddConnectionCommand'; +import { ITreeState } from '../commands/ITreeState'; +import { IValidator } from '../../domain/interfaces/IValidator'; + +/** + * 添加连接用例 + */ +export class AddConnectionUseCase { + constructor( + private readonly commandManager: CommandManager, + private readonly treeState: ITreeState, + private readonly validator: IValidator + ) {} + + /** + * 执行添加连接操作 + */ + execute( + from: string, + to: string, + connectionType: ConnectionType = 'node', + fromProperty?: string, + toProperty?: string + ): Connection { + const connection = new Connection(from, to, connectionType, fromProperty, toProperty); + + const tree = this.treeState.getTree(); + const validationResult = this.validator.validateConnection(connection, tree); + + if (!validationResult.isValid) { + const errorMessages = validationResult.errors.map((e) => e.message).join(', '); + throw new Error(`连接验证失败: ${errorMessages}`); + } + + const command = new AddConnectionCommand(this.treeState, connection); + this.commandManager.execute(command); + + return connection; + } +} diff --git a/packages/editor-app/src/application/use-cases/CreateNodeUseCase.ts b/packages/editor-app/src/application/use-cases/CreateNodeUseCase.ts new file mode 100644 index 00000000..22c77b31 --- /dev/null +++ b/packages/editor-app/src/application/use-cases/CreateNodeUseCase.ts @@ -0,0 +1,42 @@ +import { NodeTemplate } from '@esengine/behavior-tree'; +import { Node } from '../../domain/models/Node'; +import { Position } from '../../domain/value-objects/Position'; +import { INodeFactory } from '../../domain/interfaces/INodeFactory'; +import { CommandManager } from '../commands/CommandManager'; +import { CreateNodeCommand } from '../commands/tree/CreateNodeCommand'; +import { ITreeState } from '../commands/ITreeState'; + +/** + * 创建节点用例 + */ +export class CreateNodeUseCase { + constructor( + private readonly nodeFactory: INodeFactory, + private readonly commandManager: CommandManager, + private readonly treeState: ITreeState + ) {} + + /** + * 执行创建节点操作 + */ + execute(template: NodeTemplate, position: Position, data?: Record): Node { + const node = this.nodeFactory.createNode(template, position, data); + + const command = new CreateNodeCommand(this.treeState, node); + this.commandManager.execute(command); + + return node; + } + + /** + * 根据类型创建节点 + */ + executeByType(nodeType: string, position: Position, data?: Record): Node { + const node = this.nodeFactory.createNodeByType(nodeType, position, data); + + const command = new CreateNodeCommand(this.treeState, node); + this.commandManager.execute(command); + + return node; + } +} diff --git a/packages/editor-app/src/application/use-cases/DeleteNodeUseCase.ts b/packages/editor-app/src/application/use-cases/DeleteNodeUseCase.ts new file mode 100644 index 00000000..f04eb1b8 --- /dev/null +++ b/packages/editor-app/src/application/use-cases/DeleteNodeUseCase.ts @@ -0,0 +1,77 @@ +import { CommandManager } from '../commands/CommandManager'; +import { DeleteNodeCommand } from '../commands/tree/DeleteNodeCommand'; +import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand'; +import { ITreeState } from '../commands/ITreeState'; +import { ICommand } from '../commands/ICommand'; + +/** + * 删除节点用例 + * 删除节点时会自动删除相关连接 + */ +export class DeleteNodeUseCase { + constructor( + private readonly commandManager: CommandManager, + private readonly treeState: ITreeState + ) {} + + /** + * 删除单个节点 + */ + execute(nodeId: string): void { + const tree = this.treeState.getTree(); + + const relatedConnections = tree.connections.filter( + (conn) => conn.from === nodeId || conn.to === nodeId + ); + + const commands: ICommand[] = []; + + relatedConnections.forEach((conn) => { + commands.push( + new RemoveConnectionCommand( + this.treeState, + conn.from, + conn.to, + conn.fromProperty, + conn.toProperty + ) + ); + }); + + commands.push(new DeleteNodeCommand(this.treeState, nodeId)); + + this.commandManager.executeBatch(commands); + } + + /** + * 批量删除节点 + */ + executeBatch(nodeIds: string[]): void { + const tree = this.treeState.getTree(); + const commands: ICommand[] = []; + + const nodeIdSet = new Set(nodeIds); + + const relatedConnections = tree.connections.filter( + (conn) => nodeIdSet.has(conn.from) || nodeIdSet.has(conn.to) + ); + + relatedConnections.forEach((conn) => { + commands.push( + new RemoveConnectionCommand( + this.treeState, + conn.from, + conn.to, + conn.fromProperty, + conn.toProperty + ) + ); + }); + + nodeIds.forEach((nodeId) => { + commands.push(new DeleteNodeCommand(this.treeState, nodeId)); + }); + + this.commandManager.executeBatch(commands); + } +} diff --git a/packages/editor-app/src/application/use-cases/MoveNodeUseCase.ts b/packages/editor-app/src/application/use-cases/MoveNodeUseCase.ts new file mode 100644 index 00000000..89013883 --- /dev/null +++ b/packages/editor-app/src/application/use-cases/MoveNodeUseCase.ts @@ -0,0 +1,32 @@ +import { Position } from '../../domain/value-objects/Position'; +import { CommandManager } from '../commands/CommandManager'; +import { MoveNodeCommand } from '../commands/tree/MoveNodeCommand'; +import { ITreeState } from '../commands/ITreeState'; + +/** + * 移动节点用例 + */ +export class MoveNodeUseCase { + constructor( + private readonly commandManager: CommandManager, + private readonly treeState: ITreeState + ) {} + + /** + * 移动单个节点 + */ + execute(nodeId: string, newPosition: Position): void { + const command = new MoveNodeCommand(this.treeState, nodeId, newPosition); + this.commandManager.execute(command); + } + + /** + * 批量移动节点 + */ + executeBatch(moves: Array<{ nodeId: string; position: Position }>): void { + const commands = moves.map( + ({ nodeId, position }) => new MoveNodeCommand(this.treeState, nodeId, position) + ); + this.commandManager.executeBatch(commands); + } +} diff --git a/packages/editor-app/src/application/use-cases/RemoveConnectionUseCase.ts b/packages/editor-app/src/application/use-cases/RemoveConnectionUseCase.ts new file mode 100644 index 00000000..e0decd16 --- /dev/null +++ b/packages/editor-app/src/application/use-cases/RemoveConnectionUseCase.ts @@ -0,0 +1,27 @@ +import { CommandManager } from '../commands/CommandManager'; +import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand'; +import { ITreeState } from '../commands/ITreeState'; + +/** + * 移除连接用例 + */ +export class RemoveConnectionUseCase { + constructor( + private readonly commandManager: CommandManager, + private readonly treeState: ITreeState + ) {} + + /** + * 执行移除连接操作 + */ + execute(from: string, to: string, fromProperty?: string, toProperty?: string): void { + const command = new RemoveConnectionCommand( + this.treeState, + from, + to, + fromProperty, + toProperty + ); + this.commandManager.execute(command); + } +} diff --git a/packages/editor-app/src/application/use-cases/UpdateNodeDataUseCase.ts b/packages/editor-app/src/application/use-cases/UpdateNodeDataUseCase.ts new file mode 100644 index 00000000..e501a6ec --- /dev/null +++ b/packages/editor-app/src/application/use-cases/UpdateNodeDataUseCase.ts @@ -0,0 +1,21 @@ +import { CommandManager } from '../commands/CommandManager'; +import { UpdateNodeDataCommand } from '../commands/tree/UpdateNodeDataCommand'; +import { ITreeState } from '../commands/ITreeState'; + +/** + * 更新节点数据用例 + */ +export class UpdateNodeDataUseCase { + constructor( + private readonly commandManager: CommandManager, + private readonly treeState: ITreeState + ) {} + + /** + * 更新节点数据 + */ + execute(nodeId: string, data: Record): void { + const command = new UpdateNodeDataCommand(this.treeState, nodeId, data); + this.commandManager.execute(command); + } +} diff --git a/packages/editor-app/src/application/use-cases/ValidateTreeUseCase.ts b/packages/editor-app/src/application/use-cases/ValidateTreeUseCase.ts new file mode 100644 index 00000000..ff3f981a --- /dev/null +++ b/packages/editor-app/src/application/use-cases/ValidateTreeUseCase.ts @@ -0,0 +1,32 @@ +import { IValidator, ValidationResult } from '../../domain/interfaces/IValidator'; +import { ITreeState } from '../commands/ITreeState'; + +/** + * 验证行为树用例 + */ +export class ValidateTreeUseCase { + constructor( + private readonly validator: IValidator, + private readonly treeState: ITreeState + ) {} + + /** + * 验证当前行为树 + */ + execute(): ValidationResult { + const tree = this.treeState.getTree(); + return this.validator.validateTree(tree); + } + + /** + * 验证并抛出错误(如果验证失败) + */ + executeAndThrow(): void { + const result = this.execute(); + + if (!result.isValid) { + const errorMessages = result.errors.map((e) => e.message).join('\n'); + throw new Error(`行为树验证失败:\n${errorMessages}`); + } + } +} diff --git a/packages/editor-app/src/application/use-cases/index.ts b/packages/editor-app/src/application/use-cases/index.ts new file mode 100644 index 00000000..70e9ff84 --- /dev/null +++ b/packages/editor-app/src/application/use-cases/index.ts @@ -0,0 +1,7 @@ +export { CreateNodeUseCase } from './CreateNodeUseCase'; +export { DeleteNodeUseCase } from './DeleteNodeUseCase'; +export { AddConnectionUseCase } from './AddConnectionUseCase'; +export { RemoveConnectionUseCase } from './RemoveConnectionUseCase'; +export { MoveNodeUseCase } from './MoveNodeUseCase'; +export { UpdateNodeDataUseCase } from './UpdateNodeDataUseCase'; +export { ValidateTreeUseCase } from './ValidateTreeUseCase'; diff --git a/packages/editor-app/src/components/AboutDialog.tsx b/packages/editor-app/src/components/AboutDialog.tsx index a894496f..a38b8b7e 100644 --- a/packages/editor-app/src/components/AboutDialog.tsx +++ b/packages/editor-app/src/components/AboutDialog.tsx @@ -86,7 +86,7 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) { } else { setUpdateStatus('latest'); } - } catch (error: any) { + } catch (error) { console.error('Check update failed:', error); setUpdateStatus('error'); } finally { diff --git a/packages/editor-app/src/components/BehaviorTreeEditor.tsx b/packages/editor-app/src/components/BehaviorTreeEditor.tsx index efc3337f..8f0b2337 100644 --- a/packages/editor-app/src/components/BehaviorTreeEditor.tsx +++ b/packages/editor-app/src/components/BehaviorTreeEditor.tsx @@ -1,31 +1,39 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { NodeTemplate, PropertyDefinition, NodeType, NodeTemplates } from '@esengine/behavior-tree'; -import { - TreePine, Play, Pause, Square, SkipForward, RotateCcw, Trash2, - List, GitBranch, Layers, Shuffle, - Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer, - Clock, FileText, Edit, Calculator, Code, - Equal, Dices, Settings, - Database, AlertTriangle, AlertCircle, Search, X, - LucideIcon -} from 'lucide-react'; -import { ask } from '@tauri-apps/plugin-dialog'; -import { useBehaviorTreeStore, BehaviorTreeNode, Connection } from '../stores/behaviorTreeStore'; -import { BehaviorTreeExecutor, ExecutionStatus, ExecutionLog } from '../utils/BehaviorTreeExecutor'; +import React, { useEffect, useMemo } from 'react'; +import { NodeTemplate } from '@esengine/behavior-tree'; +import { RotateCcw } from 'lucide-react'; +import { useBehaviorTreeStore, BehaviorTreeNode, ROOT_NODE_ID } from '../stores/behaviorTreeStore'; +import { useUIStore } from '../application/state/UIStore'; import { BehaviorTreeExecutionPanel } from './BehaviorTreeExecutionPanel'; import { useToast } from './Toast'; +import { BlackboardValue } from '../domain/models/Blackboard'; +import { BehaviorTreeCanvas } from '../presentation/components/behavior-tree/canvas/BehaviorTreeCanvas'; +import { ConnectionLayer } from '../presentation/components/behavior-tree/connections/ConnectionLayer'; +import { NodeFactory } from '../infrastructure/factories/NodeFactory'; +import { BehaviorTreeValidator } from '../infrastructure/validation/BehaviorTreeValidator'; +import { useNodeOperations } from '../presentation/hooks/useNodeOperations'; +import { useConnectionOperations } from '../presentation/hooks/useConnectionOperations'; +import { useCommandHistory } from '../presentation/hooks/useCommandHistory'; +import { useNodeDrag } from '../presentation/hooks/useNodeDrag'; +import { usePortConnection } from '../presentation/hooks/usePortConnection'; +import { useKeyboardShortcuts } from '../presentation/hooks/useKeyboardShortcuts'; +import { useDropHandler } from '../presentation/hooks/useDropHandler'; +import { useCanvasMouseEvents } from '../presentation/hooks/useCanvasMouseEvents'; +import { useContextMenu } from '../application/hooks/useContextMenu'; +import { useQuickCreateMenu } from '../application/hooks/useQuickCreateMenu'; +import { EditorToolbar } from '../presentation/components/toolbar/EditorToolbar'; +import { QuickCreateMenu } from '../presentation/components/menu/QuickCreateMenu'; +import { NodeContextMenu } from '../presentation/components/menu/NodeContextMenu'; +import { BehaviorTreeNode as BehaviorTreeNodeComponent } from '../presentation/components/behavior-tree/nodes/BehaviorTreeNode'; +import { getPortPosition as getPortPositionUtil } from '../presentation/utils/portUtils'; +import { useExecutionController } from '../presentation/hooks/useExecutionController'; +import { useNodeTracking } from '../presentation/hooks/useNodeTracking'; +import { useEditorState } from '../presentation/hooks/useEditorState'; +import { useEditorHandlers } from '../presentation/hooks/useEditorHandlers'; +import { ICON_MAP, ROOT_NODE_TEMPLATE, DEFAULT_EDITOR_CONFIG } from '../presentation/config/editorConstants'; import '../styles/BehaviorTreeNode.css'; -type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure'; -type ExecutionMode = 'idle' | 'running' | 'paused' | 'step'; - -type BlackboardValue = string | number | boolean | null | undefined | Record | unknown[]; type BlackboardVariables = Record; -interface DraggedVariableData { - variableName: string; -} - interface BehaviorTreeEditorProps { onNodeSelect?: (node: BehaviorTreeNode) => void; onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void; @@ -33,53 +41,6 @@ interface BehaviorTreeEditorProps { projectPath?: string | null; } -/** - * 图标映射表 - * - * 将图标名称映射到对应的lucide-react组件 - */ -const iconMap: Record = { - List, - GitBranch, - Layers, - Shuffle, - RotateCcw, - Repeat, - CheckCircle, - XCircle, - CheckCheck, - HelpCircle, - Snowflake, - Timer, - Clock, - FileText, - Edit, - Calculator, - Code, - Equal, - Dices, - Settings, - Database, - TreePine -}; - -/** - * 生成短位唯一ID - * 使用时间戳和随机数组合,确保唯一性 - */ -function generateUniqueId(): string { - const timestamp = Date.now().toString(36); - const randomPart = Math.random().toString(36).substring(2, 8); - return `${timestamp}${randomPart}`; -} - -/** - * 行为树编辑器主组件 - * - * 提供可视化的行为树编辑画布 - */ -const ROOT_NODE_ID = 'root-node'; - export const BehaviorTreeEditor: React.FC = ({ onNodeSelect, onNodeCreate, @@ -88,53 +49,18 @@ export const BehaviorTreeEditor: React.FC = ({ }) => { const { showToast } = useToast(); - // 创建固定的 Root 节点 - const rootNodeTemplate: NodeTemplate = { - type: NodeType.Composite, - displayName: '根节点', - category: '根节点', - icon: 'TreePine', - description: '行为树根节点', - color: '#FFD700', - defaultConfig: { - nodeType: 'root' - }, - properties: [] - }; - - // 使用 zustand store + // 数据 store(行为树数据) const { nodes, connections, - selectedNodeIds, - draggingNodeId, - dragStartPositions, - isDraggingNode, - canvasOffset, - canvasScale, - isPanning, - panStart, connectingFrom, connectingFromProperty, connectingToPos, isBoxSelecting, boxSelectStart, boxSelectEnd, - dragDelta, setNodes, setConnections, - setSelectedNodeIds, - updateNodesPosition, - removeNodes, - removeConnections, - startDragging, - stopDragging, - setIsDraggingNode, - setCanvasOffset, - setCanvasScale, - setIsPanning, - setPanStart, - resetView, setConnectingFrom, setConnectingFromProperty, setConnectingToPos, @@ -143,7 +69,6 @@ export const BehaviorTreeEditor: React.FC = ({ setBoxSelectStart, setBoxSelectEnd, clearBoxSelect, - setDragDelta, triggerForceUpdate, sortChildrenByPosition, setBlackboardVariables, @@ -153,43 +78,36 @@ export const BehaviorTreeEditor: React.FC = ({ isExecuting } = useBehaviorTreeStore(); - // 右键菜单状态 - const [contextMenu, setContextMenu] = useState<{ - visible: boolean; - position: { x: number; y: number }; - nodeId: string | null; - }>({ - visible: false, - position: { x: 0, y: 0 }, - nodeId: null - }); + // UI store(选中、拖拽、画布状态) + const { + selectedNodeIds, + draggingNodeId, + dragStartPositions, + isDraggingNode, + canvasOffset, + canvasScale, + dragDelta, + setSelectedNodeIds, + startDragging, + stopDragging, + setIsDraggingNode, + resetView, + setDragDelta + } = useUIStore(); - // 初始化根节点(仅在首次挂载时检查) - useEffect(() => { - if (nodes.length === 0) { - setNodes([{ - id: ROOT_NODE_ID, - template: rootNodeTemplate, - data: { nodeType: 'root' }, - position: { x: 400, y: 100 }, - children: [] - }]); - } - }, []); + // 依赖注入 - 基础设施 + const nodeFactory = useMemo(() => new NodeFactory(), []); + const validator = useMemo(() => new BehaviorTreeValidator(), []); - // 初始化executor用于检查执行器是否存在 - useEffect(() => { - if (!executorRef.current) { - executorRef.current = new BehaviorTreeExecutor(); - } + // 命令历史管理(创建 CommandManager) + const { commandManager, canUndo, canRedo, undo, redo } = useCommandHistory(); - return () => { - if (executorRef.current) { - executorRef.current.destroy(); - executorRef.current = null; - } - }; - }, []); + // 应用层 hooks(使用统一的 commandManager) + const nodeOperations = useNodeOperations(nodeFactory, validator, commandManager); + const connectionOperations = useConnectionOperations(validator, commandManager); + + // 右键菜单 + const { contextMenu, setContextMenu, handleNodeContextMenu, closeContextMenu } = useContextMenu(); // 组件挂载和连线变化时强制更新,确保连线能正确渲染 useEffect(() => { @@ -206,7 +124,7 @@ export const BehaviorTreeEditor: React.FC = ({ useEffect(() => { const handleClick = () => { if (contextMenu.visible) { - setContextMenu({ ...contextMenu, visible: false }); + closeContextMenu(); } }; @@ -214,1240 +132,196 @@ export const BehaviorTreeEditor: React.FC = ({ document.addEventListener('click', handleClick); return () => document.removeEventListener('click', handleClick); } - }, [contextMenu.visible]); + }, [contextMenu.visible, closeContextMenu]); - const [isDragging, setIsDragging] = useState(false); - const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); - const canvasRef = useRef(null); + const { + canvasRef, + stopExecutionRef, + executorRef, + selectedConnection, + setSelectedConnection + } = useEditorState(); - // 快速创建菜单状态 - const [quickCreateMenu, setQuickCreateMenu] = useState<{ - visible: boolean; - position: { x: number; y: number }; - searchText: string; - selectedIndex: number; - mode: 'create' | 'replace'; - replaceNodeId: string | null; - }>({ - visible: false, - position: { x: 0, y: 0 }, - searchText: '', - selectedIndex: 0, - mode: 'create', - replaceNodeId: null - }); - const selectedNodeRef = useRef(null); - - // 运行状态 - const [executionMode, setExecutionMode] = useState('idle'); - const [executionLogs, setExecutionLogs] = useState([]); - const [executionSpeed, setExecutionSpeed] = useState(1.0); - const [tickCount, setTickCount] = useState(0); - const executionModeRef = useRef('idle'); - const executorRef = useRef(null); - const animationFrameRef = useRef(null); - const lastTickTimeRef = useRef(0); - const executionSpeedRef = useRef(1.0); - const statusTimersRef = useRef>(new Map()); - // 保存设计时的初始黑板变量值(用于保存和停止后还原) - const initialBlackboardVariablesRef = useRef({}); - - // 跟踪运行时添加的节点(在运行中未生效的节点) - const [uncommittedNodeIds, setUncommittedNodeIds] = useState>(new Set()); - const activeNodeIdsRef = useRef>(new Set()); - - // 自动滚动到选中的节点 - useEffect(() => { - if (quickCreateMenu.visible && selectedNodeRef.current) { - selectedNodeRef.current.scrollIntoView({ - block: 'nearest', - behavior: 'smooth' - }); - } - }, [quickCreateMenu.selectedIndex, quickCreateMenu.visible]); - - // 选中的连线 - const [selectedConnection, setSelectedConnection] = useState<{from: string; to: string} | null>(null); - - // 缓存DOM元素引用和上一次的状态 - const domCacheRef = useRef<{ - nodes: Map; - connections: Map; - lastNodeStatus: Map; - }>({ - nodes: new Map(), - connections: new Map(), - lastNodeStatus: new Map() + const { + executionMode, + executionLogs, + executionSpeed, + tickCount, + handlePlay, + handlePause, + handleStop, + handleStep, + handleReset, + handleSpeedChange, + setExecutionLogs, + controller + } = useExecutionController({ + rootNodeId: ROOT_NODE_ID, + projectPath, + blackboardVariables, + nodes, + connections, + initialBlackboardVariables, + onBlackboardUpdate: setBlackboardVariables, + onInitialBlackboardSave: setInitialBlackboardVariables, + onExecutingChange: setIsExecuting }); - // 键盘事件监听 - 删除选中节点 - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // 检查焦点是否在可编辑元素上 - const activeElement = document.activeElement; - const isEditingText = activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement instanceof HTMLSelectElement || - (activeElement as HTMLElement)?.isContentEditable; + executorRef.current = controller['executor'] || null; + + const { uncommittedNodeIds } = useNodeTracking({ + nodes, + executionMode + }); + + // 快速创建菜单 + const { + quickCreateMenu, + setQuickCreateMenu, + handleQuickCreateNode + } = useQuickCreateMenu({ + nodeOperations, + connectionOperations, + canvasRef, + canvasOffset, + canvasScale, + connectingFrom, + connectingFromProperty, + clearConnecting, + nodes, + setNodes, + connections, + executionMode, + onStop: () => stopExecutionRef.current?.(), + onNodeCreate, + showToast + }); + + // 节点拖拽 + const { + handleNodeMouseDown, + handleNodeMouseMove, + handleNodeMouseUp + } = useNodeDrag({ + canvasRef, + canvasOffset, + canvasScale, + nodes, + selectedNodeIds, + draggingNodeId, + dragStartPositions, + isDraggingNode, + dragDelta, + nodeOperations, + setSelectedNodeIds, + startDragging, + stopDragging, + setIsDraggingNode, + setDragDelta, + setIsBoxSelecting, + setBoxSelectStart, + setBoxSelectEnd, + sortChildrenByPosition + }); + + // 端口连接 + const { + handlePortMouseDown, + handlePortMouseUp, + handleNodeMouseUpForConnection + } = usePortConnection({ + canvasRef, + canvasOffset, + canvasScale, + nodes, + connections, + connectingFrom, + connectingFromProperty, + connectionOperations, + setConnectingFrom, + setConnectingFromProperty, + clearConnecting, + sortChildrenByPosition, + showToast + }); + + // 键盘快捷键 + useKeyboardShortcuts({ + selectedNodeIds, + selectedConnection, + connections, + nodeOperations, + connectionOperations, + setSelectedNodeIds, + setSelectedConnection + }); + + // 拖放处理 + const { + isDragging, + handleDrop, + handleDragOver, + handleDragLeave, + handleDragEnter + } = useDropHandler({ + canvasRef, + canvasOffset, + canvasScale, + nodeOperations, + onNodeCreate + }); + + // 画布鼠标事件 + const { + handleCanvasMouseMove, + handleCanvasMouseUp, + handleCanvasMouseDown + } = useCanvasMouseEvents({ + canvasRef, + canvasOffset, + canvasScale, + connectingFrom, + connectingToPos, + isBoxSelecting, + boxSelectStart, + boxSelectEnd, + nodes, + selectedNodeIds, + quickCreateMenu, + setConnectingToPos, + setIsBoxSelecting, + setBoxSelectStart, + setBoxSelectEnd, + setSelectedNodeIds, + setSelectedConnection, + setQuickCreateMenu, + clearConnecting, + clearBoxSelect + }); + + + const { + handleNodeClick, + handleResetView, + handleClearCanvas + } = useEditorHandlers({ + isDraggingNode, + selectedNodeIds, + setSelectedNodeIds, + setNodes, + setConnections, + resetView, + triggerForceUpdate, + onNodeSelect, + rootNodeId: ROOT_NODE_ID, + rootNodeTemplate: ROOT_NODE_TEMPLATE + }); + + const getPortPosition = (nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') => + getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType); + + stopExecutionRef.current = handleStop; - // 如果正在编辑文本,不执行删除节点操作 - if (isEditingText) { - return; - } - - if (e.key === 'Delete' || e.key === 'Backspace') { - e.preventDefault(); - - // 优先删除选中的连线 - if (selectedConnection) { - // 删除连接 - removeConnections((conn: Connection) => - !(conn.from === selectedConnection.from && conn.to === selectedConnection.to) - ); - - // 同步更新父节点的children数组,移除被删除的子节点引用 - setNodes(nodes.map((node: BehaviorTreeNode) => { - if (node.id === selectedConnection.from) { - return { - ...node, - children: node.children.filter((childId: string) => childId !== selectedConnection.to) - }; - } - return node; - })); - - setSelectedConnection(null); - return; - } - - // 删除选中的节点 - if (selectedNodeIds.length > 0) { - // 不能删除 Root 节点 - const nodesToDelete = selectedNodeIds.filter((id: string) => id !== ROOT_NODE_ID); - if (nodesToDelete.length > 0) { - // 删除节点 - removeNodes(nodesToDelete); - // 删除相关连接 - removeConnections((conn: Connection) => - !nodesToDelete.includes(conn.from) && !nodesToDelete.includes(conn.to) - ); - // 清空选择 - setSelectedNodeIds([]); - } - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [selectedNodeIds, selectedConnection, removeNodes, removeConnections, setSelectedNodeIds]); - - // 监听节点变化,跟踪运行时添加的节点 - useEffect(() => { - if (executionMode === 'idle') { - // 重新运行时清空未提交节点列表 - setUncommittedNodeIds(new Set()); - // 记录当前所有节点ID - activeNodeIdsRef.current = new Set(nodes.map((n) => n.id)); - } else if (executionMode === 'running' || executionMode === 'paused') { - // 检测新增的节点 - const currentNodeIds = new Set(nodes.map((n) => n.id)); - const newNodeIds = new Set(); - - currentNodeIds.forEach((id) => { - if (!activeNodeIdsRef.current.has(id)) { - newNodeIds.add(id); - } - }); - - if (newNodeIds.size > 0) { - setUncommittedNodeIds((prev) => new Set([...prev, ...newNodeIds])); - } - } - }, [nodes, executionMode]); - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - - try { - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - // 将鼠标坐标转换为画布坐标系 - const position = { - x: (e.clientX - rect.left - canvasOffset.x) / canvasScale, - y: (e.clientY - rect.top - canvasOffset.y) / canvasScale - }; - - // 检查是否是黑板变量 - const blackboardVariableData = e.dataTransfer.getData('application/blackboard-variable'); - if (blackboardVariableData) { - const variableData = JSON.parse(blackboardVariableData) as DraggedVariableData; - - // 创建黑板变量节点 - const variableTemplate: NodeTemplate = { - type: NodeType.Action, - displayName: variableData.variableName, - category: 'Blackboard Variable', - icon: 'Database', - description: `Blackboard variable: ${variableData.variableName}`, - color: '#9c27b0', - defaultConfig: { - nodeType: 'blackboard-variable', - variableName: variableData.variableName - }, - properties: [ - { - name: 'variableName', - label: '变量名', - type: 'variable', - defaultValue: variableData.variableName, - description: '黑板变量的名称', - required: true - } - ] - }; - - const newNode: BehaviorTreeNode = { - id: `var_${variableData.variableName}_${generateUniqueId()}`, - template: variableTemplate, - data: { - nodeType: 'blackboard-variable', - variableName: variableData.variableName - }, - position, - children: [] - }; - - setNodes([...nodes, newNode]); - return; - } - - // 处理普通节点 - let templateData = e.dataTransfer.getData('application/behavior-tree-node'); - if (!templateData) { - templateData = e.dataTransfer.getData('text/plain'); - } - if (!templateData) { - return; - } - - const template = JSON.parse(templateData) as NodeTemplate; - - const newNode: BehaviorTreeNode = { - id: `node_${generateUniqueId()}`, - template, - data: { ...template.defaultConfig }, - position, - children: [] - }; - - setNodes([...nodes, newNode]); - onNodeCreate?.(template, position); - } catch (error) { - console.error('Failed to create node:', error); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = 'copy'; - if (!isDragging) { - setIsDragging(true); - } - }; - - const handleDragLeave = (e: React.DragEvent) => { - if (e.currentTarget === e.target) { - setIsDragging(false); - } - }; - - const handleDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - }; - - const handleNodeClick = (e: React.MouseEvent, node: BehaviorTreeNode) => { - // 如果刚刚在拖动,不处理点击事件 - if (isDraggingNode) { - return; - } - - // Ctrl/Cmd + 点击:多选/取消选择 - if (e.ctrlKey || e.metaKey) { - if (selectedNodeIds.includes(node.id)) { - // 取消选择 - setSelectedNodeIds(selectedNodeIds.filter((id: string) => id !== node.id)); - } else { - // 添加到选择 - setSelectedNodeIds([...selectedNodeIds, node.id]); - } - } else { - // 普通点击:单选 - setSelectedNodeIds([node.id]); - } - onNodeSelect?.(node); - }; - - const handleNodeContextMenu = (e: React.MouseEvent, node: BehaviorTreeNode) => { - e.preventDefault(); - e.stopPropagation(); - - // 不允许对Root节点右键 - if (node.id === ROOT_NODE_ID) { - return; - } - - setContextMenu({ - visible: true, - position: { x: e.clientX, y: e.clientY }, - nodeId: node.id - }); - }; - - const handleReplaceNode = (newTemplate: NodeTemplate) => { - const nodeToReplace = nodes.find((n) => n.id === quickCreateMenu.replaceNodeId); - if (!nodeToReplace) return; - - // 如果行为树正在执行,先停止 - if (executionMode !== 'idle') { - handleStop(); - } - - // 合并数据:新模板的默认配置 + 保留旧节点中同名属性的值 - const newData = { ...newTemplate.defaultConfig }; - - // 获取新模板的属性名列表 - const newPropertyNames = new Set(newTemplate.properties.map((p) => p.name)); - - // 遍历旧节点的 data,保留新模板中也存在的属性 - for (const [key, value] of Object.entries(nodeToReplace.data)) { - // 跳过节点类型相关的字段 - if (key === 'nodeType' || key === 'compositeType' || key === 'decoratorType' || - key === 'actionType' || key === 'conditionType') { - continue; - } - - // 如果新模板也有这个属性,保留旧值(包括绑定信息) - if (newPropertyNames.has(key)) { - newData[key] = value; - } - } - - // 创建新节点,保留原节点的位置和连接 - const newNode: BehaviorTreeNode = { - id: nodeToReplace.id, - template: newTemplate, - data: newData, - position: nodeToReplace.position, - children: nodeToReplace.children - }; - - // 替换节点 - setNodes(nodes.map((n) => n.id === newNode.id ? newNode : n)); - - // 删除所有指向该节点的属性连接,让用户重新连接 - const updatedConnections = connections.filter((conn) => - !(conn.connectionType === 'property' && conn.to === newNode.id) - ); - setConnections(updatedConnections); - - // 关闭快速创建菜单 - setQuickCreateMenu({ - visible: false, - position: { x: 0, y: 0 }, - searchText: '', - selectedIndex: 0, - mode: 'create', - replaceNodeId: null - }); - - // 显示提示 - showToast?.(`已将节点替换为 ${newTemplate.displayName}`, 'success'); - }; - - const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => { - // 只允许左键拖动节点 - if (e.button !== 0) return; - - // Root 节点不能拖动 - if (nodeId === ROOT_NODE_ID) return; - - // 检查是否点击的是端口 - const target = e.target as HTMLElement; - if (target.getAttribute('data-port')) { - return; - } - - e.stopPropagation(); - - // 阻止框选 - setIsBoxSelecting(false); - setBoxSelectStart(null); - setBoxSelectEnd(null); - const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId); - if (!node) return; - - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - // 将鼠标坐标转换为画布坐标系 - const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale; - const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale; - - // 确定要拖动的节点列表 - let nodesToDrag: string[]; - if (selectedNodeIds.includes(nodeId)) { - // 如果拖动的节点已经在选中列表中,拖动所有选中的节点 - nodesToDrag = selectedNodeIds; - } else { - // 如果拖动的节点不在选中列表中,只拖动这一个节点,并选中它 - nodesToDrag = [nodeId]; - setSelectedNodeIds([nodeId]); - } - - // 记录所有要拖动节点的起始位置 - const startPositions = new Map(); - nodesToDrag.forEach((id: string) => { - const n = nodes.find((node: BehaviorTreeNode) => node.id === id); - if (n) { - startPositions.set(id, { ...n.position }); - } - }); - - // 使用 store 的 startDragging - startDragging(nodeId, startPositions); - setDragOffset({ - x: canvasX - node.position.x, - y: canvasY - node.position.y - }); - }; - - const handleNodeMouseMove = (e: React.MouseEvent) => { - if (!draggingNodeId) return; - - // 标记正在拖动(只在第一次调用) - if (!isDraggingNode) { - setIsDraggingNode(true); - } - - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - // 将鼠标坐标转换为画布坐标系 - const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale; - const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale; - - const newX = canvasX - dragOffset.x; - const newY = canvasY - dragOffset.y; - - // 计算拖动的节点的位移 - const draggedNodeStartPos = dragStartPositions.get(draggingNodeId); - if (!draggedNodeStartPos) return; - - const deltaX = newX - draggedNodeStartPos.x; - const deltaY = newY - draggedNodeStartPos.y; - - // 只更新偏移量,所有节点会在同一次渲染中更新 - setDragDelta({ dx: deltaX, dy: deltaY }); - }; - - const handleNodeMouseUp = () => { - if (!draggingNodeId) return; - - // 将临时位置同步到 zustand store - if (dragDelta.dx !== 0 || dragDelta.dy !== 0) { - const updates = new Map(); - dragStartPositions.forEach((startPos: { x: number; y: number }, nodeId: string) => { - updates.set(nodeId, { - x: startPos.x + dragDelta.dx, - y: startPos.y + dragDelta.dy - }); - }); - updateNodesPosition(updates); - - // 拖动结束后,自动排序子节点 - setTimeout(() => { - sortChildrenByPosition(); - }, 0); - } - - // 重置偏移量 - setDragDelta({ dx: 0, dy: 0 }); - - // 停止拖动 - stopDragging(); - - // 延迟清除拖动标志,确保 onClick 能够检测到拖动状态 - setTimeout(() => { - setIsDraggingNode(false); - }, 10); - }; - - const handlePortMouseDown = (e: React.MouseEvent, nodeId: string, propertyName?: string) => { - e.stopPropagation(); - const target = e.currentTarget as HTMLElement; - const portType = target.getAttribute('data-port-type'); - - setConnectingFrom(nodeId); - setConnectingFromProperty(propertyName || null); - - // 存储起点引脚类型到 DOM 属性,供 mouseUp 使用 - if (canvasRef.current) { - canvasRef.current.setAttribute('data-connecting-from-port-type', portType || ''); - } - }; - - const handleCanvasMouseMove = (e: React.MouseEvent) => { - // 处理连接线拖拽(如果快速创建菜单显示了,不更新预览连接线) - if (connectingFrom && canvasRef.current && !quickCreateMenu.visible) { - const rect = canvasRef.current.getBoundingClientRect(); - // 将鼠标坐标转换为画布坐标系 - const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale; - const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale; - setConnectingToPos({ - x: canvasX, - y: canvasY - }); - } - - // 处理画布平移 - if (isPanning) { - setCanvasOffset({ - x: e.clientX - panStart.x, - y: e.clientY - panStart.y - }); - } - - // 处理框选 - if (isBoxSelecting && boxSelectStart) { - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale; - const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale; - setBoxSelectEnd({ x: canvasX, y: canvasY }); - } - }; - - const handlePortMouseUp = (e: React.MouseEvent, nodeId: string, propertyName?: string) => { - e.stopPropagation(); - if (!connectingFrom) { - clearConnecting(); - return; - } - - // 禁止连接到自己 - if (connectingFrom === nodeId) { - showToast('不能将节点连接到自己', 'warning'); - clearConnecting(); - return; - } - - const target = e.currentTarget as HTMLElement; - const toPortType = target.getAttribute('data-port-type'); - const fromPortType = canvasRef.current?.getAttribute('data-connecting-from-port-type'); - - // 智能判断连接方向 - let actualFrom = connectingFrom; - let actualTo = nodeId; - let actualFromProperty = connectingFromProperty; - let actualToProperty = propertyName; - - // 判断是否需要反转方向 - const needReverse = - (fromPortType === 'node-input' || fromPortType === 'property-input') && - (toPortType === 'node-output' || toPortType === 'variable-output'); - - if (needReverse) { - // 反转连接方向 - actualFrom = nodeId; - actualTo = connectingFrom; - actualFromProperty = propertyName || null; - actualToProperty = connectingFromProperty ?? undefined; - } - - // 属性级别的连接 - if (actualFromProperty || actualToProperty) { - // 检查是否已经存在相同的属性连接 - const existingConnection = connections.find( - (conn: Connection) => - (conn.from === actualFrom && conn.to === actualTo && - conn.fromProperty === actualFromProperty && conn.toProperty === actualToProperty) || - (conn.from === actualTo && conn.to === actualFrom && - conn.fromProperty === actualToProperty && conn.toProperty === actualFromProperty) - ); - - if (existingConnection) { - showToast('该连接已存在', 'warning'); - clearConnecting(); - return; - } - - // 检查目标属性是否允许多个连接 - const toNode = nodes.find((n: BehaviorTreeNode) => n.id === actualTo); - if (toNode && actualToProperty) { - const targetProperty = toNode.template.properties.find( - (p: PropertyDefinition) => p.name === actualToProperty - ); - - // 如果属性不允许多个连接(默认行为) - if (!targetProperty?.allowMultipleConnections) { - // 检查是否已有连接到该属性 - const existingPropertyConnection = connections.find( - (conn: Connection) => - conn.connectionType === 'property' && - conn.to === actualTo && - conn.toProperty === actualToProperty - ); - - if (existingPropertyConnection) { - showToast('该属性已有连接,请先删除现有连接', 'warning'); - clearConnecting(); - return; - } - } - } - - setConnections([...connections, { - from: actualFrom, - to: actualTo, - fromProperty: actualFromProperty || undefined, - toProperty: actualToProperty || undefined, - connectionType: 'property' - }]); - } else { - // 节点级别的连接 - // Root 节点只能有一个子节点 - if (actualFrom === ROOT_NODE_ID) { - const rootNode = nodes.find((n: BehaviorTreeNode) => n.id === ROOT_NODE_ID); - if (rootNode && rootNode.children.length > 0) { - showToast('根节点只能连接一个子节点', 'warning'); - clearConnecting(); - return; - } - } - - // 检查是否已经存在相同的节点连接 - const existingConnection = connections.find( - (conn: Connection) => - (conn.from === actualFrom && conn.to === actualTo && conn.connectionType === 'node') || - (conn.from === actualTo && conn.to === actualFrom && conn.connectionType === 'node') - ); - - if (existingConnection) { - showToast('该连接已存在', 'warning'); - clearConnecting(); - return; - } - - setConnections([...connections, { - from: actualFrom, - to: actualTo, - connectionType: 'node' - }]); - - // 更新节点的 children - setNodes(nodes.map((node: BehaviorTreeNode) => - node.id === actualFrom - ? { ...node, children: [...node.children, actualTo] } - : node - )); - - // 创建连接后,自动排序子节点 - setTimeout(() => { - sortChildrenByPosition(); - }, 0); - } - - clearConnecting(); - }; - - const handleNodeMouseUpForConnection = (e: React.MouseEvent, nodeId: string) => { - // 如果正在连接,尝试自动连接到这个节点 - if (connectingFrom && connectingFrom !== nodeId) { - // 直接调用 handlePortMouseUp 来完成连接 - handlePortMouseUp(e, nodeId); - } - }; - - const handleCanvasMouseUp = (e: React.MouseEvent) => { - // 如果快速创建菜单已经显示,不要清除连接状态 - if (quickCreateMenu.visible) { - return; - } - - // 如果正在连接,显示快速创建菜单 - if (connectingFrom && connectingToPos) { - setQuickCreateMenu({ - visible: true, - position: { - x: e.clientX, - y: e.clientY - }, - searchText: '', - selectedIndex: 0, - mode: 'create', - replaceNodeId: null - }); - // 清除预览连接线,但保留 connectingFrom 用于创建连接 - setConnectingToPos(null); - return; - } - - clearConnecting(); - setIsPanning(false); - - // 完成框选 - if (isBoxSelecting && boxSelectStart && boxSelectEnd) { - // 计算框选矩形 - const minX = Math.min(boxSelectStart.x, boxSelectEnd.x); - const maxX = Math.max(boxSelectStart.x, boxSelectEnd.x); - const minY = Math.min(boxSelectStart.y, boxSelectEnd.y); - const maxY = Math.max(boxSelectStart.y, boxSelectEnd.y); - - // 检测哪些节点在框选区域内 - const selectedInBox = nodes - .filter((node: BehaviorTreeNode) => { - // Root 节点不参与框选 - if (node.id === ROOT_NODE_ID) return false; - - // 从 DOM 获取节点的实际尺寸 - const nodeElement = canvasRef.current?.querySelector(`[data-node-id="${node.id}"]`); - if (!nodeElement) { - // 如果找不到元素,回退到中心点检查 - return node.position.x >= minX && node.position.x <= maxX && - node.position.y >= minY && node.position.y <= maxY; - } - - const rect = nodeElement.getBoundingClientRect(); - const canvasRect = canvasRef.current!.getBoundingClientRect(); - - // 将 DOM 坐标转换为画布坐标 - const nodeLeft = (rect.left - canvasRect.left - canvasOffset.x) / canvasScale; - const nodeRight = (rect.right - canvasRect.left - canvasOffset.x) / canvasScale; - const nodeTop = (rect.top - canvasRect.top - canvasOffset.y) / canvasScale; - const nodeBottom = (rect.bottom - canvasRect.top - canvasOffset.y) / canvasScale; - - // 检查矩形是否重叠 - return nodeRight > minX && nodeLeft < maxX && nodeBottom > minY && nodeTop < maxY; - }) - .map((node: BehaviorTreeNode) => node.id); - - // 根据是否按下 Ctrl/Cmd 决定是添加选择还是替换选择 - if (e.ctrlKey || e.metaKey) { - // 添加到现有选择 - const newSet = new Set([...selectedNodeIds, ...selectedInBox]); - setSelectedNodeIds(Array.from(newSet)); - } else { - // 替换选择 - setSelectedNodeIds(selectedInBox); - } - } - - // 清理框选状态 - clearBoxSelect(); - }; - - const handleQuickCreateNode = (template: NodeTemplate) => { - // 如果是替换模式,直接调用替换函数 - if (quickCreateMenu.mode === 'replace') { - handleReplaceNode(template); - return; - } - - // 创建模式:需要连接 - if (!connectingFrom) { - return; - } - - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) { - return; - } - - const position = { - x: (quickCreateMenu.position.x - rect.left - canvasOffset.x) / canvasScale, - y: (quickCreateMenu.position.y - rect.top - canvasOffset.y) / canvasScale - }; - - const newNode: BehaviorTreeNode = { - id: `node_${generateUniqueId()}`, - template, - data: { ...template.defaultConfig }, - position, - children: [] - }; - - const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom); - if (fromNode) { - if (connectingFromProperty) { - // 属性连接 - setConnections([ - ...connections, - { - from: connectingFrom, - fromProperty: connectingFromProperty, - to: newNode.id, - connectionType: 'property' - } - ]); - setNodes([...nodes, newNode]); - } else { - // 节点连接:需要同时更新 connections 和父节点的 children - setConnections([ - ...connections, - { - from: connectingFrom, - to: newNode.id, - connectionType: 'node' - } - ]); - - // 更新父节点的 children 数组 - setNodes([ - ...nodes.map((node: BehaviorTreeNode) => - node.id === connectingFrom - ? { ...node, children: [...node.children, newNode.id] } - : node - ), - newNode - ]); - } - } else { - setNodes([...nodes, newNode]); - } - - setQuickCreateMenu({ - visible: false, - position: { x: 0, y: 0 }, - searchText: '', - selectedIndex: 0, - mode: 'create', - replaceNodeId: null - }); - clearConnecting(); - - onNodeCreate?.(template, position); - }; - - // 画布缩放 - const handleWheel = (e: React.WheelEvent) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? 0.9 : 1.1; - const newScale = Math.max(0.1, Math.min(3, canvasScale * delta)); - setCanvasScale(newScale); - - // 强制更新连接线位置 - requestAnimationFrame(() => { - triggerForceUpdate(); - }); - }; - - // 画布平移和框选 - const handleCanvasMouseDown = (e: React.MouseEvent) => { - if (e.button === 1 || (e.button === 0 && e.altKey)) { - // 中键或 Alt+左键:平移 - e.preventDefault(); - setIsPanning(true); - setPanStart({ x: e.clientX - canvasOffset.x, y: e.clientY - canvasOffset.y }); - } else if (e.button === 0 && !e.altKey) { - // 左键:开始框选 - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale; - const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale; - - setIsBoxSelecting(true); - setBoxSelectStart({ x: canvasX, y: canvasY }); - setBoxSelectEnd({ x: canvasX, y: canvasY }); - - // 如果不是 Ctrl/Cmd,清空当前选择 - if (!e.ctrlKey && !e.metaKey) { - setSelectedNodeIds([]); - setSelectedConnection(null); - } - } - }; - - // 重置视图 - const handleResetView = () => { - resetView(); - // 强制更新连线位置 - requestAnimationFrame(() => { - triggerForceUpdate(); - }); - }; - - // 从DOM获取引脚的实际位置(画布坐标系) - // portType: 'input' | 'output' - 只用于节点连接,属性连接不需要指定 - const getPortPosition = (nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output'): { x: number; y: number } | null => { - const canvas = canvasRef.current; - if (!canvas) return null; - - let selector: string; - if (propertyName) { - // 属性引脚 - selector = `[data-node-id="${nodeId}"][data-property="${propertyName}"]`; - } else { - // 节点的端口 - const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId); - if (!node) return null; - - // 黑板变量节点的右侧输出引脚 - if (node.data.nodeType === 'blackboard-variable') { - selector = `[data-node-id="${nodeId}"][data-port-type="variable-output"]`; - } else { - // 普通节点的端口 - if (portType === 'input') { - // 顶部输入端口 - selector = `[data-node-id="${nodeId}"][data-port-type="node-input"]`; - } else { - // 底部输出端口 - selector = `[data-node-id="${nodeId}"][data-port-type="node-output"]`; - } - } - } - - const portElement = canvas.querySelector(selector) as HTMLElement; - if (!portElement) return null; - - const rect = portElement.getBoundingClientRect(); - const canvasRect = canvas.getBoundingClientRect(); - - // 计算画布坐标系中的位置(考虑缩放和平移) - const x = (rect.left + rect.width / 2 - canvasRect.left - canvasOffset.x) / canvasScale; - const y = (rect.top + rect.height / 2 - canvasRect.top - canvasOffset.y) / canvasScale; - - return { x, y }; - }; - - // 执行状态回调(直接操作DOM,不触发React重渲染) - const handleExecutionStatusUpdate = ( - statuses: ExecutionStatus[], - logs: ExecutionLog[], - runtimeBlackboardVars?: BlackboardVariables - ): void => { - // 更新执行日志 - setExecutionLogs([...logs]); - - // 同步运行时黑板变量到 store(无论运行还是暂停都同步) - if (runtimeBlackboardVars) { - setBlackboardVariables(runtimeBlackboardVars); - } - - const cache = domCacheRef.current; - const statusMap: Record = {}; - - // 直接操作DOM来更新节点样式,避免重渲染 - statuses.forEach((s) => { - statusMap[s.nodeId] = s.status; - - // 检查状态是否真的变化了 - const lastStatus = cache.lastNodeStatus.get(s.nodeId); - if (lastStatus === s.status) { - return; // 状态未变化,跳过 - } - cache.lastNodeStatus.set(s.nodeId, s.status); - - // 获取或缓存节点DOM - let nodeElement = cache.nodes.get(s.nodeId); - if (!nodeElement) { - nodeElement = document.querySelector(`[data-node-id="${s.nodeId}"]`) || undefined; - if (nodeElement) { - cache.nodes.set(s.nodeId, nodeElement); - } else { - return; - } - } - - // 移除所有状态类 - nodeElement.classList.remove('running', 'success', 'failure', 'executed'); - - // 添加当前状态类 - if (s.status === 'running') { - nodeElement.classList.add('running'); - } else if (s.status === 'success') { - nodeElement.classList.add('success'); - - // 清除之前的定时器 - const existingTimer = statusTimersRef.current.get(s.nodeId); - if (existingTimer) { - clearTimeout(existingTimer); - } - - // 2秒后移除success状态,添加executed标记 - const timer = window.setTimeout(() => { - nodeElement!.classList.remove('success'); - nodeElement!.classList.add('executed'); - statusTimersRef.current.delete(s.nodeId); - }, 2000); - - statusTimersRef.current.set(s.nodeId, timer); - } else if (s.status === 'failure') { - nodeElement.classList.add('failure'); - - // 清除之前的定时器 - const existingTimer = statusTimersRef.current.get(s.nodeId); - if (existingTimer) { - clearTimeout(existingTimer); - } - - // 2秒后移除failure状态 - const timer = window.setTimeout(() => { - nodeElement!.classList.remove('failure'); - statusTimersRef.current.delete(s.nodeId); - }, 2000); - - statusTimersRef.current.set(s.nodeId, timer); - } - }); - - // 更新连线颜色(直接操作DOM) - updateConnectionStyles(statusMap); - }; - - // 更新连线样式(直接操作DOM,缓存查询) - const updateConnectionStyles = (statusMap: Record): void => { - const cache = domCacheRef.current; - - connections.forEach((conn) => { - const connKey = `${conn.from}-${conn.to}`; - - // 获取或缓存连线DOM - let pathElement = cache.connections.get(connKey); - if (!pathElement) { - pathElement = document.querySelector(`[data-connection-id="${connKey}"]`) || undefined; - if (pathElement) { - cache.connections.set(connKey, pathElement); - } else { - return; - } - } - - const fromStatus = statusMap[conn.from]; - const toStatus = statusMap[conn.to]; - const isActive = fromStatus === 'running' || toStatus === 'running'; - - if (conn.connectionType === 'property') { - pathElement.setAttribute('stroke', '#9c27b0'); - pathElement.setAttribute('stroke-width', '2'); - } else if (isActive) { - pathElement.setAttribute('stroke', '#ffa726'); - pathElement.setAttribute('stroke-width', '3'); - } else { - // 获取或缓存节点DOM - let fromElement = cache.nodes.get(conn.from); - if (!fromElement) { - fromElement = document.querySelector(`[data-node-id="${conn.from}"]`) || undefined; - if (fromElement) cache.nodes.set(conn.from, fromElement); - } - - let toElement = cache.nodes.get(conn.to); - if (!toElement) { - toElement = document.querySelector(`[data-node-id="${conn.to}"]`) || undefined; - if (toElement) cache.nodes.set(conn.to, toElement); - } - - const isExecuted = fromElement?.classList.contains('executed') && - toElement?.classList.contains('executed'); - - if (isExecuted) { - pathElement.setAttribute('stroke', '#4caf50'); - pathElement.setAttribute('stroke-width', '2.5'); - } else { - pathElement.setAttribute('stroke', '#0e639c'); - pathElement.setAttribute('stroke-width', '2'); - } - } - }); - }; - - // Tick 循环(基于时间间隔) - const tickLoop = (currentTime: number): void => { - if (executionModeRef.current !== 'running') { - return; - } - - if (!executorRef.current) { - return; - } - - // 根据速度计算 tick 间隔(毫秒) - // 速度 1.0 = 每秒60次tick (16.67ms) - // 速度 0.5 = 每秒30次tick (33.33ms) - // 速度 0.1 = 每秒6次tick (166.67ms) - const baseTickInterval = 16.67; // 基础间隔 (60 fps) - const tickInterval = baseTickInterval / executionSpeedRef.current; - - // 检查是否到了执行下一个tick的时间 - if (lastTickTimeRef.current === 0 || (currentTime - lastTickTimeRef.current) >= tickInterval) { - const deltaTime = 0.016; // 固定的 deltaTime - - // 执行tick但不触发重渲染 - executorRef.current.tick(deltaTime); - - // 更新 tick 计数显示 - setTickCount(executorRef.current.getTickCount()); - - lastTickTimeRef.current = currentTime; - } - - // 继续循环(保持60fps) - animationFrameRef.current = requestAnimationFrame(tickLoop); - }; - - // 速度变化处理 - const handleSpeedChange = (speed: number) => { - setExecutionSpeed(speed); - executionSpeedRef.current = speed; - }; - - const handlePlay = () => { - if (executionModeRef.current === 'running') return; - - // 保存设计时的初始黑板变量值 - const initialVars = JSON.parse(JSON.stringify(blackboardVariables || {})); - initialBlackboardVariablesRef.current = initialVars; - setInitialBlackboardVariables(initialVars); - setIsExecuting(true); - - executionModeRef.current = 'running'; - setExecutionMode('running'); - setTickCount(0); - lastTickTimeRef.current = 0; - - if (!executorRef.current) { - executorRef.current = new BehaviorTreeExecutor(); - } - - executorRef.current.buildTree( - nodes, - ROOT_NODE_ID, - blackboardVariables || {}, - connections, - handleExecutionStatusUpdate, - projectPath - ); - - executorRef.current.start(); - - animationFrameRef.current = requestAnimationFrame(tickLoop); - }; - - const handlePause = () => { - if (executionModeRef.current === 'running') { - executionModeRef.current = 'paused'; - setExecutionMode('paused'); - - if (executorRef.current) { - executorRef.current.pause(); - } - - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - } else if (executionModeRef.current === 'paused') { - executionModeRef.current = 'running'; - setExecutionMode('running'); - lastTickTimeRef.current = 0; - - if (executorRef.current) { - executorRef.current.resume(); - } - - animationFrameRef.current = requestAnimationFrame(tickLoop); - } - }; - - const handleStop = () => { - executionModeRef.current = 'idle'; - setExecutionMode('idle'); - setTickCount(0); - lastTickTimeRef.current = 0; - - // 清除所有状态定时器 - statusTimersRef.current.forEach((timer) => clearTimeout(timer)); - statusTimersRef.current.clear(); - - // 清除DOM缓存 - const cache = domCacheRef.current; - cache.lastNodeStatus.clear(); - - // 使用缓存来移除节点状态类 - cache.nodes.forEach((node) => { - node.classList.remove('running', 'success', 'failure', 'executed'); - }); - - // 使用缓存来重置连线样式 - cache.connections.forEach((path, _connKey) => { - const connectionType = path.getAttribute('data-connection-type'); - if (connectionType === 'property') { - path.setAttribute('stroke', '#9c27b0'); - } else { - path.setAttribute('stroke', '#0e639c'); - } - path.setAttribute('stroke-width', '2'); - }); - - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - - if (executorRef.current) { - executorRef.current.stop(); - - // 停止后,还原到运行前保存的初始黑板变量值 - setBlackboardVariables(initialBlackboardVariablesRef.current); - setIsExecuting(false); - } - }; - - const handleStep = async () => { - setExecutionMode('step'); - }; - - const handleReset = () => { - handleStop(); - - if (executorRef.current) { - executorRef.current.cleanup(); - } - }; - - useEffect(() => { - return () => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - } - if (executorRef.current) { - executorRef.current.destroy(); - } - }; - }, []); - - // 监听黑板变量变化,同步到执行器 - useEffect(() => { - if (!executorRef.current || executionMode === 'idle') { - return; - } - - // 获取执行器中的当前黑板变量 - const executorVars = executorRef.current.getBlackboardVariables(); - - // 检查是否有变化 - Object.entries(blackboardVariables).forEach(([key, value]) => { - if (executorVars[key] !== value) { - executorRef.current?.updateBlackboardVariable(key, value); - } - }); - }, [blackboardVariables, executionMode]); return (
= ({ overflow: 'hidden' }}> {/* 画布 */} -
{ handleNodeMouseMove(e); @@ -1499,459 +373,132 @@ export const BehaviorTreeEditor: React.FC = ({ handleNodeMouseUp(); handleCanvasMouseUp(e); }} - style={{ - width: '100%', - height: '100%', - backgroundImage: ` - linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px) - `, - backgroundSize: `${20 * canvasScale}px ${20 * canvasScale}px`, - backgroundPosition: `${canvasOffset.x}px ${canvasOffset.y}px`, - position: 'relative', - overflow: 'hidden', - cursor: isPanning ? 'grabbing' : (draggingNodeId ? 'grabbing' : (connectingFrom ? 'crosshair' : 'default')) - }} > - {/* 内容容器 - 应用变换 */} -
{ + setSelectedConnection({ from: fromId, to: toId }); + setSelectedNodeIds([]); + }} + /> + + {/* 正在拖拽的连接线预览 */} + - {/* SVG 连接线层 */} - - {/* 已有的连接 */} - {connections.map((conn: Connection, index: number) => { - const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === conn.from); - const toNode = nodes.find((n: BehaviorTreeNode) => n.id === conn.to); - if (!fromNode || !toNode) return null; + {/* 正在拖拽的连接线 */} + {connectingFrom && connectingToPos && (() => { + const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom); + if (!fromNode) return null; - let x1, y1, x2, y2; - let pathD: string; + let x1, y1; + let pathD: string; + const x2 = connectingToPos.x; + const y2 = connectingToPos.y; - // 默认颜色和宽度(会被DOM操作动态更新) - const color = conn.connectionType === 'property' ? '#9c27b0' : '#0e639c'; - const strokeWidth = 2; + // 判断是否是属性连接 + const isPropertyConnection = !!connectingFromProperty; + const fromIsBlackboard = fromNode.data.nodeType === 'blackboard-variable'; + const color = isPropertyConnection ? '#9c27b0' : '#0e639c'; - if (conn.connectionType === 'property') { - // 属性连接:从DOM获取实际引脚位置 - const fromPos = getPortPosition(conn.from); - const toPos = getPortPosition(conn.to, conn.toProperty); + if (isPropertyConnection && fromIsBlackboard) { + // 黑板变量节点的右侧输出引脚 + x1 = fromNode.position.x + 75; + y1 = fromNode.position.y; - if (!fromPos || !toPos) { - // 如果DOM还没渲染,跳过这条连接线 - return null; - } + // 使用水平贝塞尔曲线 + const controlX1 = x1 + (x2 - x1) * 0.5; + const controlX2 = x1 + (x2 - x1) * 0.5; + pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`; + } else { + // 节点连接:从底部输出端口 + x1 = fromNode.position.x; + y1 = fromNode.position.y + 30; - x1 = fromPos.x; - y1 = fromPos.y; - x2 = toPos.x; - y2 = toPos.y; - - // 使用水平贝塞尔曲线 - const controlX1 = x1 + (x2 - x1) * 0.5; - const controlX2 = x1 + (x2 - x1) * 0.5; - pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`; - } else { - // 节点连接:也使用DOM获取端口位置 - const fromPos = getPortPosition(conn.from, undefined, 'output'); - const toPos = getPortPosition(conn.to, undefined, 'input'); - - if (!fromPos || !toPos) { - // 如果DOM还没渲染,跳过这条连接线 - return null; - } - - x1 = fromPos.x; - y1 = fromPos.y; - x2 = toPos.x; - y2 = toPos.y; - - // 使用垂直贝塞尔曲线 - const controlY = y1 + (y2 - y1) * 0.5; - pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`; - } - - const isSelected = selectedConnection?.from === conn.from && selectedConnection?.to === conn.to; - - return ( - { - e.stopPropagation(); - setSelectedConnection({ from: conn.from, to: conn.to }); - setSelectedNodeIds([]); - }} - /> - ); - })} - {/* 正在拖拽的连接线 */} - {connectingFrom && connectingToPos && (() => { - const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom); - if (!fromNode) return null; - - let x1, y1; - let pathD: string; - const x2 = connectingToPos.x; - const y2 = connectingToPos.y; - - // 判断是否是属性连接 - const isPropertyConnection = !!connectingFromProperty; - const fromIsBlackboard = fromNode.data.nodeType === 'blackboard-variable'; - const color = isPropertyConnection ? '#9c27b0' : '#0e639c'; - - if (isPropertyConnection && fromIsBlackboard) { - // 黑板变量节点的右侧输出引脚 - x1 = fromNode.position.x + 75; - y1 = fromNode.position.y; - - // 使用水平贝塞尔曲线 - const controlX1 = x1 + (x2 - x1) * 0.5; - const controlX2 = x1 + (x2 - x1) * 0.5; - pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`; - } else { - // 节点连接:从底部输出端口 - x1 = fromNode.position.x; - y1 = fromNode.position.y + 30; - - const controlY = y1 + (y2 - y1) * 0.5; - pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`; - } - - return ( - - ); - })()} - - - - {/* 框选矩形 */} - {isBoxSelecting && boxSelectStart && boxSelectEnd && (() => { - const minX = Math.min(boxSelectStart.x, boxSelectEnd.x); - const maxX = Math.max(boxSelectStart.x, boxSelectEnd.x); - const minY = Math.min(boxSelectStart.y, boxSelectEnd.y); - const maxY = Math.max(boxSelectStart.y, boxSelectEnd.y); - const width = maxX - minX; - const height = maxY - minY; + const controlY = y1 + (y2 - y1) * 0.5; + pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`; + } return ( -
+ ); })()} + - {/* 节点列表 */} - {nodes.map((node: BehaviorTreeNode) => { - const isRoot = node.id === ROOT_NODE_ID; - const isBlackboardVariable = node.data.nodeType === 'blackboard-variable'; - const isSelected = selectedNodeIds.includes(node.id); - // 如果节点正在拖动,使用临时位置 - const isBeingDragged = dragStartPositions.has(node.id); - const posX = node.position.x + (isBeingDragged ? dragDelta.dx : 0); - const posY = node.position.y + (isBeingDragged ? dragDelta.dy : 0); + {/* 框选矩形 */} + {isBoxSelecting && boxSelectStart && boxSelectEnd && (() => { + const minX = Math.min(boxSelectStart.x, boxSelectEnd.x); + const maxX = Math.max(boxSelectStart.x, boxSelectEnd.x); + const minY = Math.min(boxSelectStart.y, boxSelectEnd.y); + const maxY = Math.max(boxSelectStart.y, boxSelectEnd.y); + const width = maxX - minX; + const height = maxY - minY; - const isUncommitted = uncommittedNodeIds.has(node.id); - const nodeClasses = [ - 'bt-node', - isSelected && 'selected', - isRoot && 'root', - isUncommitted && 'uncommitted' - ].filter(Boolean).join(' '); + return ( +
+ ); + })()} - return ( -
handleNodeClick(e, node)} - onContextMenu={(e) => handleNodeContextMenu(e, node)} - onMouseDown={(e) => handleNodeMouseDown(e, node.id)} - onMouseUp={(e) => handleNodeMouseUpForConnection(e, node.id)} - style={{ - left: posX, - top: posY, - transform: 'translate(-50%, -50%)', - cursor: isRoot ? 'default' : (draggingNodeId === node.id ? 'grabbing' : 'grab'), - transition: draggingNodeId === node.id ? 'none' : 'all 0.2s', - zIndex: isRoot ? 50 : (draggingNodeId === node.id ? 100 : (isSelected ? 10 : 1)) - }} - > - {isBlackboardVariable ? ( - (() => { - const varName = node.data.variableName as string; - const currentValue = blackboardVariables[varName]; - const initialValue = initialBlackboardVariables[varName]; - const isModified = isExecuting && JSON.stringify(currentValue) !== JSON.stringify(initialValue); + {/* 节点列表 */} + {nodes.map((node: BehaviorTreeNode) => { + const isSelected = selectedNodeIds.includes(node.id); + const isBeingDragged = dragStartPositions.has(node.id); - return ( - <> -
- -
- {varName || 'Variable'} -
- {isModified && ( - - 运行时 - - )} -
-
-
- {JSON.stringify(currentValue)} -
-
-
handlePortMouseDown(e, node.id, '__value__')} - onMouseUp={(e) => handlePortMouseUp(e, node.id, '__value__')} - className="bt-node-port bt-node-port-variable-output" - title="Output" - /> - - ); - })() - ) : ( - <> - {/* 标题栏 - 带渐变 */} -
- {isRoot ? ( - - ) : ( - node.template.icon && (() => { - const IconComponent = iconMap[node.template.icon]; - return IconComponent ? ( - - ) : ( - {node.template.icon} - ); - })() - )} -
-
{isRoot ? 'ROOT' : node.template.displayName}
-
- #{node.id} -
-
- {/* 缺失执行器警告 */} - {!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && ( -
e.stopPropagation()} - > - -
- 缺失执行器:找不到节点对应的执行器 "{node.template.className}" -
-
- )} - {/* 未生效节点警告 */} - {isUncommitted && ( -
e.stopPropagation()} - > - -
- 未生效节点:运行时添加的节点,需重新运行才能生效 -
-
- )} - {/* 空节点警告图标 */} - {!isRoot && !isUncommitted && node.template.type === 'composite' && - (node.template.requiresChildren === undefined || node.template.requiresChildren === true) && - !nodes.some((n) => - connections.some((c) => c.from === node.id && c.to === n.id) - ) && ( -
e.stopPropagation()} - > - -
- 空节点:没有子节点,执行时会直接跳过 -
-
- )} -
- - {/* 节点主体 */} -
- {!isRoot && ( -
- {node.template.category} -
- )} - - {/* 属性列表 */} - {node.template.properties.length > 0 && ( -
- {node.template.properties.map((prop: PropertyDefinition, idx: number) => { - const hasConnection = connections.some( - (conn: Connection) => conn.toProperty === prop.name && conn.to === node.id - ); - const propValue = node.data[prop.name]; - - return ( -
-
handlePortMouseDown(e, node.id, prop.name)} - onMouseUp={(e) => handlePortMouseUp(e, node.id, prop.name)} - className={`bt-node-port bt-node-port-property ${hasConnection ? 'connected' : ''}`} - title={prop.description || prop.name} - /> - - {prop.name}: - - {propValue !== undefined && ( - - {String(propValue)} - - )} -
- ); - })} -
- )} -
- - {/* 输入端口(顶部)- Root 节点不显示 */} - {!isRoot && ( -
handlePortMouseDown(e, node.id)} - onMouseUp={(e) => handlePortMouseUp(e, node.id)} - className="bt-node-port bt-node-port-input" - title="Input" - /> - )} - - {/* 输出端口(底部)- 只有组合节点和装饰器节点才显示,但不需要子节点的节点除外 */} - {(node.template.type === 'composite' || node.template.type === 'decorator') && - (node.template.requiresChildren === undefined || node.template.requiresChildren === true) && ( -
handlePortMouseDown(e, node.id)} - onMouseUp={(e) => handlePortMouseUp(e, node.id)} - className="bt-node-port bt-node-port-output" - title="Output" - /> - )} - - )} -
- ); - })} - -
+ return ( + + ); + })} {/* 拖拽提示 - 相对于画布视口 */} {isDragging && ( @@ -1993,439 +540,53 @@ export const BehaviorTreeEditor: React.FC = ({
)} -
+ {/* 运行控制工具栏 */} -
- - - - - - - {/* 分隔符 */} -
- - {/* 编辑按钮 */} - - - - {/* 状态指示器 */} -
- - {executionMode === 'idle' ? 'Idle' : - executionMode === 'running' ? 'Running' : - executionMode === 'paused' ? 'Paused' : 'Step'} -
-
+ {/* 快速创建菜单 */} - {quickCreateMenu.visible && (() => { - const allTemplates = NodeTemplates.getAllTemplates(); - const searchText = quickCreateMenu.searchText.toLowerCase(); - const filteredTemplates = searchText - ? allTemplates.filter((t: NodeTemplate) => { - const className = t.className || ''; - return t.displayName.toLowerCase().includes(searchText) || - t.description.toLowerCase().includes(searchText) || - t.category.toLowerCase().includes(searchText) || - className.toLowerCase().includes(searchText); - }) - : allTemplates; - - return ( - <> - -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > -
- - setQuickCreateMenu({ - ...quickCreateMenu, - searchText: e.target.value, - selectedIndex: 0 - })} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setQuickCreateMenu({ - visible: false, - position: { x: 0, y: 0 }, - searchText: '', - selectedIndex: 0, - mode: 'create', - replaceNodeId: null - }); - clearConnecting(); - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - setQuickCreateMenu({ - ...quickCreateMenu, - selectedIndex: Math.min(quickCreateMenu.selectedIndex + 1, filteredTemplates.length - 1) - }); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setQuickCreateMenu({ - ...quickCreateMenu, - selectedIndex: Math.max(quickCreateMenu.selectedIndex - 1, 0) - }); - } else if (e.key === 'Enter' && filteredTemplates.length > 0) { - e.preventDefault(); - const selectedTemplate = filteredTemplates[quickCreateMenu.selectedIndex]; - if (selectedTemplate) { - handleQuickCreateNode(selectedTemplate); - } - } - }} - style={{ - flex: 1, - background: 'transparent', - border: 'none', - outline: 'none', - color: '#ccc', - fontSize: '14px', - padding: '4px' - }} - /> - -
-
- {filteredTemplates.length === 0 ? ( -
- 未找到匹配的节点 -
- ) : ( - filteredTemplates.map((template: NodeTemplate, index: number) => { - const IconComponent = template.icon ? iconMap[template.icon] : null; - const className = template.className || ''; - const isSelected = index === quickCreateMenu.selectedIndex; - return ( -
handleQuickCreateNode(template)} - onMouseEnter={() => { - setQuickCreateMenu({ - ...quickCreateMenu, - selectedIndex: index - }); - }} - style={{ - padding: '8px 12px', - marginBottom: '4px', - backgroundColor: isSelected ? '#0e639c' : '#1e1e1e', - borderLeft: `3px solid ${template.color || '#666'}`, - borderRadius: '3px', - cursor: 'pointer', - transition: 'all 0.15s', - transform: isSelected ? 'translateX(2px)' : 'translateX(0)' - }} - > -
- {IconComponent && ( - - )} -
-
- {template.displayName} -
- {className && ( -
- {className} -
- )} -
-
-
- {template.description} -
-
- {template.category} -
-
- ); - }) - )} -
-
- - ); - })()} + setQuickCreateMenu({ + ...quickCreateMenu, + searchText: text + })} + onIndexChange={(index) => setQuickCreateMenu({ + ...quickCreateMenu, + selectedIndex: index + })} + onNodeSelect={handleQuickCreateNode} + onClose={() => { + setQuickCreateMenu({ + visible: false, + position: { x: 0, y: 0 }, + searchText: '', + selectedIndex: 0, + mode: 'create', + replaceNodeId: null + }); + clearConnecting(); + }} + /> {/* 状态栏 */}
= ({
{/* 右键菜单 */} - {contextMenu.visible && ( -
e.stopPropagation()} - > -
{ - setQuickCreateMenu({ - visible: true, - position: contextMenu.position, - searchText: '', - selectedIndex: 0, - mode: 'replace', - replaceNodeId: contextMenu.nodeId - }); - setContextMenu({ ...contextMenu, visible: false }); - }} - style={{ - padding: '8px 16px', - cursor: 'pointer', - color: '#cccccc', - fontSize: '13px', - transition: 'background-color 0.15s' - }} - onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'} - onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} - > - 替换节点 -
-
- )} + { + setQuickCreateMenu({ + visible: true, + position: contextMenu.position, + searchText: '', + selectedIndex: 0, + mode: 'replace', + replaceNodeId: contextMenu.nodeId + }); + setContextMenu({ ...contextMenu, visible: false }); + }} + />
); }; diff --git a/packages/editor-app/src/components/BehaviorTreeWindow.tsx b/packages/editor-app/src/components/BehaviorTreeWindow.tsx index c7202f09..03e0bc12 100644 --- a/packages/editor-app/src/components/BehaviorTreeWindow.tsx +++ b/packages/editor-app/src/components/BehaviorTreeWindow.tsx @@ -375,11 +375,7 @@ export const BehaviorTreeWindow: React.FC = ({ const saveToFile = async (filePath: string) => { try { // 使用初始黑板变量(设计时的值)而不是运行时的值 - const varsToSave = isExecuting ? initialBlackboardVariables : blackboardVariables; - const json = exportToJSON( - { name: 'behavior-tree', description: '' }, - varsToSave - ); + const json = exportToJSON({ name: 'behavior-tree', description: '' }); await invoke('write_behavior_tree_file', { filePath, content: json }); logger.info('行为树已保存', filePath); @@ -561,10 +557,8 @@ export const BehaviorTreeWindow: React.FC = ({ const extension = format === 'binary' ? 'bin' : 'json'; const filePath = `${outputPath}/${fileName}.btree.${extension}`; - const varsToSave = isExecuting ? initialBlackboardVariables : blackboardVariables; const data = exportToRuntimeAsset( { name: fileName, description: 'Runtime behavior tree asset' }, - varsToSave, format ); @@ -824,7 +818,7 @@ export const BehaviorTreeWindow: React.FC = ({ // 如果是黑板变量节点,动态生成属性 if (node.data.nodeType === 'blackboard-variable') { - const varName = node.data.variableName || ''; + const varName = (node.data.variableName as string) || ''; const varValue = blackboardVariables[varName]; const varType = typeof varValue === 'number' ? 'number' : typeof varValue === 'boolean' ? 'boolean' : 'string'; @@ -862,7 +856,7 @@ export const BehaviorTreeWindow: React.FC = ({ data }); }} - onNodeCreate={(template, position) => { + onNodeCreate={(_template, _position) => { // Node created successfully }} blackboardVariables={blackboardVariables} diff --git a/packages/editor-app/src/components/ProfilerWindow.tsx b/packages/editor-app/src/components/ProfilerWindow.tsx index 98fa58d7..2d0c898f 100644 --- a/packages/editor-app/src/components/ProfilerWindow.tsx +++ b/packages/editor-app/src/components/ProfilerWindow.tsx @@ -6,20 +6,20 @@ import { SettingsService } from '../services/SettingsService'; import '../styles/ProfilerWindow.css'; interface SystemPerformanceData { - name: string; - executionTime: number; - entityCount: number; - averageTime: number; - minTime: number; - maxTime: number; - percentage: number; - level: number; - children?: SystemPerformanceData[]; - isExpanded?: boolean; + name: string; + executionTime: number; + entityCount: number; + averageTime: number; + minTime: number; + maxTime: number; + percentage: number; + level: number; + children?: SystemPerformanceData[]; + isExpanded?: boolean; } interface ProfilerWindowProps { - onClose: () => void; + onClose: () => void; } type DataSource = 'local' | 'remote'; @@ -96,23 +96,22 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) { if (servicesUpdate) { const servicesStats = statsMap.get('Services.update'); - coreNode.children!.push({ - name: 'Services.update', - executionTime: servicesUpdate.executionTime, - entityCount: 0, - averageTime: servicesStats?.averageTime || 0, - minTime: servicesStats?.minTime || 0, - maxTime: servicesStats?.maxTime || 0, - percentage: coreUpdate.executionTime > 0 - ? (servicesUpdate.executionTime / coreUpdate.executionTime) * 100 - : 0, - level: 1, - isExpanded: false - }); + coreNode.children!.push({ + name: 'Services.update', + executionTime: servicesUpdate.executionTime, + entityCount: 0, + averageTime: servicesStats?.averageTime || 0, + minTime: servicesStats?.minTime || 0, + maxTime: servicesStats?.maxTime || 0, + percentage: coreUpdate.executionTime > 0 + ? (servicesUpdate.executionTime / coreUpdate.executionTime) * 100 + : 0, + level: 1, + isExpanded: false + }); } const sceneSystems: SystemPerformanceData[] = []; - let sceneSystemsTotal = 0; for (const [name, data] of flatSystems.entries()) { if (name !== 'Core.update' && name !== 'Services.update') { @@ -129,7 +128,6 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) { level: 1, isExpanded: false }); - sceneSystemsTotal += data.executionTime; } } } @@ -141,9 +139,9 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) { }); sceneSystems.sort((a, b) => b.executionTime - a.executionTime); - coreNode.children!.push(...sceneSystems); + coreNode.children!.push(...sceneSystems); - return [coreNode]; + return [coreNode]; }; // Subscribe to local performance data @@ -328,7 +326,7 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) { ); } } else if (viewMode === 'table') { - // For table view without search, flatten all + // For table view without search, flatten all const flatList: SystemPerformanceData[] = []; const flatten = (nodes: SystemPerformanceData[]) => { for (const node of nodes) { diff --git a/packages/editor-app/src/domain/constants/RootNode.ts b/packages/editor-app/src/domain/constants/RootNode.ts new file mode 100644 index 00000000..54454312 --- /dev/null +++ b/packages/editor-app/src/domain/constants/RootNode.ts @@ -0,0 +1,25 @@ +import { Node } from '../models/Node'; +import { Position } from '../value-objects/Position'; +import { NodeTemplate } from '@esengine/behavior-tree'; + +export const ROOT_NODE_ID = 'root-node'; + +export const createRootNodeTemplate = (): NodeTemplate => ({ + type: 'root', + displayName: '根节点', + category: '根节点', + icon: 'TreePine', + description: '行为树根节点', + color: '#FFD700', + maxChildren: 1, + defaultConfig: { + nodeType: 'root' + }, + properties: [] +}); + +export const createRootNode = (): Node => { + const template = createRootNodeTemplate(); + const position = new Position(400, 100); + return new Node(ROOT_NODE_ID, template, { nodeType: 'root' }, position, []); +}; diff --git a/packages/editor-app/src/domain/errors/DomainError.ts b/packages/editor-app/src/domain/errors/DomainError.ts new file mode 100644 index 00000000..f408f17b --- /dev/null +++ b/packages/editor-app/src/domain/errors/DomainError.ts @@ -0,0 +1,10 @@ +/** + * 领域错误基类 + */ +export abstract class DomainError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/packages/editor-app/src/domain/errors/NodeNotFoundError.ts b/packages/editor-app/src/domain/errors/NodeNotFoundError.ts new file mode 100644 index 00000000..5d599857 --- /dev/null +++ b/packages/editor-app/src/domain/errors/NodeNotFoundError.ts @@ -0,0 +1,10 @@ +import { DomainError } from './DomainError'; + +/** + * 节点未找到错误 + */ +export class NodeNotFoundError extends DomainError { + constructor(public readonly nodeId: string) { + super(`节点未找到: ${nodeId}`); + } +} diff --git a/packages/editor-app/src/domain/errors/ValidationError.ts b/packages/editor-app/src/domain/errors/ValidationError.ts new file mode 100644 index 00000000..0409463c --- /dev/null +++ b/packages/editor-app/src/domain/errors/ValidationError.ts @@ -0,0 +1,52 @@ +import { DomainError } from './DomainError'; + +/** + * 验证错误 + * 当业务规则验证失败时抛出 + */ +export class ValidationError extends DomainError { + constructor( + message: string, + public readonly field?: string, + public readonly value?: unknown + ) { + super(message); + } + + static rootNodeMaxChildren(): ValidationError { + return new ValidationError( + '根节点只能连接一个子节点', + 'children' + ); + } + + static decoratorNodeMaxChildren(): ValidationError { + return new ValidationError( + '装饰节点只能连接一个子节点', + 'children' + ); + } + + static leafNodeNoChildren(): ValidationError { + return new ValidationError( + '叶子节点不能有子节点', + 'children' + ); + } + + static circularReference(nodeId: string): ValidationError { + return new ValidationError( + `检测到循环引用,节点 ${nodeId} 不能连接到自己或其子节点`, + 'connection', + nodeId + ); + } + + static invalidConnection(from: string, to: string, reason: string): ValidationError { + return new ValidationError( + `无效的连接:${reason}`, + 'connection', + { from, to } + ); + } +} diff --git a/packages/editor-app/src/domain/errors/index.ts b/packages/editor-app/src/domain/errors/index.ts new file mode 100644 index 00000000..ffc8d3ba --- /dev/null +++ b/packages/editor-app/src/domain/errors/index.ts @@ -0,0 +1,3 @@ +export { DomainError } from './DomainError'; +export { ValidationError } from './ValidationError'; +export { NodeNotFoundError } from './NodeNotFoundError'; diff --git a/packages/editor-app/src/domain/index.ts b/packages/editor-app/src/domain/index.ts new file mode 100644 index 00000000..f2424390 --- /dev/null +++ b/packages/editor-app/src/domain/index.ts @@ -0,0 +1,5 @@ +export * from './models'; +export * from './value-objects'; +export * from './interfaces'; +export { DomainError, ValidationError as DomainValidationError, NodeNotFoundError } from './errors'; +export * from './services'; diff --git a/packages/editor-app/src/domain/interfaces/INodeFactory.ts b/packages/editor-app/src/domain/interfaces/INodeFactory.ts new file mode 100644 index 00000000..0a980a85 --- /dev/null +++ b/packages/editor-app/src/domain/interfaces/INodeFactory.ts @@ -0,0 +1,32 @@ +import { NodeTemplate } from '@esengine/behavior-tree'; +import { Node } from '../models/Node'; +import { Position } from '../value-objects'; + +/** + * 节点工厂接口 + * 负责创建不同类型的节点 + */ +export interface INodeFactory { + /** + * 创建节点 + */ + createNode( + template: NodeTemplate, + position: Position, + data?: Record + ): Node; + + /** + * 根据模板类型创建节点 + */ + createNodeByType( + nodeType: string, + position: Position, + data?: Record + ): Node; + + /** + * 克隆节点 + */ + cloneNode(node: Node, newPosition?: Position): Node; +} diff --git a/packages/editor-app/src/domain/interfaces/IRepository.ts b/packages/editor-app/src/domain/interfaces/IRepository.ts new file mode 100644 index 00000000..e7dd294b --- /dev/null +++ b/packages/editor-app/src/domain/interfaces/IRepository.ts @@ -0,0 +1,27 @@ +import { BehaviorTree } from '../models/BehaviorTree'; + +/** + * 仓储接口 + * 负责行为树的持久化 + */ +export interface IBehaviorTreeRepository { + /** + * 保存行为树 + */ + save(tree: BehaviorTree, path: string): Promise; + + /** + * 加载行为树 + */ + load(path: string): Promise; + + /** + * 检查文件是否存在 + */ + exists(path: string): Promise; + + /** + * 删除行为树文件 + */ + delete(path: string): Promise; +} diff --git a/packages/editor-app/src/domain/interfaces/ISerializer.ts b/packages/editor-app/src/domain/interfaces/ISerializer.ts new file mode 100644 index 00000000..b6ca28f8 --- /dev/null +++ b/packages/editor-app/src/domain/interfaces/ISerializer.ts @@ -0,0 +1,30 @@ +import { BehaviorTree } from '../models/BehaviorTree'; + +/** + * 序列化格式 + */ +export type SerializationFormat = 'json' | 'binary'; + +/** + * 序列化接口 + * 负责行为树的序列化和反序列化 + */ +export interface ISerializer { + /** + * 序列化行为树 + */ + serialize(tree: BehaviorTree, format: SerializationFormat): string | Uint8Array; + + /** + * 反序列化行为树 + */ + deserialize(data: string | Uint8Array, format: SerializationFormat): BehaviorTree; + + /** + * 导出为运行时资产格式 + */ + exportToRuntimeAsset( + tree: BehaviorTree, + format: SerializationFormat + ): string | Uint8Array; +} diff --git a/packages/editor-app/src/domain/interfaces/IValidator.ts b/packages/editor-app/src/domain/interfaces/IValidator.ts new file mode 100644 index 00000000..263499b3 --- /dev/null +++ b/packages/editor-app/src/domain/interfaces/IValidator.ts @@ -0,0 +1,46 @@ +import { BehaviorTree } from '../models/BehaviorTree'; +import { Node } from '../models/Node'; +import { Connection } from '../models/Connection'; + +/** + * 验证结果 + */ +export interface ValidationResult { + isValid: boolean; + errors: ValidationError[]; +} + +/** + * 验证错误详情 + */ +export interface ValidationError { + message: string; + nodeId?: string; + field?: string; +} + +/** + * 验证器接口 + * 负责行为树的验证逻辑 + */ +export interface IValidator { + /** + * 验证整个行为树 + */ + validateTree(tree: BehaviorTree): ValidationResult; + + /** + * 验证节点 + */ + validateNode(node: Node): ValidationResult; + + /** + * 验证连接 + */ + validateConnection(connection: Connection, tree: BehaviorTree): ValidationResult; + + /** + * 验证是否会产生循环引用 + */ + validateNoCycles(tree: BehaviorTree): ValidationResult; +} diff --git a/packages/editor-app/src/domain/interfaces/index.ts b/packages/editor-app/src/domain/interfaces/index.ts new file mode 100644 index 00000000..54e32bdc --- /dev/null +++ b/packages/editor-app/src/domain/interfaces/index.ts @@ -0,0 +1,4 @@ +export { type INodeFactory } from './INodeFactory'; +export { type ISerializer, type SerializationFormat } from './ISerializer'; +export { type IBehaviorTreeRepository } from './IRepository'; +export { type IValidator, type ValidationResult, type ValidationError } from './IValidator'; diff --git a/packages/editor-app/src/domain/models/BehaviorTree.ts b/packages/editor-app/src/domain/models/BehaviorTree.ts new file mode 100644 index 00000000..280cb364 --- /dev/null +++ b/packages/editor-app/src/domain/models/BehaviorTree.ts @@ -0,0 +1,353 @@ +import { Node } from './Node'; +import { Connection } from './Connection'; +import { Blackboard } from './Blackboard'; +import { ValidationError, NodeNotFoundError } from '../errors'; + +/** + * 行为树聚合根 + * 管理整个行为树的节点、连接和黑板 + */ +export class BehaviorTree { + private readonly _nodes: Map; + private readonly _connections: Connection[]; + private readonly _blackboard: Blackboard; + private readonly _rootNodeId: string | null; + + constructor( + nodes: Node[] = [], + connections: Connection[] = [], + blackboard: Blackboard = Blackboard.empty(), + rootNodeId: string | null = null + ) { + this._nodes = new Map(nodes.map((node) => [node.id, node])); + this._connections = [...connections]; + this._blackboard = blackboard; + this._rootNodeId = rootNodeId; + + this.validateTree(); + } + + get nodes(): ReadonlyArray { + return Array.from(this._nodes.values()); + } + + get connections(): ReadonlyArray { + return this._connections; + } + + get blackboard(): Blackboard { + return this._blackboard; + } + + get rootNodeId(): string | null { + return this._rootNodeId; + } + + /** + * 获取指定节点 + */ + getNode(nodeId: string): Node { + const node = this._nodes.get(nodeId); + if (!node) { + throw new NodeNotFoundError(nodeId); + } + return node; + } + + /** + * 检查节点是否存在 + */ + hasNode(nodeId: string): boolean { + return this._nodes.has(nodeId); + } + + /** + * 添加节点 + */ + addNode(node: Node): BehaviorTree { + if (this._nodes.has(node.id)) { + throw new ValidationError(`节点 ${node.id} 已存在`); + } + + if (node.isRoot()) { + if (this._rootNodeId) { + throw new ValidationError('行为树只能有一个根节点'); + } + return new BehaviorTree( + [...this.nodes, node], + this._connections, + this._blackboard, + node.id + ); + } + + return new BehaviorTree( + [...this.nodes, node], + this._connections, + this._blackboard, + this._rootNodeId + ); + } + + /** + * 移除节点 + * 会同时移除相关的连接 + */ + removeNode(nodeId: string): BehaviorTree { + if (!this._nodes.has(nodeId)) { + throw new NodeNotFoundError(nodeId); + } + + const node = this.getNode(nodeId); + const newNodes = Array.from(this.nodes.filter((n) => n.id !== nodeId)); + const newConnections = this._connections.filter( + (conn) => conn.from !== nodeId && conn.to !== nodeId + ); + + const newRootNodeId = node.isRoot() ? null : this._rootNodeId; + + return new BehaviorTree( + newNodes, + newConnections, + this._blackboard, + newRootNodeId + ); + } + + /** + * 更新节点 + */ + updateNode(nodeId: string, updater: (node: Node) => Node): BehaviorTree { + const node = this.getNode(nodeId); + const updatedNode = updater(node); + + const newNodes = Array.from(this.nodes.map((n) => n.id === nodeId ? updatedNode : n)); + + return new BehaviorTree( + newNodes, + this._connections, + this._blackboard, + this._rootNodeId + ); + } + + /** + * 添加连接 + * 会验证连接的合法性 + */ + addConnection(connection: Connection): BehaviorTree { + const fromNode = this.getNode(connection.from); + const toNode = this.getNode(connection.to); + + if (this.hasConnection(connection.from, connection.to)) { + throw new ValidationError(`连接已存在:${connection.from} -> ${connection.to}`); + } + + if (this.wouldCreateCycle(connection.from, connection.to)) { + throw ValidationError.circularReference(connection.to); + } + + if (connection.isNodeConnection()) { + if (!fromNode.canAddChild()) { + if (fromNode.isRoot()) { + throw ValidationError.rootNodeMaxChildren(); + } + if (fromNode.nodeType.isDecorator()) { + throw ValidationError.decoratorNodeMaxChildren(); + } + throw new ValidationError(`节点 ${connection.from} 无法添加更多子节点`); + } + + if (toNode.nodeType.getMaxChildren() === 0 && toNode.nodeType.isLeaf()) { + } + + const updatedFromNode = fromNode.addChild(connection.to); + const newNodes = Array.from(this.nodes.map((n) => + n.id === connection.from ? updatedFromNode : n + )); + + return new BehaviorTree( + newNodes, + [...this._connections, connection], + this._blackboard, + this._rootNodeId + ); + } + + return new BehaviorTree( + Array.from(this.nodes), + [...this._connections, connection], + this._blackboard, + this._rootNodeId + ); + } + + /** + * 移除连接 + */ + removeConnection(from: string, to: string, fromProperty?: string, toProperty?: string): BehaviorTree { + const connection = this._connections.find((c) => c.matches(from, to, fromProperty, toProperty)); + + if (!connection) { + throw new ValidationError(`连接不存在:${from} -> ${to}`); + } + + const newConnections = this._connections.filter((c) => !c.matches(from, to, fromProperty, toProperty)); + + if (connection.isNodeConnection()) { + const fromNode = this.getNode(from); + const updatedFromNode = fromNode.removeChild(to); + const newNodes = Array.from(this.nodes.map((n) => + n.id === from ? updatedFromNode : n + )); + + return new BehaviorTree( + newNodes, + newConnections, + this._blackboard, + this._rootNodeId + ); + } + + return new BehaviorTree( + Array.from(this.nodes), + newConnections, + this._blackboard, + this._rootNodeId + ); + } + + /** + * 检查是否存在连接 + */ + hasConnection(from: string, to: string): boolean { + return this._connections.some((c) => c.from === from && c.to === to); + } + + /** + * 检查是否会创建循环引用 + */ + private wouldCreateCycle(from: string, to: string): boolean { + const visited = new Set(); + const queue: string[] = [to]; + + while (queue.length > 0) { + const current = queue.shift()!; + + if (current === from) { + return true; + } + + if (visited.has(current)) { + continue; + } + + visited.add(current); + + const childConnections = this._connections.filter((c) => c.from === current && c.isNodeConnection()); + childConnections.forEach((conn) => queue.push(conn.to)); + } + + return false; + } + + /** + * 更新黑板 + */ + updateBlackboard(updater: (blackboard: Blackboard) => Blackboard): BehaviorTree { + return new BehaviorTree( + Array.from(this.nodes), + this._connections, + updater(this._blackboard), + this._rootNodeId + ); + } + + /** + * 获取节点的子节点 + */ + getChildren(nodeId: string): Node[] { + const node = this.getNode(nodeId); + return node.children.map((childId) => this.getNode(childId)); + } + + /** + * 获取节点的父节点 + */ + getParent(nodeId: string): Node | null { + const parentConnection = this._connections.find( + (c) => c.to === nodeId && c.isNodeConnection() + ); + + if (!parentConnection) { + return null; + } + + return this.getNode(parentConnection.from); + } + + /** + * 验证树的完整性 + */ + private validateTree(): void { + const rootNodes = this.nodes.filter((n) => n.isRoot()); + + if (rootNodes.length > 1) { + throw new ValidationError('行为树只能有一个根节点'); + } + + if (rootNodes.length === 1 && rootNodes[0] && this._rootNodeId !== rootNodes[0].id) { + throw new ValidationError('根节点ID不匹配'); + } + + this._connections.forEach((conn) => { + if (!this._nodes.has(conn.from)) { + throw new NodeNotFoundError(conn.from); + } + if (!this._nodes.has(conn.to)) { + throw new NodeNotFoundError(conn.to); + } + }); + } + + /** + * 转换为普通对象 + */ + toObject(): { + nodes: ReturnType[]; + connections: ReturnType[]; + blackboard: Record; + rootNodeId: string | null; + } { + return { + nodes: this.nodes.map((n) => n.toObject()), + connections: this._connections.map((c) => c.toObject()), + blackboard: this._blackboard.toObject(), + rootNodeId: this._rootNodeId + }; + } + + /** + * 从普通对象创建行为树 + */ + static fromObject(obj: { + nodes: Parameters[0][]; + connections: Parameters[0][]; + blackboard: Record; + rootNodeId: string | null; + }): BehaviorTree { + return new BehaviorTree( + obj.nodes.map((n) => Node.fromObject(n)), + obj.connections.map((c) => Connection.fromObject(c)), + Blackboard.fromObject(obj.blackboard), + obj.rootNodeId + ); + } + + /** + * 创建空行为树 + */ + static empty(): BehaviorTree { + return new BehaviorTree(); + } +} diff --git a/packages/editor-app/src/domain/models/Blackboard.ts b/packages/editor-app/src/domain/models/Blackboard.ts new file mode 100644 index 00000000..8f36bf9c --- /dev/null +++ b/packages/editor-app/src/domain/models/Blackboard.ts @@ -0,0 +1,122 @@ +/** + * 黑板值类型 + */ +export type BlackboardValue = string | number | boolean | null | undefined | Record | unknown[]; + +/** + * 黑板领域实体 + * 管理行为树的全局变量 + */ +export class Blackboard { + private _variables: Map; + + constructor(variables: Record = {}) { + this._variables = new Map(Object.entries(variables)); + } + + /** + * 获取变量值 + */ + get(key: string): BlackboardValue { + return this._variables.get(key); + } + + /** + * 设置变量值 + */ + set(key: string, value: BlackboardValue): Blackboard { + const newVariables = new Map(this._variables); + newVariables.set(key, value); + return new Blackboard(Object.fromEntries(newVariables)); + } + + /** + * 设置变量值(别名方法) + */ + setValue(key: string, value: BlackboardValue): void { + this._variables.set(key, value); + } + + /** + * 删除变量 + */ + delete(key: string): Blackboard { + const newVariables = new Map(this._variables); + newVariables.delete(key); + return new Blackboard(Object.fromEntries(newVariables)); + } + + /** + * 检查变量是否存在 + */ + has(key: string): boolean { + return this._variables.has(key); + } + + /** + * 获取所有变量名 + */ + keys(): string[] { + return Array.from(this._variables.keys()); + } + + /** + * 获取所有变量 + */ + getAll(): Record { + return Object.fromEntries(this._variables); + } + + /** + * 批量设置变量 + */ + setAll(variables: Record): Blackboard { + const newVariables = new Map(this._variables); + Object.entries(variables).forEach(([key, value]) => { + newVariables.set(key, value); + }); + return new Blackboard(Object.fromEntries(newVariables)); + } + + /** + * 清空所有变量 + */ + clear(): Blackboard { + return new Blackboard(); + } + + /** + * 获取变量数量 + */ + size(): number { + return this._variables.size; + } + + /** + * 克隆黑板 + */ + clone(): Blackboard { + return new Blackboard(this.getAll()); + } + + /** + * 转换为普通对象 + */ + toObject(): Record { + return this.getAll(); + } + + /** + * 从普通对象创建黑板 + */ + static fromObject(obj: Record): Blackboard { + return new Blackboard(obj as Record); + } + + /** + * 创建空黑板 + */ + static empty(): Blackboard { + return new Blackboard(); + } +} diff --git a/packages/editor-app/src/domain/models/Connection.ts b/packages/editor-app/src/domain/models/Connection.ts new file mode 100644 index 00000000..f3995c4b --- /dev/null +++ b/packages/editor-app/src/domain/models/Connection.ts @@ -0,0 +1,140 @@ +import { ValidationError } from '../errors'; + +/** + * 连接类型 + */ +export type ConnectionType = 'node' | 'property'; + +/** + * 连接领域实体 + * 表示两个节点之间的连接关系 + */ +export class Connection { + private readonly _from: string; + private readonly _to: string; + private readonly _fromProperty?: string; + private readonly _toProperty?: string; + private readonly _connectionType: ConnectionType; + + constructor( + from: string, + to: string, + connectionType: ConnectionType = 'node', + fromProperty?: string, + toProperty?: string + ) { + if (from === to) { + throw ValidationError.circularReference(from); + } + + if (connectionType === 'property' && (!fromProperty || !toProperty)) { + throw new ValidationError('属性连接必须指定源属性和目标属性'); + } + + this._from = from; + this._to = to; + this._connectionType = connectionType; + this._fromProperty = fromProperty; + this._toProperty = toProperty; + } + + get from(): string { + return this._from; + } + + get to(): string { + return this._to; + } + + get fromProperty(): string | undefined { + return this._fromProperty; + } + + get toProperty(): string | undefined { + return this._toProperty; + } + + get connectionType(): ConnectionType { + return this._connectionType; + } + + /** + * 检查是否为节点连接 + */ + isNodeConnection(): boolean { + return this._connectionType === 'node'; + } + + /** + * 检查是否为属性连接 + */ + isPropertyConnection(): boolean { + return this._connectionType === 'property'; + } + + /** + * 检查连接是否匹配指定的条件 + */ + matches(from: string, to: string, fromProperty?: string, toProperty?: string): boolean { + if (this._from !== from || this._to !== to) { + return false; + } + + if (this._connectionType === 'property') { + return this._fromProperty === fromProperty && this._toProperty === toProperty; + } + + return true; + } + + /** + * 相等性比较 + */ + equals(other: Connection): boolean { + return ( + this._from === other._from && + this._to === other._to && + this._connectionType === other._connectionType && + this._fromProperty === other._fromProperty && + this._toProperty === other._toProperty + ); + } + + /** + * 转换为普通对象 + */ + toObject(): { + from: string; + to: string; + fromProperty?: string; + toProperty?: string; + connectionType: ConnectionType; + } { + return { + from: this._from, + to: this._to, + connectionType: this._connectionType, + ...(this._fromProperty && { fromProperty: this._fromProperty }), + ...(this._toProperty && { toProperty: this._toProperty }) + }; + } + + /** + * 从普通对象创建连接 + */ + static fromObject(obj: { + from: string; + to: string; + fromProperty?: string; + toProperty?: string; + connectionType: ConnectionType; + }): Connection { + return new Connection( + obj.from, + obj.to, + obj.connectionType, + obj.fromProperty, + obj.toProperty + ); + } +} diff --git a/packages/editor-app/src/domain/models/Node.ts b/packages/editor-app/src/domain/models/Node.ts new file mode 100644 index 00000000..3d3278fd --- /dev/null +++ b/packages/editor-app/src/domain/models/Node.ts @@ -0,0 +1,190 @@ +import { NodeTemplate } from '@esengine/behavior-tree'; +import { Position, NodeType } from '../value-objects'; +import { ValidationError } from '../errors'; + +/** + * 行为树节点领域实体 + * 封装节点的业务逻辑和验证规则 + */ +export class Node { + private readonly _id: string; + private readonly _template: NodeTemplate; + private _data: Record; + private _position: Position; + private _children: string[]; + private readonly _nodeType: NodeType; + + constructor( + id: string, + template: NodeTemplate, + data: Record, + position: Position, + children: string[] = [] + ) { + this._id = id; + this._template = template; + this._data = { ...data }; + this._position = position; + this._children = [...children]; + this._nodeType = NodeType.fromString(template.type); + } + + get id(): string { + return this._id; + } + + get template(): NodeTemplate { + return this._template; + } + + get data(): Record { + return { ...this._data }; + } + + get position(): Position { + return this._position; + } + + get children(): ReadonlyArray { + return this._children; + } + + get nodeType(): NodeType { + return this._nodeType; + } + + /** + * 更新节点位置 + */ + moveToPosition(newPosition: Position): Node { + return new Node( + this._id, + this._template, + this._data, + newPosition, + this._children + ); + } + + /** + * 更新节点数据 + */ + updateData(data: Record): Node { + return new Node( + this._id, + this._template, + { ...this._data, ...data }, + this._position, + this._children + ); + } + + /** + * 添加子节点 + * @throws ValidationError 如果违反业务规则 + */ + addChild(childId: string): Node { + // 使用模板定义的约束,undefined 表示无限制 + const maxChildren = (this._template.maxChildren ?? Infinity) as number; + + if (maxChildren === 0) { + throw ValidationError.leafNodeNoChildren(); + } + + if (this._children.length >= maxChildren) { + if (this._nodeType.isRoot()) { + throw ValidationError.rootNodeMaxChildren(); + } + if (this._nodeType.isDecorator()) { + throw ValidationError.decoratorNodeMaxChildren(); + } + throw new ValidationError(`节点 ${this._id} 已达到最大子节点数 ${maxChildren}`); + } + + if (this._children.includes(childId)) { + throw new ValidationError(`子节点 ${childId} 已存在`); + } + + return new Node( + this._id, + this._template, + this._data, + this._position, + [...this._children, childId] + ); + } + + /** + * 移除子节点 + */ + removeChild(childId: string): Node { + return new Node( + this._id, + this._template, + this._data, + this._position, + this._children.filter((id) => id !== childId) + ); + } + + /** + * 检查是否可以添加子节点 + */ + canAddChild(): boolean { + // 使用模板定义的最大子节点数,undefined 表示无限制 + const maxChildren = (this._template.maxChildren ?? Infinity) as number; + return this._children.length < maxChildren; + } + + /** + * 检查是否有子节点 + */ + hasChildren(): boolean { + return this._children.length > 0; + } + + /** + * 检查是否为根节点 + */ + isRoot(): boolean { + return this._nodeType.isRoot(); + } + + /** + * 转换为普通对象(用于序列化) + */ + toObject(): { + id: string; + template: NodeTemplate; + data: Record; + position: { x: number; y: number }; + children: string[]; + } { + return { + id: this._id, + template: this._template, + data: this._data, + position: this._position.toObject(), + children: [...this._children] + }; + } + + /** + * 从普通对象创建节点 + */ + static fromObject(obj: { + id: string; + template: NodeTemplate; + data: Record; + position: { x: number; y: number }; + children: string[]; + }): Node { + return new Node( + obj.id, + obj.template, + obj.data, + Position.fromObject(obj.position), + obj.children + ); + } +} diff --git a/packages/editor-app/src/domain/models/index.ts b/packages/editor-app/src/domain/models/index.ts new file mode 100644 index 00000000..590bc80b --- /dev/null +++ b/packages/editor-app/src/domain/models/index.ts @@ -0,0 +1,4 @@ +export { Node } from './Node'; +export { Connection, type ConnectionType } from './Connection'; +export { Blackboard, type BlackboardValue } from './Blackboard'; +export { BehaviorTree } from './BehaviorTree'; diff --git a/packages/editor-app/src/domain/services/TreeValidator.ts b/packages/editor-app/src/domain/services/TreeValidator.ts new file mode 100644 index 00000000..e101c1a9 --- /dev/null +++ b/packages/editor-app/src/domain/services/TreeValidator.ts @@ -0,0 +1,198 @@ +import { BehaviorTree } from '../models/BehaviorTree'; +import { Node } from '../models/Node'; +import { Connection } from '../models/Connection'; +import { IValidator, ValidationResult, ValidationError as IValidationError } from '../interfaces/IValidator'; + +/** + * 行为树验证服务 + * 实现所有业务验证规则 + */ +export class TreeValidator implements IValidator { + /** + * 验证整个行为树 + */ + validateTree(tree: BehaviorTree): ValidationResult { + const errors: IValidationError[] = []; + + if (!tree.rootNodeId) { + errors.push({ + message: '行为树必须有一个根节点' + }); + } + + const rootNodes = tree.nodes.filter((n) => n.isRoot()); + if (rootNodes.length > 1) { + errors.push({ + message: '行为树只能有一个根节点', + nodeId: rootNodes.map((n) => n.id).join(', ') + }); + } + + tree.nodes.forEach((node) => { + const nodeValidation = this.validateNode(node); + errors.push(...nodeValidation.errors); + }); + + tree.connections.forEach((connection) => { + const connValidation = this.validateConnection(connection, tree); + errors.push(...connValidation.errors); + }); + + const cycleValidation = this.validateNoCycles(tree); + errors.push(...cycleValidation.errors); + + return { + isValid: errors.length === 0, + errors + }; + } + + /** + * 验证节点 + */ + validateNode(node: Node): ValidationResult { + const errors: IValidationError[] = []; + + // 使用模板定义的约束,undefined 表示无限制 + const maxChildren = (node.template.maxChildren ?? Infinity) as number; + const actualChildren = node.children.length; + + if (actualChildren > maxChildren) { + if (node.isRoot()) { + errors.push({ + message: '根节点只能连接一个子节点', + nodeId: node.id, + field: 'children' + }); + } else if (node.nodeType.isDecorator()) { + errors.push({ + message: '装饰节点只能连接一个子节点', + nodeId: node.id, + field: 'children' + }); + } else if (node.nodeType.isLeaf()) { + errors.push({ + message: '叶子节点不能有子节点', + nodeId: node.id, + field: 'children' + }); + } else { + errors.push({ + message: `节点子节点数量 (${actualChildren}) 超过最大限制 (${maxChildren})`, + nodeId: node.id, + field: 'children' + }); + } + } + + return { + isValid: errors.length === 0, + errors + }; + } + + /** + * 验证连接 + */ + validateConnection(connection: Connection, tree: BehaviorTree): ValidationResult { + const errors: IValidationError[] = []; + + if (!tree.hasNode(connection.from)) { + errors.push({ + message: `源节点不存在: ${connection.from}`, + nodeId: connection.from + }); + } + + if (!tree.hasNode(connection.to)) { + errors.push({ + message: `目标节点不存在: ${connection.to}`, + nodeId: connection.to + }); + } + + if (connection.from === connection.to) { + errors.push({ + message: '节点不能连接到自己', + nodeId: connection.from + }); + } + + if (tree.hasNode(connection.from) && tree.hasNode(connection.to)) { + const fromNode = tree.getNode(connection.from); + const toNode = tree.getNode(connection.to); + + if (connection.isNodeConnection()) { + if (!fromNode.canAddChild()) { + errors.push({ + message: `节点 ${connection.from} 无法添加更多子节点`, + nodeId: connection.from + }); + } + + if (toNode.nodeType.isLeaf() && toNode.hasChildren()) { + errors.push({ + message: `叶子节点 ${connection.to} 不能有子节点`, + nodeId: connection.to + }); + } + } + } + + return { + isValid: errors.length === 0, + errors + }; + } + + /** + * 验证是否存在循环引用 + */ + validateNoCycles(tree: BehaviorTree): ValidationResult { + const errors: IValidationError[] = []; + const visited = new Set(); + const recursionStack = new Set(); + + const dfs = (nodeId: string): boolean => { + if (recursionStack.has(nodeId)) { + errors.push({ + message: `检测到循环引用: 节点 ${nodeId}`, + nodeId + }); + return true; + } + + if (visited.has(nodeId)) { + return false; + } + + visited.add(nodeId); + recursionStack.add(nodeId); + + const node = tree.getNode(nodeId); + for (const childId of node.children) { + if (dfs(childId)) { + return true; + } + } + + recursionStack.delete(nodeId); + return false; + }; + + if (tree.rootNodeId) { + dfs(tree.rootNodeId); + } + + tree.nodes.forEach((node) => { + if (!visited.has(node.id) && !node.isRoot()) { + dfs(node.id); + } + }); + + return { + isValid: errors.length === 0, + errors + }; + } +} diff --git a/packages/editor-app/src/domain/services/index.ts b/packages/editor-app/src/domain/services/index.ts new file mode 100644 index 00000000..449d6848 --- /dev/null +++ b/packages/editor-app/src/domain/services/index.ts @@ -0,0 +1 @@ +export { TreeValidator } from './TreeValidator'; diff --git a/packages/editor-app/src/domain/value-objects/NodeType.ts b/packages/editor-app/src/domain/value-objects/NodeType.ts new file mode 100644 index 00000000..0f7d2e77 --- /dev/null +++ b/packages/editor-app/src/domain/value-objects/NodeType.ts @@ -0,0 +1,107 @@ +/** + * 节点类型值对象 + * 封装节点类型的业务逻辑 + */ +export class NodeType { + private readonly _value: string; + + private constructor(value: string) { + this._value = value; + } + + get value(): string { + return this._value; + } + + /** + * 是否为根节点 + */ + isRoot(): boolean { + return this._value === 'root'; + } + + /** + * 是否为组合节点(可以有多个子节点) + */ + isComposite(): boolean { + return this._value === 'composite' || + ['sequence', 'selector', 'parallel'].includes(this._value); + } + + /** + * 是否为装饰节点(只能有一个子节点) + */ + isDecorator(): boolean { + return this._value === 'decorator' || + ['repeater', 'inverter', 'succeeder', 'failer', 'until-fail', 'until-success'].includes(this._value); + } + + /** + * 是否为叶子节点(不能有子节点) + */ + isLeaf(): boolean { + return this._value === 'action' || this._value === 'condition' || + this._value.includes('action-') || this._value.includes('condition-'); + } + + /** + * 获取允许的最大子节点数 + * @returns 0 表示叶子节点,1 表示装饰节点,Infinity 表示组合节点 + */ + getMaxChildren(): number { + if (this.isLeaf()) { + return 0; + } + if (this.isRoot() || this.isDecorator()) { + return 1; + } + if (this.isComposite()) { + return Infinity; + } + return 0; + } + + /** + * 值对象相等性比较 + */ + equals(other: NodeType): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value; + } + + /** + * 预定义的节点类型 + */ + static readonly ROOT = new NodeType('root'); + static readonly SEQUENCE = new NodeType('sequence'); + static readonly SELECTOR = new NodeType('selector'); + static readonly PARALLEL = new NodeType('parallel'); + static readonly REPEATER = new NodeType('repeater'); + static readonly INVERTER = new NodeType('inverter'); + static readonly SUCCEEDER = new NodeType('succeeder'); + static readonly FAILER = new NodeType('failer'); + static readonly UNTIL_FAIL = new NodeType('until-fail'); + static readonly UNTIL_SUCCESS = new NodeType('until-success'); + + /** + * 从字符串创建节点类型 + */ + static fromString(value: string): NodeType { + switch (value) { + case 'root': return NodeType.ROOT; + case 'sequence': return NodeType.SEQUENCE; + case 'selector': return NodeType.SELECTOR; + case 'parallel': return NodeType.PARALLEL; + case 'repeater': return NodeType.REPEATER; + case 'inverter': return NodeType.INVERTER; + case 'succeeder': return NodeType.SUCCEEDER; + case 'failer': return NodeType.FAILER; + case 'until-fail': return NodeType.UNTIL_FAIL; + case 'until-success': return NodeType.UNTIL_SUCCESS; + default: return new NodeType(value); + } + } +} diff --git a/packages/editor-app/src/domain/value-objects/Position.ts b/packages/editor-app/src/domain/value-objects/Position.ts new file mode 100644 index 00000000..83728a43 --- /dev/null +++ b/packages/editor-app/src/domain/value-objects/Position.ts @@ -0,0 +1,72 @@ +/** + * 位置值对象 + * 表示二维空间中的坐标点 + */ +export class Position { + private readonly _x: number; + private readonly _y: number; + + constructor(x: number, y: number) { + this._x = x; + this._y = y; + } + + get x(): number { + return this._x; + } + + get y(): number { + return this._y; + } + + /** + * 创建新的位置,加上偏移量 + */ + add(offset: Position): Position { + return new Position(this._x + offset._x, this._y + offset._y); + } + + /** + * 创建新的位置,减去偏移量 + */ + subtract(other: Position): Position { + return new Position(this._x - other._x, this._y - other._y); + } + + /** + * 计算到另一个位置的距离 + */ + distanceTo(other: Position): number { + const dx = this._x - other._x; + const dy = this._y - other._y; + return Math.sqrt(dx * dx + dy * dy); + } + + /** + * 值对象相等性比较 + */ + equals(other: Position): boolean { + return this._x === other._x && this._y === other._y; + } + + /** + * 转换为普通对象 + */ + toObject(): { x: number; y: number } { + return { x: this._x, y: this._y }; + } + + /** + * 从普通对象创建 + */ + static fromObject(obj: { x: number; y: number }): Position { + return new Position(obj.x, obj.y); + } + + /** + * 创建零位置 + */ + static zero(): Position { + return new Position(0, 0); + } +} diff --git a/packages/editor-app/src/domain/value-objects/Size.ts b/packages/editor-app/src/domain/value-objects/Size.ts new file mode 100644 index 00000000..ff7cc916 --- /dev/null +++ b/packages/editor-app/src/domain/value-objects/Size.ts @@ -0,0 +1,59 @@ +/** + * 尺寸值对象 + * 表示宽度和高度 + */ +export class Size { + private readonly _width: number; + private readonly _height: number; + + constructor(width: number, height: number) { + if (width < 0 || height < 0) { + throw new Error('Size dimensions must be non-negative'); + } + this._width = width; + this._height = height; + } + + get width(): number { + return this._width; + } + + get height(): number { + return this._height; + } + + /** + * 获取面积 + */ + get area(): number { + return this._width * this._height; + } + + /** + * 缩放尺寸 + */ + scale(factor: number): Size { + return new Size(this._width * factor, this._height * factor); + } + + /** + * 值对象相等性比较 + */ + equals(other: Size): boolean { + return this._width === other._width && this._height === other._height; + } + + /** + * 转换为普通对象 + */ + toObject(): { width: number; height: number } { + return { width: this._width, height: this._height }; + } + + /** + * 从普通对象创建 + */ + static fromObject(obj: { width: number; height: number }): Size { + return new Size(obj.width, obj.height); + } +} diff --git a/packages/editor-app/src/domain/value-objects/index.ts b/packages/editor-app/src/domain/value-objects/index.ts new file mode 100644 index 00000000..3f9890e7 --- /dev/null +++ b/packages/editor-app/src/domain/value-objects/index.ts @@ -0,0 +1,3 @@ +export { Position } from './Position'; +export { Size } from './Size'; +export { NodeType } from './NodeType'; diff --git a/packages/editor-app/src/infrastructure/events/EditorEventBus.ts b/packages/editor-app/src/infrastructure/events/EditorEventBus.ts new file mode 100644 index 00000000..c53469da --- /dev/null +++ b/packages/editor-app/src/infrastructure/events/EditorEventBus.ts @@ -0,0 +1,137 @@ +type EventHandler = (data: T) => void; + +interface Subscription { + unsubscribe: () => void; +} + +export enum EditorEvent { + NODE_CREATED = 'node:created', + NODE_DELETED = 'node:deleted', + NODE_UPDATED = 'node:updated', + NODE_MOVED = 'node:moved', + NODE_SELECTED = 'node:selected', + + CONNECTION_ADDED = 'connection:added', + CONNECTION_REMOVED = 'connection:removed', + + EXECUTION_STARTED = 'execution:started', + EXECUTION_PAUSED = 'execution:paused', + EXECUTION_RESUMED = 'execution:resumed', + EXECUTION_STOPPED = 'execution:stopped', + EXECUTION_TICK = 'execution:tick', + EXECUTION_NODE_STATUS_CHANGED = 'execution:node_status_changed', + + TREE_SAVED = 'tree:saved', + TREE_LOADED = 'tree:loaded', + TREE_VALIDATED = 'tree:validated', + + BLACKBOARD_VARIABLE_UPDATED = 'blackboard:variable_updated', + BLACKBOARD_RESTORED = 'blackboard:restored', + + CANVAS_ZOOM_CHANGED = 'canvas:zoom_changed', + CANVAS_PAN_CHANGED = 'canvas:pan_changed', + CANVAS_RESET = 'canvas:reset', + + COMMAND_EXECUTED = 'command:executed', + COMMAND_UNDONE = 'command:undone', + COMMAND_REDONE = 'command:redone' +} + +export class EditorEventBus { + private listeners: Map> = new Map(); + private eventHistory: Array<{ event: string; data: any; timestamp: number }> = []; + private maxHistorySize: number = 100; + + on(event: EditorEvent | string, handler: EventHandler): Subscription { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + + this.listeners.get(event)!.add(handler); + + return { + unsubscribe: () => this.off(event, handler) + }; + } + + once(event: EditorEvent | string, handler: EventHandler): Subscription { + const wrappedHandler = (data: T) => { + handler(data); + this.off(event, wrappedHandler); + }; + + return this.on(event, wrappedHandler); + } + + off(event: EditorEvent | string, handler: EventHandler): void { + const handlers = this.listeners.get(event); + if (handlers) { + handlers.delete(handler); + if (handlers.size === 0) { + this.listeners.delete(event); + } + } + } + + emit(event: EditorEvent | string, data?: T): void { + if (this.eventHistory.length >= this.maxHistorySize) { + this.eventHistory.shift(); + } + this.eventHistory.push({ + event, + data, + timestamp: Date.now() + }); + + const handlers = this.listeners.get(event); + if (handlers) { + handlers.forEach((handler) => { + try { + handler(data); + } catch (error) { + console.error(`Error in event handler for ${event}:`, error); + } + }); + } + } + + clear(event?: EditorEvent | string): void { + if (event) { + this.listeners.delete(event); + } else { + this.listeners.clear(); + } + } + + getListenerCount(event: EditorEvent | string): number { + return this.listeners.get(event)?.size || 0; + } + + getAllEvents(): string[] { + return Array.from(this.listeners.keys()); + } + + getEventHistory(count?: number): Array<{ event: string; data: any; timestamp: number }> { + if (count) { + return this.eventHistory.slice(-count); + } + return [...this.eventHistory]; + } + + clearHistory(): void { + this.eventHistory = []; + } +} + +let globalEventBus: EditorEventBus | null = null; + +export function getGlobalEventBus(): EditorEventBus { + if (!globalEventBus) { + globalEventBus = new EditorEventBus(); + } + return globalEventBus; +} + +export function resetGlobalEventBus(): void { + globalEventBus = null; +} diff --git a/packages/editor-app/src/infrastructure/factories/NodeFactory.ts b/packages/editor-app/src/infrastructure/factories/NodeFactory.ts new file mode 100644 index 00000000..86085226 --- /dev/null +++ b/packages/editor-app/src/infrastructure/factories/NodeFactory.ts @@ -0,0 +1,79 @@ +import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree'; +import { Node } from '../../domain/models/Node'; +import { Position } from '../../domain/value-objects/Position'; +import { INodeFactory } from '../../domain/interfaces/INodeFactory'; + +/** + * 生成唯一ID + */ +function generateUniqueId(): string { + return `node-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * 节点工厂实现 + */ +export class NodeFactory implements INodeFactory { + /** + * 创建节点 + */ + createNode( + template: NodeTemplate, + position: Position, + data?: Record + ): Node { + const nodeId = generateUniqueId(); + const nodeData = { + ...template.defaultConfig, + ...data + }; + + return new Node(nodeId, template, nodeData, position, []); + } + + /** + * 根据模板类型创建节点 + */ + createNodeByType( + nodeType: string, + position: Position, + data?: Record + ): Node { + const template = this.getTemplateByType(nodeType); + if (!template) { + throw new Error(`未找到节点模板: ${nodeType}`); + } + + return this.createNode(template, position, data); + } + + /** + * 克隆节点 + */ + cloneNode(node: Node, newPosition?: Position): Node { + const position = newPosition || node.position; + const clonedId = generateUniqueId(); + + return new Node( + clonedId, + node.template, + node.data, + position, + [] + ); + } + + /** + * 根据类型获取模板 + */ + private getTemplateByType(nodeType: string): NodeTemplate | null { + const allTemplates = NodeTemplates.getAllTemplates(); + + const template = allTemplates.find((t: NodeTemplate) => { + const defaultNodeType = t.defaultConfig.nodeType; + return defaultNodeType === nodeType; + }); + + return template || null; + } +} diff --git a/packages/editor-app/src/infrastructure/factories/index.ts b/packages/editor-app/src/infrastructure/factories/index.ts new file mode 100644 index 00000000..2b0af3d8 --- /dev/null +++ b/packages/editor-app/src/infrastructure/factories/index.ts @@ -0,0 +1 @@ +export { NodeFactory } from './NodeFactory'; diff --git a/packages/editor-app/src/infrastructure/index.ts b/packages/editor-app/src/infrastructure/index.ts new file mode 100644 index 00000000..dda1607b --- /dev/null +++ b/packages/editor-app/src/infrastructure/index.ts @@ -0,0 +1,2 @@ +export * from './factories'; +export * from './serialization'; diff --git a/packages/editor-app/src/infrastructure/serialization/BehaviorTreeSerializer.ts b/packages/editor-app/src/infrastructure/serialization/BehaviorTreeSerializer.ts new file mode 100644 index 00000000..51f3d989 --- /dev/null +++ b/packages/editor-app/src/infrastructure/serialization/BehaviorTreeSerializer.ts @@ -0,0 +1,127 @@ +import { BehaviorTree } from '../../domain/models/BehaviorTree'; +import { ISerializer, SerializationFormat } from '../../domain/interfaces/ISerializer'; +import { BehaviorTreeAssetSerializer, EditorFormatConverter } from '@esengine/behavior-tree'; + +/** + * 序列化选项 + */ +export interface SerializationOptions { + /** + * 资产版本号 + */ + version?: string; + + /** + * 资产名称 + */ + name?: string; + + /** + * 资产描述 + */ + description?: string; + + /** + * 创建时间 + */ + createdAt?: string; + + /** + * 修改时间 + */ + modifiedAt?: string; +} + +/** + * 行为树序列化器实现 + */ +export class BehaviorTreeSerializer implements ISerializer { + private readonly defaultOptions: Required = { + version: '1.0.0', + name: 'Untitled Behavior Tree', + description: '', + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString() + }; + + constructor(private readonly options: SerializationOptions = {}) { + this.defaultOptions = { ...this.defaultOptions, ...options }; + } + /** + * 序列化行为树 + */ + serialize(tree: BehaviorTree, format: SerializationFormat): string | Uint8Array { + const treeObject = tree.toObject(); + + if (format === 'json') { + return JSON.stringify(treeObject, null, 2); + } + + throw new Error(`不支持的序列化格式: ${format}`); + } + + /** + * 反序列化行为树 + */ + deserialize(data: string | Uint8Array, format: SerializationFormat): BehaviorTree { + if (format === 'json') { + if (typeof data !== 'string') { + throw new Error('JSON 格式需要字符串数据'); + } + + const obj = JSON.parse(data); + return BehaviorTree.fromObject(obj); + } + + throw new Error(`不支持的反序列化格式: ${format}`); + } + + /** + * 导出为运行时资产格式 + * @param tree 行为树 + * @param format 导出格式 + * @param options 可选的序列化选项(覆盖默认值) + */ + exportToRuntimeAsset( + tree: BehaviorTree, + format: SerializationFormat, + options?: SerializationOptions + ): string | Uint8Array { + const nodes = tree.nodes.map((node) => ({ + id: node.id, + template: node.template, + data: node.data, + position: node.position.toObject(), + children: Array.from(node.children) + })); + + const connections = tree.connections.map((conn) => conn.toObject()); + const blackboard = tree.blackboard.toObject(); + + const finalOptions = { ...this.defaultOptions, ...options }; + finalOptions.modifiedAt = new Date().toISOString(); + + const editorFormat = { + version: finalOptions.version, + metadata: { + name: finalOptions.name, + description: finalOptions.description, + createdAt: finalOptions.createdAt, + modifiedAt: finalOptions.modifiedAt + }, + nodes, + connections, + blackboard + }; + + const asset = EditorFormatConverter.toAsset(editorFormat); + + if (format === 'json') { + return BehaviorTreeAssetSerializer.serialize(asset, { format: 'json', pretty: true }); + } else if (format === 'binary') { + return BehaviorTreeAssetSerializer.serialize(asset, { format: 'binary' }); + } + + throw new Error(`不支持的导出格式: ${format}`); + } +} diff --git a/packages/editor-app/src/infrastructure/serialization/index.ts b/packages/editor-app/src/infrastructure/serialization/index.ts new file mode 100644 index 00000000..b09b763c --- /dev/null +++ b/packages/editor-app/src/infrastructure/serialization/index.ts @@ -0,0 +1 @@ +export { BehaviorTreeSerializer } from './BehaviorTreeSerializer'; diff --git a/packages/editor-app/src/infrastructure/validation/BehaviorTreeValidator.ts b/packages/editor-app/src/infrastructure/validation/BehaviorTreeValidator.ts new file mode 100644 index 00000000..af572975 --- /dev/null +++ b/packages/editor-app/src/infrastructure/validation/BehaviorTreeValidator.ts @@ -0,0 +1,149 @@ +import { IValidator, ValidationResult, ValidationError } from '../../domain/interfaces/IValidator'; +import { BehaviorTree } from '../../domain/models/BehaviorTree'; +import { Node } from '../../domain/models/Node'; +import { Connection } from '../../domain/models/Connection'; + +/** + * 行为树验证器实现 + */ +export class BehaviorTreeValidator implements IValidator { + /** + * 验证整个行为树 + */ + validateTree(tree: BehaviorTree): ValidationResult { + const errors: ValidationError[] = []; + + // 验证所有节点 + for (const node of tree.nodes) { + const nodeResult = this.validateNode(node); + errors.push(...nodeResult.errors); + } + + // 验证所有连接 + for (const connection of tree.connections) { + const connResult = this.validateConnection(connection, tree); + errors.push(...connResult.errors); + } + + // 验证循环引用 + const cycleResult = this.validateNoCycles(tree); + errors.push(...cycleResult.errors); + + return { + isValid: errors.length === 0, + errors + }; + } + + /** + * 验证节点 + */ + validateNode(node: Node): ValidationResult { + const errors: ValidationError[] = []; + + // 验证节点必填字段 + if (!node.id) { + errors.push({ + message: '节点 ID 不能为空', + nodeId: node.id + }); + } + + if (!node.template) { + errors.push({ + message: '节点模板不能为空', + nodeId: node.id + }); + } + + return { + isValid: errors.length === 0, + errors + }; + } + + /** + * 验证连接 + */ + validateConnection(connection: Connection, tree: BehaviorTree): ValidationResult { + const errors: ValidationError[] = []; + + // 验证连接的源节点和目标节点都存在 + const fromNode = tree.nodes.find((n) => n.id === connection.from); + const toNode = tree.nodes.find((n) => n.id === connection.to); + + if (!fromNode) { + errors.push({ + message: `连接的源节点不存在: ${connection.from}` + }); + } + + if (!toNode) { + errors.push({ + message: `连接的目标节点不存在: ${connection.to}` + }); + } + + // 不能自己连接自己 + if (connection.from === connection.to) { + errors.push({ + message: '节点不能连接到自己', + nodeId: connection.from + }); + } + + return { + isValid: errors.length === 0, + errors + }; + } + + /** + * 验证是否会产生循环引用 + */ + validateNoCycles(tree: BehaviorTree): ValidationResult { + const errors: ValidationError[] = []; + const visited = new Set(); + const recursionStack = new Set(); + + const hasCycle = (nodeId: string): boolean => { + if (recursionStack.has(nodeId)) { + return true; + } + + if (visited.has(nodeId)) { + return false; + } + + visited.add(nodeId); + recursionStack.add(nodeId); + + const node = tree.nodes.find((n) => n.id === nodeId); + if (node) { + for (const childId of node.children) { + if (hasCycle(childId)) { + return true; + } + } + } + + recursionStack.delete(nodeId); + return false; + }; + + for (const node of tree.nodes) { + if (hasCycle(node.id)) { + errors.push({ + message: '行为树中存在循环引用', + nodeId: node.id + }); + break; + } + } + + return { + isValid: errors.length === 0, + errors + }; + } +} diff --git a/packages/editor-app/src/presentation/components/behavior-tree/canvas/BehaviorTreeCanvas.tsx b/packages/editor-app/src/presentation/components/behavior-tree/canvas/BehaviorTreeCanvas.tsx new file mode 100644 index 00000000..b306c65c --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/canvas/BehaviorTreeCanvas.tsx @@ -0,0 +1,196 @@ +import React, { useRef, useCallback, forwardRef } from 'react'; +import { useCanvasInteraction } from '../../../hooks/useCanvasInteraction'; +import { EditorConfig } from '../../../types'; + +/** + * 画布组件属性 + */ +interface BehaviorTreeCanvasProps { + /** + * 编辑器配置 + */ + config: EditorConfig; + + /** + * 子组件 + */ + children: React.ReactNode; + + /** + * 画布点击事件 + */ + onClick?: (e: React.MouseEvent) => void; + + /** + * 画布双击事件 + */ + onDoubleClick?: (e: React.MouseEvent) => void; + + /** + * 画布右键事件 + */ + onContextMenu?: (e: React.MouseEvent) => void; + + /** + * 鼠标移动事件 + */ + onMouseMove?: (e: React.MouseEvent) => void; + + /** + * 鼠标按下事件 + */ + onMouseDown?: (e: React.MouseEvent) => void; + + /** + * 鼠标抬起事件 + */ + onMouseUp?: (e: React.MouseEvent) => void; + + /** + * 鼠标离开事件 + */ + onMouseLeave?: (e: React.MouseEvent) => void; + + /** + * 拖放事件 + */ + onDrop?: (e: React.DragEvent) => void; + + /** + * 拖动悬停事件 + */ + onDragOver?: (e: React.DragEvent) => void; + + /** + * 拖动进入事件 + */ + onDragEnter?: (e: React.DragEvent) => void; + + /** + * 拖动离开事件 + */ + onDragLeave?: (e: React.DragEvent) => void; +} + +/** + * 行为树画布组件 + * 负责画布的渲染、缩放、平移等基础功能 + */ +export const BehaviorTreeCanvas = forwardRef(({ + config, + children, + onClick, + onDoubleClick, + onContextMenu, + onMouseMove, + onMouseDown, + onMouseUp, + onMouseLeave, + onDrop, + onDragOver, + onDragEnter, + onDragLeave +}, forwardedRef) => { + const internalRef = useRef(null); + const canvasRef = forwardedRef || internalRef; + + const { + canvasOffset, + canvasScale, + isPanning, + handleWheel, + startPanning, + updatePanning, + stopPanning + } = useCanvasInteraction(); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button === 1 || (e.button === 0 && e.altKey)) { + e.preventDefault(); + startPanning(e.clientX, e.clientY); + } + + onMouseDown?.(e); + }, [startPanning, onMouseDown]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (isPanning) { + updatePanning(e.clientX, e.clientY); + } + + onMouseMove?.(e); + }, [isPanning, updatePanning, onMouseMove]); + + const handleMouseUp = useCallback((e: React.MouseEvent) => { + if (isPanning) { + stopPanning(); + } + + onMouseUp?.(e); + }, [isPanning, stopPanning, onMouseUp]); + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + onContextMenu?.(e); + }, [onContextMenu]); + + return ( +
+ {/* 网格背景 */} + {config.showGrid && ( +
+ )} + + {/* 内容容器(应用变换) */} +
+ {children} +
+
+ ); +}); + +BehaviorTreeCanvas.displayName = 'BehaviorTreeCanvas'; diff --git a/packages/editor-app/src/presentation/components/behavior-tree/canvas/index.ts b/packages/editor-app/src/presentation/components/behavior-tree/canvas/index.ts new file mode 100644 index 00000000..2f7ccb7d --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/canvas/index.ts @@ -0,0 +1 @@ +export { BehaviorTreeCanvas } from './BehaviorTreeCanvas'; diff --git a/packages/editor-app/src/presentation/components/behavior-tree/connections/ConnectionLayer.tsx b/packages/editor-app/src/presentation/components/behavior-tree/connections/ConnectionLayer.tsx new file mode 100644 index 00000000..13a27359 --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/connections/ConnectionLayer.tsx @@ -0,0 +1,110 @@ +import React, { useMemo } from 'react'; +import { ConnectionRenderer } from './ConnectionRenderer'; +import { ConnectionViewData } from '../../../types'; +import { Node } from '../../../../domain/models/Node'; +import { Connection } from '../../../../domain/models/Connection'; + +/** + * 连线层属性 + */ +interface ConnectionLayerProps { + /** + * 所有连接 + */ + connections: Connection[]; + + /** + * 所有节点(用于查找位置) + */ + nodes: Node[]; + + /** + * 选中的连接 + */ + selectedConnection?: { from: string; to: string } | null; + + /** + * 获取端口位置的函数 + */ + getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null; + + /** + * 连线点击事件 + */ + onConnectionClick?: (e: React.MouseEvent, fromId: string, toId: string) => void; + + /** + * 连线右键事件 + */ + onConnectionContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void; +} + +/** + * 连线层 + * 管理所有连线的渲染 + */ +export const ConnectionLayer: React.FC = ({ + connections, + nodes, + selectedConnection, + getPortPosition, + onConnectionClick, + onConnectionContextMenu +}) => { + const nodeMap = useMemo(() => { + return new Map(nodes.map((node) => [node.id, node])); + }, [nodes]); + + const connectionViewData = useMemo(() => { + return connections + .map((connection) => { + const fromNode = nodeMap.get(connection.from); + const toNode = nodeMap.get(connection.to); + + if (!fromNode || !toNode) { + return null; + } + + const isSelected = selectedConnection?.from === connection.from && + selectedConnection?.to === connection.to; + + const viewData: ConnectionViewData = { + connection, + isSelected + }; + + return { viewData, fromNode, toNode }; + }) + .filter((item): item is NonNullable => item !== null); + }, [connections, nodeMap, selectedConnection]); + + if (connectionViewData.length === 0) { + return null; + } + + return ( + + + {connectionViewData.map(({ viewData, fromNode, toNode }) => ( + + ))} + + + ); +}; diff --git a/packages/editor-app/src/presentation/components/behavior-tree/connections/ConnectionRenderer.tsx b/packages/editor-app/src/presentation/components/behavior-tree/connections/ConnectionRenderer.tsx new file mode 100644 index 00000000..b256ba2c --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/connections/ConnectionRenderer.tsx @@ -0,0 +1,174 @@ +import React, { useMemo } from 'react'; +import { ConnectionViewData } from '../../../types'; +import { Node } from '../../../../domain/models/Node'; + +/** + * 连线渲染器属性 + */ +interface ConnectionRendererProps { + /** + * 连接视图数据 + */ + connectionData: ConnectionViewData; + + /** + * 源节点 + */ + fromNode: Node; + + /** + * 目标节点 + */ + toNode: Node; + + /** + * 获取端口位置的函数 + */ + getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null; + + /** + * 连线点击事件 + */ + onClick?: (e: React.MouseEvent, fromId: string, toId: string) => void; + + /** + * 连线右键事件 + */ + onContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void; +} + +/** + * 连线渲染器 + * 使用贝塞尔曲线渲染节点间的连接 + */ +export const ConnectionRenderer: React.FC = ({ + connectionData, + fromNode, + toNode, + getPortPosition, + onClick, + onContextMenu +}) => { + const { connection, isSelected } = connectionData; + + const pathData = useMemo(() => { + let fromPos, toPos; + + if (connection.connectionType === 'property') { + // 属性连接:从DOM获取实际引脚位置 + fromPos = getPortPosition(connection.from); + toPos = getPortPosition(connection.to, connection.toProperty); + } else { + // 节点连接:使用DOM获取端口位置 + fromPos = getPortPosition(connection.from, undefined, 'output'); + toPos = getPortPosition(connection.to, undefined, 'input'); + } + + if (!fromPos || !toPos) { + // 如果DOM还没渲染,返回null + return null; + } + + const x1 = fromPos.x; + const y1 = fromPos.y; + const x2 = toPos.x; + const y2 = toPos.y; + + let pathD: string; + + if (connection.connectionType === 'property') { + // 属性连接使用水平贝塞尔曲线 + const controlX1 = x1 + (x2 - x1) * 0.5; + const controlX2 = x1 + (x2 - x1) * 0.5; + pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`; + } else { + // 节点连接使用垂直贝塞尔曲线 + const controlY = y1 + (y2 - y1) * 0.5; + pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`; + } + + return { + path: pathD, + midX: (x1 + x2) / 2, + midY: (y1 + y2) / 2 + }; + }, [connection, fromNode, toNode, getPortPosition]); + + const color = connection.connectionType === 'property' ? '#9c27b0' : '#0e639c'; + const strokeColor = isSelected ? '#FFD700' : color; + const strokeWidth = isSelected ? 4 : 2; + + if (!pathData) { + // DOM还没渲染完成,跳过此连接 + return null; + } + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(e, connection.from, connection.to); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.stopPropagation(); + onContextMenu?.(e, connection.from, connection.to); + }; + + return ( + + {/* 透明的宽线条,用于更容易点击 */} + + + {/* 实际显示的线条 */} + + + {/* 箭头标记 */} + + + + + + + {/* 选中时显示的中点 */} + {isSelected && ( + + )} + + ); +}; diff --git a/packages/editor-app/src/presentation/components/behavior-tree/connections/index.ts b/packages/editor-app/src/presentation/components/behavior-tree/connections/index.ts new file mode 100644 index 00000000..ad325271 --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/connections/index.ts @@ -0,0 +1,2 @@ +export { ConnectionRenderer } from './ConnectionRenderer'; +export { ConnectionLayer } from './ConnectionLayer'; diff --git a/packages/editor-app/src/presentation/components/behavior-tree/nodes/BehaviorTreeNode.tsx b/packages/editor-app/src/presentation/components/behavior-tree/nodes/BehaviorTreeNode.tsx new file mode 100644 index 00000000..58bcd0aa --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/nodes/BehaviorTreeNode.tsx @@ -0,0 +1,319 @@ +import React from 'react'; +import { + TreePine, + Database, + AlertTriangle, + AlertCircle, + LucideIcon +} from 'lucide-react'; +import { PropertyDefinition } from '@esengine/behavior-tree'; +import { BehaviorTreeNode as BehaviorTreeNodeType, Connection, ROOT_NODE_ID } from '../../../../stores/behaviorTreeStore'; +import { BehaviorTreeExecutor } from '../../../../utils/BehaviorTreeExecutor'; +import { BlackboardValue } from '../../../../domain/models/Blackboard'; + +type BlackboardVariables = Record; + +interface BehaviorTreeNodeProps { + node: BehaviorTreeNodeType; + isSelected: boolean; + isBeingDragged: boolean; + dragDelta: { dx: number; dy: number }; + uncommittedNodeIds: Set; + blackboardVariables: BlackboardVariables; + initialBlackboardVariables: BlackboardVariables; + isExecuting: boolean; + connections: Connection[]; + nodes: BehaviorTreeNodeType[]; + executorRef: React.RefObject; + iconMap: Record; + draggingNodeId: string | null; + onNodeClick: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void; + onContextMenu: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void; + onNodeMouseDown: (e: React.MouseEvent, nodeId: string) => void; + onNodeMouseUpForConnection: (e: React.MouseEvent, nodeId: string) => void; + onPortMouseDown: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void; + onPortMouseUp: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void; +} + +export const BehaviorTreeNode: React.FC = ({ + node, + isSelected, + isBeingDragged, + dragDelta, + uncommittedNodeIds, + blackboardVariables, + initialBlackboardVariables, + isExecuting, + connections, + nodes, + executorRef, + iconMap, + draggingNodeId, + onNodeClick, + onContextMenu, + onNodeMouseDown, + onNodeMouseUpForConnection, + onPortMouseDown, + onPortMouseUp +}) => { + const isRoot = node.id === ROOT_NODE_ID; + const isBlackboardVariable = node.data.nodeType === 'blackboard-variable'; + + const posX = node.position.x + (isBeingDragged ? dragDelta.dx : 0); + const posY = node.position.y + (isBeingDragged ? dragDelta.dy : 0); + + const isUncommitted = uncommittedNodeIds.has(node.id); + const nodeClasses = [ + 'bt-node', + isSelected && 'selected', + isRoot && 'root', + isUncommitted && 'uncommitted' + ].filter(Boolean).join(' '); + + return ( +
onNodeClick(e, node)} + onContextMenu={(e) => onContextMenu(e, node)} + onMouseDown={(e) => onNodeMouseDown(e, node.id)} + onMouseUp={(e) => onNodeMouseUpForConnection(e, node.id)} + style={{ + left: posX, + top: posY, + transform: 'translate(-50%, -50%)', + cursor: isRoot ? 'default' : (draggingNodeId === node.id ? 'grabbing' : 'grab'), + transition: draggingNodeId === node.id ? 'none' : 'all 0.2s', + zIndex: isRoot ? 50 : (draggingNodeId === node.id ? 100 : (isSelected ? 10 : 1)) + }} + > + {isBlackboardVariable ? ( + (() => { + const varName = node.data.variableName as string; + const currentValue = blackboardVariables[varName]; + const initialValue = initialBlackboardVariables[varName]; + const isModified = isExecuting && JSON.stringify(currentValue) !== JSON.stringify(initialValue); + + return ( + <> +
+ +
+ {varName || 'Variable'} +
+ {isModified && ( + + 运行时 + + )} +
+
+
+ {JSON.stringify(currentValue)} +
+
+
onPortMouseDown(e, node.id, '__value__')} + onMouseUp={(e) => onPortMouseUp(e, node.id, '__value__')} + className="bt-node-port bt-node-port-variable-output" + title="Output" + /> + + ); + })() + ) : ( + <> +
+ {isRoot ? ( + + ) : ( + node.template.icon && (() => { + const IconComponent = iconMap[node.template.icon]; + return IconComponent ? ( + + ) : ( + {node.template.icon} + ); + })() + )} +
+
{isRoot ? 'ROOT' : node.template.displayName}
+
+ #{node.id} +
+
+ {!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && ( +
e.stopPropagation()} + > + +
+ 缺失执行器:找不到节点对应的执行器 "{node.template.className}" +
+
+ )} + {isUncommitted && ( +
e.stopPropagation()} + > + +
+ 未生效节点:运行时添加的节点,需重新运行才能生效 +
+
+ )} + {!isRoot && !isUncommitted && node.template.type === 'composite' && + (node.template.requiresChildren === undefined || node.template.requiresChildren === true) && + !nodes.some((n) => + connections.some((c) => c.from === node.id && c.to === n.id) + ) && ( +
e.stopPropagation()} + > + +
+ 空节点:没有子节点,执行时会直接跳过 +
+
+ )} +
+ +
+ {!isRoot && ( +
+ {node.template.category} +
+ )} + + {node.template.properties.length > 0 && ( +
+ {node.template.properties.map((prop: PropertyDefinition, idx: number) => { + const hasConnection = connections.some( + (conn: Connection) => conn.toProperty === prop.name && conn.to === node.id + ); + const propValue = node.data[prop.name]; + + return ( +
+
onPortMouseDown(e, node.id, prop.name)} + onMouseUp={(e) => onPortMouseUp(e, node.id, prop.name)} + className={`bt-node-port bt-node-port-property ${hasConnection ? 'connected' : ''}`} + title={prop.description || prop.name} + /> + + {prop.name}: + + {propValue !== undefined && ( + + {String(propValue)} + + )} +
+ ); + })} +
+ )} +
+ + {!isRoot && ( +
onPortMouseDown(e, node.id)} + onMouseUp={(e) => onPortMouseUp(e, node.id)} + className="bt-node-port bt-node-port-input" + title="Input" + /> + )} + + {(isRoot || node.template.type === 'composite' || node.template.type === 'decorator') && + (node.template.requiresChildren === undefined || node.template.requiresChildren === true) && ( +
onPortMouseDown(e, node.id)} + onMouseUp={(e) => onPortMouseUp(e, node.id)} + className="bt-node-port bt-node-port-output" + title="Output" + /> + )} + + )} +
+ ); +}; diff --git a/packages/editor-app/src/presentation/components/behavior-tree/nodes/BehaviorTreeNodeRenderer.tsx b/packages/editor-app/src/presentation/components/behavior-tree/nodes/BehaviorTreeNodeRenderer.tsx new file mode 100644 index 00000000..baa258ab --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/nodes/BehaviorTreeNodeRenderer.tsx @@ -0,0 +1,219 @@ +import React, { useMemo } from 'react'; +import * as LucideIcons from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { NodeViewData } from '../../../types'; + +/** + * 图标映射 + */ +const iconMap: Record = { + TreePine: LucideIcons.TreePine, + GitBranch: LucideIcons.GitBranch, + Shuffle: LucideIcons.Shuffle, + Repeat: LucideIcons.Repeat, + RotateCcw: LucideIcons.RotateCcw, + FlipHorizontal: LucideIcons.FlipHorizontal, + CheckCircle: LucideIcons.CheckCircle, + XCircle: LucideIcons.XCircle, + Play: LucideIcons.Play, + Pause: LucideIcons.Pause, + Square: LucideIcons.Square, + Circle: LucideIcons.Circle, + Diamond: LucideIcons.Diamond, + Box: LucideIcons.Box, + Flag: LucideIcons.Flag, + Target: LucideIcons.Target +}; + +/** + * 节点渲染器属性 + */ +interface BehaviorTreeNodeRendererProps { + /** + * 节点视图数据 + */ + nodeData: NodeViewData; + + /** + * 节点点击事件 + */ + onClick?: (e: React.MouseEvent, nodeId: string) => void; + + /** + * 节点双击事件 + */ + onDoubleClick?: (e: React.MouseEvent, nodeId: string) => void; + + /** + * 节点右键事件 + */ + onContextMenu?: (e: React.MouseEvent, nodeId: string) => void; + + /** + * 鼠标按下事件 + */ + onMouseDown?: (e: React.MouseEvent, nodeId: string) => void; +} + +/** + * 行为树节点渲染器 + * 负责单个节点的渲染 + */ +export const BehaviorTreeNodeRenderer: React.FC = ({ + nodeData, + onClick, + onDoubleClick, + onContextMenu, + onMouseDown +}) => { + const { node, isSelected, isDragging, executionStatus } = nodeData; + const { template, position } = node; + + const IconComponent = iconMap[template.icon || 'Box'] || LucideIcons.Box; + + const nodeStyle = useMemo(() => { + let borderColor = template.color || '#4a9eff'; + const backgroundColor = '#2a2a2a'; + let boxShadow = 'none'; + + if (isSelected) { + boxShadow = `0 0 0 2px ${borderColor}`; + } + + if (executionStatus === 'running') { + borderColor = '#ffa500'; + boxShadow = `0 0 10px ${borderColor}`; + } else if (executionStatus === 'success') { + borderColor = '#00ff00'; + } else if (executionStatus === 'failure') { + borderColor = '#ff0000'; + } + + return { + position: 'absolute' as const, + left: position.x, + top: position.y, + minWidth: '180px', + padding: '12px', + backgroundColor, + borderRadius: '8px', + border: `2px solid ${borderColor}`, + boxShadow, + cursor: 'pointer', + userSelect: 'none' as const, + transition: 'box-shadow 0.2s', + opacity: isDragging ? 0.7 : 1 + }; + }, [template.color, position, isSelected, isDragging, executionStatus]); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(e, node.id); + }; + + const handleDoubleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onDoubleClick?.(e, node.id); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.stopPropagation(); + onContextMenu?.(e, node.id); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + e.stopPropagation(); + onMouseDown?.(e, node.id); + }; + + return ( +
+ {/* 节点头部 */} +
+ +
+ {template.displayName} +
+
+ + {/* 节点类型 */} + {template.category && ( +
+ {template.category} +
+ )} + + {/* 节点描述 */} + {template.description && ( +
+ {template.description} +
+ )} + + {/* 输入连接点 */} +
+ + {/* 输出连接点 */} +
+
+ ); +}; diff --git a/packages/editor-app/src/presentation/components/behavior-tree/nodes/index.ts b/packages/editor-app/src/presentation/components/behavior-tree/nodes/index.ts new file mode 100644 index 00000000..c7734f6a --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/nodes/index.ts @@ -0,0 +1 @@ +export { BehaviorTreeNodeRenderer } from './BehaviorTreeNodeRenderer'; diff --git a/packages/editor-app/src/presentation/components/menu/NodeContextMenu.tsx b/packages/editor-app/src/presentation/components/menu/NodeContextMenu.tsx new file mode 100644 index 00000000..5c53544e --- /dev/null +++ b/packages/editor-app/src/presentation/components/menu/NodeContextMenu.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +interface NodeContextMenuProps { + visible: boolean; + position: { x: number; y: number }; + nodeId: string | null; + onReplaceNode: () => void; +} + +export const NodeContextMenu: React.FC = ({ + visible, + position, + onReplaceNode +}) => { + if (!visible) return null; + + return ( +
e.stopPropagation()} + > +
e.currentTarget.style.backgroundColor = '#094771'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + 替换节点 +
+
+ ); +}; diff --git a/packages/editor-app/src/presentation/components/menu/QuickCreateMenu.tsx b/packages/editor-app/src/presentation/components/menu/QuickCreateMenu.tsx new file mode 100644 index 00000000..55538dcd --- /dev/null +++ b/packages/editor-app/src/presentation/components/menu/QuickCreateMenu.tsx @@ -0,0 +1,242 @@ +import React, { useRef, useEffect } from 'react'; +import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree'; +import { Search, X, LucideIcon } from 'lucide-react'; + +interface QuickCreateMenuProps { + visible: boolean; + position: { x: number; y: number }; + searchText: string; + selectedIndex: number; + mode: 'create' | 'replace'; + iconMap: Record; + onSearchChange: (text: string) => void; + onIndexChange: (index: number) => void; + onNodeSelect: (template: NodeTemplate) => void; + onClose: () => void; +} + +export const QuickCreateMenu: React.FC = ({ + visible, + position, + searchText, + selectedIndex, + iconMap, + onSearchChange, + onIndexChange, + onNodeSelect, + onClose +}) => { + const selectedNodeRef = useRef(null); + + const allTemplates = NodeTemplates.getAllTemplates(); + const searchTextLower = searchText.toLowerCase(); + const filteredTemplates = searchTextLower + ? allTemplates.filter((t: NodeTemplate) => { + const className = t.className || ''; + return t.displayName.toLowerCase().includes(searchTextLower) || + t.description.toLowerCase().includes(searchTextLower) || + t.category.toLowerCase().includes(searchTextLower) || + className.toLowerCase().includes(searchTextLower); + }) + : allTemplates; + + useEffect(() => { + if (selectedNodeRef.current) { + selectedNodeRef.current.scrollIntoView({ + block: 'nearest', + behavior: 'smooth' + }); + } + }, [selectedIndex]); + + if (!visible) return null; + + return ( + <> + +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + {/* 搜索框 */} +
+ + { + onSearchChange(e.target.value); + onIndexChange(0); + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + onClose(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + onIndexChange(Math.min(selectedIndex + 1, filteredTemplates.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + onIndexChange(Math.max(selectedIndex - 1, 0)); + } else if (e.key === 'Enter' && filteredTemplates.length > 0) { + e.preventDefault(); + const selectedTemplate = filteredTemplates[selectedIndex]; + if (selectedTemplate) { + onNodeSelect(selectedTemplate); + } + } + }} + style={{ + flex: 1, + background: 'transparent', + border: 'none', + outline: 'none', + color: '#ccc', + fontSize: '14px', + padding: '4px' + }} + /> + +
+ + {/* 节点列表 */} +
+ {filteredTemplates.length === 0 ? ( +
+ 未找到匹配的节点 +
+ ) : ( + filteredTemplates.map((template: NodeTemplate, index: number) => { + const IconComponent = template.icon ? iconMap[template.icon] : null; + const className = template.className || ''; + const isSelected = index === selectedIndex; + return ( +
onNodeSelect(template)} + onMouseEnter={() => onIndexChange(index)} + style={{ + padding: '8px 12px', + marginBottom: '4px', + backgroundColor: isSelected ? '#0e639c' : '#1e1e1e', + borderLeft: `3px solid ${template.color || '#666'}`, + borderRadius: '3px', + cursor: 'pointer', + transition: 'all 0.15s', + transform: isSelected ? 'translateX(2px)' : 'translateX(0)' + }} + > +
+ {IconComponent && ( + + )} +
+
+ {template.displayName} +
+ {className && ( +
+ {className} +
+ )} +
+
+
+ {template.description} +
+
+ {template.category} +
+
+ ); + }) + )} +
+
+ + ); +}; diff --git a/packages/editor-app/src/presentation/components/toolbar/EditorToolbar.tsx b/packages/editor-app/src/presentation/components/toolbar/EditorToolbar.tsx new file mode 100644 index 00000000..1a12ebdd --- /dev/null +++ b/packages/editor-app/src/presentation/components/toolbar/EditorToolbar.tsx @@ -0,0 +1,278 @@ +import React from 'react'; +import { Play, Pause, Square, SkipForward, RotateCcw, Trash2, Undo, Redo } from 'lucide-react'; + +type ExecutionMode = 'idle' | 'running' | 'paused' | 'step'; + +interface EditorToolbarProps { + executionMode: ExecutionMode; + canUndo: boolean; + canRedo: boolean; + onPlay: () => void; + onPause: () => void; + onStop: () => void; + onStep: () => void; + onReset: () => void; + onUndo: () => void; + onRedo: () => void; + onResetView: () => void; + onClearCanvas: () => void; +} + +export const EditorToolbar: React.FC = ({ + executionMode, + canUndo, + canRedo, + onPlay, + onPause, + onStop, + onStep, + onReset, + onUndo, + onRedo, + onResetView, + onClearCanvas +}) => { + return ( +
+ {/* 播放按钮 */} + + + {/* 暂停按钮 */} + + + {/* 停止按钮 */} + + + {/* 单步执行按钮 */} + + + {/* 重置按钮 */} + + + {/* 分隔符 */} +
+ + {/* 重置视图按钮 */} + + + {/* 清空画布按钮 */} + + + {/* 分隔符 */} +
+ + {/* 撤销按钮 */} + + + {/* 重做按钮 */} + + + {/* 状态指示器 */} +
+ + {executionMode === 'idle' ? 'Idle' : + executionMode === 'running' ? 'Running' : + executionMode === 'paused' ? 'Paused' : 'Step'} +
+
+ ); +}; diff --git a/packages/editor-app/src/presentation/config/editorConstants.ts b/packages/editor-app/src/presentation/config/editorConstants.ts new file mode 100644 index 00000000..be0b41df --- /dev/null +++ b/packages/editor-app/src/presentation/config/editorConstants.ts @@ -0,0 +1,56 @@ +import { NodeTemplate, NodeType } from '@esengine/behavior-tree'; +import { + List, GitBranch, Layers, Shuffle, RotateCcw, + Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer, + Clock, FileText, Edit, Calculator, Code, + Equal, Dices, Settings, + Database, TreePine, + LucideIcon +} from 'lucide-react'; + +export const ICON_MAP: Record = { + List, + GitBranch, + Layers, + Shuffle, + RotateCcw, + Repeat, + CheckCircle, + XCircle, + CheckCheck, + HelpCircle, + Snowflake, + Timer, + Clock, + FileText, + Edit, + Calculator, + Code, + Equal, + Dices, + Settings, + Database, + TreePine +}; + +export const ROOT_NODE_TEMPLATE: NodeTemplate = { + type: NodeType.Composite, + displayName: '根节点', + category: '根节点', + icon: 'TreePine', + description: '行为树根节点', + color: '#FFD700', + defaultConfig: { + nodeType: 'root' + }, + properties: [] +}; + +export const DEFAULT_EDITOR_CONFIG = { + enableSnapping: false, + gridSize: 20, + minZoom: 0.1, + maxZoom: 3, + showGrid: true, + showMinimap: false +}; diff --git a/packages/editor-app/src/presentation/hooks/index.ts b/packages/editor-app/src/presentation/hooks/index.ts new file mode 100644 index 00000000..307b388c --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/index.ts @@ -0,0 +1,4 @@ +export { useCommandHistory } from './useCommandHistory'; +export { useNodeOperations } from './useNodeOperations'; +export { useConnectionOperations } from './useConnectionOperations'; +export { useCanvasInteraction } from './useCanvasInteraction'; diff --git a/packages/editor-app/src/presentation/hooks/useCanvasInteraction.ts b/packages/editor-app/src/presentation/hooks/useCanvasInteraction.ts new file mode 100644 index 00000000..19786f6c --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/useCanvasInteraction.ts @@ -0,0 +1,110 @@ +import { useCallback, useMemo } from 'react'; +import { useUIStore } from '../../application/state/UIStore'; + +/** + * 画布交互 Hook + * 封装画布的缩放、平移等交互逻辑 + */ +export function useCanvasInteraction() { + const { + canvasOffset, + canvasScale, + isPanning, + panStart, + setCanvasOffset, + setCanvasScale, + setIsPanning, + setPanStart, + resetView + } = useUIStore(); + + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + + const delta = e.deltaY; + const scaleFactor = 1.1; + + if (delta < 0) { + setCanvasScale(Math.min(canvasScale * scaleFactor, 3)); + } else { + setCanvasScale(Math.max(canvasScale / scaleFactor, 0.1)); + } + }, [canvasScale, setCanvasScale]); + + const startPanning = useCallback((clientX: number, clientY: number) => { + setIsPanning(true); + setPanStart({ x: clientX, y: clientY }); + }, [setIsPanning, setPanStart]); + + const updatePanning = useCallback((clientX: number, clientY: number) => { + if (!isPanning) return; + + const dx = clientX - panStart.x; + const dy = clientY - panStart.y; + + setCanvasOffset({ + x: canvasOffset.x + dx, + y: canvasOffset.y + dy + }); + + setPanStart({ x: clientX, y: clientY }); + }, [isPanning, panStart, canvasOffset, setCanvasOffset, setPanStart]); + + const stopPanning = useCallback(() => { + setIsPanning(false); + }, [setIsPanning]); + + const zoomIn = useCallback(() => { + setCanvasScale(Math.min(canvasScale * 1.2, 3)); + }, [canvasScale, setCanvasScale]); + + const zoomOut = useCallback(() => { + setCanvasScale(Math.max(canvasScale / 1.2, 0.1)); + }, [canvasScale, setCanvasScale]); + + const zoomToFit = useCallback(() => { + resetView(); + }, [resetView]); + + const screenToCanvas = useCallback((screenX: number, screenY: number) => { + return { + x: (screenX - canvasOffset.x) / canvasScale, + y: (screenY - canvasOffset.y) / canvasScale + }; + }, [canvasOffset, canvasScale]); + + const canvasToScreen = useCallback((canvasX: number, canvasY: number) => { + return { + x: canvasX * canvasScale + canvasOffset.x, + y: canvasY * canvasScale + canvasOffset.y + }; + }, [canvasOffset, canvasScale]); + + return useMemo(() => ({ + canvasOffset, + canvasScale, + isPanning, + handleWheel, + startPanning, + updatePanning, + stopPanning, + zoomIn, + zoomOut, + zoomToFit, + screenToCanvas, + canvasToScreen + }), [ + canvasOffset, + canvasScale, + isPanning, + handleWheel, + startPanning, + updatePanning, + stopPanning, + zoomIn, + zoomOut, + zoomToFit, + screenToCanvas, + canvasToScreen + ]); +} diff --git a/packages/editor-app/src/presentation/hooks/useCanvasMouseEvents.ts b/packages/editor-app/src/presentation/hooks/useCanvasMouseEvents.ts new file mode 100644 index 00000000..2d6f9f11 --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/useCanvasMouseEvents.ts @@ -0,0 +1,167 @@ +import { RefObject } from 'react'; +import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore'; + +interface QuickCreateMenuState { + visible: boolean; + position: { x: number; y: number }; + searchText: string; + selectedIndex: number; + mode: 'create' | 'replace'; + replaceNodeId: string | null; +} + +interface UseCanvasMouseEventsParams { + canvasRef: RefObject; + canvasOffset: { x: number; y: number }; + canvasScale: number; + connectingFrom: string | null; + connectingToPos: { x: number; y: number } | null; + isBoxSelecting: boolean; + boxSelectStart: { x: number; y: number } | null; + boxSelectEnd: { x: number; y: number } | null; + nodes: BehaviorTreeNode[]; + selectedNodeIds: string[]; + quickCreateMenu: QuickCreateMenuState; + setConnectingToPos: (pos: { x: number; y: number } | null) => void; + setIsBoxSelecting: (isSelecting: boolean) => void; + setBoxSelectStart: (pos: { x: number; y: number } | null) => void; + setBoxSelectEnd: (pos: { x: number; y: number } | null) => void; + setSelectedNodeIds: (ids: string[]) => void; + setSelectedConnection: (connection: { from: string; to: string } | null) => void; + setQuickCreateMenu: (menu: QuickCreateMenuState) => void; + clearConnecting: () => void; + clearBoxSelect: () => void; +} + +export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) { + const { + canvasRef, + canvasOffset, + canvasScale, + connectingFrom, + connectingToPos, + isBoxSelecting, + boxSelectStart, + boxSelectEnd, + nodes, + selectedNodeIds, + quickCreateMenu, + setConnectingToPos, + setIsBoxSelecting, + setBoxSelectStart, + setBoxSelectEnd, + setSelectedNodeIds, + setSelectedConnection, + setQuickCreateMenu, + clearConnecting, + clearBoxSelect + } = params; + + const handleCanvasMouseMove = (e: React.MouseEvent) => { + if (connectingFrom && canvasRef.current && !quickCreateMenu.visible) { + const rect = canvasRef.current.getBoundingClientRect(); + const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale; + const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale; + setConnectingToPos({ + x: canvasX, + y: canvasY + }); + } + + if (isBoxSelecting && boxSelectStart) { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale; + const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale; + setBoxSelectEnd({ x: canvasX, y: canvasY }); + } + }; + + const handleCanvasMouseUp = (e: React.MouseEvent) => { + if (quickCreateMenu.visible) { + return; + } + + if (connectingFrom && connectingToPos) { + setQuickCreateMenu({ + visible: true, + position: { + x: e.clientX, + y: e.clientY + }, + searchText: '', + selectedIndex: 0, + mode: 'create', + replaceNodeId: null + }); + setConnectingToPos(null); + return; + } + + clearConnecting(); + + if (isBoxSelecting && boxSelectStart && boxSelectEnd) { + const minX = Math.min(boxSelectStart.x, boxSelectEnd.x); + const maxX = Math.max(boxSelectStart.x, boxSelectEnd.x); + const minY = Math.min(boxSelectStart.y, boxSelectEnd.y); + const maxY = Math.max(boxSelectStart.y, boxSelectEnd.y); + + const selectedInBox = nodes + .filter((node: BehaviorTreeNode) => { + if (node.id === ROOT_NODE_ID) return false; + + const nodeElement = canvasRef.current?.querySelector(`[data-node-id="${node.id}"]`); + if (!nodeElement) { + return node.position.x >= minX && node.position.x <= maxX && + node.position.y >= minY && node.position.y <= maxY; + } + + const rect = nodeElement.getBoundingClientRect(); + const canvasRect = canvasRef.current!.getBoundingClientRect(); + + const nodeLeft = (rect.left - canvasRect.left - canvasOffset.x) / canvasScale; + const nodeRight = (rect.right - canvasRect.left - canvasOffset.x) / canvasScale; + const nodeTop = (rect.top - canvasRect.top - canvasOffset.y) / canvasScale; + const nodeBottom = (rect.bottom - canvasRect.top - canvasOffset.y) / canvasScale; + + return nodeRight > minX && nodeLeft < maxX && nodeBottom > minY && nodeTop < maxY; + }) + .map((node: BehaviorTreeNode) => node.id); + + if (e.ctrlKey || e.metaKey) { + const newSet = new Set([...selectedNodeIds, ...selectedInBox]); + setSelectedNodeIds(Array.from(newSet)); + } else { + setSelectedNodeIds(selectedInBox); + } + } + + clearBoxSelect(); + }; + + const handleCanvasMouseDown = (e: React.MouseEvent) => { + if (e.button === 0 && !e.altKey) { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale; + const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale; + + setIsBoxSelecting(true); + setBoxSelectStart({ x: canvasX, y: canvasY }); + setBoxSelectEnd({ x: canvasX, y: canvasY }); + + if (!e.ctrlKey && !e.metaKey) { + setSelectedNodeIds([]); + setSelectedConnection(null); + } + } + }; + + return { + handleCanvasMouseMove, + handleCanvasMouseUp, + handleCanvasMouseDown + }; +} diff --git a/packages/editor-app/src/presentation/hooks/useCommandHistory.ts b/packages/editor-app/src/presentation/hooks/useCommandHistory.ts new file mode 100644 index 00000000..07a7e486 --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/useCommandHistory.ts @@ -0,0 +1,80 @@ +import { useRef, useCallback, useMemo, useEffect } from 'react'; +import { CommandManager } from '../../application/commands/CommandManager'; + +/** + * 撤销/重做功能 Hook + */ +export function useCommandHistory() { + const commandManagerRef = useRef(new CommandManager({ + maxHistorySize: 100, + autoMerge: true + })); + + const commandManager = commandManagerRef.current; + + const canUndo = useCallback(() => { + return commandManager.canUndo(); + }, [commandManager]); + + const canRedo = useCallback(() => { + return commandManager.canRedo(); + }, [commandManager]); + + const undo = useCallback(() => { + if (commandManager.canUndo()) { + commandManager.undo(); + } + }, [commandManager]); + + const redo = useCallback(() => { + if (commandManager.canRedo()) { + commandManager.redo(); + } + }, [commandManager]); + + const getUndoHistory = useCallback(() => { + return commandManager.getUndoHistory(); + }, [commandManager]); + + const getRedoHistory = useCallback(() => { + return commandManager.getRedoHistory(); + }, [commandManager]); + + const clear = useCallback(() => { + commandManager.clear(); + }, [commandManager]); + + // 键盘快捷键 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + const isCtrlOrCmd = isMac ? e.metaKey : e.ctrlKey; + + if (isCtrlOrCmd && e.key === 'z') { + e.preventDefault(); + if (e.shiftKey) { + redo(); + } else { + undo(); + } + } else if (isCtrlOrCmd && e.key === 'y') { + e.preventDefault(); + redo(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [undo, redo]); + + return useMemo(() => ({ + commandManager, + canUndo: canUndo(), + canRedo: canRedo(), + undo, + redo, + getUndoHistory, + getRedoHistory, + clear + }), [commandManager, canUndo, canRedo, undo, redo, getUndoHistory, getRedoHistory, clear]); +} diff --git a/packages/editor-app/src/presentation/hooks/useConnectionOperations.ts b/packages/editor-app/src/presentation/hooks/useConnectionOperations.ts new file mode 100644 index 00000000..3e9b1334 --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/useConnectionOperations.ts @@ -0,0 +1,61 @@ +import { useCallback, useMemo } from 'react'; +import { ConnectionType } from '../../domain/models/Connection'; +import { IValidator } from '../../domain/interfaces/IValidator'; +import { CommandManager } from '../../application/commands/CommandManager'; +import { TreeStateAdapter } from '../../application/state/BehaviorTreeDataStore'; +import { AddConnectionUseCase } from '../../application/use-cases/AddConnectionUseCase'; +import { RemoveConnectionUseCase } from '../../application/use-cases/RemoveConnectionUseCase'; + +/** + * 连接操作 Hook + */ +export function useConnectionOperations( + validator: IValidator, + commandManager: CommandManager +) { + const treeState = useMemo(() => new TreeStateAdapter(), []); + + const addConnectionUseCase = useMemo( + () => new AddConnectionUseCase(commandManager, treeState, validator), + [commandManager, treeState, validator] + ); + + const removeConnectionUseCase = useMemo( + () => new RemoveConnectionUseCase(commandManager, treeState), + [commandManager, treeState] + ); + + const addConnection = useCallback(( + from: string, + to: string, + connectionType: ConnectionType = 'node', + fromProperty?: string, + toProperty?: string + ) => { + try { + return addConnectionUseCase.execute(from, to, connectionType, fromProperty, toProperty); + } catch (error) { + console.error('添加连接失败:', error); + throw error; + } + }, [addConnectionUseCase]); + + const removeConnection = useCallback(( + from: string, + to: string, + fromProperty?: string, + toProperty?: string + ) => { + try { + removeConnectionUseCase.execute(from, to, fromProperty, toProperty); + } catch (error) { + console.error('移除连接失败:', error); + throw error; + } + }, [removeConnectionUseCase]); + + return useMemo(() => ({ + addConnection, + removeConnection + }), [addConnection, removeConnection]); +} diff --git a/packages/editor-app/src/presentation/hooks/useDropHandler.ts b/packages/editor-app/src/presentation/hooks/useDropHandler.ts new file mode 100644 index 00000000..142e4354 --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/useDropHandler.ts @@ -0,0 +1,129 @@ +import { useState, RefObject } from 'react'; +import { NodeTemplate, NodeType } from '@esengine/behavior-tree'; +import { Position } from '../../domain/value-objects/Position'; +import { useNodeOperations } from './useNodeOperations'; + +interface DraggedVariableData { + variableName: string; +} + +interface UseDropHandlerParams { + canvasRef: RefObject; + canvasOffset: { x: number; y: number }; + canvasScale: number; + nodeOperations: ReturnType; + onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void; +} + +export function useDropHandler(params: UseDropHandlerParams) { + const { + canvasRef, + canvasOffset, + canvasScale, + nodeOperations, + onNodeCreate + } = params; + + const [isDragging, setIsDragging] = useState(false); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + try { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const position = { + x: (e.clientX - rect.left - canvasOffset.x) / canvasScale, + y: (e.clientY - rect.top - canvasOffset.y) / canvasScale + }; + + const blackboardVariableData = e.dataTransfer.getData('application/blackboard-variable'); + if (blackboardVariableData) { + const variableData = JSON.parse(blackboardVariableData) as DraggedVariableData; + + const variableTemplate: NodeTemplate = { + type: NodeType.Action, + displayName: variableData.variableName, + category: 'Blackboard Variable', + icon: 'Database', + description: `Blackboard variable: ${variableData.variableName}`, + color: '#9c27b0', + defaultConfig: { + nodeType: 'blackboard-variable', + variableName: variableData.variableName + }, + properties: [ + { + name: 'variableName', + label: '变量名', + type: 'variable', + defaultValue: variableData.variableName, + description: '黑板变量的名称', + required: true + } + ] + }; + + nodeOperations.createNode( + variableTemplate, + new Position(position.x, position.y), + { + nodeType: 'blackboard-variable', + variableName: variableData.variableName + } + ); + return; + } + + let templateData = e.dataTransfer.getData('application/behavior-tree-node'); + if (!templateData) { + templateData = e.dataTransfer.getData('text/plain'); + } + if (!templateData) { + return; + } + + const template = JSON.parse(templateData) as NodeTemplate; + + nodeOperations.createNode( + template, + new Position(position.x, position.y), + template.defaultConfig + ); + + onNodeCreate?.(template, position); + } catch (error) { + console.error('Failed to create node:', error); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + if (!isDragging) { + setIsDragging(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + if (e.currentTarget === e.target) { + setIsDragging(false); + } + }; + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + }; + + return { + isDragging, + handleDrop, + handleDragOver, + handleDragLeave, + handleDragEnter + }; +} diff --git a/packages/editor-app/src/presentation/hooks/useEditorHandlers.ts b/packages/editor-app/src/presentation/hooks/useEditorHandlers.ts new file mode 100644 index 00000000..e4a07ad6 --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/useEditorHandlers.ts @@ -0,0 +1,84 @@ +import { ask } from '@tauri-apps/plugin-dialog'; +import { BehaviorTreeNode } from '../../stores/behaviorTreeStore'; +import { Node } from '../../domain/models/Node'; +import { Position } from '../../domain/value-objects/Position'; +import { NodeTemplate } from '@esengine/behavior-tree'; + +interface UseEditorHandlersParams { + isDraggingNode: boolean; + selectedNodeIds: string[]; + setSelectedNodeIds: (ids: string[]) => void; + setNodes: (nodes: Node[]) => void; + setConnections: (connections: any[]) => void; + resetView: () => void; + triggerForceUpdate: () => void; + onNodeSelect?: (node: BehaviorTreeNode) => void; + rootNodeId: string; + rootNodeTemplate: NodeTemplate; +} + +export function useEditorHandlers(params: UseEditorHandlersParams) { + const { + isDraggingNode, + selectedNodeIds, + setSelectedNodeIds, + setNodes, + setConnections, + resetView, + triggerForceUpdate, + onNodeSelect, + rootNodeId, + rootNodeTemplate + } = params; + + const handleNodeClick = (e: React.MouseEvent, node: BehaviorTreeNode) => { + if (isDraggingNode) { + return; + } + + if (e.ctrlKey || e.metaKey) { + if (selectedNodeIds.includes(node.id)) { + setSelectedNodeIds(selectedNodeIds.filter((id: string) => id !== node.id)); + } else { + setSelectedNodeIds([...selectedNodeIds, node.id]); + } + } else { + setSelectedNodeIds([node.id]); + } + onNodeSelect?.(node); + }; + + const handleResetView = () => { + resetView(); + requestAnimationFrame(() => { + triggerForceUpdate(); + }); + }; + + const handleClearCanvas = async () => { + const confirmed = await ask('确定要清空画布吗?此操作不可撤销。', { + title: '清空画布', + kind: 'warning' + }); + + if (confirmed) { + setNodes([ + new Node( + rootNodeId, + rootNodeTemplate, + { nodeType: 'root' }, + new Position(400, 100), + [] + ) + ]); + setConnections([]); + setSelectedNodeIds([]); + } + }; + + return { + handleNodeClick, + handleResetView, + handleClearCanvas + }; +} diff --git a/packages/editor-app/src/presentation/hooks/useEditorState.ts b/packages/editor-app/src/presentation/hooks/useEditorState.ts new file mode 100644 index 00000000..36499f11 --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/useEditorState.ts @@ -0,0 +1,18 @@ +import { useRef, useState } from 'react'; +import { BehaviorTreeExecutor } from '../../utils/BehaviorTreeExecutor'; + +export function useEditorState() { + const canvasRef = useRef(null); + const stopExecutionRef = useRef<(() => void) | null>(null); + const executorRef = useRef(null); + + const [selectedConnection, setSelectedConnection] = useState<{from: string; to: string} | null>(null); + + return { + canvasRef, + stopExecutionRef, + executorRef, + selectedConnection, + setSelectedConnection + }; +} diff --git a/packages/editor-app/src/presentation/hooks/useExecutionController.ts b/packages/editor-app/src/presentation/hooks/useExecutionController.ts new file mode 100644 index 00000000..fea8bfe1 --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/useExecutionController.ts @@ -0,0 +1,148 @@ +import { useState, useEffect, useMemo } from 'react'; +import { ExecutionController, ExecutionMode } from '../../application/services/ExecutionController'; +import { BlackboardManager } from '../../application/services/BlackboardManager'; +import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore'; +import { ExecutionLog } from '../../utils/BehaviorTreeExecutor'; +import { BlackboardValue } from '../../domain/models/Blackboard'; + +type BlackboardVariables = Record; + +interface UseExecutionControllerParams { + rootNodeId: string; + projectPath: string | null; + blackboardVariables: BlackboardVariables; + nodes: BehaviorTreeNode[]; + connections: Connection[]; + initialBlackboardVariables: BlackboardVariables; + onBlackboardUpdate: (variables: BlackboardVariables) => void; + onInitialBlackboardSave: (variables: BlackboardVariables) => void; + onExecutingChange: (isExecuting: boolean) => void; +} + +export function useExecutionController(params: UseExecutionControllerParams) { + const { + rootNodeId, + projectPath, + blackboardVariables, + nodes, + connections, + onBlackboardUpdate, + onInitialBlackboardSave, + onExecutingChange + } = params; + + const [executionMode, setExecutionMode] = useState('idle'); + const [executionLogs, setExecutionLogs] = useState([]); + const [executionSpeed, setExecutionSpeed] = useState(1.0); + const [tickCount, setTickCount] = useState(0); + + const controller = useMemo(() => { + return new ExecutionController({ + rootNodeId, + projectPath, + onLogsUpdate: setExecutionLogs, + onBlackboardUpdate, + onTickCountUpdate: setTickCount + }); + }, [rootNodeId, projectPath]); + + const blackboardManager = useMemo(() => new BlackboardManager(), []); + + useEffect(() => { + return () => { + controller.destroy(); + }; + }, [controller]); + + useEffect(() => { + controller.setConnections(connections); + }, [connections, controller]); + + useEffect(() => { + if (executionMode === 'idle') return; + + const executorVars = controller.getBlackboardVariables(); + + Object.entries(blackboardVariables).forEach(([key, value]) => { + if (executorVars[key] !== value) { + controller.updateBlackboardVariable(key, value); + } + }); + }, [blackboardVariables, executionMode, controller]); + + const handlePlay = async () => { + try { + blackboardManager.setInitialVariables(blackboardVariables); + blackboardManager.setCurrentVariables(blackboardVariables); + onInitialBlackboardSave(blackboardManager.getInitialVariables()); + onExecutingChange(true); + + setExecutionMode('running'); + await controller.play(nodes, blackboardVariables, connections); + } catch (error) { + console.error('Failed to start execution:', error); + setExecutionMode('idle'); + onExecutingChange(false); + } + }; + + const handlePause = async () => { + try { + await controller.pause(); + const newMode = controller.getMode(); + setExecutionMode(newMode); + } catch (error) { + console.error('Failed to pause/resume execution:', error); + } + }; + + const handleStop = async () => { + try { + await controller.stop(); + setExecutionMode('idle'); + setTickCount(0); + + const restoredVars = blackboardManager.restoreInitialVariables(); + onBlackboardUpdate(restoredVars); + onExecutingChange(false); + } catch (error) { + console.error('Failed to stop execution:', error); + } + }; + + const handleStep = () => { + controller.step(); + setExecutionMode('step'); + }; + + const handleReset = async () => { + try { + await controller.reset(); + setExecutionMode('idle'); + setTickCount(0); + } catch (error) { + console.error('Failed to reset execution:', error); + } + }; + + const handleSpeedChange = (speed: number) => { + setExecutionSpeed(speed); + controller.setSpeed(speed); + }; + + return { + executionMode, + executionLogs, + executionSpeed, + tickCount, + handlePlay, + handlePause, + handleStop, + handleStep, + handleReset, + handleSpeedChange, + setExecutionLogs, + controller, + blackboardManager + }; +} diff --git a/packages/editor-app/src/presentation/hooks/useKeyboardShortcuts.ts b/packages/editor-app/src/presentation/hooks/useKeyboardShortcuts.ts new file mode 100644 index 00000000..ea7323f6 --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,72 @@ +import { useEffect } from 'react'; +import { Connection, ROOT_NODE_ID } from '../../stores/behaviorTreeStore'; +import { useNodeOperations } from './useNodeOperations'; +import { useConnectionOperations } from './useConnectionOperations'; + +interface UseKeyboardShortcutsParams { + selectedNodeIds: string[]; + selectedConnection: { from: string; to: string } | null; + connections: Connection[]; + nodeOperations: ReturnType; + connectionOperations: ReturnType; + setSelectedNodeIds: (ids: string[]) => void; + setSelectedConnection: (connection: { from: string; to: string } | null) => void; +} + +export function useKeyboardShortcuts(params: UseKeyboardShortcutsParams) { + const { + selectedNodeIds, + selectedConnection, + connections, + nodeOperations, + connectionOperations, + setSelectedNodeIds, + setSelectedConnection + } = params; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const activeElement = document.activeElement; + const isEditingText = activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement instanceof HTMLSelectElement || + (activeElement as HTMLElement)?.isContentEditable; + + if (isEditingText) { + return; + } + + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault(); + + if (selectedConnection) { + const conn = connections.find( + (c: Connection) => c.from === selectedConnection.from && c.to === selectedConnection.to + ); + if (conn) { + connectionOperations.removeConnection( + conn.from, + conn.to, + conn.fromProperty, + conn.toProperty + ); + } + + setSelectedConnection(null); + return; + } + + if (selectedNodeIds.length > 0) { + const nodesToDelete = selectedNodeIds.filter((id: string) => id !== ROOT_NODE_ID); + if (nodesToDelete.length > 0) { + nodeOperations.deleteNodes(nodesToDelete); + setSelectedNodeIds([]); + } + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedNodeIds, selectedConnection, nodeOperations, connectionOperations, connections, setSelectedNodeIds, setSelectedConnection]); +} diff --git a/packages/editor-app/src/presentation/hooks/useNodeDrag.ts b/packages/editor-app/src/presentation/hooks/useNodeDrag.ts new file mode 100644 index 00000000..d557b751 --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/useNodeDrag.ts @@ -0,0 +1,161 @@ +import { useState, RefObject } from 'react'; +import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore'; +import { Position } from '../../domain/value-objects/Position'; +import { useNodeOperations } from './useNodeOperations'; + +interface UseNodeDragParams { + canvasRef: RefObject; + canvasOffset: { x: number; y: number }; + canvasScale: number; + nodes: BehaviorTreeNode[]; + selectedNodeIds: string[]; + draggingNodeId: string | null; + dragStartPositions: Map; + isDraggingNode: boolean; + dragDelta: { dx: number; dy: number }; + nodeOperations: ReturnType; + setSelectedNodeIds: (ids: string[]) => void; + startDragging: (nodeId: string, startPositions: Map) => void; + stopDragging: () => void; + setIsDraggingNode: (isDragging: boolean) => void; + setDragDelta: (delta: { dx: number; dy: number }) => void; + setIsBoxSelecting: (isSelecting: boolean) => void; + setBoxSelectStart: (pos: { x: number; y: number } | null) => void; + setBoxSelectEnd: (pos: { x: number; y: number } | null) => void; + sortChildrenByPosition: () => void; +} + +export function useNodeDrag(params: UseNodeDragParams) { + const { + canvasRef, + canvasOffset, + canvasScale, + nodes, + selectedNodeIds, + draggingNodeId, + dragStartPositions, + isDraggingNode, + dragDelta, + nodeOperations, + setSelectedNodeIds, + startDragging, + stopDragging, + setIsDraggingNode, + setDragDelta, + setIsBoxSelecting, + setBoxSelectStart, + setBoxSelectEnd, + sortChildrenByPosition + } = params; + + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + + const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => { + if (e.button !== 0) return; + + if (nodeId === ROOT_NODE_ID) return; + + const target = e.target as HTMLElement; + if (target.getAttribute('data-port')) { + return; + } + + e.stopPropagation(); + + setIsBoxSelecting(false); + setBoxSelectStart(null); + setBoxSelectEnd(null); + const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId); + if (!node) return; + + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale; + const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale; + + let nodesToDrag: string[]; + if (selectedNodeIds.includes(nodeId)) { + nodesToDrag = selectedNodeIds; + } else { + nodesToDrag = [nodeId]; + setSelectedNodeIds([nodeId]); + } + + const startPositions = new Map(); + nodesToDrag.forEach((id: string) => { + const n = nodes.find((node: BehaviorTreeNode) => node.id === id); + if (n) { + startPositions.set(id, { x: n.position.x, y: n.position.y }); + } + }); + + startDragging(nodeId, startPositions); + setDragOffset({ + x: canvasX - node.position.x, + y: canvasY - node.position.y + }); + }; + + const handleNodeMouseMove = (e: React.MouseEvent) => { + if (!draggingNodeId) return; + + if (!isDraggingNode) { + setIsDraggingNode(true); + } + + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale; + const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale; + + const newX = canvasX - dragOffset.x; + const newY = canvasY - dragOffset.y; + + const draggedNodeStartPos = dragStartPositions.get(draggingNodeId); + if (!draggedNodeStartPos) return; + + const deltaX = newX - draggedNodeStartPos.x; + const deltaY = newY - draggedNodeStartPos.y; + + setDragDelta({ dx: deltaX, dy: deltaY }); + }; + + const handleNodeMouseUp = () => { + if (!draggingNodeId) return; + + if (dragDelta.dx !== 0 || dragDelta.dy !== 0) { + const moves: Array<{ nodeId: string; position: Position }> = []; + dragStartPositions.forEach((startPos: { x: number; y: number }, nodeId: string) => { + moves.push({ + nodeId, + position: new Position( + startPos.x + dragDelta.dx, + startPos.y + dragDelta.dy + ) + }); + }); + nodeOperations.moveNodes(moves); + + setTimeout(() => { + sortChildrenByPosition(); + }, 0); + } + + setDragDelta({ dx: 0, dy: 0 }); + + stopDragging(); + + setTimeout(() => { + setIsDraggingNode(false); + }, 10); + }; + + return { + handleNodeMouseDown, + handleNodeMouseMove, + handleNodeMouseUp, + dragOffset + }; +} diff --git a/packages/editor-app/src/presentation/hooks/useNodeOperations.ts b/packages/editor-app/src/presentation/hooks/useNodeOperations.ts new file mode 100644 index 00000000..961b8d07 --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/useNodeOperations.ts @@ -0,0 +1,88 @@ +import { useCallback, useMemo } from 'react'; +import { NodeTemplate } from '@esengine/behavior-tree'; +import { Position } from '../../domain/value-objects/Position'; +import { INodeFactory } from '../../domain/interfaces/INodeFactory'; +import { IValidator } from '../../domain/interfaces/IValidator'; +import { CommandManager } from '../../application/commands/CommandManager'; +import { TreeStateAdapter } from '../../application/state/BehaviorTreeDataStore'; +import { CreateNodeUseCase } from '../../application/use-cases/CreateNodeUseCase'; +import { DeleteNodeUseCase } from '../../application/use-cases/DeleteNodeUseCase'; +import { MoveNodeUseCase } from '../../application/use-cases/MoveNodeUseCase'; +import { UpdateNodeDataUseCase } from '../../application/use-cases/UpdateNodeDataUseCase'; + +/** + * 节点操作 Hook + */ +export function useNodeOperations( + nodeFactory: INodeFactory, + validator: IValidator, + commandManager: CommandManager +) { + const treeState = useMemo(() => new TreeStateAdapter(), []); + + const createNodeUseCase = useMemo( + () => new CreateNodeUseCase(nodeFactory, commandManager, treeState), + [nodeFactory, commandManager, treeState] + ); + + const deleteNodeUseCase = useMemo( + () => new DeleteNodeUseCase(commandManager, treeState), + [commandManager, treeState] + ); + + const moveNodeUseCase = useMemo( + () => new MoveNodeUseCase(commandManager, treeState), + [commandManager, treeState] + ); + + const updateNodeDataUseCase = useMemo( + () => new UpdateNodeDataUseCase(commandManager, treeState), + [commandManager, treeState] + ); + + const createNode = useCallback(( + template: NodeTemplate, + position: Position, + data?: Record + ) => { + return createNodeUseCase.execute(template, position, data); + }, [createNodeUseCase]); + + const createNodeByType = useCallback(( + nodeType: string, + position: Position, + data?: Record + ) => { + return createNodeUseCase.executeByType(nodeType, position, data); + }, [createNodeUseCase]); + + const deleteNode = useCallback((nodeId: string) => { + deleteNodeUseCase.execute(nodeId); + }, [deleteNodeUseCase]); + + const deleteNodes = useCallback((nodeIds: string[]) => { + deleteNodeUseCase.executeBatch(nodeIds); + }, [deleteNodeUseCase]); + + const moveNode = useCallback((nodeId: string, position: Position) => { + moveNodeUseCase.execute(nodeId, position); + }, [moveNodeUseCase]); + + const moveNodes = useCallback((moves: Array<{ nodeId: string; position: Position }>) => { + moveNodeUseCase.executeBatch(moves); + }, [moveNodeUseCase]); + + const updateNodeData = useCallback((nodeId: string, data: Record) => { + updateNodeDataUseCase.execute(nodeId, data); + }, [updateNodeDataUseCase]); + + return useMemo(() => ({ + createNode, + createNodeByType, + deleteNode, + deleteNodes, + moveNode, + moveNodes, + updateNodeData + }), [createNode, createNodeByType, deleteNode, deleteNodes, moveNode, moveNodes, updateNodeData]); +} diff --git a/packages/editor-app/src/presentation/hooks/useNodeTracking.ts b/packages/editor-app/src/presentation/hooks/useNodeTracking.ts new file mode 100644 index 00000000..ed0b34d2 --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/useNodeTracking.ts @@ -0,0 +1,39 @@ +import { useState, useEffect, useRef } from 'react'; +import { BehaviorTreeNode } from '../../stores/behaviorTreeStore'; +import { ExecutionMode } from '../../application/services/ExecutionController'; + +interface UseNodeTrackingParams { + nodes: BehaviorTreeNode[]; + executionMode: ExecutionMode; +} + +export function useNodeTracking(params: UseNodeTrackingParams) { + const { nodes, executionMode } = params; + + const [uncommittedNodeIds, setUncommittedNodeIds] = useState>(new Set()); + const activeNodeIdsRef = useRef>(new Set()); + + useEffect(() => { + if (executionMode === 'idle') { + setUncommittedNodeIds(new Set()); + activeNodeIdsRef.current = new Set(nodes.map((n) => n.id)); + } else if (executionMode === 'running' || executionMode === 'paused') { + const currentNodeIds = new Set(nodes.map((n) => n.id)); + const newNodeIds = new Set(); + + currentNodeIds.forEach((id) => { + if (!activeNodeIdsRef.current.has(id)) { + newNodeIds.add(id); + } + }); + + if (newNodeIds.size > 0) { + setUncommittedNodeIds((prev) => new Set([...prev, ...newNodeIds])); + } + } + }, [nodes, executionMode]); + + return { + uncommittedNodeIds + }; +} diff --git a/packages/editor-app/src/presentation/hooks/usePortConnection.ts b/packages/editor-app/src/presentation/hooks/usePortConnection.ts new file mode 100644 index 00000000..87f8c3aa --- /dev/null +++ b/packages/editor-app/src/presentation/hooks/usePortConnection.ts @@ -0,0 +1,182 @@ +import { RefObject } from 'react'; +import { BehaviorTreeNode, Connection, ROOT_NODE_ID } from '../../stores/behaviorTreeStore'; +import { PropertyDefinition } from '@esengine/behavior-tree'; +import { useConnectionOperations } from './useConnectionOperations'; + +interface UsePortConnectionParams { + canvasRef: RefObject; + canvasOffset: { x: number; y: number }; + canvasScale: number; + nodes: BehaviorTreeNode[]; + connections: Connection[]; + connectingFrom: string | null; + connectingFromProperty: string | null; + connectionOperations: ReturnType; + setConnectingFrom: (nodeId: string | null) => void; + setConnectingFromProperty: (propertyName: string | null) => void; + clearConnecting: () => void; + sortChildrenByPosition: () => void; + showToast?: (message: string, type: 'success' | 'error' | 'info' | 'warning') => void; +} + +export function usePortConnection(params: UsePortConnectionParams) { + const { + canvasRef, + nodes, + connections, + connectingFrom, + connectingFromProperty, + connectionOperations, + setConnectingFrom, + setConnectingFromProperty, + clearConnecting, + sortChildrenByPosition, + showToast + } = params; + + const handlePortMouseDown = (e: React.MouseEvent, nodeId: string, propertyName?: string) => { + e.stopPropagation(); + const target = e.currentTarget as HTMLElement; + const portType = target.getAttribute('data-port-type'); + + setConnectingFrom(nodeId); + setConnectingFromProperty(propertyName || null); + + if (canvasRef.current) { + canvasRef.current.setAttribute('data-connecting-from-port-type', portType || ''); + } + }; + + const handlePortMouseUp = (e: React.MouseEvent, nodeId: string, propertyName?: string) => { + e.stopPropagation(); + if (!connectingFrom) { + clearConnecting(); + return; + } + + if (connectingFrom === nodeId) { + showToast?.('不能将节点连接到自己', 'warning'); + clearConnecting(); + return; + } + + const target = e.currentTarget as HTMLElement; + const toPortType = target.getAttribute('data-port-type'); + const fromPortType = canvasRef.current?.getAttribute('data-connecting-from-port-type'); + + let actualFrom = connectingFrom; + let actualTo = nodeId; + let actualFromProperty = connectingFromProperty; + let actualToProperty = propertyName; + + const needReverse = + (fromPortType === 'node-input' || fromPortType === 'property-input') && + (toPortType === 'node-output' || toPortType === 'variable-output'); + + if (needReverse) { + actualFrom = nodeId; + actualTo = connectingFrom; + actualFromProperty = propertyName || null; + actualToProperty = connectingFromProperty ?? undefined; + } + + if (actualFromProperty || actualToProperty) { + const existingConnection = connections.find( + (conn: Connection) => + (conn.from === actualFrom && conn.to === actualTo && + conn.fromProperty === actualFromProperty && conn.toProperty === actualToProperty) || + (conn.from === actualTo && conn.to === actualFrom && + conn.fromProperty === actualToProperty && conn.toProperty === actualFromProperty) + ); + + if (existingConnection) { + showToast?.('该连接已存在', 'warning'); + clearConnecting(); + return; + } + + const toNode = nodes.find((n: BehaviorTreeNode) => n.id === actualTo); + if (toNode && actualToProperty) { + const targetProperty = toNode.template.properties.find( + (p: PropertyDefinition) => p.name === actualToProperty + ); + + if (!targetProperty?.allowMultipleConnections) { + const existingPropertyConnection = connections.find( + (conn: Connection) => + conn.connectionType === 'property' && + conn.to === actualTo && + conn.toProperty === actualToProperty + ); + + if (existingPropertyConnection) { + showToast?.('该属性已有连接,请先删除现有连接', 'warning'); + clearConnecting(); + return; + } + } + } + + try { + connectionOperations.addConnection( + actualFrom, + actualTo, + 'property', + actualFromProperty || undefined, + actualToProperty || undefined + ); + } catch (error) { + showToast?.(error instanceof Error ? error.message : '添加连接失败', 'error'); + clearConnecting(); + return; + } + } else { + if (actualFrom === ROOT_NODE_ID) { + const rootNode = nodes.find((n: BehaviorTreeNode) => n.id === ROOT_NODE_ID); + if (rootNode && rootNode.children.length > 0) { + showToast?.('根节点只能连接一个子节点', 'warning'); + clearConnecting(); + return; + } + } + + const existingConnection = connections.find( + (conn: Connection) => + (conn.from === actualFrom && conn.to === actualTo && conn.connectionType === 'node') || + (conn.from === actualTo && conn.to === actualFrom && conn.connectionType === 'node') + ); + + if (existingConnection) { + showToast?.('该连接已存在', 'warning'); + clearConnecting(); + return; + } + + try { + connectionOperations.addConnection(actualFrom, actualTo, 'node'); + + setTimeout(() => { + sortChildrenByPosition(); + }, 0); + } catch (error) { + showToast?.(error instanceof Error ? error.message : '添加连接失败', 'error'); + clearConnecting(); + return; + } + } + + clearConnecting(); + }; + + const handleNodeMouseUpForConnection = (e: React.MouseEvent, nodeId: string) => { + if (connectingFrom && connectingFrom !== nodeId) { + handlePortMouseUp(e, nodeId); + } + }; + + return { + handlePortMouseDown, + handlePortMouseUp, + handleNodeMouseUpForConnection + }; +} diff --git a/packages/editor-app/src/presentation/types/index.ts b/packages/editor-app/src/presentation/types/index.ts new file mode 100644 index 00000000..8e3a887f --- /dev/null +++ b/packages/editor-app/src/presentation/types/index.ts @@ -0,0 +1,121 @@ +import { Node } from '../../domain/models/Node'; +import { Connection } from '../../domain/models/Connection'; + +/** + * 节点执行状态 + */ +export type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure'; + +/** + * 执行模式 + */ +export type ExecutionMode = 'idle' | 'running' | 'paused' | 'step'; + +/** + * 执行日志条目 + */ +export interface ExecutionLog { + nodeId: string; + status: NodeExecutionStatus; + timestamp: number; + message?: string; +} + +/** + * 上下文菜单状态 + */ +export interface ContextMenuState { + visible: boolean; + position: { x: number; y: number }; + nodeId: string | null; +} + +/** + * 快速创建菜单状态 + */ +export interface QuickCreateMenuState { + visible: boolean; + position: { x: number; y: number }; + searchTerm: string; +} + +/** + * 画布坐标 + */ +export interface CanvasPoint { + x: number; + y: number; +} + +/** + * 选择区域 + */ +export interface SelectionBox { + start: CanvasPoint; + end: CanvasPoint; +} + +/** + * 节点视图数据(用于渲染) + */ +export interface NodeViewData { + node: Node; + isSelected: boolean; + isDragging: boolean; + executionStatus?: NodeExecutionStatus; +} + +/** + * 连接视图数据(用于渲染) + */ +export interface ConnectionViewData { + connection: Connection; + isSelected: boolean; +} + +/** + * 编辑器配置 + */ +export interface EditorConfig { + /** + * 是否启用网格吸附 + */ + enableSnapping: boolean; + + /** + * 网格大小 + */ + gridSize: number; + + /** + * 最小缩放 + */ + minZoom: number; + + /** + * 最大缩放 + */ + maxZoom: number; + + /** + * 是否显示网格 + */ + showGrid: boolean; + + /** + * 是否显示小地图 + */ + showMinimap: boolean; +} + +/** + * 默认编辑器配置 + */ +export const DEFAULT_EDITOR_CONFIG: EditorConfig = { + enableSnapping: true, + gridSize: 20, + minZoom: 0.1, + maxZoom: 3, + showGrid: true, + showMinimap: false +}; diff --git a/packages/editor-app/src/presentation/utils/DOMCache.ts b/packages/editor-app/src/presentation/utils/DOMCache.ts new file mode 100644 index 00000000..d5e85aaf --- /dev/null +++ b/packages/editor-app/src/presentation/utils/DOMCache.ts @@ -0,0 +1,125 @@ +type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure'; + +export class DOMCache { + private nodeElements: Map = new Map(); + private connectionElements: Map = new Map(); + private lastNodeStatus: Map = new Map(); + private statusTimers: Map = new Map(); + + getNode(nodeId: string): Element | undefined { + let element = this.nodeElements.get(nodeId); + if (!element) { + element = document.querySelector(`[data-node-id="${nodeId}"]`) || undefined; + if (element) { + this.nodeElements.set(nodeId, element); + } + } + return element; + } + + getConnection(connectionKey: string): Element | undefined { + let element = this.connectionElements.get(connectionKey); + if (!element) { + element = document.querySelector(`[data-connection-id="${connectionKey}"]`) || undefined; + if (element) { + this.connectionElements.set(connectionKey, element); + } + } + return element; + } + + getLastStatus(nodeId: string): NodeExecutionStatus | undefined { + return this.lastNodeStatus.get(nodeId); + } + + setLastStatus(nodeId: string, status: NodeExecutionStatus): void { + this.lastNodeStatus.set(nodeId, status); + } + + hasStatusChanged(nodeId: string, newStatus: NodeExecutionStatus): boolean { + return this.lastNodeStatus.get(nodeId) !== newStatus; + } + + getStatusTimer(nodeId: string): number | undefined { + return this.statusTimers.get(nodeId); + } + + setStatusTimer(nodeId: string, timerId: number): void { + this.statusTimers.set(nodeId, timerId); + } + + clearStatusTimer(nodeId: string): void { + const timerId = this.statusTimers.get(nodeId); + if (timerId) { + clearTimeout(timerId); + this.statusTimers.delete(nodeId); + } + } + + clearAllStatusTimers(): void { + this.statusTimers.forEach((timerId) => clearTimeout(timerId)); + this.statusTimers.clear(); + } + + clearNodeCache(): void { + this.nodeElements.clear(); + } + + clearConnectionCache(): void { + this.connectionElements.clear(); + } + + clearStatusCache(): void { + this.lastNodeStatus.clear(); + } + + clearAll(): void { + this.clearNodeCache(); + this.clearConnectionCache(); + this.clearStatusCache(); + this.clearAllStatusTimers(); + } + + removeNodeClasses(nodeId: string, ...classes: string[]): void { + const element = this.getNode(nodeId); + if (element) { + element.classList.remove(...classes); + } + } + + addNodeClasses(nodeId: string, ...classes: string[]): void { + const element = this.getNode(nodeId); + if (element) { + element.classList.add(...classes); + } + } + + hasNodeClass(nodeId: string, className: string): boolean { + const element = this.getNode(nodeId); + return element?.classList.contains(className) || false; + } + + setConnectionAttribute(connectionKey: string, attribute: string, value: string): void { + const element = this.getConnection(connectionKey); + if (element) { + element.setAttribute(attribute, value); + } + } + + getConnectionAttribute(connectionKey: string, attribute: string): string | null { + const element = this.getConnection(connectionKey); + return element?.getAttribute(attribute) || null; + } + + forEachNode(callback: (element: Element, nodeId: string) => void): void { + this.nodeElements.forEach((element, nodeId) => { + callback(element, nodeId); + }); + } + + forEachConnection(callback: (element: Element, connectionKey: string) => void): void { + this.connectionElements.forEach((element, connectionKey) => { + callback(element, connectionKey); + }); + } +} diff --git a/packages/editor-app/src/presentation/utils/portUtils.ts b/packages/editor-app/src/presentation/utils/portUtils.ts new file mode 100644 index 00000000..e3966e6f --- /dev/null +++ b/packages/editor-app/src/presentation/utils/portUtils.ts @@ -0,0 +1,44 @@ +import { RefObject } from 'react'; +import { BehaviorTreeNode } from '../../stores/behaviorTreeStore'; + +export function getPortPosition( + canvasRef: RefObject, + canvasOffset: { x: number; y: number }, + canvasScale: number, + nodes: BehaviorTreeNode[], + nodeId: string, + propertyName?: string, + portType: 'input' | 'output' = 'output' +): { x: number; y: number } | null { + const canvas = canvasRef.current; + if (!canvas) return null; + + let selector: string; + if (propertyName) { + selector = `[data-node-id="${nodeId}"][data-property="${propertyName}"]`; + } else { + const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId); + if (!node) return null; + + if (node.data.nodeType === 'blackboard-variable') { + selector = `[data-node-id="${nodeId}"][data-port-type="variable-output"]`; + } else { + if (portType === 'input') { + selector = `[data-node-id="${nodeId}"][data-port-type="node-input"]`; + } else { + selector = `[data-node-id="${nodeId}"][data-port-type="node-output"]`; + } + } + } + + const portElement = canvas.querySelector(selector) as HTMLElement; + if (!portElement) return null; + + const rect = portElement.getBoundingClientRect(); + const canvasRect = canvas.getBoundingClientRect(); + + const x = (rect.left + rect.width / 2 - canvasRect.left - canvasOffset.x) / canvasScale; + const y = (rect.top + rect.height / 2 - canvasRect.top - canvasOffset.y) / canvasScale; + + return { x, y }; +} diff --git a/packages/editor-app/src/stores/behaviorTreeStore.ts b/packages/editor-app/src/stores/behaviorTreeStore.ts index 44b12592..763ab78b 100644 --- a/packages/editor-app/src/stores/behaviorTreeStore.ts +++ b/packages/editor-app/src/stores/behaviorTreeStore.ts @@ -1,66 +1,51 @@ import { create } from 'zustand'; -import { NodeTemplate, NodeTemplates, EditorFormatConverter, BehaviorTreeAssetSerializer, NodeType } from '@esengine/behavior-tree'; - -interface BehaviorTreeNode { - id: string; - template: NodeTemplate; - data: Record; - position: { x: number; y: number }; - children: string[]; -} - -interface Connection { - from: string; - to: string; - fromProperty?: string; - toProperty?: string; - connectionType: 'node' | 'property'; -} +import { NodeTemplate, NodeTemplates, EditorFormatConverter, BehaviorTreeAssetSerializer } from '@esengine/behavior-tree'; +import { Node } from '../domain/models/Node'; +import { Connection } from '../domain/models/Connection'; +import { Blackboard, BlackboardValue } from '../domain/models/Blackboard'; +import { Position } from '../domain/value-objects/Position'; +import { createRootNode, ROOT_NODE_ID } from '../domain/constants/RootNode'; +/** + * 行为树 Store 状态接口 + */ interface BehaviorTreeState { - nodes: BehaviorTreeNode[]; + nodes: Node[]; connections: Connection[]; + blackboard: Blackboard; + blackboardVariables: Record; + initialBlackboardVariables: Record; selectedNodeIds: string[]; draggingNodeId: string | null; dragStartPositions: Map; isDraggingNode: boolean; - // 黑板变量 - blackboardVariables: Record; - // 初始黑板变量(设计时的值,用于保存) - initialBlackboardVariables: Record; - // 是否正在运行行为树 isExecuting: boolean; - // 画布变换 canvasOffset: { x: number; y: number }; canvasScale: number; isPanning: boolean; panStart: { x: number; y: number }; - // 连接状态 connectingFrom: string | null; connectingFromProperty: string | null; connectingToPos: { x: number; y: number } | null; - // 框选状态 isBoxSelecting: boolean; boxSelectStart: { x: number; y: number } | null; boxSelectEnd: { x: number; y: number } | null; - // 拖动偏移 dragDelta: { dx: number; dy: number }; - // 强制更新计数器 forceUpdateCounter: number; - // Actions - setNodes: (nodes: BehaviorTreeNode[]) => void; - updateNodes: (updater: (nodes: BehaviorTreeNode[]) => BehaviorTreeNode[]) => void; - addNode: (node: BehaviorTreeNode) => void; + setNodes: (nodes: Node[]) => void; + updateNodes: (updater: (nodes: Node[]) => Node[]) => void; + addNode: (node: Node) => void; removeNodes: (nodeIds: string[]) => void; updateNodePosition: (nodeId: string, position: { x: number; y: number }) => void; updateNodesPosition: (updates: Map) => void; + updateNodeData: (nodeId: string, data: Record) => void; setConnections: (connections: Connection[]) => void; addConnection: (connection: Connection) => void; @@ -74,149 +59,124 @@ interface BehaviorTreeState { stopDragging: () => void; setIsDraggingNode: (isDragging: boolean) => void; - // 画布变换 Actions setCanvasOffset: (offset: { x: number; y: number }) => void; setCanvasScale: (scale: number) => void; setIsPanning: (isPanning: boolean) => void; setPanStart: (panStart: { x: number; y: number }) => void; resetView: () => void; - // 连接 Actions setConnectingFrom: (nodeId: string | null) => void; setConnectingFromProperty: (propertyName: string | null) => void; setConnectingToPos: (pos: { x: number; y: number } | null) => void; clearConnecting: () => void; - // 框选 Actions setIsBoxSelecting: (isSelecting: boolean) => void; setBoxSelectStart: (pos: { x: number; y: number } | null) => void; setBoxSelectEnd: (pos: { x: number; y: number } | null) => void; clearBoxSelect: () => void; - // 拖动偏移 Actions setDragDelta: (delta: { dx: number; dy: number }) => void; - // 强制更新 triggerForceUpdate: () => void; - // 黑板变量 Actions - setBlackboardVariables: (variables: Record) => void; - updateBlackboardVariable: (name: string, value: any) => void; - setInitialBlackboardVariables: (variables: Record) => void; + setBlackboard: (blackboard: Blackboard) => void; + updateBlackboardVariable: (name: string, value: BlackboardValue) => void; + setBlackboardVariables: (variables: Record) => void; + setInitialBlackboardVariables: (variables: Record) => void; setIsExecuting: (isExecuting: boolean) => void; - // 自动排序子节点 sortChildrenByPosition: () => void; - // 数据导出/导入 - exportToJSON: (metadata: { name: string; description: string }, blackboard: Record) => string; - importFromJSON: (json: string) => { blackboard: Record }; + exportToJSON: (metadata: { name: string; description: string }) => string; + importFromJSON: (json: string) => void; - // 运行时资产导出 exportToRuntimeAsset: ( metadata: { name: string; description: string }, - blackboard: Record, format: 'json' | 'binary' ) => string | Uint8Array; - // 重置所有状态 reset: () => void; } -const ROOT_NODE_ID = 'root-node'; - -// 创建根节点模板 -const createRootNodeTemplate = (): NodeTemplate => ({ - type: NodeType.Composite, - displayName: '根节点', - category: '根节点', - icon: 'TreePine', - description: '行为树根节点', - color: '#FFD700', - defaultConfig: { - nodeType: 'root' - }, - properties: [] -}); - -// 创建初始根节点 -const createInitialRootNode = (): BehaviorTreeNode => ({ - id: ROOT_NODE_ID, - template: createRootNodeTemplate(), - data: { nodeType: 'root' }, - position: { x: 400, y: 100 }, - children: [] -}); +/** + * 行为树 Store + */ export const useBehaviorTreeStore = create((set, get) => ({ - nodes: [createInitialRootNode()], + nodes: [createRootNode()], connections: [], + blackboard: new Blackboard(), + blackboardVariables: {}, + initialBlackboardVariables: {}, selectedNodeIds: [], draggingNodeId: null, dragStartPositions: new Map(), isDraggingNode: false, - // 黑板变量初始值 - blackboardVariables: {}, - initialBlackboardVariables: {}, isExecuting: false, - // 画布变换初始值 canvasOffset: { x: 0, y: 0 }, canvasScale: 1, isPanning: false, panStart: { x: 0, y: 0 }, - // 连接状态初始值 connectingFrom: null, connectingFromProperty: null, connectingToPos: null, - // 框选状态初始值 isBoxSelecting: false, boxSelectStart: null, boxSelectEnd: null, - // 拖动偏移初始值 dragDelta: { dx: 0, dy: 0 }, - // 强制更新计数器初始值 forceUpdateCounter: 0, - setNodes: (nodes: BehaviorTreeNode[]) => set({ nodes }), + setNodes: (nodes: Node[]) => set({ nodes }), - updateNodes: (updater: (nodes: BehaviorTreeNode[]) => BehaviorTreeNode[]) => set((state: BehaviorTreeState) => ({ nodes: updater(state.nodes) })), + updateNodes: (updater: (nodes: Node[]) => Node[]) => set((state: BehaviorTreeState) => ({ + nodes: updater(state.nodes) + })), - addNode: (node: BehaviorTreeNode) => set((state: BehaviorTreeState) => ({ nodes: [...state.nodes, node] })), + addNode: (node: Node) => set((state: BehaviorTreeState) => ({ + nodes: [...state.nodes, node] + })), removeNodes: (nodeIds: string[]) => set((state: BehaviorTreeState) => { - // 只删除指定的节点,不删除子节点 const nodesToDelete = new Set(nodeIds); - // 过滤掉删除的节点,并清理所有节点的 children 引用 const remainingNodes = state.nodes - .filter((n: BehaviorTreeNode) => !nodesToDelete.has(n.id)) - .map((n: BehaviorTreeNode) => ({ - ...n, - children: n.children.filter((childId: string) => !nodesToDelete.has(childId)) - })); + .filter((n: Node) => !nodesToDelete.has(n.id)) + .map((n: Node) => { + const newChildren = Array.from(n.children).filter((childId) => !nodesToDelete.has(childId)); + if (newChildren.length !== n.children.length) { + return new Node(n.id, n.template, n.data, n.position, newChildren); + } + return n; + }); return { nodes: remainingNodes }; }), updateNodePosition: (nodeId: string, position: { x: number; y: number }) => set((state: BehaviorTreeState) => ({ - nodes: state.nodes.map((n: BehaviorTreeNode) => - n.id === nodeId ? { ...n, position } : n + nodes: state.nodes.map((n: Node) => + n.id === nodeId ? new Node(n.id, n.template, n.data, new Position(position.x, position.y), Array.from(n.children)) : n ) })), updateNodesPosition: (updates: Map) => set((state: BehaviorTreeState) => ({ - nodes: state.nodes.map((node: BehaviorTreeNode) => { + nodes: state.nodes.map((node: Node) => { const newPos = updates.get(node.id); - return newPos ? { ...node, position: newPos } : node; + return newPos ? new Node(node.id, node.template, node.data, new Position(newPos.x, newPos.y), Array.from(node.children)) : node; }) })), + updateNodeData: (nodeId: string, data: Record) => set((state: BehaviorTreeState) => ({ + nodes: state.nodes.map((n: Node) => + n.id === nodeId ? new Node(n.id, n.template, data, n.position, Array.from(n.children)) : n + ) + })), + setConnections: (connections: Connection[]) => set({ connections }), addConnection: (connection: Connection) => set((state: BehaviorTreeState) => ({ @@ -246,7 +206,6 @@ export const useBehaviorTreeStore = create((set, get) => ({ setIsDraggingNode: (isDragging: boolean) => set({ isDraggingNode: isDragging }), - // 画布变换 Actions setCanvasOffset: (offset: { x: number; y: number }) => set({ canvasOffset: offset }), setCanvasScale: (scale: number) => set({ canvasScale: scale }), @@ -257,7 +216,6 @@ export const useBehaviorTreeStore = create((set, get) => ({ resetView: () => set({ canvasOffset: { x: 0, y: 0 }, canvasScale: 1 }), - // 连接 Actions setConnectingFrom: (nodeId: string | null) => set({ connectingFrom: nodeId }), setConnectingFromProperty: (propertyName: string | null) => set({ connectingFromProperty: propertyName }), @@ -270,7 +228,6 @@ export const useBehaviorTreeStore = create((set, get) => ({ connectingToPos: null }), - // 框选 Actions setIsBoxSelecting: (isSelecting: boolean) => set({ isBoxSelecting: isSelecting }), setBoxSelectStart: (pos: { x: number; y: number } | null) => set({ boxSelectStart: pos }), @@ -283,29 +240,40 @@ export const useBehaviorTreeStore = create((set, get) => ({ boxSelectEnd: null }), - // 拖动偏移 Actions setDragDelta: (delta: { dx: number; dy: number }) => set({ dragDelta: delta }), - // 强制更新 triggerForceUpdate: () => set((state: BehaviorTreeState) => ({ forceUpdateCounter: state.forceUpdateCounter + 1 })), - // 黑板变量 Actions - setBlackboardVariables: (variables: Record) => set({ blackboardVariables: variables }), + setBlackboard: (blackboard: Blackboard) => set({ + blackboard, + blackboardVariables: blackboard.toObject() + }), - updateBlackboardVariable: (name: string, value: any) => set((state: BehaviorTreeState) => ({ - blackboardVariables: { - ...state.blackboardVariables, - [name]: value - } - })), + updateBlackboardVariable: (name: string, value: BlackboardValue) => set((state: BehaviorTreeState) => { + const newBlackboard = Blackboard.fromObject(state.blackboard.toObject()); + newBlackboard.setValue(name, value); + return { + blackboard: newBlackboard, + blackboardVariables: newBlackboard.toObject() + }; + }), - setInitialBlackboardVariables: (variables: Record) => set({ initialBlackboardVariables: variables }), + setBlackboardVariables: (variables: Record) => set(() => { + const newBlackboard = Blackboard.fromObject(variables); + return { + blackboard: newBlackboard, + blackboardVariables: variables + }; + }), + + setInitialBlackboardVariables: (variables: Record) => set({ + initialBlackboardVariables: variables + }), setIsExecuting: (isExecuting: boolean) => set({ isExecuting }), - // 自动排序子节点(按X坐标从左到右) sortChildrenByPosition: () => set((state: BehaviorTreeState) => { - const nodeMap = new Map(); + const nodeMap = new Map(); state.nodes.forEach((node) => nodeMap.set(node.id, node)); const sortedNodes = state.nodes.map((node) => { @@ -313,20 +281,20 @@ export const useBehaviorTreeStore = create((set, get) => ({ return node; } - const sortedChildren = [...node.children].sort((a, b) => { + const sortedChildren = Array.from(node.children).sort((a, b) => { const nodeA = nodeMap.get(a); const nodeB = nodeMap.get(b); if (!nodeA || !nodeB) return 0; return nodeA.position.x - nodeB.position.x; }); - return { ...node, children: sortedChildren }; + return new Node(node.id, node.template, node.data, node.position, sortedChildren); }); return { nodes: sortedNodes }; }), - exportToJSON: (metadata: { name: string; description: string }, blackboard: Record) => { + exportToJSON: (metadata: { name: string; description: string }) => { const state = get(); const now = new Date().toISOString(); const data = { @@ -337,9 +305,9 @@ export const useBehaviorTreeStore = create((set, get) => ({ createdAt: now, modifiedAt: now }, - nodes: state.nodes, - connections: state.connections, - blackboard: blackboard, + nodes: state.nodes.map((n) => n.toObject()), + connections: state.connections.map((c) => c.toObject()), + blackboard: state.blackboard.toObject(), canvasState: { offset: state.canvasOffset, scale: state.canvasScale @@ -350,54 +318,57 @@ export const useBehaviorTreeStore = create((set, get) => ({ importFromJSON: (json: string) => { const data = JSON.parse(json); - const blackboard = data.blackboard || {}; + const blackboardData = data.blackboard || {}; - // 重新关联最新模板:根据 className 从模板库查找 - const loadedNodes: BehaviorTreeNode[] = (data.nodes || []).map((node: any) => { - // 如果是根节点,使用根节点模板 - if (node.id === ROOT_NODE_ID) { - return { - ...node, - template: createRootNodeTemplate() - }; + const loadedNodes: Node[] = (data.nodes || []).map((nodeObj: any) => { + if (nodeObj.id === ROOT_NODE_ID) { + return createRootNode(); } - // 查找最新模板 - const className = node.template?.className; + const className = nodeObj.template?.className; + let template = nodeObj.template; + if (className) { const allTemplates = NodeTemplates.getAllTemplates(); const latestTemplate = allTemplates.find((t) => t.className === className); - if (latestTemplate) { - return { - ...node, - template: latestTemplate // 使用最新模板 - }; + template = latestTemplate; } } - // 如果找不到,保留旧模板(兼容性) - return node; + const position = new Position(nodeObj.position.x, nodeObj.position.y); + return new Node(nodeObj.id, template, nodeObj.data, position, nodeObj.children || []); }); + const loadedConnections: Connection[] = (data.connections || []).map((connObj: any) => { + return new Connection( + connObj.from, + connObj.to, + connObj.connectionType || 'node', + connObj.fromProperty, + connObj.toProperty + ); + }); + + const loadedBlackboard = Blackboard.fromObject(blackboardData); + set({ nodes: loadedNodes, - connections: data.connections || [], - blackboardVariables: blackboard, + connections: loadedConnections, + blackboard: loadedBlackboard, + blackboardVariables: blackboardData, + initialBlackboardVariables: blackboardData, canvasOffset: data.canvasState?.offset || { x: 0, y: 0 }, canvasScale: data.canvasState?.scale || 1 }); - return { blackboard }; }, exportToRuntimeAsset: ( metadata: { name: string; description: string }, - blackboard: Record, format: 'json' | 'binary' ) => { const state = get(); - // 构建编辑器格式数据 const editorFormat = { version: '1.0.0', metadata: { @@ -406,15 +377,13 @@ export const useBehaviorTreeStore = create((set, get) => ({ createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString() }, - nodes: state.nodes, - connections: state.connections, - blackboard: blackboard + nodes: state.nodes.map((n) => n.toObject()), + connections: state.connections.map((c) => c.toObject()), + blackboard: state.blackboard.toObject() }; - // 转换为资产格式 const asset = EditorFormatConverter.toAsset(editorFormat, metadata); - // 序列化为指定格式 return BehaviorTreeAssetSerializer.serialize(asset, { format, pretty: format === 'json', @@ -423,14 +392,15 @@ export const useBehaviorTreeStore = create((set, get) => ({ }, reset: () => set({ - nodes: [createInitialRootNode()], + nodes: [createRootNode()], connections: [], + blackboard: new Blackboard(), + blackboardVariables: {}, + initialBlackboardVariables: {}, selectedNodeIds: [], draggingNodeId: null, dragStartPositions: new Map(), isDraggingNode: false, - blackboardVariables: {}, - initialBlackboardVariables: {}, isExecuting: false, canvasOffset: { x: 0, y: 0 }, canvasScale: 1, @@ -447,5 +417,6 @@ export const useBehaviorTreeStore = create((set, get) => ({ }) })); -export type { BehaviorTreeNode, Connection }; export { ROOT_NODE_ID }; +export type { Node as BehaviorTreeNode }; +export type { Connection }; diff --git a/packages/editor-app/src/utils/BehaviorTreeExecutor.ts b/packages/editor-app/src/utils/BehaviorTreeExecutor.ts index aedbf7d8..fe4f159d 100644 --- a/packages/editor-app/src/utils/BehaviorTreeExecutor.ts +++ b/packages/editor-app/src/utils/BehaviorTreeExecutor.ts @@ -78,8 +78,7 @@ export class BehaviorTreeExecutor { rootNodeId: string, blackboard: Record, connections: Array<{ from: string; to: string; fromProperty?: string; toProperty?: string; connectionType: 'node' | 'property' }>, - callback: ExecutionCallback, - projectPath?: string | null + callback: ExecutionCallback ): void { this.cleanup(); this.callback = callback; @@ -151,9 +150,9 @@ export class BehaviorTreeExecutor { id: node.id, name: node.template.displayName, nodeType: this.convertNodeType(node.template.type), - implementationType: node.template.className || this.getImplementationType(node.template.displayName, node.template.type), + implementationType: node.template.className || this.getImplementationType(node.template.displayName), config: { ...node.data }, - children: node.children + children: Array.from(node.children) }; treeData.nodes.set(node.id, nodeData); @@ -206,7 +205,7 @@ export class BehaviorTreeExecutor { /** * 根据显示名称获取实现类型 */ - private getImplementationType(displayName: string, nodeType: string): string { + private getImplementationType(displayName: string): string { const typeMap: Record = { '序列': 'Sequence', '选择': 'Selector',