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:
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;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { BehaviorTree } from '../../domain/models/BehaviorTree';
|
||||
|
||||
/**
|
||||
* 行为树状态接口
|
||||
* 命令通过此接口操作状态
|
||||
*/
|
||||
export interface ITreeState {
|
||||
/**
|
||||
* 获取当前行为树
|
||||
*/
|
||||
getTree(): BehaviorTree;
|
||||
|
||||
/**
|
||||
* 设置行为树
|
||||
*/
|
||||
setTree(tree: BehaviorTree): void;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Connection } from '../../../domain/models/Connection';
|
||||
import { BaseCommand } from '@esengine/editor-core';
|
||||
import { ITreeState } from '../ITreeState';
|
||||
|
||||
/**
|
||||
* 添加连接命令
|
||||
*/
|
||||
export class AddConnectionCommand extends BaseCommand {
|
||||
constructor(
|
||||
private readonly state: ITreeState,
|
||||
private readonly connection: Connection
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.addConnection(this.connection);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.removeConnection(
|
||||
this.connection.from,
|
||||
this.connection.to,
|
||||
this.connection.fromProperty,
|
||||
this.connection.toProperty
|
||||
);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `添加连接: ${this.connection.from} -> ${this.connection.to}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Node } from '../../../domain/models/Node';
|
||||
import { BaseCommand } from '@esengine/editor-core';
|
||||
import { ITreeState } from '../ITreeState';
|
||||
|
||||
/**
|
||||
* 创建节点命令
|
||||
*/
|
||||
export class CreateNodeCommand extends BaseCommand {
|
||||
private createdNodeId: string;
|
||||
|
||||
constructor(
|
||||
private readonly state: ITreeState,
|
||||
private readonly node: Node
|
||||
) {
|
||||
super();
|
||||
this.createdNodeId = node.id;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.addNode(this.node);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.removeNode(this.createdNodeId);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建节点: ${this.node.template.displayName}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Node } from '../../../domain/models/Node';
|
||||
import { BaseCommand } from '@esengine/editor-core';
|
||||
import { ITreeState } from '../ITreeState';
|
||||
|
||||
/**
|
||||
* 删除节点命令
|
||||
*/
|
||||
export class DeleteNodeCommand extends BaseCommand {
|
||||
private deletedNode: Node | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly state: ITreeState,
|
||||
private readonly nodeId: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tree = this.state.getTree();
|
||||
this.deletedNode = tree.getNode(this.nodeId);
|
||||
const newTree = tree.removeNode(this.nodeId);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.deletedNode) {
|
||||
throw new Error('无法撤销:未保存已删除的节点');
|
||||
}
|
||||
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.addNode(this.deletedNode);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `删除节点: ${this.deletedNode?.template.displayName ?? this.nodeId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Position } from '../../../domain/value-objects/Position';
|
||||
import { BaseCommand, ICommand } from '@esengine/editor-core';
|
||||
import { ITreeState } from '../ITreeState';
|
||||
|
||||
/**
|
||||
* 移动节点命令
|
||||
* 支持合并连续的移动操作
|
||||
*/
|
||||
export class MoveNodeCommand extends BaseCommand {
|
||||
private oldPosition: Position;
|
||||
|
||||
constructor(
|
||||
private readonly state: ITreeState,
|
||||
private readonly nodeId: string,
|
||||
private readonly newPosition: Position
|
||||
) {
|
||||
super();
|
||||
const tree = this.state.getTree();
|
||||
const node = tree.getNode(nodeId);
|
||||
this.oldPosition = node.position;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.updateNode(this.nodeId, (node) =>
|
||||
node.moveToPosition(this.newPosition)
|
||||
);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.updateNode(this.nodeId, (node) =>
|
||||
node.moveToPosition(this.oldPosition)
|
||||
);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `移动节点: ${this.nodeId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动命令可以合并
|
||||
*/
|
||||
canMergeWith(other: ICommand): boolean {
|
||||
if (!(other instanceof MoveNodeCommand)) {
|
||||
return false;
|
||||
}
|
||||
return this.nodeId === other.nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并移动命令
|
||||
* 保留初始位置,更新最终位置
|
||||
*/
|
||||
mergeWith(other: ICommand): ICommand {
|
||||
if (!(other instanceof MoveNodeCommand)) {
|
||||
throw new Error('只能与 MoveNodeCommand 合并');
|
||||
}
|
||||
|
||||
if (this.nodeId !== other.nodeId) {
|
||||
throw new Error('只能合并同一节点的移动命令');
|
||||
}
|
||||
|
||||
const merged = new MoveNodeCommand(
|
||||
this.state,
|
||||
this.nodeId,
|
||||
other.newPosition
|
||||
);
|
||||
merged.oldPosition = this.oldPosition;
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Connection } from '../../../domain/models/Connection';
|
||||
import { BaseCommand } from '@esengine/editor-core';
|
||||
import { ITreeState } from '../ITreeState';
|
||||
|
||||
/**
|
||||
* 移除连接命令
|
||||
*/
|
||||
export class RemoveConnectionCommand extends BaseCommand {
|
||||
private removedConnection: Connection | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly state: ITreeState,
|
||||
private readonly from: string,
|
||||
private readonly to: string,
|
||||
private readonly fromProperty?: string,
|
||||
private readonly toProperty?: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tree = this.state.getTree();
|
||||
|
||||
const connection = tree.connections.find((c) =>
|
||||
c.matches(this.from, this.to, this.fromProperty, this.toProperty)
|
||||
);
|
||||
|
||||
if (!connection) {
|
||||
throw new Error(`连接不存在: ${this.from} -> ${this.to}`);
|
||||
}
|
||||
|
||||
this.removedConnection = connection;
|
||||
const newTree = tree.removeConnection(this.from, this.to, this.fromProperty, this.toProperty);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.removedConnection) {
|
||||
throw new Error('无法撤销:未保存已删除的连接');
|
||||
}
|
||||
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.addConnection(this.removedConnection);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `移除连接: ${this.from} -> ${this.to}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseCommand } from '@esengine/editor-core';
|
||||
import { ITreeState } from '../ITreeState';
|
||||
|
||||
/**
|
||||
* 更新节点数据命令
|
||||
*/
|
||||
export class UpdateNodeDataCommand extends BaseCommand {
|
||||
private oldData: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
private readonly state: ITreeState,
|
||||
private readonly nodeId: string,
|
||||
private readonly newData: Record<string, unknown>
|
||||
) {
|
||||
super();
|
||||
const tree = this.state.getTree();
|
||||
const node = tree.getNode(nodeId);
|
||||
this.oldData = node.data;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.updateNode(this.nodeId, (node) =>
|
||||
node.updateData(this.newData)
|
||||
);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const tree = this.state.getTree();
|
||||
const newTree = tree.updateNode(this.nodeId, (node) =>
|
||||
node.updateData(this.oldData)
|
||||
);
|
||||
this.state.setTree(newTree);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `更新节点数据: ${this.nodeId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { CreateNodeCommand } from './CreateNodeCommand';
|
||||
export { DeleteNodeCommand } from './DeleteNodeCommand';
|
||||
export { AddConnectionCommand } from './AddConnectionCommand';
|
||||
export { RemoveConnectionCommand } from './RemoveConnectionCommand';
|
||||
export { MoveNodeCommand } from './MoveNodeCommand';
|
||||
export { UpdateNodeDataCommand } from './UpdateNodeDataCommand';
|
||||
@@ -0,0 +1,253 @@
|
||||
import { Node as BehaviorTreeNode } from '../../domain/models/Node';
|
||||
import { Connection } from '../../domain/models/Connection';
|
||||
import { ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
|
||||
const logger = createLogger('ExecutionHooks');
|
||||
|
||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||
type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
|
||||
|
||||
export interface ExecutionContext {
|
||||
nodes: BehaviorTreeNode[];
|
||||
connections: Connection[];
|
||||
blackboardVariables: BlackboardVariables;
|
||||
rootNodeId: string;
|
||||
tickCount: number;
|
||||
}
|
||||
|
||||
export interface NodeStatusChangeEvent {
|
||||
nodeId: string;
|
||||
status: NodeExecutionStatus;
|
||||
previousStatus?: NodeExecutionStatus;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface IExecutionHooks {
|
||||
beforePlay?(context: ExecutionContext): void | Promise<void>;
|
||||
|
||||
afterPlay?(context: ExecutionContext): void | Promise<void>;
|
||||
|
||||
beforePause?(): void | Promise<void>;
|
||||
|
||||
afterPause?(): void | Promise<void>;
|
||||
|
||||
beforeResume?(): void | Promise<void>;
|
||||
|
||||
afterResume?(): void | Promise<void>;
|
||||
|
||||
beforeStop?(): void | Promise<void>;
|
||||
|
||||
afterStop?(): void | Promise<void>;
|
||||
|
||||
beforeStep?(deltaTime: number): void | Promise<void>;
|
||||
|
||||
afterStep?(deltaTime: number): void | Promise<void>;
|
||||
|
||||
onTick?(tickCount: number, deltaTime: number): void | Promise<void>;
|
||||
|
||||
onNodeStatusChange?(event: NodeStatusChangeEvent): void | Promise<void>;
|
||||
|
||||
onExecutionComplete?(logs: ExecutionLog[]): void | Promise<void>;
|
||||
|
||||
onBlackboardUpdate?(variables: BlackboardVariables): void | Promise<void>;
|
||||
|
||||
onError?(error: Error, context?: string): void | Promise<void>;
|
||||
}
|
||||
|
||||
export class ExecutionHooksManager {
|
||||
private hooks: Set<IExecutionHooks> = new Set();
|
||||
|
||||
register(hook: IExecutionHooks): void {
|
||||
this.hooks.add(hook);
|
||||
}
|
||||
|
||||
unregister(hook: IExecutionHooks): void {
|
||||
this.hooks.delete(hook);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.hooks.clear();
|
||||
}
|
||||
|
||||
async triggerBeforePlay(context: ExecutionContext): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.beforePlay) {
|
||||
try {
|
||||
await hook.beforePlay(context);
|
||||
} catch (error) {
|
||||
logger.error('Error in beforePlay hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAfterPlay(context: ExecutionContext): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.afterPlay) {
|
||||
try {
|
||||
await hook.afterPlay(context);
|
||||
} catch (error) {
|
||||
logger.error('Error in afterPlay hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerBeforePause(): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.beforePause) {
|
||||
try {
|
||||
await hook.beforePause();
|
||||
} catch (error) {
|
||||
logger.error('Error in beforePause hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAfterPause(): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.afterPause) {
|
||||
try {
|
||||
await hook.afterPause();
|
||||
} catch (error) {
|
||||
logger.error('Error in afterPause hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerBeforeResume(): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.beforeResume) {
|
||||
try {
|
||||
await hook.beforeResume();
|
||||
} catch (error) {
|
||||
logger.error('Error in beforeResume hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAfterResume(): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.afterResume) {
|
||||
try {
|
||||
await hook.afterResume();
|
||||
} catch (error) {
|
||||
logger.error('Error in afterResume hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerBeforeStop(): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.beforeStop) {
|
||||
try {
|
||||
await hook.beforeStop();
|
||||
} catch (error) {
|
||||
logger.error('Error in beforeStop hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAfterStop(): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.afterStop) {
|
||||
try {
|
||||
await hook.afterStop();
|
||||
} catch (error) {
|
||||
logger.error('Error in afterStop hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerBeforeStep(deltaTime: number): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.beforeStep) {
|
||||
try {
|
||||
await hook.beforeStep(deltaTime);
|
||||
} catch (error) {
|
||||
logger.error('Error in beforeStep hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAfterStep(deltaTime: number): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.afterStep) {
|
||||
try {
|
||||
await hook.afterStep(deltaTime);
|
||||
} catch (error) {
|
||||
logger.error('Error in afterStep hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerOnTick(tickCount: number, deltaTime: number): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.onTick) {
|
||||
try {
|
||||
await hook.onTick(tickCount, deltaTime);
|
||||
} catch (error) {
|
||||
logger.error('Error in onTick hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerOnNodeStatusChange(event: NodeStatusChangeEvent): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.onNodeStatusChange) {
|
||||
try {
|
||||
await hook.onNodeStatusChange(event);
|
||||
} catch (error) {
|
||||
logger.error('Error in onNodeStatusChange hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerOnExecutionComplete(logs: ExecutionLog[]): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.onExecutionComplete) {
|
||||
try {
|
||||
await hook.onExecutionComplete(logs);
|
||||
} catch (error) {
|
||||
logger.error('Error in onExecutionComplete hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerOnBlackboardUpdate(variables: BlackboardVariables): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.onBlackboardUpdate) {
|
||||
try {
|
||||
await hook.onBlackboardUpdate(variables);
|
||||
} catch (error) {
|
||||
logger.error('Error in onBlackboardUpdate hook:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerOnError(error: Error, context?: string): Promise<void> {
|
||||
for (const hook of this.hooks) {
|
||||
if (hook.onError) {
|
||||
try {
|
||||
await hook.onError(error, context);
|
||||
} catch (err) {
|
||||
logger.error('Error in onError hook:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||
|
||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||
|
||||
export class BlackboardManager {
|
||||
private initialVariables: BlackboardVariables = {};
|
||||
private currentVariables: BlackboardVariables = {};
|
||||
|
||||
setInitialVariables(variables: BlackboardVariables): void {
|
||||
this.initialVariables = JSON.parse(JSON.stringify(variables)) as BlackboardVariables;
|
||||
}
|
||||
|
||||
getInitialVariables(): BlackboardVariables {
|
||||
return { ...this.initialVariables };
|
||||
}
|
||||
|
||||
setCurrentVariables(variables: BlackboardVariables): void {
|
||||
this.currentVariables = { ...variables };
|
||||
}
|
||||
|
||||
getCurrentVariables(): BlackboardVariables {
|
||||
return { ...this.currentVariables };
|
||||
}
|
||||
|
||||
updateVariable(key: string, value: BlackboardValue): void {
|
||||
this.currentVariables[key] = value;
|
||||
}
|
||||
|
||||
restoreInitialVariables(): BlackboardVariables {
|
||||
this.currentVariables = { ...this.initialVariables };
|
||||
return this.getInitialVariables();
|
||||
}
|
||||
|
||||
hasChanges(): boolean {
|
||||
return JSON.stringify(this.currentVariables) !== JSON.stringify(this.initialVariables);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.initialVariables = {};
|
||||
this.currentVariables = {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { BehaviorTreeExecutor, ExecutionStatus, ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
||||
import { BehaviorTreeNode, Connection } from '../../stores';
|
||||
import type { NodeExecutionStatus } from '../../stores';
|
||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||
import { DOMCache } from '../../utils/DOMCache';
|
||||
import { EditorEventBus, EditorEvent } from '../../infrastructure/events/EditorEventBus';
|
||||
import { ExecutionHooksManager } from '../interfaces/IExecutionHooks';
|
||||
import type { Breakpoint } from '../../types/Breakpoint';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
|
||||
const logger = createLogger('ExecutionController');
|
||||
|
||||
export type ExecutionMode = 'idle' | 'running' | 'paused';
|
||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||
|
||||
interface ExecutionControllerConfig {
|
||||
rootNodeId: string;
|
||||
projectPath: string | null;
|
||||
onLogsUpdate: (logs: ExecutionLog[]) => void;
|
||||
onBlackboardUpdate: (variables: BlackboardVariables) => void;
|
||||
onTickCountUpdate: (count: number) => void;
|
||||
onExecutionStatusUpdate: (statuses: Map<string, NodeExecutionStatus>, orders: Map<string, number>) => void;
|
||||
onBreakpointHit?: (nodeId: string, nodeName: string) => void;
|
||||
eventBus?: EditorEventBus;
|
||||
hooksManager?: ExecutionHooksManager;
|
||||
}
|
||||
|
||||
export class ExecutionController {
|
||||
private executor: BehaviorTreeExecutor | null = null;
|
||||
private mode: ExecutionMode = 'idle';
|
||||
private animationFrameId: number | null = null;
|
||||
private lastTickTime: number = 0;
|
||||
private speed: number = 1.0;
|
||||
private tickCount: number = 0;
|
||||
|
||||
private domCache: DOMCache = new DOMCache();
|
||||
private eventBus?: EditorEventBus;
|
||||
private hooksManager?: ExecutionHooksManager;
|
||||
|
||||
private config: ExecutionControllerConfig;
|
||||
private currentNodes: BehaviorTreeNode[] = [];
|
||||
private currentConnections: Connection[] = [];
|
||||
private currentBlackboard: BlackboardVariables = {};
|
||||
|
||||
private stepByStepMode: boolean = true;
|
||||
private pendingStatusUpdates: ExecutionStatus[] = [];
|
||||
private currentlyDisplayedIndex: number = 0;
|
||||
private lastStepTime: number = 0;
|
||||
private stepInterval: number = 200;
|
||||
|
||||
// 存储断点回调的引用
|
||||
private breakpointCallback: ((nodeId: string, nodeName: string) => void) | null = null;
|
||||
|
||||
constructor(config: ExecutionControllerConfig) {
|
||||
this.config = config;
|
||||
this.executor = new BehaviorTreeExecutor();
|
||||
this.eventBus = config.eventBus;
|
||||
this.hooksManager = config.hooksManager;
|
||||
}
|
||||
|
||||
getMode(): ExecutionMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
getTickCount(): number {
|
||||
return this.tickCount;
|
||||
}
|
||||
|
||||
getSpeed(): number {
|
||||
return this.speed;
|
||||
}
|
||||
|
||||
setSpeed(speed: number): void {
|
||||
this.speed = speed;
|
||||
this.lastTickTime = 0;
|
||||
}
|
||||
|
||||
async play(
|
||||
nodes: BehaviorTreeNode[],
|
||||
blackboardVariables: BlackboardVariables,
|
||||
connections: Connection[]
|
||||
): Promise<void> {
|
||||
if (this.mode === 'running') return;
|
||||
|
||||
this.currentNodes = nodes;
|
||||
this.currentConnections = connections;
|
||||
this.currentBlackboard = blackboardVariables;
|
||||
|
||||
const context = {
|
||||
nodes,
|
||||
connections,
|
||||
blackboardVariables,
|
||||
rootNodeId: this.config.rootNodeId,
|
||||
tickCount: 0
|
||||
};
|
||||
|
||||
try {
|
||||
await this.hooksManager?.triggerBeforePlay(context);
|
||||
|
||||
this.mode = 'running';
|
||||
this.tickCount = 0;
|
||||
this.lastTickTime = 0;
|
||||
|
||||
if (!this.executor) {
|
||||
this.executor = new BehaviorTreeExecutor();
|
||||
}
|
||||
|
||||
this.executor.buildTree(
|
||||
nodes,
|
||||
this.config.rootNodeId,
|
||||
blackboardVariables,
|
||||
connections,
|
||||
this.handleExecutionStatusUpdate.bind(this)
|
||||
);
|
||||
|
||||
// 设置断点触发回调(使用存储的回调)
|
||||
if (this.breakpointCallback) {
|
||||
this.executor.setBreakpointCallback(this.breakpointCallback);
|
||||
}
|
||||
|
||||
this.executor.start();
|
||||
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
|
||||
|
||||
this.eventBus?.emit(EditorEvent.EXECUTION_STARTED, context);
|
||||
await this.hooksManager?.triggerAfterPlay(context);
|
||||
} catch (error) {
|
||||
console.error('Error in play:', error);
|
||||
await this.hooksManager?.triggerOnError(error as Error, 'play');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
try {
|
||||
if (this.mode === 'running') {
|
||||
await this.hooksManager?.triggerBeforePause();
|
||||
|
||||
this.mode = 'paused';
|
||||
|
||||
if (this.executor) {
|
||||
this.executor.pause();
|
||||
}
|
||||
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
|
||||
this.eventBus?.emit(EditorEvent.EXECUTION_PAUSED);
|
||||
await this.hooksManager?.triggerAfterPause();
|
||||
} else if (this.mode === 'paused') {
|
||||
await this.hooksManager?.triggerBeforeResume();
|
||||
|
||||
this.mode = 'running';
|
||||
this.lastTickTime = 0;
|
||||
|
||||
if (this.executor) {
|
||||
this.executor.resume();
|
||||
}
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
|
||||
|
||||
this.eventBus?.emit(EditorEvent.EXECUTION_RESUMED);
|
||||
await this.hooksManager?.triggerAfterResume();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in pause/resume:', error);
|
||||
await this.hooksManager?.triggerOnError(error as Error, 'pause');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
try {
|
||||
await this.hooksManager?.triggerBeforeStop();
|
||||
|
||||
this.mode = 'idle';
|
||||
this.tickCount = 0;
|
||||
this.lastTickTime = 0;
|
||||
this.lastStepTime = 0;
|
||||
this.pendingStatusUpdates = [];
|
||||
this.currentlyDisplayedIndex = 0;
|
||||
|
||||
this.domCache.clearAllStatusTimers();
|
||||
this.domCache.clearStatusCache();
|
||||
|
||||
this.config.onExecutionStatusUpdate(new Map(), new Map());
|
||||
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
|
||||
if (this.executor) {
|
||||
this.executor.stop();
|
||||
}
|
||||
|
||||
this.eventBus?.emit(EditorEvent.EXECUTION_STOPPED);
|
||||
await this.hooksManager?.triggerAfterStop();
|
||||
} catch (error) {
|
||||
console.error('Error in stop:', error);
|
||||
await this.hooksManager?.triggerOnError(error as Error, 'stop');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
await this.stop();
|
||||
|
||||
if (this.executor) {
|
||||
this.executor.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (this.executor && this.mode !== 'idle') {
|
||||
this.executor.updateBlackboardVariable(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
getBlackboardVariables(): BlackboardVariables {
|
||||
if (this.executor) {
|
||||
return this.executor.getBlackboardVariables();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
updateNodes(nodes: BehaviorTreeNode[]): void {
|
||||
if (this.mode === 'idle' || !this.executor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentNodes = nodes;
|
||||
|
||||
this.executor.buildTree(
|
||||
nodes,
|
||||
this.config.rootNodeId,
|
||||
this.currentBlackboard,
|
||||
this.currentConnections,
|
||||
this.handleExecutionStatusUpdate.bind(this)
|
||||
);
|
||||
|
||||
// 设置断点触发回调(使用存储的回调)
|
||||
if (this.breakpointCallback) {
|
||||
this.executor.setBreakpointCallback(this.breakpointCallback);
|
||||
}
|
||||
|
||||
this.executor.start();
|
||||
}
|
||||
|
||||
clearDOMCache(): void {
|
||||
this.domCache.clearAll();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
|
||||
if (this.executor) {
|
||||
this.executor.destroy();
|
||||
this.executor = null;
|
||||
}
|
||||
}
|
||||
|
||||
private tickLoop(currentTime: number): void {
|
||||
if (this.mode !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.executor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stepByStepMode) {
|
||||
this.handleStepByStepExecution(currentTime);
|
||||
} else {
|
||||
this.handleNormalExecution(currentTime);
|
||||
}
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
|
||||
}
|
||||
|
||||
private handleNormalExecution(currentTime: number): void {
|
||||
const baseTickInterval = 16.67;
|
||||
const scaledTickInterval = baseTickInterval / this.speed;
|
||||
|
||||
if (this.lastTickTime === 0) {
|
||||
this.lastTickTime = currentTime;
|
||||
}
|
||||
|
||||
const elapsed = currentTime - this.lastTickTime;
|
||||
|
||||
if (elapsed >= scaledTickInterval) {
|
||||
const deltaTime = baseTickInterval / 1000;
|
||||
|
||||
this.executor!.tick(deltaTime);
|
||||
|
||||
this.tickCount = this.executor!.getTickCount();
|
||||
this.config.onTickCountUpdate(this.tickCount);
|
||||
|
||||
this.lastTickTime = currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
private handleStepByStepExecution(currentTime: number): void {
|
||||
if (this.lastStepTime === 0) {
|
||||
this.lastStepTime = currentTime;
|
||||
}
|
||||
|
||||
const stepElapsed = currentTime - this.lastStepTime;
|
||||
const actualStepInterval = this.stepInterval / this.speed;
|
||||
|
||||
if (stepElapsed >= actualStepInterval) {
|
||||
if (this.currentlyDisplayedIndex < this.pendingStatusUpdates.length) {
|
||||
this.displayNextNode();
|
||||
this.lastStepTime = currentTime;
|
||||
} else {
|
||||
if (this.lastTickTime === 0) {
|
||||
this.lastTickTime = currentTime;
|
||||
}
|
||||
|
||||
const tickElapsed = currentTime - this.lastTickTime;
|
||||
const baseTickInterval = 16.67;
|
||||
const scaledTickInterval = baseTickInterval / this.speed;
|
||||
|
||||
if (tickElapsed >= scaledTickInterval) {
|
||||
const deltaTime = baseTickInterval / 1000;
|
||||
this.executor!.tick(deltaTime);
|
||||
this.tickCount = this.executor!.getTickCount();
|
||||
this.config.onTickCountUpdate(this.tickCount);
|
||||
this.lastTickTime = currentTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private displayNextNode(): void {
|
||||
if (this.currentlyDisplayedIndex >= this.pendingStatusUpdates.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusesToDisplay = this.pendingStatusUpdates.slice(0, this.currentlyDisplayedIndex + 1);
|
||||
const currentNode = this.pendingStatusUpdates[this.currentlyDisplayedIndex];
|
||||
|
||||
if (!currentNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusMap = new Map<string, NodeExecutionStatus>();
|
||||
const orderMap = new Map<string, number>();
|
||||
|
||||
statusesToDisplay.forEach((s) => {
|
||||
statusMap.set(s.nodeId, s.status);
|
||||
if (s.executionOrder !== undefined) {
|
||||
orderMap.set(s.nodeId, s.executionOrder);
|
||||
}
|
||||
});
|
||||
|
||||
const nodeName = this.currentNodes.find((n) => n.id === currentNode.nodeId)?.template.displayName || 'Unknown';
|
||||
logger.info(`[StepByStep] Displaying ${this.currentlyDisplayedIndex + 1}/${this.pendingStatusUpdates.length} | ${nodeName} | Order: ${currentNode.executionOrder} | ID: ${currentNode.nodeId}`);
|
||||
this.config.onExecutionStatusUpdate(statusMap, orderMap);
|
||||
|
||||
this.currentlyDisplayedIndex++;
|
||||
}
|
||||
|
||||
private handleExecutionStatusUpdate(
|
||||
statuses: ExecutionStatus[],
|
||||
logs: ExecutionLog[],
|
||||
runtimeBlackboardVars?: BlackboardVariables
|
||||
): void {
|
||||
this.config.onLogsUpdate([...logs]);
|
||||
|
||||
if (runtimeBlackboardVars) {
|
||||
this.config.onBlackboardUpdate(runtimeBlackboardVars);
|
||||
}
|
||||
|
||||
if (this.stepByStepMode) {
|
||||
const statusesWithOrder = statuses.filter((s) => s.executionOrder !== undefined);
|
||||
|
||||
if (statusesWithOrder.length > 0) {
|
||||
const minOrder = Math.min(...statusesWithOrder.map((s) => s.executionOrder!));
|
||||
|
||||
if (minOrder === 1 || this.pendingStatusUpdates.length === 0) {
|
||||
this.pendingStatusUpdates = statusesWithOrder.sort((a, b) =>
|
||||
(a.executionOrder || 0) - (b.executionOrder || 0)
|
||||
);
|
||||
this.currentlyDisplayedIndex = 0;
|
||||
this.lastStepTime = 0;
|
||||
} else {
|
||||
const maxExistingOrder = this.pendingStatusUpdates.length > 0
|
||||
? Math.max(...this.pendingStatusUpdates.map((s) => s.executionOrder || 0))
|
||||
: 0;
|
||||
|
||||
const newStatuses = statusesWithOrder.filter((s) =>
|
||||
(s.executionOrder || 0) > maxExistingOrder
|
||||
);
|
||||
|
||||
if (newStatuses.length > 0) {
|
||||
logger.info(`[StepByStep] Appending ${newStatuses.length} new nodes, orders:`, newStatuses.map((s) => s.executionOrder));
|
||||
this.pendingStatusUpdates = [
|
||||
...this.pendingStatusUpdates,
|
||||
...newStatuses
|
||||
].sort((a, b) => (a.executionOrder || 0) - (b.executionOrder || 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const statusMap = new Map<string, NodeExecutionStatus>();
|
||||
const orderMap = new Map<string, number>();
|
||||
|
||||
statuses.forEach((s) => {
|
||||
statusMap.set(s.nodeId, s.status);
|
||||
if (s.executionOrder !== undefined) {
|
||||
orderMap.set(s.nodeId, s.executionOrder);
|
||||
}
|
||||
});
|
||||
|
||||
this.config.onExecutionStatusUpdate(statusMap, orderMap);
|
||||
}
|
||||
}
|
||||
|
||||
private updateConnectionStyles(
|
||||
statusMap: Record<string, NodeExecutionStatus>,
|
||||
connections?: Connection[]
|
||||
): void {
|
||||
if (!connections) return;
|
||||
|
||||
connections.forEach((conn) => {
|
||||
const connKey = `${conn.from}-${conn.to}`;
|
||||
|
||||
const pathElement = this.domCache.getConnection(connKey);
|
||||
if (!pathElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fromStatus = statusMap[conn.from];
|
||||
const toStatus = statusMap[conn.to];
|
||||
const isActive = fromStatus === 'running' || toStatus === 'running';
|
||||
|
||||
if (conn.connectionType === 'property') {
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke', '#9c27b0');
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2');
|
||||
} else if (isActive) {
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke', '#ffa726');
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '3');
|
||||
} else {
|
||||
const isExecuted = this.domCache.hasNodeClass(conn.from, 'executed') &&
|
||||
this.domCache.hasNodeClass(conn.to, 'executed');
|
||||
|
||||
if (isExecuted) {
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke', '#4caf50');
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2.5');
|
||||
} else {
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke', '#0e639c');
|
||||
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setConnections(connections: Connection[]): void {
|
||||
if (this.mode !== 'idle') {
|
||||
const currentStatuses: Record<string, NodeExecutionStatus> = {};
|
||||
connections.forEach((conn) => {
|
||||
const fromStatus = this.domCache.getLastStatus(conn.from);
|
||||
const toStatus = this.domCache.getLastStatus(conn.to);
|
||||
if (fromStatus) currentStatuses[conn.from] = fromStatus;
|
||||
if (toStatus) currentStatuses[conn.to] = toStatus;
|
||||
});
|
||||
this.updateConnectionStyles(currentStatuses, connections);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Connection, ConnectionType } from '../../domain/models/Connection';
|
||||
import { CommandManager } from '@esengine/editor-core';
|
||||
import { AddConnectionCommand } from '../commands/tree/AddConnectionCommand';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
import { IValidator } from '../../domain/interfaces/IValidator';
|
||||
|
||||
/**
|
||||
* 添加连接用例
|
||||
*/
|
||||
export class AddConnectionUseCase {
|
||||
constructor(
|
||||
private readonly commandManager: CommandManager,
|
||||
private readonly treeState: ITreeState,
|
||||
private readonly validator: IValidator
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 执行添加连接操作
|
||||
*/
|
||||
execute(
|
||||
from: string,
|
||||
to: string,
|
||||
connectionType: ConnectionType = 'node',
|
||||
fromProperty?: string,
|
||||
toProperty?: string
|
||||
): Connection {
|
||||
const connection = new Connection(from, to, connectionType, fromProperty, toProperty);
|
||||
|
||||
const tree = this.treeState.getTree();
|
||||
const validationResult = this.validator.validateConnection(connection, tree);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
const errorMessages = validationResult.errors.map((e) => e.message).join(', ');
|
||||
throw new Error(`连接验证失败: ${errorMessages}`);
|
||||
}
|
||||
|
||||
const command = new AddConnectionCommand(this.treeState, connection);
|
||||
this.commandManager.execute(command);
|
||||
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { Node } from '../../domain/models/Node';
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
|
||||
import { CommandManager } from '@esengine/editor-core';
|
||||
import { CreateNodeCommand } from '../commands/tree/CreateNodeCommand';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
|
||||
/**
|
||||
* 创建节点用例
|
||||
*/
|
||||
export class CreateNodeUseCase {
|
||||
constructor(
|
||||
private readonly nodeFactory: INodeFactory,
|
||||
private readonly commandManager: CommandManager,
|
||||
private readonly treeState: ITreeState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 执行创建节点操作
|
||||
*/
|
||||
execute(template: NodeTemplate, position: Position, data?: Record<string, unknown>): Node {
|
||||
const node = this.nodeFactory.createNode(template, position, data);
|
||||
|
||||
const command = new CreateNodeCommand(this.treeState, node);
|
||||
this.commandManager.execute(command);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型创建节点
|
||||
*/
|
||||
executeByType(nodeType: string, position: Position, data?: Record<string, unknown>): Node {
|
||||
const node = this.nodeFactory.createNodeByType(nodeType, position, data);
|
||||
|
||||
const command = new CreateNodeCommand(this.treeState, node);
|
||||
this.commandManager.execute(command);
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { CommandManager, ICommand } from '@esengine/editor-core';
|
||||
import { DeleteNodeCommand } from '../commands/tree/DeleteNodeCommand';
|
||||
import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
|
||||
/**
|
||||
* 删除节点用例
|
||||
* 删除节点时会自动删除相关连接
|
||||
*/
|
||||
export class DeleteNodeUseCase {
|
||||
constructor(
|
||||
private readonly commandManager: CommandManager,
|
||||
private readonly treeState: ITreeState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 删除单个节点
|
||||
*/
|
||||
execute(nodeId: string): void {
|
||||
const tree = this.treeState.getTree();
|
||||
|
||||
const relatedConnections = tree.connections.filter(
|
||||
(conn) => conn.from === nodeId || conn.to === nodeId
|
||||
);
|
||||
|
||||
const commands: ICommand[] = [];
|
||||
|
||||
relatedConnections.forEach((conn) => {
|
||||
commands.push(
|
||||
new RemoveConnectionCommand(
|
||||
this.treeState,
|
||||
conn.from,
|
||||
conn.to,
|
||||
conn.fromProperty,
|
||||
conn.toProperty
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
commands.push(new DeleteNodeCommand(this.treeState, nodeId));
|
||||
|
||||
this.commandManager.executeBatch(commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除节点
|
||||
*/
|
||||
executeBatch(nodeIds: string[]): void {
|
||||
const tree = this.treeState.getTree();
|
||||
const commands: ICommand[] = [];
|
||||
|
||||
const nodeIdSet = new Set(nodeIds);
|
||||
|
||||
const relatedConnections = tree.connections.filter(
|
||||
(conn) => nodeIdSet.has(conn.from) || nodeIdSet.has(conn.to)
|
||||
);
|
||||
|
||||
relatedConnections.forEach((conn) => {
|
||||
commands.push(
|
||||
new RemoveConnectionCommand(
|
||||
this.treeState,
|
||||
conn.from,
|
||||
conn.to,
|
||||
conn.fromProperty,
|
||||
conn.toProperty
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
nodeIds.forEach((nodeId) => {
|
||||
commands.push(new DeleteNodeCommand(this.treeState, nodeId));
|
||||
});
|
||||
|
||||
this.commandManager.executeBatch(commands);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { CommandManager } from '@esengine/editor-core';
|
||||
import { MoveNodeCommand } from '../commands/tree/MoveNodeCommand';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
|
||||
/**
|
||||
* 移动节点用例
|
||||
*/
|
||||
export class MoveNodeUseCase {
|
||||
constructor(
|
||||
private readonly commandManager: CommandManager,
|
||||
private readonly treeState: ITreeState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 移动单个节点
|
||||
*/
|
||||
execute(nodeId: string, newPosition: Position): void {
|
||||
const command = new MoveNodeCommand(this.treeState, nodeId, newPosition);
|
||||
this.commandManager.execute(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量移动节点
|
||||
*/
|
||||
executeBatch(moves: Array<{ nodeId: string; position: Position }>): void {
|
||||
const commands = moves.map(
|
||||
({ nodeId, position }) => new MoveNodeCommand(this.treeState, nodeId, position)
|
||||
);
|
||||
this.commandManager.executeBatch(commands);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CommandManager } from '@esengine/editor-core';
|
||||
import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
|
||||
/**
|
||||
* 移除连接用例
|
||||
*/
|
||||
export class RemoveConnectionUseCase {
|
||||
constructor(
|
||||
private readonly commandManager: CommandManager,
|
||||
private readonly treeState: ITreeState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 执行移除连接操作
|
||||
*/
|
||||
execute(from: string, to: string, fromProperty?: string, toProperty?: string): void {
|
||||
const command = new RemoveConnectionCommand(
|
||||
this.treeState,
|
||||
from,
|
||||
to,
|
||||
fromProperty,
|
||||
toProperty
|
||||
);
|
||||
this.commandManager.execute(command);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CommandManager } from '@esengine/editor-core';
|
||||
import { UpdateNodeDataCommand } from '../commands/tree/UpdateNodeDataCommand';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
|
||||
/**
|
||||
* 更新节点数据用例
|
||||
*/
|
||||
export class UpdateNodeDataUseCase {
|
||||
constructor(
|
||||
private readonly commandManager: CommandManager,
|
||||
private readonly treeState: ITreeState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 更新节点数据
|
||||
*/
|
||||
execute(nodeId: string, data: Record<string, unknown>): void {
|
||||
const command = new UpdateNodeDataCommand(this.treeState, nodeId, data);
|
||||
this.commandManager.execute(command);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IValidator, ValidationResult } from '../../domain/interfaces/IValidator';
|
||||
import { ITreeState } from '../commands/ITreeState';
|
||||
|
||||
/**
|
||||
* 验证行为树用例
|
||||
*/
|
||||
export class ValidateTreeUseCase {
|
||||
constructor(
|
||||
private readonly validator: IValidator,
|
||||
private readonly treeState: ITreeState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 验证当前行为树
|
||||
*/
|
||||
execute(): ValidationResult {
|
||||
const tree = this.treeState.getTree();
|
||||
return this.validator.validateTree(tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并抛出错误(如果验证失败)
|
||||
*/
|
||||
executeAndThrow(): void {
|
||||
const result = this.execute();
|
||||
|
||||
if (!result.isValid) {
|
||||
const errorMessages = result.errors.map((e) => e.message).join('\n');
|
||||
throw new Error(`行为树验证失败:\n${errorMessages}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { CreateNodeUseCase } from './CreateNodeUseCase';
|
||||
export { DeleteNodeUseCase } from './DeleteNodeUseCase';
|
||||
export { AddConnectionUseCase } from './AddConnectionUseCase';
|
||||
export { RemoveConnectionUseCase } from './RemoveConnectionUseCase';
|
||||
export { MoveNodeUseCase } from './MoveNodeUseCase';
|
||||
export { UpdateNodeDataUseCase } from './UpdateNodeDataUseCase';
|
||||
export { ValidateTreeUseCase } from './ValidateTreeUseCase';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,790 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Clipboard, Edit2, Trash2, ChevronDown, ChevronRight, Globe, GripVertical, ChevronLeft, Plus, Copy } from 'lucide-react';
|
||||
|
||||
type SimpleBlackboardType = 'number' | 'string' | 'boolean' | 'object';
|
||||
|
||||
interface BlackboardPanelProps {
|
||||
variables: Record<string, any>;
|
||||
initialVariables?: Record<string, any>;
|
||||
globalVariables?: Record<string, any>;
|
||||
onVariableChange: (key: string, value: any) => void;
|
||||
onVariableAdd: (key: string, value: any, type: SimpleBlackboardType) => void;
|
||||
onVariableDelete: (key: string) => void;
|
||||
onVariableRename?: (oldKey: string, newKey: string) => void;
|
||||
onGlobalVariableChange?: (key: string, value: any) => void;
|
||||
onGlobalVariableAdd?: (key: string, value: any, type: SimpleBlackboardType) => void;
|
||||
onGlobalVariableDelete?: (key: string) => void;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 黑板面板组件 - 内嵌在编辑器侧边
|
||||
* 支持本地变量和全局变量的管理
|
||||
*/
|
||||
export const BlackboardPanel: React.FC<BlackboardPanelProps> = ({
|
||||
variables,
|
||||
initialVariables,
|
||||
globalVariables,
|
||||
onVariableChange,
|
||||
onVariableAdd,
|
||||
onVariableDelete,
|
||||
onVariableRename,
|
||||
onGlobalVariableChange,
|
||||
onGlobalVariableAdd,
|
||||
onGlobalVariableDelete,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'local' | 'global'>('local');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const [newType, setNewType] = useState<SimpleBlackboardType>('string');
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editingNewKey, setEditingNewKey] = useState('');
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [editType, setEditType] = useState<SimpleBlackboardType>('string');
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const isModified = (key: string): boolean => {
|
||||
if (!initialVariables || viewMode !== 'local') return false;
|
||||
return JSON.stringify(variables[key]) !== JSON.stringify(initialVariables[key]);
|
||||
};
|
||||
|
||||
const handleAddVariable = () => {
|
||||
if (!newKey.trim()) return;
|
||||
|
||||
let parsedValue: any = newValue;
|
||||
if (newType === 'number') {
|
||||
parsedValue = parseFloat(newValue) || 0;
|
||||
} else if (newType === 'boolean') {
|
||||
parsedValue = newValue === 'true';
|
||||
} else if (newType === 'object') {
|
||||
try {
|
||||
parsedValue = JSON.parse(newValue);
|
||||
} catch {
|
||||
parsedValue = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (viewMode === 'global' && onGlobalVariableAdd) {
|
||||
onGlobalVariableAdd(newKey, parsedValue, newType);
|
||||
} else {
|
||||
onVariableAdd(newKey, parsedValue, newType);
|
||||
}
|
||||
|
||||
setNewKey('');
|
||||
setNewValue('');
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleStartEdit = (key: string, value: any) => {
|
||||
setEditingKey(key);
|
||||
setEditingNewKey(key);
|
||||
const currentType = getVariableType(value);
|
||||
setEditType(currentType);
|
||||
setEditValue(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
|
||||
};
|
||||
|
||||
const handleSaveEdit = (key: string) => {
|
||||
const newKey = editingNewKey.trim();
|
||||
if (!newKey) return;
|
||||
|
||||
let parsedValue: any = editValue;
|
||||
if (editType === 'number') {
|
||||
parsedValue = parseFloat(editValue) || 0;
|
||||
} else if (editType === 'boolean') {
|
||||
parsedValue = editValue === 'true' || editValue === '1';
|
||||
} else if (editType === 'object') {
|
||||
try {
|
||||
parsedValue = JSON.parse(editValue);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (viewMode === 'global' && onGlobalVariableChange) {
|
||||
if (newKey !== key && onGlobalVariableDelete) {
|
||||
onGlobalVariableDelete(key);
|
||||
}
|
||||
onGlobalVariableChange(newKey, parsedValue);
|
||||
} else {
|
||||
if (newKey !== key && onVariableRename) {
|
||||
onVariableRename(key, newKey);
|
||||
}
|
||||
onVariableChange(newKey, parsedValue);
|
||||
}
|
||||
|
||||
setEditingKey(null);
|
||||
};
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(groupName)) {
|
||||
newSet.delete(groupName);
|
||||
} else {
|
||||
newSet.add(groupName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const getVariableType = (value: any): SimpleBlackboardType => {
|
||||
if (typeof value === 'number') return 'number';
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'object') return 'object';
|
||||
return 'string';
|
||||
};
|
||||
|
||||
const currentVariables = viewMode === 'global' ? (globalVariables || {}) : variables;
|
||||
const variableEntries = Object.entries(currentVariables);
|
||||
const currentOnDelete = viewMode === 'global' ? onGlobalVariableDelete : onVariableDelete;
|
||||
|
||||
const groupedVariables: Record<string, Array<{ fullKey: string; varName: string; value: any }>> = variableEntries.reduce((groups, [key, value]) => {
|
||||
const parts = key.split('.');
|
||||
const groupName = (parts.length > 1 && parts[0]) ? parts[0] : 'default';
|
||||
const varName = parts.length > 1 ? parts.slice(1).join('.') : key;
|
||||
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = [];
|
||||
}
|
||||
groups[groupName].push({ fullKey: key, varName, value });
|
||||
return groups;
|
||||
}, {} as Record<string, Array<{ fullKey: string; varName: string; value: any }>>);
|
||||
|
||||
const groupNames = Object.keys(groupedVariables).sort((a, b) => {
|
||||
if (a === 'default') return 1;
|
||||
if (b === 'default') return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// 复制变量到剪贴板
|
||||
const handleCopyVariable = (key: string, value: any) => {
|
||||
const text = `${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`;
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#cccccc',
|
||||
borderLeft: '1px solid #333',
|
||||
transition: 'width 0.2s ease'
|
||||
}}>
|
||||
{/* 标题栏 */}
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
backgroundColor: '#252525',
|
||||
borderBottom: '1px solid #333',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
color: '#ccc'
|
||||
}}>
|
||||
<Clipboard size={14} />
|
||||
{!isCollapsed && <span>Blackboard</span>}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{!isCollapsed && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
backgroundColor: '#1e1e1e',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setViewMode('local')}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: viewMode === 'local' ? '#007acc' : 'transparent',
|
||||
border: 'none',
|
||||
color: viewMode === 'local' ? '#fff' : '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (viewMode !== 'local') {
|
||||
e.currentTarget.style.backgroundColor = '#2a2a2a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (viewMode !== 'local') {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Clipboard size={11} />
|
||||
Local
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('global')}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: viewMode === 'global' ? '#007acc' : 'transparent',
|
||||
border: 'none',
|
||||
color: viewMode === 'global' ? '#fff' : '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (viewMode !== 'global') {
|
||||
e.currentTarget.style.backgroundColor = '#2a2a2a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (viewMode !== 'global') {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Globe size={11} />
|
||||
Global
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{onToggleCollapse && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
style={{
|
||||
padding: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '2px',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#3c3c3c';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
<ChevronLeft size={14} style={{
|
||||
transform: isCollapsed ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.2s'
|
||||
}} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{/* 工具栏 */}
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#222',
|
||||
borderBottom: '1px solid #333',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
fontSize: '10px',
|
||||
color: '#888'
|
||||
}}>
|
||||
{viewMode === 'local' ? '当前行为树的本地变量' : '所有行为树共享的全局变量'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#007acc',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'background-color 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#005a9e'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#007acc'}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 变量列表 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '10px'
|
||||
}}>
|
||||
{variableEntries.length === 0 && !isAdding && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
padding: '20px'
|
||||
}}>
|
||||
No variables yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加新变量表单 */}
|
||||
{isAdding && (
|
||||
<div style={{
|
||||
marginBottom: '10px',
|
||||
padding: '10px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #3c3c3c'
|
||||
}}>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Name</div>
|
||||
<input
|
||||
type="text"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
placeholder="variable.name"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '3px',
|
||||
color: '#9cdcfe',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Type</div>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value as SimpleBlackboardType)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<option value="string">String</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="object">Object (JSON)</option>
|
||||
</select>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Value</div>
|
||||
<textarea
|
||||
placeholder={
|
||||
newType === 'object' ? '{"key": "value"}' :
|
||||
newType === 'boolean' ? 'true or false' :
|
||||
newType === 'number' ? '0' : 'value'
|
||||
}
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: newType === 'object' ? '80px' : '30px',
|
||||
padding: '6px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '5px' }}>
|
||||
<button
|
||||
onClick={handleAddVariable}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setNewKey('');
|
||||
setNewValue('');
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分组显示变量 */}
|
||||
{groupNames.map((groupName) => {
|
||||
const isGroupCollapsed = collapsedGroups.has(groupName);
|
||||
const groupVars = groupedVariables[groupName];
|
||||
if (!groupVars) return null;
|
||||
|
||||
return (
|
||||
<div key={groupName} style={{ marginBottom: '8px' }}>
|
||||
{groupName !== 'default' && (
|
||||
<div
|
||||
onClick={() => toggleGroup(groupName)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 6px',
|
||||
backgroundColor: '#252525',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '4px',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
{isGroupCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: '#888'
|
||||
}}>
|
||||
{groupName} ({groupVars.length})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isGroupCollapsed && groupVars.map(({ fullKey: key, varName, value }) => {
|
||||
const type = getVariableType(value);
|
||||
const isEditing = editingKey === key;
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
const variableData = {
|
||||
variableName: key,
|
||||
variableValue: value,
|
||||
variableType: type
|
||||
};
|
||||
e.dataTransfer.setData('application/blackboard-variable', JSON.stringify(variableData));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
const typeColor =
|
||||
type === 'number' ? '#4ec9b0' :
|
||||
type === 'boolean' ? '#569cd6' :
|
||||
type === 'object' ? '#ce9178' : '#d4d4d4';
|
||||
|
||||
const displayValue = type === 'object' ?
|
||||
JSON.stringify(value) :
|
||||
String(value);
|
||||
|
||||
const truncatedValue = displayValue.length > 30 ?
|
||||
displayValue.substring(0, 30) + '...' :
|
||||
displayValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
draggable={!isEditing}
|
||||
onDragStart={handleDragStart}
|
||||
style={{
|
||||
marginBottom: '6px',
|
||||
padding: '6px 8px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderRadius: '3px',
|
||||
borderLeft: `3px solid ${typeColor}`,
|
||||
cursor: isEditing ? 'default' : 'grab'
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Name</div>
|
||||
<input
|
||||
type="text"
|
||||
value={editingNewKey}
|
||||
onChange={(e) => setEditingNewKey(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '2px',
|
||||
color: '#9cdcfe',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Type</div>
|
||||
<select
|
||||
value={editType}
|
||||
onChange={(e) => setEditType(e.target.value as SimpleBlackboardType)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '2px',
|
||||
color: '#cccccc',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
<option value="string">String</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="object">Object (JSON)</option>
|
||||
</select>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Value</div>
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: editType === 'object' ? '60px' : '24px',
|
||||
padding: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #0e639c',
|
||||
borderRadius: '2px',
|
||||
color: '#cccccc',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical',
|
||||
marginBottom: '4px'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => handleSaveEdit(key)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingKey(null)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#9cdcfe',
|
||||
fontWeight: 'bold',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
<GripVertical size={10} style={{ opacity: 0.3, flexShrink: 0 }} />
|
||||
{varName}
|
||||
<span style={{
|
||||
color: '#666',
|
||||
fontWeight: 'normal',
|
||||
fontSize: '10px'
|
||||
}}>({type})</span>
|
||||
{isModified(key) && (
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
color: '#ff9800',
|
||||
fontWeight: 'bold'
|
||||
}}>*</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#888',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{truncatedValue}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '2px', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => handleCopyVariable(key, value)}
|
||||
style={{
|
||||
padding: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#3c3c3c';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
title="Copy"
|
||||
>
|
||||
<Copy size={11} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStartEdit(key, value)}
|
||||
style={{
|
||||
padding: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#3c3c3c';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 size={11} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => currentOnDelete && currentOnDelete(key)}
|
||||
style={{
|
||||
padding: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#5a1a1a';
|
||||
e.currentTarget.style.color = '#f48771';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 底部信息栏 */}
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
borderTop: '1px solid #333',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
backgroundColor: '#252525'
|
||||
}}>
|
||||
{viewMode === 'local' ? 'Local' : 'Global'}: {variableEntries.length} variable{variableEntries.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 滚动条样式 */}
|
||||
<style>{`
|
||||
.blackboard-scrollable::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.blackboard-scrollable::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
.blackboard-scrollable::-webkit-scrollbar-thumb {
|
||||
background: #3c3c3c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.blackboard-scrollable::-webkit-scrollbar-thumb:hover {
|
||||
background: #4c4c4c;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useRef, useCallback, forwardRef, useState, useEffect } from 'react';
|
||||
import { useCanvasInteraction } from '../../hooks/useCanvasInteraction';
|
||||
import { EditorConfig } from '../../types';
|
||||
import { GridBackground } from './GridBackground';
|
||||
|
||||
/**
|
||||
* 画布组件属性
|
||||
*/
|
||||
interface BehaviorTreeCanvasProps {
|
||||
/**
|
||||
* 编辑器配置
|
||||
*/
|
||||
config: EditorConfig;
|
||||
|
||||
/**
|
||||
* 子组件
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* 画布点击事件
|
||||
*/
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 画布双击事件
|
||||
*/
|
||||
onDoubleClick?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 画布右键事件
|
||||
*/
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 鼠标移动事件
|
||||
*/
|
||||
onMouseMove?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 鼠标按下事件
|
||||
*/
|
||||
onMouseDown?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 鼠标抬起事件
|
||||
*/
|
||||
onMouseUp?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 鼠标离开事件
|
||||
*/
|
||||
onMouseLeave?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 拖放事件
|
||||
*/
|
||||
onDrop?: (e: React.DragEvent) => void;
|
||||
|
||||
/**
|
||||
* 拖动悬停事件
|
||||
*/
|
||||
onDragOver?: (e: React.DragEvent) => void;
|
||||
|
||||
/**
|
||||
* 拖动进入事件
|
||||
*/
|
||||
onDragEnter?: (e: React.DragEvent) => void;
|
||||
|
||||
/**
|
||||
* 拖动离开事件
|
||||
*/
|
||||
onDragLeave?: (e: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树画布组件
|
||||
* 负责画布的渲染、缩放、平移等基础功能
|
||||
*/
|
||||
export const BehaviorTreeCanvas = forwardRef<HTMLDivElement, BehaviorTreeCanvasProps>(({
|
||||
config,
|
||||
children,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
onMouseMove,
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
onMouseLeave,
|
||||
onDrop,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave
|
||||
}, forwardedRef) => {
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = (forwardedRef as React.RefObject<HTMLDivElement>) || internalRef;
|
||||
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
const {
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
isPanning,
|
||||
handleWheel,
|
||||
startPanning,
|
||||
updatePanning,
|
||||
stopPanning
|
||||
} = 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) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.altKey)) {
|
||||
e.preventDefault();
|
||||
startPanning(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
onMouseDown?.(e);
|
||||
}, [startPanning, onMouseDown]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (isPanning) {
|
||||
updatePanning(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
onMouseMove?.(e);
|
||||
}, [isPanning, updatePanning, onMouseMove]);
|
||||
|
||||
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
||||
if (isPanning) {
|
||||
stopPanning();
|
||||
}
|
||||
|
||||
onMouseUp?.(e);
|
||||
}, [isPanning, stopPanning, onMouseUp]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onContextMenu?.(e);
|
||||
}, [onContextMenu]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="behavior-tree-canvas"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
cursor: isPanning ? 'grabbing' : 'default',
|
||||
backgroundColor: '#1a1a1a'
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
>
|
||||
{/* 网格背景 */}
|
||||
{config.showGrid && canvasSize.width > 0 && canvasSize.height > 0 && (
|
||||
<GridBackground
|
||||
canvasOffset={canvasOffset}
|
||||
canvasScale={canvasScale}
|
||||
width={canvasSize.width}
|
||||
height={canvasSize.height}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 内容容器(应用变换) */}
|
||||
<div
|
||||
className="canvas-content"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transformOrigin: '0 0',
|
||||
transform: `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasScale})`,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
BehaviorTreeCanvas.displayName = 'BehaviorTreeCanvas';
|
||||
@@ -0,0 +1,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 @@
|
||||
export { BehaviorTreeCanvas } from './BehaviorTreeCanvas';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ConnectionRenderer } from './ConnectionRenderer';
|
||||
import { ConnectionViewData } from '../../types';
|
||||
import { Node } from '../../domain/models/Node';
|
||||
import { Connection } from '../../domain/models/Connection';
|
||||
|
||||
interface ConnectionLayerProps {
|
||||
connections: Connection[];
|
||||
nodes: Node[];
|
||||
selectedConnection?: { from: string; to: string } | null;
|
||||
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
|
||||
onConnectionClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||
onConnectionContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||
}
|
||||
export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
|
||||
connections,
|
||||
nodes,
|
||||
selectedConnection,
|
||||
getPortPosition,
|
||||
onConnectionClick,
|
||||
onConnectionContextMenu
|
||||
}) => {
|
||||
const nodeMap = useMemo(() => {
|
||||
return new Map(nodes.map((node) => [node.id, node]));
|
||||
}, [nodes]);
|
||||
|
||||
const connectionViewData = useMemo(() => {
|
||||
return connections
|
||||
.map((connection) => {
|
||||
const fromNode = nodeMap.get(connection.from);
|
||||
const toNode = nodeMap.get(connection.to);
|
||||
|
||||
if (!fromNode || !toNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { connection, fromNode, toNode };
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
}, [connections, nodeMap]);
|
||||
|
||||
const isConnectionSelected = (connection: { from: string; to: string }) => {
|
||||
return selectedConnection?.from === connection.from &&
|
||||
selectedConnection?.to === connection.to;
|
||||
};
|
||||
|
||||
if (connectionViewData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="connection-layer"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
overflow: 'visible',
|
||||
zIndex: 0
|
||||
}}
|
||||
>
|
||||
<g style={{ pointerEvents: 'auto' }}>
|
||||
{connectionViewData.map(({ connection, fromNode, toNode }) => {
|
||||
const viewData: ConnectionViewData = {
|
||||
connection,
|
||||
isSelected: isConnectionSelected(connection)
|
||||
};
|
||||
return (
|
||||
<ConnectionRenderer
|
||||
key={`${connection.from}-${connection.to}`}
|
||||
connectionData={viewData}
|
||||
fromNode={fromNode}
|
||||
toNode={toNode}
|
||||
getPortPosition={getPortPosition}
|
||||
onClick={onConnectionClick}
|
||||
onContextMenu={onConnectionContextMenu}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ConnectionViewData } from '../../types';
|
||||
import { Node } from '../../domain/models/Node';
|
||||
|
||||
interface ConnectionRendererProps {
|
||||
connectionData: ConnectionViewData;
|
||||
fromNode: Node;
|
||||
toNode: Node;
|
||||
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
|
||||
onClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||
}
|
||||
const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
|
||||
connectionData,
|
||||
fromNode,
|
||||
toNode,
|
||||
getPortPosition,
|
||||
onClick,
|
||||
onContextMenu
|
||||
}) => {
|
||||
const { connection, isSelected } = connectionData;
|
||||
|
||||
const pathData = useMemo(() => {
|
||||
let fromPos, toPos;
|
||||
|
||||
if (connection.connectionType === 'property') {
|
||||
// 属性连接:使用 fromProperty 和 toProperty
|
||||
fromPos = getPortPosition(connection.from, connection.fromProperty);
|
||||
toPos = getPortPosition(connection.to, connection.toProperty);
|
||||
} else {
|
||||
// 节点连接:使用输出和输入端口
|
||||
fromPos = getPortPosition(connection.from, undefined, 'output');
|
||||
toPos = getPortPosition(connection.to, undefined, 'input');
|
||||
}
|
||||
|
||||
if (!fromPos || !toPos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const x1 = fromPos.x;
|
||||
const y1 = fromPos.y;
|
||||
const x2 = toPos.x;
|
||||
const y2 = toPos.y;
|
||||
|
||||
let pathD: string;
|
||||
|
||||
if (connection.connectionType === 'property') {
|
||||
const controlX1 = x1 + (x2 - x1) * 0.5;
|
||||
const controlX2 = x1 + (x2 - x1) * 0.5;
|
||||
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
|
||||
} else {
|
||||
const controlY = y1 + (y2 - y1) * 0.5;
|
||||
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
return {
|
||||
path: pathD,
|
||||
midX: (x1 + x2) / 2,
|
||||
midY: (y1 + y2) / 2
|
||||
};
|
||||
}, [connection, fromNode, toNode, getPortPosition]);
|
||||
|
||||
const 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 strokeWidth = isSelected ? 3.5 : 2.5;
|
||||
|
||||
const gradientId = `gradient-${connection.from}-${connection.to}`;
|
||||
|
||||
if (!pathData) {
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e, connection.from, connection.to);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onContextMenu?.(e, connection.from, connection.to);
|
||||
};
|
||||
|
||||
return (
|
||||
<g
|
||||
className="connection"
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ cursor: 'pointer' }}
|
||||
data-connection-from={connection.from}
|
||||
data-connection-to={connection.to}
|
||||
>
|
||||
<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
|
||||
d={pathData.path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={24}
|
||||
/>
|
||||
|
||||
<path
|
||||
d={pathData.path}
|
||||
fill="none"
|
||||
stroke={glowColor}
|
||||
strokeWidth={strokeWidth + 2}
|
||||
strokeLinecap="round"
|
||||
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 && (
|
||||
<>
|
||||
<circle
|
||||
cx={pathData.midX}
|
||||
cy={pathData.midY}
|
||||
r="8"
|
||||
fill={strokeColor}
|
||||
opacity="0.3"
|
||||
/>
|
||||
<circle
|
||||
cx={pathData.midX}
|
||||
cy={pathData.midY}
|
||||
r="5"
|
||||
fill={strokeColor}
|
||||
stroke="rgba(0, 0, 0, 0.5)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectionRenderer = ConnectionRendererComponent;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ConnectionRenderer } from './ConnectionRenderer';
|
||||
export { ConnectionLayer } from './ConnectionLayer';
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,407 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
TreePine,
|
||||
Database,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { PropertyDefinition } from '@esengine/behavior-tree';
|
||||
import { Node as BehaviorTreeNodeType } from '../../domain/models/Node';
|
||||
import { Connection } from '../../domain/models/Connection';
|
||||
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>;
|
||||
|
||||
interface BehaviorTreeNodeProps {
|
||||
node: BehaviorTreeNodeType;
|
||||
isSelected: boolean;
|
||||
isBeingDragged: boolean;
|
||||
dragDelta: { dx: number; dy: number };
|
||||
uncommittedNodeIds: Set<string>;
|
||||
blackboardVariables: BlackboardVariables;
|
||||
initialBlackboardVariables: BlackboardVariables;
|
||||
isExecuting: boolean;
|
||||
executionStatus?: NodeExecutionStatus;
|
||||
executionOrder?: number;
|
||||
connections: Connection[];
|
||||
nodes: BehaviorTreeNodeType[];
|
||||
executorRef: React.RefObject<BehaviorTreeExecutor | null>;
|
||||
iconMap: Record<string, LucideIcon>;
|
||||
draggingNodeId: string | null;
|
||||
onNodeClick: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
|
||||
onContextMenu: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
|
||||
onNodeMouseDown: (e: React.MouseEvent, nodeId: string) => void;
|
||||
onNodeMouseUpForConnection: (e: React.MouseEvent, nodeId: string) => void;
|
||||
onPortMouseDown: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
|
||||
onPortMouseUp: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
|
||||
}
|
||||
|
||||
const BehaviorTreeNodeComponent: React.FC<BehaviorTreeNodeProps> = ({
|
||||
node,
|
||||
isSelected,
|
||||
isBeingDragged,
|
||||
dragDelta,
|
||||
uncommittedNodeIds,
|
||||
blackboardVariables,
|
||||
initialBlackboardVariables,
|
||||
isExecuting,
|
||||
executionStatus,
|
||||
executionOrder,
|
||||
connections,
|
||||
nodes,
|
||||
executorRef,
|
||||
iconMap,
|
||||
draggingNodeId,
|
||||
onNodeClick,
|
||||
onContextMenu,
|
||||
onNodeMouseDown,
|
||||
onNodeMouseUpForConnection,
|
||||
onPortMouseDown,
|
||||
onPortMouseUp
|
||||
}) => {
|
||||
const isRoot = node.id === ROOT_NODE_ID;
|
||||
const isBlackboardVariable = node.data.nodeType === 'blackboard-variable';
|
||||
|
||||
const posX = node.position.x + (isBeingDragged ? dragDelta.dx : 0);
|
||||
const posY = node.position.y + (isBeingDragged ? dragDelta.dy : 0);
|
||||
|
||||
const isUncommitted = uncommittedNodeIds.has(node.id);
|
||||
const nodeClasses = [
|
||||
'bt-node',
|
||||
isSelected && 'selected',
|
||||
isRoot && 'root',
|
||||
isUncommitted && 'uncommitted',
|
||||
executionStatus && executionStatus !== 'idle' && executionStatus
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
data-node-id={node.id}
|
||||
className={nodeClasses}
|
||||
onClick={(e) => onNodeClick(e, node)}
|
||||
onContextMenu={(e) => onContextMenu(e, node)}
|
||||
onMouseDown={(e) => onNodeMouseDown(e, node.id)}
|
||||
onMouseUp={(e) => onNodeMouseUpForConnection(e, node.id)}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
style={{
|
||||
left: posX,
|
||||
top: posY,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
cursor: isRoot ? 'default' : (draggingNodeId === node.id ? 'grabbing' : 'grab'),
|
||||
transition: draggingNodeId === node.id ? 'none' : 'all 0.2s',
|
||||
zIndex: isRoot ? 50 : (draggingNodeId === node.id ? 100 : (isSelected ? 10 : 1))
|
||||
}}
|
||||
>
|
||||
{/* 执行顺序角标 - 使用绝对定位,不影响节点布局 */}
|
||||
{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 ? (
|
||||
(() => {
|
||||
const varName = node.data.variableName as string;
|
||||
const currentValue = blackboardVariables[varName];
|
||||
const initialValue = initialBlackboardVariables[varName];
|
||||
const isModified = isExecuting && JSON.stringify(currentValue) !== JSON.stringify(initialValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bt-node-header blackboard">
|
||||
<Database size={16} className="bt-node-header-icon" />
|
||||
<div className="bt-node-header-title">
|
||||
{varName || 'Variable'}
|
||||
</div>
|
||||
{isModified && (
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
color: '#ffbb00',
|
||||
backgroundColor: 'rgba(255, 187, 0, 0.2)',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '2px',
|
||||
marginLeft: '4px'
|
||||
}}>
|
||||
运行时
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bt-node-body">
|
||||
<div
|
||||
className="bt-node-blackboard-value"
|
||||
style={{
|
||||
backgroundColor: isModified ? 'rgba(255, 187, 0, 0.15)' : 'transparent',
|
||||
border: isModified ? '1px solid rgba(255, 187, 0, 0.3)' : 'none',
|
||||
borderRadius: '2px',
|
||||
padding: '2px 4px'
|
||||
}}
|
||||
title={isModified ? `初始值: ${JSON.stringify(initialValue)}\n当前值: ${JSON.stringify(currentValue)}` : undefined}
|
||||
>
|
||||
{JSON.stringify(currentValue)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-property="__value__"
|
||||
data-port-type="variable-output"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id, '__value__')}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id, '__value__')}
|
||||
className="bt-node-port bt-node-port-variable-output"
|
||||
title="Output"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<>
|
||||
<div className={`bt-node-header ${isRoot ? 'root' : (node.template.type || 'action')}`}>
|
||||
{isRoot ? (
|
||||
<TreePine size={16} className="bt-node-header-icon" />
|
||||
) : (
|
||||
node.template.icon && (() => {
|
||||
const IconComponent = iconMap[node.template.icon];
|
||||
return IconComponent ? (
|
||||
<IconComponent size={16} className="bt-node-header-icon" />
|
||||
) : (
|
||||
<span className="bt-node-header-icon">{node.template.icon}</span>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
<div className="bt-node-header-title">
|
||||
<div>{isRoot ? 'ROOT' : node.template.displayName}</div>
|
||||
<div className="bt-node-id" title={node.id}>
|
||||
#{node.id}
|
||||
</div>
|
||||
</div>
|
||||
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
|
||||
<div
|
||||
className="bt-node-missing-executor-warning"
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertCircle
|
||||
size={14}
|
||||
style={{
|
||||
color: '#f44336',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<div className="bt-node-missing-executor-tooltip">
|
||||
缺失执行器:找不到节点对应的执行器 "{node.template.className}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isUncommitted && (
|
||||
<div
|
||||
className="bt-node-uncommitted-warning"
|
||||
style={{
|
||||
marginLeft: (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) ? '4px' : 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
style={{
|
||||
color: '#ff5722',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<div className="bt-node-uncommitted-tooltip">
|
||||
未生效节点:运行时添加的节点,需重新运行才能生效
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isRoot && !isUncommitted && node.template.type === 'composite' &&
|
||||
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) &&
|
||||
!nodes.some((n) =>
|
||||
connections.some((c) => c.from === node.id && c.to === n.id)
|
||||
) && (
|
||||
<div
|
||||
className="bt-node-empty-warning-container"
|
||||
style={{
|
||||
marginLeft: ((!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) || isUncommitted) ? '4px' : 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
style={{
|
||||
color: '#ff9800',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<div className="bt-node-empty-warning-tooltip">
|
||||
空节点:没有子节点,执行时会直接跳过
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bt-node-body">
|
||||
{!isRoot && (
|
||||
<div className="bt-node-category">
|
||||
{node.template.category}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.template.properties.length > 0 && (
|
||||
<div className="bt-node-properties">
|
||||
{node.template.properties.map((prop: PropertyDefinition, idx: number) => {
|
||||
const hasConnection = connections.some(
|
||||
(conn: Connection) => conn.toProperty === prop.name && conn.to === node.id
|
||||
);
|
||||
const propValue = node.data[prop.name];
|
||||
|
||||
return (
|
||||
<div key={idx} className="bt-node-property">
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-property={prop.name}
|
||||
data-port-type="property-input"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id, prop.name)}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id, prop.name)}
|
||||
className={`bt-node-port bt-node-port-property ${hasConnection ? 'connected' : ''}`}
|
||||
title={prop.description || prop.name}
|
||||
/>
|
||||
<span
|
||||
className="bt-node-property-label"
|
||||
title={prop.description}
|
||||
>
|
||||
{prop.name}:
|
||||
</span>
|
||||
{propValue !== undefined && (
|
||||
<span className="bt-node-property-value">
|
||||
{String(propValue)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isRoot && (
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-port-type="node-input"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id)}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id)}
|
||||
className="bt-node-port bt-node-port-input"
|
||||
title="Input"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isRoot || node.template.type === 'composite' || node.template.type === 'decorator') &&
|
||||
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) && (
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-port-type="node-output"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id)}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id)}
|
||||
className="bt-node-port bt-node-port-output"
|
||||
title="Output"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用 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;
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { NodeViewData } from '../../types';
|
||||
|
||||
/**
|
||||
* 图标映射
|
||||
*/
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
TreePine: LucideIcons.TreePine,
|
||||
GitBranch: LucideIcons.GitBranch,
|
||||
Shuffle: LucideIcons.Shuffle,
|
||||
Repeat: LucideIcons.Repeat,
|
||||
RotateCcw: LucideIcons.RotateCcw,
|
||||
FlipHorizontal: LucideIcons.FlipHorizontal,
|
||||
CheckCircle: LucideIcons.CheckCircle,
|
||||
XCircle: LucideIcons.XCircle,
|
||||
Play: LucideIcons.Play,
|
||||
Pause: LucideIcons.Pause,
|
||||
Square: LucideIcons.Square,
|
||||
Circle: LucideIcons.Circle,
|
||||
Diamond: LucideIcons.Diamond,
|
||||
Box: LucideIcons.Box,
|
||||
Flag: LucideIcons.Flag,
|
||||
Target: LucideIcons.Target
|
||||
};
|
||||
|
||||
/**
|
||||
* 节点渲染器属性
|
||||
*/
|
||||
interface BehaviorTreeNodeRendererProps {
|
||||
/**
|
||||
* 节点视图数据
|
||||
*/
|
||||
nodeData: NodeViewData;
|
||||
|
||||
/**
|
||||
* 节点点击事件
|
||||
*/
|
||||
onClick?: (e: React.MouseEvent, nodeId: string) => void;
|
||||
|
||||
/**
|
||||
* 节点双击事件
|
||||
*/
|
||||
onDoubleClick?: (e: React.MouseEvent, nodeId: string) => void;
|
||||
|
||||
/**
|
||||
* 节点右键事件
|
||||
*/
|
||||
onContextMenu?: (e: React.MouseEvent, nodeId: string) => void;
|
||||
|
||||
/**
|
||||
* 鼠标按下事件
|
||||
*/
|
||||
onMouseDown?: (e: React.MouseEvent, nodeId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树节点渲染器
|
||||
* 负责单个节点的渲染
|
||||
*/
|
||||
export const BehaviorTreeNodeRenderer: React.FC<BehaviorTreeNodeRendererProps> = ({
|
||||
nodeData,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
onMouseDown
|
||||
}) => {
|
||||
const { node, isSelected, isDragging, executionStatus } = nodeData;
|
||||
const { template, position } = node;
|
||||
|
||||
const IconComponent = iconMap[template.icon || 'Box'] || LucideIcons.Box;
|
||||
|
||||
const nodeStyle = useMemo(() => {
|
||||
let borderColor = template.color || '#4a9eff';
|
||||
const backgroundColor = '#2a2a2a';
|
||||
let boxShadow = 'none';
|
||||
|
||||
if (isSelected) {
|
||||
boxShadow = `0 0 0 2px ${borderColor}`;
|
||||
}
|
||||
|
||||
if (executionStatus === 'running') {
|
||||
borderColor = '#ffa500';
|
||||
boxShadow = `0 0 10px ${borderColor}`;
|
||||
} else if (executionStatus === 'success') {
|
||||
borderColor = '#00ff00';
|
||||
} else if (executionStatus === 'failure') {
|
||||
borderColor = '#ff0000';
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
minWidth: '180px',
|
||||
padding: '12px',
|
||||
backgroundColor,
|
||||
borderRadius: '8px',
|
||||
border: `2px solid ${borderColor}`,
|
||||
boxShadow,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none' as const,
|
||||
transition: 'box-shadow 0.2s',
|
||||
opacity: isDragging ? 0.7 : 1
|
||||
};
|
||||
}, [template.color, position, isSelected, isDragging, executionStatus]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e, node.id);
|
||||
};
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDoubleClick?.(e, node.id);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onContextMenu?.(e, node.id);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onMouseDown?.(e, node.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="behavior-tree-node"
|
||||
style={nodeStyle}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseDown={handleMouseDown}
|
||||
data-node-id={node.id}
|
||||
>
|
||||
{/* 节点头部 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
<IconComponent size={20} color={template.color || '#4a9eff'} />
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
flex: 1
|
||||
}}>
|
||||
{template.displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 节点类型 */}
|
||||
{template.category && (
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#888888',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{template.category}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 节点描述 */}
|
||||
{template.description && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#cccccc',
|
||||
marginTop: '8px',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{template.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入连接点 */}
|
||||
<div
|
||||
className="node-input-pin"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '-6px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: template.color || '#4a9eff',
|
||||
border: '2px solid #1a1a1a',
|
||||
transform: 'translateY(-50%)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
data-pin-type="input"
|
||||
data-node-id={node.id}
|
||||
/>
|
||||
|
||||
{/* 输出连接点 */}
|
||||
<div
|
||||
className="node-output-pin"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '-6px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: template.color || '#4a9eff',
|
||||
border: '2px solid #1a1a1a',
|
||||
transform: 'translateY(-50%)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
data-pin-type="output"
|
||||
data-node-id={node.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { BehaviorTreeNodeRenderer } from './BehaviorTreeNodeRenderer';
|
||||
@@ -0,0 +1,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,45 @@
|
||||
.behavior-tree-properties-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.properties-panel-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.properties-panel-tabs .tab-button {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.properties-panel-tabs .tab-button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.properties-panel-tabs .tab-button.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.properties-panel-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
60
packages/behavior-tree-editor/src/config/editorConstants.ts
Normal file
60
packages/behavior-tree-editor/src/config/editorConstants.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NodeTemplate, NodeType } from '@esengine/behavior-tree';
|
||||
import {
|
||||
List, GitBranch, Layers, Shuffle, RotateCcw,
|
||||
Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer,
|
||||
Clock, FileText, Edit, Calculator, Code,
|
||||
Equal, Dices, Settings,
|
||||
Database, TreePine,
|
||||
LucideIcon
|
||||
} from 'lucide-react';
|
||||
|
||||
export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
List,
|
||||
GitBranch,
|
||||
Layers,
|
||||
Shuffle,
|
||||
RotateCcw,
|
||||
Repeat,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
CheckCheck,
|
||||
HelpCircle,
|
||||
Snowflake,
|
||||
Timer,
|
||||
Clock,
|
||||
FileText,
|
||||
Edit,
|
||||
Calculator,
|
||||
Code,
|
||||
Equal,
|
||||
Dices,
|
||||
Settings,
|
||||
Database,
|
||||
TreePine
|
||||
};
|
||||
|
||||
export const ROOT_NODE_TEMPLATE: NodeTemplate = {
|
||||
type: NodeType.Composite,
|
||||
displayName: '根节点',
|
||||
category: '根节点',
|
||||
icon: 'TreePine',
|
||||
description: '行为树根节点',
|
||||
color: '#FFD700',
|
||||
defaultConfig: {
|
||||
nodeType: 'root'
|
||||
},
|
||||
properties: []
|
||||
};
|
||||
|
||||
export const DEFAULT_EDITOR_CONFIG = {
|
||||
enableSnapping: false,
|
||||
gridSize: 20,
|
||||
minZoom: 0.1,
|
||||
maxZoom: 3,
|
||||
showGrid: true,
|
||||
showMinimap: false,
|
||||
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'
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Node } from '../models/Node';
|
||||
import { Position } from '../value-objects/Position';
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
|
||||
export const ROOT_NODE_ID = 'root-node';
|
||||
|
||||
export const createRootNodeTemplate = (): NodeTemplate => ({
|
||||
type: 'root',
|
||||
displayName: '根节点',
|
||||
category: '根节点',
|
||||
icon: 'TreePine',
|
||||
description: '行为树根节点',
|
||||
color: '#FFD700',
|
||||
maxChildren: 1,
|
||||
defaultConfig: {
|
||||
nodeType: 'root'
|
||||
},
|
||||
properties: []
|
||||
});
|
||||
|
||||
export const createRootNode = (): Node => {
|
||||
const template = createRootNodeTemplate();
|
||||
const position = new Position(400, 100);
|
||||
return new Node(ROOT_NODE_ID, template, { nodeType: 'root' }, position, []);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 领域错误基类
|
||||
*/
|
||||
export abstract class DomainError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DomainError } from './DomainError';
|
||||
|
||||
/**
|
||||
* 节点未找到错误
|
||||
*/
|
||||
export class NodeNotFoundError extends DomainError {
|
||||
constructor(public readonly nodeId: string) {
|
||||
super(`节点未找到: ${nodeId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { DomainError } from './DomainError';
|
||||
|
||||
/**
|
||||
* 验证错误
|
||||
* 当业务规则验证失败时抛出
|
||||
*/
|
||||
export class ValidationError extends DomainError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly field?: string,
|
||||
public readonly value?: unknown
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static rootNodeMaxChildren(): ValidationError {
|
||||
return new ValidationError(
|
||||
'根节点只能连接一个子节点',
|
||||
'children'
|
||||
);
|
||||
}
|
||||
|
||||
static decoratorNodeMaxChildren(): ValidationError {
|
||||
return new ValidationError(
|
||||
'装饰节点只能连接一个子节点',
|
||||
'children'
|
||||
);
|
||||
}
|
||||
|
||||
static leafNodeNoChildren(): ValidationError {
|
||||
return new ValidationError(
|
||||
'叶子节点不能有子节点',
|
||||
'children'
|
||||
);
|
||||
}
|
||||
|
||||
static circularReference(nodeId: string): ValidationError {
|
||||
return new ValidationError(
|
||||
`检测到循环引用,节点 ${nodeId} 不能连接到自己或其子节点`,
|
||||
'connection',
|
||||
nodeId
|
||||
);
|
||||
}
|
||||
|
||||
static invalidConnection(from: string, to: string, reason: string): ValidationError {
|
||||
return new ValidationError(
|
||||
`无效的连接:${reason}`,
|
||||
'connection',
|
||||
{ from, to }
|
||||
);
|
||||
}
|
||||
}
|
||||
3
packages/behavior-tree-editor/src/domain/errors/index.ts
Normal file
3
packages/behavior-tree-editor/src/domain/errors/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { DomainError } from './DomainError';
|
||||
export { ValidationError } from './ValidationError';
|
||||
export { NodeNotFoundError } from './NodeNotFoundError';
|
||||
5
packages/behavior-tree-editor/src/domain/index.ts
Normal file
5
packages/behavior-tree-editor/src/domain/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './models';
|
||||
export * from './value-objects';
|
||||
export * from './interfaces';
|
||||
export { DomainError, ValidationError as DomainValidationError, NodeNotFoundError } from './errors';
|
||||
export * from './services';
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { Node } from '../models/Node';
|
||||
import { Position } from '../value-objects';
|
||||
|
||||
/**
|
||||
* 节点工厂接口
|
||||
* 负责创建不同类型的节点
|
||||
*/
|
||||
export interface INodeFactory {
|
||||
/**
|
||||
* 创建节点
|
||||
*/
|
||||
createNode(
|
||||
template: NodeTemplate,
|
||||
position: Position,
|
||||
data?: Record<string, unknown>
|
||||
): Node;
|
||||
|
||||
/**
|
||||
* 根据模板类型创建节点
|
||||
*/
|
||||
createNodeByType(
|
||||
nodeType: string,
|
||||
position: Position,
|
||||
data?: Record<string, unknown>
|
||||
): Node;
|
||||
|
||||
/**
|
||||
* 克隆节点
|
||||
*/
|
||||
cloneNode(node: Node, newPosition?: Position): Node;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BehaviorTree } from '../models/BehaviorTree';
|
||||
|
||||
/**
|
||||
* 仓储接口
|
||||
* 负责行为树的持久化
|
||||
*/
|
||||
export interface IBehaviorTreeRepository {
|
||||
/**
|
||||
* 保存行为树
|
||||
*/
|
||||
save(tree: BehaviorTree, path: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 加载行为树
|
||||
*/
|
||||
load(path: string): Promise<BehaviorTree>;
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
exists(path: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 删除行为树文件
|
||||
*/
|
||||
delete(path: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BehaviorTree } from '../models/BehaviorTree';
|
||||
|
||||
/**
|
||||
* 序列化格式
|
||||
*/
|
||||
export type SerializationFormat = 'json' | 'binary';
|
||||
|
||||
/**
|
||||
* 序列化接口
|
||||
* 负责行为树的序列化和反序列化
|
||||
*/
|
||||
export interface ISerializer {
|
||||
/**
|
||||
* 序列化行为树
|
||||
*/
|
||||
serialize(tree: BehaviorTree, format: SerializationFormat): string | Uint8Array;
|
||||
|
||||
/**
|
||||
* 反序列化行为树
|
||||
*/
|
||||
deserialize(data: string | Uint8Array, format: SerializationFormat): BehaviorTree;
|
||||
|
||||
/**
|
||||
* 导出为运行时资产格式
|
||||
*/
|
||||
exportToRuntimeAsset(
|
||||
tree: BehaviorTree,
|
||||
format: SerializationFormat
|
||||
): string | Uint8Array;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BehaviorTree } from '../models/BehaviorTree';
|
||||
import { Node } from '../models/Node';
|
||||
import { Connection } from '../models/Connection';
|
||||
|
||||
/**
|
||||
* 验证结果
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证错误详情
|
||||
*/
|
||||
export interface ValidationError {
|
||||
message: string;
|
||||
nodeId?: string;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证器接口
|
||||
* 负责行为树的验证逻辑
|
||||
*/
|
||||
export interface IValidator {
|
||||
/**
|
||||
* 验证整个行为树
|
||||
*/
|
||||
validateTree(tree: BehaviorTree): ValidationResult;
|
||||
|
||||
/**
|
||||
* 验证节点
|
||||
*/
|
||||
validateNode(node: Node): ValidationResult;
|
||||
|
||||
/**
|
||||
* 验证连接
|
||||
*/
|
||||
validateConnection(connection: Connection, tree: BehaviorTree): ValidationResult;
|
||||
|
||||
/**
|
||||
* 验证是否会产生循环引用
|
||||
*/
|
||||
validateNoCycles(tree: BehaviorTree): ValidationResult;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { type INodeFactory } from './INodeFactory';
|
||||
export { type ISerializer, type SerializationFormat } from './ISerializer';
|
||||
export { type IBehaviorTreeRepository } from './IRepository';
|
||||
export { type IValidator, type ValidationResult, type ValidationError } from './IValidator';
|
||||
353
packages/behavior-tree-editor/src/domain/models/BehaviorTree.ts
Normal file
353
packages/behavior-tree-editor/src/domain/models/BehaviorTree.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { Node } from './Node';
|
||||
import { Connection } from './Connection';
|
||||
import { Blackboard } from './Blackboard';
|
||||
import { ValidationError, NodeNotFoundError } from '../errors';
|
||||
|
||||
/**
|
||||
* 行为树聚合根
|
||||
* 管理整个行为树的节点、连接和黑板
|
||||
*/
|
||||
export class BehaviorTree {
|
||||
private readonly _nodes: Map<string, Node>;
|
||||
private readonly _connections: Connection[];
|
||||
private readonly _blackboard: Blackboard;
|
||||
private readonly _rootNodeId: string | null;
|
||||
|
||||
constructor(
|
||||
nodes: Node[] = [],
|
||||
connections: Connection[] = [],
|
||||
blackboard: Blackboard = Blackboard.empty(),
|
||||
rootNodeId: string | null = null
|
||||
) {
|
||||
this._nodes = new Map(nodes.map((node) => [node.id, node]));
|
||||
this._connections = [...connections];
|
||||
this._blackboard = blackboard;
|
||||
this._rootNodeId = rootNodeId;
|
||||
|
||||
this.validateTree();
|
||||
}
|
||||
|
||||
get nodes(): ReadonlyArray<Node> {
|
||||
return Array.from(this._nodes.values());
|
||||
}
|
||||
|
||||
get connections(): ReadonlyArray<Connection> {
|
||||
return this._connections;
|
||||
}
|
||||
|
||||
get blackboard(): Blackboard {
|
||||
return this._blackboard;
|
||||
}
|
||||
|
||||
get rootNodeId(): string | null {
|
||||
return this._rootNodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定节点
|
||||
*/
|
||||
getNode(nodeId: string): Node {
|
||||
const node = this._nodes.get(nodeId);
|
||||
if (!node) {
|
||||
throw new NodeNotFoundError(nodeId);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查节点是否存在
|
||||
*/
|
||||
hasNode(nodeId: string): boolean {
|
||||
return this._nodes.has(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加节点
|
||||
*/
|
||||
addNode(node: Node): BehaviorTree {
|
||||
if (this._nodes.has(node.id)) {
|
||||
throw new ValidationError(`节点 ${node.id} 已存在`);
|
||||
}
|
||||
|
||||
if (node.isRoot()) {
|
||||
if (this._rootNodeId) {
|
||||
throw new ValidationError('行为树只能有一个根节点');
|
||||
}
|
||||
return new BehaviorTree(
|
||||
[...this.nodes, node],
|
||||
this._connections,
|
||||
this._blackboard,
|
||||
node.id
|
||||
);
|
||||
}
|
||||
|
||||
return new BehaviorTree(
|
||||
[...this.nodes, node],
|
||||
this._connections,
|
||||
this._blackboard,
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除节点
|
||||
* 会同时移除相关的连接
|
||||
*/
|
||||
removeNode(nodeId: string): BehaviorTree {
|
||||
if (!this._nodes.has(nodeId)) {
|
||||
throw new NodeNotFoundError(nodeId);
|
||||
}
|
||||
|
||||
const node = this.getNode(nodeId);
|
||||
const newNodes = Array.from(this.nodes.filter((n) => n.id !== nodeId));
|
||||
const newConnections = this._connections.filter(
|
||||
(conn) => conn.from !== nodeId && conn.to !== nodeId
|
||||
);
|
||||
|
||||
const newRootNodeId = node.isRoot() ? null : this._rootNodeId;
|
||||
|
||||
return new BehaviorTree(
|
||||
newNodes,
|
||||
newConnections,
|
||||
this._blackboard,
|
||||
newRootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点
|
||||
*/
|
||||
updateNode(nodeId: string, updater: (node: Node) => Node): BehaviorTree {
|
||||
const node = this.getNode(nodeId);
|
||||
const updatedNode = updater(node);
|
||||
|
||||
const newNodes = Array.from(this.nodes.map((n) => n.id === nodeId ? updatedNode : n));
|
||||
|
||||
return new BehaviorTree(
|
||||
newNodes,
|
||||
this._connections,
|
||||
this._blackboard,
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加连接
|
||||
* 会验证连接的合法性
|
||||
*/
|
||||
addConnection(connection: Connection): BehaviorTree {
|
||||
const fromNode = this.getNode(connection.from);
|
||||
const toNode = this.getNode(connection.to);
|
||||
|
||||
if (this.hasConnection(connection.from, connection.to)) {
|
||||
throw new ValidationError(`连接已存在:${connection.from} -> ${connection.to}`);
|
||||
}
|
||||
|
||||
if (this.wouldCreateCycle(connection.from, connection.to)) {
|
||||
throw ValidationError.circularReference(connection.to);
|
||||
}
|
||||
|
||||
if (connection.isNodeConnection()) {
|
||||
if (!fromNode.canAddChild()) {
|
||||
if (fromNode.isRoot()) {
|
||||
throw ValidationError.rootNodeMaxChildren();
|
||||
}
|
||||
if (fromNode.nodeType.isDecorator()) {
|
||||
throw ValidationError.decoratorNodeMaxChildren();
|
||||
}
|
||||
throw new ValidationError(`节点 ${connection.from} 无法添加更多子节点`);
|
||||
}
|
||||
|
||||
if (toNode.nodeType.getMaxChildren() === 0 && toNode.nodeType.isLeaf()) {
|
||||
}
|
||||
|
||||
const updatedFromNode = fromNode.addChild(connection.to);
|
||||
const newNodes = Array.from(this.nodes.map((n) =>
|
||||
n.id === connection.from ? updatedFromNode : n
|
||||
));
|
||||
|
||||
return new BehaviorTree(
|
||||
newNodes,
|
||||
[...this._connections, connection],
|
||||
this._blackboard,
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
return new BehaviorTree(
|
||||
Array.from(this.nodes),
|
||||
[...this._connections, connection],
|
||||
this._blackboard,
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除连接
|
||||
*/
|
||||
removeConnection(from: string, to: string, fromProperty?: string, toProperty?: string): BehaviorTree {
|
||||
const connection = this._connections.find((c) => c.matches(from, to, fromProperty, toProperty));
|
||||
|
||||
if (!connection) {
|
||||
throw new ValidationError(`连接不存在:${from} -> ${to}`);
|
||||
}
|
||||
|
||||
const newConnections = this._connections.filter((c) => !c.matches(from, to, fromProperty, toProperty));
|
||||
|
||||
if (connection.isNodeConnection()) {
|
||||
const fromNode = this.getNode(from);
|
||||
const updatedFromNode = fromNode.removeChild(to);
|
||||
const newNodes = Array.from(this.nodes.map((n) =>
|
||||
n.id === from ? updatedFromNode : n
|
||||
));
|
||||
|
||||
return new BehaviorTree(
|
||||
newNodes,
|
||||
newConnections,
|
||||
this._blackboard,
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
return new BehaviorTree(
|
||||
Array.from(this.nodes),
|
||||
newConnections,
|
||||
this._blackboard,
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否存在连接
|
||||
*/
|
||||
hasConnection(from: string, to: string): boolean {
|
||||
return this._connections.some((c) => c.from === from && c.to === to);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否会创建循环引用
|
||||
*/
|
||||
private wouldCreateCycle(from: string, to: string): boolean {
|
||||
const visited = new Set<string>();
|
||||
const queue: string[] = [to];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
|
||||
if (current === from) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (visited.has(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(current);
|
||||
|
||||
const childConnections = this._connections.filter((c) => c.from === current && c.isNodeConnection());
|
||||
childConnections.forEach((conn) => queue.push(conn.to));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新黑板
|
||||
*/
|
||||
updateBlackboard(updater: (blackboard: Blackboard) => Blackboard): BehaviorTree {
|
||||
return new BehaviorTree(
|
||||
Array.from(this.nodes),
|
||||
this._connections,
|
||||
updater(this._blackboard),
|
||||
this._rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的子节点
|
||||
*/
|
||||
getChildren(nodeId: string): Node[] {
|
||||
const node = this.getNode(nodeId);
|
||||
return node.children.map((childId) => this.getNode(childId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的父节点
|
||||
*/
|
||||
getParent(nodeId: string): Node | null {
|
||||
const parentConnection = this._connections.find(
|
||||
(c) => c.to === nodeId && c.isNodeConnection()
|
||||
);
|
||||
|
||||
if (!parentConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getNode(parentConnection.from);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证树的完整性
|
||||
*/
|
||||
private validateTree(): void {
|
||||
const rootNodes = this.nodes.filter((n) => n.isRoot());
|
||||
|
||||
if (rootNodes.length > 1) {
|
||||
throw new ValidationError('行为树只能有一个根节点');
|
||||
}
|
||||
|
||||
if (rootNodes.length === 1 && rootNodes[0] && this._rootNodeId !== rootNodes[0].id) {
|
||||
throw new ValidationError('根节点ID不匹配');
|
||||
}
|
||||
|
||||
this._connections.forEach((conn) => {
|
||||
if (!this._nodes.has(conn.from)) {
|
||||
throw new NodeNotFoundError(conn.from);
|
||||
}
|
||||
if (!this._nodes.has(conn.to)) {
|
||||
throw new NodeNotFoundError(conn.to);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为普通对象
|
||||
*/
|
||||
toObject(): {
|
||||
nodes: ReturnType<Node['toObject']>[];
|
||||
connections: ReturnType<Connection['toObject']>[];
|
||||
blackboard: Record<string, unknown>;
|
||||
rootNodeId: string | null;
|
||||
} {
|
||||
return {
|
||||
nodes: this.nodes.map((n) => n.toObject()),
|
||||
connections: this._connections.map((c) => c.toObject()),
|
||||
blackboard: this._blackboard.toObject(),
|
||||
rootNodeId: this._rootNodeId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从普通对象创建行为树
|
||||
*/
|
||||
static fromObject(obj: {
|
||||
nodes: Parameters<typeof Node.fromObject>[0][];
|
||||
connections: Parameters<typeof Connection.fromObject>[0][];
|
||||
blackboard: Record<string, unknown>;
|
||||
rootNodeId: string | null;
|
||||
}): BehaviorTree {
|
||||
return new BehaviorTree(
|
||||
obj.nodes.map((n) => Node.fromObject(n)),
|
||||
obj.connections.map((c) => Connection.fromObject(c)),
|
||||
Blackboard.fromObject(obj.blackboard),
|
||||
obj.rootNodeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空行为树
|
||||
*/
|
||||
static empty(): BehaviorTree {
|
||||
return new BehaviorTree();
|
||||
}
|
||||
}
|
||||
122
packages/behavior-tree-editor/src/domain/models/Blackboard.ts
Normal file
122
packages/behavior-tree-editor/src/domain/models/Blackboard.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 黑板值类型
|
||||
*/
|
||||
export type BlackboardValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[];
|
||||
|
||||
/**
|
||||
* 黑板领域实体
|
||||
* 管理行为树的全局变量
|
||||
*/
|
||||
export class Blackboard {
|
||||
private _variables: Map<string, BlackboardValue>;
|
||||
|
||||
constructor(variables: Record<string, BlackboardValue> = {}) {
|
||||
this._variables = new Map(Object.entries(variables));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取变量值
|
||||
*/
|
||||
get(key: string): BlackboardValue {
|
||||
return this._variables.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置变量值
|
||||
*/
|
||||
set(key: string, value: BlackboardValue): Blackboard {
|
||||
const newVariables = new Map(this._variables);
|
||||
newVariables.set(key, value);
|
||||
return new Blackboard(Object.fromEntries(newVariables));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置变量值(别名方法)
|
||||
*/
|
||||
setValue(key: string, value: BlackboardValue): void {
|
||||
this._variables.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除变量
|
||||
*/
|
||||
delete(key: string): Blackboard {
|
||||
const newVariables = new Map(this._variables);
|
||||
newVariables.delete(key);
|
||||
return new Blackboard(Object.fromEntries(newVariables));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查变量是否存在
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
return this._variables.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有变量名
|
||||
*/
|
||||
keys(): string[] {
|
||||
return Array.from(this._variables.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有变量
|
||||
*/
|
||||
getAll(): Record<string, BlackboardValue> {
|
||||
return Object.fromEntries(this._variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置变量
|
||||
*/
|
||||
setAll(variables: Record<string, BlackboardValue>): Blackboard {
|
||||
const newVariables = new Map(this._variables);
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
newVariables.set(key, value);
|
||||
});
|
||||
return new Blackboard(Object.fromEntries(newVariables));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有变量
|
||||
*/
|
||||
clear(): Blackboard {
|
||||
return new Blackboard();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取变量数量
|
||||
*/
|
||||
size(): number {
|
||||
return this._variables.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆黑板
|
||||
*/
|
||||
clone(): Blackboard {
|
||||
return new Blackboard(this.getAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为普通对象
|
||||
*/
|
||||
toObject(): Record<string, BlackboardValue> {
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从普通对象创建黑板
|
||||
*/
|
||||
static fromObject(obj: Record<string, unknown>): Blackboard {
|
||||
return new Blackboard(obj as Record<string, BlackboardValue>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空黑板
|
||||
*/
|
||||
static empty(): Blackboard {
|
||||
return new Blackboard();
|
||||
}
|
||||
}
|
||||
140
packages/behavior-tree-editor/src/domain/models/Connection.ts
Normal file
140
packages/behavior-tree-editor/src/domain/models/Connection.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { ValidationError } from '../errors';
|
||||
|
||||
/**
|
||||
* 连接类型
|
||||
*/
|
||||
export type ConnectionType = 'node' | 'property';
|
||||
|
||||
/**
|
||||
* 连接领域实体
|
||||
* 表示两个节点之间的连接关系
|
||||
*/
|
||||
export class Connection {
|
||||
private readonly _from: string;
|
||||
private readonly _to: string;
|
||||
private readonly _fromProperty?: string;
|
||||
private readonly _toProperty?: string;
|
||||
private readonly _connectionType: ConnectionType;
|
||||
|
||||
constructor(
|
||||
from: string,
|
||||
to: string,
|
||||
connectionType: ConnectionType = 'node',
|
||||
fromProperty?: string,
|
||||
toProperty?: string
|
||||
) {
|
||||
if (from === to) {
|
||||
throw ValidationError.circularReference(from);
|
||||
}
|
||||
|
||||
if (connectionType === 'property' && (!fromProperty || !toProperty)) {
|
||||
throw new ValidationError('属性连接必须指定源属性和目标属性');
|
||||
}
|
||||
|
||||
this._from = from;
|
||||
this._to = to;
|
||||
this._connectionType = connectionType;
|
||||
this._fromProperty = fromProperty;
|
||||
this._toProperty = toProperty;
|
||||
}
|
||||
|
||||
get from(): string {
|
||||
return this._from;
|
||||
}
|
||||
|
||||
get to(): string {
|
||||
return this._to;
|
||||
}
|
||||
|
||||
get fromProperty(): string | undefined {
|
||||
return this._fromProperty;
|
||||
}
|
||||
|
||||
get toProperty(): string | undefined {
|
||||
return this._toProperty;
|
||||
}
|
||||
|
||||
get connectionType(): ConnectionType {
|
||||
return this._connectionType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为节点连接
|
||||
*/
|
||||
isNodeConnection(): boolean {
|
||||
return this._connectionType === 'node';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为属性连接
|
||||
*/
|
||||
isPropertyConnection(): boolean {
|
||||
return this._connectionType === 'property';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接是否匹配指定的条件
|
||||
*/
|
||||
matches(from: string, to: string, fromProperty?: string, toProperty?: string): boolean {
|
||||
if (this._from !== from || this._to !== to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._connectionType === 'property') {
|
||||
return this._fromProperty === fromProperty && this._toProperty === toProperty;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 相等性比较
|
||||
*/
|
||||
equals(other: Connection): boolean {
|
||||
return (
|
||||
this._from === other._from &&
|
||||
this._to === other._to &&
|
||||
this._connectionType === other._connectionType &&
|
||||
this._fromProperty === other._fromProperty &&
|
||||
this._toProperty === other._toProperty
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为普通对象
|
||||
*/
|
||||
toObject(): {
|
||||
from: string;
|
||||
to: string;
|
||||
fromProperty?: string;
|
||||
toProperty?: string;
|
||||
connectionType: ConnectionType;
|
||||
} {
|
||||
return {
|
||||
from: this._from,
|
||||
to: this._to,
|
||||
connectionType: this._connectionType,
|
||||
...(this._fromProperty && { fromProperty: this._fromProperty }),
|
||||
...(this._toProperty && { toProperty: this._toProperty })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从普通对象创建连接
|
||||
*/
|
||||
static fromObject(obj: {
|
||||
from: string;
|
||||
to: string;
|
||||
fromProperty?: string;
|
||||
toProperty?: string;
|
||||
connectionType: ConnectionType;
|
||||
}): Connection {
|
||||
return new Connection(
|
||||
obj.from,
|
||||
obj.to,
|
||||
obj.connectionType,
|
||||
obj.fromProperty,
|
||||
obj.toProperty
|
||||
);
|
||||
}
|
||||
}
|
||||
190
packages/behavior-tree-editor/src/domain/models/Node.ts
Normal file
190
packages/behavior-tree-editor/src/domain/models/Node.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { Position, NodeType } from '../value-objects';
|
||||
import { ValidationError } from '../errors';
|
||||
|
||||
/**
|
||||
* 行为树节点领域实体
|
||||
* 封装节点的业务逻辑和验证规则
|
||||
*/
|
||||
export class Node {
|
||||
private readonly _id: string;
|
||||
private readonly _template: NodeTemplate;
|
||||
private _data: Record<string, unknown>;
|
||||
private _position: Position;
|
||||
private _children: string[];
|
||||
private readonly _nodeType: NodeType;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
template: NodeTemplate,
|
||||
data: Record<string, unknown>,
|
||||
position: Position,
|
||||
children: string[] = []
|
||||
) {
|
||||
this._id = id;
|
||||
this._template = template;
|
||||
this._data = { ...data };
|
||||
this._position = position;
|
||||
this._children = [...children];
|
||||
this._nodeType = NodeType.fromString(template.type);
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get template(): NodeTemplate {
|
||||
return this._template;
|
||||
}
|
||||
|
||||
get data(): Record<string, unknown> {
|
||||
return { ...this._data };
|
||||
}
|
||||
|
||||
get position(): Position {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
get children(): ReadonlyArray<string> {
|
||||
return this._children;
|
||||
}
|
||||
|
||||
get nodeType(): NodeType {
|
||||
return this._nodeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点位置
|
||||
*/
|
||||
moveToPosition(newPosition: Position): Node {
|
||||
return new Node(
|
||||
this._id,
|
||||
this._template,
|
||||
this._data,
|
||||
newPosition,
|
||||
this._children
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点数据
|
||||
*/
|
||||
updateData(data: Record<string, unknown>): Node {
|
||||
return new Node(
|
||||
this._id,
|
||||
this._template,
|
||||
{ ...this._data, ...data },
|
||||
this._position,
|
||||
this._children
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加子节点
|
||||
* @throws ValidationError 如果违反业务规则
|
||||
*/
|
||||
addChild(childId: string): Node {
|
||||
// 使用模板定义的约束,undefined 表示无限制
|
||||
const maxChildren = (this._template.maxChildren ?? Infinity) as number;
|
||||
|
||||
if (maxChildren === 0) {
|
||||
throw ValidationError.leafNodeNoChildren();
|
||||
}
|
||||
|
||||
if (this._children.length >= maxChildren) {
|
||||
if (this._nodeType.isRoot()) {
|
||||
throw ValidationError.rootNodeMaxChildren();
|
||||
}
|
||||
if (this._nodeType.isDecorator()) {
|
||||
throw ValidationError.decoratorNodeMaxChildren();
|
||||
}
|
||||
throw new ValidationError(`节点 ${this._id} 已达到最大子节点数 ${maxChildren}`);
|
||||
}
|
||||
|
||||
if (this._children.includes(childId)) {
|
||||
throw new ValidationError(`子节点 ${childId} 已存在`);
|
||||
}
|
||||
|
||||
return new Node(
|
||||
this._id,
|
||||
this._template,
|
||||
this._data,
|
||||
this._position,
|
||||
[...this._children, childId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除子节点
|
||||
*/
|
||||
removeChild(childId: string): Node {
|
||||
return new Node(
|
||||
this._id,
|
||||
this._template,
|
||||
this._data,
|
||||
this._position,
|
||||
this._children.filter((id) => id !== childId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以添加子节点
|
||||
*/
|
||||
canAddChild(): boolean {
|
||||
// 使用模板定义的最大子节点数,undefined 表示无限制
|
||||
const maxChildren = (this._template.maxChildren ?? Infinity) as number;
|
||||
return this._children.length < maxChildren;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有子节点
|
||||
*/
|
||||
hasChildren(): boolean {
|
||||
return this._children.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为根节点
|
||||
*/
|
||||
isRoot(): boolean {
|
||||
return this._nodeType.isRoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为普通对象(用于序列化)
|
||||
*/
|
||||
toObject(): {
|
||||
id: string;
|
||||
template: NodeTemplate;
|
||||
data: Record<string, unknown>;
|
||||
position: { x: number; y: number };
|
||||
children: string[];
|
||||
} {
|
||||
return {
|
||||
id: this._id,
|
||||
template: this._template,
|
||||
data: this._data,
|
||||
position: this._position.toObject(),
|
||||
children: [...this._children]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从普通对象创建节点
|
||||
*/
|
||||
static fromObject(obj: {
|
||||
id: string;
|
||||
template: NodeTemplate;
|
||||
data: Record<string, unknown>;
|
||||
position: { x: number; y: number };
|
||||
children: string[];
|
||||
}): Node {
|
||||
return new Node(
|
||||
obj.id,
|
||||
obj.template,
|
||||
obj.data,
|
||||
Position.fromObject(obj.position),
|
||||
obj.children
|
||||
);
|
||||
}
|
||||
}
|
||||
4
packages/behavior-tree-editor/src/domain/models/index.ts
Normal file
4
packages/behavior-tree-editor/src/domain/models/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { Node } from './Node';
|
||||
export { Connection, type ConnectionType } from './Connection';
|
||||
export { Blackboard, type BlackboardValue } from './Blackboard';
|
||||
export { BehaviorTree } from './BehaviorTree';
|
||||
@@ -0,0 +1,198 @@
|
||||
import { BehaviorTree } from '../models/BehaviorTree';
|
||||
import { Node } from '../models/Node';
|
||||
import { Connection } from '../models/Connection';
|
||||
import { IValidator, ValidationResult, ValidationError as IValidationError } from '../interfaces/IValidator';
|
||||
|
||||
/**
|
||||
* 行为树验证服务
|
||||
* 实现所有业务验证规则
|
||||
*/
|
||||
export class TreeValidator implements IValidator {
|
||||
/**
|
||||
* 验证整个行为树
|
||||
*/
|
||||
validateTree(tree: BehaviorTree): ValidationResult {
|
||||
const errors: IValidationError[] = [];
|
||||
|
||||
if (!tree.rootNodeId) {
|
||||
errors.push({
|
||||
message: '行为树必须有一个根节点'
|
||||
});
|
||||
}
|
||||
|
||||
const rootNodes = tree.nodes.filter((n) => n.isRoot());
|
||||
if (rootNodes.length > 1) {
|
||||
errors.push({
|
||||
message: '行为树只能有一个根节点',
|
||||
nodeId: rootNodes.map((n) => n.id).join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
tree.nodes.forEach((node) => {
|
||||
const nodeValidation = this.validateNode(node);
|
||||
errors.push(...nodeValidation.errors);
|
||||
});
|
||||
|
||||
tree.connections.forEach((connection) => {
|
||||
const connValidation = this.validateConnection(connection, tree);
|
||||
errors.push(...connValidation.errors);
|
||||
});
|
||||
|
||||
const cycleValidation = this.validateNoCycles(tree);
|
||||
errors.push(...cycleValidation.errors);
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证节点
|
||||
*/
|
||||
validateNode(node: Node): ValidationResult {
|
||||
const errors: IValidationError[] = [];
|
||||
|
||||
// 使用模板定义的约束,undefined 表示无限制
|
||||
const maxChildren = (node.template.maxChildren ?? Infinity) as number;
|
||||
const actualChildren = node.children.length;
|
||||
|
||||
if (actualChildren > maxChildren) {
|
||||
if (node.isRoot()) {
|
||||
errors.push({
|
||||
message: '根节点只能连接一个子节点',
|
||||
nodeId: node.id,
|
||||
field: 'children'
|
||||
});
|
||||
} else if (node.nodeType.isDecorator()) {
|
||||
errors.push({
|
||||
message: '装饰节点只能连接一个子节点',
|
||||
nodeId: node.id,
|
||||
field: 'children'
|
||||
});
|
||||
} else if (node.nodeType.isLeaf()) {
|
||||
errors.push({
|
||||
message: '叶子节点不能有子节点',
|
||||
nodeId: node.id,
|
||||
field: 'children'
|
||||
});
|
||||
} else {
|
||||
errors.push({
|
||||
message: `节点子节点数量 (${actualChildren}) 超过最大限制 (${maxChildren})`,
|
||||
nodeId: node.id,
|
||||
field: 'children'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证连接
|
||||
*/
|
||||
validateConnection(connection: Connection, tree: BehaviorTree): ValidationResult {
|
||||
const errors: IValidationError[] = [];
|
||||
|
||||
if (!tree.hasNode(connection.from)) {
|
||||
errors.push({
|
||||
message: `源节点不存在: ${connection.from}`,
|
||||
nodeId: connection.from
|
||||
});
|
||||
}
|
||||
|
||||
if (!tree.hasNode(connection.to)) {
|
||||
errors.push({
|
||||
message: `目标节点不存在: ${connection.to}`,
|
||||
nodeId: connection.to
|
||||
});
|
||||
}
|
||||
|
||||
if (connection.from === connection.to) {
|
||||
errors.push({
|
||||
message: '节点不能连接到自己',
|
||||
nodeId: connection.from
|
||||
});
|
||||
}
|
||||
|
||||
if (tree.hasNode(connection.from) && tree.hasNode(connection.to)) {
|
||||
const fromNode = tree.getNode(connection.from);
|
||||
const toNode = tree.getNode(connection.to);
|
||||
|
||||
if (connection.isNodeConnection()) {
|
||||
if (!fromNode.canAddChild()) {
|
||||
errors.push({
|
||||
message: `节点 ${connection.from} 无法添加更多子节点`,
|
||||
nodeId: connection.from
|
||||
});
|
||||
}
|
||||
|
||||
if (toNode.nodeType.isLeaf() && toNode.hasChildren()) {
|
||||
errors.push({
|
||||
message: `叶子节点 ${connection.to} 不能有子节点`,
|
||||
nodeId: connection.to
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证是否存在循环引用
|
||||
*/
|
||||
validateNoCycles(tree: BehaviorTree): ValidationResult {
|
||||
const errors: IValidationError[] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
|
||||
const dfs = (nodeId: string): boolean => {
|
||||
if (recursionStack.has(nodeId)) {
|
||||
errors.push({
|
||||
message: `检测到循环引用: 节点 ${nodeId}`,
|
||||
nodeId
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (visited.has(nodeId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
visited.add(nodeId);
|
||||
recursionStack.add(nodeId);
|
||||
|
||||
const node = tree.getNode(nodeId);
|
||||
for (const childId of node.children) {
|
||||
if (dfs(childId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(nodeId);
|
||||
return false;
|
||||
};
|
||||
|
||||
if (tree.rootNodeId) {
|
||||
dfs(tree.rootNodeId);
|
||||
}
|
||||
|
||||
tree.nodes.forEach((node) => {
|
||||
if (!visited.has(node.id) && !node.isRoot()) {
|
||||
dfs(node.id);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TreeValidator } from './TreeValidator';
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 节点类型值对象
|
||||
* 封装节点类型的业务逻辑
|
||||
*/
|
||||
export class NodeType {
|
||||
private readonly _value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为根节点
|
||||
*/
|
||||
isRoot(): boolean {
|
||||
return this._value === 'root';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为组合节点(可以有多个子节点)
|
||||
*/
|
||||
isComposite(): boolean {
|
||||
return this._value === 'composite' ||
|
||||
['sequence', 'selector', 'parallel'].includes(this._value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为装饰节点(只能有一个子节点)
|
||||
*/
|
||||
isDecorator(): boolean {
|
||||
return this._value === 'decorator' ||
|
||||
['repeater', 'inverter', 'succeeder', 'failer', 'until-fail', 'until-success'].includes(this._value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为叶子节点(不能有子节点)
|
||||
*/
|
||||
isLeaf(): boolean {
|
||||
return this._value === 'action' || this._value === 'condition' ||
|
||||
this._value.includes('action-') || this._value.includes('condition-');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取允许的最大子节点数
|
||||
* @returns 0 表示叶子节点,1 表示装饰节点,Infinity 表示组合节点
|
||||
*/
|
||||
getMaxChildren(): number {
|
||||
if (this.isLeaf()) {
|
||||
return 0;
|
||||
}
|
||||
if (this.isRoot() || this.isDecorator()) {
|
||||
return 1;
|
||||
}
|
||||
if (this.isComposite()) {
|
||||
return Infinity;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 值对象相等性比较
|
||||
*/
|
||||
equals(other: NodeType): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预定义的节点类型
|
||||
*/
|
||||
static readonly ROOT = new NodeType('root');
|
||||
static readonly SEQUENCE = new NodeType('sequence');
|
||||
static readonly SELECTOR = new NodeType('selector');
|
||||
static readonly PARALLEL = new NodeType('parallel');
|
||||
static readonly REPEATER = new NodeType('repeater');
|
||||
static readonly INVERTER = new NodeType('inverter');
|
||||
static readonly SUCCEEDER = new NodeType('succeeder');
|
||||
static readonly FAILER = new NodeType('failer');
|
||||
static readonly UNTIL_FAIL = new NodeType('until-fail');
|
||||
static readonly UNTIL_SUCCESS = new NodeType('until-success');
|
||||
|
||||
/**
|
||||
* 从字符串创建节点类型
|
||||
*/
|
||||
static fromString(value: string): NodeType {
|
||||
switch (value) {
|
||||
case 'root': return NodeType.ROOT;
|
||||
case 'sequence': return NodeType.SEQUENCE;
|
||||
case 'selector': return NodeType.SELECTOR;
|
||||
case 'parallel': return NodeType.PARALLEL;
|
||||
case 'repeater': return NodeType.REPEATER;
|
||||
case 'inverter': return NodeType.INVERTER;
|
||||
case 'succeeder': return NodeType.SUCCEEDER;
|
||||
case 'failer': return NodeType.FAILER;
|
||||
case 'until-fail': return NodeType.UNTIL_FAIL;
|
||||
case 'until-success': return NodeType.UNTIL_SUCCESS;
|
||||
default: return new NodeType(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 位置值对象
|
||||
* 表示二维空间中的坐标点
|
||||
*/
|
||||
export class Position {
|
||||
private readonly _x: number;
|
||||
private readonly _y: number;
|
||||
|
||||
constructor(x: number, y: number) {
|
||||
this._x = x;
|
||||
this._y = y;
|
||||
}
|
||||
|
||||
get x(): number {
|
||||
return this._x;
|
||||
}
|
||||
|
||||
get y(): number {
|
||||
return this._y;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的位置,加上偏移量
|
||||
*/
|
||||
add(offset: Position): Position {
|
||||
return new Position(this._x + offset._x, this._y + offset._y);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的位置,减去偏移量
|
||||
*/
|
||||
subtract(other: Position): Position {
|
||||
return new Position(this._x - other._x, this._y - other._y);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算到另一个位置的距离
|
||||
*/
|
||||
distanceTo(other: Position): number {
|
||||
const dx = this._x - other._x;
|
||||
const dy = this._y - other._y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 值对象相等性比较
|
||||
*/
|
||||
equals(other: Position): boolean {
|
||||
return this._x === other._x && this._y === other._y;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为普通对象
|
||||
*/
|
||||
toObject(): { x: number; y: number } {
|
||||
return { x: this._x, y: this._y };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从普通对象创建
|
||||
*/
|
||||
static fromObject(obj: { x: number; y: number }): Position {
|
||||
return new Position(obj.x, obj.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建零位置
|
||||
*/
|
||||
static zero(): Position {
|
||||
return new Position(0, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 尺寸值对象
|
||||
* 表示宽度和高度
|
||||
*/
|
||||
export class Size {
|
||||
private readonly _width: number;
|
||||
private readonly _height: number;
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
if (width < 0 || height < 0) {
|
||||
throw new Error('Size dimensions must be non-negative');
|
||||
}
|
||||
this._width = width;
|
||||
this._height = height;
|
||||
}
|
||||
|
||||
get width(): number {
|
||||
return this._width;
|
||||
}
|
||||
|
||||
get height(): number {
|
||||
return this._height;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取面积
|
||||
*/
|
||||
get area(): number {
|
||||
return this._width * this._height;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩放尺寸
|
||||
*/
|
||||
scale(factor: number): Size {
|
||||
return new Size(this._width * factor, this._height * factor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 值对象相等性比较
|
||||
*/
|
||||
equals(other: Size): boolean {
|
||||
return this._width === other._width && this._height === other._height;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为普通对象
|
||||
*/
|
||||
toObject(): { width: number; height: number } {
|
||||
return { width: this._width, height: this._height };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从普通对象创建
|
||||
*/
|
||||
static fromObject(obj: { width: number; height: number }): Size {
|
||||
return new Size(obj.width, obj.height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Position } from './Position';
|
||||
export { Size } from './Size';
|
||||
export { NodeType } from './NodeType';
|
||||
@@ -0,0 +1,501 @@
|
||||
import { GlobalBlackboardConfig, BlackboardValueType } from '@esengine/behavior-tree';
|
||||
|
||||
/**
|
||||
* 类型生成配置选项
|
||||
*/
|
||||
export interface TypeGenerationOptions {
|
||||
/** 常量名称大小写风格 */
|
||||
constantCase?: 'UPPER_SNAKE' | 'camelCase' | 'PascalCase';
|
||||
|
||||
/** 常量对象名称 */
|
||||
constantsName?: string;
|
||||
|
||||
/** 接口名称 */
|
||||
interfaceName?: string;
|
||||
|
||||
/** 类型别名名称 */
|
||||
typeAliasName?: string;
|
||||
|
||||
/** 包装类名称 */
|
||||
wrapperClassName?: string;
|
||||
|
||||
/** 默认值对象名称 */
|
||||
defaultsName?: string;
|
||||
|
||||
/** 导入路径 */
|
||||
importPath?: string;
|
||||
|
||||
/** 是否生成常量对象 */
|
||||
includeConstants?: boolean;
|
||||
|
||||
/** 是否生成接口 */
|
||||
includeInterface?: boolean;
|
||||
|
||||
/** 是否生成类型别名 */
|
||||
includeTypeAlias?: boolean;
|
||||
|
||||
/** 是否生成包装类 */
|
||||
includeWrapperClass?: boolean;
|
||||
|
||||
/** 是否生成默认值 */
|
||||
includeDefaults?: boolean;
|
||||
|
||||
/** 自定义头部注释 */
|
||||
customHeader?: string;
|
||||
|
||||
/** 使用单引号还是双引号 */
|
||||
quoteStyle?: 'single' | 'double';
|
||||
|
||||
/** 是否在文件末尾添加换行 */
|
||||
trailingNewline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局黑板 TypeScript 类型生成器
|
||||
*
|
||||
* 将全局黑板配置导出为 TypeScript 类型定义,提供:
|
||||
* - 编译时类型检查
|
||||
* - IDE 自动补全
|
||||
* - 避免拼写错误
|
||||
* - 重构友好
|
||||
*/
|
||||
export class GlobalBlackboardTypeGenerator {
|
||||
/**
|
||||
* 默认生成选项
|
||||
*/
|
||||
static readonly DEFAULT_OPTIONS: Required<TypeGenerationOptions> = {
|
||||
constantCase: 'UPPER_SNAKE',
|
||||
constantsName: 'GlobalVars',
|
||||
interfaceName: 'GlobalBlackboardTypes',
|
||||
typeAliasName: 'GlobalVariableName',
|
||||
wrapperClassName: 'TypedGlobalBlackboard',
|
||||
defaultsName: 'GlobalBlackboardDefaults',
|
||||
importPath: '@esengine/behavior-tree',
|
||||
includeConstants: true,
|
||||
includeInterface: true,
|
||||
includeTypeAlias: true,
|
||||
includeWrapperClass: true,
|
||||
includeDefaults: true,
|
||||
customHeader: '',
|
||||
quoteStyle: 'single',
|
||||
trailingNewline: true
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成 TypeScript 类型定义代码
|
||||
*
|
||||
* @param config 全局黑板配置
|
||||
* @param options 生成选项
|
||||
* @returns TypeScript 代码字符串
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 使用默认选项
|
||||
* const code = GlobalBlackboardTypeGenerator.generate(config);
|
||||
*
|
||||
* // 自定义命名
|
||||
* const code = GlobalBlackboardTypeGenerator.generate(config, {
|
||||
* constantsName: 'GameVars',
|
||||
* wrapperClassName: 'GameBlackboard'
|
||||
* });
|
||||
*
|
||||
* // 只生成接口和类型别名,不生成包装类
|
||||
* const code = GlobalBlackboardTypeGenerator.generate(config, {
|
||||
* includeWrapperClass: false,
|
||||
* includeDefaults: false
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static generate(config: GlobalBlackboardConfig, options?: TypeGenerationOptions): string {
|
||||
const opts = { ...this.DEFAULT_OPTIONS, ...options };
|
||||
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
||||
const variables = config.variables || [];
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// 生成文件头部注释
|
||||
parts.push(this.generateHeader(now, opts));
|
||||
|
||||
// 根据配置生成各个部分
|
||||
if (opts.includeConstants) {
|
||||
parts.push(this.generateConstants(variables, opts));
|
||||
}
|
||||
|
||||
if (opts.includeInterface) {
|
||||
parts.push(this.generateInterface(variables, opts));
|
||||
}
|
||||
|
||||
if (opts.includeTypeAlias) {
|
||||
parts.push(this.generateTypeAliases(opts));
|
||||
}
|
||||
|
||||
if (opts.includeWrapperClass) {
|
||||
parts.push(this.generateTypedClass(opts));
|
||||
}
|
||||
|
||||
if (opts.includeDefaults) {
|
||||
parts.push(this.generateDefaults(variables, opts));
|
||||
}
|
||||
|
||||
// 组合所有部分
|
||||
let code = parts.join('\n\n');
|
||||
|
||||
// 添加文件末尾换行
|
||||
if (opts.trailingNewline && !code.endsWith('\n')) {
|
||||
code += '\n';
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件头部注释
|
||||
*/
|
||||
private static generateHeader(timestamp: string, opts: Required<TypeGenerationOptions>): string {
|
||||
const customHeader = opts.customHeader || `/**
|
||||
* 全局黑板类型定义
|
||||
*
|
||||
* ⚠️ 此文件由编辑器自动生成,请勿手动修改!
|
||||
* 生成时间: ${timestamp}
|
||||
*/`;
|
||||
|
||||
return `${customHeader}
|
||||
|
||||
import { GlobalBlackboardService } from '${opts.importPath}';`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成常量对象
|
||||
*/
|
||||
private static generateConstants(variables: any[], opts: Required<TypeGenerationOptions>): string {
|
||||
const quote = opts.quoteStyle === 'single' ? "'" : '"';
|
||||
|
||||
if (variables.length === 0) {
|
||||
return `/**
|
||||
* 全局变量名称常量
|
||||
*/
|
||||
export const ${opts.constantsName} = {} as const;`;
|
||||
}
|
||||
|
||||
// 按命名空间分组
|
||||
const grouped = this.groupVariablesByNamespace(variables);
|
||||
|
||||
if (Object.keys(grouped).length === 1 && grouped[''] !== undefined) {
|
||||
// 无命名空间,扁平结构
|
||||
const entries = variables
|
||||
.map((v) => ` ${this.transformName(v.name, opts.constantCase)}: ${quote}${v.name}${quote}`)
|
||||
.join(',\n');
|
||||
|
||||
return `/**
|
||||
* 全局变量名称常量
|
||||
* 使用常量避免拼写错误
|
||||
*/
|
||||
export const ${opts.constantsName} = {
|
||||
${entries}
|
||||
} as const;`;
|
||||
} else {
|
||||
// 有命名空间,分组结构
|
||||
const namespaces = Object.entries(grouped)
|
||||
.map(([namespace, vars]) => {
|
||||
if (namespace === '') {
|
||||
// 根级别变量
|
||||
return vars
|
||||
.map((v) => ` ${this.transformName(v.name, opts.constantCase)}: ${quote}${v.name}${quote}`)
|
||||
.join(',\n');
|
||||
} else {
|
||||
// 命名空间变量
|
||||
const nsName = this.toPascalCase(namespace);
|
||||
const entries = vars
|
||||
.map((v) => {
|
||||
const shortName = v.name.substring(namespace.length + 1);
|
||||
return ` ${this.transformName(shortName, opts.constantCase)}: ${quote}${v.name}${quote}`;
|
||||
})
|
||||
.join(',\n');
|
||||
return ` ${nsName}: {\n${entries}\n }`;
|
||||
}
|
||||
})
|
||||
.join(',\n');
|
||||
|
||||
return `/**
|
||||
* 全局变量名称常量
|
||||
* 使用常量避免拼写错误
|
||||
*/
|
||||
export const ${opts.constantsName} = {
|
||||
${namespaces}
|
||||
} as const;`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成接口定义
|
||||
*/
|
||||
private static generateInterface(variables: any[], opts: Required<TypeGenerationOptions>): string {
|
||||
if (variables.length === 0) {
|
||||
return `/**
|
||||
* 全局变量类型定义
|
||||
*/
|
||||
export interface ${opts.interfaceName} {}`;
|
||||
}
|
||||
|
||||
const properties = variables
|
||||
.map((v) => {
|
||||
const tsType = this.mapBlackboardTypeToTS(v.type);
|
||||
const comment = v.description ? ` /** ${v.description} */\n` : '';
|
||||
return `${comment} ${v.name}: ${tsType};`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `/**
|
||||
* 全局变量类型定义
|
||||
*/
|
||||
export interface ${opts.interfaceName} {
|
||||
${properties}
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成类型别名
|
||||
*/
|
||||
private static generateTypeAliases(opts: Required<TypeGenerationOptions>): string {
|
||||
return `/**
|
||||
* 全局变量名称联合类型
|
||||
*/
|
||||
export type ${opts.typeAliasName} = keyof ${opts.interfaceName};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成类型安全包装类
|
||||
*/
|
||||
private static generateTypedClass(opts: Required<TypeGenerationOptions>): string {
|
||||
return `/**
|
||||
* 类型安全的全局黑板服务包装器
|
||||
*
|
||||
* @example
|
||||
* \`\`\`typescript
|
||||
* // 游戏运行时使用
|
||||
* const service = core.services.resolve(GlobalBlackboardService);
|
||||
* const gb = new ${opts.wrapperClassName}(service);
|
||||
*
|
||||
* // 类型安全的获取
|
||||
* const hp = gb.getValue('playerHP'); // 类型: number | undefined
|
||||
*
|
||||
* // 类型安全的设置
|
||||
* gb.setValue('playerHP', 100); // ✅ 正确
|
||||
* gb.setValue('playerHP', 'invalid'); // ❌ 编译错误
|
||||
* \`\`\`
|
||||
*/
|
||||
export class ${opts.wrapperClassName} {
|
||||
constructor(private service: GlobalBlackboardService) {}
|
||||
|
||||
/**
|
||||
* 获取全局变量(类型安全)
|
||||
*/
|
||||
getValue<K extends ${opts.typeAliasName}>(
|
||||
name: K
|
||||
): ${opts.interfaceName}[K] | undefined {
|
||||
return this.service.getValue(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全局变量(类型安全)
|
||||
*/
|
||||
setValue<K extends ${opts.typeAliasName}>(
|
||||
name: K,
|
||||
value: ${opts.interfaceName}[K]
|
||||
): boolean {
|
||||
return this.service.setValue(name, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查全局变量是否存在
|
||||
*/
|
||||
hasVariable(name: ${opts.typeAliasName}): boolean {
|
||||
return this.service.hasVariable(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有变量名
|
||||
*/
|
||||
getVariableNames(): ${opts.typeAliasName}[] {
|
||||
return this.service.getVariableNames() as ${opts.typeAliasName}[];
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成默认值配置
|
||||
*/
|
||||
private static generateDefaults(variables: any[], opts: Required<TypeGenerationOptions>): string {
|
||||
if (variables.length === 0) {
|
||||
return `/**
|
||||
* 默认值配置
|
||||
*/
|
||||
export const ${opts.defaultsName}: ${opts.interfaceName} = {};`;
|
||||
}
|
||||
|
||||
const properties = variables
|
||||
.map((v) => {
|
||||
const value = this.formatValue(v.value, v.type, opts);
|
||||
return ` ${v.name}: ${value}`;
|
||||
})
|
||||
.join(',\n');
|
||||
|
||||
return `/**
|
||||
* 默认值配置
|
||||
*
|
||||
* 可在游戏启动时用于初始化全局黑板
|
||||
*
|
||||
* @example
|
||||
* \`\`\`typescript
|
||||
* // 获取服务
|
||||
* const service = core.services.resolve(GlobalBlackboardService);
|
||||
*
|
||||
* // 初始化配置
|
||||
* const config = {
|
||||
* version: '1.0',
|
||||
* variables: Object.entries(${opts.defaultsName}).map(([name, value]) => ({
|
||||
* name,
|
||||
* type: typeof value as BlackboardValueType,
|
||||
* value
|
||||
* }))
|
||||
* };
|
||||
* service.importConfig(config);
|
||||
* \`\`\`
|
||||
*/
|
||||
export const ${opts.defaultsName}: ${opts.interfaceName} = {
|
||||
${properties}
|
||||
};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按命名空间分组变量
|
||||
*/
|
||||
private static groupVariablesByNamespace(variables: any[]): Record<string, any[]> {
|
||||
const groups: Record<string, any[]> = { '': [] };
|
||||
|
||||
for (const variable of variables) {
|
||||
const dotIndex = variable.name.indexOf('.');
|
||||
if (dotIndex === -1) {
|
||||
groups['']!.push(variable);
|
||||
} else {
|
||||
const namespace = variable.name.substring(0, dotIndex);
|
||||
if (!groups[namespace]) {
|
||||
groups[namespace] = [];
|
||||
}
|
||||
groups[namespace]!.push(variable);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将变量名转换为常量名(UPPER_SNAKE_CASE)
|
||||
*/
|
||||
private static toConstantName(name: string): string {
|
||||
// player.hp -> PLAYER_HP
|
||||
// playerHP -> PLAYER_HP
|
||||
return name
|
||||
.replace(/\./g, '_')
|
||||
.replace(/([a-z])([A-Z])/g, '$1_$2')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PascalCase
|
||||
*/
|
||||
private static toPascalCase(str: string): string {
|
||||
return str
|
||||
.split(/[._-]/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射黑板类型到 TypeScript 类型
|
||||
*/
|
||||
private static mapBlackboardTypeToTS(type: BlackboardValueType): string {
|
||||
switch (type) {
|
||||
case BlackboardValueType.Number:
|
||||
return 'number';
|
||||
case BlackboardValueType.String:
|
||||
return 'string';
|
||||
case BlackboardValueType.Boolean:
|
||||
return 'boolean';
|
||||
case BlackboardValueType.Vector2:
|
||||
return '{ x: number; y: number }';
|
||||
case BlackboardValueType.Vector3:
|
||||
return '{ x: number; y: number; z: number }';
|
||||
case BlackboardValueType.Object:
|
||||
return 'any';
|
||||
case BlackboardValueType.Array:
|
||||
return 'any[]';
|
||||
default:
|
||||
return 'any';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化值为 TypeScript 字面量
|
||||
*/
|
||||
private static formatValue(value: any, type: BlackboardValueType, opts: Required<TypeGenerationOptions>): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'undefined';
|
||||
}
|
||||
|
||||
const quote = opts.quoteStyle === 'single' ? "'" : '"';
|
||||
const escapeRegex = opts.quoteStyle === 'single' ? /'/g : /"/g;
|
||||
const escapeChar = opts.quoteStyle === 'single' ? "\\'" : '\\"';
|
||||
|
||||
switch (type) {
|
||||
case BlackboardValueType.String:
|
||||
return `${quote}${value.toString().replace(escapeRegex, escapeChar)}${quote}`;
|
||||
case BlackboardValueType.Number:
|
||||
case BlackboardValueType.Boolean:
|
||||
return String(value);
|
||||
case BlackboardValueType.Vector2:
|
||||
if (typeof value === 'object' && value.x !== undefined && value.y !== undefined) {
|
||||
return `{ x: ${value.x}, y: ${value.y} }`;
|
||||
}
|
||||
return '{ x: 0, y: 0 }';
|
||||
case BlackboardValueType.Vector3:
|
||||
if (typeof value === 'object' && value.x !== undefined && value.y !== undefined && value.z !== undefined) {
|
||||
return `{ x: ${value.x}, y: ${value.y}, z: ${value.z} }`;
|
||||
}
|
||||
return '{ x: 0, y: 0, z: 0 }';
|
||||
case BlackboardValueType.Array:
|
||||
return '[]';
|
||||
case BlackboardValueType.Object:
|
||||
return '{}';
|
||||
default:
|
||||
return 'undefined';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据指定的大小写风格转换变量名
|
||||
*/
|
||||
private static transformName(name: string, caseStyle: 'UPPER_SNAKE' | 'camelCase' | 'PascalCase'): string {
|
||||
switch (caseStyle) {
|
||||
case 'UPPER_SNAKE':
|
||||
return this.toConstantName(name);
|
||||
case 'camelCase':
|
||||
return this.toCamelCase(name);
|
||||
case 'PascalCase':
|
||||
return this.toPascalCase(name);
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 camelCase
|
||||
*/
|
||||
private static toCamelCase(str: string): string {
|
||||
const parts = str.split(/[._-]/);
|
||||
if (parts.length === 0) return str;
|
||||
return (parts[0] || '').toLowerCase() + parts.slice(1)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* 局部黑板变量信息
|
||||
*/
|
||||
export interface LocalBlackboardVariable {
|
||||
name: string;
|
||||
type: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 局部黑板类型生成配置
|
||||
*/
|
||||
export interface LocalTypeGenerationOptions {
|
||||
/** 行为树名称 */
|
||||
behaviorTreeName: string;
|
||||
|
||||
/** 是否生成常量枚举 */
|
||||
includeConstants?: boolean;
|
||||
|
||||
/** 是否生成默认值 */
|
||||
includeDefaults?: boolean;
|
||||
|
||||
/** 是否生成辅助函数 */
|
||||
includeHelpers?: boolean;
|
||||
|
||||
/** 使用单引号还是双引号 */
|
||||
quoteStyle?: 'single' | 'double';
|
||||
}
|
||||
|
||||
/**
|
||||
* 局部黑板 TypeScript 类型生成器
|
||||
*
|
||||
* 为行为树的局部黑板变量生成类型安全的 TypeScript 定义
|
||||
*/
|
||||
export class LocalBlackboardTypeGenerator {
|
||||
/**
|
||||
* 生成局部黑板的 TypeScript 类型定义
|
||||
*
|
||||
* @param variables 黑板变量列表
|
||||
* @param options 生成配置
|
||||
* @returns TypeScript 代码
|
||||
*/
|
||||
static generate(
|
||||
variables: Record<string, any>,
|
||||
options: LocalTypeGenerationOptions
|
||||
): string {
|
||||
const opts = {
|
||||
includeConstants: true,
|
||||
includeDefaults: true,
|
||||
includeHelpers: true,
|
||||
quoteStyle: 'single' as const,
|
||||
...options
|
||||
};
|
||||
|
||||
const quote = opts.quoteStyle === 'single' ? "'" : '"';
|
||||
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
||||
const treeName = opts.behaviorTreeName;
|
||||
const interfaceName = `${this.toPascalCase(treeName)}Blackboard`;
|
||||
const constantsName = `${this.toPascalCase(treeName)}Vars`;
|
||||
const defaultsName = `${this.toPascalCase(treeName)}Defaults`;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// 文件头部注释
|
||||
parts.push(`/**
|
||||
* 行为树黑板变量类型定义
|
||||
*
|
||||
* 行为树: ${treeName}
|
||||
* ⚠️ 此文件由编辑器自动生成,请勿手动修改!
|
||||
* 生成时间: ${now}
|
||||
*/`);
|
||||
|
||||
const varEntries = Object.entries(variables);
|
||||
|
||||
// 如果没有变量
|
||||
if (varEntries.length === 0) {
|
||||
parts.push(`\n/**
|
||||
* 黑板变量类型定义(空)
|
||||
*/
|
||||
export interface ${interfaceName} {}`);
|
||||
return parts.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// 生成常量枚举
|
||||
if (opts.includeConstants) {
|
||||
const constants = varEntries
|
||||
.map(([name]) => ` ${this.toConstantName(name)}: ${quote}${name}${quote}`)
|
||||
.join(',\n');
|
||||
|
||||
parts.push(`\n/**
|
||||
* 黑板变量名称常量
|
||||
* 使用常量避免拼写错误
|
||||
*
|
||||
* @example
|
||||
* \`\`\`typescript
|
||||
* // 使用常量代替字符串
|
||||
* const hp = blackboard.getValue(${constantsName}.PLAYER_HP); // ✅ 类型安全
|
||||
* const hp = blackboard.getValue('playerHP'); // ❌ 容易拼写错误
|
||||
* \`\`\`
|
||||
*/
|
||||
export const ${constantsName} = {
|
||||
${constants}
|
||||
} as const;`);
|
||||
}
|
||||
|
||||
// 生成类型接口
|
||||
const interfaceProps = varEntries
|
||||
.map(([name, value]) => {
|
||||
const tsType = this.inferType(value);
|
||||
return ` ${name}: ${tsType};`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
parts.push(`\n/**
|
||||
* 黑板变量类型定义
|
||||
*/
|
||||
export interface ${interfaceName} {
|
||||
${interfaceProps}
|
||||
}`);
|
||||
|
||||
// 生成变量名联合类型
|
||||
parts.push(`\n/**
|
||||
* 黑板变量名称联合类型
|
||||
*/
|
||||
export type ${this.toPascalCase(treeName)}VariableName = keyof ${interfaceName};`);
|
||||
|
||||
// 生成默认值
|
||||
if (opts.includeDefaults) {
|
||||
const defaultProps = varEntries
|
||||
.map(([name, value]) => {
|
||||
const formattedValue = this.formatValue(value, opts.quoteStyle);
|
||||
return ` ${name}: ${formattedValue}`;
|
||||
})
|
||||
.join(',\n');
|
||||
|
||||
parts.push(`\n/**
|
||||
* 黑板变量默认值
|
||||
*
|
||||
* 可用于初始化行为树黑板
|
||||
*
|
||||
* @example
|
||||
* \`\`\`typescript
|
||||
* // 创建行为树时使用默认值
|
||||
* const blackboard = { ...${defaultsName} };
|
||||
* const tree = new BehaviorTree(rootNode, blackboard);
|
||||
* \`\`\`
|
||||
*/
|
||||
export const ${defaultsName}: ${interfaceName} = {
|
||||
${defaultProps}
|
||||
};`);
|
||||
}
|
||||
|
||||
// 生成辅助函数
|
||||
if (opts.includeHelpers) {
|
||||
parts.push(`\n/**
|
||||
* 创建类型安全的黑板访问器
|
||||
*
|
||||
* @example
|
||||
* \`\`\`typescript
|
||||
* const blackboard = create${this.toPascalCase(treeName)}Blackboard();
|
||||
*
|
||||
* // 类型安全的访问
|
||||
* const hp = blackboard.playerHP; // 类型: number
|
||||
* blackboard.playerHP = 100; // ✅ 正确
|
||||
* blackboard.playerHP = 'invalid'; // ❌ 编译错误
|
||||
* \`\`\`
|
||||
*/
|
||||
export function create${this.toPascalCase(treeName)}Blackboard(
|
||||
initialValues?: Partial<${interfaceName}>
|
||||
): ${interfaceName} {
|
||||
return { ...${defaultsName}, ...initialValues };
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫:检查变量名是否有效
|
||||
*/
|
||||
export function is${this.toPascalCase(treeName)}Variable(
|
||||
name: string
|
||||
): name is ${this.toPascalCase(treeName)}VariableName {
|
||||
return name in ${defaultsName};
|
||||
}`);
|
||||
}
|
||||
|
||||
return parts.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断 TypeScript 类型
|
||||
*/
|
||||
private static inferType(value: any): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'any';
|
||||
}
|
||||
|
||||
const type = typeof value;
|
||||
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return 'number';
|
||||
case 'string':
|
||||
return 'string';
|
||||
case 'boolean':
|
||||
return 'boolean';
|
||||
case 'object':
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return 'any[]';
|
||||
}
|
||||
const elementType = this.inferType(value[0]);
|
||||
return `${elementType}[]`;
|
||||
}
|
||||
// 检查是否是 Vector2 或 Vector3
|
||||
if ('x' in value && 'y' in value) {
|
||||
if ('z' in value) {
|
||||
return '{ x: number; y: number; z: number }';
|
||||
}
|
||||
return '{ x: number; y: number }';
|
||||
}
|
||||
return 'any';
|
||||
default:
|
||||
return 'any';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化值为 TypeScript 字面量
|
||||
*/
|
||||
private static formatValue(value: any, quoteStyle: 'single' | 'double'): string {
|
||||
if (value === null) {
|
||||
return 'null';
|
||||
}
|
||||
if (value === undefined) {
|
||||
return 'undefined';
|
||||
}
|
||||
|
||||
const quote = quoteStyle === 'single' ? "'" : '"';
|
||||
const type = typeof value;
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
const escaped = value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(quoteStyle === 'single' ? /'/g : /"/g,
|
||||
quoteStyle === 'single' ? "\\'" : '\\"');
|
||||
return `${quote}${escaped}${quote}`;
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
return String(value);
|
||||
case 'object':
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '[]';
|
||||
}
|
||||
const items = value.map((v) => this.formatValue(v, quoteStyle)).join(', ');
|
||||
return `[${items}]`;
|
||||
}
|
||||
// Vector2/Vector3
|
||||
if ('x' in value && 'y' in value) {
|
||||
if ('z' in value) {
|
||||
return `{ x: ${value.x}, y: ${value.y}, z: ${value.z} }`;
|
||||
}
|
||||
return `{ x: ${value.x}, y: ${value.y} }`;
|
||||
}
|
||||
// 普通对象
|
||||
return '{}';
|
||||
default:
|
||||
return 'undefined';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 UPPER_SNAKE_CASE
|
||||
*/
|
||||
private static toConstantName(name: string): string {
|
||||
return name
|
||||
.replace(/\./g, '_')
|
||||
.replace(/([a-z])([A-Z])/g, '$1_$2')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PascalCase
|
||||
*/
|
||||
private static toPascalCase(str: string): string {
|
||||
return str
|
||||
.split(/[._-]/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
4
packages/behavior-tree-editor/src/hooks/index.ts
Normal file
4
packages/behavior-tree-editor/src/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useCommandHistory } from './useCommandHistory';
|
||||
export { useNodeOperations } from './useNodeOperations';
|
||||
export { useConnectionOperations } from './useConnectionOperations';
|
||||
export { useCanvasInteraction } from './useCanvasInteraction';
|
||||
111
packages/behavior-tree-editor/src/hooks/useCanvasInteraction.ts
Normal file
111
packages/behavior-tree-editor/src/hooks/useCanvasInteraction.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useBehaviorTreeDataStore, useUIStore } from '../stores';
|
||||
|
||||
/**
|
||||
* 画布交互 Hook
|
||||
* 封装画布的缩放、平移等交互逻辑
|
||||
*/
|
||||
export function useCanvasInteraction() {
|
||||
// 从数据 store 获取画布状态
|
||||
const canvasOffset = useBehaviorTreeDataStore(state => state.canvasOffset);
|
||||
const canvasScale = useBehaviorTreeDataStore(state => state.canvasScale);
|
||||
const setCanvasOffset = useBehaviorTreeDataStore(state => state.setCanvasOffset);
|
||||
const setCanvasScale = useBehaviorTreeDataStore(state => state.setCanvasScale);
|
||||
const resetView = useBehaviorTreeDataStore(state => state.resetView);
|
||||
|
||||
// 从 UI store 获取平移状态
|
||||
const isPanning = useUIStore(state => state.isPanning);
|
||||
const panStart = useUIStore(state => state.panStart);
|
||||
const setIsPanning = useUIStore(state => state.setIsPanning);
|
||||
const setPanStart = useUIStore(state => state.setPanStart);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const delta = e.deltaY;
|
||||
const scaleFactor = 1.1;
|
||||
|
||||
if (delta < 0) {
|
||||
setCanvasScale(Math.min(canvasScale * scaleFactor, 3));
|
||||
} else {
|
||||
setCanvasScale(Math.max(canvasScale / scaleFactor, 0.1));
|
||||
}
|
||||
}, [canvasScale, setCanvasScale]);
|
||||
|
||||
const startPanning = useCallback((clientX: number, clientY: number) => {
|
||||
setIsPanning(true);
|
||||
setPanStart({ x: clientX, y: clientY });
|
||||
}, [setIsPanning, setPanStart]);
|
||||
|
||||
const updatePanning = useCallback((clientX: number, clientY: number) => {
|
||||
if (!isPanning) return;
|
||||
|
||||
const dx = clientX - panStart.x;
|
||||
const dy = clientY - panStart.y;
|
||||
|
||||
setCanvasOffset({
|
||||
x: canvasOffset.x + dx,
|
||||
y: canvasOffset.y + dy
|
||||
});
|
||||
|
||||
setPanStart({ x: clientX, y: clientY });
|
||||
}, [isPanning, panStart, canvasOffset, setCanvasOffset, setPanStart]);
|
||||
|
||||
const stopPanning = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, [setIsPanning]);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setCanvasScale(Math.min(canvasScale * 1.2, 3));
|
||||
}, [canvasScale, setCanvasScale]);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setCanvasScale(Math.max(canvasScale / 1.2, 0.1));
|
||||
}, [canvasScale, setCanvasScale]);
|
||||
|
||||
const zoomToFit = useCallback(() => {
|
||||
resetView();
|
||||
}, [resetView]);
|
||||
|
||||
const screenToCanvas = useCallback((screenX: number, screenY: number) => {
|
||||
return {
|
||||
x: (screenX - canvasOffset.x) / canvasScale,
|
||||
y: (screenY - canvasOffset.y) / canvasScale
|
||||
};
|
||||
}, [canvasOffset, canvasScale]);
|
||||
|
||||
const canvasToScreen = useCallback((canvasX: number, canvasY: number) => {
|
||||
return {
|
||||
x: canvasX * canvasScale + canvasOffset.x,
|
||||
y: canvasY * canvasScale + canvasOffset.y
|
||||
};
|
||||
}, [canvasOffset, canvasScale]);
|
||||
|
||||
return useMemo(() => ({
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
isPanning,
|
||||
handleWheel,
|
||||
startPanning,
|
||||
updatePanning,
|
||||
stopPanning,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomToFit,
|
||||
screenToCanvas,
|
||||
canvasToScreen
|
||||
}), [
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
isPanning,
|
||||
handleWheel,
|
||||
startPanning,
|
||||
updatePanning,
|
||||
stopPanning,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomToFit,
|
||||
screenToCanvas,
|
||||
canvasToScreen
|
||||
]);
|
||||
}
|
||||
250
packages/behavior-tree-editor/src/hooks/useCanvasMouseEvents.ts
Normal file
250
packages/behavior-tree-editor/src/hooks/useCanvasMouseEvents.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { RefObject, useEffect, useRef } from 'react';
|
||||
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
|
||||
|
||||
interface QuickCreateMenuState {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
searchText: string;
|
||||
selectedIndex: number;
|
||||
mode: 'create' | 'replace';
|
||||
replaceNodeId: string | null;
|
||||
}
|
||||
|
||||
interface UseCanvasMouseEventsParams {
|
||||
canvasRef: RefObject<HTMLDivElement>;
|
||||
canvasOffset: { x: number; y: number };
|
||||
canvasScale: number;
|
||||
connectingFrom: string | null;
|
||||
connectingFromProperty: string | null;
|
||||
connectingToPos: { x: number; y: number } | null;
|
||||
isBoxSelecting: boolean;
|
||||
boxSelectStart: { x: number; y: number } | null;
|
||||
boxSelectEnd: { x: number; y: number } | null;
|
||||
nodes: BehaviorTreeNode[];
|
||||
selectedNodeIds: string[];
|
||||
quickCreateMenu: QuickCreateMenuState;
|
||||
setConnectingToPos: (pos: { x: number; y: number } | null) => void;
|
||||
setIsBoxSelecting: (isSelecting: boolean) => void;
|
||||
setBoxSelectStart: (pos: { x: number; y: number } | null) => void;
|
||||
setBoxSelectEnd: (pos: { x: number; y: number } | null) => void;
|
||||
setSelectedNodeIds: (ids: string[]) => void;
|
||||
setSelectedConnection: (connection: { from: string; to: string } | null) => void;
|
||||
setQuickCreateMenu: (menu: QuickCreateMenuState) => void;
|
||||
clearConnecting: () => void;
|
||||
clearBoxSelect: () => void;
|
||||
showToast?: (message: string, type: 'success' | 'error' | 'warning' | 'info', duration?: number) => void;
|
||||
}
|
||||
|
||||
export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
||||
const {
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
connectingFrom,
|
||||
connectingFromProperty,
|
||||
connectingToPos,
|
||||
isBoxSelecting,
|
||||
boxSelectStart,
|
||||
boxSelectEnd,
|
||||
nodes,
|
||||
selectedNodeIds,
|
||||
quickCreateMenu,
|
||||
setConnectingToPos,
|
||||
setIsBoxSelecting,
|
||||
setBoxSelectStart,
|
||||
setBoxSelectEnd,
|
||||
setSelectedNodeIds,
|
||||
setSelectedConnection,
|
||||
setQuickCreateMenu,
|
||||
clearConnecting,
|
||||
clearBoxSelect,
|
||||
showToast
|
||||
} = params;
|
||||
|
||||
const isBoxSelectingRef = useRef(isBoxSelecting);
|
||||
const boxSelectStartRef = useRef(boxSelectStart);
|
||||
const canvasOffsetRef = useRef(canvasOffset);
|
||||
const canvasScaleRef = useRef(canvasScale);
|
||||
const nodesRef = useRef(nodes);
|
||||
const selectedNodeIdsRef = useRef(selectedNodeIds);
|
||||
|
||||
useEffect(() => {
|
||||
isBoxSelectingRef.current = isBoxSelecting;
|
||||
boxSelectStartRef.current = boxSelectStart;
|
||||
canvasOffsetRef.current = canvasOffset;
|
||||
canvasScaleRef.current = canvasScale;
|
||||
nodesRef.current = nodes;
|
||||
selectedNodeIdsRef.current = selectedNodeIds;
|
||||
}, [isBoxSelecting, boxSelectStart, canvasOffset, canvasScale, nodes, selectedNodeIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBoxSelecting) return;
|
||||
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||
if (!isBoxSelectingRef.current || !boxSelectStartRef.current) return;
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const canvasX = (e.clientX - rect.left - canvasOffsetRef.current.x) / canvasScaleRef.current;
|
||||
const canvasY = (e.clientY - rect.top - canvasOffsetRef.current.y) / canvasScaleRef.current;
|
||||
setBoxSelectEnd({ x: canvasX, y: canvasY });
|
||||
};
|
||||
|
||||
const handleGlobalMouseUp = (e: MouseEvent) => {
|
||||
if (!isBoxSelectingRef.current || !boxSelectStartRef.current || !boxSelectEnd) return;
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
clearBoxSelect();
|
||||
return;
|
||||
}
|
||||
|
||||
const minX = Math.min(boxSelectStartRef.current.x, boxSelectEnd.x);
|
||||
const maxX = Math.max(boxSelectStartRef.current.x, boxSelectEnd.x);
|
||||
const minY = Math.min(boxSelectStartRef.current.y, boxSelectEnd.y);
|
||||
const maxY = Math.max(boxSelectStartRef.current.y, boxSelectEnd.y);
|
||||
|
||||
const selectedInBox = nodesRef.current
|
||||
.filter((node: BehaviorTreeNode) => {
|
||||
if (node.id === ROOT_NODE_ID) return false;
|
||||
|
||||
const nodeElement = canvasRef.current?.querySelector(`[data-node-id="${node.id}"]`);
|
||||
if (!nodeElement) {
|
||||
return node.position.x >= minX && node.position.x <= maxX &&
|
||||
node.position.y >= minY && node.position.y <= maxY;
|
||||
}
|
||||
|
||||
const nodeRect = nodeElement.getBoundingClientRect();
|
||||
const canvasRect = canvasRef.current!.getBoundingClientRect();
|
||||
|
||||
const nodeLeft = (nodeRect.left - canvasRect.left - canvasOffsetRef.current.x) / canvasScaleRef.current;
|
||||
const nodeRight = (nodeRect.right - canvasRect.left - canvasOffsetRef.current.x) / canvasScaleRef.current;
|
||||
const nodeTop = (nodeRect.top - canvasRect.top - canvasOffsetRef.current.y) / canvasScaleRef.current;
|
||||
const nodeBottom = (nodeRect.bottom - canvasRect.top - canvasOffsetRef.current.y) / canvasScaleRef.current;
|
||||
|
||||
return nodeRight > minX && nodeLeft < maxX && nodeBottom > minY && nodeTop < maxY;
|
||||
})
|
||||
.map((node: BehaviorTreeNode) => node.id);
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const newSet = new Set([...selectedNodeIdsRef.current, ...selectedInBox]);
|
||||
setSelectedNodeIds(Array.from(newSet));
|
||||
} else {
|
||||
setSelectedNodeIds(selectedInBox);
|
||||
}
|
||||
|
||||
clearBoxSelect();
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
};
|
||||
}, [isBoxSelecting, boxSelectStart, boxSelectEnd, canvasRef, setBoxSelectEnd, setSelectedNodeIds, clearBoxSelect]);
|
||||
|
||||
const handleCanvasMouseMove = (e: React.MouseEvent) => {
|
||||
if (connectingFrom && canvasRef.current && !quickCreateMenu.visible) {
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
|
||||
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
|
||||
setConnectingToPos({
|
||||
x: canvasX,
|
||||
y: canvasY
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasMouseUp = (e: React.MouseEvent) => {
|
||||
if (quickCreateMenu.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
const isPort = target.closest('[data-port="true"]');
|
||||
if (isPort) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectingFrom && connectingToPos) {
|
||||
// 如果是属性连接,不允许创建新节点
|
||||
if (connectingFromProperty) {
|
||||
showToast?.(
|
||||
'属性连接必须连接到现有节点的属性端口',
|
||||
'warning'
|
||||
);
|
||||
clearConnecting();
|
||||
setConnectingToPos(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceNode = nodes.find(n => n.id === connectingFrom);
|
||||
if (sourceNode && !sourceNode.canAddChild()) {
|
||||
const maxChildren = sourceNode.template.maxChildren ?? Infinity;
|
||||
showToast?.(
|
||||
`节点"${sourceNode.template.displayName}"已达到最大子节点数 ${maxChildren}`,
|
||||
'warning'
|
||||
);
|
||||
clearConnecting();
|
||||
setConnectingToPos(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setQuickCreateMenu({
|
||||
visible: true,
|
||||
position: {
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
},
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode: 'create',
|
||||
replaceNodeId: null
|
||||
});
|
||||
setConnectingToPos(null);
|
||||
return;
|
||||
}
|
||||
|
||||
clearConnecting();
|
||||
};
|
||||
|
||||
const handleCanvasMouseDown = (e: React.MouseEvent) => {
|
||||
if (quickCreateMenu.visible) {
|
||||
setQuickCreateMenu({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode: 'create',
|
||||
replaceNodeId: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.button === 0 && !e.altKey) {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
|
||||
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
|
||||
|
||||
setIsBoxSelecting(true);
|
||||
setBoxSelectStart({ x: canvasX, y: canvasY });
|
||||
setBoxSelectEnd({ x: canvasX, y: canvasY });
|
||||
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
setSelectedNodeIds([]);
|
||||
setSelectedConnection(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleCanvasMouseMove,
|
||||
handleCanvasMouseUp,
|
||||
handleCanvasMouseDown
|
||||
};
|
||||
}
|
||||
77
packages/behavior-tree-editor/src/hooks/useCommandHistory.ts
Normal file
77
packages/behavior-tree-editor/src/hooks/useCommandHistory.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { CommandManager } from '@esengine/editor-core';
|
||||
|
||||
/**
|
||||
* 撤销/重做功能 Hook
|
||||
*/
|
||||
export function useCommandHistory() {
|
||||
const commandManagerRef = useRef<CommandManager>(new CommandManager());
|
||||
|
||||
const commandManager = commandManagerRef.current;
|
||||
|
||||
const canUndo = useCallback(() => {
|
||||
return commandManager.canUndo();
|
||||
}, [commandManager]);
|
||||
|
||||
const canRedo = useCallback(() => {
|
||||
return commandManager.canRedo();
|
||||
}, [commandManager]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (commandManager.canUndo()) {
|
||||
commandManager.undo();
|
||||
}
|
||||
}, [commandManager]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (commandManager.canRedo()) {
|
||||
commandManager.redo();
|
||||
}
|
||||
}, [commandManager]);
|
||||
|
||||
const getUndoHistory = useCallback(() => {
|
||||
return commandManager.getUndoHistory();
|
||||
}, [commandManager]);
|
||||
|
||||
const getRedoHistory = useCallback(() => {
|
||||
return commandManager.getRedoHistory();
|
||||
}, [commandManager]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
commandManager.clear();
|
||||
}, [commandManager]);
|
||||
|
||||
// 键盘快捷键
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
const isCtrlOrCmd = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
if (isCtrlOrCmd && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
redo();
|
||||
} else {
|
||||
undo();
|
||||
}
|
||||
} else if (isCtrlOrCmd && e.key === 'y') {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [undo, redo]);
|
||||
|
||||
return {
|
||||
commandManager,
|
||||
canUndo: canUndo(),
|
||||
canRedo: canRedo(),
|
||||
undo,
|
||||
redo,
|
||||
getUndoHistory,
|
||||
getRedoHistory,
|
||||
clear
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { CommandManager } from '@esengine/editor-core';
|
||||
import { ConnectionType } from '../domain/models/Connection';
|
||||
import { IValidator } from '../domain/interfaces/IValidator';
|
||||
import { TreeStateAdapter } from '../application/state/BehaviorTreeDataStore';
|
||||
import { AddConnectionUseCase } from '../application/use-cases/AddConnectionUseCase';
|
||||
import { RemoveConnectionUseCase } from '../application/use-cases/RemoveConnectionUseCase';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
|
||||
const logger = createLogger('useConnectionOperations');
|
||||
|
||||
/**
|
||||
* 连接操作 Hook
|
||||
*/
|
||||
export function useConnectionOperations(
|
||||
validator: IValidator,
|
||||
commandManager: CommandManager
|
||||
) {
|
||||
const treeState = useMemo(() => TreeStateAdapter.getInstance(), []);
|
||||
|
||||
const addConnectionUseCase = useMemo(
|
||||
() => new AddConnectionUseCase(commandManager, treeState, validator),
|
||||
[commandManager, treeState, validator]
|
||||
);
|
||||
|
||||
const removeConnectionUseCase = useMemo(
|
||||
() => new RemoveConnectionUseCase(commandManager, treeState),
|
||||
[commandManager, treeState]
|
||||
);
|
||||
|
||||
const addConnection = useCallback((
|
||||
from: string,
|
||||
to: string,
|
||||
connectionType: ConnectionType = 'node',
|
||||
fromProperty?: string,
|
||||
toProperty?: string
|
||||
) => {
|
||||
try {
|
||||
return addConnectionUseCase.execute(from, to, connectionType, fromProperty, toProperty);
|
||||
} catch (error) {
|
||||
logger.error('添加连接失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [addConnectionUseCase]);
|
||||
|
||||
const removeConnection = useCallback((
|
||||
from: string,
|
||||
to: string,
|
||||
fromProperty?: string,
|
||||
toProperty?: string
|
||||
) => {
|
||||
try {
|
||||
removeConnectionUseCase.execute(from, to, fromProperty, toProperty);
|
||||
} catch (error) {
|
||||
logger.error('移除连接失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [removeConnectionUseCase]);
|
||||
|
||||
return useMemo(() => ({
|
||||
addConnection,
|
||||
removeConnection
|
||||
}), [addConnection, removeConnection]);
|
||||
}
|
||||
55
packages/behavior-tree-editor/src/hooks/useContextMenu.ts
Normal file
55
packages/behavior-tree-editor/src/hooks/useContextMenu.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
nodeId: string | null;
|
||||
}
|
||||
|
||||
export function useContextMenu() {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
nodeId: null
|
||||
});
|
||||
|
||||
const handleNodeContextMenu = (e: React.MouseEvent, node: BehaviorTreeNode) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 不允许对Root节点右键
|
||||
if (node.id === ROOT_NODE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
nodeId: node.id
|
||||
});
|
||||
};
|
||||
|
||||
const handleCanvasContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
nodeId: null
|
||||
});
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu({ ...contextMenu, visible: false });
|
||||
};
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
setContextMenu,
|
||||
handleNodeContextMenu,
|
||||
handleCanvasContextMenu,
|
||||
closeContextMenu
|
||||
};
|
||||
}
|
||||
132
packages/behavior-tree-editor/src/hooks/useDropHandler.ts
Normal file
132
packages/behavior-tree-editor/src/hooks/useDropHandler.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, RefObject } from 'react';
|
||||
import { NodeTemplate, NodeType } from '@esengine/behavior-tree';
|
||||
import { Position } from '../domain/value-objects/Position';
|
||||
import { useNodeOperations } from './useNodeOperations';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
|
||||
const logger = createLogger('useDropHandler');
|
||||
|
||||
interface DraggedVariableData {
|
||||
variableName: string;
|
||||
}
|
||||
|
||||
interface UseDropHandlerParams {
|
||||
canvasRef: RefObject<HTMLDivElement>;
|
||||
canvasOffset: { x: number; y: number };
|
||||
canvasScale: number;
|
||||
nodeOperations: ReturnType<typeof useNodeOperations>;
|
||||
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
export function useDropHandler(params: UseDropHandlerParams) {
|
||||
const {
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
nodeOperations,
|
||||
onNodeCreate
|
||||
} = params;
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
try {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const position = {
|
||||
x: (e.clientX - rect.left - canvasOffset.x) / canvasScale,
|
||||
y: (e.clientY - rect.top - canvasOffset.y) / canvasScale
|
||||
};
|
||||
|
||||
const blackboardVariableData = e.dataTransfer.getData('application/blackboard-variable');
|
||||
if (blackboardVariableData) {
|
||||
const variableData = JSON.parse(blackboardVariableData) as DraggedVariableData;
|
||||
|
||||
const variableTemplate: NodeTemplate = {
|
||||
type: NodeType.Action,
|
||||
displayName: variableData.variableName,
|
||||
category: 'Blackboard Variable',
|
||||
icon: 'Database',
|
||||
description: `Blackboard variable: ${variableData.variableName}`,
|
||||
color: '#9c27b0',
|
||||
defaultConfig: {
|
||||
nodeType: 'blackboard-variable',
|
||||
variableName: variableData.variableName
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
name: 'variableName',
|
||||
label: '变量名',
|
||||
type: 'variable',
|
||||
defaultValue: variableData.variableName,
|
||||
description: '黑板变量的名称',
|
||||
required: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
nodeOperations.createNode(
|
||||
variableTemplate,
|
||||
new Position(position.x, position.y),
|
||||
{
|
||||
nodeType: 'blackboard-variable',
|
||||
variableName: variableData.variableName
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let templateData = e.dataTransfer.getData('application/behavior-tree-node');
|
||||
if (!templateData) {
|
||||
templateData = e.dataTransfer.getData('text/plain');
|
||||
}
|
||||
if (!templateData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = JSON.parse(templateData) as NodeTemplate;
|
||||
|
||||
nodeOperations.createNode(
|
||||
template,
|
||||
new Position(position.x, position.y),
|
||||
template.defaultConfig
|
||||
);
|
||||
|
||||
onNodeCreate?.(template, position);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create node:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
if (!isDragging) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (e.currentTarget === e.target) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDragEnter
|
||||
};
|
||||
}
|
||||
70
packages/behavior-tree-editor/src/hooks/useEditorHandlers.ts
Normal file
70
packages/behavior-tree-editor/src/hooks/useEditorHandlers.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useCallback } from 'react';
|
||||
import { ask } from '@tauri-apps/plugin-dialog';
|
||||
import { BehaviorTreeNode } from '../stores';
|
||||
|
||||
interface UseEditorHandlersParams {
|
||||
isDraggingNode: boolean;
|
||||
selectedNodeIds: string[];
|
||||
setSelectedNodeIds: (ids: string[]) => void;
|
||||
resetView: () => void;
|
||||
resetTree: () => void;
|
||||
triggerForceUpdate: () => void;
|
||||
onNodeSelect?: (node: BehaviorTreeNode) => void;
|
||||
}
|
||||
|
||||
export function useEditorHandlers(params: UseEditorHandlersParams) {
|
||||
const {
|
||||
isDraggingNode,
|
||||
selectedNodeIds,
|
||||
setSelectedNodeIds,
|
||||
resetView,
|
||||
resetTree,
|
||||
triggerForceUpdate,
|
||||
onNodeSelect
|
||||
} = params;
|
||||
|
||||
const handleNodeClick = useCallback((e: React.MouseEvent, node: BehaviorTreeNode) => {
|
||||
// 阻止事件冒泡,避免触发画布的点击事件
|
||||
e.stopPropagation();
|
||||
|
||||
if (isDraggingNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (selectedNodeIds.includes(node.id)) {
|
||||
setSelectedNodeIds(selectedNodeIds.filter((id: string) => id !== node.id));
|
||||
} else {
|
||||
setSelectedNodeIds([...selectedNodeIds, node.id]);
|
||||
}
|
||||
} else {
|
||||
setSelectedNodeIds([node.id]);
|
||||
}
|
||||
onNodeSelect?.(node);
|
||||
}, [isDraggingNode, selectedNodeIds, setSelectedNodeIds, onNodeSelect]);
|
||||
|
||||
const handleResetView = useCallback(() => {
|
||||
resetView();
|
||||
requestAnimationFrame(() => {
|
||||
triggerForceUpdate();
|
||||
});
|
||||
}, [resetView, triggerForceUpdate]);
|
||||
|
||||
const handleClearCanvas = useCallback(async () => {
|
||||
const confirmed = await ask('确定要清空画布吗?此操作不可撤销。', {
|
||||
title: '清空画布',
|
||||
kind: 'warning'
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
resetTree();
|
||||
setSelectedNodeIds([]);
|
||||
}
|
||||
}, [resetTree, setSelectedNodeIds]);
|
||||
|
||||
return {
|
||||
handleNodeClick,
|
||||
handleResetView,
|
||||
handleClearCanvas
|
||||
};
|
||||
}
|
||||
18
packages/behavior-tree-editor/src/hooks/useEditorState.ts
Normal file
18
packages/behavior-tree-editor/src/hooks/useEditorState.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { BehaviorTreeExecutor } from '../utils/BehaviorTreeExecutor';
|
||||
|
||||
export function useEditorState() {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const stopExecutionRef = useRef<(() => void) | null>(null);
|
||||
const executorRef = useRef<BehaviorTreeExecutor | null>(null);
|
||||
|
||||
const [selectedConnection, setSelectedConnection] = useState<{from: string; to: string} | null>(null);
|
||||
|
||||
return {
|
||||
canvasRef,
|
||||
stopExecutionRef,
|
||||
executorRef,
|
||||
selectedConnection,
|
||||
setSelectedConnection
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { ExecutionController, ExecutionMode } from '../application/services/ExecutionController';
|
||||
import { BlackboardManager } from '../application/services/BlackboardManager';
|
||||
import { BehaviorTreeNode, Connection, useBehaviorTreeDataStore } from '../stores';
|
||||
import { ExecutionLog } from '../utils/BehaviorTreeExecutor';
|
||||
import { BlackboardValue } from '../domain/models/Blackboard';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
|
||||
const logger = createLogger('useExecutionController');
|
||||
|
||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||
|
||||
interface UseExecutionControllerParams {
|
||||
rootNodeId: string;
|
||||
projectPath: string | null;
|
||||
blackboardVariables: BlackboardVariables;
|
||||
nodes: BehaviorTreeNode[];
|
||||
connections: Connection[];
|
||||
initialBlackboardVariables: BlackboardVariables;
|
||||
onBlackboardUpdate: (variables: BlackboardVariables) => void;
|
||||
onInitialBlackboardSave: (variables: BlackboardVariables) => void;
|
||||
onExecutingChange: (isExecuting: boolean) => void;
|
||||
onSaveNodesDataSnapshot: () => void;
|
||||
onRestoreNodesData: () => void;
|
||||
sortChildrenByPosition: () => void;
|
||||
}
|
||||
|
||||
export function useExecutionController(params: UseExecutionControllerParams) {
|
||||
const {
|
||||
rootNodeId,
|
||||
projectPath,
|
||||
blackboardVariables,
|
||||
nodes,
|
||||
connections,
|
||||
onBlackboardUpdate,
|
||||
onInitialBlackboardSave,
|
||||
onExecutingChange,
|
||||
onSaveNodesDataSnapshot,
|
||||
onRestoreNodesData,
|
||||
sortChildrenByPosition
|
||||
} = params;
|
||||
|
||||
const [executionMode, setExecutionMode] = useState<ExecutionMode>('idle');
|
||||
const [executionLogs, setExecutionLogs] = useState<ExecutionLog[]>([]);
|
||||
const [executionSpeed, setExecutionSpeed] = useState<number>(1.0);
|
||||
const [tickCount, setTickCount] = useState(0);
|
||||
|
||||
const controller = useMemo(() => {
|
||||
return new ExecutionController({
|
||||
rootNodeId,
|
||||
projectPath,
|
||||
onLogsUpdate: setExecutionLogs,
|
||||
onBlackboardUpdate,
|
||||
onTickCountUpdate: setTickCount,
|
||||
onExecutionStatusUpdate: (statuses, orders) => {
|
||||
const store = useBehaviorTreeDataStore.getState();
|
||||
store.updateNodeExecutionStatuses(statuses, orders);
|
||||
}
|
||||
// 不在这里传递 onBreakpointHit,避免频繁重建
|
||||
});
|
||||
}, [rootNodeId, projectPath, onBlackboardUpdate]);
|
||||
|
||||
const blackboardManager = useMemo(() => new BlackboardManager(), []);
|
||||
|
||||
useEffect(() => {
|
||||
// 保存当前 controller 的引用,确保清理时使用正确的实例
|
||||
const currentController = controller;
|
||||
return () => {
|
||||
currentController.destroy();
|
||||
};
|
||||
}, [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
controller.setConnections(connections);
|
||||
}, [connections, controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (executionMode === 'idle') return;
|
||||
|
||||
const executorVars = controller.getBlackboardVariables();
|
||||
|
||||
Object.entries(blackboardVariables).forEach(([key, value]) => {
|
||||
if (executorVars[key] !== value) {
|
||||
controller.updateBlackboardVariable(key, value);
|
||||
}
|
||||
});
|
||||
}, [blackboardVariables, executionMode, controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (executionMode === 'idle') return;
|
||||
|
||||
controller.updateNodes(nodes);
|
||||
}, [nodes, executionMode, controller]);
|
||||
|
||||
const handlePlay = async () => {
|
||||
try {
|
||||
sortChildrenByPosition();
|
||||
logger.info('[Execute] Sorted children by position before execution');
|
||||
|
||||
blackboardManager.setInitialVariables(blackboardVariables);
|
||||
blackboardManager.setCurrentVariables(blackboardVariables);
|
||||
onInitialBlackboardSave(blackboardManager.getInitialVariables());
|
||||
onSaveNodesDataSnapshot();
|
||||
onExecutingChange(true);
|
||||
|
||||
setExecutionMode('running');
|
||||
await controller.play(nodes, blackboardVariables, connections);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start execution:', error);
|
||||
setExecutionMode('idle');
|
||||
onExecutingChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
try {
|
||||
await controller.pause();
|
||||
const newMode = controller.getMode();
|
||||
setExecutionMode(newMode);
|
||||
} catch (error) {
|
||||
logger.error('Failed to pause/resume execution:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
await controller.stop();
|
||||
setExecutionMode('idle');
|
||||
setTickCount(0);
|
||||
|
||||
const restoredVars = blackboardManager.restoreInitialVariables();
|
||||
onBlackboardUpdate(restoredVars);
|
||||
onRestoreNodesData();
|
||||
useBehaviorTreeDataStore.getState().clearNodeExecutionStatuses();
|
||||
onExecutingChange(false);
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop execution:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStep = () => {
|
||||
controller.step();
|
||||
// 单步执行后保持idle状态,不需要专门的step状态
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await controller.reset();
|
||||
setExecutionMode('idle');
|
||||
setTickCount(0);
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset execution:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpeedChange = (speed: number) => {
|
||||
setExecutionSpeed(speed);
|
||||
controller.setSpeed(speed);
|
||||
};
|
||||
|
||||
return {
|
||||
executionMode,
|
||||
executionLogs,
|
||||
executionSpeed,
|
||||
tickCount,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleStop,
|
||||
handleStep,
|
||||
handleReset,
|
||||
handleSpeedChange,
|
||||
setExecutionLogs,
|
||||
controller,
|
||||
blackboardManager
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Connection, ROOT_NODE_ID } from '../stores';
|
||||
import { useNodeOperations } from './useNodeOperations';
|
||||
import { useConnectionOperations } from './useConnectionOperations';
|
||||
|
||||
interface UseKeyboardShortcutsParams {
|
||||
selectedNodeIds: string[];
|
||||
selectedConnection: { from: string; to: string } | null;
|
||||
connections: Connection[];
|
||||
nodeOperations: ReturnType<typeof useNodeOperations>;
|
||||
connectionOperations: ReturnType<typeof useConnectionOperations>;
|
||||
setSelectedNodeIds: (ids: string[]) => void;
|
||||
setSelectedConnection: (connection: { from: string; to: string } | null) => void;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(params: UseKeyboardShortcutsParams) {
|
||||
const {
|
||||
selectedNodeIds,
|
||||
selectedConnection,
|
||||
connections,
|
||||
nodeOperations,
|
||||
connectionOperations,
|
||||
setSelectedNodeIds,
|
||||
setSelectedConnection
|
||||
} = params;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
const isEditingText = activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement instanceof HTMLSelectElement ||
|
||||
(activeElement as HTMLElement)?.isContentEditable;
|
||||
|
||||
if (isEditingText) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedConnection) {
|
||||
const conn = connections.find(
|
||||
(c: Connection) => c.from === selectedConnection.from && c.to === selectedConnection.to
|
||||
);
|
||||
if (conn) {
|
||||
connectionOperations.removeConnection(
|
||||
conn.from,
|
||||
conn.to,
|
||||
conn.fromProperty,
|
||||
conn.toProperty
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedConnection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedNodeIds.length > 0) {
|
||||
const nodesToDelete = selectedNodeIds.filter((id: string) => id !== ROOT_NODE_ID);
|
||||
if (nodesToDelete.length > 0) {
|
||||
nodeOperations.deleteNodes(nodesToDelete);
|
||||
setSelectedNodeIds([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedNodeIds, selectedConnection, nodeOperations, connectionOperations, connections, setSelectedNodeIds, setSelectedConnection]);
|
||||
}
|
||||
182
packages/behavior-tree-editor/src/hooks/useNodeDrag.ts
Normal file
182
packages/behavior-tree-editor/src/hooks/useNodeDrag.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useRef, useCallback, RefObject } from 'react';
|
||||
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
|
||||
import { Position } from '../domain/value-objects/Position';
|
||||
import { useNodeOperations } from './useNodeOperations';
|
||||
|
||||
interface UseNodeDragParams {
|
||||
canvasRef: RefObject<HTMLDivElement>;
|
||||
canvasOffset: { x: number; y: number };
|
||||
canvasScale: number;
|
||||
nodes: BehaviorTreeNode[];
|
||||
selectedNodeIds: string[];
|
||||
draggingNodeId: string | null;
|
||||
dragStartPositions: Map<string, { x: number; y: number }>;
|
||||
isDraggingNode: boolean;
|
||||
dragDelta: { dx: number; dy: number };
|
||||
nodeOperations: ReturnType<typeof useNodeOperations>;
|
||||
setSelectedNodeIds: (ids: string[]) => void;
|
||||
startDragging: (nodeId: string, startPositions: Map<string, { x: number; y: number }>) => void;
|
||||
stopDragging: () => void;
|
||||
setIsDraggingNode: (isDragging: boolean) => void;
|
||||
setDragDelta: (delta: { dx: number; dy: number }) => void;
|
||||
setIsBoxSelecting: (isSelecting: boolean) => void;
|
||||
setBoxSelectStart: (pos: { x: number; y: number } | null) => void;
|
||||
setBoxSelectEnd: (pos: { x: number; y: number } | null) => void;
|
||||
sortChildrenByPosition: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽上下文,存储拖拽过程中需要保持稳定的值
|
||||
*/
|
||||
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) {
|
||||
const {
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
nodes,
|
||||
selectedNodeIds,
|
||||
draggingNodeId,
|
||||
dragStartPositions,
|
||||
isDraggingNode,
|
||||
dragDelta,
|
||||
nodeOperations,
|
||||
setSelectedNodeIds,
|
||||
startDragging,
|
||||
stopDragging,
|
||||
setIsDraggingNode,
|
||||
setDragDelta,
|
||||
setIsBoxSelecting,
|
||||
setBoxSelectStart,
|
||||
setBoxSelectEnd,
|
||||
sortChildrenByPosition
|
||||
} = params;
|
||||
|
||||
// 使用 ref 存储拖拽上下文,避免闭包问题
|
||||
const dragContextRef = useRef<DragContext | null>(null);
|
||||
|
||||
const handleNodeMouseDown = useCallback((e: React.MouseEvent, nodeId: string) => {
|
||||
if (e.button !== 0) return;
|
||||
if (nodeId === ROOT_NODE_ID) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
const isPort = target.closest('[data-port="true"]');
|
||||
if (isPort) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
setIsBoxSelecting(false);
|
||||
setBoxSelectStart(null);
|
||||
setBoxSelectEnd(null);
|
||||
|
||||
const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId);
|
||||
if (!node) return;
|
||||
|
||||
// 确定要拖拽的节点列表
|
||||
let nodesToDrag: string[];
|
||||
if (selectedNodeIds.includes(nodeId)) {
|
||||
nodesToDrag = selectedNodeIds;
|
||||
} else {
|
||||
nodesToDrag = [nodeId];
|
||||
setSelectedNodeIds([nodeId]);
|
||||
}
|
||||
|
||||
// 记录所有要拖拽节点的初始位置
|
||||
const startPositions = new Map<string, { x: number; y: number }>();
|
||||
nodesToDrag.forEach((id: string) => {
|
||||
const n = nodes.find((node: BehaviorTreeNode) => node.id === id);
|
||||
if (n) {
|
||||
startPositions.set(id, { x: n.position.x, y: n.position.y });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建拖拽上下文,保存拖拽开始时的所有关键状态
|
||||
dragContextRef.current = {
|
||||
startClientX: e.clientX,
|
||||
startClientY: e.clientY,
|
||||
startCanvasScale: canvasScale,
|
||||
startCanvasOffset: { ...canvasOffset },
|
||||
nodeStartPositions: startPositions
|
||||
};
|
||||
|
||||
startDragging(nodeId, startPositions);
|
||||
}, [nodes, selectedNodeIds, canvasScale, canvasOffset, setSelectedNodeIds, setIsBoxSelecting, setBoxSelectStart, setBoxSelectEnd, startDragging]);
|
||||
|
||||
const handleNodeMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!draggingNodeId || !dragContextRef.current) return;
|
||||
|
||||
if (!isDraggingNode) {
|
||||
setIsDraggingNode(true);
|
||||
}
|
||||
|
||||
const context = dragContextRef.current;
|
||||
|
||||
// 计算鼠标在客户端坐标系中的移动距离(像素)
|
||||
const clientDeltaX = e.clientX - context.startClientX;
|
||||
const clientDeltaY = e.clientY - context.startClientY;
|
||||
|
||||
// 转换为画布坐标系中的移动距离
|
||||
// 注意:这里使用拖拽开始时的缩放比例,确保计算一致性
|
||||
const canvasDeltaX = clientDeltaX / context.startCanvasScale;
|
||||
const canvasDeltaY = clientDeltaY / context.startCanvasScale;
|
||||
|
||||
setDragDelta({ dx: canvasDeltaX, dy: canvasDeltaY });
|
||||
}, [draggingNodeId, isDraggingNode, setIsDraggingNode, setDragDelta]);
|
||||
|
||||
const handleNodeMouseUp = useCallback(() => {
|
||||
if (!draggingNodeId || !dragContextRef.current) return;
|
||||
|
||||
const context = dragContextRef.current;
|
||||
|
||||
if (dragDelta.dx !== 0 || dragDelta.dy !== 0) {
|
||||
// 根据拖拽增量计算所有节点的新位置
|
||||
const moves: Array<{ nodeId: string; position: Position }> = [];
|
||||
context.nodeStartPositions.forEach((startPos, nodeId) => {
|
||||
moves.push({
|
||||
nodeId,
|
||||
position: new Position(
|
||||
startPos.x + dragDelta.dx,
|
||||
startPos.y + dragDelta.dy
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
// 先重置拖拽状态,避免 moveNodes 触发重新渲染时位置计算错误
|
||||
setDragDelta({ dx: 0, dy: 0 });
|
||||
setIsDraggingNode(false);
|
||||
|
||||
// 然后更新节点位置
|
||||
nodeOperations.moveNodes(moves);
|
||||
|
||||
setTimeout(() => {
|
||||
sortChildrenByPosition();
|
||||
}, 0);
|
||||
} else {
|
||||
// 没有实际移动,直接重置状态
|
||||
setDragDelta({ dx: 0, dy: 0 });
|
||||
setIsDraggingNode(false);
|
||||
}
|
||||
|
||||
// 清理拖拽上下文
|
||||
dragContextRef.current = null;
|
||||
stopDragging();
|
||||
}, [draggingNodeId, dragDelta, nodeOperations, sortChildrenByPosition, setDragDelta, stopDragging, setIsDraggingNode]);
|
||||
|
||||
return {
|
||||
handleNodeMouseDown,
|
||||
handleNodeMouseMove,
|
||||
handleNodeMouseUp
|
||||
};
|
||||
}
|
||||
86
packages/behavior-tree-editor/src/hooks/useNodeOperations.ts
Normal file
86
packages/behavior-tree-editor/src/hooks/useNodeOperations.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { CommandManager } from '@esengine/editor-core';
|
||||
import { Position } from '../domain/value-objects/Position';
|
||||
import { INodeFactory } from '../domain/interfaces/INodeFactory';
|
||||
import { TreeStateAdapter } from '../application/state/BehaviorTreeDataStore';
|
||||
import { CreateNodeUseCase } from '../application/use-cases/CreateNodeUseCase';
|
||||
import { DeleteNodeUseCase } from '../application/use-cases/DeleteNodeUseCase';
|
||||
import { MoveNodeUseCase } from '../application/use-cases/MoveNodeUseCase';
|
||||
import { UpdateNodeDataUseCase } from '../application/use-cases/UpdateNodeDataUseCase';
|
||||
|
||||
/**
|
||||
* 节点操作 Hook
|
||||
*/
|
||||
export function useNodeOperations(
|
||||
nodeFactory: INodeFactory,
|
||||
commandManager: CommandManager
|
||||
) {
|
||||
const treeState = useMemo(() => TreeStateAdapter.getInstance(), []);
|
||||
|
||||
const createNodeUseCase = useMemo(
|
||||
() => new CreateNodeUseCase(nodeFactory, commandManager, treeState),
|
||||
[nodeFactory, commandManager, treeState]
|
||||
);
|
||||
|
||||
const deleteNodeUseCase = useMemo(
|
||||
() => new DeleteNodeUseCase(commandManager, treeState),
|
||||
[commandManager, treeState]
|
||||
);
|
||||
|
||||
const moveNodeUseCase = useMemo(
|
||||
() => new MoveNodeUseCase(commandManager, treeState),
|
||||
[commandManager, treeState]
|
||||
);
|
||||
|
||||
const updateNodeDataUseCase = useMemo(
|
||||
() => new UpdateNodeDataUseCase(commandManager, treeState),
|
||||
[commandManager, treeState]
|
||||
);
|
||||
|
||||
const createNode = useCallback((
|
||||
template: NodeTemplate,
|
||||
position: Position,
|
||||
data?: Record<string, unknown>
|
||||
) => {
|
||||
return createNodeUseCase.execute(template, position, data);
|
||||
}, [createNodeUseCase]);
|
||||
|
||||
const createNodeByType = useCallback((
|
||||
nodeType: string,
|
||||
position: Position,
|
||||
data?: Record<string, unknown>
|
||||
) => {
|
||||
return createNodeUseCase.executeByType(nodeType, position, data);
|
||||
}, [createNodeUseCase]);
|
||||
|
||||
const deleteNode = useCallback((nodeId: string) => {
|
||||
deleteNodeUseCase.execute(nodeId);
|
||||
}, [deleteNodeUseCase]);
|
||||
|
||||
const deleteNodes = useCallback((nodeIds: string[]) => {
|
||||
deleteNodeUseCase.executeBatch(nodeIds);
|
||||
}, [deleteNodeUseCase]);
|
||||
|
||||
const moveNode = useCallback((nodeId: string, position: Position) => {
|
||||
moveNodeUseCase.execute(nodeId, position);
|
||||
}, [moveNodeUseCase]);
|
||||
|
||||
const moveNodes = useCallback((moves: Array<{ nodeId: string; position: Position }>) => {
|
||||
moveNodeUseCase.executeBatch(moves);
|
||||
}, [moveNodeUseCase]);
|
||||
|
||||
const updateNodeData = useCallback((nodeId: string, data: Record<string, unknown>) => {
|
||||
updateNodeDataUseCase.execute(nodeId, data);
|
||||
}, [updateNodeDataUseCase]);
|
||||
|
||||
return useMemo(() => ({
|
||||
createNode,
|
||||
createNodeByType,
|
||||
deleteNode,
|
||||
deleteNodes,
|
||||
moveNode,
|
||||
moveNodes,
|
||||
updateNodeData
|
||||
}), [createNode, createNodeByType, deleteNode, deleteNodes, moveNode, moveNodes, updateNodeData]);
|
||||
}
|
||||
39
packages/behavior-tree-editor/src/hooks/useNodeTracking.ts
Normal file
39
packages/behavior-tree-editor/src/hooks/useNodeTracking.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { BehaviorTreeNode } from '../stores';
|
||||
import { ExecutionMode } from '../application/services/ExecutionController';
|
||||
|
||||
interface UseNodeTrackingParams {
|
||||
nodes: BehaviorTreeNode[];
|
||||
executionMode: ExecutionMode;
|
||||
}
|
||||
|
||||
export function useNodeTracking(params: UseNodeTrackingParams) {
|
||||
const { nodes, executionMode } = params;
|
||||
|
||||
const [uncommittedNodeIds, setUncommittedNodeIds] = useState<Set<string>>(new Set());
|
||||
const activeNodeIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (executionMode === 'idle') {
|
||||
setUncommittedNodeIds(new Set());
|
||||
activeNodeIdsRef.current = new Set(nodes.map((n) => n.id));
|
||||
} else if (executionMode === 'running' || executionMode === 'paused') {
|
||||
const currentNodeIds = new Set(nodes.map((n) => n.id));
|
||||
const newNodeIds = new Set<string>();
|
||||
|
||||
currentNodeIds.forEach((id) => {
|
||||
if (!activeNodeIdsRef.current.has(id)) {
|
||||
newNodeIds.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (newNodeIds.size > 0) {
|
||||
setUncommittedNodeIds((prev) => new Set([...prev, ...newNodeIds]));
|
||||
}
|
||||
}
|
||||
}, [nodes, executionMode]);
|
||||
|
||||
return {
|
||||
uncommittedNodeIds
|
||||
};
|
||||
}
|
||||
186
packages/behavior-tree-editor/src/hooks/usePortConnection.ts
Normal file
186
packages/behavior-tree-editor/src/hooks/usePortConnection.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { RefObject } from 'react';
|
||||
import { BehaviorTreeNode, Connection, ROOT_NODE_ID, useUIStore } from '../stores';
|
||||
import { PropertyDefinition } from '@esengine/behavior-tree';
|
||||
import { useConnectionOperations } from './useConnectionOperations';
|
||||
|
||||
interface UsePortConnectionParams {
|
||||
canvasRef: RefObject<HTMLDivElement>;
|
||||
canvasOffset: { x: number; y: number };
|
||||
canvasScale: number;
|
||||
nodes: BehaviorTreeNode[];
|
||||
connections: Connection[];
|
||||
connectingFrom: string | null;
|
||||
connectingFromProperty: string | null;
|
||||
connectionOperations: ReturnType<typeof useConnectionOperations>;
|
||||
setConnectingFrom: (nodeId: string | null) => void;
|
||||
setConnectingFromProperty: (propertyName: string | null) => void;
|
||||
clearConnecting: () => void;
|
||||
sortChildrenByPosition: () => void;
|
||||
showToast?: (message: string, type: 'success' | 'error' | 'info' | 'warning') => void;
|
||||
}
|
||||
|
||||
export function usePortConnection(params: UsePortConnectionParams) {
|
||||
const {
|
||||
canvasRef,
|
||||
nodes,
|
||||
connections,
|
||||
connectionOperations,
|
||||
setConnectingFrom,
|
||||
setConnectingFromProperty,
|
||||
clearConnecting,
|
||||
sortChildrenByPosition,
|
||||
showToast
|
||||
} = params;
|
||||
|
||||
const handlePortMouseDown = (e: React.MouseEvent, nodeId: string, propertyName?: string) => {
|
||||
e.stopPropagation();
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const portType = target.getAttribute('data-port-type');
|
||||
|
||||
setConnectingFrom(nodeId);
|
||||
setConnectingFromProperty(propertyName || null);
|
||||
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.setAttribute('data-connecting-from-port-type', portType || '');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePortMouseUp = (e: React.MouseEvent, nodeId: string, propertyName?: string) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 从 store 读取最新状态避免闭包陷阱
|
||||
const currentConnectingFrom = useUIStore.getState().connectingFrom;
|
||||
const currentConnectingFromProperty = useUIStore.getState().connectingFromProperty;
|
||||
|
||||
if (!currentConnectingFrom) {
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentConnectingFrom === nodeId) {
|
||||
showToast?.('不能将节点连接到自己', 'warning');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const toPortType = target.getAttribute('data-port-type');
|
||||
const fromPortType = canvasRef.current?.getAttribute('data-connecting-from-port-type');
|
||||
|
||||
let actualFrom = currentConnectingFrom;
|
||||
let actualTo = nodeId;
|
||||
let actualFromProperty = currentConnectingFromProperty;
|
||||
let actualToProperty = propertyName;
|
||||
|
||||
const needReverse =
|
||||
(fromPortType === 'node-input' || fromPortType === 'property-input') &&
|
||||
(toPortType === 'node-output' || toPortType === 'variable-output');
|
||||
|
||||
if (needReverse) {
|
||||
actualFrom = nodeId;
|
||||
actualTo = currentConnectingFrom;
|
||||
actualFromProperty = propertyName || null;
|
||||
actualToProperty = currentConnectingFromProperty ?? undefined;
|
||||
}
|
||||
|
||||
if (actualFromProperty || actualToProperty) {
|
||||
const existingConnection = connections.find(
|
||||
(conn: Connection) =>
|
||||
(conn.from === actualFrom && conn.to === actualTo &&
|
||||
conn.fromProperty === actualFromProperty && conn.toProperty === actualToProperty) ||
|
||||
(conn.from === actualTo && conn.to === actualFrom &&
|
||||
conn.fromProperty === actualToProperty && conn.toProperty === actualFromProperty)
|
||||
);
|
||||
|
||||
if (existingConnection) {
|
||||
showToast?.('该连接已存在', 'warning');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
|
||||
const toNode = nodes.find((n: BehaviorTreeNode) => n.id === actualTo);
|
||||
if (toNode && actualToProperty) {
|
||||
const targetProperty = toNode.template.properties.find(
|
||||
(p: PropertyDefinition) => p.name === actualToProperty
|
||||
);
|
||||
|
||||
if (!targetProperty?.allowMultipleConnections) {
|
||||
const existingPropertyConnection = connections.find(
|
||||
(conn: Connection) =>
|
||||
conn.connectionType === 'property' &&
|
||||
conn.to === actualTo &&
|
||||
conn.toProperty === actualToProperty
|
||||
);
|
||||
|
||||
if (existingPropertyConnection) {
|
||||
showToast?.('该属性已有连接,请先删除现有连接', 'warning');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
connectionOperations.addConnection(
|
||||
actualFrom,
|
||||
actualTo,
|
||||
'property',
|
||||
actualFromProperty || undefined,
|
||||
actualToProperty || undefined
|
||||
);
|
||||
} catch (error) {
|
||||
showToast?.(error instanceof Error ? error.message : '添加连接失败', 'error');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (actualFrom === ROOT_NODE_ID) {
|
||||
const rootNode = nodes.find((n: BehaviorTreeNode) => n.id === ROOT_NODE_ID);
|
||||
if (rootNode && rootNode.children.length > 0) {
|
||||
showToast?.('根节点只能连接一个子节点', 'warning');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const existingConnection = connections.find(
|
||||
(conn: Connection) =>
|
||||
(conn.from === actualFrom && conn.to === actualTo && conn.connectionType === 'node') ||
|
||||
(conn.from === actualTo && conn.to === actualFrom && conn.connectionType === 'node')
|
||||
);
|
||||
|
||||
if (existingConnection) {
|
||||
showToast?.('该连接已存在', 'warning');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
connectionOperations.addConnection(actualFrom, actualTo, 'node');
|
||||
|
||||
setTimeout(() => {
|
||||
sortChildrenByPosition();
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
showToast?.(error instanceof Error ? error.message : '添加连接失败', 'error');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
clearConnecting();
|
||||
};
|
||||
|
||||
const handleNodeMouseUpForConnection = (e: React.MouseEvent, nodeId: string) => {
|
||||
const currentConnectingFrom = useUIStore.getState().connectingFrom;
|
||||
if (currentConnectingFrom && currentConnectingFrom !== nodeId) {
|
||||
handlePortMouseUp(e, nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handlePortMouseDown,
|
||||
handlePortMouseUp,
|
||||
handleNodeMouseUpForConnection
|
||||
};
|
||||
}
|
||||
208
packages/behavior-tree-editor/src/hooks/useQuickCreateMenu.ts
Normal file
208
packages/behavior-tree-editor/src/hooks/useQuickCreateMenu.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState, RefObject } from 'react';
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { BehaviorTreeNode, Connection, useBehaviorTreeDataStore } from '../stores';
|
||||
import { Node } from '../domain/models/Node';
|
||||
import { Position } from '../domain/value-objects/Position';
|
||||
import { useNodeOperations } from './useNodeOperations';
|
||||
import { useConnectionOperations } from './useConnectionOperations';
|
||||
|
||||
interface QuickCreateMenuState {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
searchText: string;
|
||||
selectedIndex: number;
|
||||
mode: 'create' | 'replace';
|
||||
replaceNodeId: string | null;
|
||||
}
|
||||
|
||||
type ExecutionMode = 'idle' | 'running' | 'paused';
|
||||
|
||||
interface UseQuickCreateMenuParams {
|
||||
nodeOperations: ReturnType<typeof useNodeOperations>;
|
||||
connectionOperations: ReturnType<typeof useConnectionOperations>;
|
||||
canvasRef: RefObject<HTMLDivElement>;
|
||||
canvasOffset: { x: number; y: number };
|
||||
canvasScale: number;
|
||||
connectingFrom: string | null;
|
||||
connectingFromProperty: string | null;
|
||||
clearConnecting: () => void;
|
||||
nodes: BehaviorTreeNode[];
|
||||
connections: Connection[];
|
||||
executionMode: ExecutionMode;
|
||||
onStop: () => void;
|
||||
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
|
||||
showToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
export function useQuickCreateMenu(params: UseQuickCreateMenuParams) {
|
||||
const {
|
||||
nodeOperations,
|
||||
connectionOperations,
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
connectingFrom,
|
||||
connectingFromProperty,
|
||||
clearConnecting,
|
||||
nodes,
|
||||
connections,
|
||||
executionMode,
|
||||
onStop,
|
||||
onNodeCreate,
|
||||
showToast
|
||||
} = params;
|
||||
|
||||
const [quickCreateMenu, setQuickCreateMenu] = useState<QuickCreateMenuState>({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode: 'create',
|
||||
replaceNodeId: null
|
||||
});
|
||||
|
||||
const handleReplaceNode = (newTemplate: NodeTemplate) => {
|
||||
const nodeToReplace = nodes.find((n) => n.id === quickCreateMenu.replaceNodeId);
|
||||
if (!nodeToReplace) return;
|
||||
|
||||
// 如果行为树正在执行,先停止
|
||||
if (executionMode !== 'idle') {
|
||||
onStop();
|
||||
}
|
||||
|
||||
// 合并数据:新模板的默认配置 + 保留旧节点中同名属性的值
|
||||
const newData = { ...newTemplate.defaultConfig };
|
||||
|
||||
// 获取新模板的属性名列表
|
||||
const newPropertyNames = new Set(newTemplate.properties.map((p) => p.name));
|
||||
|
||||
// 遍历旧节点的 data,保留新模板中也存在的属性
|
||||
for (const [key, value] of Object.entries(nodeToReplace.data)) {
|
||||
// 跳过节点类型相关的字段
|
||||
if (key === 'nodeType' || key === 'compositeType' || key === 'decoratorType' ||
|
||||
key === 'actionType' || key === 'conditionType') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果新模板也有这个属性,保留旧值(包括绑定信息)
|
||||
if (newPropertyNames.has(key)) {
|
||||
newData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新节点,保留原节点的位置和连接
|
||||
const newNode = new Node(
|
||||
nodeToReplace.id,
|
||||
newTemplate,
|
||||
newData,
|
||||
nodeToReplace.position,
|
||||
Array.from(nodeToReplace.children)
|
||||
);
|
||||
|
||||
// 替换节点 - 通过 store 更新
|
||||
const store = useBehaviorTreeDataStore.getState();
|
||||
const updatedTree = store.tree.updateNode(newNode.id, () => newNode);
|
||||
store.setTree(updatedTree);
|
||||
|
||||
// 删除所有指向该节点的属性连接,让用户重新连接
|
||||
const propertyConnections = connections.filter((conn) =>
|
||||
conn.connectionType === 'property' && conn.to === newNode.id
|
||||
);
|
||||
propertyConnections.forEach((conn) => {
|
||||
connectionOperations.removeConnection(
|
||||
conn.from,
|
||||
conn.to,
|
||||
conn.fromProperty,
|
||||
conn.toProperty
|
||||
);
|
||||
});
|
||||
|
||||
// 关闭快速创建菜单
|
||||
closeQuickCreateMenu();
|
||||
|
||||
// 显示提示
|
||||
showToast?.(`已将节点替换为 ${newTemplate.displayName}`, 'success');
|
||||
};
|
||||
|
||||
const handleQuickCreateNode = (template: NodeTemplate) => {
|
||||
// 如果是替换模式,直接调用替换函数
|
||||
if (quickCreateMenu.mode === 'replace') {
|
||||
handleReplaceNode(template);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const posX = (quickCreateMenu.position.x - rect.left - canvasOffset.x) / canvasScale;
|
||||
const posY = (quickCreateMenu.position.y - rect.top - canvasOffset.y) / canvasScale;
|
||||
|
||||
const newNode = nodeOperations.createNode(
|
||||
template,
|
||||
new Position(posX, posY),
|
||||
template.defaultConfig
|
||||
);
|
||||
|
||||
// 如果有连接源,创建连接
|
||||
if (connectingFrom) {
|
||||
const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom);
|
||||
if (fromNode) {
|
||||
if (connectingFromProperty) {
|
||||
// 属性连接
|
||||
connectionOperations.addConnection(
|
||||
connectingFrom,
|
||||
newNode.id,
|
||||
'property',
|
||||
connectingFromProperty,
|
||||
undefined
|
||||
);
|
||||
} else {
|
||||
// 节点连接
|
||||
connectionOperations.addConnection(connectingFrom, newNode.id, 'node');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeQuickCreateMenu();
|
||||
|
||||
onNodeCreate?.(template, { x: posX, y: posY });
|
||||
};
|
||||
|
||||
const openQuickCreateMenu = (
|
||||
position: { x: number; y: number },
|
||||
mode: 'create' | 'replace',
|
||||
replaceNodeId?: string | null
|
||||
) => {
|
||||
setQuickCreateMenu({
|
||||
visible: true,
|
||||
position,
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode,
|
||||
replaceNodeId: replaceNodeId || null
|
||||
});
|
||||
};
|
||||
|
||||
const closeQuickCreateMenu = () => {
|
||||
setQuickCreateMenu({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode: 'create',
|
||||
replaceNodeId: null
|
||||
});
|
||||
clearConnecting();
|
||||
};
|
||||
|
||||
return {
|
||||
quickCreateMenu,
|
||||
setQuickCreateMenu,
|
||||
handleQuickCreateNode,
|
||||
handleReplaceNode,
|
||||
openQuickCreateMenu,
|
||||
closeQuickCreateMenu
|
||||
};
|
||||
}
|
||||
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';
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
|
||||
const logger = createLogger('EditorEventBus');
|
||||
|
||||
type EventHandler<T = any> = (data: T) => void;
|
||||
|
||||
interface Subscription {
|
||||
unsubscribe: () => void;
|
||||
}
|
||||
|
||||
export enum EditorEvent {
|
||||
NODE_CREATED = 'node:created',
|
||||
NODE_DELETED = 'node:deleted',
|
||||
NODE_UPDATED = 'node:updated',
|
||||
NODE_MOVED = 'node:moved',
|
||||
NODE_SELECTED = 'node:selected',
|
||||
|
||||
CONNECTION_ADDED = 'connection:added',
|
||||
CONNECTION_REMOVED = 'connection:removed',
|
||||
|
||||
EXECUTION_STARTED = 'execution:started',
|
||||
EXECUTION_PAUSED = 'execution:paused',
|
||||
EXECUTION_RESUMED = 'execution:resumed',
|
||||
EXECUTION_STOPPED = 'execution:stopped',
|
||||
EXECUTION_STEPPED = 'execution:stepped',
|
||||
EXECUTION_TICK = 'execution:tick',
|
||||
EXECUTION_NODE_STATUS_CHANGED = 'execution:node_status_changed',
|
||||
|
||||
TREE_SAVED = 'tree:saved',
|
||||
TREE_LOADED = 'tree:loaded',
|
||||
TREE_VALIDATED = 'tree:validated',
|
||||
|
||||
BLACKBOARD_VARIABLE_UPDATED = 'blackboard:variable_updated',
|
||||
BLACKBOARD_RESTORED = 'blackboard:restored',
|
||||
|
||||
CANVAS_ZOOM_CHANGED = 'canvas:zoom_changed',
|
||||
CANVAS_PAN_CHANGED = 'canvas:pan_changed',
|
||||
CANVAS_RESET = 'canvas:reset',
|
||||
|
||||
COMMAND_EXECUTED = 'command:executed',
|
||||
COMMAND_UNDONE = 'command:undone',
|
||||
COMMAND_REDONE = 'command:redone'
|
||||
}
|
||||
|
||||
export class EditorEventBus {
|
||||
private listeners: Map<string, Set<EventHandler>> = new Map();
|
||||
private eventHistory: Array<{ event: string; data: any; timestamp: number }> = [];
|
||||
private maxHistorySize: number = 100;
|
||||
|
||||
on<T = any>(event: EditorEvent | string, handler: EventHandler<T>): Subscription {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set());
|
||||
}
|
||||
|
||||
this.listeners.get(event)!.add(handler);
|
||||
|
||||
return {
|
||||
unsubscribe: () => this.off(event, handler)
|
||||
};
|
||||
}
|
||||
|
||||
once<T = any>(event: EditorEvent | string, handler: EventHandler<T>): Subscription {
|
||||
const wrappedHandler = (data: T) => {
|
||||
handler(data);
|
||||
this.off(event, wrappedHandler);
|
||||
};
|
||||
|
||||
return this.on(event, wrappedHandler);
|
||||
}
|
||||
|
||||
off<T = any>(event: EditorEvent | string, handler: EventHandler<T>): void {
|
||||
const handlers = this.listeners.get(event);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit<T = any>(event: EditorEvent | string, data?: T): void {
|
||||
if (this.eventHistory.length >= this.maxHistorySize) {
|
||||
this.eventHistory.shift();
|
||||
}
|
||||
this.eventHistory.push({
|
||||
event,
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
const handlers = this.listeners.get(event);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
logger.error(`Error in event handler for ${event}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clear(event?: EditorEvent | string): void {
|
||||
if (event) {
|
||||
this.listeners.delete(event);
|
||||
} else {
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
getListenerCount(event: EditorEvent | string): number {
|
||||
return this.listeners.get(event)?.size || 0;
|
||||
}
|
||||
|
||||
getAllEvents(): string[] {
|
||||
return Array.from(this.listeners.keys());
|
||||
}
|
||||
|
||||
getEventHistory(count?: number): Array<{ event: string; data: any; timestamp: number }> {
|
||||
if (count) {
|
||||
return this.eventHistory.slice(-count);
|
||||
}
|
||||
return [...this.eventHistory];
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
this.eventHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
let globalEventBus: EditorEventBus | null = null;
|
||||
|
||||
export function getGlobalEventBus(): EditorEventBus {
|
||||
if (!globalEventBus) {
|
||||
globalEventBus = new EditorEventBus();
|
||||
}
|
||||
return globalEventBus;
|
||||
}
|
||||
|
||||
export function resetGlobalEventBus(): void {
|
||||
globalEventBus = null;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree';
|
||||
import { Node } from '../../domain/models/Node';
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
|
||||
import { NodeRegistryService } from '../services/NodeRegistryService';
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
*/
|
||||
function generateUniqueId(): string {
|
||||
return `node-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点工厂实现
|
||||
*/
|
||||
export class NodeFactory implements INodeFactory {
|
||||
/**
|
||||
* 创建节点
|
||||
*/
|
||||
createNode(
|
||||
template: NodeTemplate,
|
||||
position: Position,
|
||||
data?: Record<string, unknown>
|
||||
): Node {
|
||||
const nodeId = generateUniqueId();
|
||||
const nodeData = {
|
||||
...template.defaultConfig,
|
||||
...data
|
||||
};
|
||||
|
||||
return new Node(nodeId, template, nodeData, position, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模板类型创建节点
|
||||
*/
|
||||
createNodeByType(
|
||||
nodeType: string,
|
||||
position: Position,
|
||||
data?: Record<string, unknown>
|
||||
): Node {
|
||||
const template = this.getTemplateByType(nodeType);
|
||||
if (!template) {
|
||||
throw new Error(`未找到节点模板: ${nodeType}`);
|
||||
}
|
||||
|
||||
return this.createNode(template, position, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆节点
|
||||
*/
|
||||
cloneNode(node: Node, newPosition?: Position): Node {
|
||||
const position = newPosition || node.position;
|
||||
const clonedId = generateUniqueId();
|
||||
|
||||
return new Node(
|
||||
clonedId,
|
||||
node.template,
|
||||
node.data,
|
||||
position,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的节点模板
|
||||
*/
|
||||
getAllTemplates(): NodeTemplate[] {
|
||||
const coreTemplates = NodeTemplates.getAllTemplates();
|
||||
const customTemplates = NodeRegistryService.getInstance().getCustomTemplates();
|
||||
return [...coreTemplates, ...customTemplates];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型获取模板
|
||||
*/
|
||||
private getTemplateByType(nodeType: string): NodeTemplate | null {
|
||||
const allTemplates = this.getAllTemplates();
|
||||
|
||||
const template = allTemplates.find((t: NodeTemplate) => {
|
||||
const defaultNodeType = t.defaultConfig.nodeType;
|
||||
return defaultNodeType === nodeType;
|
||||
});
|
||||
|
||||
return template || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据实现类型获取模板
|
||||
*/
|
||||
getTemplateByImplementationType(implementationType: string): NodeTemplate | null {
|
||||
const allTemplates = this.getAllTemplates();
|
||||
return allTemplates.find(t => t.className === implementationType) || null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { NodeFactory } from './NodeFactory';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './factories';
|
||||
export * from './serialization';
|
||||
@@ -0,0 +1,127 @@
|
||||
import { BehaviorTree } from '../../domain/models/BehaviorTree';
|
||||
import { ISerializer, SerializationFormat } from '../../domain/interfaces/ISerializer';
|
||||
import { BehaviorTreeAssetSerializer, EditorFormatConverter } from '@esengine/behavior-tree';
|
||||
|
||||
/**
|
||||
* 序列化选项
|
||||
*/
|
||||
export interface SerializationOptions {
|
||||
/**
|
||||
* 资产版本号
|
||||
*/
|
||||
version?: string;
|
||||
|
||||
/**
|
||||
* 资产名称
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* 资产描述
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createdAt?: string;
|
||||
|
||||
/**
|
||||
* 修改时间
|
||||
*/
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树序列化器实现
|
||||
*/
|
||||
export class BehaviorTreeSerializer implements ISerializer {
|
||||
private readonly defaultOptions: Required<SerializationOptions> = {
|
||||
version: '1.0.0',
|
||||
name: 'Untitled Behavior Tree',
|
||||
description: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
constructor(private readonly options: SerializationOptions = {}) {
|
||||
this.defaultOptions = { ...this.defaultOptions, ...options };
|
||||
}
|
||||
/**
|
||||
* 序列化行为树
|
||||
*/
|
||||
serialize(tree: BehaviorTree, format: SerializationFormat): string | Uint8Array {
|
||||
const treeObject = tree.toObject();
|
||||
|
||||
if (format === 'json') {
|
||||
return JSON.stringify(treeObject, null, 2);
|
||||
}
|
||||
|
||||
throw new Error(`不支持的序列化格式: ${format}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 反序列化行为树
|
||||
*/
|
||||
deserialize(data: string | Uint8Array, format: SerializationFormat): BehaviorTree {
|
||||
if (format === 'json') {
|
||||
if (typeof data !== 'string') {
|
||||
throw new Error('JSON 格式需要字符串数据');
|
||||
}
|
||||
|
||||
const obj = JSON.parse(data);
|
||||
return BehaviorTree.fromObject(obj);
|
||||
}
|
||||
|
||||
throw new Error(`不支持的反序列化格式: ${format}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为运行时资产格式
|
||||
* @param tree 行为树
|
||||
* @param format 导出格式
|
||||
* @param options 可选的序列化选项(覆盖默认值)
|
||||
*/
|
||||
exportToRuntimeAsset(
|
||||
tree: BehaviorTree,
|
||||
format: SerializationFormat,
|
||||
options?: SerializationOptions
|
||||
): string | Uint8Array {
|
||||
const nodes = tree.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
template: node.template,
|
||||
data: node.data,
|
||||
position: node.position.toObject(),
|
||||
children: Array.from(node.children)
|
||||
}));
|
||||
|
||||
const connections = tree.connections.map((conn) => conn.toObject());
|
||||
const blackboard = tree.blackboard.toObject();
|
||||
|
||||
const finalOptions = { ...this.defaultOptions, ...options };
|
||||
finalOptions.modifiedAt = new Date().toISOString();
|
||||
|
||||
const editorFormat = {
|
||||
version: finalOptions.version,
|
||||
metadata: {
|
||||
name: finalOptions.name,
|
||||
description: finalOptions.description,
|
||||
createdAt: finalOptions.createdAt,
|
||||
modifiedAt: finalOptions.modifiedAt
|
||||
},
|
||||
nodes,
|
||||
connections,
|
||||
blackboard
|
||||
};
|
||||
|
||||
const asset = EditorFormatConverter.toAsset(editorFormat);
|
||||
|
||||
if (format === 'json') {
|
||||
return BehaviorTreeAssetSerializer.serialize(asset, { format: 'json', pretty: true });
|
||||
} else if (format === 'binary') {
|
||||
return BehaviorTreeAssetSerializer.serialize(asset, { format: 'binary' });
|
||||
}
|
||||
|
||||
throw new Error(`不支持的导出格式: ${format}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BehaviorTreeSerializer } from './BehaviorTreeSerializer';
|
||||
@@ -0,0 +1,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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { IValidator, ValidationResult, ValidationError } from '../../domain/interfaces/IValidator';
|
||||
import { BehaviorTree } from '../../domain/models/BehaviorTree';
|
||||
import { Node } from '../../domain/models/Node';
|
||||
import { Connection } from '../../domain/models/Connection';
|
||||
|
||||
/**
|
||||
* 行为树验证器实现
|
||||
*/
|
||||
export class BehaviorTreeValidator implements IValidator {
|
||||
/**
|
||||
* 验证整个行为树
|
||||
*/
|
||||
validateTree(tree: BehaviorTree): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// 验证所有节点
|
||||
for (const node of tree.nodes) {
|
||||
const nodeResult = this.validateNode(node);
|
||||
errors.push(...nodeResult.errors);
|
||||
}
|
||||
|
||||
// 验证所有连接
|
||||
for (const connection of tree.connections) {
|
||||
const connResult = this.validateConnection(connection, tree);
|
||||
errors.push(...connResult.errors);
|
||||
}
|
||||
|
||||
// 验证循环引用
|
||||
const cycleResult = this.validateNoCycles(tree);
|
||||
errors.push(...cycleResult.errors);
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证节点
|
||||
*/
|
||||
validateNode(node: Node): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// 验证节点必填字段
|
||||
if (!node.id) {
|
||||
errors.push({
|
||||
message: '节点 ID 不能为空',
|
||||
nodeId: node.id
|
||||
});
|
||||
}
|
||||
|
||||
if (!node.template) {
|
||||
errors.push({
|
||||
message: '节点模板不能为空',
|
||||
nodeId: node.id
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证连接
|
||||
*/
|
||||
validateConnection(connection: Connection, tree: BehaviorTree): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// 验证连接的源节点和目标节点都存在
|
||||
const fromNode = tree.nodes.find((n) => n.id === connection.from);
|
||||
const toNode = tree.nodes.find((n) => n.id === connection.to);
|
||||
|
||||
if (!fromNode) {
|
||||
errors.push({
|
||||
message: `连接的源节点不存在: ${connection.from}`
|
||||
});
|
||||
}
|
||||
|
||||
if (!toNode) {
|
||||
errors.push({
|
||||
message: `连接的目标节点不存在: ${connection.to}`
|
||||
});
|
||||
}
|
||||
|
||||
// 不能自己连接自己
|
||||
if (connection.from === connection.to) {
|
||||
errors.push({
|
||||
message: '节点不能连接到自己',
|
||||
nodeId: connection.from
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证是否会产生循环引用
|
||||
*/
|
||||
validateNoCycles(tree: BehaviorTree): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
|
||||
const hasCycle = (nodeId: string): boolean => {
|
||||
if (recursionStack.has(nodeId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (visited.has(nodeId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
visited.add(nodeId);
|
||||
recursionStack.add(nodeId);
|
||||
|
||||
const node = tree.nodes.find((n) => n.id === nodeId);
|
||||
if (node) {
|
||||
for (const childId of node.children) {
|
||||
if (hasCycle(childId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(nodeId);
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const node of tree.nodes) {
|
||||
if (hasCycle(node.id)) {
|
||||
errors.push({
|
||||
message: '行为树中存在循环引用',
|
||||
nodeId: node.id
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { Node as BehaviorTreeNode } from '../domain/models/Node';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
|
||||
const logger = createLogger('IEditorExtensions');
|
||||
|
||||
export interface INodeRenderer {
|
||||
canRender(node: BehaviorTreeNode): boolean;
|
||||
|
||||
render(node: BehaviorTreeNode, context: NodeRenderContext): React.ReactElement;
|
||||
}
|
||||
|
||||
export interface NodeRenderContext {
|
||||
isSelected: boolean;
|
||||
isExecuting: boolean;
|
||||
onNodeClick: (e: React.MouseEvent, node: BehaviorTreeNode) => void;
|
||||
onContextMenu: (e: React.MouseEvent, node: BehaviorTreeNode) => void;
|
||||
}
|
||||
|
||||
export interface IPropertyEditor {
|
||||
canEdit(propertyType: string): boolean;
|
||||
|
||||
render(property: PropertyEditorProps): React.ReactElement;
|
||||
}
|
||||
|
||||
export type PropertyValue = string | number | boolean | object | null | undefined;
|
||||
|
||||
export interface PropertyEditorProps<T = PropertyValue> {
|
||||
propertyName: string;
|
||||
propertyType: string;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
config?: Record<string, PropertyValue>;
|
||||
}
|
||||
|
||||
export interface INodeProvider {
|
||||
getNodeTemplates(): NodeTemplate[];
|
||||
|
||||
getCategory(): string;
|
||||
|
||||
getIcon(): string | LucideIcon;
|
||||
}
|
||||
|
||||
export interface IToolbarButton {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
tooltip?: string;
|
||||
onClick: () => void;
|
||||
isVisible?: () => boolean;
|
||||
isEnabled?: () => boolean;
|
||||
}
|
||||
|
||||
export interface IPanelProvider {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
|
||||
render(): React.ReactElement;
|
||||
|
||||
canActivate?(): boolean;
|
||||
}
|
||||
|
||||
export interface IEditorValidator {
|
||||
name: string;
|
||||
|
||||
validate(nodes: BehaviorTreeNode[]): EditorValidationResult[];
|
||||
}
|
||||
|
||||
export interface EditorValidationResult {
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
nodeId?: string;
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface ICommandProvider {
|
||||
getCommandId(): string;
|
||||
|
||||
getCommandName(): string;
|
||||
|
||||
getShortcut?(): string;
|
||||
|
||||
canExecute?(): boolean;
|
||||
|
||||
execute(context: CommandExecutionContext): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface CommandExecutionContext {
|
||||
selectedNodeIds: string[];
|
||||
nodes: BehaviorTreeNode[];
|
||||
currentFile?: string;
|
||||
}
|
||||
|
||||
export class EditorExtensionRegistry {
|
||||
private nodeRenderers: Set<INodeRenderer> = new Set();
|
||||
private propertyEditors: Set<IPropertyEditor> = new Set();
|
||||
private nodeProviders: Set<INodeProvider> = new Set();
|
||||
private toolbarButtons: Set<IToolbarButton> = new Set();
|
||||
private panelProviders: Set<IPanelProvider> = new Set();
|
||||
private validators: Set<IEditorValidator> = new Set();
|
||||
private commandProviders: Set<ICommandProvider> = new Set();
|
||||
|
||||
registerNodeRenderer(renderer: INodeRenderer): void {
|
||||
this.nodeRenderers.add(renderer);
|
||||
}
|
||||
|
||||
unregisterNodeRenderer(renderer: INodeRenderer): void {
|
||||
this.nodeRenderers.delete(renderer);
|
||||
}
|
||||
|
||||
getNodeRenderer(node: BehaviorTreeNode): INodeRenderer | undefined {
|
||||
for (const renderer of this.nodeRenderers) {
|
||||
if (renderer.canRender(node)) {
|
||||
return renderer;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
registerPropertyEditor(editor: IPropertyEditor): void {
|
||||
this.propertyEditors.add(editor);
|
||||
}
|
||||
|
||||
unregisterPropertyEditor(editor: IPropertyEditor): void {
|
||||
this.propertyEditors.delete(editor);
|
||||
}
|
||||
|
||||
getPropertyEditor(propertyType: string): IPropertyEditor | undefined {
|
||||
for (const editor of this.propertyEditors) {
|
||||
if (editor.canEdit(propertyType)) {
|
||||
return editor;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
registerNodeProvider(provider: INodeProvider): void {
|
||||
this.nodeProviders.add(provider);
|
||||
}
|
||||
|
||||
unregisterNodeProvider(provider: INodeProvider): void {
|
||||
this.nodeProviders.delete(provider);
|
||||
}
|
||||
|
||||
getAllNodeTemplates(): NodeTemplate[] {
|
||||
const templates: NodeTemplate[] = [];
|
||||
this.nodeProviders.forEach((provider) => {
|
||||
templates.push(...provider.getNodeTemplates());
|
||||
});
|
||||
return templates;
|
||||
}
|
||||
|
||||
registerToolbarButton(button: IToolbarButton): void {
|
||||
this.toolbarButtons.add(button);
|
||||
}
|
||||
|
||||
unregisterToolbarButton(button: IToolbarButton): void {
|
||||
this.toolbarButtons.delete(button);
|
||||
}
|
||||
|
||||
getToolbarButtons(): IToolbarButton[] {
|
||||
return Array.from(this.toolbarButtons).filter((btn) => {
|
||||
return btn.isVisible ? btn.isVisible() : true;
|
||||
});
|
||||
}
|
||||
|
||||
registerPanelProvider(provider: IPanelProvider): void {
|
||||
this.panelProviders.add(provider);
|
||||
}
|
||||
|
||||
unregisterPanelProvider(provider: IPanelProvider): void {
|
||||
this.panelProviders.delete(provider);
|
||||
}
|
||||
|
||||
getPanelProviders(): IPanelProvider[] {
|
||||
return Array.from(this.panelProviders).filter((panel) => {
|
||||
return panel.canActivate ? panel.canActivate() : true;
|
||||
});
|
||||
}
|
||||
|
||||
registerValidator(validator: IEditorValidator): void {
|
||||
this.validators.add(validator);
|
||||
}
|
||||
|
||||
unregisterValidator(validator: IEditorValidator): void {
|
||||
this.validators.delete(validator);
|
||||
}
|
||||
|
||||
async validateTree(nodes: BehaviorTreeNode[]): Promise<EditorValidationResult[]> {
|
||||
const results: EditorValidationResult[] = [];
|
||||
for (const validator of this.validators) {
|
||||
try {
|
||||
const validationResults = validator.validate(nodes);
|
||||
results.push(...validationResults);
|
||||
} catch (error) {
|
||||
logger.error(`Error in validator ${validator.name}:`, error);
|
||||
results.push({
|
||||
severity: 'error',
|
||||
message: `Validator ${validator.name} failed: ${error}`,
|
||||
code: 'VALIDATOR_ERROR'
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
registerCommandProvider(provider: ICommandProvider): void {
|
||||
this.commandProviders.add(provider);
|
||||
}
|
||||
|
||||
unregisterCommandProvider(provider: ICommandProvider): void {
|
||||
this.commandProviders.delete(provider);
|
||||
}
|
||||
|
||||
getCommandProvider(commandId: string): ICommandProvider | undefined {
|
||||
for (const provider of this.commandProviders) {
|
||||
if (provider.getCommandId() === commandId) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getAllCommandProviders(): ICommandProvider[] {
|
||||
return Array.from(this.commandProviders);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.nodeRenderers.clear();
|
||||
this.propertyEditors.clear();
|
||||
this.nodeProviders.clear();
|
||||
this.toolbarButtons.clear();
|
||||
this.panelProviders.clear();
|
||||
this.validators.clear();
|
||||
this.commandProviders.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let globalExtensionRegistry: EditorExtensionRegistry | null = null;
|
||||
|
||||
export function getGlobalExtensionRegistry(): EditorExtensionRegistry {
|
||||
if (!globalExtensionRegistry) {
|
||||
globalExtensionRegistry = new EditorExtensionRegistry();
|
||||
}
|
||||
return globalExtensionRegistry;
|
||||
}
|
||||
|
||||
export function resetGlobalExtensionRegistry(): void {
|
||||
globalExtensionRegistry = null;
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { IInspectorProvider, InspectorContext, MessageHub } from '@esengine/editor-core';
|
||||
import { Node as BehaviorTreeNode } from '../domain/models/Node';
|
||||
import { PropertyDefinition } from '@esengine/behavior-tree';
|
||||
|
||||
/**
|
||||
* 节点属性编辑器组件
|
||||
*/
|
||||
interface PropertyEditorProps {
|
||||
property: PropertyDefinition;
|
||||
value: any;
|
||||
onChange: (name: string, value: any) => void;
|
||||
}
|
||||
|
||||
const PropertyEditor: React.FC<PropertyEditorProps> = ({ property, value, onChange }) => {
|
||||
const handleChange = useCallback((newValue: any) => {
|
||||
onChange(property.name, newValue);
|
||||
}, [property.name, onChange]);
|
||||
|
||||
const renderInput = () => {
|
||||
switch (property.type) {
|
||||
case 'number':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value ?? property.defaultValue ?? 0}
|
||||
min={property.min}
|
||||
max={property.max}
|
||||
step={property.step || 1}
|
||||
onChange={(e) => handleChange(parseFloat(e.target.value) || 0)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#2a2a2a',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '3px',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value ?? property.defaultValue ?? false}
|
||||
onChange={(e) => handleChange(e.target.checked)}
|
||||
/>
|
||||
<span style={{ fontSize: '11px', color: value ? '#4ade80' : '#888' }}>
|
||||
{value ? '是' : '否'}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<select
|
||||
value={value ?? property.defaultValue ?? ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#2a2a2a',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '3px',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
{property.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case 'code':
|
||||
return (
|
||||
<textarea
|
||||
value={value ?? property.defaultValue ?? ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '3px',
|
||||
color: '#d4d4d4',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'Consolas, Monaco, monospace',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? property.defaultValue ?? ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#2a2a2a',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '3px',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field" style={{
|
||||
marginBottom: '6px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '120px 1fr',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<label
|
||||
className="property-label"
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: '#999',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
title={property.description || property.label || property.name}
|
||||
>
|
||||
{property.label || property.name}
|
||||
{property.required && <span style={{ color: '#f87171', marginLeft: '2px' }}>*</span>}
|
||||
</label>
|
||||
{renderInput()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 节点属性面板组件
|
||||
*/
|
||||
interface NodePropertiesPanelProps {
|
||||
node: BehaviorTreeNode;
|
||||
onPropertyChange?: (nodeId: string, propertyName: string, value: any) => void;
|
||||
}
|
||||
|
||||
const NodePropertiesPanel: React.FC<NodePropertiesPanelProps> = ({ node, onPropertyChange }) => {
|
||||
const [localData, setLocalData] = useState<Record<string, any>>(node.data);
|
||||
|
||||
const handlePropertyChange = useCallback((name: string, value: any) => {
|
||||
setLocalData(prev => ({ ...prev, [name]: value }));
|
||||
onPropertyChange?.(node.id, name, value);
|
||||
}, [node.id, onPropertyChange]);
|
||||
|
||||
const properties = node.template.properties || [];
|
||||
|
||||
if (properties.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '12px', color: '#888', fontSize: '12px', textAlign: 'center' }}>
|
||||
该节点没有可配置的属性
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{properties.map((prop) => (
|
||||
<PropertyEditor
|
||||
key={prop.name}
|
||||
property={prop}
|
||||
value={localData[prop.name]}
|
||||
onChange={handlePropertyChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 行为树节点Inspector提供器
|
||||
* 为行为树节点提供检视面板
|
||||
*/
|
||||
export class BehaviorTreeNodeInspectorProvider implements IInspectorProvider<BehaviorTreeNode> {
|
||||
readonly id = 'behavior-tree-node-inspector';
|
||||
readonly name = '行为树节点检视器';
|
||||
readonly priority = 100;
|
||||
private messageHub?: MessageHub;
|
||||
|
||||
setMessageHub(hub: MessageHub): void {
|
||||
this.messageHub = hub;
|
||||
}
|
||||
|
||||
canHandle(target: unknown): target is BehaviorTreeNode {
|
||||
return target instanceof BehaviorTreeNode ||
|
||||
(typeof target === 'object' &&
|
||||
target !== null &&
|
||||
'template' in target &&
|
||||
'data' in target &&
|
||||
'position' in target &&
|
||||
'children' in target);
|
||||
}
|
||||
|
||||
render(node: BehaviorTreeNode, context: InspectorContext): React.ReactElement {
|
||||
const handlePropertyChange = (nodeId: string, propertyName: string, value: any) => {
|
||||
if (this.messageHub) {
|
||||
this.messageHub.publish('behavior-tree:node-property-changed', {
|
||||
nodeId,
|
||||
propertyName,
|
||||
value
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
<span className="entity-name">{node.template.displayName || '未命名节点'}</span>
|
||||
</div>
|
||||
|
||||
<div className="inspector-content">
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">基本信息</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '80px 1fr',
|
||||
gap: '4px 8px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<span style={{ color: '#888' }}>类型</span>
|
||||
<span style={{ color: '#e0e0e0' }}>{node.template.type}</span>
|
||||
|
||||
<span style={{ color: '#888' }}>分类</span>
|
||||
<span style={{ color: '#e0e0e0' }}>{node.template.category}</span>
|
||||
|
||||
{node.template.description && (
|
||||
<>
|
||||
<span style={{ color: '#888' }}>描述</span>
|
||||
<span
|
||||
style={{
|
||||
color: '#999',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
title={node.template.description}
|
||||
>
|
||||
{node.template.description}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{node.template.properties && node.template.properties.length > 0 && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">节点属性</div>
|
||||
<NodePropertiesPanel
|
||||
node={node}
|
||||
onPropertyChange={handlePropertyChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">调试信息</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '80px 1fr',
|
||||
gap: '4px 8px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<span style={{ color: '#888' }}>ID</span>
|
||||
<span style={{
|
||||
fontFamily: 'Consolas, Monaco, monospace',
|
||||
color: '#666',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}} title={node.id}>
|
||||
{node.id}
|
||||
</span>
|
||||
|
||||
<span style={{ color: '#888' }}>位置</span>
|
||||
<span style={{ color: '#999' }}>
|
||||
({node.position.x.toFixed(0)}, {node.position.y.toFixed(0)})
|
||||
</span>
|
||||
|
||||
<span style={{ color: '#888' }}>子节点</span>
|
||||
<span style={{ color: '#e0e0e0' }}>{node.children.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { singleton } from 'tsyringe';
|
||||
import { Core, IService, createLogger } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { useBehaviorTreeDataStore } from '../application/state/BehaviorTreeDataStore';
|
||||
import type { BehaviorTree } from '../domain/models/BehaviorTree';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
|
||||
const logger = createLogger('BehaviorTreeService');
|
||||
|
||||
@singleton()
|
||||
export class BehaviorTreeService implements IService {
|
||||
async createNew(): Promise<void> {
|
||||
useBehaviorTreeDataStore.getState().reset();
|
||||
}
|
||||
|
||||
async loadFromFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
// 运行时解析 FileSystemService
|
||||
const fileSystem = Core.services.resolve(FileSystemService);
|
||||
if (!fileSystem) {
|
||||
throw new Error('FileSystemService not found. Please ensure the BehaviorTreePlugin is properly installed.');
|
||||
}
|
||||
|
||||
const content = await fileSystem.readBehaviorTreeFile(filePath);
|
||||
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || 'Untitled';
|
||||
|
||||
const store = useBehaviorTreeDataStore.getState();
|
||||
store.importFromJSON(content);
|
||||
// 在 store 中保存文件信息,Panel 挂载时读取
|
||||
store.setCurrentFile(filePath, fileName);
|
||||
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('dynamic-panel:open', {
|
||||
panelId: 'behavior-tree-editor',
|
||||
title: `Behavior Tree - ${filePath.split(/[\\/]/).pop()}`
|
||||
});
|
||||
|
||||
// 保留事件发布,以防 Panel 已挂载
|
||||
messageHub.publish('behavior-tree:file-opened', {
|
||||
filePath,
|
||||
fileName
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load tree:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveToFile(filePath: string, metadata?: { name: string; description: string }): Promise<void> {
|
||||
try {
|
||||
// 运行时解析 FileSystemService
|
||||
const fileSystem = Core.services.resolve(FileSystemService);
|
||||
if (!fileSystem) {
|
||||
throw new Error('FileSystemService not found. Please ensure the BehaviorTreePlugin is properly installed.');
|
||||
}
|
||||
|
||||
const store = useBehaviorTreeDataStore.getState();
|
||||
|
||||
// 如果没有提供元数据,使用文件名作为默认名称
|
||||
const defaultMetadata = {
|
||||
name: metadata?.name || filePath.split(/[\\/]/).pop()?.replace('.btree', '') || 'Untitled',
|
||||
description: metadata?.description || ''
|
||||
};
|
||||
|
||||
const content = store.exportToJSON(defaultMetadata);
|
||||
await fileSystem.writeBehaviorTreeFile(filePath, content);
|
||||
|
||||
logger.info('Tree saved successfully:', filePath);
|
||||
} catch (error) {
|
||||
logger.error('Failed to save tree:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentTree(): BehaviorTree {
|
||||
return useBehaviorTreeDataStore.getState().tree;
|
||||
}
|
||||
|
||||
setTree(tree: BehaviorTree): void {
|
||||
useBehaviorTreeDataStore.getState().setTree(tree);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
useBehaviorTreeDataStore.getState().reset();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { singleton } from 'tsyringe';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { IService } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 文件系统服务
|
||||
* 封装所有文件读写操作,使用通用后端命令
|
||||
*/
|
||||
@singleton()
|
||||
export class FileSystemService implements IService {
|
||||
/**
|
||||
* 读取行为树文件
|
||||
*/
|
||||
async readBehaviorTreeFile(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await invoke<string>('read_file_content', { path: filePath });
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read file ${filePath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入行为树文件
|
||||
*/
|
||||
async writeBehaviorTreeFile(filePath: string, content: string): Promise<void> {
|
||||
try {
|
||||
await invoke('write_file_content', { path: filePath, content });
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to write file ${filePath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取全局黑板配置
|
||||
* 业务逻辑在前端,后端只提供通用文件操作
|
||||
*/
|
||||
async readGlobalBlackboard(projectPath: string): Promise<string> {
|
||||
try {
|
||||
const configPath = `${projectPath}/.ecs/global-blackboard.json`;
|
||||
const exists = await invoke<boolean>('path_exists', { path: configPath });
|
||||
|
||||
if (!exists) {
|
||||
return JSON.stringify({ version: '1.0', variables: [] });
|
||||
}
|
||||
|
||||
return await invoke<string>('read_file_content', { path: configPath });
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read global blackboard: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入全局黑板配置
|
||||
* 业务逻辑在前端,后端只提供通用文件操作
|
||||
*/
|
||||
async writeGlobalBlackboard(projectPath: string, content: string): Promise<void> {
|
||||
try {
|
||||
const ecsDir = `${projectPath}/.ecs`;
|
||||
const configPath = `${ecsDir}/global-blackboard.json`;
|
||||
|
||||
// 创建 .ecs 目录(如果不存在)
|
||||
const dirExists = await invoke<boolean>('path_exists', { path: ecsDir });
|
||||
if (!dirExists) {
|
||||
await invoke('create_directory', { path: ecsDir });
|
||||
}
|
||||
|
||||
await invoke('write_file_content', { path: configPath, content });
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to write global blackboard: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async writeTextFile(filePath: string, content: string): Promise<void> {
|
||||
try {
|
||||
await invoke('write_file_content', { path: filePath, content });
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to write text file ${filePath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async writeBinaryFile(filePath: string, data: Uint8Array): Promise<void> {
|
||||
try {
|
||||
await invoke('write_binary_file', { filePath, content: Array.from(data) });
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to write binary file ${filePath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// 文件系统服务无需清理资源
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Core, createLogger } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
|
||||
const logger = createLogger('NotificationService');
|
||||
|
||||
export class NotificationService {
|
||||
private static instance: NotificationService;
|
||||
private messageHub: MessageHub | null = null;
|
||||
|
||||
private constructor() {
|
||||
// 尝试从 Core 获取 MessageHub
|
||||
try {
|
||||
this.messageHub = Core.services.resolve(MessageHub);
|
||||
} catch (error) {
|
||||
logger.warn('MessageHub not available, toast notifications will be disabled');
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): NotificationService {
|
||||
if (!NotificationService.instance) {
|
||||
NotificationService.instance = new NotificationService();
|
||||
}
|
||||
return NotificationService.instance;
|
||||
}
|
||||
|
||||
public showToast(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info'): void {
|
||||
if (!this.messageHub) {
|
||||
logger.info(`[Toast ${type}] ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = {
|
||||
type,
|
||||
message,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.messageHub.publish('notification:show', notification);
|
||||
}
|
||||
|
||||
public success(message: string): void {
|
||||
this.showToast(message, 'success');
|
||||
}
|
||||
|
||||
public error(message: string): void {
|
||||
this.showToast(message, 'error');
|
||||
}
|
||||
|
||||
public warning(message: string): void {
|
||||
this.showToast(message, 'warning');
|
||||
}
|
||||
|
||||
public info(message: string): void {
|
||||
this.showToast(message, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例的便捷方法
|
||||
export const showToast = (message: string, type?: 'success' | 'error' | 'warning' | 'info') => {
|
||||
NotificationService.getInstance().showToast(message, type);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user