refactor(editor): 重构编辑器架构并增强行为树执行可视化

This commit is contained in:
YHH
2025-11-04 18:29:28 +08:00
parent adfc7e91b3
commit f9afa22406
44 changed files with 4942 additions and 546 deletions

View File

@@ -30,6 +30,17 @@ export function useContextMenu() {
});
};
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 });
};
@@ -38,6 +49,7 @@ export function useContextMenu() {
contextMenu,
setContextMenu,
handleNodeContextMenu,
handleCanvasContextMenu,
closeContextMenu
};
}

View File

@@ -131,11 +131,6 @@ export function useQuickCreateMenu(params: UseQuickCreateMenuParams) {
return;
}
// 创建模式:需要连接
if (!connectingFrom) {
return;
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) {
return;
@@ -150,20 +145,23 @@ export function useQuickCreateMenu(params: UseQuickCreateMenuParams) {
template.defaultConfig
);
const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom);
if (fromNode) {
if (connectingFromProperty) {
// 属性连接
connectionOperations.addConnection(
connectingFrom,
newNode.id,
'property',
connectingFromProperty,
undefined
);
} else {
// 节点连接
connectionOperations.addConnection(connectingFrom, newNode.id, 'node');
// 如果有连接源,创建连接
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');
}
}
}

View File

@@ -1,12 +1,11 @@
import { BehaviorTreeExecutor, ExecutionStatus, ExecutionLog } from '../../utils/BehaviorTreeExecutor';
import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore';
import { BehaviorTreeNode, Connection, NodeExecutionStatus } 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 {
@@ -15,6 +14,7 @@ interface ExecutionControllerConfig {
onLogsUpdate: (logs: ExecutionLog[]) => void;
onBlackboardUpdate: (variables: BlackboardVariables) => void;
onTickCountUpdate: (count: number) => void;
onExecutionStatusUpdate: (statuses: Map<string, NodeExecutionStatus>, orders: Map<string, number>) => void;
eventBus?: EditorEventBus;
hooksManager?: ExecutionHooksManager;
}
@@ -36,6 +36,12 @@ export class ExecutionController {
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;
constructor(config: ExecutionControllerConfig) {
this.config = config;
this.executor = new BehaviorTreeExecutor();
@@ -57,6 +63,7 @@ export class ExecutionController {
setSpeed(speed: number): void {
this.speed = speed;
this.lastTickTime = 0;
}
async play(
@@ -156,23 +163,14 @@ export class ExecutionController {
this.mode = 'idle';
this.tickCount = 0;
this.lastTickTime = 0;
this.lastStepTime = 0;
this.pendingStatusUpdates = [];
this.currentlyDisplayedIndex = 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');
});
this.config.onExecutionStatusUpdate(new Map(), new Map());
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
@@ -217,6 +215,24 @@ export class ExecutionController {
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)
);
this.executor.start();
}
clearDOMCache(): void {
this.domCache.clearAll();
}
@@ -239,21 +255,96 @@ export class ExecutionController {
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 tickInterval = baseTickInterval / this.speed;
const scaledTickInterval = baseTickInterval / this.speed;
if (this.lastTickTime === 0 || (currentTime - this.lastTickTime) >= tickInterval) {
const deltaTime = 0.016;
if (this.lastTickTime === 0) {
this.lastTickTime = currentTime;
}
this.executor.tick(deltaTime);
const elapsed = currentTime - this.lastTickTime;
this.tickCount = this.executor.getTickCount();
if (elapsed >= scaledTickInterval) {
const deltaTime = baseTickInterval / 1000;
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 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';
console.log(`[StepByStep] Displaying ${this.currentlyDisplayedIndex + 1}/${this.pendingStatusUpdates.length} | ${nodeName} | Order: ${currentNode.executionOrder} | ID: ${currentNode.nodeId}`);
this.config.onExecutionStatusUpdate(statusMap, orderMap);
this.currentlyDisplayedIndex++;
}
private handleExecutionStatusUpdate(
@@ -267,52 +358,49 @@ export class ExecutionController {
this.config.onBlackboardUpdate(runtimeBlackboardVars);
}
const statusMap: Record<string, NodeExecutionStatus> = {};
if (this.stepByStepMode) {
const statusesWithOrder = statuses.filter(s => s.executionOrder !== undefined);
statuses.forEach((s) => {
statusMap[s.nodeId] = s.status;
if (statusesWithOrder.length > 0) {
const minOrder = Math.min(...statusesWithOrder.map(s => s.executionOrder!));
if (!this.domCache.hasStatusChanged(s.nodeId, s.status)) {
return;
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) {
console.log(`[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));
}
}
}
this.domCache.setLastStatus(s.nodeId, s.status);
} else {
const statusMap = new Map<string, NodeExecutionStatus>();
const orderMap = new Map<string, number>();
const nodeElement = this.domCache.getNode(s.nodeId);
if (!nodeElement) {
return;
}
statuses.forEach((s) => {
statusMap.set(s.nodeId, s.status);
if (s.executionOrder !== undefined) {
orderMap.set(s.nodeId, s.executionOrder);
}
});
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);
this.config.onExecutionStatusUpdate(statusMap, orderMap);
}
}
private updateConnectionStyles(