refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 (#216)
* refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 * feat(editor): 添加插件市场功能 * feat(editor): 重构插件市场以支持版本管理和ZIP打包 * feat(editor): 重构插件发布流程并修复React渲染警告 * fix(plugin): 修复插件发布和市场的路径不一致问题 * feat: 重构插件发布流程并添加插件删除功能 * fix(editor): 完善插件删除功能并修复多个关键问题 * fix(auth): 修复自动登录与手动登录的竞态条件问题 * feat(editor): 重构插件管理流程 * feat(editor): 支持 ZIP 文件直接发布插件 - 新增 PluginSourceParser 解析插件源 - 重构发布流程支持文件夹和 ZIP 两种方式 - 优化发布向导 UI * feat(editor): 插件市场支持多版本安装 - 插件解压到项目 plugins 目录 - 新增 Tauri 后端安装/卸载命令 - 支持选择任意版本安装 - 修复打包逻辑,保留完整 dist 目录结构 * feat(editor): 个人中心支持多版本管理 - 合并同一插件的不同版本 - 添加版本历史展开/折叠功能 - 禁止有待审核 PR 时更新插件 * fix(editor): 修复 InspectorRegistry 服务注册 - InspectorRegistry 实现 IService 接口 - 注册到 Core.services 供插件使用 * feat(behavior-tree-editor): 完善插件注册和文件操作 - 添加文件创建模板和操作处理器 - 实现右键菜单创建行为树功能 - 修复文件读取权限问题(使用 Tauri 命令) - 添加 BehaviorTreeEditorPanel 组件 - 修复 rollup 配置支持动态导入 * feat(plugin): 完善插件构建和发布流程 * fix(behavior-tree-editor): 完整恢复编辑器并修复 Toast 集成 * fix(behavior-tree-editor): 修复节点选中、连线跟随和文件加载问题并优化性能 * fix(behavior-tree-editor): 修复端口连接失败问题并优化连线样式 * refactor(behavior-tree-editor): 移除调试面板功能简化代码结构 * refactor(behavior-tree-editor): 清理冗余代码合并重复逻辑 * feat(behavior-tree-editor): 完善编辑器核心功能增强扩展性 * fix(lint): 修复ESLint错误确保CI通过 * refactor(behavior-tree-editor): 优化编辑器工具栏和编译器功能 * refactor(behavior-tree-editor): 清理技术债务,优化代码质量 * fix(editor-app): 修复字符串替换安全问题
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ dist/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
.cache/
|
.cache/
|
||||||
|
.build-cache/
|
||||||
|
|
||||||
# IDE 配置
|
# IDE 配置
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
2981
package-lock.json
generated
2981
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
59
packages/behavior-tree-editor/package.json
Normal file
59
packages/behavior-tree-editor/package.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "@esengine/behavior-tree-editor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Behavior Tree Editor Plugin for ECS Framework",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.esm.js",
|
||||||
|
"module": "dist/index.esm.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"clean": "rimraf bin dist tsconfig.tsbuildinfo",
|
||||||
|
"prebuild": "npm run clean",
|
||||||
|
"build": "npm run build:tsc && npm run copy:css && npm run build:rollup",
|
||||||
|
"build:tsc": "tsc",
|
||||||
|
"copy:css": "node scripts/copy-css.js",
|
||||||
|
"build:rollup": "rollup -c",
|
||||||
|
"dev": "rollup -c -w"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ecs",
|
||||||
|
"behavior-tree",
|
||||||
|
"editor",
|
||||||
|
"plugin"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@esengine/behavior-tree": "file:../behavior-tree",
|
||||||
|
"@esengine/ecs-framework": "file:../core",
|
||||||
|
"@esengine/editor-core": "file:../editor-core",
|
||||||
|
"@rollup/plugin-commonjs": "^28.0.1",
|
||||||
|
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"rimraf": "^6.0.1",
|
||||||
|
"rollup": "^4.28.1",
|
||||||
|
"rollup-plugin-copy": "^3.5.0",
|
||||||
|
"rollup-plugin-dts": "^6.1.1",
|
||||||
|
"rollup-plugin-postcss": "^4.0.2",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"zustand": "^5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@esengine/behavior-tree": "*",
|
||||||
|
"@esengine/ecs-framework": "*",
|
||||||
|
"@esengine/editor-core": "*",
|
||||||
|
"@tauri-apps/api": "*",
|
||||||
|
"@tauri-apps/plugin-dialog": "*",
|
||||||
|
"@tauri-apps/plugin-http": "*",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"tsyringe": "*",
|
||||||
|
"zustand": "^5.0.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mobx": "^6.15.0",
|
||||||
|
"mobx-react-lite": "^4.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
66
packages/behavior-tree-editor/rollup.config.cjs
Normal file
66
packages/behavior-tree-editor/rollup.config.cjs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const resolve = require('@rollup/plugin-node-resolve');
|
||||||
|
const commonjs = require('@rollup/plugin-commonjs');
|
||||||
|
const dts = require('rollup-plugin-dts').default;
|
||||||
|
const postcss = require('rollup-plugin-postcss');
|
||||||
|
|
||||||
|
const external = [
|
||||||
|
'react',
|
||||||
|
'react/jsx-runtime',
|
||||||
|
'zustand',
|
||||||
|
'zustand/middleware',
|
||||||
|
'lucide-react',
|
||||||
|
'@esengine/ecs-framework',
|
||||||
|
'@esengine/editor-core',
|
||||||
|
'@esengine/behavior-tree',
|
||||||
|
'tsyringe',
|
||||||
|
'@tauri-apps/api/core',
|
||||||
|
'@tauri-apps/plugin-dialog'
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
input: 'bin/index.js',
|
||||||
|
output: {
|
||||||
|
file: 'dist/index.esm.js',
|
||||||
|
format: 'es',
|
||||||
|
sourcemap: true,
|
||||||
|
exports: 'named',
|
||||||
|
inlineDynamicImports: true
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
resolve({
|
||||||
|
extensions: ['.js', '.jsx']
|
||||||
|
}),
|
||||||
|
postcss({
|
||||||
|
inject: true,
|
||||||
|
minimize: false
|
||||||
|
}),
|
||||||
|
commonjs()
|
||||||
|
],
|
||||||
|
external,
|
||||||
|
onwarn(warning, warn) {
|
||||||
|
if (warning.code === 'CIRCULAR_DEPENDENCY' || warning.code === 'THIS_IS_UNDEFINED') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
warn(warning);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 类型定义构建
|
||||||
|
{
|
||||||
|
input: 'bin/index.d.ts',
|
||||||
|
output: {
|
||||||
|
file: 'dist/index.d.ts',
|
||||||
|
format: 'es'
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
dts({
|
||||||
|
respectExternal: true
|
||||||
|
})
|
||||||
|
],
|
||||||
|
external: [
|
||||||
|
...external,
|
||||||
|
/\.css$/
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
25
packages/behavior-tree-editor/scripts/copy-css.js
Normal file
25
packages/behavior-tree-editor/scripts/copy-css.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { readdirSync, statSync, copyFileSync, mkdirSync } from 'fs';
|
||||||
|
import { join, dirname, relative } from 'path';
|
||||||
|
|
||||||
|
function copyCSS(srcDir, destDir) {
|
||||||
|
const files = readdirSync(srcDir);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const srcPath = join(srcDir, file);
|
||||||
|
const stat = statSync(srcPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
copyCSS(srcPath, destDir);
|
||||||
|
} else if (file.endsWith('.css')) {
|
||||||
|
const relativePath = relative('src', srcPath);
|
||||||
|
const destPath = join(destDir, relativePath);
|
||||||
|
|
||||||
|
mkdirSync(dirname(destPath), { recursive: true });
|
||||||
|
copyFileSync(srcPath, destPath);
|
||||||
|
console.log(`Copied: ${relativePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copyCSS('src', 'bin');
|
||||||
|
console.log('CSS files copied successfully!');
|
||||||
105
packages/behavior-tree-editor/src/BehaviorTreeModule.ts
Normal file
105
packages/behavior-tree-editor/src/BehaviorTreeModule.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { singleton } from 'tsyringe';
|
||||||
|
import { Core, createLogger } from '@esengine/ecs-framework';
|
||||||
|
import { CompilerRegistry, IEditorModule, IModuleContext, PanelPosition } from '@esengine/editor-core';
|
||||||
|
import { BehaviorTreeService } from './services/BehaviorTreeService';
|
||||||
|
import { BehaviorTreeCompiler } from './compiler/BehaviorTreeCompiler';
|
||||||
|
import { BehaviorTreeNodeInspectorProvider } from './providers/BehaviorTreeNodeInspectorProvider';
|
||||||
|
import { BehaviorTreeEditorPanel } from './components/panels/BehaviorTreeEditorPanel';
|
||||||
|
|
||||||
|
const logger = createLogger('BehaviorTreeModule');
|
||||||
|
|
||||||
|
@singleton()
|
||||||
|
export class BehaviorTreeModule implements IEditorModule {
|
||||||
|
readonly id = 'behavior-tree';
|
||||||
|
readonly name = 'Behavior Tree Editor';
|
||||||
|
readonly version = '1.0.0';
|
||||||
|
|
||||||
|
async load(context: IModuleContext): Promise<void> {
|
||||||
|
logger.info('[BehaviorTreeModule] Loading behavior tree editor module...');
|
||||||
|
|
||||||
|
this.registerServices(context);
|
||||||
|
this.registerCompilers();
|
||||||
|
this.registerInspectors(context);
|
||||||
|
this.registerCommands(context);
|
||||||
|
this.registerPanels(context);
|
||||||
|
this.subscribeEvents(context);
|
||||||
|
|
||||||
|
logger.info('[BehaviorTreeModule] Behavior tree editor module loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerServices(context: IModuleContext): void {
|
||||||
|
context.container.register(BehaviorTreeService, { useClass: BehaviorTreeService });
|
||||||
|
logger.info('[BehaviorTreeModule] Services registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerCompilers(): void {
|
||||||
|
const compilerRegistry = Core.services.resolve(CompilerRegistry);
|
||||||
|
if (compilerRegistry) {
|
||||||
|
const compiler = new BehaviorTreeCompiler();
|
||||||
|
compilerRegistry.register(compiler);
|
||||||
|
logger.info('[BehaviorTreeModule] Compiler registered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerInspectors(context: IModuleContext): void {
|
||||||
|
const provider = new BehaviorTreeNodeInspectorProvider();
|
||||||
|
context.inspectorRegistry.register(provider);
|
||||||
|
logger.info('[BehaviorTreeModule] Inspector provider registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
async unload(): Promise<void> {
|
||||||
|
logger.info('[BehaviorTreeModule] Unloading behavior tree editor module...');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerCommands(context: IModuleContext): void {
|
||||||
|
context.commands.register({
|
||||||
|
id: 'behavior-tree.new',
|
||||||
|
label: 'New Behavior Tree',
|
||||||
|
icon: 'file-plus',
|
||||||
|
execute: async () => {
|
||||||
|
const service = context.container.resolve(BehaviorTreeService);
|
||||||
|
await service.createNew();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.commands.register({
|
||||||
|
id: 'behavior-tree.open',
|
||||||
|
label: 'Open Behavior Tree',
|
||||||
|
icon: 'folder-open',
|
||||||
|
execute: async () => {
|
||||||
|
logger.info('Open behavior tree');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.commands.register({
|
||||||
|
id: 'behavior-tree.save',
|
||||||
|
label: 'Save Behavior Tree',
|
||||||
|
icon: 'save',
|
||||||
|
keybinding: { key: 'S', ctrl: true },
|
||||||
|
execute: async () => {
|
||||||
|
logger.info('Save behavior tree');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerPanels(context: IModuleContext): void {
|
||||||
|
logger.info('[BehaviorTreeModule] Registering panels...');
|
||||||
|
|
||||||
|
context.panels.register({
|
||||||
|
id: 'behavior-tree-editor',
|
||||||
|
title: '行为树编辑器',
|
||||||
|
icon: 'GitBranch',
|
||||||
|
component: BehaviorTreeEditorPanel,
|
||||||
|
position: PanelPosition.Center,
|
||||||
|
defaultSize: 400,
|
||||||
|
closable: true,
|
||||||
|
isDynamic: true
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('[BehaviorTreeModule] Panel registered: behavior-tree-editor');
|
||||||
|
}
|
||||||
|
|
||||||
|
private subscribeEvents(_context: IModuleContext): void {
|
||||||
|
// 文件加载由 BehaviorTreeEditorPanel 处理
|
||||||
|
}
|
||||||
|
}
|
||||||
158
packages/behavior-tree-editor/src/BehaviorTreePlugin.ts
Normal file
158
packages/behavior-tree-editor/src/BehaviorTreePlugin.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
|
||||||
|
import {
|
||||||
|
IEditorPlugin,
|
||||||
|
EditorPluginCategory,
|
||||||
|
CompilerRegistry,
|
||||||
|
InspectorRegistry,
|
||||||
|
PanelPosition,
|
||||||
|
type FileCreationTemplate,
|
||||||
|
type FileActionHandler,
|
||||||
|
type PanelDescriptor
|
||||||
|
} from '@esengine/editor-core';
|
||||||
|
import { BehaviorTreeService } from './services/BehaviorTreeService';
|
||||||
|
import { FileSystemService } from './services/FileSystemService';
|
||||||
|
import { BehaviorTreeCompiler } from './compiler/BehaviorTreeCompiler';
|
||||||
|
import { BehaviorTreeNodeInspectorProvider } from './providers/BehaviorTreeNodeInspectorProvider';
|
||||||
|
import { BehaviorTreeEditorPanel } from './components/panels/BehaviorTreeEditorPanel';
|
||||||
|
import { useBehaviorTreeDataStore } from './stores';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { GitBranch } from 'lucide-react';
|
||||||
|
import { createRootNode } from './domain/constants/RootNode';
|
||||||
|
import type { IService, ServiceType } from '@esengine/ecs-framework';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
const logger = createLogger('BehaviorTreePlugin');
|
||||||
|
|
||||||
|
export class BehaviorTreePlugin implements IEditorPlugin {
|
||||||
|
readonly name = '@esengine/behavior-tree-editor';
|
||||||
|
readonly version = '1.0.0';
|
||||||
|
readonly displayName = 'Behavior Tree Editor';
|
||||||
|
readonly category = EditorPluginCategory.Tool;
|
||||||
|
readonly description = 'Visual behavior tree editor for game AI development';
|
||||||
|
readonly icon = 'GitBranch';
|
||||||
|
|
||||||
|
private services?: ServiceContainer;
|
||||||
|
private registeredServices: Set<ServiceType<IService>> = new Set();
|
||||||
|
private fileActionHandler?: FileActionHandler;
|
||||||
|
private fileCreationTemplate?: FileCreationTemplate;
|
||||||
|
|
||||||
|
async install(core: Core, services: ServiceContainer): Promise<void> {
|
||||||
|
this.services = services;
|
||||||
|
this.registerServices(services);
|
||||||
|
this.registerCompilers(services);
|
||||||
|
this.registerInspectors(services);
|
||||||
|
this.registerFileActions(services);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstall(): Promise<void> {
|
||||||
|
if (this.services) {
|
||||||
|
for (const serviceType of this.registeredServices) {
|
||||||
|
this.services.unregister(serviceType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.registeredServices.clear();
|
||||||
|
useBehaviorTreeDataStore.getState().reset();
|
||||||
|
this.services = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPanels(): PanelDescriptor[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'behavior-tree-editor',
|
||||||
|
title: 'Behavior Tree Editor',
|
||||||
|
position: PanelPosition.Center,
|
||||||
|
closable: true,
|
||||||
|
component: BehaviorTreeEditorPanel,
|
||||||
|
order: 100,
|
||||||
|
isDynamic: true // 标记为动态面板
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerServices(services: ServiceContainer): void {
|
||||||
|
// 先注册 FileSystemService(BehaviorTreeService 依赖它)
|
||||||
|
if (services.isRegistered(FileSystemService)) {
|
||||||
|
services.unregister(FileSystemService);
|
||||||
|
}
|
||||||
|
services.registerSingleton(FileSystemService);
|
||||||
|
this.registeredServices.add(FileSystemService);
|
||||||
|
|
||||||
|
// 再注册 BehaviorTreeService
|
||||||
|
if (services.isRegistered(BehaviorTreeService)) {
|
||||||
|
services.unregister(BehaviorTreeService);
|
||||||
|
}
|
||||||
|
services.registerSingleton(BehaviorTreeService);
|
||||||
|
this.registeredServices.add(BehaviorTreeService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerCompilers(services: ServiceContainer): void {
|
||||||
|
try {
|
||||||
|
const compilerRegistry = services.resolve(CompilerRegistry);
|
||||||
|
const compiler = new BehaviorTreeCompiler();
|
||||||
|
compilerRegistry.register(compiler);
|
||||||
|
logger.info('Successfully registered BehaviorTreeCompiler');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to register compiler:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerInspectors(services: ServiceContainer): void {
|
||||||
|
const inspectorRegistry = services.resolve(InspectorRegistry);
|
||||||
|
if (inspectorRegistry) {
|
||||||
|
const provider = new BehaviorTreeNodeInspectorProvider();
|
||||||
|
inspectorRegistry.register(provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerFileActions(services: ServiceContainer): void {
|
||||||
|
this.fileCreationTemplate = {
|
||||||
|
label: 'Behavior Tree',
|
||||||
|
extension: 'btree',
|
||||||
|
defaultFileName: 'NewBehaviorTree',
|
||||||
|
icon: createElement(GitBranch, { size: 16 }),
|
||||||
|
createContent: (fileName: string) => {
|
||||||
|
// 创建根节点
|
||||||
|
const rootNode = createRootNode();
|
||||||
|
const rootNodeData = {
|
||||||
|
id: rootNode.id,
|
||||||
|
type: rootNode.template.type,
|
||||||
|
displayName: rootNode.template.displayName,
|
||||||
|
data: rootNode.data,
|
||||||
|
position: {
|
||||||
|
x: rootNode.position.x,
|
||||||
|
y: rootNode.position.y
|
||||||
|
},
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyTree = {
|
||||||
|
name: fileName.replace('.btree', ''),
|
||||||
|
nodes: [rootNodeData],
|
||||||
|
connections: [],
|
||||||
|
variables: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(emptyTree, null, 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.fileActionHandler = {
|
||||||
|
extensions: ['btree'],
|
||||||
|
onDoubleClick: async (filePath: string) => {
|
||||||
|
const service = services.resolve(BehaviorTreeService);
|
||||||
|
if (service) {
|
||||||
|
await service.loadFromFile(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
registerFileActionHandlers(): FileActionHandler[] {
|
||||||
|
return this.fileActionHandler ? [this.fileActionHandler] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
registerFileCreationTemplates(): FileCreationTemplate[] {
|
||||||
|
return this.fileCreationTemplate ? [this.fileCreationTemplate] : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('批量命令不支持合并');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 命令接口
|
||||||
|
* 实现命令模式,支持撤销/重做功能
|
||||||
|
*/
|
||||||
|
export interface ICommand {
|
||||||
|
/**
|
||||||
|
* 执行命令
|
||||||
|
*/
|
||||||
|
execute(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销命令
|
||||||
|
*/
|
||||||
|
undo(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取命令描述(用于显示历史记录)
|
||||||
|
*/
|
||||||
|
getDescription(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查命令是否可以合并
|
||||||
|
* 用于优化撤销/重做历史,例如连续的移动操作可以合并为一个
|
||||||
|
*/
|
||||||
|
canMergeWith(other: ICommand): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与另一个命令合并
|
||||||
|
*/
|
||||||
|
mergeWith(other: ICommand): ICommand;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Connection } from '../../../domain/models/Connection';
|
import { Connection } from '../../../domain/models/Connection';
|
||||||
import { BaseCommand } from '../BaseCommand';
|
import { BaseCommand } from '@esengine/editor-core';
|
||||||
import { ITreeState } from '../ITreeState';
|
import { ITreeState } from '../ITreeState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Node } from '../../../domain/models/Node';
|
import { Node } from '../../../domain/models/Node';
|
||||||
import { BaseCommand } from '../BaseCommand';
|
import { BaseCommand } from '@esengine/editor-core';
|
||||||
import { ITreeState } from '../ITreeState';
|
import { ITreeState } from '../ITreeState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Node } from '../../../domain/models/Node';
|
import { Node } from '../../../domain/models/Node';
|
||||||
import { BaseCommand } from '../BaseCommand';
|
import { BaseCommand } from '@esengine/editor-core';
|
||||||
import { ITreeState } from '../ITreeState';
|
import { ITreeState } from '../ITreeState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Position } from '../../../domain/value-objects/Position';
|
import { Position } from '../../../domain/value-objects/Position';
|
||||||
import { BaseCommand } from '../BaseCommand';
|
import { BaseCommand, ICommand } from '@esengine/editor-core';
|
||||||
import { ITreeState } from '../ITreeState';
|
import { ITreeState } from '../ITreeState';
|
||||||
import { ICommand } from '../ICommand';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移动节点命令
|
* 移动节点命令
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Connection } from '../../../domain/models/Connection';
|
import { Connection } from '../../../domain/models/Connection';
|
||||||
import { BaseCommand } from '../BaseCommand';
|
import { BaseCommand } from '@esengine/editor-core';
|
||||||
import { ITreeState } from '../ITreeState';
|
import { ITreeState } from '../ITreeState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BaseCommand } from '../BaseCommand';
|
import { BaseCommand } from '@esengine/editor-core';
|
||||||
import { ITreeState } from '../ITreeState';
|
import { ITreeState } from '../ITreeState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore';
|
import { Node as BehaviorTreeNode } from '../../domain/models/Node';
|
||||||
|
import { Connection } from '../../domain/models/Connection';
|
||||||
import { ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
import { ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
||||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
const logger = createLogger('ExecutionHooks');
|
||||||
|
|
||||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||||
type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
|
type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
|
||||||
@@ -73,7 +77,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.beforePlay(context);
|
await hook.beforePlay(context);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in beforePlay hook:', error);
|
logger.error('Error in beforePlay hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +89,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.afterPlay(context);
|
await hook.afterPlay(context);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in afterPlay hook:', error);
|
logger.error('Error in afterPlay hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +101,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.beforePause();
|
await hook.beforePause();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in beforePause hook:', error);
|
logger.error('Error in beforePause hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,7 +113,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.afterPause();
|
await hook.afterPause();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in afterPause hook:', error);
|
logger.error('Error in afterPause hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +125,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.beforeResume();
|
await hook.beforeResume();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in beforeResume hook:', error);
|
logger.error('Error in beforeResume hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +137,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.afterResume();
|
await hook.afterResume();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in afterResume hook:', error);
|
logger.error('Error in afterResume hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +149,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.beforeStop();
|
await hook.beforeStop();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in beforeStop hook:', error);
|
logger.error('Error in beforeStop hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,7 +161,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.afterStop();
|
await hook.afterStop();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in afterStop hook:', error);
|
logger.error('Error in afterStop hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +173,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.beforeStep(deltaTime);
|
await hook.beforeStep(deltaTime);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in beforeStep hook:', error);
|
logger.error('Error in beforeStep hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +185,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.afterStep(deltaTime);
|
await hook.afterStep(deltaTime);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in afterStep hook:', error);
|
logger.error('Error in afterStep hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +197,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.onTick(tickCount, deltaTime);
|
await hook.onTick(tickCount, deltaTime);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in onTick hook:', error);
|
logger.error('Error in onTick hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +209,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.onNodeStatusChange(event);
|
await hook.onNodeStatusChange(event);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in onNodeStatusChange hook:', error);
|
logger.error('Error in onNodeStatusChange hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +221,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.onExecutionComplete(logs);
|
await hook.onExecutionComplete(logs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in onExecutionComplete hook:', error);
|
logger.error('Error in onExecutionComplete hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +233,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.onBlackboardUpdate(variables);
|
await hook.onBlackboardUpdate(variables);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in onBlackboardUpdate hook:', error);
|
logger.error('Error in onBlackboardUpdate hook:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +245,7 @@ export class ExecutionHooksManager {
|
|||||||
try {
|
try {
|
||||||
await hook.onError(error, context);
|
await hook.onError(error, context);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error in onError hook:', err);
|
logger.error('Error in onError hook:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { BehaviorTreeExecutor, ExecutionStatus, ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
import { BehaviorTreeExecutor, ExecutionStatus, ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
||||||
import { BehaviorTreeNode, Connection, NodeExecutionStatus } from '../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode, Connection } from '../../stores';
|
||||||
|
import type { NodeExecutionStatus } from '../../stores';
|
||||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||||
import { DOMCache } from '../../presentation/utils/DOMCache';
|
import { DOMCache } from '../../utils/DOMCache';
|
||||||
import { EditorEventBus, EditorEvent } from '../../infrastructure/events/EditorEventBus';
|
import { EditorEventBus, EditorEvent } from '../../infrastructure/events/EditorEventBus';
|
||||||
import { ExecutionHooksManager } from '../interfaces/IExecutionHooks';
|
import { ExecutionHooksManager } from '../interfaces/IExecutionHooks';
|
||||||
|
import type { Breakpoint } from '../../types/Breakpoint';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
export type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
|
const logger = createLogger('ExecutionController');
|
||||||
|
|
||||||
|
export type ExecutionMode = 'idle' | 'running' | 'paused';
|
||||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||||
|
|
||||||
interface ExecutionControllerConfig {
|
interface ExecutionControllerConfig {
|
||||||
@@ -15,6 +20,7 @@ interface ExecutionControllerConfig {
|
|||||||
onBlackboardUpdate: (variables: BlackboardVariables) => void;
|
onBlackboardUpdate: (variables: BlackboardVariables) => void;
|
||||||
onTickCountUpdate: (count: number) => void;
|
onTickCountUpdate: (count: number) => void;
|
||||||
onExecutionStatusUpdate: (statuses: Map<string, NodeExecutionStatus>, orders: Map<string, number>) => void;
|
onExecutionStatusUpdate: (statuses: Map<string, NodeExecutionStatus>, orders: Map<string, number>) => void;
|
||||||
|
onBreakpointHit?: (nodeId: string, nodeName: string) => void;
|
||||||
eventBus?: EditorEventBus;
|
eventBus?: EditorEventBus;
|
||||||
hooksManager?: ExecutionHooksManager;
|
hooksManager?: ExecutionHooksManager;
|
||||||
}
|
}
|
||||||
@@ -42,6 +48,9 @@ export class ExecutionController {
|
|||||||
private lastStepTime: number = 0;
|
private lastStepTime: number = 0;
|
||||||
private stepInterval: number = 200;
|
private stepInterval: number = 200;
|
||||||
|
|
||||||
|
// 存储断点回调的引用
|
||||||
|
private breakpointCallback: ((nodeId: string, nodeName: string) => void) | null = null;
|
||||||
|
|
||||||
constructor(config: ExecutionControllerConfig) {
|
constructor(config: ExecutionControllerConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.executor = new BehaviorTreeExecutor();
|
this.executor = new BehaviorTreeExecutor();
|
||||||
@@ -104,6 +113,11 @@ export class ExecutionController {
|
|||||||
this.handleExecutionStatusUpdate.bind(this)
|
this.handleExecutionStatusUpdate.bind(this)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 设置断点触发回调(使用存储的回调)
|
||||||
|
if (this.breakpointCallback) {
|
||||||
|
this.executor.setBreakpointCallback(this.breakpointCallback);
|
||||||
|
}
|
||||||
|
|
||||||
this.executor.start();
|
this.executor.start();
|
||||||
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
|
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
|
||||||
|
|
||||||
@@ -198,8 +212,67 @@ export class ExecutionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
step(): void {
|
async step(): Promise<void> {
|
||||||
// 单步执行功能预留
|
if (this.mode === 'running') {
|
||||||
|
await this.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mode === 'idle') {
|
||||||
|
if (!this.currentNodes.length) {
|
||||||
|
logger.warn('No tree loaded for step execution');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.executor) {
|
||||||
|
this.executor = new BehaviorTreeExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.executor.buildTree(
|
||||||
|
this.currentNodes,
|
||||||
|
this.config.rootNodeId,
|
||||||
|
this.currentBlackboard,
|
||||||
|
this.currentConnections,
|
||||||
|
this.handleExecutionStatusUpdate.bind(this)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.breakpointCallback) {
|
||||||
|
this.executor.setBreakpointCallback(this.breakpointCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.executor.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.hooksManager?.triggerBeforeStep?.(0);
|
||||||
|
|
||||||
|
if (this.stepByStepMode && this.pendingStatusUpdates.length > 0) {
|
||||||
|
if (this.currentlyDisplayedIndex < this.pendingStatusUpdates.length) {
|
||||||
|
this.displayNextNode();
|
||||||
|
} else {
|
||||||
|
this.executeSingleTick();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.executeSingleTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventBus?.emit(EditorEvent.EXECUTION_STEPPED, { tickCount: this.tickCount });
|
||||||
|
await this.hooksManager?.triggerAfterStep?.(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in step:', error);
|
||||||
|
await this.hooksManager?.triggerOnError(error as Error, 'step');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mode = 'paused';
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeSingleTick(): void {
|
||||||
|
if (!this.executor) return;
|
||||||
|
|
||||||
|
const deltaTime = 16.67 / 1000;
|
||||||
|
this.executor.tick(deltaTime);
|
||||||
|
|
||||||
|
this.tickCount = this.executor.getTickCount();
|
||||||
|
this.config.onTickCountUpdate(this.tickCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBlackboardVariable(key: string, value: BlackboardValue): void {
|
updateBlackboardVariable(key: string, value: BlackboardValue): void {
|
||||||
@@ -230,6 +303,11 @@ export class ExecutionController {
|
|||||||
this.handleExecutionStatusUpdate.bind(this)
|
this.handleExecutionStatusUpdate.bind(this)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 设置断点触发回调(使用存储的回调)
|
||||||
|
if (this.breakpointCallback) {
|
||||||
|
this.executor.setBreakpointCallback(this.breakpointCallback);
|
||||||
|
}
|
||||||
|
|
||||||
this.executor.start();
|
this.executor.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,8 +418,8 @@ export class ExecutionController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const nodeName = this.currentNodes.find(n => n.id === currentNode.nodeId)?.template.displayName || 'Unknown';
|
const nodeName = this.currentNodes.find((n) => n.id === currentNode.nodeId)?.template.displayName || 'Unknown';
|
||||||
console.log(`[StepByStep] Displaying ${this.currentlyDisplayedIndex + 1}/${this.pendingStatusUpdates.length} | ${nodeName} | Order: ${currentNode.executionOrder} | ID: ${currentNode.nodeId}`);
|
logger.info(`[StepByStep] Displaying ${this.currentlyDisplayedIndex + 1}/${this.pendingStatusUpdates.length} | ${nodeName} | Order: ${currentNode.executionOrder} | ID: ${currentNode.nodeId}`);
|
||||||
this.config.onExecutionStatusUpdate(statusMap, orderMap);
|
this.config.onExecutionStatusUpdate(statusMap, orderMap);
|
||||||
|
|
||||||
this.currentlyDisplayedIndex++;
|
this.currentlyDisplayedIndex++;
|
||||||
@@ -359,10 +437,10 @@ export class ExecutionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.stepByStepMode) {
|
if (this.stepByStepMode) {
|
||||||
const statusesWithOrder = statuses.filter(s => s.executionOrder !== undefined);
|
const statusesWithOrder = statuses.filter((s) => s.executionOrder !== undefined);
|
||||||
|
|
||||||
if (statusesWithOrder.length > 0) {
|
if (statusesWithOrder.length > 0) {
|
||||||
const minOrder = Math.min(...statusesWithOrder.map(s => s.executionOrder!));
|
const minOrder = Math.min(...statusesWithOrder.map((s) => s.executionOrder!));
|
||||||
|
|
||||||
if (minOrder === 1 || this.pendingStatusUpdates.length === 0) {
|
if (minOrder === 1 || this.pendingStatusUpdates.length === 0) {
|
||||||
this.pendingStatusUpdates = statusesWithOrder.sort((a, b) =>
|
this.pendingStatusUpdates = statusesWithOrder.sort((a, b) =>
|
||||||
@@ -372,15 +450,15 @@ export class ExecutionController {
|
|||||||
this.lastStepTime = 0;
|
this.lastStepTime = 0;
|
||||||
} else {
|
} else {
|
||||||
const maxExistingOrder = this.pendingStatusUpdates.length > 0
|
const maxExistingOrder = this.pendingStatusUpdates.length > 0
|
||||||
? Math.max(...this.pendingStatusUpdates.map(s => s.executionOrder || 0))
|
? Math.max(...this.pendingStatusUpdates.map((s) => s.executionOrder || 0))
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const newStatuses = statusesWithOrder.filter(s =>
|
const newStatuses = statusesWithOrder.filter((s) =>
|
||||||
(s.executionOrder || 0) > maxExistingOrder
|
(s.executionOrder || 0) > maxExistingOrder
|
||||||
);
|
);
|
||||||
|
|
||||||
if (newStatuses.length > 0) {
|
if (newStatuses.length > 0) {
|
||||||
console.log(`[StepByStep] Appending ${newStatuses.length} new nodes, orders:`, newStatuses.map(s => s.executionOrder));
|
logger.info(`[StepByStep] Appending ${newStatuses.length} new nodes, orders:`, newStatuses.map((s) => s.executionOrder));
|
||||||
this.pendingStatusUpdates = [
|
this.pendingStatusUpdates = [
|
||||||
...this.pendingStatusUpdates,
|
...this.pendingStatusUpdates,
|
||||||
...newStatuses
|
...newStatuses
|
||||||
@@ -454,4 +532,21 @@ export class ExecutionController {
|
|||||||
this.updateConnectionStyles(currentStatuses, connections);
|
this.updateConnectionStyles(currentStatuses, connections);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBreakpoints(breakpoints: Map<string, Breakpoint>): void {
|
||||||
|
if (this.executor) {
|
||||||
|
this.executor.setBreakpoints(breakpoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置断点触发回调
|
||||||
|
*/
|
||||||
|
setBreakpointCallback(callback: (nodeId: string, nodeName: string) => void): void {
|
||||||
|
this.breakpointCallback = callback;
|
||||||
|
// 如果 executor 已存在,立即设置
|
||||||
|
if (this.executor) {
|
||||||
|
this.executor.setBreakpointCallback(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { GlobalBlackboardConfig, BlackboardValueType, BlackboardVariable } from '@esengine/behavior-tree';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
const logger = createLogger('GlobalBlackboardService');
|
||||||
|
|
||||||
|
export type GlobalBlackboardValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| { x: number; y: number }
|
||||||
|
| { x: number; y: number; z: number }
|
||||||
|
| Record<string, string | number | boolean>
|
||||||
|
| Array<string | number | boolean>;
|
||||||
|
|
||||||
|
export interface GlobalBlackboardVariable {
|
||||||
|
key: string;
|
||||||
|
type: BlackboardValueType;
|
||||||
|
defaultValue: GlobalBlackboardValue;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局黑板服务
|
||||||
|
* 管理跨行为树共享的全局变量
|
||||||
|
*/
|
||||||
|
export class GlobalBlackboardService {
|
||||||
|
private static instance: GlobalBlackboardService;
|
||||||
|
private variables: Map<string, GlobalBlackboardVariable> = new Map();
|
||||||
|
private changeCallbacks: Array<() => void> = [];
|
||||||
|
private projectPath: string | null = null;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): GlobalBlackboardService {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new GlobalBlackboardService();
|
||||||
|
}
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置项目路径
|
||||||
|
*/
|
||||||
|
setProjectPath(path: string | null): void {
|
||||||
|
this.projectPath = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取项目路径
|
||||||
|
*/
|
||||||
|
getProjectPath(): string | null {
|
||||||
|
return this.projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加全局变量
|
||||||
|
*/
|
||||||
|
addVariable(variable: GlobalBlackboardVariable): void {
|
||||||
|
if (this.variables.has(variable.key)) {
|
||||||
|
throw new Error(`全局变量 "${variable.key}" 已存在`);
|
||||||
|
}
|
||||||
|
this.variables.set(variable.key, variable);
|
||||||
|
this.notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新全局变量
|
||||||
|
*/
|
||||||
|
updateVariable(key: string, updates: Partial<Omit<GlobalBlackboardVariable, 'key'>>): void {
|
||||||
|
const variable = this.variables.get(key);
|
||||||
|
if (!variable) {
|
||||||
|
throw new Error(`全局变量 "${key}" 不存在`);
|
||||||
|
}
|
||||||
|
this.variables.set(key, { ...variable, ...updates });
|
||||||
|
this.notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除全局变量
|
||||||
|
*/
|
||||||
|
deleteVariable(key: string): boolean {
|
||||||
|
const result = this.variables.delete(key);
|
||||||
|
if (result) {
|
||||||
|
this.notifyChange();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名全局变量
|
||||||
|
*/
|
||||||
|
renameVariable(oldKey: string, newKey: string): void {
|
||||||
|
if (!this.variables.has(oldKey)) {
|
||||||
|
throw new Error(`全局变量 "${oldKey}" 不存在`);
|
||||||
|
}
|
||||||
|
if (this.variables.has(newKey)) {
|
||||||
|
throw new Error(`全局变量 "${newKey}" 已存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const variable = this.variables.get(oldKey)!;
|
||||||
|
this.variables.delete(oldKey);
|
||||||
|
this.variables.set(newKey, { ...variable, key: newKey });
|
||||||
|
this.notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全局变量
|
||||||
|
*/
|
||||||
|
getVariable(key: string): GlobalBlackboardVariable | undefined {
|
||||||
|
return this.variables.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有全局变量
|
||||||
|
*/
|
||||||
|
getAllVariables(): GlobalBlackboardVariable[] {
|
||||||
|
return Array.from(this.variables.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
getVariablesMap(): Record<string, GlobalBlackboardValue> {
|
||||||
|
const map: Record<string, GlobalBlackboardValue> = {};
|
||||||
|
for (const [, variable] of this.variables) {
|
||||||
|
map[variable.key] = variable.defaultValue;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查变量是否存在
|
||||||
|
*/
|
||||||
|
hasVariable(key: string): boolean {
|
||||||
|
return this.variables.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有变量
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.variables.clear();
|
||||||
|
this.notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出为全局黑板配置
|
||||||
|
*/
|
||||||
|
toConfig(): GlobalBlackboardConfig {
|
||||||
|
const variables: BlackboardVariable[] = [];
|
||||||
|
|
||||||
|
for (const variable of this.variables.values()) {
|
||||||
|
variables.push({
|
||||||
|
name: variable.key,
|
||||||
|
type: variable.type,
|
||||||
|
value: variable.defaultValue,
|
||||||
|
description: variable.description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { version: '1.0', variables };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从配置导入
|
||||||
|
*/
|
||||||
|
fromConfig(config: GlobalBlackboardConfig): void {
|
||||||
|
this.variables.clear();
|
||||||
|
|
||||||
|
if (config.variables && Array.isArray(config.variables)) {
|
||||||
|
for (const variable of config.variables) {
|
||||||
|
this.variables.set(variable.name, {
|
||||||
|
key: variable.name,
|
||||||
|
type: variable.type,
|
||||||
|
defaultValue: variable.value as GlobalBlackboardValue,
|
||||||
|
description: variable.description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化为 JSON
|
||||||
|
*/
|
||||||
|
toJSON(): string {
|
||||||
|
return JSON.stringify(this.toConfig(), null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 JSON 反序列化
|
||||||
|
*/
|
||||||
|
fromJSON(json: string): void {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(json) as GlobalBlackboardConfig;
|
||||||
|
this.fromConfig(config);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to parse global blackboard JSON:', error);
|
||||||
|
throw new Error('无效的全局黑板配置格式');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听变化
|
||||||
|
*/
|
||||||
|
onChange(callback: () => void): () => void {
|
||||||
|
this.changeCallbacks.push(callback);
|
||||||
|
return () => {
|
||||||
|
const index = this.changeCallbacks.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
this.changeCallbacks.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyChange(): void {
|
||||||
|
this.changeCallbacks.forEach((cb) => {
|
||||||
|
try {
|
||||||
|
cb();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in global blackboard change callback:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { NodeTemplates, NodeTemplate } from '@esengine/behavior-tree';
|
||||||
|
import { BehaviorTree } from '../../domain/models/BehaviorTree';
|
||||||
|
import { Node } from '../../domain/models/Node';
|
||||||
|
import { Connection, ConnectionType } from '../../domain/models/Connection';
|
||||||
|
import { Blackboard, BlackboardValue } from '../../domain/models/Blackboard';
|
||||||
|
import { ITreeState } from '../commands/ITreeState';
|
||||||
|
import { createRootNode, createRootNodeTemplate, ROOT_NODE_ID } from '../../domain/constants/RootNode';
|
||||||
|
import { Position } from '../../domain/value-objects/Position';
|
||||||
|
import { DEFAULT_EDITOR_CONFIG } from '../../config/editorConstants';
|
||||||
|
|
||||||
|
const createInitialTree = (): BehaviorTree => {
|
||||||
|
const rootNode = createRootNode();
|
||||||
|
return new BehaviorTree([rootNode], [], Blackboard.empty(), ROOT_NODE_ID);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点执行状态
|
||||||
|
*/
|
||||||
|
export type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行为树数据状态
|
||||||
|
* 唯一的业务数据源
|
||||||
|
*/
|
||||||
|
interface BehaviorTreeDataState {
|
||||||
|
/**
|
||||||
|
* 当前行为树(领域对象)
|
||||||
|
*/
|
||||||
|
tree: BehaviorTree;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存的节点数组(避免每次创建新数组)
|
||||||
|
*/
|
||||||
|
cachedNodes: Node[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存的连接数组(避免每次创建新数组)
|
||||||
|
*/
|
||||||
|
cachedConnections: Connection[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件是否已打开
|
||||||
|
*/
|
||||||
|
isOpen: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前文件路径
|
||||||
|
*/
|
||||||
|
currentFilePath: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前文件名
|
||||||
|
*/
|
||||||
|
currentFileName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 黑板变量(运行时)
|
||||||
|
*/
|
||||||
|
blackboardVariables: Record<string, BlackboardValue>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始黑板变量
|
||||||
|
*/
|
||||||
|
initialBlackboardVariables: Record<string, BlackboardValue>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点初始数据快照(用于执行重置)
|
||||||
|
*/
|
||||||
|
initialNodesData: Map<string, Record<string, unknown>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否正在执行
|
||||||
|
*/
|
||||||
|
isExecuting: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点执行状态
|
||||||
|
*/
|
||||||
|
nodeExecutionStatuses: Map<string, NodeExecutionStatus>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点执行顺序
|
||||||
|
*/
|
||||||
|
nodeExecutionOrders: Map<string, number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布状态(持久化)
|
||||||
|
*/
|
||||||
|
canvasOffset: { x: number; y: number };
|
||||||
|
canvasScale: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制更新计数器
|
||||||
|
*/
|
||||||
|
forceUpdateCounter: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置行为树
|
||||||
|
*/
|
||||||
|
setTree: (tree: BehaviorTree) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置为空树
|
||||||
|
*/
|
||||||
|
reset: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置文件打开状态
|
||||||
|
*/
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前文件信息
|
||||||
|
*/
|
||||||
|
setCurrentFile: (filePath: string | null, fileName: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 JSON 导入
|
||||||
|
*/
|
||||||
|
importFromJSON: (json: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出为 JSON
|
||||||
|
*/
|
||||||
|
exportToJSON: (metadata: { name: string; description: string }) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 黑板相关
|
||||||
|
*/
|
||||||
|
setBlackboardVariables: (variables: Record<string, BlackboardValue>) => void;
|
||||||
|
setInitialBlackboardVariables: (variables: Record<string, BlackboardValue>) => void;
|
||||||
|
updateBlackboardVariable: (name: string, value: BlackboardValue) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行相关
|
||||||
|
*/
|
||||||
|
setIsExecuting: (isExecuting: boolean) => void;
|
||||||
|
saveNodesDataSnapshot: () => void;
|
||||||
|
restoreNodesData: () => void;
|
||||||
|
setNodeExecutionStatus: (nodeId: string, status: NodeExecutionStatus) => void;
|
||||||
|
updateNodeExecutionStatuses: (statuses: Map<string, NodeExecutionStatus>, orders?: Map<string, number>) => void;
|
||||||
|
clearNodeExecutionStatuses: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布状态
|
||||||
|
*/
|
||||||
|
setCanvasOffset: (offset: { x: number; y: number }) => void;
|
||||||
|
setCanvasScale: (scale: number) => void;
|
||||||
|
resetView: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制更新
|
||||||
|
*/
|
||||||
|
triggerForceUpdate: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子节点排序
|
||||||
|
*/
|
||||||
|
sortChildrenByPosition: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有节点(数组形式)
|
||||||
|
*/
|
||||||
|
getNodes: () => Node[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定节点
|
||||||
|
*/
|
||||||
|
getNode: (nodeId: string) => Node | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查节点是否存在
|
||||||
|
*/
|
||||||
|
hasNode: (nodeId: string) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有连接
|
||||||
|
*/
|
||||||
|
getConnections: () => Connection[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取黑板
|
||||||
|
*/
|
||||||
|
getBlackboard: () => Blackboard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取根节点 ID
|
||||||
|
*/
|
||||||
|
getRootNodeId: () => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行为树数据 Store
|
||||||
|
* 实现 ITreeState 接口,供命令使用
|
||||||
|
*/
|
||||||
|
export const useBehaviorTreeDataStore = create<BehaviorTreeDataState>((set, get) => {
|
||||||
|
const initialTree = createInitialTree();
|
||||||
|
return {
|
||||||
|
tree: initialTree,
|
||||||
|
cachedNodes: Array.from(initialTree.nodes),
|
||||||
|
cachedConnections: Array.from(initialTree.connections),
|
||||||
|
isOpen: false,
|
||||||
|
currentFilePath: null,
|
||||||
|
currentFileName: 'Untitled',
|
||||||
|
blackboardVariables: {},
|
||||||
|
initialBlackboardVariables: {},
|
||||||
|
initialNodesData: new Map(),
|
||||||
|
isExecuting: false,
|
||||||
|
nodeExecutionStatuses: new Map(),
|
||||||
|
nodeExecutionOrders: new Map(),
|
||||||
|
canvasOffset: { x: 0, y: 0 },
|
||||||
|
canvasScale: 1,
|
||||||
|
forceUpdateCounter: 0,
|
||||||
|
|
||||||
|
setTree: (tree: BehaviorTree) => {
|
||||||
|
set({
|
||||||
|
tree,
|
||||||
|
cachedNodes: Array.from(tree.nodes),
|
||||||
|
cachedConnections: Array.from(tree.connections)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: () => {
|
||||||
|
const newTree = createInitialTree();
|
||||||
|
set({
|
||||||
|
tree: newTree,
|
||||||
|
cachedNodes: Array.from(newTree.nodes),
|
||||||
|
cachedConnections: Array.from(newTree.connections),
|
||||||
|
isOpen: false,
|
||||||
|
currentFilePath: null,
|
||||||
|
currentFileName: 'Untitled',
|
||||||
|
blackboardVariables: {},
|
||||||
|
initialBlackboardVariables: {},
|
||||||
|
initialNodesData: new Map(),
|
||||||
|
isExecuting: false,
|
||||||
|
nodeExecutionStatuses: new Map(),
|
||||||
|
nodeExecutionOrders: new Map(),
|
||||||
|
canvasOffset: { x: 0, y: 0 },
|
||||||
|
canvasScale: 1,
|
||||||
|
forceUpdateCounter: 0
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setIsOpen: (isOpen: boolean) => set({ isOpen }),
|
||||||
|
|
||||||
|
setCurrentFile: (filePath: string | null, fileName: string) => set({
|
||||||
|
currentFilePath: filePath,
|
||||||
|
currentFileName: fileName
|
||||||
|
}),
|
||||||
|
|
||||||
|
importFromJSON: (json: string) => {
|
||||||
|
const data = JSON.parse(json) as {
|
||||||
|
nodes?: Array<{
|
||||||
|
id: string;
|
||||||
|
template?: { className?: string };
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
children?: string[];
|
||||||
|
}>;
|
||||||
|
connections?: Array<{
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
connectionType?: string;
|
||||||
|
fromProperty?: string;
|
||||||
|
toProperty?: string;
|
||||||
|
}>;
|
||||||
|
blackboard?: Record<string, BlackboardValue>;
|
||||||
|
canvasState?: { offset?: { x: number; y: number }; scale?: number };
|
||||||
|
};
|
||||||
|
const blackboardData = data.blackboard || {};
|
||||||
|
|
||||||
|
// 导入节点
|
||||||
|
const loadedNodes: Node[] = (data.nodes || []).map((nodeObj) => {
|
||||||
|
// 根节点也需要保留文件中的 children 数据
|
||||||
|
if (nodeObj.id === ROOT_NODE_ID) {
|
||||||
|
const position = new Position(
|
||||||
|
nodeObj.position.x || DEFAULT_EDITOR_CONFIG.defaultRootNodePosition.x,
|
||||||
|
nodeObj.position.y || DEFAULT_EDITOR_CONFIG.defaultRootNodePosition.y
|
||||||
|
);
|
||||||
|
return new Node(
|
||||||
|
ROOT_NODE_ID,
|
||||||
|
createRootNodeTemplate(),
|
||||||
|
{ nodeType: 'root' },
|
||||||
|
position,
|
||||||
|
nodeObj.children || []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const className = nodeObj.template?.className;
|
||||||
|
let template = nodeObj.template;
|
||||||
|
|
||||||
|
if (className) {
|
||||||
|
const allTemplates = NodeTemplates.getAllTemplates();
|
||||||
|
const latestTemplate = allTemplates.find((t) => t.className === className);
|
||||||
|
if (latestTemplate) {
|
||||||
|
template = latestTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = new Position(nodeObj.position.x, nodeObj.position.y);
|
||||||
|
return new Node(nodeObj.id, template as NodeTemplate, nodeObj.data, position, nodeObj.children || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadedConnections: Connection[] = (data.connections || []).map((connObj) => {
|
||||||
|
return new Connection(
|
||||||
|
connObj.from,
|
||||||
|
connObj.to,
|
||||||
|
(connObj.connectionType || 'node') as ConnectionType,
|
||||||
|
connObj.fromProperty,
|
||||||
|
connObj.toProperty
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadedBlackboard = Blackboard.fromObject(blackboardData);
|
||||||
|
|
||||||
|
// 创建新的行为树
|
||||||
|
const tree = new BehaviorTree(
|
||||||
|
loadedNodes,
|
||||||
|
loadedConnections,
|
||||||
|
loadedBlackboard,
|
||||||
|
ROOT_NODE_ID
|
||||||
|
);
|
||||||
|
|
||||||
|
set({
|
||||||
|
tree,
|
||||||
|
cachedNodes: Array.from(tree.nodes),
|
||||||
|
cachedConnections: Array.from(tree.connections),
|
||||||
|
isOpen: true,
|
||||||
|
blackboardVariables: blackboardData,
|
||||||
|
initialBlackboardVariables: blackboardData,
|
||||||
|
canvasOffset: data.canvasState?.offset || { x: 0, y: 0 },
|
||||||
|
canvasScale: data.canvasState?.scale || 1
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
exportToJSON: (metadata: { name: string; description: string }) => {
|
||||||
|
const state = get();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const data = {
|
||||||
|
version: '1.0.0',
|
||||||
|
metadata: {
|
||||||
|
name: metadata.name,
|
||||||
|
description: metadata.description,
|
||||||
|
createdAt: now,
|
||||||
|
modifiedAt: now
|
||||||
|
},
|
||||||
|
nodes: state.getNodes().map((n) => n.toObject()),
|
||||||
|
connections: state.getConnections().map((c) => c.toObject()),
|
||||||
|
blackboard: state.getBlackboard().toObject(),
|
||||||
|
canvasState: {
|
||||||
|
offset: state.canvasOffset,
|
||||||
|
scale: state.canvasScale
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
},
|
||||||
|
|
||||||
|
setBlackboardVariables: (variables: Record<string, BlackboardValue>) => {
|
||||||
|
const newBlackboard = Blackboard.fromObject(variables);
|
||||||
|
const currentTree = get().tree;
|
||||||
|
const newTree = new BehaviorTree(
|
||||||
|
currentTree.nodes as Node[],
|
||||||
|
currentTree.connections as Connection[],
|
||||||
|
newBlackboard,
|
||||||
|
currentTree.rootNodeId
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
tree: newTree,
|
||||||
|
cachedNodes: Array.from(newTree.nodes),
|
||||||
|
cachedConnections: Array.from(newTree.connections),
|
||||||
|
blackboardVariables: variables
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setInitialBlackboardVariables: (variables: Record<string, BlackboardValue>) =>
|
||||||
|
set({ initialBlackboardVariables: variables }),
|
||||||
|
|
||||||
|
updateBlackboardVariable: (name: string, value: BlackboardValue) => {
|
||||||
|
const state = get();
|
||||||
|
const newBlackboard = Blackboard.fromObject(state.blackboardVariables);
|
||||||
|
newBlackboard.setValue(name, value);
|
||||||
|
const variables = newBlackboard.toObject();
|
||||||
|
|
||||||
|
const currentTree = state.tree;
|
||||||
|
const newTree = new BehaviorTree(
|
||||||
|
currentTree.nodes as Node[],
|
||||||
|
currentTree.connections as Connection[],
|
||||||
|
newBlackboard,
|
||||||
|
currentTree.rootNodeId
|
||||||
|
);
|
||||||
|
|
||||||
|
set({
|
||||||
|
tree: newTree,
|
||||||
|
cachedNodes: Array.from(newTree.nodes),
|
||||||
|
cachedConnections: Array.from(newTree.connections),
|
||||||
|
blackboardVariables: variables
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setIsExecuting: (isExecuting: boolean) => set({ isExecuting }),
|
||||||
|
|
||||||
|
saveNodesDataSnapshot: () => {
|
||||||
|
const snapshot = new Map<string, Record<string, unknown>>();
|
||||||
|
get().getNodes().forEach((node) => {
|
||||||
|
snapshot.set(node.id, { ...node.data });
|
||||||
|
});
|
||||||
|
set({ initialNodesData: snapshot });
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreNodesData: () => {
|
||||||
|
const state = get();
|
||||||
|
const snapshot = state.initialNodesData;
|
||||||
|
if (snapshot.size === 0) return;
|
||||||
|
|
||||||
|
const updatedNodes = state.getNodes().map((node) => {
|
||||||
|
const savedData = snapshot.get(node.id);
|
||||||
|
if (savedData) {
|
||||||
|
return new Node(node.id, node.template, savedData, node.position, Array.from(node.children));
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
const newTree = new BehaviorTree(
|
||||||
|
updatedNodes,
|
||||||
|
state.getConnections(),
|
||||||
|
state.getBlackboard(),
|
||||||
|
state.getRootNodeId()
|
||||||
|
);
|
||||||
|
|
||||||
|
set({
|
||||||
|
tree: newTree,
|
||||||
|
cachedNodes: Array.from(newTree.nodes),
|
||||||
|
cachedConnections: Array.from(newTree.connections),
|
||||||
|
initialNodesData: new Map()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setNodeExecutionStatus: (nodeId: string, status: NodeExecutionStatus) => {
|
||||||
|
const newStatuses = new Map(get().nodeExecutionStatuses);
|
||||||
|
newStatuses.set(nodeId, status);
|
||||||
|
set({ nodeExecutionStatuses: newStatuses });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNodeExecutionStatuses: (statuses: Map<string, NodeExecutionStatus>, orders?: Map<string, number>) => {
|
||||||
|
set({
|
||||||
|
nodeExecutionStatuses: new Map(statuses),
|
||||||
|
nodeExecutionOrders: orders ? new Map(orders) : new Map()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearNodeExecutionStatuses: () => {
|
||||||
|
set({
|
||||||
|
nodeExecutionStatuses: new Map(),
|
||||||
|
nodeExecutionOrders: new Map()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setCanvasOffset: (offset: { x: number; y: number }) => set({ canvasOffset: offset }),
|
||||||
|
|
||||||
|
setCanvasScale: (scale: number) => set({ canvasScale: scale }),
|
||||||
|
|
||||||
|
resetView: () => set({ canvasOffset: { x: 0, y: 0 }, canvasScale: 1 }),
|
||||||
|
|
||||||
|
triggerForceUpdate: () => set((state) => ({ forceUpdateCounter: state.forceUpdateCounter + 1 })),
|
||||||
|
|
||||||
|
sortChildrenByPosition: () => {
|
||||||
|
const state = get();
|
||||||
|
const nodes = state.getNodes();
|
||||||
|
const nodeMap = new Map<string, Node>();
|
||||||
|
nodes.forEach((node) => nodeMap.set(node.id, node));
|
||||||
|
|
||||||
|
const sortedNodes = nodes.map((node) => {
|
||||||
|
if (node.children.length <= 1) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedChildren = Array.from(node.children).sort((a, b) => {
|
||||||
|
const nodeA = nodeMap.get(a);
|
||||||
|
const nodeB = nodeMap.get(b);
|
||||||
|
if (!nodeA || !nodeB) return 0;
|
||||||
|
return nodeA.position.x - nodeB.position.x;
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Node(node.id, node.template, node.data, node.position, sortedChildren);
|
||||||
|
});
|
||||||
|
|
||||||
|
const newTree = new BehaviorTree(
|
||||||
|
sortedNodes,
|
||||||
|
state.getConnections(),
|
||||||
|
state.getBlackboard(),
|
||||||
|
state.getRootNodeId()
|
||||||
|
);
|
||||||
|
|
||||||
|
set({
|
||||||
|
tree: newTree,
|
||||||
|
cachedNodes: Array.from(newTree.nodes),
|
||||||
|
cachedConnections: Array.from(newTree.connections)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getNodes: () => {
|
||||||
|
return get().cachedNodes;
|
||||||
|
},
|
||||||
|
|
||||||
|
getNode: (nodeId: string) => {
|
||||||
|
try {
|
||||||
|
return get().tree.getNode(nodeId);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
hasNode: (nodeId: string) => {
|
||||||
|
return get().tree.hasNode(nodeId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getConnections: () => {
|
||||||
|
return get().cachedConnections;
|
||||||
|
},
|
||||||
|
|
||||||
|
getBlackboard: () => {
|
||||||
|
return get().tree.blackboard;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRootNodeId: () => {
|
||||||
|
return get().tree.rootNodeId;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TreeState 适配器
|
||||||
|
* 将 Zustand Store 适配为 ITreeState 接口
|
||||||
|
*/
|
||||||
|
export class TreeStateAdapter implements ITreeState {
|
||||||
|
private static instance: TreeStateAdapter | null = null;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): TreeStateAdapter {
|
||||||
|
if (!TreeStateAdapter.instance) {
|
||||||
|
TreeStateAdapter.instance = new TreeStateAdapter();
|
||||||
|
}
|
||||||
|
return TreeStateAdapter.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTree(): BehaviorTree {
|
||||||
|
return useBehaviorTreeDataStore.getState().tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTree(tree: BehaviorTree): void {
|
||||||
|
useBehaviorTreeDataStore.getState().setTree(tree);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Connection, ConnectionType } from '../../domain/models/Connection';
|
import { Connection, ConnectionType } from '../../domain/models/Connection';
|
||||||
import { CommandManager } from '../commands/CommandManager';
|
import { CommandManager } from '@esengine/editor-core';
|
||||||
import { AddConnectionCommand } from '../commands/tree/AddConnectionCommand';
|
import { AddConnectionCommand } from '../commands/tree/AddConnectionCommand';
|
||||||
import { ITreeState } from '../commands/ITreeState';
|
import { ITreeState } from '../commands/ITreeState';
|
||||||
import { IValidator } from '../../domain/interfaces/IValidator';
|
import { IValidator } from '../../domain/interfaces/IValidator';
|
||||||
@@ -2,7 +2,7 @@ import { NodeTemplate } from '@esengine/behavior-tree';
|
|||||||
import { Node } from '../../domain/models/Node';
|
import { Node } from '../../domain/models/Node';
|
||||||
import { Position } from '../../domain/value-objects/Position';
|
import { Position } from '../../domain/value-objects/Position';
|
||||||
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
|
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
|
||||||
import { CommandManager } from '../commands/CommandManager';
|
import { CommandManager } from '@esengine/editor-core';
|
||||||
import { CreateNodeCommand } from '../commands/tree/CreateNodeCommand';
|
import { CreateNodeCommand } from '../commands/tree/CreateNodeCommand';
|
||||||
import { ITreeState } from '../commands/ITreeState';
|
import { ITreeState } from '../commands/ITreeState';
|
||||||
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { CommandManager } from '../commands/CommandManager';
|
import { CommandManager, ICommand } from '@esengine/editor-core';
|
||||||
import { DeleteNodeCommand } from '../commands/tree/DeleteNodeCommand';
|
import { DeleteNodeCommand } from '../commands/tree/DeleteNodeCommand';
|
||||||
import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand';
|
import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand';
|
||||||
import { ITreeState } from '../commands/ITreeState';
|
import { ITreeState } from '../commands/ITreeState';
|
||||||
import { ICommand } from '../commands/ICommand';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除节点用例
|
* 删除节点用例
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Position } from '../../domain/value-objects/Position';
|
import { Position } from '../../domain/value-objects/Position';
|
||||||
import { CommandManager } from '../commands/CommandManager';
|
import { CommandManager } from '@esengine/editor-core';
|
||||||
import { MoveNodeCommand } from '../commands/tree/MoveNodeCommand';
|
import { MoveNodeCommand } from '../commands/tree/MoveNodeCommand';
|
||||||
import { ITreeState } from '../commands/ITreeState';
|
import { ITreeState } from '../commands/ITreeState';
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CommandManager } from '../commands/CommandManager';
|
import { CommandManager } from '@esengine/editor-core';
|
||||||
import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand';
|
import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand';
|
||||||
import { ITreeState } from '../commands/ITreeState';
|
import { ITreeState } from '../commands/ITreeState';
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CommandManager } from '../commands/CommandManager';
|
import { CommandManager } from '@esengine/editor-core';
|
||||||
import { UpdateNodeDataCommand } from '../commands/tree/UpdateNodeDataCommand';
|
import { UpdateNodeDataCommand } from '../commands/tree/UpdateNodeDataCommand';
|
||||||
import { ITreeState } from '../commands/ITreeState';
|
import { ITreeState } from '../commands/ITreeState';
|
||||||
|
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ICompiler, CompileResult, CompilerContext, IFileSystem } from '@esengine/editor-core';
|
||||||
|
import { File, FolderTree, FolderOpen } from 'lucide-react';
|
||||||
|
import { GlobalBlackboardTypeGenerator } from '../generators/GlobalBlackboardTypeGenerator';
|
||||||
|
import { EditorFormatConverter, BehaviorTreeAssetSerializer } from '@esengine/behavior-tree';
|
||||||
|
import { useBehaviorTreeDataStore } from '../application/state/BehaviorTreeDataStore';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
const logger = createLogger('BehaviorTreeCompiler');
|
||||||
|
|
||||||
|
export interface BehaviorTreeCompileOptions {
|
||||||
|
mode: 'single' | 'workspace';
|
||||||
|
assetOutputPath: string;
|
||||||
|
typeOutputPath: string;
|
||||||
|
selectedFiles: string[];
|
||||||
|
fileFormats: Map<string, 'json' | 'binary'>;
|
||||||
|
currentFile?: string;
|
||||||
|
currentFilePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BehaviorTreeCompiler implements ICompiler<BehaviorTreeCompileOptions> {
|
||||||
|
readonly id = 'behavior-tree';
|
||||||
|
readonly name = '行为树编译器';
|
||||||
|
readonly description = '将行为树文件编译为运行时资产和TypeScript类型定义';
|
||||||
|
|
||||||
|
private projectPath: string | null = null;
|
||||||
|
private currentOptions: BehaviorTreeCompileOptions | null = null;
|
||||||
|
|
||||||
|
async compile(options: BehaviorTreeCompileOptions, context: CompilerContext): Promise<CompileResult> {
|
||||||
|
this.projectPath = context.projectPath;
|
||||||
|
this.currentOptions = options;
|
||||||
|
const fileSystem = context.moduleContext.fileSystem;
|
||||||
|
|
||||||
|
if (!this.projectPath) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '错误:没有打开的项目',
|
||||||
|
errors: ['请先打开一个项目']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outputFiles: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (options.mode === 'workspace') {
|
||||||
|
for (const fileId of options.selectedFiles) {
|
||||||
|
const format = options.fileFormats.get(fileId) || 'binary';
|
||||||
|
const result = await this.compileFile(fileId, options.assetOutputPath, options.typeOutputPath, format, fileSystem);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
outputFiles.push(...(result.outputFiles || []));
|
||||||
|
} else {
|
||||||
|
errors.push(`${fileId}: ${result.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalTypeResult = await this.generateGlobalBlackboardTypes(options.typeOutputPath, fileSystem);
|
||||||
|
if (globalTypeResult.success) {
|
||||||
|
outputFiles.push(...(globalTypeResult.outputFiles || []));
|
||||||
|
} else {
|
||||||
|
errors.push(globalTypeResult.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const currentFileName = this.getCurrentFileName();
|
||||||
|
const currentFilePath = this.currentOptions?.currentFilePath;
|
||||||
|
|
||||||
|
if (!currentFileName) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '错误:没有打开的行为树文件',
|
||||||
|
errors: ['请先打开一个行为树文件']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = options.fileFormats.get(currentFileName) || 'binary';
|
||||||
|
const result = await this.compileFileWithPath(
|
||||||
|
currentFileName,
|
||||||
|
currentFilePath || `${this.projectPath}/.ecs/behaviors/${currentFileName}.btree`,
|
||||||
|
options.assetOutputPath,
|
||||||
|
options.typeOutputPath,
|
||||||
|
format,
|
||||||
|
fileSystem
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
outputFiles.push(...(result.outputFiles || []));
|
||||||
|
} else {
|
||||||
|
errors.push(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `编译完成,但有 ${errors.length} 个错误`,
|
||||||
|
outputFiles,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `成功编译 ${outputFiles.length} 个文件`,
|
||||||
|
outputFiles
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `编译失败: ${error}`,
|
||||||
|
errors: [String(error)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async compileFile(
|
||||||
|
fileId: string,
|
||||||
|
assetOutputPath: string,
|
||||||
|
typeOutputPath: string,
|
||||||
|
format: 'json' | 'binary',
|
||||||
|
fileSystem: IFileSystem
|
||||||
|
): Promise<CompileResult> {
|
||||||
|
const btreePath = `${this.projectPath}/.ecs/behaviors/${fileId}.btree`;
|
||||||
|
return this.compileFileWithPath(fileId, btreePath, assetOutputPath, typeOutputPath, format, fileSystem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async compileFileWithPath(
|
||||||
|
fileId: string,
|
||||||
|
btreePath: string,
|
||||||
|
assetOutputPath: string,
|
||||||
|
typeOutputPath: string,
|
||||||
|
format: 'json' | 'binary',
|
||||||
|
fileSystem: IFileSystem
|
||||||
|
): Promise<CompileResult> {
|
||||||
|
try {
|
||||||
|
logger.info(`Reading file: ${btreePath}`);
|
||||||
|
const fileContent = await fileSystem.readFile(btreePath);
|
||||||
|
const treeData = JSON.parse(fileContent);
|
||||||
|
|
||||||
|
const editorFormat = this.convertToEditorFormat(treeData, fileId);
|
||||||
|
const asset = EditorFormatConverter.toAsset(editorFormat);
|
||||||
|
|
||||||
|
let runtimeAsset: string | Uint8Array;
|
||||||
|
const extension = format === 'json' ? '.btree.json' : '.btree.bin';
|
||||||
|
const assetPath = `${assetOutputPath}/${fileId}${extension}`;
|
||||||
|
|
||||||
|
if (format === 'json') {
|
||||||
|
runtimeAsset = BehaviorTreeAssetSerializer.serialize(asset, { format: 'json', pretty: true });
|
||||||
|
await fileSystem.writeFile(assetPath, runtimeAsset as string);
|
||||||
|
} else {
|
||||||
|
runtimeAsset = BehaviorTreeAssetSerializer.serialize(asset, { format: 'binary' });
|
||||||
|
await fileSystem.writeBinary(assetPath, runtimeAsset as Uint8Array);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blackboardVars = treeData.blackboard || {};
|
||||||
|
logger.info(`${fileId} blackboard vars:`, blackboardVars);
|
||||||
|
const typeContent = this.generateBlackboardTypes(fileId, blackboardVars);
|
||||||
|
const typePath = `${typeOutputPath}/${fileId}.ts`;
|
||||||
|
await fileSystem.writeFile(typePath, typeContent);
|
||||||
|
logger.info(`Generated type file: ${typePath}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `成功编译 ${fileId}`,
|
||||||
|
outputFiles: [assetPath, typePath]
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `编译 ${fileId} 失败: ${error}`,
|
||||||
|
errors: [String(error)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将存储的 JSON 数据转换为 EditorFormat
|
||||||
|
* @param treeData - 从文件读取的原始数据
|
||||||
|
* @param fileId - 文件标识符
|
||||||
|
* @returns 编辑器格式数据
|
||||||
|
*/
|
||||||
|
private convertToEditorFormat(treeData: any, fileId: string): any {
|
||||||
|
// 如果已经是新格式(包含 nodes 数组),直接使用
|
||||||
|
if (treeData.nodes && Array.isArray(treeData.nodes)) {
|
||||||
|
return {
|
||||||
|
version: treeData.version || '1.0.0',
|
||||||
|
metadata: treeData.metadata || {
|
||||||
|
name: fileId,
|
||||||
|
description: ''
|
||||||
|
},
|
||||||
|
nodes: treeData.nodes,
|
||||||
|
connections: treeData.connections || [],
|
||||||
|
blackboard: treeData.blackboard || {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧格式,返回默认结构
|
||||||
|
return {
|
||||||
|
version: '1.0.0',
|
||||||
|
metadata: {
|
||||||
|
name: fileId,
|
||||||
|
description: ''
|
||||||
|
},
|
||||||
|
nodes: [],
|
||||||
|
connections: [],
|
||||||
|
blackboard: treeData.blackboard || {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateGlobalBlackboardTypes(
|
||||||
|
typeOutputPath: string,
|
||||||
|
fileSystem: IFileSystem
|
||||||
|
): Promise<CompileResult> {
|
||||||
|
try {
|
||||||
|
if (!this.projectPath) {
|
||||||
|
throw new Error('No project path');
|
||||||
|
}
|
||||||
|
|
||||||
|
const btreeFiles = await fileSystem.scanFiles(`${this.projectPath}/.ecs/behaviors`, '*.btree');
|
||||||
|
const variables: any[] = [];
|
||||||
|
|
||||||
|
for (const fileId of btreeFiles) {
|
||||||
|
const btreePath = `${this.projectPath}/.ecs/behaviors/${fileId}.btree`;
|
||||||
|
const fileContent = await fileSystem.readFile(btreePath);
|
||||||
|
const treeData = JSON.parse(fileContent);
|
||||||
|
const blackboard = treeData.blackboard || {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(blackboard)) {
|
||||||
|
variables.push({
|
||||||
|
name: key,
|
||||||
|
type: this.inferType(value),
|
||||||
|
defaultValue: value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
version: '1.0.0',
|
||||||
|
variables
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeContent = GlobalBlackboardTypeGenerator.generate(config);
|
||||||
|
const typePath = `${typeOutputPath}/GlobalBlackboard.ts`;
|
||||||
|
await fileSystem.writeFile(typePath, typeContent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '成功生成全局黑板类型',
|
||||||
|
outputFiles: [typePath]
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `生成全局黑板类型失败: ${error}`,
|
||||||
|
errors: [String(error)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateBlackboardTypes(behaviorName: string, blackboardVars: Record<string, unknown>): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`export interface ${behaviorName}Blackboard {`);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(blackboardVars)) {
|
||||||
|
const type = this.inferType(value);
|
||||||
|
lines.push(` ${key}: ${type};`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('}');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferType(value: unknown): string {
|
||||||
|
if (value === null) return 'null';
|
||||||
|
if (value === undefined) return 'undefined';
|
||||||
|
if (typeof value === 'string') return 'string';
|
||||||
|
if (typeof value === 'number') return 'number';
|
||||||
|
if (typeof value === 'boolean') return 'boolean';
|
||||||
|
if (Array.isArray(value)) return 'unknown[]';
|
||||||
|
if (typeof value === 'object') return 'Record<string, unknown>';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrentFileName(): string | null {
|
||||||
|
if (this.currentOptions?.currentFile) {
|
||||||
|
return this.currentOptions.currentFile;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateOptions(options: BehaviorTreeCompileOptions): string | null {
|
||||||
|
if (!options.assetOutputPath) {
|
||||||
|
return '请选择资产输出路径';
|
||||||
|
}
|
||||||
|
if (!options.typeOutputPath) {
|
||||||
|
return '请选择类型定义输出路径';
|
||||||
|
}
|
||||||
|
if (options.mode === 'workspace' && options.selectedFiles.length === 0) {
|
||||||
|
return '请至少选择一个文件';
|
||||||
|
}
|
||||||
|
if (options.mode === 'single' && !this.getCurrentFileName()) {
|
||||||
|
return '没有打开的行为树文件';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createConfigUI(onOptionsChange: (options: BehaviorTreeCompileOptions) => void, context: CompilerContext): React.ReactElement {
|
||||||
|
return <BehaviorTreeCompileConfigUI onOptionsChange={onOptionsChange} context={context} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigUIProps {
|
||||||
|
onOptionsChange: (options: BehaviorTreeCompileOptions) => void;
|
||||||
|
context: CompilerContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BehaviorTreeCompileConfigUI({ onOptionsChange, context }: ConfigUIProps) {
|
||||||
|
const { projectPath, moduleContext } = context;
|
||||||
|
const { fileSystem, dialog } = moduleContext;
|
||||||
|
const [mode, setMode] = useState<'single' | 'workspace'>('workspace');
|
||||||
|
const [assetOutputPath, setAssetOutputPath] = useState('');
|
||||||
|
const [typeOutputPath, setTypeOutputPath] = useState('');
|
||||||
|
const [availableFiles, setAvailableFiles] = useState<string[]>([]);
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [fileFormats, setFileFormats] = useState<Map<string, 'json' | 'binary'>>(new Map());
|
||||||
|
const [selectAll, setSelectAll] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadFiles = async () => {
|
||||||
|
if (projectPath) {
|
||||||
|
const files = await fileSystem.scanFiles(`${projectPath}/.ecs/behaviors`, '*.btree');
|
||||||
|
setAvailableFiles(files);
|
||||||
|
setSelectedFiles(new Set(files));
|
||||||
|
|
||||||
|
const formats = new Map<string, 'json' | 'binary'>();
|
||||||
|
files.forEach((file: string) => formats.set(file, 'binary'));
|
||||||
|
setFileFormats(formats);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadFiles();
|
||||||
|
|
||||||
|
const savedAssetPath = localStorage.getItem('export-asset-path');
|
||||||
|
const savedTypePath = localStorage.getItem('export-type-path');
|
||||||
|
|
||||||
|
// Set default paths based on projectPath if no saved paths
|
||||||
|
if (savedAssetPath) {
|
||||||
|
setAssetOutputPath(savedAssetPath);
|
||||||
|
} else if (projectPath) {
|
||||||
|
const defaultAssetPath = `${projectPath}/assets/behaviors`;
|
||||||
|
setAssetOutputPath(defaultAssetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedTypePath) {
|
||||||
|
setTypeOutputPath(savedTypePath);
|
||||||
|
} else if (projectPath) {
|
||||||
|
const defaultTypePath = `${projectPath}/src/types/behaviors`;
|
||||||
|
setTypeOutputPath(defaultTypePath);
|
||||||
|
}
|
||||||
|
}, [projectPath]);
|
||||||
|
|
||||||
|
const currentFilePath = useBehaviorTreeDataStore((state) => state.currentFilePath);
|
||||||
|
const currentFileName = useBehaviorTreeDataStore((state) => state.currentFileName);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onOptionsChange({
|
||||||
|
mode,
|
||||||
|
assetOutputPath,
|
||||||
|
typeOutputPath,
|
||||||
|
selectedFiles: mode === 'workspace' ? Array.from(selectedFiles) : [],
|
||||||
|
fileFormats,
|
||||||
|
currentFile: currentFileName || undefined,
|
||||||
|
currentFilePath: currentFilePath || undefined
|
||||||
|
});
|
||||||
|
}, [mode, assetOutputPath, typeOutputPath, selectedFiles, fileFormats, onOptionsChange, currentFileName, currentFilePath]);
|
||||||
|
|
||||||
|
const handleBrowseAssetPath = async () => {
|
||||||
|
const selected = await dialog.openDialog({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: '选择资产输出目录',
|
||||||
|
defaultPath: assetOutputPath || projectPath || undefined
|
||||||
|
});
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
setAssetOutputPath(selected);
|
||||||
|
localStorage.setItem('export-asset-path', selected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseTypePath = async () => {
|
||||||
|
const selected = await dialog.openDialog({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: '选择类型定义输出目录',
|
||||||
|
defaultPath: typeOutputPath || projectPath || undefined
|
||||||
|
});
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
setTypeOutputPath(selected);
|
||||||
|
localStorage.setItem('export-type-path', selected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectAll) {
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
setSelectAll(false);
|
||||||
|
} else {
|
||||||
|
setSelectedFiles(new Set(availableFiles));
|
||||||
|
setSelectAll(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleFile = (file: string) => {
|
||||||
|
const newSelected = new Set(selectedFiles);
|
||||||
|
if (newSelected.has(file)) {
|
||||||
|
newSelected.delete(file);
|
||||||
|
} else {
|
||||||
|
newSelected.add(file);
|
||||||
|
}
|
||||||
|
setSelectedFiles(newSelected);
|
||||||
|
setSelectAll(newSelected.size === availableFiles.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileFormatChange = (file: string, format: 'json' | 'binary') => {
|
||||||
|
const newFormats = new Map(fileFormats);
|
||||||
|
newFormats.set(file, format);
|
||||||
|
setFileFormats(newFormats);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{/* 模式选择 */}
|
||||||
|
<div style={{ display: 'flex', gap: '8px', borderBottom: '1px solid #3e3e3e', paddingBottom: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('workspace')}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: mode === 'workspace' ? '#0e639c' : '#3a3a3a',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '13px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderTree size={16} />
|
||||||
|
工作区编译
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('single')}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: mode === 'single' ? '#0e639c' : '#3a3a3a',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '13px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<File size={16} />
|
||||||
|
当前文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 模式说明 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#8ac3ff',
|
||||||
|
lineHeight: '1.5'
|
||||||
|
}}>
|
||||||
|
{mode === 'workspace' ? (
|
||||||
|
<>
|
||||||
|
<strong>工作区模式:</strong>将编译 <code style={{ background: '#0d2744', padding: '2px 4px', borderRadius: '2px' }}>{projectPath}/.ecs/behaviors/</code> 目录下的所有 .btree 文件
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>当前文件模式:</strong>将编译当前打开的文件
|
||||||
|
{currentFilePath && (
|
||||||
|
<div style={{ marginTop: '4px', wordBreak: 'break-all' }}>
|
||||||
|
<code style={{ background: '#0d2744', padding: '2px 4px', borderRadius: '2px' }}>{currentFilePath}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!currentFilePath && (
|
||||||
|
<div style={{ marginTop: '4px', color: '#ffaa00' }}>
|
||||||
|
⚠️ 未打开任何文件
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 资产输出路径 */}
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600, color: '#ccc' }}>
|
||||||
|
资产输出路径
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={assetOutputPath}
|
||||||
|
onChange={(e) => setAssetOutputPath(e.target.value)}
|
||||||
|
placeholder="选择资产输出目录..."
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#2d2d2d',
|
||||||
|
border: '1px solid #3a3a3a',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#ccc',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleBrowseAssetPath}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#0e639c',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
浏览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TypeScript类型输出路径 */}
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600, color: '#ccc' }}>
|
||||||
|
TypeScript 类型定义输出路径
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={typeOutputPath}
|
||||||
|
onChange={(e) => setTypeOutputPath(e.target.value)}
|
||||||
|
placeholder="选择类型定义输出目录..."
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#2d2d2d',
|
||||||
|
border: '1px solid #3a3a3a',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#ccc',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleBrowseTypePath}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#0e639c',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
浏览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文件列表 */}
|
||||||
|
{mode === 'workspace' && availableFiles.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 600, color: '#ccc' }}>
|
||||||
|
选择文件 ({selectedFiles.size}/{availableFiles.length})
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
background: '#3a3a3a',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '3px',
|
||||||
|
color: '#ccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectAll ? '取消全选' : '全选'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: '200px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
{availableFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
background: selectedFiles.has(file) ? '#2a2d2e' : '#1e1e1e',
|
||||||
|
border: `1px solid ${selectedFiles.has(file) ? '#0e639c' : '#3a3a3a'}`,
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedFiles.has(file)}
|
||||||
|
onChange={() => handleToggleFile(file)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<File size={14} style={{ color: '#ab47bc' }} />
|
||||||
|
<span style={{ flex: 1, color: '#ccc' }}>{file}.btree</span>
|
||||||
|
<select
|
||||||
|
value={fileFormats.get(file) || 'binary'}
|
||||||
|
onChange={(e) => handleFileFormatChange(file, e.target.value as 'json' | 'binary')}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: '#2d2d2d',
|
||||||
|
border: '1px solid #3a3a3a',
|
||||||
|
borderRadius: '3px',
|
||||||
|
color: '#ccc',
|
||||||
|
fontSize: '11px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="binary">二进制</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
|
import { NodeTemplate, BlackboardValueType } from '@esengine/behavior-tree';
|
||||||
|
import { useBehaviorTreeDataStore, BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
|
||||||
|
import { useUIStore } from '../stores';
|
||||||
|
import { showToast as notificationShowToast } from '../services/NotificationService';
|
||||||
|
import { BlackboardValue } from '../domain/models/Blackboard';
|
||||||
|
import { GlobalBlackboardService } from '../application/services/GlobalBlackboardService';
|
||||||
|
import { BehaviorTreeCanvas } from './canvas/BehaviorTreeCanvas';
|
||||||
|
import { ConnectionLayer } from './connections/ConnectionLayer';
|
||||||
|
import { NodeFactory } from '../infrastructure/factories/NodeFactory';
|
||||||
|
import { TreeValidator } from '../domain/services/TreeValidator';
|
||||||
|
import { useNodeOperations } from '../hooks/useNodeOperations';
|
||||||
|
import { useConnectionOperations } from '../hooks/useConnectionOperations';
|
||||||
|
import { useCommandHistory } from '../hooks/useCommandHistory';
|
||||||
|
import { useNodeDrag } from '../hooks/useNodeDrag';
|
||||||
|
import { usePortConnection } from '../hooks/usePortConnection';
|
||||||
|
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||||
|
import { useDropHandler } from '../hooks/useDropHandler';
|
||||||
|
import { useCanvasMouseEvents } from '../hooks/useCanvasMouseEvents';
|
||||||
|
import { useContextMenu } from '../hooks/useContextMenu';
|
||||||
|
import { useQuickCreateMenu } from '../hooks/useQuickCreateMenu';
|
||||||
|
import { EditorToolbar } from './toolbar/EditorToolbar';
|
||||||
|
import { QuickCreateMenu } from './menu/QuickCreateMenu';
|
||||||
|
import { NodeContextMenu } from './menu/NodeContextMenu';
|
||||||
|
import { BehaviorTreeNode as BehaviorTreeNodeComponent } from './nodes/BehaviorTreeNode';
|
||||||
|
import { BlackboardPanel } from './blackboard/BlackboardPanel';
|
||||||
|
import { getPortPosition as getPortPositionUtil } from '../utils/portUtils';
|
||||||
|
import { useExecutionController } from '../hooks/useExecutionController';
|
||||||
|
import { useNodeTracking } from '../hooks/useNodeTracking';
|
||||||
|
import { useEditorHandlers } from '../hooks/useEditorHandlers';
|
||||||
|
import { ICON_MAP, DEFAULT_EDITOR_CONFIG } from '../config/editorConstants';
|
||||||
|
import '../styles/BehaviorTreeNode.css';
|
||||||
|
|
||||||
|
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||||
|
|
||||||
|
interface BehaviorTreeEditorProps {
|
||||||
|
onNodeSelect?: (node: BehaviorTreeNode) => void;
|
||||||
|
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
|
||||||
|
blackboardVariables?: BlackboardVariables;
|
||||||
|
projectPath?: string | null;
|
||||||
|
showToolbar?: boolean;
|
||||||
|
showToast?: (message: string, type?: 'success' | 'error' | 'warning' | 'info') => void;
|
||||||
|
currentFileName?: string;
|
||||||
|
hasUnsavedChanges?: boolean;
|
||||||
|
onSave?: () => void;
|
||||||
|
onOpen?: () => void;
|
||||||
|
onExport?: () => void;
|
||||||
|
onCopyToClipboard?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
||||||
|
onNodeSelect,
|
||||||
|
onNodeCreate,
|
||||||
|
blackboardVariables = {},
|
||||||
|
projectPath = null,
|
||||||
|
showToolbar = true,
|
||||||
|
showToast: showToastProp,
|
||||||
|
currentFileName,
|
||||||
|
hasUnsavedChanges = false,
|
||||||
|
onSave,
|
||||||
|
onOpen,
|
||||||
|
onExport,
|
||||||
|
onCopyToClipboard
|
||||||
|
}) => {
|
||||||
|
// 使用传入的 showToast 或回退到 NotificationService
|
||||||
|
const showToast = showToastProp || notificationShowToast;
|
||||||
|
|
||||||
|
// 数据 store(行为树数据 - 唯一数据源)
|
||||||
|
const {
|
||||||
|
canvasOffset,
|
||||||
|
canvasScale,
|
||||||
|
triggerForceUpdate,
|
||||||
|
sortChildrenByPosition,
|
||||||
|
setBlackboardVariables,
|
||||||
|
setInitialBlackboardVariables,
|
||||||
|
setIsExecuting,
|
||||||
|
initialBlackboardVariables,
|
||||||
|
isExecuting,
|
||||||
|
saveNodesDataSnapshot,
|
||||||
|
restoreNodesData,
|
||||||
|
nodeExecutionStatuses,
|
||||||
|
nodeExecutionOrders,
|
||||||
|
resetView
|
||||||
|
} = useBehaviorTreeDataStore();
|
||||||
|
|
||||||
|
// 使用缓存的节点和连接数组(store 中已经优化,只在 tree 真正变化时更新)
|
||||||
|
const nodes = useBehaviorTreeDataStore((state) => state.cachedNodes);
|
||||||
|
const connections = useBehaviorTreeDataStore((state) => state.cachedConnections);
|
||||||
|
|
||||||
|
// UI store(UI 交互状态)
|
||||||
|
const {
|
||||||
|
selectedNodeIds,
|
||||||
|
selectedConnection,
|
||||||
|
draggingNodeId,
|
||||||
|
dragStartPositions,
|
||||||
|
isDraggingNode,
|
||||||
|
dragDelta,
|
||||||
|
connectingFrom,
|
||||||
|
connectingFromProperty,
|
||||||
|
connectingToPos,
|
||||||
|
isBoxSelecting,
|
||||||
|
boxSelectStart,
|
||||||
|
boxSelectEnd,
|
||||||
|
setSelectedNodeIds,
|
||||||
|
setSelectedConnection,
|
||||||
|
startDragging,
|
||||||
|
stopDragging,
|
||||||
|
setIsDraggingNode,
|
||||||
|
setDragDelta,
|
||||||
|
setConnectingFrom,
|
||||||
|
setConnectingFromProperty,
|
||||||
|
setConnectingToPos,
|
||||||
|
clearConnecting,
|
||||||
|
setIsBoxSelecting,
|
||||||
|
setBoxSelectStart,
|
||||||
|
setBoxSelectEnd,
|
||||||
|
clearBoxSelect
|
||||||
|
} = useUIStore();
|
||||||
|
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
const stopExecutionRef = useRef<(() => void) | null>(null);
|
||||||
|
const justFinishedBoxSelectRef = useRef(false);
|
||||||
|
const [blackboardCollapsed, setBlackboardCollapsed] = useState(false);
|
||||||
|
const [globalVariables, setGlobalVariables] = useState<Record<string, BlackboardValue>>({});
|
||||||
|
|
||||||
|
const updateVariable = useBehaviorTreeDataStore((state) => state.updateBlackboardVariable);
|
||||||
|
|
||||||
|
const globalBlackboardService = useMemo(() => GlobalBlackboardService.getInstance(), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectPath) {
|
||||||
|
globalBlackboardService.setProjectPath(projectPath);
|
||||||
|
setGlobalVariables(globalBlackboardService.getVariablesMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = globalBlackboardService.onChange(() => {
|
||||||
|
setGlobalVariables(globalBlackboardService.getVariablesMap());
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [globalBlackboardService, projectPath]);
|
||||||
|
|
||||||
|
const handleGlobalVariableAdd = useCallback((key: string, value: any, type: string) => {
|
||||||
|
try {
|
||||||
|
let bbType: BlackboardValueType;
|
||||||
|
switch (type) {
|
||||||
|
case 'number':
|
||||||
|
bbType = BlackboardValueType.Number;
|
||||||
|
break;
|
||||||
|
case 'boolean':
|
||||||
|
bbType = BlackboardValueType.Boolean;
|
||||||
|
break;
|
||||||
|
case 'object':
|
||||||
|
bbType = BlackboardValueType.Object;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
bbType = BlackboardValueType.String;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalBlackboardService.addVariable({ key, type: bbType, defaultValue: value });
|
||||||
|
showToast(`全局变量 "${key}" 已添加`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`添加全局变量失败: ${error}`, 'error');
|
||||||
|
}
|
||||||
|
}, [globalBlackboardService, showToast]);
|
||||||
|
|
||||||
|
const handleGlobalVariableChange = useCallback((key: string, value: any) => {
|
||||||
|
try {
|
||||||
|
globalBlackboardService.updateVariable(key, { defaultValue: value });
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`更新全局变量失败: ${error}`, 'error');
|
||||||
|
}
|
||||||
|
}, [globalBlackboardService, showToast]);
|
||||||
|
|
||||||
|
const handleGlobalVariableDelete = useCallback((key: string) => {
|
||||||
|
try {
|
||||||
|
globalBlackboardService.deleteVariable(key);
|
||||||
|
showToast(`全局变量 "${key}" 已删除`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`删除全局变量失败: ${error}`, 'error');
|
||||||
|
}
|
||||||
|
}, [globalBlackboardService, showToast]);
|
||||||
|
|
||||||
|
// 监听框选状态变化,当框选结束时设置标记
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isBoxSelecting && justFinishedBoxSelectRef.current) {
|
||||||
|
// 框选刚结束,在下一个事件循环清除标记
|
||||||
|
setTimeout(() => {
|
||||||
|
justFinishedBoxSelectRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
} else if (isBoxSelecting) {
|
||||||
|
// 正在框选
|
||||||
|
justFinishedBoxSelectRef.current = true;
|
||||||
|
}
|
||||||
|
}, [isBoxSelecting]);
|
||||||
|
|
||||||
|
// Node factory
|
||||||
|
const nodeFactory = useMemo(() => new NodeFactory(), []);
|
||||||
|
|
||||||
|
// 验证器
|
||||||
|
const validator = useMemo(() => new TreeValidator(), []);
|
||||||
|
|
||||||
|
// 命令历史
|
||||||
|
const { commandManager, canUndo, canRedo, undo, redo } = useCommandHistory();
|
||||||
|
|
||||||
|
// 节点操作
|
||||||
|
const nodeOperations = useNodeOperations(
|
||||||
|
nodeFactory,
|
||||||
|
commandManager
|
||||||
|
);
|
||||||
|
|
||||||
|
// 连接操作
|
||||||
|
const connectionOperations = useConnectionOperations(
|
||||||
|
validator,
|
||||||
|
commandManager
|
||||||
|
);
|
||||||
|
|
||||||
|
// 上下文菜单
|
||||||
|
const contextMenu = useContextMenu();
|
||||||
|
|
||||||
|
// 执行控制器
|
||||||
|
const {
|
||||||
|
executionMode,
|
||||||
|
executionSpeed,
|
||||||
|
handlePlay,
|
||||||
|
handlePause,
|
||||||
|
handleStop,
|
||||||
|
handleStep,
|
||||||
|
handleSpeedChange,
|
||||||
|
controller
|
||||||
|
} = useExecutionController({
|
||||||
|
rootNodeId: ROOT_NODE_ID,
|
||||||
|
projectPath: projectPath || '',
|
||||||
|
blackboardVariables,
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
initialBlackboardVariables,
|
||||||
|
onBlackboardUpdate: setBlackboardVariables,
|
||||||
|
onInitialBlackboardSave: setInitialBlackboardVariables,
|
||||||
|
onExecutingChange: setIsExecuting,
|
||||||
|
onSaveNodesDataSnapshot: saveNodesDataSnapshot,
|
||||||
|
onRestoreNodesData: restoreNodesData,
|
||||||
|
sortChildrenByPosition
|
||||||
|
});
|
||||||
|
|
||||||
|
const executorRef = useRef(null);
|
||||||
|
const { uncommittedNodeIds } = useNodeTracking({ nodes, executionMode });
|
||||||
|
|
||||||
|
// 快速创建菜单
|
||||||
|
const quickCreateMenu = useQuickCreateMenu({
|
||||||
|
nodeOperations,
|
||||||
|
connectionOperations,
|
||||||
|
canvasRef,
|
||||||
|
canvasOffset,
|
||||||
|
canvasScale,
|
||||||
|
connectingFrom,
|
||||||
|
connectingFromProperty,
|
||||||
|
clearConnecting,
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
executionMode,
|
||||||
|
onStop: () => stopExecutionRef.current?.(),
|
||||||
|
onNodeCreate,
|
||||||
|
showToast
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleNodeClick,
|
||||||
|
handleResetView,
|
||||||
|
handleClearCanvas
|
||||||
|
} = useEditorHandlers({
|
||||||
|
isDraggingNode,
|
||||||
|
selectedNodeIds,
|
||||||
|
setSelectedNodeIds,
|
||||||
|
resetView,
|
||||||
|
resetTree: useBehaviorTreeDataStore.getState().reset,
|
||||||
|
triggerForceUpdate,
|
||||||
|
onNodeSelect
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加缺少的处理函数
|
||||||
|
const handleCanvasClick = (e: React.MouseEvent) => {
|
||||||
|
// 如果正在框选或者刚刚结束框选,不要清空选择
|
||||||
|
// 因为 click 事件会在 mouseup 之后触发,会清空框选的结果
|
||||||
|
if (!isDraggingNode && !isBoxSelecting && !justFinishedBoxSelectRef.current) {
|
||||||
|
setSelectedNodeIds([]);
|
||||||
|
setSelectedConnection(null);
|
||||||
|
}
|
||||||
|
// 关闭右键菜单
|
||||||
|
contextMenu.closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasContextMenu = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
contextMenu.handleCanvasContextMenu(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodeContextMenu = (e: React.MouseEvent, node: BehaviorTreeNode) => {
|
||||||
|
e.preventDefault();
|
||||||
|
contextMenu.handleNodeContextMenu(e, node);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnectionClick = (e: React.MouseEvent, fromId: string, toId: string) => {
|
||||||
|
setSelectedConnection({ from: fromId, to: toId });
|
||||||
|
setSelectedNodeIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasDoubleClick = (e: React.MouseEvent) => {
|
||||||
|
quickCreateMenu.openQuickCreateMenu(
|
||||||
|
{ x: e.clientX, y: e.clientY },
|
||||||
|
'create'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 黑板变量管理
|
||||||
|
const handleBlackboardVariableAdd = (key: string, value: any) => {
|
||||||
|
const newVariables = { ...blackboardVariables, [key]: value };
|
||||||
|
setBlackboardVariables(newVariables);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlackboardVariableChange = (key: string, value: any) => {
|
||||||
|
const newVariables = { ...blackboardVariables, [key]: value };
|
||||||
|
setBlackboardVariables(newVariables);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlackboardVariableDelete = (key: string) => {
|
||||||
|
const newVariables = { ...blackboardVariables };
|
||||||
|
delete newVariables[key];
|
||||||
|
setBlackboardVariables(newVariables);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetBlackboardVariable = (name: string) => {
|
||||||
|
const initialValue = initialBlackboardVariables[name];
|
||||||
|
if (initialValue !== undefined) {
|
||||||
|
updateVariable(name, initialValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetAllBlackboardVariables = () => {
|
||||||
|
setBlackboardVariables(initialBlackboardVariables);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlackboardVariableRename = (oldKey: string, newKey: string) => {
|
||||||
|
if (oldKey === newKey) return;
|
||||||
|
const newVariables = { ...blackboardVariables };
|
||||||
|
newVariables[newKey] = newVariables[oldKey];
|
||||||
|
delete newVariables[oldKey];
|
||||||
|
setBlackboardVariables(newVariables);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 节点拖拽
|
||||||
|
const {
|
||||||
|
handleNodeMouseDown,
|
||||||
|
handleNodeMouseMove,
|
||||||
|
handleNodeMouseUp
|
||||||
|
} = useNodeDrag({
|
||||||
|
canvasRef,
|
||||||
|
canvasOffset,
|
||||||
|
canvasScale,
|
||||||
|
nodes,
|
||||||
|
selectedNodeIds,
|
||||||
|
draggingNodeId,
|
||||||
|
dragStartPositions,
|
||||||
|
isDraggingNode,
|
||||||
|
dragDelta,
|
||||||
|
nodeOperations,
|
||||||
|
setSelectedNodeIds,
|
||||||
|
startDragging,
|
||||||
|
stopDragging,
|
||||||
|
setIsDraggingNode,
|
||||||
|
setDragDelta,
|
||||||
|
setIsBoxSelecting,
|
||||||
|
setBoxSelectStart,
|
||||||
|
setBoxSelectEnd,
|
||||||
|
sortChildrenByPosition
|
||||||
|
});
|
||||||
|
|
||||||
|
// 端口连接
|
||||||
|
const {
|
||||||
|
handlePortMouseDown,
|
||||||
|
handlePortMouseUp,
|
||||||
|
handleNodeMouseUpForConnection
|
||||||
|
} = usePortConnection({
|
||||||
|
canvasRef,
|
||||||
|
canvasOffset,
|
||||||
|
canvasScale,
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
connectingFrom,
|
||||||
|
connectingFromProperty,
|
||||||
|
connectionOperations,
|
||||||
|
setConnectingFrom,
|
||||||
|
setConnectingFromProperty,
|
||||||
|
clearConnecting,
|
||||||
|
sortChildrenByPosition,
|
||||||
|
showToast
|
||||||
|
});
|
||||||
|
|
||||||
|
// 键盘快捷键
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
selectedNodeIds,
|
||||||
|
selectedConnection,
|
||||||
|
connections,
|
||||||
|
nodeOperations,
|
||||||
|
connectionOperations,
|
||||||
|
setSelectedNodeIds,
|
||||||
|
setSelectedConnection
|
||||||
|
});
|
||||||
|
|
||||||
|
// 拖放处理
|
||||||
|
const {
|
||||||
|
isDragging,
|
||||||
|
handleDrop,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDragEnter
|
||||||
|
} = useDropHandler({
|
||||||
|
canvasRef,
|
||||||
|
canvasOffset,
|
||||||
|
canvasScale,
|
||||||
|
nodeOperations,
|
||||||
|
onNodeCreate
|
||||||
|
});
|
||||||
|
|
||||||
|
// 画布鼠标事件
|
||||||
|
const {
|
||||||
|
handleCanvasMouseMove,
|
||||||
|
handleCanvasMouseUp,
|
||||||
|
handleCanvasMouseDown
|
||||||
|
} = useCanvasMouseEvents({
|
||||||
|
canvasRef,
|
||||||
|
canvasOffset,
|
||||||
|
canvasScale,
|
||||||
|
connectingFrom,
|
||||||
|
connectingFromProperty,
|
||||||
|
connectingToPos,
|
||||||
|
isBoxSelecting,
|
||||||
|
boxSelectStart,
|
||||||
|
boxSelectEnd,
|
||||||
|
nodes,
|
||||||
|
selectedNodeIds,
|
||||||
|
quickCreateMenu: quickCreateMenu.quickCreateMenu,
|
||||||
|
setConnectingToPos,
|
||||||
|
setIsBoxSelecting,
|
||||||
|
setBoxSelectStart,
|
||||||
|
setBoxSelectEnd,
|
||||||
|
setSelectedNodeIds,
|
||||||
|
setSelectedConnection,
|
||||||
|
setQuickCreateMenu: quickCreateMenu.setQuickCreateMenu,
|
||||||
|
clearConnecting,
|
||||||
|
clearBoxSelect,
|
||||||
|
showToast
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCombinedMouseMove = (e: React.MouseEvent) => {
|
||||||
|
handleCanvasMouseMove(e);
|
||||||
|
handleNodeMouseMove(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCombinedMouseUp = (e: React.MouseEvent) => {
|
||||||
|
handleCanvasMouseUp(e);
|
||||||
|
handleNodeMouseUp();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPortPosition = (nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') =>
|
||||||
|
getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType, draggingNodeId, dragDelta, selectedNodeIds);
|
||||||
|
|
||||||
|
stopExecutionRef.current = handleStop;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}>
|
||||||
|
{showToolbar && (
|
||||||
|
<EditorToolbar
|
||||||
|
executionMode={executionMode}
|
||||||
|
canUndo={canUndo}
|
||||||
|
canRedo={canRedo}
|
||||||
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onStop={handleStop}
|
||||||
|
onStep={handleStep}
|
||||||
|
onReset={handleStop}
|
||||||
|
onUndo={undo}
|
||||||
|
onRedo={redo}
|
||||||
|
onResetView={handleResetView}
|
||||||
|
onSave={onSave}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onExport={onExport}
|
||||||
|
onCopyToClipboard={onCopyToClipboard}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主内容区:画布 + 黑板面板 */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
{/* 画布区域 */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
|
||||||
|
<BehaviorTreeCanvas
|
||||||
|
ref={canvasRef}
|
||||||
|
config={DEFAULT_EDITOR_CONFIG}
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
onContextMenu={handleCanvasContextMenu}
|
||||||
|
onDoubleClick={handleCanvasDoubleClick}
|
||||||
|
onMouseMove={handleCombinedMouseMove}
|
||||||
|
onMouseDown={handleCanvasMouseDown}
|
||||||
|
onMouseUp={handleCombinedMouseUp}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
>
|
||||||
|
{/* 连接线层 */}
|
||||||
|
<ConnectionLayer
|
||||||
|
connections={connections}
|
||||||
|
nodes={nodes}
|
||||||
|
selectedConnection={selectedConnection}
|
||||||
|
getPortPosition={getPortPosition}
|
||||||
|
onConnectionClick={(e, fromId, toId) => {
|
||||||
|
setSelectedConnection({ from: fromId, to: toId });
|
||||||
|
setSelectedNodeIds([]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 正在拖拽的连接线预览 */}
|
||||||
|
{connectingFrom && connectingToPos && (
|
||||||
|
<svg style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
overflow: 'visible',
|
||||||
|
zIndex: 150
|
||||||
|
}}>
|
||||||
|
{(() => {
|
||||||
|
// 获取正在连接的端口类型
|
||||||
|
const fromPortType = canvasRef.current?.getAttribute('data-connecting-from-port-type') || '';
|
||||||
|
|
||||||
|
// 根据端口类型判断是从输入还是输出端口开始
|
||||||
|
let portType: 'input' | 'output' = 'output';
|
||||||
|
if (fromPortType === 'node-input' || fromPortType === 'property-input') {
|
||||||
|
portType = 'input';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromPos = getPortPosition(
|
||||||
|
connectingFrom,
|
||||||
|
connectingFromProperty || undefined,
|
||||||
|
portType
|
||||||
|
);
|
||||||
|
if (!fromPos) return null;
|
||||||
|
|
||||||
|
const isPropertyConnection = !!connectingFromProperty;
|
||||||
|
const x1 = fromPos.x;
|
||||||
|
const y1 = fromPos.y;
|
||||||
|
const x2 = connectingToPos.x;
|
||||||
|
const y2 = connectingToPos.y;
|
||||||
|
|
||||||
|
// 使用贝塞尔曲线渲染
|
||||||
|
let pathD: string;
|
||||||
|
if (isPropertyConnection) {
|
||||||
|
// 属性连接使用水平贝塞尔曲线
|
||||||
|
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
|
||||||
|
d={pathD}
|
||||||
|
stroke={isPropertyConnection ? '#ab47bc' : '#00bcd4'}
|
||||||
|
strokeWidth="2.5"
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={isPropertyConnection ? '5,5' : 'none'}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 节点层 */}
|
||||||
|
{nodes.map((node: BehaviorTreeNode) => (
|
||||||
|
<BehaviorTreeNodeComponent
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
isSelected={selectedNodeIds.includes(node.id)}
|
||||||
|
isBeingDragged={draggingNodeId === node.id}
|
||||||
|
dragDelta={dragDelta}
|
||||||
|
uncommittedNodeIds={uncommittedNodeIds}
|
||||||
|
blackboardVariables={blackboardVariables}
|
||||||
|
initialBlackboardVariables={initialBlackboardVariables}
|
||||||
|
isExecuting={isExecuting}
|
||||||
|
executionStatus={nodeExecutionStatuses.get(node.id)}
|
||||||
|
executionOrder={nodeExecutionOrders.get(node.id)}
|
||||||
|
connections={connections}
|
||||||
|
nodes={nodes}
|
||||||
|
executorRef={executorRef}
|
||||||
|
iconMap={ICON_MAP}
|
||||||
|
draggingNodeId={draggingNodeId}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onContextMenu={handleNodeContextMenu}
|
||||||
|
onNodeMouseDown={handleNodeMouseDown}
|
||||||
|
onNodeMouseUpForConnection={handleNodeMouseUpForConnection}
|
||||||
|
onPortMouseDown={handlePortMouseDown}
|
||||||
|
onPortMouseUp={handlePortMouseUp}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BehaviorTreeCanvas>
|
||||||
|
|
||||||
|
{/* 框选区域 - 在画布外层,这样才能显示在节点上方 */}
|
||||||
|
{isBoxSelecting && boxSelectStart && boxSelectEnd && canvasRef.current && (() => {
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const minX = Math.min(boxSelectStart.x, boxSelectEnd.x);
|
||||||
|
const minY = Math.min(boxSelectStart.y, boxSelectEnd.y);
|
||||||
|
const maxX = Math.max(boxSelectStart.x, boxSelectEnd.x);
|
||||||
|
const maxY = Math.max(boxSelectStart.y, boxSelectEnd.y);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: rect.left + minX * canvasScale + canvasOffset.x,
|
||||||
|
top: rect.top + minY * canvasScale + canvasOffset.y,
|
||||||
|
width: (maxX - minX) * canvasScale,
|
||||||
|
height: (maxY - minY) * canvasScale,
|
||||||
|
border: '1px dashed #4a90e2',
|
||||||
|
backgroundColor: 'rgba(74, 144, 226, 0.1)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 9999
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* 右键菜单 */}
|
||||||
|
<NodeContextMenu
|
||||||
|
visible={contextMenu.contextMenu.visible}
|
||||||
|
position={contextMenu.contextMenu.position}
|
||||||
|
nodeId={contextMenu.contextMenu.nodeId}
|
||||||
|
isBlackboardVariable={contextMenu.contextMenu.nodeId ? nodes.find((n) => n.id === contextMenu.contextMenu.nodeId)?.data.nodeType === 'blackboard-variable' : false}
|
||||||
|
onReplaceNode={() => {
|
||||||
|
if (contextMenu.contextMenu.nodeId) {
|
||||||
|
quickCreateMenu.openQuickCreateMenu(
|
||||||
|
contextMenu.contextMenu.position,
|
||||||
|
'replace',
|
||||||
|
contextMenu.contextMenu.nodeId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
contextMenu.closeContextMenu();
|
||||||
|
}}
|
||||||
|
onDeleteNode={() => {
|
||||||
|
if (contextMenu.contextMenu.nodeId) {
|
||||||
|
nodeOperations.deleteNode(contextMenu.contextMenu.nodeId);
|
||||||
|
}
|
||||||
|
contextMenu.closeContextMenu();
|
||||||
|
}}
|
||||||
|
onCreateNode={() => {
|
||||||
|
quickCreateMenu.openQuickCreateMenu(
|
||||||
|
contextMenu.contextMenu.position,
|
||||||
|
'create'
|
||||||
|
);
|
||||||
|
contextMenu.closeContextMenu();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 快速创建菜单 */}
|
||||||
|
<QuickCreateMenu
|
||||||
|
visible={quickCreateMenu.quickCreateMenu.visible}
|
||||||
|
position={quickCreateMenu.quickCreateMenu.position}
|
||||||
|
searchText={quickCreateMenu.quickCreateMenu.searchText}
|
||||||
|
selectedIndex={quickCreateMenu.quickCreateMenu.selectedIndex}
|
||||||
|
mode={quickCreateMenu.quickCreateMenu.mode}
|
||||||
|
iconMap={ICON_MAP}
|
||||||
|
onSearchChange={(text) => quickCreateMenu.setQuickCreateMenu((prev) => ({ ...prev, searchText: text }))}
|
||||||
|
onIndexChange={(index) => quickCreateMenu.setQuickCreateMenu((prev) => ({ ...prev, selectedIndex: index }))}
|
||||||
|
onNodeSelect={(template) => {
|
||||||
|
if (quickCreateMenu.quickCreateMenu.mode === 'create') {
|
||||||
|
quickCreateMenu.handleQuickCreateNode(template);
|
||||||
|
} else {
|
||||||
|
quickCreateMenu.handleReplaceNode(template);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClose={() => quickCreateMenu.setQuickCreateMenu((prev) => ({ ...prev, visible: false }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 黑板面板(侧边栏) */}
|
||||||
|
<div style={{
|
||||||
|
width: blackboardCollapsed ? '48px' : '300px',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'width 0.2s ease'
|
||||||
|
}}>
|
||||||
|
<BlackboardPanel
|
||||||
|
variables={blackboardVariables}
|
||||||
|
initialVariables={initialBlackboardVariables}
|
||||||
|
globalVariables={globalVariables}
|
||||||
|
onVariableAdd={handleBlackboardVariableAdd}
|
||||||
|
onVariableChange={handleBlackboardVariableChange}
|
||||||
|
onVariableDelete={handleBlackboardVariableDelete}
|
||||||
|
onVariableRename={handleBlackboardVariableRename}
|
||||||
|
onGlobalVariableChange={handleGlobalVariableChange}
|
||||||
|
onGlobalVariableAdd={handleGlobalVariableAdd}
|
||||||
|
onGlobalVariableDelete={handleGlobalVariableDelete}
|
||||||
|
isCollapsed={blackboardCollapsed}
|
||||||
|
onToggleCollapse={() => setBlackboardCollapsed(!blackboardCollapsed)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import React, { useRef, useCallback, forwardRef } from 'react';
|
import React, { useRef, useCallback, forwardRef, useState, useEffect } from 'react';
|
||||||
import { useCanvasInteraction } from '../../../hooks/useCanvasInteraction';
|
import { useCanvasInteraction } from '../../hooks/useCanvasInteraction';
|
||||||
import { EditorConfig } from '../../../types';
|
import { EditorConfig } from '../../types';
|
||||||
|
import { GridBackground } from './GridBackground';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 画布组件属性
|
* 画布组件属性
|
||||||
@@ -92,7 +93,9 @@ export const BehaviorTreeCanvas = forwardRef<HTMLDivElement, BehaviorTreeCanvasP
|
|||||||
onDragLeave
|
onDragLeave
|
||||||
}, forwardedRef) => {
|
}, forwardedRef) => {
|
||||||
const internalRef = useRef<HTMLDivElement>(null);
|
const internalRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = forwardedRef || internalRef;
|
const canvasRef = (forwardedRef as React.RefObject<HTMLDivElement>) || internalRef;
|
||||||
|
|
||||||
|
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
canvasOffset,
|
canvasOffset,
|
||||||
@@ -104,6 +107,30 @@ export const BehaviorTreeCanvas = forwardRef<HTMLDivElement, BehaviorTreeCanvasP
|
|||||||
stopPanning
|
stopPanning
|
||||||
} = useCanvasInteraction();
|
} = useCanvasInteraction();
|
||||||
|
|
||||||
|
// 监听画布尺寸变化
|
||||||
|
useEffect(() => {
|
||||||
|
const updateSize = () => {
|
||||||
|
if (canvasRef.current) {
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
setCanvasSize({
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSize();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateSize);
|
||||||
|
if (canvasRef.current) {
|
||||||
|
resizeObserver.observe(canvasRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [canvasRef]);
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
if (e.button === 1 || (e.button === 0 && e.altKey)) {
|
if (e.button === 1 || (e.button === 0 && e.altKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -160,19 +187,12 @@ export const BehaviorTreeCanvas = forwardRef<HTMLDivElement, BehaviorTreeCanvasP
|
|||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
>
|
>
|
||||||
{/* 网格背景 */}
|
{/* 网格背景 */}
|
||||||
{config.showGrid && (
|
{config.showGrid && canvasSize.width > 0 && canvasSize.height > 0 && (
|
||||||
<div
|
<GridBackground
|
||||||
className="canvas-grid"
|
canvasOffset={canvasOffset}
|
||||||
style={{
|
canvasScale={canvasScale}
|
||||||
position: 'absolute',
|
width={canvasSize.width}
|
||||||
inset: 0,
|
height={canvasSize.height}
|
||||||
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`
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
|
interface GridBackgroundProps {
|
||||||
|
canvasOffset: { x: number; y: number };
|
||||||
|
canvasScale: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑器网格背景
|
||||||
|
*/
|
||||||
|
export const GridBackground: React.FC<GridBackgroundProps> = ({
|
||||||
|
canvasOffset,
|
||||||
|
canvasScale,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
}) => {
|
||||||
|
const gridPattern = useMemo(() => {
|
||||||
|
// 基础网格大小(未缩放)
|
||||||
|
const baseGridSize = 20;
|
||||||
|
const baseDotSize = 1.5;
|
||||||
|
|
||||||
|
// 根据缩放级别调整网格大小
|
||||||
|
const gridSize = baseGridSize * canvasScale;
|
||||||
|
const dotSize = Math.max(baseDotSize, baseDotSize * canvasScale);
|
||||||
|
|
||||||
|
// 计算网格偏移(考虑画布偏移)
|
||||||
|
const offsetX = canvasOffset.x % gridSize;
|
||||||
|
const offsetY = canvasOffset.y % gridSize;
|
||||||
|
|
||||||
|
// 计算需要渲染的网格点数量
|
||||||
|
const cols = Math.ceil(width / gridSize) + 2;
|
||||||
|
const rows = Math.ceil(height / gridSize) + 2;
|
||||||
|
|
||||||
|
const dots: Array<{ x: number; y: number }> = [];
|
||||||
|
|
||||||
|
for (let i = -1; i < rows; i++) {
|
||||||
|
for (let j = -1; j < cols; j++) {
|
||||||
|
dots.push({
|
||||||
|
x: j * gridSize + offsetX,
|
||||||
|
y: i * gridSize + offsetY
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dots, dotSize, gridSize };
|
||||||
|
}, [canvasOffset, canvasScale, width, height]);
|
||||||
|
|
||||||
|
// 大网格(每5个小格一个大格)
|
||||||
|
const majorGridPattern = useMemo(() => {
|
||||||
|
const majorGridSize = gridPattern.gridSize * 5;
|
||||||
|
const offsetX = canvasOffset.x % majorGridSize;
|
||||||
|
const offsetY = canvasOffset.y % majorGridSize;
|
||||||
|
|
||||||
|
const lines: Array<{ type: 'h' | 'v'; pos: number }> = [];
|
||||||
|
|
||||||
|
// 垂直线
|
||||||
|
const vCols = Math.ceil(width / majorGridSize) + 2;
|
||||||
|
for (let i = -1; i < vCols; i++) {
|
||||||
|
lines.push({
|
||||||
|
type: 'v',
|
||||||
|
pos: i * majorGridSize + offsetX
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 水平线
|
||||||
|
const hRows = Math.ceil(height / majorGridSize) + 2;
|
||||||
|
for (let i = -1; i < hRows; i++) {
|
||||||
|
lines.push({
|
||||||
|
type: 'h',
|
||||||
|
pos: i * majorGridSize + offsetY
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}, [canvasOffset, canvasScale, width, height, gridPattern.gridSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 主网格线 */}
|
||||||
|
{majorGridPattern.map((line, idx) => (
|
||||||
|
line.type === 'v' ? (
|
||||||
|
<line
|
||||||
|
key={`v-${idx}`}
|
||||||
|
x1={line.pos}
|
||||||
|
y1={0}
|
||||||
|
x2={line.pos}
|
||||||
|
y2={height}
|
||||||
|
stroke="rgba(255, 255, 255, 0.03)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<line
|
||||||
|
key={`h-${idx}`}
|
||||||
|
x1={0}
|
||||||
|
y1={line.pos}
|
||||||
|
x2={width}
|
||||||
|
y2={line.pos}
|
||||||
|
stroke="rgba(255, 255, 255, 0.03)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 点阵网格 */}
|
||||||
|
{gridPattern.dots.map((dot, idx) => (
|
||||||
|
<circle
|
||||||
|
key={idx}
|
||||||
|
cx={dot.x}
|
||||||
|
cy={dot.y}
|
||||||
|
r={gridPattern.dotSize}
|
||||||
|
fill="rgba(255, 255, 255, 0.15)"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import React, { useState, useRef, useEffect, ReactNode } from 'react';
|
||||||
|
import { GripVertical } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DraggablePanelProps {
|
||||||
|
title: string | ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
isVisible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
width?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
initialPosition?: { x: number; y: number };
|
||||||
|
headerActions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可拖动面板通用组件
|
||||||
|
* 提供标题栏拖动、关闭按钮等基础功能
|
||||||
|
*/
|
||||||
|
export const DraggablePanel: React.FC<DraggablePanelProps> = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
isVisible,
|
||||||
|
onClose,
|
||||||
|
width = 400,
|
||||||
|
maxHeight = 600,
|
||||||
|
initialPosition = { x: 20, y: 100 },
|
||||||
|
headerActions,
|
||||||
|
children,
|
||||||
|
footer
|
||||||
|
}) => {
|
||||||
|
const [position, setPosition] = useState(initialPosition);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
const newX = e.clientX - dragOffset.x;
|
||||||
|
const newY = e.clientY - dragOffset.y;
|
||||||
|
|
||||||
|
// 限制面板在视口内
|
||||||
|
const maxX = window.innerWidth - width;
|
||||||
|
const maxY = window.innerHeight - 100;
|
||||||
|
|
||||||
|
setPosition({
|
||||||
|
x: Math.max(0, Math.min(newX, maxX)),
|
||||||
|
y: Math.max(0, Math.min(newY, maxY))
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isDragging, dragOffset, width]);
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (!panelRef.current) return;
|
||||||
|
|
||||||
|
const rect = panelRef.current.getBoundingClientRect();
|
||||||
|
setDragOffset({
|
||||||
|
x: e.clientX - rect.left,
|
||||||
|
y: e.clientY - rect.top
|
||||||
|
});
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${position.x}px`,
|
||||||
|
top: `${position.y}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
maxHeight: `${maxHeight}px`,
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
border: '1px solid #3f3f3f',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
userSelect: isDragging ? 'none' : 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 可拖动标题栏 */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid #3f3f3f',
|
||||||
|
backgroundColor: '#252525',
|
||||||
|
borderTopLeftRadius: '8px',
|
||||||
|
borderTopRightRadius: '8px',
|
||||||
|
cursor: isDragging ? 'grabbing' : 'grab',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}>
|
||||||
|
<GripVertical size={14} color="#666" style={{ flexShrink: 0 }} />
|
||||||
|
{icon}
|
||||||
|
{typeof title === 'string' ? (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#fff'
|
||||||
|
}}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{headerActions}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
backgroundColor: '#3c3c3c',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#ccc',
|
||||||
|
fontSize: '11px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
{footer && (
|
||||||
|
<div style={{
|
||||||
|
borderTop: '1px solid #3f3f3f',
|
||||||
|
backgroundColor: '#252525',
|
||||||
|
borderBottomLeftRadius: '8px',
|
||||||
|
borderBottomRightRadius: '8px'
|
||||||
|
}}>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,48 +1,17 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { ConnectionRenderer } from './ConnectionRenderer';
|
import { ConnectionRenderer } from './ConnectionRenderer';
|
||||||
import { ConnectionViewData } from '../../../types';
|
import { ConnectionViewData } from '../../types';
|
||||||
import { Node } from '../../../../domain/models/Node';
|
import { Node } from '../../domain/models/Node';
|
||||||
import { Connection } from '../../../../domain/models/Connection';
|
import { Connection } from '../../domain/models/Connection';
|
||||||
|
|
||||||
/**
|
|
||||||
* 连线层属性
|
|
||||||
*/
|
|
||||||
interface ConnectionLayerProps {
|
interface ConnectionLayerProps {
|
||||||
/**
|
|
||||||
* 所有连接
|
|
||||||
*/
|
|
||||||
connections: Connection[];
|
connections: Connection[];
|
||||||
|
|
||||||
/**
|
|
||||||
* 所有节点(用于查找位置)
|
|
||||||
*/
|
|
||||||
nodes: Node[];
|
nodes: Node[];
|
||||||
|
|
||||||
/**
|
|
||||||
* 选中的连接
|
|
||||||
*/
|
|
||||||
selectedConnection?: { from: string; to: string } | null;
|
selectedConnection?: { from: string; to: string } | null;
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取端口位置的函数
|
|
||||||
*/
|
|
||||||
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
|
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
|
||||||
|
|
||||||
/**
|
|
||||||
* 连线点击事件
|
|
||||||
*/
|
|
||||||
onConnectionClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
onConnectionClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||||
|
|
||||||
/**
|
|
||||||
* 连线右键事件
|
|
||||||
*/
|
|
||||||
onConnectionContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
onConnectionContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 连线层
|
|
||||||
* 管理所有连线的渲染
|
|
||||||
*/
|
|
||||||
export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
|
export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
|
||||||
connections,
|
connections,
|
||||||
nodes,
|
nodes,
|
||||||
@@ -65,18 +34,15 @@ export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSelected = selectedConnection?.from === connection.from &&
|
return { connection, fromNode, toNode };
|
||||||
selectedConnection?.to === connection.to;
|
|
||||||
|
|
||||||
const viewData: ConnectionViewData = {
|
|
||||||
connection,
|
|
||||||
isSelected
|
|
||||||
};
|
|
||||||
|
|
||||||
return { viewData, fromNode, toNode };
|
|
||||||
})
|
})
|
||||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||||
}, [connections, nodeMap, selectedConnection]);
|
}, [connections, nodeMap]);
|
||||||
|
|
||||||
|
const isConnectionSelected = (connection: { from: string; to: string }) => {
|
||||||
|
return selectedConnection?.from === connection.from &&
|
||||||
|
selectedConnection?.to === connection.to;
|
||||||
|
};
|
||||||
|
|
||||||
if (connectionViewData.length === 0) {
|
if (connectionViewData.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -89,21 +55,28 @@ export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
overflow: 'visible'
|
overflow: 'visible',
|
||||||
|
zIndex: 0
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<g style={{ pointerEvents: 'auto' }}>
|
<g style={{ pointerEvents: 'auto' }}>
|
||||||
{connectionViewData.map(({ viewData, fromNode, toNode }) => (
|
{connectionViewData.map(({ connection, fromNode, toNode }) => {
|
||||||
<ConnectionRenderer
|
const viewData: ConnectionViewData = {
|
||||||
key={`${viewData.connection.from}-${viewData.connection.to}`}
|
connection,
|
||||||
connectionData={viewData}
|
isSelected: isConnectionSelected(connection)
|
||||||
fromNode={fromNode}
|
};
|
||||||
toNode={toNode}
|
return (
|
||||||
getPortPosition={getPortPosition}
|
<ConnectionRenderer
|
||||||
onClick={onConnectionClick}
|
key={`${connection.from}-${connection.to}`}
|
||||||
onContextMenu={onConnectionContextMenu}
|
connectionData={viewData}
|
||||||
/>
|
fromNode={fromNode}
|
||||||
))}
|
toNode={toNode}
|
||||||
|
getPortPosition={getPortPosition}
|
||||||
|
onClick={onConnectionClick}
|
||||||
|
onContextMenu={onConnectionContextMenu}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,47 +1,16 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { ConnectionViewData } from '../../../types';
|
import { ConnectionViewData } from '../../types';
|
||||||
import { Node } from '../../../../domain/models/Node';
|
import { Node } from '../../domain/models/Node';
|
||||||
|
|
||||||
/**
|
|
||||||
* 连线渲染器属性
|
|
||||||
*/
|
|
||||||
interface ConnectionRendererProps {
|
interface ConnectionRendererProps {
|
||||||
/**
|
|
||||||
* 连接视图数据
|
|
||||||
*/
|
|
||||||
connectionData: ConnectionViewData;
|
connectionData: ConnectionViewData;
|
||||||
|
|
||||||
/**
|
|
||||||
* 源节点
|
|
||||||
*/
|
|
||||||
fromNode: Node;
|
fromNode: Node;
|
||||||
|
|
||||||
/**
|
|
||||||
* 目标节点
|
|
||||||
*/
|
|
||||||
toNode: Node;
|
toNode: Node;
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取端口位置的函数
|
|
||||||
*/
|
|
||||||
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
|
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
|
||||||
|
|
||||||
/**
|
|
||||||
* 连线点击事件
|
|
||||||
*/
|
|
||||||
onClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
onClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||||
|
|
||||||
/**
|
|
||||||
* 连线右键事件
|
|
||||||
*/
|
|
||||||
onContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
onContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||||
}
|
}
|
||||||
|
const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
|
||||||
/**
|
|
||||||
* 连线渲染器
|
|
||||||
* 使用贝塞尔曲线渲染节点间的连接
|
|
||||||
*/
|
|
||||||
export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
|
||||||
connectionData,
|
connectionData,
|
||||||
fromNode,
|
fromNode,
|
||||||
toNode,
|
toNode,
|
||||||
@@ -55,17 +24,16 @@ export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
|||||||
let fromPos, toPos;
|
let fromPos, toPos;
|
||||||
|
|
||||||
if (connection.connectionType === 'property') {
|
if (connection.connectionType === 'property') {
|
||||||
// 属性连接:从DOM获取实际引脚位置
|
// 属性连接:使用 fromProperty 和 toProperty
|
||||||
fromPos = getPortPosition(connection.from);
|
fromPos = getPortPosition(connection.from, connection.fromProperty);
|
||||||
toPos = getPortPosition(connection.to, connection.toProperty);
|
toPos = getPortPosition(connection.to, connection.toProperty);
|
||||||
} else {
|
} else {
|
||||||
// 节点连接:使用DOM获取端口位置
|
// 节点连接:使用输出和输入端口
|
||||||
fromPos = getPortPosition(connection.from, undefined, 'output');
|
fromPos = getPortPosition(connection.from, undefined, 'output');
|
||||||
toPos = getPortPosition(connection.to, undefined, 'input');
|
toPos = getPortPosition(connection.to, undefined, 'input');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fromPos || !toPos) {
|
if (!fromPos || !toPos) {
|
||||||
// 如果DOM还没渲染,返回null
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,12 +45,10 @@ export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
|||||||
let pathD: string;
|
let pathD: string;
|
||||||
|
|
||||||
if (connection.connectionType === 'property') {
|
if (connection.connectionType === 'property') {
|
||||||
// 属性连接使用水平贝塞尔曲线
|
|
||||||
const controlX1 = x1 + (x2 - x1) * 0.5;
|
const controlX1 = x1 + (x2 - x1) * 0.5;
|
||||||
const controlX2 = x1 + (x2 - x1) * 0.5;
|
const controlX2 = x1 + (x2 - x1) * 0.5;
|
||||||
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
|
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
|
||||||
} else {
|
} else {
|
||||||
// 节点连接使用垂直贝塞尔曲线
|
|
||||||
const controlY = y1 + (y2 - y1) * 0.5;
|
const controlY = y1 + (y2 - y1) * 0.5;
|
||||||
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
|
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
|
||||||
}
|
}
|
||||||
@@ -94,16 +60,24 @@ export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
|||||||
};
|
};
|
||||||
}, [connection, fromNode, toNode, getPortPosition]);
|
}, [connection, fromNode, toNode, getPortPosition]);
|
||||||
|
|
||||||
const color = connection.connectionType === 'property' ? '#9c27b0' : '#0e639c';
|
const isPropertyConnection = connection.connectionType === 'property';
|
||||||
|
|
||||||
|
const color = isPropertyConnection ? '#ab47bc' : '#00bcd4';
|
||||||
|
const glowColor = isPropertyConnection ? 'rgba(171, 71, 188, 0.6)' : 'rgba(0, 188, 212, 0.6)';
|
||||||
const strokeColor = isSelected ? '#FFD700' : color;
|
const strokeColor = isSelected ? '#FFD700' : color;
|
||||||
const strokeWidth = isSelected ? 4 : 2;
|
const strokeWidth = isSelected ? 3.5 : 2.5;
|
||||||
const markerId = `arrowhead-${connection.from}-${connection.to}`;
|
|
||||||
|
const gradientId = `gradient-${connection.from}-${connection.to}`;
|
||||||
|
|
||||||
if (!pathData) {
|
if (!pathData) {
|
||||||
// DOM还没渲染完成,跳过此连接
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pathD = pathData.path;
|
||||||
|
const endPosMatch = pathD.match(/C [0-9.-]+ [0-9.-]+, [0-9.-]+ [0-9.-]+, ([0-9.-]+) ([0-9.-]+)/);
|
||||||
|
const endX = endPosMatch ? parseFloat(endPosMatch[1]) : 0;
|
||||||
|
const endY = endPosMatch ? parseFloat(endPosMatch[2]) : 0;
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick?.(e, connection.from, connection.to);
|
onClick?.(e, connection.from, connection.to);
|
||||||
@@ -123,53 +97,68 @@ export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
|||||||
data-connection-from={connection.from}
|
data-connection-from={connection.from}
|
||||||
data-connection-to={connection.to}
|
data-connection-to={connection.to}
|
||||||
>
|
>
|
||||||
{/* 透明的宽线条,用于更容易点击 */}
|
<defs>
|
||||||
|
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.8" />
|
||||||
|
<stop offset="50%" stopColor={strokeColor} stopOpacity="1" />
|
||||||
|
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.8" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
<path
|
<path
|
||||||
d={pathData.path}
|
d={pathData.path}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="transparent"
|
stroke="transparent"
|
||||||
strokeWidth={20}
|
strokeWidth={24}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 箭头标记定义 */}
|
|
||||||
<defs>
|
|
||||||
<marker
|
|
||||||
id={markerId}
|
|
||||||
markerWidth="10"
|
|
||||||
markerHeight="10"
|
|
||||||
refX="9"
|
|
||||||
refY="3"
|
|
||||||
orient="auto"
|
|
||||||
markerUnits="strokeWidth"
|
|
||||||
>
|
|
||||||
<polygon
|
|
||||||
points="0 0, 10 3, 0 6"
|
|
||||||
fill={strokeColor}
|
|
||||||
/>
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{/* 实际显示的线条 */}
|
|
||||||
<path
|
<path
|
||||||
d={pathData.path}
|
d={pathData.path}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={strokeColor}
|
stroke={glowColor}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth + 2}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
markerEnd={`url(#${markerId})`}
|
opacity={isSelected ? 0.4 : 0.2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
d={pathData.path}
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${gradientId})`}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<circle
|
||||||
|
cx={endX}
|
||||||
|
cy={endY}
|
||||||
|
r="5"
|
||||||
|
fill={strokeColor}
|
||||||
|
stroke="rgba(0, 0, 0, 0.3)"
|
||||||
|
strokeWidth="1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 选中时显示的中点 */}
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<circle
|
<>
|
||||||
cx={pathData.midX}
|
<circle
|
||||||
cy={pathData.midY}
|
cx={pathData.midX}
|
||||||
r="5"
|
cy={pathData.midY}
|
||||||
fill={strokeColor}
|
r="8"
|
||||||
stroke="#1a1a1a"
|
fill={strokeColor}
|
||||||
strokeWidth="2"
|
opacity="0.3"
|
||||||
/>
|
/>
|
||||||
|
<circle
|
||||||
|
cx={pathData.midX}
|
||||||
|
cy={pathData.midY}
|
||||||
|
r="5"
|
||||||
|
fill={strokeColor}
|
||||||
|
stroke="rgba(0, 0, 0, 0.5)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ConnectionRenderer = ConnectionRendererComponent;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Trash2, Replace, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface NodeContextMenuProps {
|
||||||
|
visible: boolean;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
nodeId: string | null;
|
||||||
|
isBlackboardVariable?: boolean;
|
||||||
|
onReplaceNode?: () => void;
|
||||||
|
onDeleteNode?: () => void;
|
||||||
|
onCreateNode?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
||||||
|
visible,
|
||||||
|
position,
|
||||||
|
nodeId,
|
||||||
|
isBlackboardVariable = false,
|
||||||
|
onReplaceNode,
|
||||||
|
onDeleteNode,
|
||||||
|
onCreateNode
|
||||||
|
}) => {
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const menuItemStyle = {
|
||||||
|
padding: '8px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#cccccc',
|
||||||
|
fontSize: '13px',
|
||||||
|
transition: 'background-color 0.15s',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
};
|
||||||
|
|
||||||
|
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()}
|
||||||
|
>
|
||||||
|
{nodeId ? (
|
||||||
|
<>
|
||||||
|
{onReplaceNode && (
|
||||||
|
<div
|
||||||
|
onClick={onReplaceNode}
|
||||||
|
style={menuItemStyle}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<Replace size={14} />
|
||||||
|
替换节点
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onDeleteNode && (
|
||||||
|
<div
|
||||||
|
onClick={onDeleteNode}
|
||||||
|
style={{...menuItemStyle, color: '#f48771'}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#5a1a1a'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
删除节点
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{onCreateNode && (
|
||||||
|
<div
|
||||||
|
onClick={onCreateNode}
|
||||||
|
style={menuItemStyle}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
新建节点
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useMemo } from 'react';
|
||||||
|
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||||
|
import { Search, X, LucideIcon, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { NodeFactory } from '../../infrastructure/factories/NodeFactory';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryGroup {
|
||||||
|
category: string;
|
||||||
|
templates: NodeTemplate[];
|
||||||
|
isExpanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||||
|
visible,
|
||||||
|
position,
|
||||||
|
searchText,
|
||||||
|
selectedIndex,
|
||||||
|
iconMap,
|
||||||
|
onSearchChange,
|
||||||
|
onIndexChange,
|
||||||
|
onNodeSelect,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const selectedNodeRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||||
|
const [shouldAutoScroll, setShouldAutoScroll] = useState(false);
|
||||||
|
|
||||||
|
const nodeFactory = useMemo(() => new NodeFactory(), []);
|
||||||
|
const allTemplates = useMemo(() => nodeFactory.getAllTemplates(), [nodeFactory]);
|
||||||
|
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;
|
||||||
|
|
||||||
|
const categoryGroups: CategoryGroup[] = React.useMemo(() => {
|
||||||
|
const groups = new Map<string, NodeTemplate[]>();
|
||||||
|
|
||||||
|
filteredTemplates.forEach((template: NodeTemplate) => {
|
||||||
|
const category = template.category || '未分类';
|
||||||
|
if (!groups.has(category)) {
|
||||||
|
groups.set(category, []);
|
||||||
|
}
|
||||||
|
groups.get(category)!.push(template);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(groups.entries()).map(([category, templates]) => ({
|
||||||
|
category,
|
||||||
|
templates,
|
||||||
|
isExpanded: searchTextLower ? true : expandedCategories.has(category)
|
||||||
|
})).sort((a, b) => a.category.localeCompare(b.category));
|
||||||
|
}, [filteredTemplates, expandedCategories, searchTextLower]);
|
||||||
|
|
||||||
|
const flattenedTemplates = React.useMemo(() => {
|
||||||
|
return categoryGroups.flatMap(group =>
|
||||||
|
group.isExpanded ? group.templates : []
|
||||||
|
);
|
||||||
|
}, [categoryGroups]);
|
||||||
|
|
||||||
|
const toggleCategory = (category: string) => {
|
||||||
|
setExpandedCategories(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(category)) {
|
||||||
|
newSet.delete(category);
|
||||||
|
} else {
|
||||||
|
newSet.add(category);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (allTemplates.length > 0 && expandedCategories.size === 0) {
|
||||||
|
const categories = new Set(allTemplates.map(t => t.category || '未分类'));
|
||||||
|
setExpandedCategories(categories);
|
||||||
|
}
|
||||||
|
}, [allTemplates, expandedCategories.size]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldAutoScroll && selectedNodeRef.current) {
|
||||||
|
selectedNodeRef.current.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
setShouldAutoScroll(false);
|
||||||
|
}
|
||||||
|
}, [selectedIndex, shouldAutoScroll]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
let globalIndex = -1;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.category-header {
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
.category-header:hover {
|
||||||
|
background-color: #3c3c3c;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${position.x}px`,
|
||||||
|
top: `${position.y}px`,
|
||||||
|
width: '300px',
|
||||||
|
maxHeight: '500px',
|
||||||
|
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();
|
||||||
|
setShouldAutoScroll(true);
|
||||||
|
onIndexChange(Math.min(selectedIndex + 1, flattenedTemplates.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShouldAutoScroll(true);
|
||||||
|
onIndexChange(Math.max(selectedIndex - 1, 0));
|
||||||
|
} else if (e.key === 'Enter' && flattenedTemplates.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const selectedTemplate = flattenedTemplates[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: '4px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categoryGroups.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
padding: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
未找到匹配的节点
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
categoryGroups.map((group) => {
|
||||||
|
return (
|
||||||
|
<div key={group.category} style={{ marginBottom: '4px' }}>
|
||||||
|
<div
|
||||||
|
className="category-header"
|
||||||
|
onClick={() => toggleCategory(group.category)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.isExpanded ? (
|
||||||
|
<ChevronDown size={14} style={{ color: '#999', flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} style={{ color: '#999', flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
color: '#aaa',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1
|
||||||
|
}}>
|
||||||
|
{group.category}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '11px',
|
||||||
|
backgroundColor: '#2d2d2d',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '10px'
|
||||||
|
}}>
|
||||||
|
{group.templates.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{group.isExpanded && (
|
||||||
|
<div style={{ paddingLeft: '8px', paddingTop: '4px' }}>
|
||||||
|
{group.templates.map((template: NodeTemplate) => {
|
||||||
|
globalIndex++;
|
||||||
|
const IconComponent = template.icon ? iconMap[template.icon] : null;
|
||||||
|
const className = template.className || '';
|
||||||
|
const isSelected = globalIndex === selectedIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={template.className || template.displayName}
|
||||||
|
ref={isSelected ? selectedNodeRef : null}
|
||||||
|
onClick={() => onNodeSelect(template)}
|
||||||
|
onMouseEnter={() => onIndexChange(globalIndex)}
|
||||||
|
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'
|
||||||
|
}}>
|
||||||
|
{template.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,9 +7,12 @@ import {
|
|||||||
LucideIcon
|
LucideIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PropertyDefinition } from '@esengine/behavior-tree';
|
import { PropertyDefinition } from '@esengine/behavior-tree';
|
||||||
import { BehaviorTreeNode as BehaviorTreeNodeType, Connection, ROOT_NODE_ID, NodeExecutionStatus } from '../../../../stores/behaviorTreeStore';
|
import { Node as BehaviorTreeNodeType } from '../../domain/models/Node';
|
||||||
import { BehaviorTreeExecutor } from '../../../../utils/BehaviorTreeExecutor';
|
import { Connection } from '../../domain/models/Connection';
|
||||||
import { BlackboardValue } from '../../../../domain/models/Blackboard';
|
import { ROOT_NODE_ID } from '../../domain/constants/RootNode';
|
||||||
|
import type { NodeExecutionStatus } from '../../stores';
|
||||||
|
import { BehaviorTreeExecutor } from '../../utils/BehaviorTreeExecutor';
|
||||||
|
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||||
|
|
||||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||||
|
|
||||||
@@ -37,7 +40,7 @@ interface BehaviorTreeNodeProps {
|
|||||||
onPortMouseUp: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
|
onPortMouseUp: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
const BehaviorTreeNodeComponent: React.FC<BehaviorTreeNodeProps> = ({
|
||||||
node,
|
node,
|
||||||
isSelected,
|
isSelected,
|
||||||
isBeingDragged,
|
isBeingDragged,
|
||||||
@@ -84,6 +87,7 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
|||||||
onContextMenu={(e) => onContextMenu(e, node)}
|
onContextMenu={(e) => onContextMenu(e, node)}
|
||||||
onMouseDown={(e) => onNodeMouseDown(e, node.id)}
|
onMouseDown={(e) => onNodeMouseDown(e, node.id)}
|
||||||
onMouseUp={(e) => onNodeMouseUpForConnection(e, node.id)}
|
onMouseUp={(e) => onNodeMouseUpForConnection(e, node.id)}
|
||||||
|
onDragStart={(e) => e.preventDefault()}
|
||||||
style={{
|
style={{
|
||||||
left: posX,
|
left: posX,
|
||||||
top: posY,
|
top: posY,
|
||||||
@@ -93,6 +97,35 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
|||||||
zIndex: isRoot ? 50 : (draggingNodeId === node.id ? 100 : (isSelected ? 10 : 1))
|
zIndex: isRoot ? 50 : (draggingNodeId === node.id ? 100 : (isSelected ? 10 : 1))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 执行顺序角标 - 使用绝对定位,不影响节点布局 */}
|
||||||
|
{executionOrder !== undefined && (
|
||||||
|
<div
|
||||||
|
className="bt-node-execution-badge"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-8px',
|
||||||
|
right: '-8px',
|
||||||
|
backgroundColor: '#2196f3',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: '50%',
|
||||||
|
minWidth: '24px',
|
||||||
|
height: '24px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
padding: '0 6px',
|
||||||
|
boxShadow: '0 2px 8px rgba(33, 150, 243, 0.5)',
|
||||||
|
border: '2px solid #1a1a1d',
|
||||||
|
zIndex: 10,
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
title={`执行顺序: ${executionOrder}`}
|
||||||
|
>
|
||||||
|
{executionOrder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isBlackboardVariable ? (
|
{isBlackboardVariable ? (
|
||||||
(() => {
|
(() => {
|
||||||
const varName = node.data.variableName as string;
|
const varName = node.data.variableName as string;
|
||||||
@@ -137,6 +170,7 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
|||||||
<div
|
<div
|
||||||
data-port="true"
|
data-port="true"
|
||||||
data-node-id={node.id}
|
data-node-id={node.id}
|
||||||
|
data-property="__value__"
|
||||||
data-port-type="variable-output"
|
data-port-type="variable-output"
|
||||||
onMouseDown={(e) => onPortMouseDown(e, node.id, '__value__')}
|
onMouseDown={(e) => onPortMouseDown(e, node.id, '__value__')}
|
||||||
onMouseUp={(e) => onPortMouseUp(e, node.id, '__value__')}
|
onMouseUp={(e) => onPortMouseUp(e, node.id, '__value__')}
|
||||||
@@ -167,33 +201,11 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
|||||||
#{node.id}
|
#{node.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{executionOrder !== undefined && (
|
|
||||||
<div
|
|
||||||
className="bt-node-execution-order"
|
|
||||||
style={{
|
|
||||||
marginLeft: 'auto',
|
|
||||||
backgroundColor: '#2196f3',
|
|
||||||
color: '#fff',
|
|
||||||
borderRadius: '50%',
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
flexShrink: 0
|
|
||||||
}}
|
|
||||||
title={`执行顺序: ${executionOrder}`}
|
|
||||||
>
|
|
||||||
{executionOrder}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
|
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
|
||||||
<div
|
<div
|
||||||
className="bt-node-missing-executor-warning"
|
className="bt-node-missing-executor-warning"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: executionOrder !== undefined ? '4px' : 'auto',
|
marginLeft: 'auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
cursor: 'help',
|
cursor: 'help',
|
||||||
@@ -218,7 +230,7 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
|||||||
<div
|
<div
|
||||||
className="bt-node-uncommitted-warning"
|
className="bt-node-uncommitted-warning"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: (executionOrder !== undefined || (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className))) ? '4px' : 'auto',
|
marginLeft: (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) ? '4px' : 'auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
cursor: 'help',
|
cursor: 'help',
|
||||||
@@ -247,7 +259,7 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
|||||||
<div
|
<div
|
||||||
className="bt-node-empty-warning-container"
|
className="bt-node-empty-warning-container"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: (executionOrder !== undefined || (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) || isUncommitted) ? '4px' : 'auto',
|
marginLeft: ((!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) || isUncommitted) ? '4px' : 'auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
cursor: 'help',
|
cursor: 'help',
|
||||||
@@ -344,3 +356,52 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 React.memo 优化节点组件性能
|
||||||
|
* 只在关键 props 变化时重新渲染
|
||||||
|
*/
|
||||||
|
export const BehaviorTreeNode = React.memo(BehaviorTreeNodeComponent, (prevProps, nextProps) => {
|
||||||
|
// 如果节点本身变化,需要重新渲染
|
||||||
|
if (prevProps.node.id !== nextProps.node.id ||
|
||||||
|
prevProps.node.position.x !== nextProps.node.position.x ||
|
||||||
|
prevProps.node.position.y !== nextProps.node.position.y ||
|
||||||
|
prevProps.node.template.className !== nextProps.node.template.className) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevProps.isSelected !== nextProps.isSelected ||
|
||||||
|
prevProps.isBeingDragged !== nextProps.isBeingDragged ||
|
||||||
|
prevProps.executionStatus !== nextProps.executionStatus ||
|
||||||
|
prevProps.executionOrder !== nextProps.executionOrder ||
|
||||||
|
prevProps.draggingNodeId !== nextProps.draggingNodeId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果正在被拖拽,且 dragDelta 变化,需要重新渲染
|
||||||
|
if (nextProps.isBeingDragged &&
|
||||||
|
(prevProps.dragDelta.dx !== nextProps.dragDelta.dx ||
|
||||||
|
prevProps.dragDelta.dy !== nextProps.dragDelta.dy)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果执行状态变化,需要重新渲染
|
||||||
|
if (prevProps.isExecuting !== nextProps.isExecuting) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 uncommittedNodeIds 中是否包含当前节点
|
||||||
|
const prevUncommitted = prevProps.uncommittedNodeIds.has(nextProps.node.id);
|
||||||
|
const nextUncommitted = nextProps.uncommittedNodeIds.has(nextProps.node.id);
|
||||||
|
if (prevUncommitted !== nextUncommitted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节点数据变化时需要重新渲染
|
||||||
|
if (JSON.stringify(prevProps.node.data) !== JSON.stringify(nextProps.node.data)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他情况不重新渲染
|
||||||
|
return true;
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { NodeViewData } from '../../../types';
|
import { NodeViewData } from '../../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图标映射
|
* 图标映射
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/* 行为树编辑器面板样式 */
|
||||||
|
.behavior-tree-editor-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.behavior-tree-editor-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state svg {
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具栏 */
|
||||||
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background-color: #2d2d30;
|
||||||
|
border-bottom: 1px solid #3e3e42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left,
|
||||||
|
.toolbar-center,
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
color: #cccccc;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #cccccc;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover:not(:disabled) {
|
||||||
|
background-color: #3e3e42;
|
||||||
|
border-color: #464647;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:active:not(:disabled) {
|
||||||
|
background-color: #2a2d2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 画布容器 */
|
||||||
|
.editor-canvas-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 节点层 */
|
||||||
|
.nodes-layer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行为树画布 */
|
||||||
|
.behavior-tree-canvas {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-content {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Core, createLogger } from '@esengine/ecs-framework';
|
||||||
|
import { MessageHub } from '@esengine/editor-core';
|
||||||
|
import { open, save } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { useBehaviorTreeDataStore } from '../../stores';
|
||||||
|
import { BehaviorTreeEditor } from '../BehaviorTreeEditor';
|
||||||
|
import { BehaviorTreeService } from '../../services/BehaviorTreeService';
|
||||||
|
import { showToast } from '../../services/NotificationService';
|
||||||
|
import { FolderOpen } from 'lucide-react';
|
||||||
|
import type { Node as BehaviorTreeNode } from '../../domain/models/Node';
|
||||||
|
import './BehaviorTreeEditorPanel.css';
|
||||||
|
|
||||||
|
const logger = createLogger('BehaviorTreeEditorPanel');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行为树编辑器面板组件
|
||||||
|
* 提供完整的行为树编辑功能,包括:
|
||||||
|
* - 节点的创建、删除、移动
|
||||||
|
* - 连接管理
|
||||||
|
* - 黑板变量管理
|
||||||
|
* - 文件保存和加载
|
||||||
|
*/
|
||||||
|
interface BehaviorTreeEditorPanelProps {
|
||||||
|
/** 项目路径,用于文件系统操作 */
|
||||||
|
projectPath?: string | null;
|
||||||
|
/** 导出对话框打开回调 */
|
||||||
|
onOpenExportDialog?: () => void;
|
||||||
|
/** 获取可用文件列表回调 */
|
||||||
|
onGetAvailableFiles?: () => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BehaviorTreeEditorPanel: React.FC<BehaviorTreeEditorPanelProps> = ({
|
||||||
|
projectPath,
|
||||||
|
onOpenExportDialog
|
||||||
|
// onGetAvailableFiles - 保留用于未来的批量导出功能
|
||||||
|
}) => {
|
||||||
|
const isOpen = useBehaviorTreeDataStore((state) => state.isOpen);
|
||||||
|
const blackboardVariables = useBehaviorTreeDataStore((state) => state.blackboardVariables);
|
||||||
|
|
||||||
|
// 文件状态管理
|
||||||
|
const [currentFilePath, setCurrentFilePath] = useState<string | null>(null);
|
||||||
|
const [currentFileName, setCurrentFileName] = useState<string>('');
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string>('');
|
||||||
|
|
||||||
|
// 监听树的变化来检测未保存更改
|
||||||
|
const tree = useBehaviorTreeDataStore((state) => state.tree);
|
||||||
|
const storeFilePath = useBehaviorTreeDataStore((state) => state.currentFilePath);
|
||||||
|
const storeFileName = useBehaviorTreeDataStore((state) => state.currentFileName);
|
||||||
|
|
||||||
|
// 初始化时从 store 读取文件信息(解决时序问题)
|
||||||
|
useEffect(() => {
|
||||||
|
if (storeFilePath && !currentFilePath) {
|
||||||
|
setCurrentFilePath(storeFilePath);
|
||||||
|
setCurrentFileName(storeFileName);
|
||||||
|
const loadedTree = useBehaviorTreeDataStore.getState().tree;
|
||||||
|
setLastSavedSnapshot(JSON.stringify(loadedTree));
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
}
|
||||||
|
}, [storeFilePath, storeFileName, currentFilePath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && lastSavedSnapshot) {
|
||||||
|
const currentSnapshot = JSON.stringify(tree);
|
||||||
|
setHasUnsavedChanges(currentSnapshot !== lastSavedSnapshot);
|
||||||
|
}
|
||||||
|
}, [tree, lastSavedSnapshot, isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const messageHub = Core.services.resolve(MessageHub);
|
||||||
|
const unsubscribe = messageHub.subscribe('behavior-tree:file-opened', (data: { filePath: string; fileName: string }) => {
|
||||||
|
setCurrentFilePath(data.filePath);
|
||||||
|
setCurrentFileName(data.fileName);
|
||||||
|
const loadedTree = useBehaviorTreeDataStore.getState().tree;
|
||||||
|
setLastSavedSnapshot(JSON.stringify(loadedTree));
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to subscribe to file-opened event:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNodeSelect = useCallback((node: BehaviorTreeNode) => {
|
||||||
|
try {
|
||||||
|
const messageHub = Core.services.resolve(MessageHub);
|
||||||
|
messageHub.publish('behavior-tree:node-selected', { data: node });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to publish node selection:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
let filePath = currentFilePath;
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
const selected = await save({
|
||||||
|
filters: [{ name: 'Behavior Tree', extensions: ['btree'] }],
|
||||||
|
defaultPath: projectPath || undefined,
|
||||||
|
title: '保存行为树'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selected) return;
|
||||||
|
filePath = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = Core.services.resolve(BehaviorTreeService);
|
||||||
|
await service.saveToFile(filePath);
|
||||||
|
|
||||||
|
setCurrentFilePath(filePath);
|
||||||
|
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || 'Untitled';
|
||||||
|
setCurrentFileName(fileName);
|
||||||
|
setLastSavedSnapshot(JSON.stringify(tree));
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
|
||||||
|
showToast(`文件已保存: ${fileName}.btree`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save file:', error);
|
||||||
|
showToast(`保存失败: ${error}`, 'error');
|
||||||
|
}
|
||||||
|
}, [currentFilePath, projectPath, tree]);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
const confirmed = window.confirm('当前文件有未保存的更改,是否继续打开新文件?');
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = await open({
|
||||||
|
filters: [{ name: 'Behavior Tree', extensions: ['btree'] }],
|
||||||
|
multiple: false,
|
||||||
|
directory: false,
|
||||||
|
defaultPath: projectPath || undefined,
|
||||||
|
title: '打开行为树'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
const filePath = selected as string;
|
||||||
|
const service = Core.services.resolve(BehaviorTreeService);
|
||||||
|
await service.loadFromFile(filePath);
|
||||||
|
|
||||||
|
setCurrentFilePath(filePath);
|
||||||
|
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || 'Untitled';
|
||||||
|
setCurrentFileName(fileName);
|
||||||
|
|
||||||
|
const loadedTree = useBehaviorTreeDataStore.getState().tree;
|
||||||
|
setLastSavedSnapshot(JSON.stringify(loadedTree));
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
|
||||||
|
showToast(`文件已打开: ${fileName}.btree`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to open file:', error);
|
||||||
|
showToast(`打开失败: ${error}`, 'error');
|
||||||
|
}
|
||||||
|
}, [hasUnsavedChanges, projectPath]);
|
||||||
|
|
||||||
|
const handleExport = useCallback(() => {
|
||||||
|
if (onOpenExportDialog) {
|
||||||
|
onOpenExportDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messageHub = Core.services.resolve(MessageHub);
|
||||||
|
messageHub.publish('compiler:open-dialog', {
|
||||||
|
compilerId: 'behavior-tree',
|
||||||
|
currentFileName: currentFileName || undefined,
|
||||||
|
projectPath: projectPath || undefined
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to open export dialog:', error);
|
||||||
|
showToast(`无法打开导出对话框: ${error}`, 'error');
|
||||||
|
}
|
||||||
|
}, [onOpenExportDialog, currentFileName, projectPath]);
|
||||||
|
|
||||||
|
const handleCopyToClipboard = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const store = useBehaviorTreeDataStore.getState();
|
||||||
|
const metadata = { name: currentFileName || 'Untitled', description: '' };
|
||||||
|
const jsonContent = store.exportToJSON(metadata);
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(jsonContent);
|
||||||
|
showToast('已复制到剪贴板', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to copy to clipboard:', error);
|
||||||
|
showToast(`复制失败: ${error}`, 'error');
|
||||||
|
}
|
||||||
|
}, [currentFileName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.ctrlKey && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.key === 'o') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleOpen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleSave, handleOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<div className="behavior-tree-editor-empty">
|
||||||
|
<div className="empty-state">
|
||||||
|
<FolderOpen size={48} />
|
||||||
|
<p>No behavior tree file opened</p>
|
||||||
|
<p className="hint">Double-click a .btree file to edit</p>
|
||||||
|
<button
|
||||||
|
onClick={handleOpen}
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#0e639c',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderOpen size={16} />
|
||||||
|
打开文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="behavior-tree-editor-panel">
|
||||||
|
<BehaviorTreeEditor
|
||||||
|
blackboardVariables={blackboardVariables}
|
||||||
|
projectPath={projectPath}
|
||||||
|
showToolbar={true}
|
||||||
|
currentFileName={currentFileName}
|
||||||
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
|
onNodeSelect={handleNodeSelect}
|
||||||
|
onSave={handleSave}
|
||||||
|
onOpen={handleOpen}
|
||||||
|
onExport={handleExport}
|
||||||
|
onCopyToClipboard={handleCopyToClipboard}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Play, Pause, Square, SkipForward, Undo, Redo, ZoomIn, Save, FolderOpen, Download, Clipboard, Home } from 'lucide-react';
|
||||||
|
|
||||||
|
type ExecutionMode = 'idle' | 'running' | 'paused';
|
||||||
|
|
||||||
|
interface EditorToolbarProps {
|
||||||
|
executionMode: ExecutionMode;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
hasUnsavedChanges?: boolean;
|
||||||
|
onPlay: () => void;
|
||||||
|
onPause: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onStep: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onRedo: () => void;
|
||||||
|
onResetView: () => void;
|
||||||
|
onSave?: () => void;
|
||||||
|
onOpen?: () => void;
|
||||||
|
onExport?: () => void;
|
||||||
|
onCopyToClipboard?: () => void;
|
||||||
|
onGoToRoot?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditorToolbar: React.FC<EditorToolbarProps> = ({
|
||||||
|
executionMode,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
hasUnsavedChanges = false,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onStop,
|
||||||
|
onStep,
|
||||||
|
onReset,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
onResetView,
|
||||||
|
onSave,
|
||||||
|
onOpen,
|
||||||
|
onExport,
|
||||||
|
onCopyToClipboard,
|
||||||
|
onGoToRoot
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '12px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '6px',
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
padding: '6px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||||
|
border: '1px solid #3f3f3f',
|
||||||
|
zIndex: 100
|
||||||
|
}}>
|
||||||
|
{/* 文件操作组 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '4px',
|
||||||
|
padding: '2px',
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
borderRadius: '6px'
|
||||||
|
}}>
|
||||||
|
{onOpen && (
|
||||||
|
<button
|
||||||
|
onClick={onOpen}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
backgroundColor: '#3c3c3c',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#ccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
title="打开文件 (Ctrl+O)"
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onSave && (
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
backgroundColor: hasUnsavedChanges ? '#2563eb' : '#3c3c3c',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: hasUnsavedChanges ? '#fff' : '#ccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
title={`保存 (Ctrl+S)${hasUnsavedChanges ? ' - 有未保存的更改' : ''}`}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = hasUnsavedChanges ? '#1d4ed8' : '#4a4a4a'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = hasUnsavedChanges ? '#2563eb' : '#3c3c3c'}
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onExport && (
|
||||||
|
<button
|
||||||
|
onClick={onExport}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
backgroundColor: '#3c3c3c',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#ccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
title="导出运行时配置"
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onCopyToClipboard && (
|
||||||
|
<button
|
||||||
|
onClick={onCopyToClipboard}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
backgroundColor: '#3c3c3c',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#ccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
title="复制JSON到剪贴板"
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
|
||||||
|
>
|
||||||
|
<Clipboard size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔符 */}
|
||||||
|
<div style={{
|
||||||
|
width: '1px',
|
||||||
|
backgroundColor: '#444',
|
||||||
|
margin: '2px 0'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 执行控制组 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '4px',
|
||||||
|
padding: '2px',
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
borderRadius: '6px'
|
||||||
|
}}>
|
||||||
|
{/* 播放按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onPlay}
|
||||||
|
disabled={executionMode === 'running'}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
backgroundColor: executionMode === 'running' ? '#2a2a2a' : '#16a34a',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: executionMode === 'running' ? '#666' : '#fff',
|
||||||
|
cursor: executionMode === 'running' ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
title="运行 (Play)"
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (executionMode !== 'running') {
|
||||||
|
e.currentTarget.style.backgroundColor = '#15803d';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (executionMode !== 'running') {
|
||||||
|
e.currentTarget.style.backgroundColor = '#16a34a';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play size={14} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 暂停按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onPause}
|
||||||
|
disabled={executionMode === 'idle'}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
backgroundColor: executionMode === 'idle' ? '#2a2a2a' : '#f59e0b',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: executionMode === 'idle' ? '#666' : '#fff',
|
||||||
|
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
title={executionMode === 'paused' ? '继续' : '暂停'}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (executionMode !== 'idle') {
|
||||||
|
e.currentTarget.style.backgroundColor = '#d97706';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (executionMode !== 'idle') {
|
||||||
|
e.currentTarget.style.backgroundColor = '#f59e0b';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{executionMode === 'paused' ? <Play size={14} fill="currentColor" /> : <Pause size={14} fill="currentColor" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 停止按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onStop}
|
||||||
|
disabled={executionMode === 'idle'}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
backgroundColor: executionMode === 'idle' ? '#2a2a2a' : '#dc2626',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: executionMode === 'idle' ? '#666' : '#fff',
|
||||||
|
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
title="停止"
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (executionMode !== 'idle') {
|
||||||
|
e.currentTarget.style.backgroundColor = '#b91c1c';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (executionMode !== 'idle') {
|
||||||
|
e.currentTarget.style.backgroundColor = '#dc2626';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Square size={14} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 单步执行按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onStep}
|
||||||
|
disabled={executionMode !== 'idle' && executionMode !== 'paused'}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
backgroundColor: (executionMode !== 'idle' && executionMode !== 'paused') ? '#2a2a2a' : '#3b82f6',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: (executionMode !== 'idle' && executionMode !== 'paused') ? '#666' : '#fff',
|
||||||
|
cursor: (executionMode !== 'idle' && executionMode !== 'paused') ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
title="单步执行"
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (executionMode === 'idle' || executionMode === 'paused') {
|
||||||
|
e.currentTarget.style.backgroundColor = '#2563eb';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (executionMode === 'idle' || executionMode === 'paused') {
|
||||||
|
e.currentTarget.style.backgroundColor = '#3b82f6';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SkipForward size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔符 */}
|
||||||
|
<div style={{
|
||||||
|
width: '1px',
|
||||||
|
backgroundColor: '#444',
|
||||||
|
margin: '2px 0'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 视图控制 */}
|
||||||
|
<button
|
||||||
|
onClick={onResetView}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
backgroundColor: '#3c3c3c',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#ccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
title="重置视图 (滚轮缩放, Alt+拖动平移)"
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
|
||||||
|
>
|
||||||
|
<ZoomIn size={13} />
|
||||||
|
<span>Reset View</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 分隔符 */}
|
||||||
|
<div style={{
|
||||||
|
width: '1px',
|
||||||
|
backgroundColor: '#444',
|
||||||
|
margin: '2px 0'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 历史控制组 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '4px',
|
||||||
|
padding: '2px',
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
borderRadius: '6px'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
backgroundColor: canUndo ? '#3c3c3c' : '#2a2a2a',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: canUndo ? '#ccc' : '#666',
|
||||||
|
cursor: canUndo ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
title="撤销 (Ctrl+Z)"
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (canUndo) {
|
||||||
|
e.currentTarget.style.backgroundColor = '#4a4a4a';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (canUndo) {
|
||||||
|
e.currentTarget.style.backgroundColor = '#3c3c3c';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Undo size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onRedo}
|
||||||
|
disabled={!canRedo}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
backgroundColor: canRedo ? '#3c3c3c' : '#2a2a2a',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: canRedo ? '#ccc' : '#666',
|
||||||
|
cursor: canRedo ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
title="重做 (Ctrl+Shift+Z / Ctrl+Y)"
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (canRedo) {
|
||||||
|
e.currentTarget.style.backgroundColor = '#4a4a4a';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (canRedo) {
|
||||||
|
e.currentTarget.style.backgroundColor = '#3c3c3c';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Redo size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态指示器 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#999',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: '70px'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor:
|
||||||
|
executionMode === 'running' ? '#16a34a' :
|
||||||
|
executionMode === 'paused' ? '#f59e0b' : '#666',
|
||||||
|
boxShadow: executionMode !== 'idle' ? `0 0 8px ${
|
||||||
|
executionMode === 'running' ? '#16a34a' :
|
||||||
|
executionMode === 'paused' ? '#f59e0b' : 'transparent'
|
||||||
|
}` : 'none',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}} />
|
||||||
|
<span style={{
|
||||||
|
color: executionMode === 'running' ? '#16a34a' :
|
||||||
|
executionMode === 'paused' ? '#f59e0b' : '#888'
|
||||||
|
}}>
|
||||||
|
{executionMode === 'idle' ? 'Idle' :
|
||||||
|
executionMode === 'running' ? 'Running' : 'Paused'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onGoToRoot && (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
width: '1px',
|
||||||
|
backgroundColor: '#444',
|
||||||
|
margin: '2px 0'
|
||||||
|
}} />
|
||||||
|
<button
|
||||||
|
onClick={onGoToRoot}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
backgroundColor: '#3c3c3c',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#ccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
title="回到根节点"
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
|
||||||
|
>
|
||||||
|
<Home size={13} />
|
||||||
|
<span>Root</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -52,5 +52,9 @@ export const DEFAULT_EDITOR_CONFIG = {
|
|||||||
minZoom: 0.1,
|
minZoom: 0.1,
|
||||||
maxZoom: 3,
|
maxZoom: 3,
|
||||||
showGrid: true,
|
showGrid: true,
|
||||||
showMinimap: false
|
showMinimap: false,
|
||||||
|
defaultRootNodePosition: {
|
||||||
|
x: 400,
|
||||||
|
y: 100
|
||||||
|
}
|
||||||
};
|
};
|
||||||
47
packages/behavior-tree-editor/src/constants/index.ts
Normal file
47
packages/behavior-tree-editor/src/constants/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 行为树编辑器常量定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 根节点 ID
|
||||||
|
export const ROOT_NODE_ID = 'root';
|
||||||
|
|
||||||
|
// 节点类型
|
||||||
|
export enum NodeType {
|
||||||
|
Root = 'root',
|
||||||
|
Sequence = 'sequence',
|
||||||
|
Selector = 'selector',
|
||||||
|
Parallel = 'parallel',
|
||||||
|
Decorator = 'decorator',
|
||||||
|
Action = 'action',
|
||||||
|
Condition = 'condition'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 端口类型
|
||||||
|
export enum PortType {
|
||||||
|
Input = 'input',
|
||||||
|
Output = 'output'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器默认配置
|
||||||
|
export const DEFAULT_EDITOR_CONFIG = {
|
||||||
|
showGrid: true,
|
||||||
|
gridSize: 20,
|
||||||
|
snapToGrid: true,
|
||||||
|
canvasBackground: '#1a1a1a',
|
||||||
|
connectionColor: '#4a9eff',
|
||||||
|
nodeSpacing: { x: 200, y: 100 },
|
||||||
|
nodeWidth: 160,
|
||||||
|
nodeHeight: 60,
|
||||||
|
portSize: 8
|
||||||
|
};
|
||||||
|
|
||||||
|
// 颜色配置
|
||||||
|
export const NODE_COLORS = {
|
||||||
|
[NodeType.Root]: '#666',
|
||||||
|
[NodeType.Sequence]: '#4a9eff',
|
||||||
|
[NodeType.Selector]: '#ffb84d',
|
||||||
|
[NodeType.Parallel]: '#b84dff',
|
||||||
|
[NodeType.Decorator]: '#4dffb8',
|
||||||
|
[NodeType.Action]: '#ff4d4d',
|
||||||
|
[NodeType.Condition]: '#4dff9e'
|
||||||
|
};
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useUIStore } from '../../application/state/UIStore';
|
import { useBehaviorTreeDataStore, useUIStore } from '../stores';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 画布交互 Hook
|
* 画布交互 Hook
|
||||||
* 封装画布的缩放、平移等交互逻辑
|
* 封装画布的缩放、平移等交互逻辑
|
||||||
*/
|
*/
|
||||||
export function useCanvasInteraction() {
|
export function useCanvasInteraction() {
|
||||||
const {
|
// 从数据 store 获取画布状态
|
||||||
canvasOffset,
|
const canvasOffset = useBehaviorTreeDataStore(state => state.canvasOffset);
|
||||||
canvasScale,
|
const canvasScale = useBehaviorTreeDataStore(state => state.canvasScale);
|
||||||
isPanning,
|
const setCanvasOffset = useBehaviorTreeDataStore(state => state.setCanvasOffset);
|
||||||
panStart,
|
const setCanvasScale = useBehaviorTreeDataStore(state => state.setCanvasScale);
|
||||||
setCanvasOffset,
|
const resetView = useBehaviorTreeDataStore(state => state.resetView);
|
||||||
setCanvasScale,
|
|
||||||
setIsPanning,
|
// 从 UI store 获取平移状态
|
||||||
setPanStart,
|
const isPanning = useUIStore(state => state.isPanning);
|
||||||
resetView
|
const panStart = useUIStore(state => state.panStart);
|
||||||
} = useUIStore();
|
const setIsPanning = useUIStore(state => state.setIsPanning);
|
||||||
|
const setPanStart = useUIStore(state => state.setPanStart);
|
||||||
|
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { RefObject, useEffect, useRef } from 'react';
|
import { RefObject, useEffect, useRef } from 'react';
|
||||||
import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
|
||||||
|
|
||||||
interface QuickCreateMenuState {
|
interface QuickCreateMenuState {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -15,6 +15,7 @@ interface UseCanvasMouseEventsParams {
|
|||||||
canvasOffset: { x: number; y: number };
|
canvasOffset: { x: number; y: number };
|
||||||
canvasScale: number;
|
canvasScale: number;
|
||||||
connectingFrom: string | null;
|
connectingFrom: string | null;
|
||||||
|
connectingFromProperty: string | null;
|
||||||
connectingToPos: { x: number; y: number } | null;
|
connectingToPos: { x: number; y: number } | null;
|
||||||
isBoxSelecting: boolean;
|
isBoxSelecting: boolean;
|
||||||
boxSelectStart: { x: number; y: number } | null;
|
boxSelectStart: { x: number; y: number } | null;
|
||||||
@@ -40,6 +41,7 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
|||||||
canvasOffset,
|
canvasOffset,
|
||||||
canvasScale,
|
canvasScale,
|
||||||
connectingFrom,
|
connectingFrom,
|
||||||
|
connectingFromProperty,
|
||||||
connectingToPos,
|
connectingToPos,
|
||||||
isBoxSelecting,
|
isBoxSelecting,
|
||||||
boxSelectStart,
|
boxSelectStart,
|
||||||
@@ -61,11 +63,19 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
|||||||
|
|
||||||
const isBoxSelectingRef = useRef(isBoxSelecting);
|
const isBoxSelectingRef = useRef(isBoxSelecting);
|
||||||
const boxSelectStartRef = useRef(boxSelectStart);
|
const boxSelectStartRef = useRef(boxSelectStart);
|
||||||
|
const canvasOffsetRef = useRef(canvasOffset);
|
||||||
|
const canvasScaleRef = useRef(canvasScale);
|
||||||
|
const nodesRef = useRef(nodes);
|
||||||
|
const selectedNodeIdsRef = useRef(selectedNodeIds);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isBoxSelectingRef.current = isBoxSelecting;
|
isBoxSelectingRef.current = isBoxSelecting;
|
||||||
boxSelectStartRef.current = boxSelectStart;
|
boxSelectStartRef.current = boxSelectStart;
|
||||||
}, [isBoxSelecting, boxSelectStart]);
|
canvasOffsetRef.current = canvasOffset;
|
||||||
|
canvasScaleRef.current = canvasScale;
|
||||||
|
nodesRef.current = nodes;
|
||||||
|
selectedNodeIdsRef.current = selectedNodeIds;
|
||||||
|
}, [isBoxSelecting, boxSelectStart, canvasOffset, canvasScale, nodes, selectedNodeIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isBoxSelecting) return;
|
if (!isBoxSelecting) return;
|
||||||
@@ -76,8 +86,8 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
|||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
||||||
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
|
const canvasX = (e.clientX - rect.left - canvasOffsetRef.current.x) / canvasScaleRef.current;
|
||||||
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
|
const canvasY = (e.clientY - rect.top - canvasOffsetRef.current.y) / canvasScaleRef.current;
|
||||||
setBoxSelectEnd({ x: canvasX, y: canvasY });
|
setBoxSelectEnd({ x: canvasX, y: canvasY });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,7 +105,7 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
|||||||
const minY = Math.min(boxSelectStartRef.current.y, boxSelectEnd.y);
|
const minY = Math.min(boxSelectStartRef.current.y, boxSelectEnd.y);
|
||||||
const maxY = Math.max(boxSelectStartRef.current.y, boxSelectEnd.y);
|
const maxY = Math.max(boxSelectStartRef.current.y, boxSelectEnd.y);
|
||||||
|
|
||||||
const selectedInBox = nodes
|
const selectedInBox = nodesRef.current
|
||||||
.filter((node: BehaviorTreeNode) => {
|
.filter((node: BehaviorTreeNode) => {
|
||||||
if (node.id === ROOT_NODE_ID) return false;
|
if (node.id === ROOT_NODE_ID) return false;
|
||||||
|
|
||||||
@@ -108,17 +118,17 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
|||||||
const nodeRect = nodeElement.getBoundingClientRect();
|
const nodeRect = nodeElement.getBoundingClientRect();
|
||||||
const canvasRect = canvasRef.current!.getBoundingClientRect();
|
const canvasRect = canvasRef.current!.getBoundingClientRect();
|
||||||
|
|
||||||
const nodeLeft = (nodeRect.left - canvasRect.left - canvasOffset.x) / canvasScale;
|
const nodeLeft = (nodeRect.left - canvasRect.left - canvasOffsetRef.current.x) / canvasScaleRef.current;
|
||||||
const nodeRight = (nodeRect.right - canvasRect.left - canvasOffset.x) / canvasScale;
|
const nodeRight = (nodeRect.right - canvasRect.left - canvasOffsetRef.current.x) / canvasScaleRef.current;
|
||||||
const nodeTop = (nodeRect.top - canvasRect.top - canvasOffset.y) / canvasScale;
|
const nodeTop = (nodeRect.top - canvasRect.top - canvasOffsetRef.current.y) / canvasScaleRef.current;
|
||||||
const nodeBottom = (nodeRect.bottom - canvasRect.top - canvasOffset.y) / canvasScale;
|
const nodeBottom = (nodeRect.bottom - canvasRect.top - canvasOffsetRef.current.y) / canvasScaleRef.current;
|
||||||
|
|
||||||
return nodeRight > minX && nodeLeft < maxX && nodeBottom > minY && nodeTop < maxY;
|
return nodeRight > minX && nodeLeft < maxX && nodeBottom > minY && nodeTop < maxY;
|
||||||
})
|
})
|
||||||
.map((node: BehaviorTreeNode) => node.id);
|
.map((node: BehaviorTreeNode) => node.id);
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
const newSet = new Set([...selectedNodeIds, ...selectedInBox]);
|
const newSet = new Set([...selectedNodeIdsRef.current, ...selectedInBox]);
|
||||||
setSelectedNodeIds(Array.from(newSet));
|
setSelectedNodeIds(Array.from(newSet));
|
||||||
} else {
|
} else {
|
||||||
setSelectedNodeIds(selectedInBox);
|
setSelectedNodeIds(selectedInBox);
|
||||||
@@ -134,7 +144,7 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
|||||||
document.removeEventListener('mousemove', handleGlobalMouseMove);
|
document.removeEventListener('mousemove', handleGlobalMouseMove);
|
||||||
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||||
};
|
};
|
||||||
}, [isBoxSelecting, boxSelectStart, boxSelectEnd, nodes, selectedNodeIds, canvasRef, canvasOffset, canvasScale, setBoxSelectEnd, setSelectedNodeIds, clearBoxSelect]);
|
}, [isBoxSelecting, boxSelectStart, boxSelectEnd, canvasRef, setBoxSelectEnd, setSelectedNodeIds, clearBoxSelect]);
|
||||||
|
|
||||||
const handleCanvasMouseMove = (e: React.MouseEvent) => {
|
const handleCanvasMouseMove = (e: React.MouseEvent) => {
|
||||||
if (connectingFrom && canvasRef.current && !quickCreateMenu.visible) {
|
if (connectingFrom && canvasRef.current && !quickCreateMenu.visible) {
|
||||||
@@ -153,7 +163,24 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const isPort = target.closest('[data-port="true"]');
|
||||||
|
if (isPort) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (connectingFrom && connectingToPos) {
|
if (connectingFrom && connectingToPos) {
|
||||||
|
// 如果是属性连接,不允许创建新节点
|
||||||
|
if (connectingFromProperty) {
|
||||||
|
showToast?.(
|
||||||
|
'属性连接必须连接到现有节点的属性端口',
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
clearConnecting();
|
||||||
|
setConnectingToPos(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const sourceNode = nodes.find(n => n.id === connectingFrom);
|
const sourceNode = nodes.find(n => n.id === connectingFrom);
|
||||||
if (sourceNode && !sourceNode.canAddChild()) {
|
if (sourceNode && !sourceNode.canAddChild()) {
|
||||||
const maxChildren = sourceNode.template.maxChildren ?? Infinity;
|
const maxChildren = sourceNode.template.maxChildren ?? Infinity;
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import { useRef, useCallback, useMemo, useEffect } from 'react';
|
import { useRef, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { CommandManager } from '../../application/commands/CommandManager';
|
import { CommandManager } from '@esengine/editor-core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 撤销/重做功能 Hook
|
* 撤销/重做功能 Hook
|
||||||
*/
|
*/
|
||||||
export function useCommandHistory() {
|
export function useCommandHistory() {
|
||||||
const commandManagerRef = useRef<CommandManager>(new CommandManager({
|
const commandManagerRef = useRef<CommandManager>(new CommandManager());
|
||||||
maxHistorySize: 100,
|
|
||||||
autoMerge: true
|
|
||||||
}));
|
|
||||||
|
|
||||||
const commandManager = commandManagerRef.current;
|
const commandManager = commandManagerRef.current;
|
||||||
|
|
||||||
@@ -67,7 +64,7 @@ export function useCommandHistory() {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [undo, redo]);
|
}, [undo, redo]);
|
||||||
|
|
||||||
return useMemo(() => ({
|
return {
|
||||||
commandManager,
|
commandManager,
|
||||||
canUndo: canUndo(),
|
canUndo: canUndo(),
|
||||||
canRedo: canRedo(),
|
canRedo: canRedo(),
|
||||||
@@ -76,5 +73,5 @@ export function useCommandHistory() {
|
|||||||
getUndoHistory,
|
getUndoHistory,
|
||||||
getRedoHistory,
|
getRedoHistory,
|
||||||
clear
|
clear
|
||||||
}), [commandManager, canUndo, canRedo, undo, redo, getUndoHistory, getRedoHistory, clear]);
|
};
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { ConnectionType } from '../../domain/models/Connection';
|
import { CommandManager } from '@esengine/editor-core';
|
||||||
import { IValidator } from '../../domain/interfaces/IValidator';
|
import { ConnectionType } from '../domain/models/Connection';
|
||||||
import { CommandManager } from '../../application/commands/CommandManager';
|
import { IValidator } from '../domain/interfaces/IValidator';
|
||||||
import { TreeStateAdapter } from '../../application/state/BehaviorTreeDataStore';
|
import { TreeStateAdapter } from '../application/state/BehaviorTreeDataStore';
|
||||||
import { AddConnectionUseCase } from '../../application/use-cases/AddConnectionUseCase';
|
import { AddConnectionUseCase } from '../application/use-cases/AddConnectionUseCase';
|
||||||
import { RemoveConnectionUseCase } from '../../application/use-cases/RemoveConnectionUseCase';
|
import { RemoveConnectionUseCase } from '../application/use-cases/RemoveConnectionUseCase';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
const logger = createLogger('useConnectionOperations');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连接操作 Hook
|
* 连接操作 Hook
|
||||||
@@ -13,7 +16,7 @@ export function useConnectionOperations(
|
|||||||
validator: IValidator,
|
validator: IValidator,
|
||||||
commandManager: CommandManager
|
commandManager: CommandManager
|
||||||
) {
|
) {
|
||||||
const treeState = useMemo(() => new TreeStateAdapter(), []);
|
const treeState = useMemo(() => TreeStateAdapter.getInstance(), []);
|
||||||
|
|
||||||
const addConnectionUseCase = useMemo(
|
const addConnectionUseCase = useMemo(
|
||||||
() => new AddConnectionUseCase(commandManager, treeState, validator),
|
() => new AddConnectionUseCase(commandManager, treeState, validator),
|
||||||
@@ -35,7 +38,7 @@ export function useConnectionOperations(
|
|||||||
try {
|
try {
|
||||||
return addConnectionUseCase.execute(from, to, connectionType, fromProperty, toProperty);
|
return addConnectionUseCase.execute(from, to, connectionType, fromProperty, toProperty);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('添加连接失败:', error);
|
logger.error('添加连接失败:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [addConnectionUseCase]);
|
}, [addConnectionUseCase]);
|
||||||
@@ -49,7 +52,7 @@ export function useConnectionOperations(
|
|||||||
try {
|
try {
|
||||||
removeConnectionUseCase.execute(from, to, fromProperty, toProperty);
|
removeConnectionUseCase.execute(from, to, fromProperty, toProperty);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('移除连接失败:', error);
|
logger.error('移除连接失败:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [removeConnectionUseCase]);
|
}, [removeConnectionUseCase]);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
|
||||||
|
|
||||||
interface ContextMenuState {
|
interface ContextMenuState {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useState, RefObject } from 'react';
|
import { useState, RefObject } from 'react';
|
||||||
import { NodeTemplate, NodeType } from '@esengine/behavior-tree';
|
import { NodeTemplate, NodeType } from '@esengine/behavior-tree';
|
||||||
import { Position } from '../../domain/value-objects/Position';
|
import { Position } from '../domain/value-objects/Position';
|
||||||
import { useNodeOperations } from './useNodeOperations';
|
import { useNodeOperations } from './useNodeOperations';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
const logger = createLogger('useDropHandler');
|
||||||
|
|
||||||
interface DraggedVariableData {
|
interface DraggedVariableData {
|
||||||
variableName: string;
|
variableName: string;
|
||||||
@@ -96,7 +99,7 @@ export function useDropHandler(params: UseDropHandlerParams) {
|
|||||||
|
|
||||||
onNodeCreate?.(template, position);
|
onNodeCreate?.(template, position);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create node:', error);
|
logger.error('Failed to create node:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
import { ask } from '@tauri-apps/plugin-dialog';
|
import { ask } from '@tauri-apps/plugin-dialog';
|
||||||
import { BehaviorTreeNode } from '../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode } from '../stores';
|
||||||
import { Node } from '../../domain/models/Node';
|
|
||||||
import { Position } from '../../domain/value-objects/Position';
|
|
||||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
|
||||||
|
|
||||||
interface UseEditorHandlersParams {
|
interface UseEditorHandlersParams {
|
||||||
isDraggingNode: boolean;
|
isDraggingNode: boolean;
|
||||||
selectedNodeIds: string[];
|
selectedNodeIds: string[];
|
||||||
setSelectedNodeIds: (ids: string[]) => void;
|
setSelectedNodeIds: (ids: string[]) => void;
|
||||||
setNodes: (nodes: Node[]) => void;
|
|
||||||
setConnections: (connections: any[]) => void;
|
|
||||||
resetView: () => void;
|
resetView: () => void;
|
||||||
|
resetTree: () => void;
|
||||||
triggerForceUpdate: () => void;
|
triggerForceUpdate: () => void;
|
||||||
onNodeSelect?: (node: BehaviorTreeNode) => void;
|
onNodeSelect?: (node: BehaviorTreeNode) => void;
|
||||||
rootNodeId: string;
|
|
||||||
rootNodeTemplate: NodeTemplate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEditorHandlers(params: UseEditorHandlersParams) {
|
export function useEditorHandlers(params: UseEditorHandlersParams) {
|
||||||
@@ -22,16 +17,16 @@ export function useEditorHandlers(params: UseEditorHandlersParams) {
|
|||||||
isDraggingNode,
|
isDraggingNode,
|
||||||
selectedNodeIds,
|
selectedNodeIds,
|
||||||
setSelectedNodeIds,
|
setSelectedNodeIds,
|
||||||
setNodes,
|
|
||||||
setConnections,
|
|
||||||
resetView,
|
resetView,
|
||||||
|
resetTree,
|
||||||
triggerForceUpdate,
|
triggerForceUpdate,
|
||||||
onNodeSelect,
|
onNodeSelect
|
||||||
rootNodeId,
|
|
||||||
rootNodeTemplate
|
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const handleNodeClick = (e: React.MouseEvent, node: BehaviorTreeNode) => {
|
const handleNodeClick = useCallback((e: React.MouseEvent, node: BehaviorTreeNode) => {
|
||||||
|
// 阻止事件冒泡,避免触发画布的点击事件
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
if (isDraggingNode) {
|
if (isDraggingNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -46,35 +41,26 @@ export function useEditorHandlers(params: UseEditorHandlersParams) {
|
|||||||
setSelectedNodeIds([node.id]);
|
setSelectedNodeIds([node.id]);
|
||||||
}
|
}
|
||||||
onNodeSelect?.(node);
|
onNodeSelect?.(node);
|
||||||
};
|
}, [isDraggingNode, selectedNodeIds, setSelectedNodeIds, onNodeSelect]);
|
||||||
|
|
||||||
const handleResetView = () => {
|
const handleResetView = useCallback(() => {
|
||||||
resetView();
|
resetView();
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
triggerForceUpdate();
|
triggerForceUpdate();
|
||||||
});
|
});
|
||||||
};
|
}, [resetView, triggerForceUpdate]);
|
||||||
|
|
||||||
const handleClearCanvas = async () => {
|
const handleClearCanvas = useCallback(async () => {
|
||||||
const confirmed = await ask('确定要清空画布吗?此操作不可撤销。', {
|
const confirmed = await ask('确定要清空画布吗?此操作不可撤销。', {
|
||||||
title: '清空画布',
|
title: '清空画布',
|
||||||
kind: 'warning'
|
kind: 'warning'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
setNodes([
|
resetTree();
|
||||||
new Node(
|
|
||||||
rootNodeId,
|
|
||||||
rootNodeTemplate,
|
|
||||||
{ nodeType: 'root' },
|
|
||||||
new Position(400, 100),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
setConnections([]);
|
|
||||||
setSelectedNodeIds([]);
|
setSelectedNodeIds([]);
|
||||||
}
|
}
|
||||||
};
|
}, [resetTree, setSelectedNodeIds]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleNodeClick,
|
handleNodeClick,
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { BehaviorTreeExecutor } from '../../utils/BehaviorTreeExecutor';
|
import { BehaviorTreeExecutor } from '../utils/BehaviorTreeExecutor';
|
||||||
|
|
||||||
export function useEditorState() {
|
export function useEditorState() {
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { ExecutionController, ExecutionMode } from '../../application/services/ExecutionController';
|
import { ExecutionController, ExecutionMode } from '../application/services/ExecutionController';
|
||||||
import { BlackboardManager } from '../../application/services/BlackboardManager';
|
import { BlackboardManager } from '../application/services/BlackboardManager';
|
||||||
import { BehaviorTreeNode, Connection, useBehaviorTreeStore } from '../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode, Connection, useBehaviorTreeDataStore } from '../stores';
|
||||||
import { ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
import { ExecutionLog } from '../utils/BehaviorTreeExecutor';
|
||||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
import { BlackboardValue } from '../domain/models/Blackboard';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
const logger = createLogger('useExecutionController');
|
||||||
|
|
||||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||||
|
|
||||||
@@ -19,6 +22,7 @@ interface UseExecutionControllerParams {
|
|||||||
onExecutingChange: (isExecuting: boolean) => void;
|
onExecutingChange: (isExecuting: boolean) => void;
|
||||||
onSaveNodesDataSnapshot: () => void;
|
onSaveNodesDataSnapshot: () => void;
|
||||||
onRestoreNodesData: () => void;
|
onRestoreNodesData: () => void;
|
||||||
|
sortChildrenByPosition: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useExecutionController(params: UseExecutionControllerParams) {
|
export function useExecutionController(params: UseExecutionControllerParams) {
|
||||||
@@ -32,7 +36,8 @@ export function useExecutionController(params: UseExecutionControllerParams) {
|
|||||||
onInitialBlackboardSave,
|
onInitialBlackboardSave,
|
||||||
onExecutingChange,
|
onExecutingChange,
|
||||||
onSaveNodesDataSnapshot,
|
onSaveNodesDataSnapshot,
|
||||||
onRestoreNodesData
|
onRestoreNodesData,
|
||||||
|
sortChildrenByPosition
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const [executionMode, setExecutionMode] = useState<ExecutionMode>('idle');
|
const [executionMode, setExecutionMode] = useState<ExecutionMode>('idle');
|
||||||
@@ -48,17 +53,20 @@ export function useExecutionController(params: UseExecutionControllerParams) {
|
|||||||
onBlackboardUpdate,
|
onBlackboardUpdate,
|
||||||
onTickCountUpdate: setTickCount,
|
onTickCountUpdate: setTickCount,
|
||||||
onExecutionStatusUpdate: (statuses, orders) => {
|
onExecutionStatusUpdate: (statuses, orders) => {
|
||||||
const store = useBehaviorTreeStore.getState();
|
const store = useBehaviorTreeDataStore.getState();
|
||||||
store.updateNodeExecutionStatuses(statuses, orders);
|
store.updateNodeExecutionStatuses(statuses, orders);
|
||||||
}
|
}
|
||||||
|
// 不在这里传递 onBreakpointHit,避免频繁重建
|
||||||
});
|
});
|
||||||
}, [rootNodeId, projectPath, onBlackboardUpdate]);
|
}, [rootNodeId, projectPath, onBlackboardUpdate]);
|
||||||
|
|
||||||
const blackboardManager = useMemo(() => new BlackboardManager(), []);
|
const blackboardManager = useMemo(() => new BlackboardManager(), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 保存当前 controller 的引用,确保清理时使用正确的实例
|
||||||
|
const currentController = controller;
|
||||||
return () => {
|
return () => {
|
||||||
controller.destroy();
|
currentController.destroy();
|
||||||
};
|
};
|
||||||
}, [controller]);
|
}, [controller]);
|
||||||
|
|
||||||
@@ -86,6 +94,9 @@ export function useExecutionController(params: UseExecutionControllerParams) {
|
|||||||
|
|
||||||
const handlePlay = async () => {
|
const handlePlay = async () => {
|
||||||
try {
|
try {
|
||||||
|
sortChildrenByPosition();
|
||||||
|
logger.info('[Execute] Sorted children by position before execution');
|
||||||
|
|
||||||
blackboardManager.setInitialVariables(blackboardVariables);
|
blackboardManager.setInitialVariables(blackboardVariables);
|
||||||
blackboardManager.setCurrentVariables(blackboardVariables);
|
blackboardManager.setCurrentVariables(blackboardVariables);
|
||||||
onInitialBlackboardSave(blackboardManager.getInitialVariables());
|
onInitialBlackboardSave(blackboardManager.getInitialVariables());
|
||||||
@@ -95,7 +106,7 @@ export function useExecutionController(params: UseExecutionControllerParams) {
|
|||||||
setExecutionMode('running');
|
setExecutionMode('running');
|
||||||
await controller.play(nodes, blackboardVariables, connections);
|
await controller.play(nodes, blackboardVariables, connections);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start execution:', error);
|
logger.error('Failed to start execution:', error);
|
||||||
setExecutionMode('idle');
|
setExecutionMode('idle');
|
||||||
onExecutingChange(false);
|
onExecutingChange(false);
|
||||||
}
|
}
|
||||||
@@ -107,7 +118,7 @@ export function useExecutionController(params: UseExecutionControllerParams) {
|
|||||||
const newMode = controller.getMode();
|
const newMode = controller.getMode();
|
||||||
setExecutionMode(newMode);
|
setExecutionMode(newMode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to pause/resume execution:', error);
|
logger.error('Failed to pause/resume execution:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,16 +131,16 @@ export function useExecutionController(params: UseExecutionControllerParams) {
|
|||||||
const restoredVars = blackboardManager.restoreInitialVariables();
|
const restoredVars = blackboardManager.restoreInitialVariables();
|
||||||
onBlackboardUpdate(restoredVars);
|
onBlackboardUpdate(restoredVars);
|
||||||
onRestoreNodesData();
|
onRestoreNodesData();
|
||||||
useBehaviorTreeStore.getState().clearNodeExecutionStatuses();
|
useBehaviorTreeDataStore.getState().clearNodeExecutionStatuses();
|
||||||
onExecutingChange(false);
|
onExecutingChange(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to stop execution:', error);
|
logger.error('Failed to stop execution:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStep = () => {
|
const handleStep = () => {
|
||||||
controller.step();
|
controller.step();
|
||||||
setExecutionMode('step');
|
// 单步执行后保持idle状态,不需要专门的step状态
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
@@ -138,7 +149,7 @@ export function useExecutionController(params: UseExecutionControllerParams) {
|
|||||||
setExecutionMode('idle');
|
setExecutionMode('idle');
|
||||||
setTickCount(0);
|
setTickCount(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to reset execution:', error);
|
logger.error('Failed to reset execution:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Connection, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
|
import { Connection, ROOT_NODE_ID } from '../stores';
|
||||||
import { useNodeOperations } from './useNodeOperations';
|
import { useNodeOperations } from './useNodeOperations';
|
||||||
import { useConnectionOperations } from './useConnectionOperations';
|
import { useConnectionOperations } from './useConnectionOperations';
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, RefObject } from 'react';
|
import { useRef, useCallback, RefObject } from 'react';
|
||||||
import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
|
||||||
import { Position } from '../../domain/value-objects/Position';
|
import { Position } from '../domain/value-objects/Position';
|
||||||
import { useNodeOperations } from './useNodeOperations';
|
import { useNodeOperations } from './useNodeOperations';
|
||||||
|
|
||||||
interface UseNodeDragParams {
|
interface UseNodeDragParams {
|
||||||
@@ -25,6 +25,20 @@ interface UseNodeDragParams {
|
|||||||
sortChildrenByPosition: () => void;
|
sortChildrenByPosition: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖拽上下文,存储拖拽过程中需要保持稳定的值
|
||||||
|
*/
|
||||||
|
interface DragContext {
|
||||||
|
// 鼠标按下时的客户端坐标
|
||||||
|
startClientX: number;
|
||||||
|
startClientY: number;
|
||||||
|
// 拖拽开始时的画布状态(缩放和偏移)
|
||||||
|
startCanvasScale: number;
|
||||||
|
startCanvasOffset: { x: number; y: number };
|
||||||
|
// 被拖拽节点的初始画布坐标
|
||||||
|
nodeStartPositions: Map<string, { x: number; y: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
export function useNodeDrag(params: UseNodeDragParams) {
|
export function useNodeDrag(params: UseNodeDragParams) {
|
||||||
const {
|
const {
|
||||||
canvasRef,
|
canvasRef,
|
||||||
@@ -48,15 +62,16 @@ export function useNodeDrag(params: UseNodeDragParams) {
|
|||||||
sortChildrenByPosition
|
sortChildrenByPosition
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
// 使用 ref 存储拖拽上下文,避免闭包问题
|
||||||
|
const dragContextRef = useRef<DragContext | null>(null);
|
||||||
|
|
||||||
const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => {
|
const handleNodeMouseDown = useCallback((e: React.MouseEvent, nodeId: string) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
|
|
||||||
if (nodeId === ROOT_NODE_ID) return;
|
if (nodeId === ROOT_NODE_ID) return;
|
||||||
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.getAttribute('data-port')) {
|
const isPort = target.closest('[data-port="true"]');
|
||||||
|
if (isPort) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,15 +80,11 @@ export function useNodeDrag(params: UseNodeDragParams) {
|
|||||||
setIsBoxSelecting(false);
|
setIsBoxSelecting(false);
|
||||||
setBoxSelectStart(null);
|
setBoxSelectStart(null);
|
||||||
setBoxSelectEnd(null);
|
setBoxSelectEnd(null);
|
||||||
|
|
||||||
const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId);
|
const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId);
|
||||||
if (!node) return;
|
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[];
|
let nodesToDrag: string[];
|
||||||
if (selectedNodeIds.includes(nodeId)) {
|
if (selectedNodeIds.includes(nodeId)) {
|
||||||
nodesToDrag = selectedNodeIds;
|
nodesToDrag = selectedNodeIds;
|
||||||
@@ -82,6 +93,7 @@ export function useNodeDrag(params: UseNodeDragParams) {
|
|||||||
setSelectedNodeIds([nodeId]);
|
setSelectedNodeIds([nodeId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录所有要拖拽节点的初始位置
|
||||||
const startPositions = new Map<string, { x: number; y: number }>();
|
const startPositions = new Map<string, { x: number; y: number }>();
|
||||||
nodesToDrag.forEach((id: string) => {
|
nodesToDrag.forEach((id: string) => {
|
||||||
const n = nodes.find((node: BehaviorTreeNode) => node.id === id);
|
const n = nodes.find((node: BehaviorTreeNode) => node.id === id);
|
||||||
@@ -90,44 +102,48 @@ export function useNodeDrag(params: UseNodeDragParams) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
startDragging(nodeId, startPositions);
|
// 创建拖拽上下文,保存拖拽开始时的所有关键状态
|
||||||
setDragOffset({
|
dragContextRef.current = {
|
||||||
x: canvasX - node.position.x,
|
startClientX: e.clientX,
|
||||||
y: canvasY - node.position.y
|
startClientY: e.clientY,
|
||||||
});
|
startCanvasScale: canvasScale,
|
||||||
};
|
startCanvasOffset: { ...canvasOffset },
|
||||||
|
nodeStartPositions: startPositions
|
||||||
|
};
|
||||||
|
|
||||||
const handleNodeMouseMove = (e: React.MouseEvent) => {
|
startDragging(nodeId, startPositions);
|
||||||
if (!draggingNodeId) return;
|
}, [nodes, selectedNodeIds, canvasScale, canvasOffset, setSelectedNodeIds, setIsBoxSelecting, setBoxSelectStart, setBoxSelectEnd, startDragging]);
|
||||||
|
|
||||||
|
const handleNodeMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (!draggingNodeId || !dragContextRef.current) return;
|
||||||
|
|
||||||
if (!isDraggingNode) {
|
if (!isDraggingNode) {
|
||||||
setIsDraggingNode(true);
|
setIsDraggingNode(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const context = dragContextRef.current;
|
||||||
if (!rect) return;
|
|
||||||
|
|
||||||
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
|
// 计算鼠标在客户端坐标系中的移动距离(像素)
|
||||||
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
|
const clientDeltaX = e.clientX - context.startClientX;
|
||||||
|
const clientDeltaY = e.clientY - context.startClientY;
|
||||||
|
|
||||||
const newX = canvasX - dragOffset.x;
|
// 转换为画布坐标系中的移动距离
|
||||||
const newY = canvasY - dragOffset.y;
|
// 注意:这里使用拖拽开始时的缩放比例,确保计算一致性
|
||||||
|
const canvasDeltaX = clientDeltaX / context.startCanvasScale;
|
||||||
|
const canvasDeltaY = clientDeltaY / context.startCanvasScale;
|
||||||
|
|
||||||
const draggedNodeStartPos = dragStartPositions.get(draggingNodeId);
|
setDragDelta({ dx: canvasDeltaX, dy: canvasDeltaY });
|
||||||
if (!draggedNodeStartPos) return;
|
}, [draggingNodeId, isDraggingNode, setIsDraggingNode, setDragDelta]);
|
||||||
|
|
||||||
const deltaX = newX - draggedNodeStartPos.x;
|
const handleNodeMouseUp = useCallback(() => {
|
||||||
const deltaY = newY - draggedNodeStartPos.y;
|
if (!draggingNodeId || !dragContextRef.current) return;
|
||||||
|
|
||||||
setDragDelta({ dx: deltaX, dy: deltaY });
|
const context = dragContextRef.current;
|
||||||
};
|
|
||||||
|
|
||||||
const handleNodeMouseUp = () => {
|
|
||||||
if (!draggingNodeId) return;
|
|
||||||
|
|
||||||
if (dragDelta.dx !== 0 || dragDelta.dy !== 0) {
|
if (dragDelta.dx !== 0 || dragDelta.dy !== 0) {
|
||||||
|
// 根据拖拽增量计算所有节点的新位置
|
||||||
const moves: Array<{ nodeId: string; position: Position }> = [];
|
const moves: Array<{ nodeId: string; position: Position }> = [];
|
||||||
dragStartPositions.forEach((startPos: { x: number; y: number }, nodeId: string) => {
|
context.nodeStartPositions.forEach((startPos, nodeId) => {
|
||||||
moves.push({
|
moves.push({
|
||||||
nodeId,
|
nodeId,
|
||||||
position: new Position(
|
position: new Position(
|
||||||
@@ -136,26 +152,31 @@ export function useNodeDrag(params: UseNodeDragParams) {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 先重置拖拽状态,避免 moveNodes 触发重新渲染时位置计算错误
|
||||||
|
setDragDelta({ dx: 0, dy: 0 });
|
||||||
|
setIsDraggingNode(false);
|
||||||
|
|
||||||
|
// 然后更新节点位置
|
||||||
nodeOperations.moveNodes(moves);
|
nodeOperations.moveNodes(moves);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
sortChildrenByPosition();
|
sortChildrenByPosition();
|
||||||
}, 0);
|
}, 0);
|
||||||
|
} else {
|
||||||
|
// 没有实际移动,直接重置状态
|
||||||
|
setDragDelta({ dx: 0, dy: 0 });
|
||||||
|
setIsDraggingNode(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setDragDelta({ dx: 0, dy: 0 });
|
// 清理拖拽上下文
|
||||||
|
dragContextRef.current = null;
|
||||||
stopDragging();
|
stopDragging();
|
||||||
|
}, [draggingNodeId, dragDelta, nodeOperations, sortChildrenByPosition, setDragDelta, stopDragging, setIsDraggingNode]);
|
||||||
setTimeout(() => {
|
|
||||||
setIsDraggingNode(false);
|
|
||||||
}, 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleNodeMouseDown,
|
handleNodeMouseDown,
|
||||||
handleNodeMouseMove,
|
handleNodeMouseMove,
|
||||||
handleNodeMouseUp,
|
handleNodeMouseUp
|
||||||
dragOffset
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,22 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||||
import { Position } from '../../domain/value-objects/Position';
|
import { CommandManager } from '@esengine/editor-core';
|
||||||
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
|
import { Position } from '../domain/value-objects/Position';
|
||||||
import { IValidator } from '../../domain/interfaces/IValidator';
|
import { INodeFactory } from '../domain/interfaces/INodeFactory';
|
||||||
import { CommandManager } from '../../application/commands/CommandManager';
|
import { TreeStateAdapter } from '../application/state/BehaviorTreeDataStore';
|
||||||
import { TreeStateAdapter } from '../../application/state/BehaviorTreeDataStore';
|
import { CreateNodeUseCase } from '../application/use-cases/CreateNodeUseCase';
|
||||||
import { CreateNodeUseCase } from '../../application/use-cases/CreateNodeUseCase';
|
import { DeleteNodeUseCase } from '../application/use-cases/DeleteNodeUseCase';
|
||||||
import { DeleteNodeUseCase } from '../../application/use-cases/DeleteNodeUseCase';
|
import { MoveNodeUseCase } from '../application/use-cases/MoveNodeUseCase';
|
||||||
import { MoveNodeUseCase } from '../../application/use-cases/MoveNodeUseCase';
|
import { UpdateNodeDataUseCase } from '../application/use-cases/UpdateNodeDataUseCase';
|
||||||
import { UpdateNodeDataUseCase } from '../../application/use-cases/UpdateNodeDataUseCase';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节点操作 Hook
|
* 节点操作 Hook
|
||||||
*/
|
*/
|
||||||
export function useNodeOperations(
|
export function useNodeOperations(
|
||||||
nodeFactory: INodeFactory,
|
nodeFactory: INodeFactory,
|
||||||
validator: IValidator,
|
|
||||||
commandManager: CommandManager
|
commandManager: CommandManager
|
||||||
) {
|
) {
|
||||||
const treeState = useMemo(() => new TreeStateAdapter(), []);
|
const treeState = useMemo(() => TreeStateAdapter.getInstance(), []);
|
||||||
|
|
||||||
const createNodeUseCase = useMemo(
|
const createNodeUseCase = useMemo(
|
||||||
() => new CreateNodeUseCase(nodeFactory, commandManager, treeState),
|
() => new CreateNodeUseCase(nodeFactory, commandManager, treeState),
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { BehaviorTreeNode } from '../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode } from '../stores';
|
||||||
import { ExecutionMode } from '../../application/services/ExecutionController';
|
import { ExecutionMode } from '../application/services/ExecutionController';
|
||||||
|
|
||||||
interface UseNodeTrackingParams {
|
interface UseNodeTrackingParams {
|
||||||
nodes: BehaviorTreeNode[];
|
nodes: BehaviorTreeNode[];
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { RefObject } from 'react';
|
import { RefObject } from 'react';
|
||||||
import { BehaviorTreeNode, Connection, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode, Connection, ROOT_NODE_ID, useUIStore } from '../stores';
|
||||||
import { PropertyDefinition } from '@esengine/behavior-tree';
|
import { PropertyDefinition } from '@esengine/behavior-tree';
|
||||||
import { useConnectionOperations } from './useConnectionOperations';
|
import { useConnectionOperations } from './useConnectionOperations';
|
||||||
|
|
||||||
@@ -24,8 +24,6 @@ export function usePortConnection(params: UsePortConnectionParams) {
|
|||||||
canvasRef,
|
canvasRef,
|
||||||
nodes,
|
nodes,
|
||||||
connections,
|
connections,
|
||||||
connectingFrom,
|
|
||||||
connectingFromProperty,
|
|
||||||
connectionOperations,
|
connectionOperations,
|
||||||
setConnectingFrom,
|
setConnectingFrom,
|
||||||
setConnectingFromProperty,
|
setConnectingFromProperty,
|
||||||
@@ -49,12 +47,17 @@ export function usePortConnection(params: UsePortConnectionParams) {
|
|||||||
|
|
||||||
const handlePortMouseUp = (e: React.MouseEvent, nodeId: string, propertyName?: string) => {
|
const handlePortMouseUp = (e: React.MouseEvent, nodeId: string, propertyName?: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!connectingFrom) {
|
|
||||||
|
// 从 store 读取最新状态避免闭包陷阱
|
||||||
|
const currentConnectingFrom = useUIStore.getState().connectingFrom;
|
||||||
|
const currentConnectingFromProperty = useUIStore.getState().connectingFromProperty;
|
||||||
|
|
||||||
|
if (!currentConnectingFrom) {
|
||||||
clearConnecting();
|
clearConnecting();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connectingFrom === nodeId) {
|
if (currentConnectingFrom === nodeId) {
|
||||||
showToast?.('不能将节点连接到自己', 'warning');
|
showToast?.('不能将节点连接到自己', 'warning');
|
||||||
clearConnecting();
|
clearConnecting();
|
||||||
return;
|
return;
|
||||||
@@ -64,9 +67,9 @@ export function usePortConnection(params: UsePortConnectionParams) {
|
|||||||
const toPortType = target.getAttribute('data-port-type');
|
const toPortType = target.getAttribute('data-port-type');
|
||||||
const fromPortType = canvasRef.current?.getAttribute('data-connecting-from-port-type');
|
const fromPortType = canvasRef.current?.getAttribute('data-connecting-from-port-type');
|
||||||
|
|
||||||
let actualFrom = connectingFrom;
|
let actualFrom = currentConnectingFrom;
|
||||||
let actualTo = nodeId;
|
let actualTo = nodeId;
|
||||||
let actualFromProperty = connectingFromProperty;
|
let actualFromProperty = currentConnectingFromProperty;
|
||||||
let actualToProperty = propertyName;
|
let actualToProperty = propertyName;
|
||||||
|
|
||||||
const needReverse =
|
const needReverse =
|
||||||
@@ -75,9 +78,9 @@ export function usePortConnection(params: UsePortConnectionParams) {
|
|||||||
|
|
||||||
if (needReverse) {
|
if (needReverse) {
|
||||||
actualFrom = nodeId;
|
actualFrom = nodeId;
|
||||||
actualTo = connectingFrom;
|
actualTo = currentConnectingFrom;
|
||||||
actualFromProperty = propertyName || null;
|
actualFromProperty = propertyName || null;
|
||||||
actualToProperty = connectingFromProperty ?? undefined;
|
actualToProperty = currentConnectingFromProperty ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actualFromProperty || actualToProperty) {
|
if (actualFromProperty || actualToProperty) {
|
||||||
@@ -169,7 +172,8 @@ export function usePortConnection(params: UsePortConnectionParams) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNodeMouseUpForConnection = (e: React.MouseEvent, nodeId: string) => {
|
const handleNodeMouseUpForConnection = (e: React.MouseEvent, nodeId: string) => {
|
||||||
if (connectingFrom && connectingFrom !== nodeId) {
|
const currentConnectingFrom = useUIStore.getState().connectingFrom;
|
||||||
|
if (currentConnectingFrom && currentConnectingFrom !== nodeId) {
|
||||||
handlePortMouseUp(e, nodeId);
|
handlePortMouseUp(e, nodeId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState, RefObject } from 'react';
|
import { useState, RefObject } from 'react';
|
||||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||||
import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode, Connection, useBehaviorTreeDataStore } from '../stores';
|
||||||
import { Node } from '../../domain/models/Node';
|
import { Node } from '../domain/models/Node';
|
||||||
import { Position } from '../../domain/value-objects/Position';
|
import { Position } from '../domain/value-objects/Position';
|
||||||
import { useNodeOperations } from '../../presentation/hooks/useNodeOperations';
|
import { useNodeOperations } from './useNodeOperations';
|
||||||
import { useConnectionOperations } from '../../presentation/hooks/useConnectionOperations';
|
import { useConnectionOperations } from './useConnectionOperations';
|
||||||
|
|
||||||
interface QuickCreateMenuState {
|
interface QuickCreateMenuState {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -15,7 +15,7 @@ interface QuickCreateMenuState {
|
|||||||
replaceNodeId: string | null;
|
replaceNodeId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
|
type ExecutionMode = 'idle' | 'running' | 'paused';
|
||||||
|
|
||||||
interface UseQuickCreateMenuParams {
|
interface UseQuickCreateMenuParams {
|
||||||
nodeOperations: ReturnType<typeof useNodeOperations>;
|
nodeOperations: ReturnType<typeof useNodeOperations>;
|
||||||
@@ -27,7 +27,6 @@ interface UseQuickCreateMenuParams {
|
|||||||
connectingFromProperty: string | null;
|
connectingFromProperty: string | null;
|
||||||
clearConnecting: () => void;
|
clearConnecting: () => void;
|
||||||
nodes: BehaviorTreeNode[];
|
nodes: BehaviorTreeNode[];
|
||||||
setNodes: (nodes: BehaviorTreeNode[]) => void;
|
|
||||||
connections: Connection[];
|
connections: Connection[];
|
||||||
executionMode: ExecutionMode;
|
executionMode: ExecutionMode;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
@@ -46,7 +45,6 @@ export function useQuickCreateMenu(params: UseQuickCreateMenuParams) {
|
|||||||
connectingFromProperty,
|
connectingFromProperty,
|
||||||
clearConnecting,
|
clearConnecting,
|
||||||
nodes,
|
nodes,
|
||||||
setNodes,
|
|
||||||
connections,
|
connections,
|
||||||
executionMode,
|
executionMode,
|
||||||
onStop,
|
onStop,
|
||||||
@@ -101,8 +99,10 @@ export function useQuickCreateMenu(params: UseQuickCreateMenuParams) {
|
|||||||
Array.from(nodeToReplace.children)
|
Array.from(nodeToReplace.children)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 替换节点
|
// 替换节点 - 通过 store 更新
|
||||||
setNodes(nodes.map((n) => n.id === newNode.id ? newNode : n));
|
const store = useBehaviorTreeDataStore.getState();
|
||||||
|
const updatedTree = store.tree.updateNode(newNode.id, () => newNode);
|
||||||
|
store.setTree(updatedTree);
|
||||||
|
|
||||||
// 删除所有指向该节点的属性连接,让用户重新连接
|
// 删除所有指向该节点的属性连接,让用户重新连接
|
||||||
const propertyConnections = connections.filter((conn) =>
|
const propertyConnections = connections.filter((conn) =>
|
||||||
41
packages/behavior-tree-editor/src/index.ts
Normal file
41
packages/behavior-tree-editor/src/index.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { BehaviorTreePlugin } from './BehaviorTreePlugin';
|
||||||
|
|
||||||
|
export default new BehaviorTreePlugin();
|
||||||
|
|
||||||
|
export { BehaviorTreePlugin } from './BehaviorTreePlugin';
|
||||||
|
export { BehaviorTreeEditorPanel } from './components/panels/BehaviorTreeEditorPanel';
|
||||||
|
export * from './BehaviorTreeModule';
|
||||||
|
export * from './services/BehaviorTreeService';
|
||||||
|
export * from './providers/BehaviorTreeNodeInspectorProvider';
|
||||||
|
|
||||||
|
export * from './domain';
|
||||||
|
export * from './application/commands/tree';
|
||||||
|
export * from './application/use-cases';
|
||||||
|
export * from './application/services/BlackboardManager';
|
||||||
|
export * from './application/services/ExecutionController';
|
||||||
|
export * from './application/services/GlobalBlackboardService';
|
||||||
|
export * from './application/interfaces/IExecutionHooks';
|
||||||
|
export * from './application/state/BehaviorTreeDataStore';
|
||||||
|
export * from './hooks';
|
||||||
|
export * from './stores';
|
||||||
|
// Re-export specific items to avoid conflicts
|
||||||
|
export {
|
||||||
|
EditorConfig
|
||||||
|
} from './types';
|
||||||
|
export * from './infrastructure/factories/NodeFactory';
|
||||||
|
export * from './infrastructure/serialization/BehaviorTreeSerializer';
|
||||||
|
export * from './infrastructure/validation/BehaviorTreeValidator';
|
||||||
|
export * from './infrastructure/events/EditorEventBus';
|
||||||
|
export * from './infrastructure/services/NodeRegistryService';
|
||||||
|
export * from './utils/BehaviorTreeExecutor';
|
||||||
|
export * from './utils/DOMCache';
|
||||||
|
export * from './utils/portUtils';
|
||||||
|
export * from './utils/RuntimeLoader';
|
||||||
|
export * from './compiler/BehaviorTreeCompiler';
|
||||||
|
// Export everything except DEFAULT_EDITOR_CONFIG from editorConstants
|
||||||
|
export {
|
||||||
|
ICON_MAP,
|
||||||
|
ROOT_NODE_TEMPLATE,
|
||||||
|
DEFAULT_EDITOR_CONFIG
|
||||||
|
} from './config/editorConstants';
|
||||||
|
export * from './interfaces/IEditorExtensions';
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
const logger = createLogger('EditorEventBus');
|
||||||
|
|
||||||
type EventHandler<T = any> = (data: T) => void;
|
type EventHandler<T = any> = (data: T) => void;
|
||||||
|
|
||||||
interface Subscription {
|
interface Subscription {
|
||||||
@@ -18,6 +22,7 @@ export enum EditorEvent {
|
|||||||
EXECUTION_PAUSED = 'execution:paused',
|
EXECUTION_PAUSED = 'execution:paused',
|
||||||
EXECUTION_RESUMED = 'execution:resumed',
|
EXECUTION_RESUMED = 'execution:resumed',
|
||||||
EXECUTION_STOPPED = 'execution:stopped',
|
EXECUTION_STOPPED = 'execution:stopped',
|
||||||
|
EXECUTION_STEPPED = 'execution:stepped',
|
||||||
EXECUTION_TICK = 'execution:tick',
|
EXECUTION_TICK = 'execution:tick',
|
||||||
EXECUTION_NODE_STATUS_CHANGED = 'execution:node_status_changed',
|
EXECUTION_NODE_STATUS_CHANGED = 'execution:node_status_changed',
|
||||||
|
|
||||||
@@ -89,7 +94,7 @@ export class EditorEventBus {
|
|||||||
try {
|
try {
|
||||||
handler(data);
|
handler(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error in event handler for ${event}:`, error);
|
logger.error(`Error in event handler for ${event}:`, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree';
|
|||||||
import { Node } from '../../domain/models/Node';
|
import { Node } from '../../domain/models/Node';
|
||||||
import { Position } from '../../domain/value-objects/Position';
|
import { Position } from '../../domain/value-objects/Position';
|
||||||
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
|
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
|
||||||
|
import { NodeRegistryService } from '../services/NodeRegistryService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成唯一ID
|
* 生成唯一ID
|
||||||
@@ -63,11 +64,20 @@ export class NodeFactory implements INodeFactory {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有可用的节点模板
|
||||||
|
*/
|
||||||
|
getAllTemplates(): NodeTemplate[] {
|
||||||
|
const coreTemplates = NodeTemplates.getAllTemplates();
|
||||||
|
const customTemplates = NodeRegistryService.getInstance().getCustomTemplates();
|
||||||
|
return [...coreTemplates, ...customTemplates];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据类型获取模板
|
* 根据类型获取模板
|
||||||
*/
|
*/
|
||||||
private getTemplateByType(nodeType: string): NodeTemplate | null {
|
private getTemplateByType(nodeType: string): NodeTemplate | null {
|
||||||
const allTemplates = NodeTemplates.getAllTemplates();
|
const allTemplates = this.getAllTemplates();
|
||||||
|
|
||||||
const template = allTemplates.find((t: NodeTemplate) => {
|
const template = allTemplates.find((t: NodeTemplate) => {
|
||||||
const defaultNodeType = t.defaultConfig.nodeType;
|
const defaultNodeType = t.defaultConfig.nodeType;
|
||||||
@@ -76,4 +86,12 @@ export class NodeFactory implements INodeFactory {
|
|||||||
|
|
||||||
return template || null;
|
return template || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据实现类型获取模板
|
||||||
|
*/
|
||||||
|
getTemplateByImplementationType(implementationType: string): NodeTemplate | null {
|
||||||
|
const allTemplates = this.getAllTemplates();
|
||||||
|
return allTemplates.find(t => t.className === implementationType) || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { NodeTemplate, NodeMetadataRegistry, NodeMetadata, NodeType } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简化的节点注册配置
|
||||||
|
*/
|
||||||
|
export interface NodeRegistrationConfig {
|
||||||
|
type: 'composite' | 'decorator' | 'action' | 'condition';
|
||||||
|
implementationType: string;
|
||||||
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
properties?: NodePropertyConfig[];
|
||||||
|
minChildren?: number;
|
||||||
|
maxChildren?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点属性配置
|
||||||
|
*/
|
||||||
|
export interface NodePropertyConfig {
|
||||||
|
name: string;
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'select' | 'blackboard' | 'code';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
defaultValue?: any;
|
||||||
|
options?: Array<{ label: string; value: any }>;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点注册服务
|
||||||
|
* 提供编辑器级别的节点注册和管理功能
|
||||||
|
*/
|
||||||
|
export class NodeRegistryService {
|
||||||
|
private static instance: NodeRegistryService;
|
||||||
|
private customTemplates: Map<string, NodeTemplate> = new Map();
|
||||||
|
private registrationCallbacks: Array<(template: NodeTemplate) => void> = [];
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): NodeRegistryService {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new NodeRegistryService();
|
||||||
|
}
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册自定义节点类型
|
||||||
|
*/
|
||||||
|
registerNode(config: NodeRegistrationConfig): void {
|
||||||
|
const nodeType = this.mapStringToNodeType(config.type);
|
||||||
|
|
||||||
|
const metadata: NodeMetadata = {
|
||||||
|
implementationType: config.implementationType,
|
||||||
|
nodeType: nodeType,
|
||||||
|
displayName: config.displayName,
|
||||||
|
description: config.description || '',
|
||||||
|
category: config.category || this.getDefaultCategory(config.type),
|
||||||
|
configSchema: this.convertPropertiesToSchema(config.properties || []),
|
||||||
|
childrenConstraints: this.getChildrenConstraints(config)
|
||||||
|
};
|
||||||
|
|
||||||
|
class DummyExecutor {}
|
||||||
|
NodeMetadataRegistry.register(DummyExecutor, metadata);
|
||||||
|
|
||||||
|
const template = this.createTemplate(config, metadata);
|
||||||
|
this.customTemplates.set(config.implementationType, template);
|
||||||
|
|
||||||
|
this.registrationCallbacks.forEach(cb => cb(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销节点类型
|
||||||
|
*/
|
||||||
|
unregisterNode(implementationType: string): boolean {
|
||||||
|
return this.customTemplates.delete(implementationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有自定义模板
|
||||||
|
*/
|
||||||
|
getCustomTemplates(): NodeTemplate[] {
|
||||||
|
return Array.from(this.customTemplates.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查节点类型是否已注册
|
||||||
|
*/
|
||||||
|
hasNode(implementationType: string): boolean {
|
||||||
|
return this.customTemplates.has(implementationType) ||
|
||||||
|
NodeMetadataRegistry.getMetadata(implementationType) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听节点注册事件
|
||||||
|
*/
|
||||||
|
onNodeRegistered(callback: (template: NodeTemplate) => void): () => void {
|
||||||
|
this.registrationCallbacks.push(callback);
|
||||||
|
return () => {
|
||||||
|
const index = this.registrationCallbacks.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
this.registrationCallbacks.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapStringToNodeType(type: string): NodeType {
|
||||||
|
switch (type) {
|
||||||
|
case 'composite': return NodeType.Composite;
|
||||||
|
case 'decorator': return NodeType.Decorator;
|
||||||
|
case 'action': return NodeType.Action;
|
||||||
|
case 'condition': return NodeType.Condition;
|
||||||
|
default: return NodeType.Action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultCategory(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'composite': return '组合';
|
||||||
|
case 'decorator': return '装饰器';
|
||||||
|
case 'action': return '动作';
|
||||||
|
case 'condition': return '条件';
|
||||||
|
default: return '其他';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertPropertiesToSchema(properties: NodePropertyConfig[]): Record<string, any> {
|
||||||
|
const schema: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const prop of properties) {
|
||||||
|
schema[prop.name] = {
|
||||||
|
type: this.mapPropertyType(prop.type),
|
||||||
|
default: prop.defaultValue,
|
||||||
|
description: prop.description,
|
||||||
|
min: prop.min,
|
||||||
|
max: prop.max,
|
||||||
|
options: prop.options?.map(o => o.value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapPropertyType(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'string':
|
||||||
|
case 'code':
|
||||||
|
case 'blackboard':
|
||||||
|
case 'select':
|
||||||
|
return 'string';
|
||||||
|
case 'number':
|
||||||
|
return 'number';
|
||||||
|
case 'boolean':
|
||||||
|
return 'boolean';
|
||||||
|
default:
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getChildrenConstraints(config: NodeRegistrationConfig): { min?: number; max?: number } | undefined {
|
||||||
|
if (config.minChildren !== undefined || config.maxChildren !== undefined) {
|
||||||
|
return {
|
||||||
|
min: config.minChildren,
|
||||||
|
max: config.maxChildren
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (config.type) {
|
||||||
|
case 'composite':
|
||||||
|
return { min: 1 };
|
||||||
|
case 'decorator':
|
||||||
|
return { min: 1, max: 1 };
|
||||||
|
case 'action':
|
||||||
|
case 'condition':
|
||||||
|
return { max: 0 };
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTemplate(config: NodeRegistrationConfig, metadata: NodeMetadata): NodeTemplate {
|
||||||
|
const defaultConfig: any = {
|
||||||
|
nodeType: config.type
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (config.type) {
|
||||||
|
case 'composite':
|
||||||
|
defaultConfig.compositeType = config.implementationType;
|
||||||
|
break;
|
||||||
|
case 'decorator':
|
||||||
|
defaultConfig.decoratorType = config.implementationType;
|
||||||
|
break;
|
||||||
|
case 'action':
|
||||||
|
defaultConfig.actionType = config.implementationType;
|
||||||
|
break;
|
||||||
|
case 'condition':
|
||||||
|
defaultConfig.conditionType = config.implementationType;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prop of config.properties || []) {
|
||||||
|
if (prop.defaultValue !== undefined) {
|
||||||
|
defaultConfig[prop.name] = prop.defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const template: NodeTemplate = {
|
||||||
|
type: metadata.nodeType,
|
||||||
|
displayName: config.displayName,
|
||||||
|
category: config.category || this.getDefaultCategory(config.type),
|
||||||
|
description: config.description || '',
|
||||||
|
icon: config.icon || this.getDefaultIcon(config.type),
|
||||||
|
color: config.color || this.getDefaultColor(config.type),
|
||||||
|
className: config.implementationType,
|
||||||
|
defaultConfig,
|
||||||
|
properties: (config.properties || []).map(p => ({
|
||||||
|
name: p.name,
|
||||||
|
type: p.type,
|
||||||
|
label: p.label,
|
||||||
|
description: p.description,
|
||||||
|
defaultValue: p.defaultValue,
|
||||||
|
options: p.options,
|
||||||
|
min: p.min,
|
||||||
|
max: p.max,
|
||||||
|
required: p.required
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.minChildren !== undefined) {
|
||||||
|
template.minChildren = config.minChildren;
|
||||||
|
template.requiresChildren = config.minChildren > 0;
|
||||||
|
}
|
||||||
|
if (config.maxChildren !== undefined) {
|
||||||
|
template.maxChildren = config.maxChildren;
|
||||||
|
}
|
||||||
|
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultIcon(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'composite': return 'GitBranch';
|
||||||
|
case 'decorator': return 'Settings';
|
||||||
|
case 'action': return 'Play';
|
||||||
|
case 'condition': return 'HelpCircle';
|
||||||
|
default: return 'Circle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultColor(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'composite': return '#1976d2';
|
||||||
|
case 'decorator': return '#fb8c00';
|
||||||
|
case 'action': return '#388e3c';
|
||||||
|
case 'condition': return '#d32f2f';
|
||||||
|
default: return '#757575';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user