Refactor/clean architecture phase1 (#215)
* refactor(editor): 建立Clean Architecture领域模型层 * refactor(editor): 实现应用层架构 - 命令模式、用例和状态管理 * refactor(editor): 实现展示层核心Hooks * refactor(editor): 实现基础设施层和展示层组件 * refactor(editor): 迁移画布和连接渲染到 Clean Architecture 组件 * feat(editor): 集成应用层架构和命令模式,实现撤销/重做功能 * refactor(editor): UI组件拆分 * refactor(editor): 提取快速创建菜单逻辑 * refactor(editor): 重构BehaviorTreeEditor,提取组件和Hook * refactor(editor): 提取端口连接和键盘事件Hook * refactor(editor): 提取拖放处理Hook * refactor(editor): 提取画布交互Hook和工具函数 * refactor(editor): 完成核心重构 * fix(editor): 修复节点无法创建和连接 * refactor(behavior-tree,editor): 重构节点子节点约束系统,实现元数据驱动的架构
This commit is contained in:
137
packages/editor-app/src/infrastructure/events/EditorEventBus.ts
Normal file
137
packages/editor-app/src/infrastructure/events/EditorEventBus.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
type EventHandler<T = any> = (data: T) => void;
|
||||
|
||||
interface Subscription {
|
||||
unsubscribe: () => void;
|
||||
}
|
||||
|
||||
export enum EditorEvent {
|
||||
NODE_CREATED = 'node:created',
|
||||
NODE_DELETED = 'node:deleted',
|
||||
NODE_UPDATED = 'node:updated',
|
||||
NODE_MOVED = 'node:moved',
|
||||
NODE_SELECTED = 'node:selected',
|
||||
|
||||
CONNECTION_ADDED = 'connection:added',
|
||||
CONNECTION_REMOVED = 'connection:removed',
|
||||
|
||||
EXECUTION_STARTED = 'execution:started',
|
||||
EXECUTION_PAUSED = 'execution:paused',
|
||||
EXECUTION_RESUMED = 'execution:resumed',
|
||||
EXECUTION_STOPPED = 'execution:stopped',
|
||||
EXECUTION_TICK = 'execution:tick',
|
||||
EXECUTION_NODE_STATUS_CHANGED = 'execution:node_status_changed',
|
||||
|
||||
TREE_SAVED = 'tree:saved',
|
||||
TREE_LOADED = 'tree:loaded',
|
||||
TREE_VALIDATED = 'tree:validated',
|
||||
|
||||
BLACKBOARD_VARIABLE_UPDATED = 'blackboard:variable_updated',
|
||||
BLACKBOARD_RESTORED = 'blackboard:restored',
|
||||
|
||||
CANVAS_ZOOM_CHANGED = 'canvas:zoom_changed',
|
||||
CANVAS_PAN_CHANGED = 'canvas:pan_changed',
|
||||
CANVAS_RESET = 'canvas:reset',
|
||||
|
||||
COMMAND_EXECUTED = 'command:executed',
|
||||
COMMAND_UNDONE = 'command:undone',
|
||||
COMMAND_REDONE = 'command:redone'
|
||||
}
|
||||
|
||||
export class EditorEventBus {
|
||||
private listeners: Map<string, Set<EventHandler>> = new Map();
|
||||
private eventHistory: Array<{ event: string; data: any; timestamp: number }> = [];
|
||||
private maxHistorySize: number = 100;
|
||||
|
||||
on<T = any>(event: EditorEvent | string, handler: EventHandler<T>): Subscription {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set());
|
||||
}
|
||||
|
||||
this.listeners.get(event)!.add(handler);
|
||||
|
||||
return {
|
||||
unsubscribe: () => this.off(event, handler)
|
||||
};
|
||||
}
|
||||
|
||||
once<T = any>(event: EditorEvent | string, handler: EventHandler<T>): Subscription {
|
||||
const wrappedHandler = (data: T) => {
|
||||
handler(data);
|
||||
this.off(event, wrappedHandler);
|
||||
};
|
||||
|
||||
return this.on(event, wrappedHandler);
|
||||
}
|
||||
|
||||
off<T = any>(event: EditorEvent | string, handler: EventHandler<T>): void {
|
||||
const handlers = this.listeners.get(event);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit<T = any>(event: EditorEvent | string, data?: T): void {
|
||||
if (this.eventHistory.length >= this.maxHistorySize) {
|
||||
this.eventHistory.shift();
|
||||
}
|
||||
this.eventHistory.push({
|
||||
event,
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
const handlers = this.listeners.get(event);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in event handler for ${event}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clear(event?: EditorEvent | string): void {
|
||||
if (event) {
|
||||
this.listeners.delete(event);
|
||||
} else {
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
getListenerCount(event: EditorEvent | string): number {
|
||||
return this.listeners.get(event)?.size || 0;
|
||||
}
|
||||
|
||||
getAllEvents(): string[] {
|
||||
return Array.from(this.listeners.keys());
|
||||
}
|
||||
|
||||
getEventHistory(count?: number): Array<{ event: string; data: any; timestamp: number }> {
|
||||
if (count) {
|
||||
return this.eventHistory.slice(-count);
|
||||
}
|
||||
return [...this.eventHistory];
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
this.eventHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
let globalEventBus: EditorEventBus | null = null;
|
||||
|
||||
export function getGlobalEventBus(): EditorEventBus {
|
||||
if (!globalEventBus) {
|
||||
globalEventBus = new EditorEventBus();
|
||||
}
|
||||
return globalEventBus;
|
||||
}
|
||||
|
||||
export function resetGlobalEventBus(): void {
|
||||
globalEventBus = null;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree';
|
||||
import { Node } from '../../domain/models/Node';
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
*/
|
||||
function generateUniqueId(): string {
|
||||
return `node-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点工厂实现
|
||||
*/
|
||||
export class NodeFactory implements INodeFactory {
|
||||
/**
|
||||
* 创建节点
|
||||
*/
|
||||
createNode(
|
||||
template: NodeTemplate,
|
||||
position: Position,
|
||||
data?: Record<string, unknown>
|
||||
): Node {
|
||||
const nodeId = generateUniqueId();
|
||||
const nodeData = {
|
||||
...template.defaultConfig,
|
||||
...data
|
||||
};
|
||||
|
||||
return new Node(nodeId, template, nodeData, position, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模板类型创建节点
|
||||
*/
|
||||
createNodeByType(
|
||||
nodeType: string,
|
||||
position: Position,
|
||||
data?: Record<string, unknown>
|
||||
): Node {
|
||||
const template = this.getTemplateByType(nodeType);
|
||||
if (!template) {
|
||||
throw new Error(`未找到节点模板: ${nodeType}`);
|
||||
}
|
||||
|
||||
return this.createNode(template, position, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆节点
|
||||
*/
|
||||
cloneNode(node: Node, newPosition?: Position): Node {
|
||||
const position = newPosition || node.position;
|
||||
const clonedId = generateUniqueId();
|
||||
|
||||
return new Node(
|
||||
clonedId,
|
||||
node.template,
|
||||
node.data,
|
||||
position,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型获取模板
|
||||
*/
|
||||
private getTemplateByType(nodeType: string): NodeTemplate | null {
|
||||
const allTemplates = NodeTemplates.getAllTemplates();
|
||||
|
||||
const template = allTemplates.find((t: NodeTemplate) => {
|
||||
const defaultNodeType = t.defaultConfig.nodeType;
|
||||
return defaultNodeType === nodeType;
|
||||
});
|
||||
|
||||
return template || null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { NodeFactory } from './NodeFactory';
|
||||
2
packages/editor-app/src/infrastructure/index.ts
Normal file
2
packages/editor-app/src/infrastructure/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './factories';
|
||||
export * from './serialization';
|
||||
@@ -0,0 +1,127 @@
|
||||
import { BehaviorTree } from '../../domain/models/BehaviorTree';
|
||||
import { ISerializer, SerializationFormat } from '../../domain/interfaces/ISerializer';
|
||||
import { BehaviorTreeAssetSerializer, EditorFormatConverter } from '@esengine/behavior-tree';
|
||||
|
||||
/**
|
||||
* 序列化选项
|
||||
*/
|
||||
export interface SerializationOptions {
|
||||
/**
|
||||
* 资产版本号
|
||||
*/
|
||||
version?: string;
|
||||
|
||||
/**
|
||||
* 资产名称
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* 资产描述
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
createdAt?: string;
|
||||
|
||||
/**
|
||||
* 修改时间
|
||||
*/
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树序列化器实现
|
||||
*/
|
||||
export class BehaviorTreeSerializer implements ISerializer {
|
||||
private readonly defaultOptions: Required<SerializationOptions> = {
|
||||
version: '1.0.0',
|
||||
name: 'Untitled Behavior Tree',
|
||||
description: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
constructor(private readonly options: SerializationOptions = {}) {
|
||||
this.defaultOptions = { ...this.defaultOptions, ...options };
|
||||
}
|
||||
/**
|
||||
* 序列化行为树
|
||||
*/
|
||||
serialize(tree: BehaviorTree, format: SerializationFormat): string | Uint8Array {
|
||||
const treeObject = tree.toObject();
|
||||
|
||||
if (format === 'json') {
|
||||
return JSON.stringify(treeObject, null, 2);
|
||||
}
|
||||
|
||||
throw new Error(`不支持的序列化格式: ${format}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 反序列化行为树
|
||||
*/
|
||||
deserialize(data: string | Uint8Array, format: SerializationFormat): BehaviorTree {
|
||||
if (format === 'json') {
|
||||
if (typeof data !== 'string') {
|
||||
throw new Error('JSON 格式需要字符串数据');
|
||||
}
|
||||
|
||||
const obj = JSON.parse(data);
|
||||
return BehaviorTree.fromObject(obj);
|
||||
}
|
||||
|
||||
throw new Error(`不支持的反序列化格式: ${format}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为运行时资产格式
|
||||
* @param tree 行为树
|
||||
* @param format 导出格式
|
||||
* @param options 可选的序列化选项(覆盖默认值)
|
||||
*/
|
||||
exportToRuntimeAsset(
|
||||
tree: BehaviorTree,
|
||||
format: SerializationFormat,
|
||||
options?: SerializationOptions
|
||||
): string | Uint8Array {
|
||||
const nodes = tree.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
template: node.template,
|
||||
data: node.data,
|
||||
position: node.position.toObject(),
|
||||
children: Array.from(node.children)
|
||||
}));
|
||||
|
||||
const connections = tree.connections.map((conn) => conn.toObject());
|
||||
const blackboard = tree.blackboard.toObject();
|
||||
|
||||
const finalOptions = { ...this.defaultOptions, ...options };
|
||||
finalOptions.modifiedAt = new Date().toISOString();
|
||||
|
||||
const editorFormat = {
|
||||
version: finalOptions.version,
|
||||
metadata: {
|
||||
name: finalOptions.name,
|
||||
description: finalOptions.description,
|
||||
createdAt: finalOptions.createdAt,
|
||||
modifiedAt: finalOptions.modifiedAt
|
||||
},
|
||||
nodes,
|
||||
connections,
|
||||
blackboard
|
||||
};
|
||||
|
||||
const asset = EditorFormatConverter.toAsset(editorFormat);
|
||||
|
||||
if (format === 'json') {
|
||||
return BehaviorTreeAssetSerializer.serialize(asset, { format: 'json', pretty: true });
|
||||
} else if (format === 'binary') {
|
||||
return BehaviorTreeAssetSerializer.serialize(asset, { format: 'binary' });
|
||||
}
|
||||
|
||||
throw new Error(`不支持的导出格式: ${format}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BehaviorTreeSerializer } from './BehaviorTreeSerializer';
|
||||
@@ -0,0 +1,149 @@
|
||||
import { IValidator, ValidationResult, ValidationError } from '../../domain/interfaces/IValidator';
|
||||
import { BehaviorTree } from '../../domain/models/BehaviorTree';
|
||||
import { Node } from '../../domain/models/Node';
|
||||
import { Connection } from '../../domain/models/Connection';
|
||||
|
||||
/**
|
||||
* 行为树验证器实现
|
||||
*/
|
||||
export class BehaviorTreeValidator implements IValidator {
|
||||
/**
|
||||
* 验证整个行为树
|
||||
*/
|
||||
validateTree(tree: BehaviorTree): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// 验证所有节点
|
||||
for (const node of tree.nodes) {
|
||||
const nodeResult = this.validateNode(node);
|
||||
errors.push(...nodeResult.errors);
|
||||
}
|
||||
|
||||
// 验证所有连接
|
||||
for (const connection of tree.connections) {
|
||||
const connResult = this.validateConnection(connection, tree);
|
||||
errors.push(...connResult.errors);
|
||||
}
|
||||
|
||||
// 验证循环引用
|
||||
const cycleResult = this.validateNoCycles(tree);
|
||||
errors.push(...cycleResult.errors);
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证节点
|
||||
*/
|
||||
validateNode(node: Node): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// 验证节点必填字段
|
||||
if (!node.id) {
|
||||
errors.push({
|
||||
message: '节点 ID 不能为空',
|
||||
nodeId: node.id
|
||||
});
|
||||
}
|
||||
|
||||
if (!node.template) {
|
||||
errors.push({
|
||||
message: '节点模板不能为空',
|
||||
nodeId: node.id
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证连接
|
||||
*/
|
||||
validateConnection(connection: Connection, tree: BehaviorTree): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// 验证连接的源节点和目标节点都存在
|
||||
const fromNode = tree.nodes.find((n) => n.id === connection.from);
|
||||
const toNode = tree.nodes.find((n) => n.id === connection.to);
|
||||
|
||||
if (!fromNode) {
|
||||
errors.push({
|
||||
message: `连接的源节点不存在: ${connection.from}`
|
||||
});
|
||||
}
|
||||
|
||||
if (!toNode) {
|
||||
errors.push({
|
||||
message: `连接的目标节点不存在: ${connection.to}`
|
||||
});
|
||||
}
|
||||
|
||||
// 不能自己连接自己
|
||||
if (connection.from === connection.to) {
|
||||
errors.push({
|
||||
message: '节点不能连接到自己',
|
||||
nodeId: connection.from
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证是否会产生循环引用
|
||||
*/
|
||||
validateNoCycles(tree: BehaviorTree): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
|
||||
const hasCycle = (nodeId: string): boolean => {
|
||||
if (recursionStack.has(nodeId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (visited.has(nodeId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
visited.add(nodeId);
|
||||
recursionStack.add(nodeId);
|
||||
|
||||
const node = tree.nodes.find((n) => n.id === nodeId);
|
||||
if (node) {
|
||||
for (const childId of node.children) {
|
||||
if (hasCycle(childId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(nodeId);
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const node of tree.nodes) {
|
||||
if (hasCycle(node.id)) {
|
||||
errors.push({
|
||||
message: '行为树中存在循环引用',
|
||||
nodeId: node.id
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user