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:
@@ -12,7 +12,11 @@ import { NodeExecutorMetadata } from '../NodeMetadata';
|
|||||||
nodeType: NodeType.Decorator,
|
nodeType: NodeType.Decorator,
|
||||||
displayName: '反转',
|
displayName: '反转',
|
||||||
description: '反转子节点的执行结果',
|
description: '反转子节点的执行结果',
|
||||||
category: 'Decorator'
|
category: 'Decorator',
|
||||||
|
childrenConstraints: {
|
||||||
|
min: 1,
|
||||||
|
max: 1
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export class InverterExecutor implements INodeExecutor {
|
export class InverterExecutor implements INodeExecutor {
|
||||||
execute(context: NodeExecutionContext): TaskStatus {
|
execute(context: NodeExecutionContext): TaskStatus {
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import { NodeExecutorMetadata } from '../NodeMetadata';
|
|||||||
description: '失败策略',
|
description: '失败策略',
|
||||||
options: ['all', 'one']
|
options: ['all', 'one']
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
childrenConstraints: {
|
||||||
|
min: 2
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class ParallelExecutor implements INodeExecutor {
|
export class ParallelExecutor implements INodeExecutor {
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { NodeExecutorMetadata } from '../NodeMetadata';
|
|||||||
nodeType: NodeType.Composite,
|
nodeType: NodeType.Composite,
|
||||||
displayName: '随机序列',
|
displayName: '随机序列',
|
||||||
description: '随机顺序执行子节点,全部成功才成功',
|
description: '随机顺序执行子节点,全部成功才成功',
|
||||||
category: 'Composite'
|
category: 'Composite',
|
||||||
|
childrenConstraints: {
|
||||||
|
min: 1
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export class RandomSequenceExecutor implements INodeExecutor {
|
export class RandomSequenceExecutor implements INodeExecutor {
|
||||||
execute(context: NodeExecutionContext): TaskStatus {
|
execute(context: NodeExecutionContext): TaskStatus {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import { NodeExecutorMetadata } from '../NodeMetadata';
|
|||||||
default: false,
|
default: false,
|
||||||
description: '子节点失败时是否结束'
|
description: '子节点失败时是否结束'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
childrenConstraints: {
|
||||||
|
min: 1,
|
||||||
|
max: 1
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class RepeaterExecutor implements INodeExecutor {
|
export class RepeaterExecutor implements INodeExecutor {
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { NodeExecutorMetadata } from '../NodeMetadata';
|
|||||||
nodeType: NodeType.Composite,
|
nodeType: NodeType.Composite,
|
||||||
displayName: '选择器',
|
displayName: '选择器',
|
||||||
description: '按顺序执行子节点,任一成功则成功',
|
description: '按顺序执行子节点,任一成功则成功',
|
||||||
category: 'Composite'
|
category: 'Composite',
|
||||||
|
childrenConstraints: {
|
||||||
|
min: 1
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export class SelectorExecutor implements INodeExecutor {
|
export class SelectorExecutor implements INodeExecutor {
|
||||||
execute(context: NodeExecutionContext): TaskStatus {
|
execute(context: NodeExecutionContext): TaskStatus {
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { NodeExecutorMetadata } from '../NodeMetadata';
|
|||||||
nodeType: NodeType.Composite,
|
nodeType: NodeType.Composite,
|
||||||
displayName: '序列',
|
displayName: '序列',
|
||||||
description: '按顺序执行子节点,全部成功才成功',
|
description: '按顺序执行子节点,全部成功才成功',
|
||||||
category: 'Composite'
|
category: 'Composite',
|
||||||
|
childrenConstraints: {
|
||||||
|
min: 1
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export class SequenceExecutor implements INodeExecutor {
|
export class SequenceExecutor implements INodeExecutor {
|
||||||
execute(context: NodeExecutionContext): TaskStatus {
|
execute(context: NodeExecutionContext): TaskStatus {
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ export interface ConfigFieldDefinition {
|
|||||||
allowMultipleConnections?: boolean;
|
allowMultipleConnections?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子节点约束配置
|
||||||
|
*/
|
||||||
|
export interface ChildrenConstraints {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节点元数据
|
* 节点元数据
|
||||||
*/
|
*/
|
||||||
@@ -24,6 +33,26 @@ export interface NodeMetadata {
|
|||||||
description?: string;
|
description?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
configSchema?: Record<string, ConfigFieldDefinition>;
|
configSchema?: Record<string, ConfigFieldDefinition>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NodeType } from '../Types/TaskStatus';
|
import { NodeType } from '../Types/TaskStatus';
|
||||||
import { NodeMetadataRegistry, ConfigFieldDefinition } from '../Runtime/NodeMetadata';
|
import { NodeMetadataRegistry, ConfigFieldDefinition, NodeMetadata } from '../Runtime/NodeMetadata';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节点数据JSON格式
|
* 节点数据JSON格式
|
||||||
@@ -48,7 +48,7 @@ export const PropertyType = {
|
|||||||
* type: 'curve-editor'
|
* 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;
|
message?: string;
|
||||||
/** 自定义验证函数 */
|
/** 自定义验证函数 */
|
||||||
validator?: string; // 函数字符串,编辑器会解析
|
validator?: string; // 函数字符串,编辑器会解析
|
||||||
/** 最小长度(字符串) */
|
/** 最小长度(字符串) */
|
||||||
minLength?: number;
|
minLength?: number;
|
||||||
/** 最大长度(字符串) */
|
/** 最大长度(字符串) */
|
||||||
@@ -141,6 +141,8 @@ export interface NodeTemplate {
|
|||||||
className?: string;
|
className?: string;
|
||||||
componentClass?: Function;
|
componentClass?: Function;
|
||||||
requiresChildren?: boolean;
|
requiresChildren?: boolean;
|
||||||
|
minChildren?: number;
|
||||||
|
maxChildren?: number;
|
||||||
defaultConfig: Partial<NodeDataJSON>;
|
defaultConfig: Partial<NodeDataJSON>;
|
||||||
properties: PropertyDefinition[];
|
properties: PropertyDefinition[];
|
||||||
}
|
}
|
||||||
@@ -183,7 +185,7 @@ export class NodeTemplates {
|
|||||||
/**
|
/**
|
||||||
* 将NodeMetadata转换为NodeTemplate
|
* 将NodeMetadata转换为NodeTemplate
|
||||||
*/
|
*/
|
||||||
private static convertMetadataToTemplate(metadata: any): NodeTemplate {
|
private static convertMetadataToTemplate(metadata: NodeMetadata): NodeTemplate {
|
||||||
const properties = this.convertConfigSchemaToProperties(metadata.configSchema || {});
|
const properties = this.convertConfigSchemaToProperties(metadata.configSchema || {});
|
||||||
|
|
||||||
const defaultConfig: Partial<NodeDataJSON> = {
|
const defaultConfig: Partial<NodeDataJSON> = {
|
||||||
@@ -217,7 +219,10 @@ export class NodeTemplates {
|
|||||||
// 根据节点类型生成默认颜色和图标
|
// 根据节点类型生成默认颜色和图标
|
||||||
const { icon, color } = this.getIconAndColorByType(metadata.nodeType, metadata.category || '');
|
const { icon, color } = this.getIconAndColorByType(metadata.nodeType, metadata.category || '');
|
||||||
|
|
||||||
return {
|
// 应用子节点约束
|
||||||
|
const constraints = metadata.childrenConstraints || this.getDefaultConstraintsByNodeType(metadata.nodeType);
|
||||||
|
|
||||||
|
const template: NodeTemplate = {
|
||||||
type: metadata.nodeType,
|
type: metadata.nodeType,
|
||||||
displayName: metadata.displayName,
|
displayName: metadata.displayName,
|
||||||
category: metadata.category || this.getCategoryByNodeType(metadata.nodeType),
|
category: metadata.category || this.getCategoryByNodeType(metadata.nodeType),
|
||||||
@@ -228,6 +233,35 @@ export class NodeTemplates {
|
|||||||
defaultConfig,
|
defaultConfig,
|
||||||
properties
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -75,15 +75,15 @@ function App() {
|
|||||||
const [isProfilerMode, setIsProfilerMode] = useState(false);
|
const [isProfilerMode, setIsProfilerMode] = useState(false);
|
||||||
const [errorDialog, setErrorDialog] = useState<{ title: string; message: string } | null>(null);
|
const [errorDialog, setErrorDialog] = useState<{ title: string; message: string } | null>(null);
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
confirmText: string;
|
confirmText: string;
|
||||||
cancelText: string;
|
cancelText: string;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 禁用默认右键菜单
|
// 禁用默认右键菜单
|
||||||
const handleContextMenu = (e: MouseEvent) => {
|
const handleContextMenu = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
@@ -267,7 +267,7 @@ function App() {
|
|||||||
} else {
|
} else {
|
||||||
await sceneManagerService.newScene();
|
await sceneManagerService.newScene();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
await sceneManagerService.newScene();
|
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 () => {
|
const handleCloseProject = async () => {
|
||||||
if (pluginManager) {
|
if (pluginManager) {
|
||||||
await pluginLoaderRef.current.unloadProjectPlugins(pluginManager);
|
await pluginLoaderRef.current.unloadProjectPlugins(pluginManager);
|
||||||
|
|||||||
25
packages/editor-app/src/application/commands/BaseCommand.ts
Normal file
25
packages/editor-app/src/application/commands/BaseCommand.ts
Normal 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} 不支持合并操作`);
|
||||||
|
}
|
||||||
|
}
|
||||||
203
packages/editor-app/src/application/commands/CommandManager.ts
Normal file
203
packages/editor-app/src/application/commands/CommandManager.ts
Normal 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('批量命令不支持合并');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
packages/editor-app/src/application/commands/ICommand.ts
Normal file
31
packages/editor-app/src/application/commands/ICommand.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 命令接口
|
||||||
|
* 实现命令模式,支持撤销/重做功能
|
||||||
|
*/
|
||||||
|
export interface ICommand {
|
||||||
|
/**
|
||||||
|
* 执行命令
|
||||||
|
*/
|
||||||
|
execute(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销命令
|
||||||
|
*/
|
||||||
|
undo(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取命令描述(用于显示历史记录)
|
||||||
|
*/
|
||||||
|
getDescription(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查命令是否可以合并
|
||||||
|
* 用于优化撤销/重做历史,例如连续的移动操作可以合并为一个
|
||||||
|
*/
|
||||||
|
canMergeWith(other: ICommand): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与另一个命令合并
|
||||||
|
*/
|
||||||
|
mergeWith(other: ICommand): ICommand;
|
||||||
|
}
|
||||||
17
packages/editor-app/src/application/commands/ITreeState.ts
Normal file
17
packages/editor-app/src/application/commands/ITreeState.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { BehaviorTree } from '../../domain/models/BehaviorTree';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行为树状态接口
|
||||||
|
* 命令通过此接口操作状态
|
||||||
|
*/
|
||||||
|
export interface ITreeState {
|
||||||
|
/**
|
||||||
|
* 获取当前行为树
|
||||||
|
*/
|
||||||
|
getTree(): BehaviorTree;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置行为树
|
||||||
|
*/
|
||||||
|
setTree(tree: BehaviorTree): void;
|
||||||
|
}
|
||||||
5
packages/editor-app/src/application/commands/index.ts
Normal file
5
packages/editor-app/src/application/commands/index.ts
Normal 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';
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
43
packages/editor-app/src/application/hooks/useContextMenu.ts
Normal file
43
packages/editor-app/src/application/hooks/useContextMenu.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
210
packages/editor-app/src/application/hooks/useQuickCreateMenu.ts
Normal file
210
packages/editor-app/src/application/hooks/useQuickCreateMenu.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
3
packages/editor-app/src/application/index.ts
Normal file
3
packages/editor-app/src/application/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './commands';
|
||||||
|
export * from './use-cases';
|
||||||
|
export * from './state';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
packages/editor-app/src/application/state/EditorStore.ts
Normal file
88
packages/editor-app/src/application/state/EditorStore.ts
Normal 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
|
||||||
|
})
|
||||||
|
}));
|
||||||
131
packages/editor-app/src/application/state/UIStore.ts
Normal file
131
packages/editor-app/src/application/state/UIStore.ts
Normal 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
|
||||||
|
})
|
||||||
|
}));
|
||||||
3
packages/editor-app/src/application/state/index.ts
Normal file
3
packages/editor-app/src/application/state/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { useBehaviorTreeDataStore, TreeStateAdapter } from './BehaviorTreeDataStore';
|
||||||
|
export { useUIStore } from './UIStore';
|
||||||
|
export { useEditorStore } from './EditorStore';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/editor-app/src/application/use-cases/index.ts
Normal file
7
packages/editor-app/src/application/use-cases/index.ts
Normal 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';
|
||||||
@@ -86,7 +86,7 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
|
|||||||
} else {
|
} else {
|
||||||
setUpdateStatus('latest');
|
setUpdateStatus('latest');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error('Check update failed:', error);
|
console.error('Check update failed:', error);
|
||||||
setUpdateStatus('error');
|
setUpdateStatus('error');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -375,11 +375,7 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
|||||||
const saveToFile = async (filePath: string) => {
|
const saveToFile = async (filePath: string) => {
|
||||||
try {
|
try {
|
||||||
// 使用初始黑板变量(设计时的值)而不是运行时的值
|
// 使用初始黑板变量(设计时的值)而不是运行时的值
|
||||||
const varsToSave = isExecuting ? initialBlackboardVariables : blackboardVariables;
|
const json = exportToJSON({ name: 'behavior-tree', description: '' });
|
||||||
const json = exportToJSON(
|
|
||||||
{ name: 'behavior-tree', description: '' },
|
|
||||||
varsToSave
|
|
||||||
);
|
|
||||||
await invoke('write_behavior_tree_file', { filePath, content: json });
|
await invoke('write_behavior_tree_file', { filePath, content: json });
|
||||||
logger.info('行为树已保存', filePath);
|
logger.info('行为树已保存', filePath);
|
||||||
|
|
||||||
@@ -561,10 +557,8 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
|||||||
const extension = format === 'binary' ? 'bin' : 'json';
|
const extension = format === 'binary' ? 'bin' : 'json';
|
||||||
const filePath = `${outputPath}/${fileName}.btree.${extension}`;
|
const filePath = `${outputPath}/${fileName}.btree.${extension}`;
|
||||||
|
|
||||||
const varsToSave = isExecuting ? initialBlackboardVariables : blackboardVariables;
|
|
||||||
const data = exportToRuntimeAsset(
|
const data = exportToRuntimeAsset(
|
||||||
{ name: fileName, description: 'Runtime behavior tree asset' },
|
{ name: fileName, description: 'Runtime behavior tree asset' },
|
||||||
varsToSave,
|
|
||||||
format
|
format
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -824,7 +818,7 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
|||||||
|
|
||||||
// 如果是黑板变量节点,动态生成属性
|
// 如果是黑板变量节点,动态生成属性
|
||||||
if (node.data.nodeType === 'blackboard-variable') {
|
if (node.data.nodeType === 'blackboard-variable') {
|
||||||
const varName = node.data.variableName || '';
|
const varName = (node.data.variableName as string) || '';
|
||||||
const varValue = blackboardVariables[varName];
|
const varValue = blackboardVariables[varName];
|
||||||
const varType = typeof varValue === 'number' ? 'number' :
|
const varType = typeof varValue === 'number' ? 'number' :
|
||||||
typeof varValue === 'boolean' ? 'boolean' : 'string';
|
typeof varValue === 'boolean' ? 'boolean' : 'string';
|
||||||
@@ -862,7 +856,7 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
|||||||
data
|
data
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onNodeCreate={(template, position) => {
|
onNodeCreate={(_template, _position) => {
|
||||||
// Node created successfully
|
// Node created successfully
|
||||||
}}
|
}}
|
||||||
blackboardVariables={blackboardVariables}
|
blackboardVariables={blackboardVariables}
|
||||||
|
|||||||
@@ -6,20 +6,20 @@ import { SettingsService } from '../services/SettingsService';
|
|||||||
import '../styles/ProfilerWindow.css';
|
import '../styles/ProfilerWindow.css';
|
||||||
|
|
||||||
interface SystemPerformanceData {
|
interface SystemPerformanceData {
|
||||||
name: string;
|
name: string;
|
||||||
executionTime: number;
|
executionTime: number;
|
||||||
entityCount: number;
|
entityCount: number;
|
||||||
averageTime: number;
|
averageTime: number;
|
||||||
minTime: number;
|
minTime: number;
|
||||||
maxTime: number;
|
maxTime: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
level: number;
|
level: number;
|
||||||
children?: SystemPerformanceData[];
|
children?: SystemPerformanceData[];
|
||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProfilerWindowProps {
|
interface ProfilerWindowProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DataSource = 'local' | 'remote';
|
type DataSource = 'local' | 'remote';
|
||||||
@@ -96,23 +96,22 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
|
|||||||
|
|
||||||
if (servicesUpdate) {
|
if (servicesUpdate) {
|
||||||
const servicesStats = statsMap.get('Services.update');
|
const servicesStats = statsMap.get('Services.update');
|
||||||
coreNode.children!.push({
|
coreNode.children!.push({
|
||||||
name: 'Services.update',
|
name: 'Services.update',
|
||||||
executionTime: servicesUpdate.executionTime,
|
executionTime: servicesUpdate.executionTime,
|
||||||
entityCount: 0,
|
entityCount: 0,
|
||||||
averageTime: servicesStats?.averageTime || 0,
|
averageTime: servicesStats?.averageTime || 0,
|
||||||
minTime: servicesStats?.minTime || 0,
|
minTime: servicesStats?.minTime || 0,
|
||||||
maxTime: servicesStats?.maxTime || 0,
|
maxTime: servicesStats?.maxTime || 0,
|
||||||
percentage: coreUpdate.executionTime > 0
|
percentage: coreUpdate.executionTime > 0
|
||||||
? (servicesUpdate.executionTime / coreUpdate.executionTime) * 100
|
? (servicesUpdate.executionTime / coreUpdate.executionTime) * 100
|
||||||
: 0,
|
: 0,
|
||||||
level: 1,
|
level: 1,
|
||||||
isExpanded: false
|
isExpanded: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sceneSystems: SystemPerformanceData[] = [];
|
const sceneSystems: SystemPerformanceData[] = [];
|
||||||
let sceneSystemsTotal = 0;
|
|
||||||
|
|
||||||
for (const [name, data] of flatSystems.entries()) {
|
for (const [name, data] of flatSystems.entries()) {
|
||||||
if (name !== 'Core.update' && name !== 'Services.update') {
|
if (name !== 'Core.update' && name !== 'Services.update') {
|
||||||
@@ -129,7 +128,6 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
|
|||||||
level: 1,
|
level: 1,
|
||||||
isExpanded: false
|
isExpanded: false
|
||||||
});
|
});
|
||||||
sceneSystemsTotal += data.executionTime;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,9 +139,9 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
sceneSystems.sort((a, b) => b.executionTime - a.executionTime);
|
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
|
// Subscribe to local performance data
|
||||||
@@ -328,7 +326,7 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (viewMode === 'table') {
|
} else if (viewMode === 'table') {
|
||||||
// For table view without search, flatten all
|
// For table view without search, flatten all
|
||||||
const flatList: SystemPerformanceData[] = [];
|
const flatList: SystemPerformanceData[] = [];
|
||||||
const flatten = (nodes: SystemPerformanceData[]) => {
|
const flatten = (nodes: SystemPerformanceData[]) => {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
|
|||||||
25
packages/editor-app/src/domain/constants/RootNode.ts
Normal file
25
packages/editor-app/src/domain/constants/RootNode.ts
Normal file
@@ -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, []);
|
||||||
|
};
|
||||||
10
packages/editor-app/src/domain/errors/DomainError.ts
Normal file
10
packages/editor-app/src/domain/errors/DomainError.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/editor-app/src/domain/errors/NodeNotFoundError.ts
Normal file
10
packages/editor-app/src/domain/errors/NodeNotFoundError.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { DomainError } from './DomainError';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点未找到错误
|
||||||
|
*/
|
||||||
|
export class NodeNotFoundError extends DomainError {
|
||||||
|
constructor(public readonly nodeId: string) {
|
||||||
|
super(`节点未找到: ${nodeId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
packages/editor-app/src/domain/errors/ValidationError.ts
Normal file
52
packages/editor-app/src/domain/errors/ValidationError.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/editor-app/src/domain/errors/index.ts
Normal file
3
packages/editor-app/src/domain/errors/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { DomainError } from './DomainError';
|
||||||
|
export { ValidationError } from './ValidationError';
|
||||||
|
export { NodeNotFoundError } from './NodeNotFoundError';
|
||||||
5
packages/editor-app/src/domain/index.ts
Normal file
5
packages/editor-app/src/domain/index.ts
Normal file
@@ -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';
|
||||||
32
packages/editor-app/src/domain/interfaces/INodeFactory.ts
Normal file
32
packages/editor-app/src/domain/interfaces/INodeFactory.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
): Node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据模板类型创建节点
|
||||||
|
*/
|
||||||
|
createNodeByType(
|
||||||
|
nodeType: string,
|
||||||
|
position: Position,
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
): Node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 克隆节点
|
||||||
|
*/
|
||||||
|
cloneNode(node: Node, newPosition?: Position): Node;
|
||||||
|
}
|
||||||
27
packages/editor-app/src/domain/interfaces/IRepository.ts
Normal file
27
packages/editor-app/src/domain/interfaces/IRepository.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { BehaviorTree } from '../models/BehaviorTree';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓储接口
|
||||||
|
* 负责行为树的持久化
|
||||||
|
*/
|
||||||
|
export interface IBehaviorTreeRepository {
|
||||||
|
/**
|
||||||
|
* 保存行为树
|
||||||
|
*/
|
||||||
|
save(tree: BehaviorTree, path: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载行为树
|
||||||
|
*/
|
||||||
|
load(path: string): Promise<BehaviorTree>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否存在
|
||||||
|
*/
|
||||||
|
exists(path: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除行为树文件
|
||||||
|
*/
|
||||||
|
delete(path: string): Promise<void>;
|
||||||
|
}
|
||||||
30
packages/editor-app/src/domain/interfaces/ISerializer.ts
Normal file
30
packages/editor-app/src/domain/interfaces/ISerializer.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
46
packages/editor-app/src/domain/interfaces/IValidator.ts
Normal file
46
packages/editor-app/src/domain/interfaces/IValidator.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
4
packages/editor-app/src/domain/interfaces/index.ts
Normal file
4
packages/editor-app/src/domain/interfaces/index.ts
Normal file
@@ -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';
|
||||||
353
packages/editor-app/src/domain/models/BehaviorTree.ts
Normal file
353
packages/editor-app/src/domain/models/BehaviorTree.ts
Normal file
@@ -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<string, Node>;
|
||||||
|
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<Node> {
|
||||||
|
return Array.from(this._nodes.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
get connections(): ReadonlyArray<Connection> {
|
||||||
|
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<string>();
|
||||||
|
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<Node['toObject']>[];
|
||||||
|
connections: ReturnType<Connection['toObject']>[];
|
||||||
|
blackboard: Record<string, unknown>;
|
||||||
|
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<typeof Node.fromObject>[0][];
|
||||||
|
connections: Parameters<typeof Connection.fromObject>[0][];
|
||||||
|
blackboard: Record<string, unknown>;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
122
packages/editor-app/src/domain/models/Blackboard.ts
Normal file
122
packages/editor-app/src/domain/models/Blackboard.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* 黑板值类型
|
||||||
|
*/
|
||||||
|
export type BlackboardValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 黑板领域实体
|
||||||
|
* 管理行为树的全局变量
|
||||||
|
*/
|
||||||
|
export class Blackboard {
|
||||||
|
private _variables: Map<string, BlackboardValue>;
|
||||||
|
|
||||||
|
constructor(variables: Record<string, BlackboardValue> = {}) {
|
||||||
|
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<string, BlackboardValue> {
|
||||||
|
return Object.fromEntries(this._variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置变量
|
||||||
|
*/
|
||||||
|
setAll(variables: Record<string, BlackboardValue>): 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<string, BlackboardValue> {
|
||||||
|
return this.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从普通对象创建黑板
|
||||||
|
*/
|
||||||
|
static fromObject(obj: Record<string, unknown>): Blackboard {
|
||||||
|
return new Blackboard(obj as Record<string, BlackboardValue>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建空黑板
|
||||||
|
*/
|
||||||
|
static empty(): Blackboard {
|
||||||
|
return new Blackboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
140
packages/editor-app/src/domain/models/Connection.ts
Normal file
140
packages/editor-app/src/domain/models/Connection.ts
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
190
packages/editor-app/src/domain/models/Node.ts
Normal file
190
packages/editor-app/src/domain/models/Node.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
private _position: Position;
|
||||||
|
private _children: string[];
|
||||||
|
private readonly _nodeType: NodeType;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id: string,
|
||||||
|
template: NodeTemplate,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
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<string, unknown> {
|
||||||
|
return { ...this._data };
|
||||||
|
}
|
||||||
|
|
||||||
|
get position(): Position {
|
||||||
|
return this._position;
|
||||||
|
}
|
||||||
|
|
||||||
|
get children(): ReadonlyArray<string> {
|
||||||
|
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<string, unknown>): 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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
children: string[];
|
||||||
|
}): Node {
|
||||||
|
return new Node(
|
||||||
|
obj.id,
|
||||||
|
obj.template,
|
||||||
|
obj.data,
|
||||||
|
Position.fromObject(obj.position),
|
||||||
|
obj.children
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
packages/editor-app/src/domain/models/index.ts
Normal file
4
packages/editor-app/src/domain/models/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { Node } from './Node';
|
||||||
|
export { Connection, type ConnectionType } from './Connection';
|
||||||
|
export { Blackboard, type BlackboardValue } from './Blackboard';
|
||||||
|
export { BehaviorTree } from './BehaviorTree';
|
||||||
198
packages/editor-app/src/domain/services/TreeValidator.ts
Normal file
198
packages/editor-app/src/domain/services/TreeValidator.ts
Normal file
@@ -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<string>();
|
||||||
|
const recursionStack = new Set<string>();
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/editor-app/src/domain/services/index.ts
Normal file
1
packages/editor-app/src/domain/services/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { TreeValidator } from './TreeValidator';
|
||||||
107
packages/editor-app/src/domain/value-objects/NodeType.ts
Normal file
107
packages/editor-app/src/domain/value-objects/NodeType.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
packages/editor-app/src/domain/value-objects/Position.ts
Normal file
72
packages/editor-app/src/domain/value-objects/Position.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
packages/editor-app/src/domain/value-objects/Size.ts
Normal file
59
packages/editor-app/src/domain/value-objects/Size.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/editor-app/src/domain/value-objects/index.ts
Normal file
3
packages/editor-app/src/domain/value-objects/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { Position } from './Position';
|
||||||
|
export { Size } from './Size';
|
||||||
|
export { NodeType } from './NodeType';
|
||||||
137
packages/editor-app/src/infrastructure/events/EditorEventBus.ts
Normal file
137
packages/editor-app/src/infrastructure/events/EditorEventBus.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
type EventHandler<T = any> = (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<string, Set<EventHandler>> = new Map();
|
||||||
|
private eventHistory: Array<{ event: string; data: any; timestamp: number }> = [];
|
||||||
|
private maxHistorySize: number = 100;
|
||||||
|
|
||||||
|
on<T = any>(event: EditorEvent | string, handler: EventHandler<T>): 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<T = any>(event: EditorEvent | string, handler: EventHandler<T>): Subscription {
|
||||||
|
const wrappedHandler = (data: T) => {
|
||||||
|
handler(data);
|
||||||
|
this.off(event, wrappedHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.on(event, wrappedHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off<T = any>(event: EditorEvent | string, handler: EventHandler<T>): void {
|
||||||
|
const handlers = this.listeners.get(event);
|
||||||
|
if (handlers) {
|
||||||
|
handlers.delete(handler);
|
||||||
|
if (handlers.size === 0) {
|
||||||
|
this.listeners.delete(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<T = any>(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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown>
|
||||||
|
): Node {
|
||||||
|
const nodeId = generateUniqueId();
|
||||||
|
const nodeData = {
|
||||||
|
...template.defaultConfig,
|
||||||
|
...data
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Node(nodeId, template, nodeData, position, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据模板类型创建节点
|
||||||
|
*/
|
||||||
|
createNodeByType(
|
||||||
|
nodeType: string,
|
||||||
|
position: Position,
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { NodeFactory } from './NodeFactory';
|
||||||
2
packages/editor-app/src/infrastructure/index.ts
Normal file
2
packages/editor-app/src/infrastructure/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './factories';
|
||||||
|
export * from './serialization';
|
||||||
@@ -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<SerializationOptions> = {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { BehaviorTreeSerializer } from './BehaviorTreeSerializer';
|
||||||
@@ -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<string>();
|
||||||
|
const recursionStack = new Set<string>();
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement, BehaviorTreeCanvasProps>(({
|
||||||
|
config,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
onDoubleClick,
|
||||||
|
onContextMenu,
|
||||||
|
onMouseMove,
|
||||||
|
onMouseDown,
|
||||||
|
onMouseUp,
|
||||||
|
onMouseLeave,
|
||||||
|
onDrop,
|
||||||
|
onDragOver,
|
||||||
|
onDragEnter,
|
||||||
|
onDragLeave
|
||||||
|
}, forwardedRef) => {
|
||||||
|
const internalRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={canvasRef}
|
||||||
|
className="behavior-tree-canvas"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: isPanning ? 'grabbing' : 'default',
|
||||||
|
backgroundColor: '#1a1a1a'
|
||||||
|
}}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onClick={onClick}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragEnter={onDragEnter}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
>
|
||||||
|
{/* 网格背景 */}
|
||||||
|
{config.showGrid && (
|
||||||
|
<div
|
||||||
|
className="canvas-grid"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
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: `${config.gridSize * canvasScale}px ${config.gridSize * canvasScale}px`,
|
||||||
|
backgroundPosition: `${canvasOffset.x}px ${canvasOffset.y}px`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 内容容器(应用变换) */}
|
||||||
|
<div
|
||||||
|
className="canvas-content"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
transform: `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasScale})`,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
BehaviorTreeCanvas.displayName = 'BehaviorTreeCanvas';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { BehaviorTreeCanvas } from './BehaviorTreeCanvas';
|
||||||
@@ -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<ConnectionLayerProps> = ({
|
||||||
|
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<typeof item> => item !== null);
|
||||||
|
}, [connections, nodeMap, selectedConnection]);
|
||||||
|
|
||||||
|
if (connectionViewData.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="connection-layer"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
overflow: 'visible'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<g style={{ pointerEvents: 'auto' }}>
|
||||||
|
{connectionViewData.map(({ viewData, fromNode, toNode }) => (
|
||||||
|
<ConnectionRenderer
|
||||||
|
key={`${viewData.connection.from}-${viewData.connection.to}`}
|
||||||
|
connectionData={viewData}
|
||||||
|
fromNode={fromNode}
|
||||||
|
toNode={toNode}
|
||||||
|
getPortPosition={getPortPosition}
|
||||||
|
onClick={onConnectionClick}
|
||||||
|
onContextMenu={onConnectionContextMenu}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<ConnectionRendererProps> = ({
|
||||||
|
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 (
|
||||||
|
<g
|
||||||
|
className="connection"
|
||||||
|
onClick={handleClick}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
data-connection-from={connection.from}
|
||||||
|
data-connection-to={connection.to}
|
||||||
|
>
|
||||||
|
{/* 透明的宽线条,用于更容易点击 */}
|
||||||
|
<path
|
||||||
|
d={pathData.path}
|
||||||
|
fill="none"
|
||||||
|
stroke="transparent"
|
||||||
|
strokeWidth={20}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 实际显示的线条 */}
|
||||||
|
<path
|
||||||
|
d={pathData.path}
|
||||||
|
fill="none"
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
markerEnd="url(#arrowhead)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 箭头标记 */}
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="arrowhead"
|
||||||
|
markerWidth="10"
|
||||||
|
markerHeight="10"
|
||||||
|
refX="9"
|
||||||
|
refY="3"
|
||||||
|
orient="auto"
|
||||||
|
markerUnits="strokeWidth"
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="0 0, 10 3, 0 6"
|
||||||
|
fill={strokeColor}
|
||||||
|
/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* 选中时显示的中点 */}
|
||||||
|
{isSelected && (
|
||||||
|
<circle
|
||||||
|
cx={pathData.midX}
|
||||||
|
cy={pathData.midY}
|
||||||
|
r="5"
|
||||||
|
fill={strokeColor}
|
||||||
|
stroke="#1a1a1a"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ConnectionRenderer } from './ConnectionRenderer';
|
||||||
|
export { ConnectionLayer } from './ConnectionLayer';
|
||||||
@@ -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<string, BlackboardValue>;
|
||||||
|
|
||||||
|
interface BehaviorTreeNodeProps {
|
||||||
|
node: BehaviorTreeNodeType;
|
||||||
|
isSelected: boolean;
|
||||||
|
isBeingDragged: boolean;
|
||||||
|
dragDelta: { dx: number; dy: number };
|
||||||
|
uncommittedNodeIds: Set<string>;
|
||||||
|
blackboardVariables: BlackboardVariables;
|
||||||
|
initialBlackboardVariables: BlackboardVariables;
|
||||||
|
isExecuting: boolean;
|
||||||
|
connections: Connection[];
|
||||||
|
nodes: BehaviorTreeNodeType[];
|
||||||
|
executorRef: React.RefObject<BehaviorTreeExecutor | null>;
|
||||||
|
iconMap: Record<string, LucideIcon>;
|
||||||
|
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<BehaviorTreeNodeProps> = ({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
data-node-id={node.id}
|
||||||
|
className={nodeClasses}
|
||||||
|
onClick={(e) => 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 (
|
||||||
|
<>
|
||||||
|
<div className="bt-node-header blackboard">
|
||||||
|
<Database size={16} className="bt-node-header-icon" />
|
||||||
|
<div className="bt-node-header-title">
|
||||||
|
{varName || 'Variable'}
|
||||||
|
</div>
|
||||||
|
{isModified && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '9px',
|
||||||
|
color: '#ffbb00',
|
||||||
|
backgroundColor: 'rgba(255, 187, 0, 0.2)',
|
||||||
|
padding: '2px 4px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
marginLeft: '4px'
|
||||||
|
}}>
|
||||||
|
运行时
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bt-node-body">
|
||||||
|
<div
|
||||||
|
className="bt-node-blackboard-value"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isModified ? 'rgba(255, 187, 0, 0.15)' : 'transparent',
|
||||||
|
border: isModified ? '1px solid rgba(255, 187, 0, 0.3)' : 'none',
|
||||||
|
borderRadius: '2px',
|
||||||
|
padding: '2px 4px'
|
||||||
|
}}
|
||||||
|
title={isModified ? `初始值: ${JSON.stringify(initialValue)}\n当前值: ${JSON.stringify(currentValue)}` : undefined}
|
||||||
|
>
|
||||||
|
{JSON.stringify(currentValue)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-port="true"
|
||||||
|
data-node-id={node.id}
|
||||||
|
data-port-type="variable-output"
|
||||||
|
onMouseDown={(e) => onPortMouseDown(e, node.id, '__value__')}
|
||||||
|
onMouseUp={(e) => onPortMouseUp(e, node.id, '__value__')}
|
||||||
|
className="bt-node-port bt-node-port-variable-output"
|
||||||
|
title="Output"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={`bt-node-header ${isRoot ? 'root' : (node.template.type || 'action')}`}>
|
||||||
|
{isRoot ? (
|
||||||
|
<TreePine size={16} className="bt-node-header-icon" />
|
||||||
|
) : (
|
||||||
|
node.template.icon && (() => {
|
||||||
|
const IconComponent = iconMap[node.template.icon];
|
||||||
|
return IconComponent ? (
|
||||||
|
<IconComponent size={16} className="bt-node-header-icon" />
|
||||||
|
) : (
|
||||||
|
<span className="bt-node-header-icon">{node.template.icon}</span>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
<div className="bt-node-header-title">
|
||||||
|
<div>{isRoot ? 'ROOT' : node.template.displayName}</div>
|
||||||
|
<div className="bt-node-id" title={node.id}>
|
||||||
|
#{node.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
|
||||||
|
<div
|
||||||
|
className="bt-node-missing-executor-warning"
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'help',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AlertCircle
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
color: '#f44336',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="bt-node-missing-executor-tooltip">
|
||||||
|
缺失执行器:找不到节点对应的执行器 "{node.template.className}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isUncommitted && (
|
||||||
|
<div
|
||||||
|
className="bt-node-uncommitted-warning"
|
||||||
|
style={{
|
||||||
|
marginLeft: !isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) ? '4px' : 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'help',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AlertTriangle
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
color: '#ff5722',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="bt-node-uncommitted-tooltip">
|
||||||
|
未生效节点:运行时添加的节点,需重新运行才能生效
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!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)
|
||||||
|
) && (
|
||||||
|
<div
|
||||||
|
className="bt-node-empty-warning-container"
|
||||||
|
style={{
|
||||||
|
marginLeft: isUncommitted ? '4px' : 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'help',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AlertTriangle
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
color: '#ff9800',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="bt-node-empty-warning-tooltip">
|
||||||
|
空节点:没有子节点,执行时会直接跳过
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bt-node-body">
|
||||||
|
{!isRoot && (
|
||||||
|
<div className="bt-node-category">
|
||||||
|
{node.template.category}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.template.properties.length > 0 && (
|
||||||
|
<div className="bt-node-properties">
|
||||||
|
{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 (
|
||||||
|
<div key={idx} className="bt-node-property">
|
||||||
|
<div
|
||||||
|
data-port="true"
|
||||||
|
data-node-id={node.id}
|
||||||
|
data-property={prop.name}
|
||||||
|
data-port-type="property-input"
|
||||||
|
onMouseDown={(e) => 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}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="bt-node-property-label"
|
||||||
|
title={prop.description}
|
||||||
|
>
|
||||||
|
{prop.name}:
|
||||||
|
</span>
|
||||||
|
{propValue !== undefined && (
|
||||||
|
<span className="bt-node-property-value">
|
||||||
|
{String(propValue)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isRoot && (
|
||||||
|
<div
|
||||||
|
data-port="true"
|
||||||
|
data-node-id={node.id}
|
||||||
|
data-port-type="node-input"
|
||||||
|
onMouseDown={(e) => 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) && (
|
||||||
|
<div
|
||||||
|
data-port="true"
|
||||||
|
data-node-id={node.id}
|
||||||
|
data-port-type="node-output"
|
||||||
|
onMouseDown={(e) => onPortMouseDown(e, node.id)}
|
||||||
|
onMouseUp={(e) => onPortMouseUp(e, node.id)}
|
||||||
|
className="bt-node-port bt-node-port-output"
|
||||||
|
title="Output"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<string, LucideIcon> = {
|
||||||
|
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<BehaviorTreeNodeRendererProps> = ({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="behavior-tree-node"
|
||||||
|
style={nodeStyle}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
data-node-id={node.id}
|
||||||
|
>
|
||||||
|
{/* 节点头部 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '4px'
|
||||||
|
}}>
|
||||||
|
<IconComponent size={20} color={template.color || '#4a9eff'} />
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#ffffff',
|
||||||
|
flex: 1
|
||||||
|
}}>
|
||||||
|
{template.displayName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 节点类型 */}
|
||||||
|
{template.category && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#888888',
|
||||||
|
marginBottom: '4px'
|
||||||
|
}}>
|
||||||
|
{template.category}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 节点描述 */}
|
||||||
|
{template.description && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#cccccc',
|
||||||
|
marginTop: '8px',
|
||||||
|
lineHeight: '1.4'
|
||||||
|
}}>
|
||||||
|
{template.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 输入连接点 */}
|
||||||
|
<div
|
||||||
|
className="node-input-pin"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '-6px',
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: template.color || '#4a9eff',
|
||||||
|
border: '2px solid #1a1a1a',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
data-pin-type="input"
|
||||||
|
data-node-id={node.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 输出连接点 */}
|
||||||
|
<div
|
||||||
|
className="node-output-pin"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
right: '-6px',
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: template.color || '#4a9eff',
|
||||||
|
border: '2px solid #1a1a1a',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
data-pin-type="output"
|
||||||
|
data-node-id={node.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { BehaviorTreeNodeRenderer } from './BehaviorTreeNodeRenderer';
|
||||||
@@ -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<NodeContextMenuProps> = ({
|
||||||
|
visible,
|
||||||
|
position,
|
||||||
|
onReplaceNode
|
||||||
|
}) => {
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
backgroundColor: '#2d2d30',
|
||||||
|
border: '1px solid #454545',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
||||||
|
zIndex: 10000,
|
||||||
|
minWidth: '150px',
|
||||||
|
padding: '4px 0'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={onReplaceNode}
|
||||||
|
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'}
|
||||||
|
>
|
||||||
|
替换节点
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<string, LucideIcon>;
|
||||||
|
onSearchChange: (text: string) => void;
|
||||||
|
onIndexChange: (index: number) => void;
|
||||||
|
onNodeSelect: (template: NodeTemplate) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||||
|
visible,
|
||||||
|
position,
|
||||||
|
searchText,
|
||||||
|
selectedIndex,
|
||||||
|
iconMap,
|
||||||
|
onSearchChange,
|
||||||
|
onIndexChange,
|
||||||
|
onNodeSelect,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const selectedNodeRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
.quick-create-menu-list::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
.quick-create-menu-list::-webkit-scrollbar-track {
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
.quick-create-menu-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #3c3c3c;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.quick-create-menu-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4c4c4c;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${position.x}px`,
|
||||||
|
top: `${position.y}px`,
|
||||||
|
width: '300px',
|
||||||
|
maxHeight: '400px',
|
||||||
|
backgroundColor: '#2d2d2d',
|
||||||
|
borderRadius: '6px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
borderBottom: '1px solid #3c3c3c',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}>
|
||||||
|
<Search size={16} style={{ color: '#999', flexShrink: 0 }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索节点..."
|
||||||
|
autoFocus
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => {
|
||||||
|
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'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#999',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 节点列表 */}
|
||||||
|
<div
|
||||||
|
className="quick-create-menu-list"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredTemplates.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
padding: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
未找到匹配的节点
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTemplates.map((template: NodeTemplate, index: number) => {
|
||||||
|
const IconComponent = template.icon ? iconMap[template.icon] : null;
|
||||||
|
const className = template.className || '';
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={isSelected ? selectedNodeRef : null}
|
||||||
|
onClick={() => 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)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '4px'
|
||||||
|
}}>
|
||||||
|
{IconComponent && (
|
||||||
|
<IconComponent size={14} style={{ color: template.color || '#999', flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
color: '#ccc',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: '2px'
|
||||||
|
}}>
|
||||||
|
{template.displayName}
|
||||||
|
</div>
|
||||||
|
{className && (
|
||||||
|
<div style={{
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontFamily: 'Consolas, Monaco, monospace',
|
||||||
|
opacity: 0.8
|
||||||
|
}}>
|
||||||
|
{className}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#999',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
marginBottom: '2px'
|
||||||
|
}}>
|
||||||
|
{template.description}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#666'
|
||||||
|
}}>
|
||||||
|
{template.category}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<EditorToolbarProps> = ({
|
||||||
|
executionMode,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onStop,
|
||||||
|
onStep,
|
||||||
|
onReset,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
onResetView,
|
||||||
|
onClearCanvas
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '10px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
backgroundColor: 'rgba(45, 45, 45, 0.95)',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||||
|
zIndex: 100
|
||||||
|
}}>
|
||||||
|
{/* 播放按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onPlay}
|
||||||
|
disabled={executionMode === 'running'}
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: executionMode === 'running' ? '#2d2d2d' : '#4caf50',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: executionMode === 'running' ? '#666' : '#fff',
|
||||||
|
cursor: executionMode === 'running' ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
title="运行 (Play)"
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 暂停按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onPause}
|
||||||
|
disabled={executionMode === 'idle'}
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: executionMode === 'idle' ? '#2d2d2d' : '#ff9800',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: executionMode === 'idle' ? '#666' : '#fff',
|
||||||
|
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
title={executionMode === 'paused' ? '继续' : '暂停'}
|
||||||
|
>
|
||||||
|
{executionMode === 'paused' ? <Play size={16} /> : <Pause size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 停止按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onStop}
|
||||||
|
disabled={executionMode === 'idle'}
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: executionMode === 'idle' ? '#2d2d2d' : '#f44336',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: executionMode === 'idle' ? '#666' : '#fff',
|
||||||
|
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
title="停止"
|
||||||
|
>
|
||||||
|
<Square size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 单步执行按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onStep}
|
||||||
|
disabled={executionMode !== 'idle' && executionMode !== 'paused'}
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: (executionMode !== 'idle' && executionMode !== 'paused') ? '#2d2d2d' : '#2196f3',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: (executionMode !== 'idle' && executionMode !== 'paused') ? '#666' : '#fff',
|
||||||
|
cursor: (executionMode !== 'idle' && executionMode !== 'paused') ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
title="单步执行"
|
||||||
|
>
|
||||||
|
<SkipForward size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 重置按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: '#9e9e9e',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
title="重置"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 分隔符 */}
|
||||||
|
<div style={{
|
||||||
|
width: '1px',
|
||||||
|
backgroundColor: '#666',
|
||||||
|
margin: '4px 0'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 重置视图按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onResetView}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#3c3c3c',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#cccccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px'
|
||||||
|
}}
|
||||||
|
title="重置视图 (滚轮缩放, Alt+拖动平移)"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 清空画布按钮 */}
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#3c3c3c',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#cccccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px'
|
||||||
|
}}
|
||||||
|
title="清空画布"
|
||||||
|
onClick={onClearCanvas}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 分隔符 */}
|
||||||
|
<div style={{
|
||||||
|
width: '1px',
|
||||||
|
height: '24px',
|
||||||
|
backgroundColor: '#555',
|
||||||
|
margin: '0 4px'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 撤销按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: canUndo ? '#3c3c3c' : '#2d2d2d',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: canUndo ? '#cccccc' : '#666',
|
||||||
|
cursor: canUndo ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
title="撤销 (Ctrl+Z)"
|
||||||
|
>
|
||||||
|
<Undo size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 重做按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onRedo}
|
||||||
|
disabled={!canRedo}
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: canRedo ? '#3c3c3c' : '#2d2d2d',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: canRedo ? '#cccccc' : '#666',
|
||||||
|
cursor: canRedo ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
title="重做 (Ctrl+Shift+Z / Ctrl+Y)"
|
||||||
|
>
|
||||||
|
<Redo size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 状态指示器 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#ccc',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor:
|
||||||
|
executionMode === 'running' ? '#4caf50' :
|
||||||
|
executionMode === 'paused' ? '#ff9800' : '#666'
|
||||||
|
}} />
|
||||||
|
{executionMode === 'idle' ? 'Idle' :
|
||||||
|
executionMode === 'running' ? 'Running' :
|
||||||
|
executionMode === 'paused' ? 'Paused' : 'Step'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<string, LucideIcon> = {
|
||||||
|
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
|
||||||
|
};
|
||||||
4
packages/editor-app/src/presentation/hooks/index.ts
Normal file
4
packages/editor-app/src/presentation/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { useCommandHistory } from './useCommandHistory';
|
||||||
|
export { useNodeOperations } from './useNodeOperations';
|
||||||
|
export { useConnectionOperations } from './useConnectionOperations';
|
||||||
|
export { useCanvasInteraction } from './useCanvasInteraction';
|
||||||
@@ -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
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>;
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useRef, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import { CommandManager } from '../../application/commands/CommandManager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销/重做功能 Hook
|
||||||
|
*/
|
||||||
|
export function useCommandHistory() {
|
||||||
|
const commandManagerRef = useRef<CommandManager>(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]);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
129
packages/editor-app/src/presentation/hooks/useDropHandler.ts
Normal file
129
packages/editor-app/src/presentation/hooks/useDropHandler.ts
Normal file
@@ -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<HTMLDivElement>;
|
||||||
|
canvasOffset: { x: number; y: number };
|
||||||
|
canvasScale: number;
|
||||||
|
nodeOperations: ReturnType<typeof useNodeOperations>;
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
18
packages/editor-app/src/presentation/hooks/useEditorState.ts
Normal file
18
packages/editor-app/src/presentation/hooks/useEditorState.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { BehaviorTreeExecutor } from '../../utils/BehaviorTreeExecutor';
|
||||||
|
|
||||||
|
export function useEditorState() {
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
const stopExecutionRef = useRef<(() => void) | null>(null);
|
||||||
|
const executorRef = useRef<BehaviorTreeExecutor | null>(null);
|
||||||
|
|
||||||
|
const [selectedConnection, setSelectedConnection] = useState<{from: string; to: string} | null>(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canvasRef,
|
||||||
|
stopExecutionRef,
|
||||||
|
executorRef,
|
||||||
|
selectedConnection,
|
||||||
|
setSelectedConnection
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, BlackboardValue>;
|
||||||
|
|
||||||
|
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<ExecutionMode>('idle');
|
||||||
|
const [executionLogs, setExecutionLogs] = useState<ExecutionLog[]>([]);
|
||||||
|
const [executionSpeed, setExecutionSpeed] = useState<number>(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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<typeof useNodeOperations>;
|
||||||
|
connectionOperations: ReturnType<typeof useConnectionOperations>;
|
||||||
|
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]);
|
||||||
|
}
|
||||||
161
packages/editor-app/src/presentation/hooks/useNodeDrag.ts
Normal file
161
packages/editor-app/src/presentation/hooks/useNodeDrag.ts
Normal file
@@ -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<HTMLDivElement>;
|
||||||
|
canvasOffset: { x: number; y: number };
|
||||||
|
canvasScale: number;
|
||||||
|
nodes: BehaviorTreeNode[];
|
||||||
|
selectedNodeIds: string[];
|
||||||
|
draggingNodeId: string | null;
|
||||||
|
dragStartPositions: Map<string, { x: number; y: number }>;
|
||||||
|
isDraggingNode: boolean;
|
||||||
|
dragDelta: { dx: number; dy: number };
|
||||||
|
nodeOperations: ReturnType<typeof useNodeOperations>;
|
||||||
|
setSelectedNodeIds: (ids: string[]) => 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;
|
||||||
|
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<string, { x: number; y: number }>();
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, unknown>
|
||||||
|
) => {
|
||||||
|
return createNodeUseCase.execute(template, position, data);
|
||||||
|
}, [createNodeUseCase]);
|
||||||
|
|
||||||
|
const createNodeByType = useCallback((
|
||||||
|
nodeType: string,
|
||||||
|
position: Position,
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
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<string, unknown>) => {
|
||||||
|
updateNodeDataUseCase.execute(nodeId, data);
|
||||||
|
}, [updateNodeDataUseCase]);
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
createNode,
|
||||||
|
createNodeByType,
|
||||||
|
deleteNode,
|
||||||
|
deleteNodes,
|
||||||
|
moveNode,
|
||||||
|
moveNodes,
|
||||||
|
updateNodeData
|
||||||
|
}), [createNode, createNodeByType, deleteNode, deleteNodes, moveNode, moveNodes, updateNodeData]);
|
||||||
|
}
|
||||||
@@ -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<Set<string>>(new Set());
|
||||||
|
const activeNodeIdsRef = useRef<Set<string>>(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<string>();
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
182
packages/editor-app/src/presentation/hooks/usePortConnection.ts
Normal file
182
packages/editor-app/src/presentation/hooks/usePortConnection.ts
Normal file
@@ -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<HTMLDivElement>;
|
||||||
|
canvasOffset: { x: number; y: number };
|
||||||
|
canvasScale: number;
|
||||||
|
nodes: BehaviorTreeNode[];
|
||||||
|
connections: Connection[];
|
||||||
|
connectingFrom: string | null;
|
||||||
|
connectingFromProperty: string | null;
|
||||||
|
connectionOperations: ReturnType<typeof useConnectionOperations>;
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
121
packages/editor-app/src/presentation/types/index.ts
Normal file
121
packages/editor-app/src/presentation/types/index.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user