Refactor/clean architecture phase1 (#215)

* refactor(editor): 建立Clean Architecture领域模型层

* refactor(editor): 实现应用层架构 - 命令模式、用例和状态管理

* refactor(editor): 实现展示层核心Hooks

* refactor(editor): 实现基础设施层和展示层组件

* refactor(editor): 迁移画布和连接渲染到 Clean Architecture 组件

* feat(editor): 集成应用层架构和命令模式,实现撤销/重做功能

* refactor(editor): UI组件拆分

* refactor(editor): 提取快速创建菜单逻辑

* refactor(editor): 重构BehaviorTreeEditor,提取组件和Hook

* refactor(editor): 提取端口连接和键盘事件Hook

* refactor(editor): 提取拖放处理Hook

* refactor(editor): 提取画布交互Hook和工具函数

* refactor(editor): 完成核心重构

* fix(editor): 修复节点无法创建和连接

* refactor(behavior-tree,editor): 重构节点子节点约束系统,实现元数据驱动的架构
This commit is contained in:
YHH
2025-11-03 21:22:16 +08:00
committed by GitHub
parent 40cde9c050
commit adfc7e91b3
104 changed files with 8232 additions and 2506 deletions

View File

@@ -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} 不支持合并操作`);
}
}

View File

@@ -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<CommandManagerConfig>;
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('批量命令不支持合并');
}
}

View File

@@ -0,0 +1,31 @@
/**
* 命令接口
* 实现命令模式,支持撤销/重做功能
*/
export interface ICommand {
/**
* 执行命令
*/
execute(): void;
/**
* 撤销命令
*/
undo(): void;
/**
* 获取命令描述(用于显示历史记录)
*/
getDescription(): string;
/**
* 检查命令是否可以合并
* 用于优化撤销/重做历史,例如连续的移动操作可以合并为一个
*/
canMergeWith(other: ICommand): boolean;
/**
* 与另一个命令合并
*/
mergeWith(other: ICommand): ICommand;
}

View File

@@ -0,0 +1,17 @@
import { BehaviorTree } from '../../domain/models/BehaviorTree';
/**
* 行为树状态接口
* 命令通过此接口操作状态
*/
export interface ITreeState {
/**
* 获取当前行为树
*/
getTree(): BehaviorTree;
/**
* 设置行为树
*/
setTree(tree: BehaviorTree): void;
}

View File

@@ -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';

View File

@@ -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}`;
}
}

View File

@@ -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}`;
}
}

View File

@@ -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}`;
}
}

View File

@@ -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;
}
}

View File

@@ -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}`;
}
}

View File

@@ -0,0 +1,40 @@
import { BaseCommand } from '../BaseCommand';
import { ITreeState } from '../ITreeState';
/**
* 更新节点数据命令
*/
export class UpdateNodeDataCommand extends BaseCommand {
private oldData: Record<string, unknown>;
constructor(
private readonly state: ITreeState,
private readonly nodeId: string,
private readonly newData: Record<string, unknown>
) {
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}`;
}
}

View File

@@ -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';

View File

@@ -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<ContextMenuState>({
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
};
}

View File

@@ -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<typeof useNodeOperations>;
connectionOperations: ReturnType<typeof useConnectionOperations>;
canvasRef: RefObject<HTMLDivElement>;
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<QuickCreateMenuState>({
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
};
}

View File

@@ -0,0 +1,3 @@
export * from './commands';
export * from './use-cases';
export * from './state';

View File

@@ -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<T = PropertyValue> {
propertyName: string;
propertyType: string;
value: T;
onChange: (value: T) => void;
config?: Record<string, PropertyValue>;
}
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<void>;
}
export interface CommandExecutionContext {
selectedNodeIds: string[];
nodes: BehaviorTreeNode[];
currentFile?: string;
}
export class EditorExtensionRegistry {
private nodeRenderers: Set<INodeRenderer> = new Set();
private propertyEditors: Set<IPropertyEditor> = new Set();
private nodeProviders: Set<INodeProvider> = new Set();
private toolbarButtons: Set<IToolbarButton> = new Set();
private panelProviders: Set<IPanelProvider> = new Set();
private validators: Set<IValidator> = new Set();
private commandProviders: Set<ICommandProvider> = 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<ValidationResult[]> {
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;
}

View File

@@ -0,0 +1,249 @@
import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore';
import { ExecutionLog } from '../../utils/BehaviorTreeExecutor';
import { BlackboardValue } from '../../domain/models/Blackboard';
type BlackboardVariables = Record<string, BlackboardValue>;
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<void>;
afterPlay?(context: ExecutionContext): void | Promise<void>;
beforePause?(): void | Promise<void>;
afterPause?(): void | Promise<void>;
beforeResume?(): void | Promise<void>;
afterResume?(): void | Promise<void>;
beforeStop?(): void | Promise<void>;
afterStop?(): void | Promise<void>;
beforeStep?(deltaTime: number): void | Promise<void>;
afterStep?(deltaTime: number): void | Promise<void>;
onTick?(tickCount: number, deltaTime: number): void | Promise<void>;
onNodeStatusChange?(event: NodeStatusChangeEvent): void | Promise<void>;
onExecutionComplete?(logs: ExecutionLog[]): void | Promise<void>;
onBlackboardUpdate?(variables: BlackboardVariables): void | Promise<void>;
onError?(error: Error, context?: string): void | Promise<void>;
}
export class ExecutionHooksManager {
private hooks: Set<IExecutionHooks> = 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
for (const hook of this.hooks) {
if (hook.onError) {
try {
await hook.onError(error, context);
} catch (err) {
console.error('Error in onError hook:', err);
}
}
}
}
}

View File

@@ -0,0 +1,42 @@
import { BlackboardValue } from '../../domain/models/Blackboard';
type BlackboardVariables = Record<string, BlackboardValue>;
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 = {};
}
}

View File

@@ -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<string, BlackboardValue>;
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<void> {
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<void> {
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<void> {
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<void> {
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<string, NodeExecutionStatus> = {};
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<string, NodeExecutionStatus>,
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<string, NodeExecutionStatus> = {};
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);
}
}
}

View File

@@ -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<BehaviorTreeDataState>((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);
}
}

View File

@@ -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<EditorState>((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
})
}));

View File

@@ -0,0 +1,131 @@
import { create } from 'zustand';
/**
* UI 状态
* 管理UI相关的状态选中、拖拽、画布
*/
interface UIState {
/**
* 选中的节点ID列表
*/
selectedNodeIds: string[];
/**
* 正在拖拽的节点ID
*/
draggingNodeId: string | null;
/**
* 拖拽起始位置映射
*/
dragStartPositions: Map<string, { x: number; y: number }>;
/**
* 是否正在拖拽节点
*/
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<string, { x: number; y: number }>) => 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<UIState>((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<string, { x: number; y: number }>) =>
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
})
}));

View File

@@ -0,0 +1,3 @@
export { useBehaviorTreeDataStore, TreeStateAdapter } from './BehaviorTreeDataStore';
export { useUIStore } from './UIStore';
export { useEditorStore } from './EditorStore';

View File

@@ -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;
}
}

View File

@@ -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<string, unknown>): 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<string, unknown>): Node {
const node = this.nodeFactory.createNodeByType(nodeType, position, data);
const command = new CreateNodeCommand(this.treeState, node);
this.commandManager.execute(command);
return node;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<string, unknown>): void {
const command = new UpdateNodeDataCommand(this.treeState, nodeId, data);
this.commandManager.execute(command);
}
}

View File

@@ -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}`);
}
}
}

View File

@@ -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';