Refactor/clean architecture phase1 (#215)

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

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

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

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

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

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

* refactor(editor): UI组件拆分

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,369 @@
import { BehaviorTreeExecutor, ExecutionStatus, ExecutionLog } from '../../utils/BehaviorTreeExecutor';
import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore';
import { BlackboardValue } from '../../domain/models/Blackboard';
import { DOMCache } from '../../presentation/utils/DOMCache';
import { EditorEventBus, EditorEvent } from '../../infrastructure/events/EditorEventBus';
import { ExecutionHooksManager } from '../interfaces/IExecutionHooks';
export type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
type BlackboardVariables = Record<string, BlackboardValue>;
interface ExecutionControllerConfig {
rootNodeId: string;
projectPath: string | null;
onLogsUpdate: (logs: ExecutionLog[]) => void;
onBlackboardUpdate: (variables: BlackboardVariables) => void;
onTickCountUpdate: (count: number) => void;
eventBus?: EditorEventBus;
hooksManager?: ExecutionHooksManager;
}
export class ExecutionController {
private executor: BehaviorTreeExecutor | null = null;
private mode: ExecutionMode = 'idle';
private animationFrameId: number | null = null;
private lastTickTime: number = 0;
private speed: number = 1.0;
private tickCount: number = 0;
private domCache: DOMCache = new DOMCache();
private eventBus?: EditorEventBus;
private hooksManager?: ExecutionHooksManager;
private config: ExecutionControllerConfig;
private currentNodes: BehaviorTreeNode[] = [];
private currentConnections: Connection[] = [];
private currentBlackboard: BlackboardVariables = {};
constructor(config: ExecutionControllerConfig) {
this.config = config;
this.executor = new BehaviorTreeExecutor();
this.eventBus = config.eventBus;
this.hooksManager = config.hooksManager;
}
getMode(): ExecutionMode {
return this.mode;
}
getTickCount(): number {
return this.tickCount;
}
getSpeed(): number {
return this.speed;
}
setSpeed(speed: number): void {
this.speed = speed;
}
async play(
nodes: BehaviorTreeNode[],
blackboardVariables: BlackboardVariables,
connections: Connection[]
): Promise<void> {
if (this.mode === 'running') return;
this.currentNodes = nodes;
this.currentConnections = connections;
this.currentBlackboard = blackboardVariables;
const context = {
nodes,
connections,
blackboardVariables,
rootNodeId: this.config.rootNodeId,
tickCount: 0
};
try {
await this.hooksManager?.triggerBeforePlay(context);
this.mode = 'running';
this.tickCount = 0;
this.lastTickTime = 0;
if (!this.executor) {
this.executor = new BehaviorTreeExecutor();
}
this.executor.buildTree(
nodes,
this.config.rootNodeId,
blackboardVariables,
connections,
this.handleExecutionStatusUpdate.bind(this)
);
this.executor.start();
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
this.eventBus?.emit(EditorEvent.EXECUTION_STARTED, context);
await this.hooksManager?.triggerAfterPlay(context);
} catch (error) {
console.error('Error in play:', error);
await this.hooksManager?.triggerOnError(error as Error, 'play');
throw error;
}
}
async pause(): Promise<void> {
try {
if (this.mode === 'running') {
await this.hooksManager?.triggerBeforePause();
this.mode = 'paused';
if (this.executor) {
this.executor.pause();
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.eventBus?.emit(EditorEvent.EXECUTION_PAUSED);
await this.hooksManager?.triggerAfterPause();
} else if (this.mode === 'paused') {
await this.hooksManager?.triggerBeforeResume();
this.mode = 'running';
this.lastTickTime = 0;
if (this.executor) {
this.executor.resume();
}
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
this.eventBus?.emit(EditorEvent.EXECUTION_RESUMED);
await this.hooksManager?.triggerAfterResume();
}
} catch (error) {
console.error('Error in pause/resume:', error);
await this.hooksManager?.triggerOnError(error as Error, 'pause');
throw error;
}
}
async stop(): Promise<void> {
try {
await this.hooksManager?.triggerBeforeStop();
this.mode = 'idle';
this.tickCount = 0;
this.lastTickTime = 0;
this.domCache.clearAllStatusTimers();
this.domCache.clearStatusCache();
this.domCache.forEachNode((node) => {
node.classList.remove('running', 'success', 'failure', 'executed');
});
this.domCache.forEachConnection((path) => {
const connectionType = path.getAttribute('data-connection-type');
if (connectionType === 'property') {
path.setAttribute('stroke', '#9c27b0');
} else {
path.setAttribute('stroke', '#0e639c');
}
path.setAttribute('stroke-width', '2');
});
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.executor) {
this.executor.stop();
}
this.eventBus?.emit(EditorEvent.EXECUTION_STOPPED);
await this.hooksManager?.triggerAfterStop();
} catch (error) {
console.error('Error in stop:', error);
await this.hooksManager?.triggerOnError(error as Error, 'stop');
throw error;
}
}
async reset(): Promise<void> {
await this.stop();
if (this.executor) {
this.executor.cleanup();
}
}
step(): void {
// 单步执行功能预留
}
updateBlackboardVariable(key: string, value: BlackboardValue): void {
if (this.executor && this.mode !== 'idle') {
this.executor.updateBlackboardVariable(key, value);
}
}
getBlackboardVariables(): BlackboardVariables {
if (this.executor) {
return this.executor.getBlackboardVariables();
}
return {};
}
clearDOMCache(): void {
this.domCache.clearAll();
}
destroy(): void {
this.stop();
if (this.executor) {
this.executor.destroy();
this.executor = null;
}
}
private tickLoop(currentTime: number): void {
if (this.mode !== 'running') {
return;
}
if (!this.executor) {
return;
}
const baseTickInterval = 16.67;
const tickInterval = baseTickInterval / this.speed;
if (this.lastTickTime === 0 || (currentTime - this.lastTickTime) >= tickInterval) {
const deltaTime = 0.016;
this.executor.tick(deltaTime);
this.tickCount = this.executor.getTickCount();
this.config.onTickCountUpdate(this.tickCount);
this.lastTickTime = currentTime;
}
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
}
private handleExecutionStatusUpdate(
statuses: ExecutionStatus[],
logs: ExecutionLog[],
runtimeBlackboardVars?: BlackboardVariables
): void {
this.config.onLogsUpdate([...logs]);
if (runtimeBlackboardVars) {
this.config.onBlackboardUpdate(runtimeBlackboardVars);
}
const statusMap: Record<string, NodeExecutionStatus> = {};
statuses.forEach((s) => {
statusMap[s.nodeId] = s.status;
if (!this.domCache.hasStatusChanged(s.nodeId, s.status)) {
return;
}
this.domCache.setLastStatus(s.nodeId, s.status);
const nodeElement = this.domCache.getNode(s.nodeId);
if (!nodeElement) {
return;
}
this.domCache.removeNodeClasses(s.nodeId, 'running', 'success', 'failure', 'executed');
if (s.status === 'running') {
this.domCache.addNodeClasses(s.nodeId, 'running');
} else if (s.status === 'success') {
this.domCache.addNodeClasses(s.nodeId, 'success');
this.domCache.clearStatusTimer(s.nodeId);
const timer = window.setTimeout(() => {
this.domCache.removeNodeClasses(s.nodeId, 'success');
this.domCache.addNodeClasses(s.nodeId, 'executed');
this.domCache.clearStatusTimer(s.nodeId);
}, 2000);
this.domCache.setStatusTimer(s.nodeId, timer);
} else if (s.status === 'failure') {
this.domCache.addNodeClasses(s.nodeId, 'failure');
this.domCache.clearStatusTimer(s.nodeId);
const timer = window.setTimeout(() => {
this.domCache.removeNodeClasses(s.nodeId, 'failure');
this.domCache.clearStatusTimer(s.nodeId);
}, 2000);
this.domCache.setStatusTimer(s.nodeId, timer);
}
});
this.updateConnectionStyles(statusMap);
}
private updateConnectionStyles(
statusMap: Record<string, NodeExecutionStatus>,
connections?: Connection[]
): void {
if (!connections) return;
connections.forEach((conn) => {
const connKey = `${conn.from}-${conn.to}`;
const pathElement = this.domCache.getConnection(connKey);
if (!pathElement) {
return;
}
const fromStatus = statusMap[conn.from];
const toStatus = statusMap[conn.to];
const isActive = fromStatus === 'running' || toStatus === 'running';
if (conn.connectionType === 'property') {
this.domCache.setConnectionAttribute(connKey, 'stroke', '#9c27b0');
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2');
} else if (isActive) {
this.domCache.setConnectionAttribute(connKey, 'stroke', '#ffa726');
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '3');
} else {
const isExecuted = this.domCache.hasNodeClass(conn.from, 'executed') &&
this.domCache.hasNodeClass(conn.to, 'executed');
if (isExecuted) {
this.domCache.setConnectionAttribute(connKey, 'stroke', '#4caf50');
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2.5');
} else {
this.domCache.setConnectionAttribute(connKey, 'stroke', '#0e639c');
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2');
}
}
});
}
setConnections(connections: Connection[]): void {
if (this.mode !== 'idle') {
const currentStatuses: Record<string, NodeExecutionStatus> = {};
connections.forEach((conn) => {
const fromStatus = this.domCache.getLastStatus(conn.from);
const toStatus = this.domCache.getLastStatus(conn.to);
if (fromStatus) currentStatuses[conn.from] = fromStatus;
if (toStatus) currentStatuses[conn.to] = toStatus;
});
this.updateConnectionStyles(currentStatuses, connections);
}
}
}