refactor(behavior-tree)!: 迁移到 Runtime 执行器架构 (#196)

* refactor(behavior-tree)!: 迁移到 Runtime 执行器架构

* fix(behavior-tree): 修复LogAction中的ReDoS安全漏洞

* feat(behavior-tree): 完善行为树核心功能并修复类型错误
This commit is contained in:
YHH
2025-10-31 17:27:38 +08:00
committed by GitHub
parent c58e3411fd
commit 61813e67b6
113 changed files with 7795 additions and 10564 deletions

View File

@@ -6,7 +6,7 @@ import {
Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer,
Clock, FileText, Edit, Calculator, Code,
Equal, Dices, Settings,
Database, AlertTriangle, Search, X,
Database, AlertTriangle, AlertCircle, Search, X,
LucideIcon
} from 'lucide-react';
import { ask } from '@tauri-apps/plugin-dialog';
@@ -199,6 +199,17 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
isExecuting
} = useBehaviorTreeStore();
// 右键菜单状态
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
position: { x: number; y: number };
nodeId: string | null;
}>({
visible: false,
position: { x: 0, y: 0 },
nodeId: null
});
// 初始化根节点(仅在首次挂载时检查)
useEffect(() => {
if (nodes.length === 0) {
@@ -212,6 +223,20 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
}
}, []);
// 初始化executor用于检查执行器是否存在
useEffect(() => {
if (!executorRef.current) {
executorRef.current = new BehaviorTreeExecutor();
}
return () => {
if (executorRef.current) {
executorRef.current.destroy();
executorRef.current = null;
}
};
}, []);
// 组件挂载和连线变化时强制更新,确保连线能正确渲染
useEffect(() => {
if (nodes.length > 0 || connections.length > 0) {
@@ -223,6 +248,20 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
}
}, [nodes.length, connections.length]);
// 点击其他地方关闭右键菜单
useEffect(() => {
const handleClick = () => {
if (contextMenu.visible) {
setContextMenu({ ...contextMenu, visible: false });
}
};
if (contextMenu.visible) {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}
}, [contextMenu.visible]);
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const canvasRef = useRef<HTMLDivElement>(null);
@@ -233,11 +272,15 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
position: { x: number; y: number };
searchText: string;
selectedIndex: number;
mode: 'create' | 'replace';
replaceNodeId: string | null;
}>({
visible: false,
position: { x: 0, y: 0 },
searchText: '',
selectedIndex: 0
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
const selectedNodeRef = useRef<HTMLDivElement>(null);
@@ -485,6 +528,83 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
onNodeSelect?.(node);
};
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 handleReplaceNode = (newTemplate: NodeTemplate) => {
const nodeToReplace = nodes.find(n => n.id === quickCreateMenu.replaceNodeId);
if (!nodeToReplace) return;
// 如果行为树正在执行,先停止
if (executionMode !== 'idle') {
handleStop();
}
// 合并数据:新模板的默认配置 + 保留旧节点中同名属性的值
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: BehaviorTreeNode = {
id: nodeToReplace.id,
template: newTemplate,
data: newData,
position: nodeToReplace.position,
children: nodeToReplace.children
};
// 替换节点
setNodes(nodes.map(n => n.id === newNode.id ? newNode : n));
// 删除所有指向该节点的属性连接,让用户重新连接
const updatedConnections = connections.filter(conn =>
!(conn.connectionType === 'property' && conn.to === newNode.id)
);
setConnections(updatedConnections);
// 关闭快速创建菜单
setQuickCreateMenu({
visible: false,
position: { x: 0, y: 0 },
searchText: '',
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
// 显示提示
showToast?.(`已将节点替换为 ${newTemplate.displayName}`, 'success');
};
const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => {
// 只允许左键拖动节点
if (e.button !== 0) return;
@@ -703,9 +823,33 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
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;
}
}
}
// 类型兼容性检查
const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === actualFrom);
const toNode = nodes.find((n: BehaviorTreeNode) => n.id === actualTo);
if (fromNode && toNode && actualFromProperty && actualToProperty) {
const isFromBlackboard = fromNode.data.nodeType === 'blackboard-variable';
@@ -814,7 +958,9 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
y: e.clientY
},
searchText: '',
selectedIndex: 0
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
// 清除预览连接线,但保留 connectingFrom 用于创建连接
setConnectingToPos(null);
@@ -876,6 +1022,13 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
};
const handleQuickCreateNode = (template: NodeTemplate) => {
// 如果是替换模式,直接调用替换函数
if (quickCreateMenu.mode === 'replace') {
handleReplaceNode(template);
return;
}
// 创建模式:需要连接
if (!connectingFrom) {
return;
}
@@ -941,7 +1094,9 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
visible: false,
position: { x: 0, y: 0 },
searchText: '',
selectedIndex: 0
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
clearConnecting();
@@ -1676,6 +1831,7 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
data-node-id={node.id}
className={nodeClasses}
onClick={(e) => handleNodeClick(e, node)}
onContextMenu={(e) => handleNodeContextMenu(e, node)}
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
onMouseUp={(e) => handleNodeMouseUpForConnection(e, node.id)}
style={{
@@ -1762,12 +1918,38 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
#{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: 'auto',
marginLeft: !isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) ? '4px' : 'auto',
display: 'flex',
alignItems: 'center',
cursor: 'help',
@@ -1847,9 +2029,14 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
onMouseDown={(e) => handlePortMouseDown(e, node.id, prop.name)}
onMouseUp={(e) => handlePortMouseUp(e, node.id, prop.name)}
className={`bt-node-port bt-node-port-property ${hasConnection ? 'connected' : ''}`}
title={`Input: ${prop.label}`}
title={prop.description || prop.name}
/>
<span className="bt-node-property-label">{prop.label}:</span>
<span
className="bt-node-property-label"
title={prop.description}
>
{prop.name}:
</span>
{propValue !== undefined && (
<span className="bt-node-property-value">
{String(propValue)}
@@ -2212,7 +2399,9 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
visible: false,
position: { x: 0, y: 0 },
searchText: '',
selectedIndex: 0
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
clearConnecting();
} else if (e.key === 'ArrowDown') {
@@ -2251,7 +2440,9 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
visible: false,
position: { x: 0, y: 0 },
searchText: '',
selectedIndex: 0
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
clearConnecting();
}}
@@ -2407,6 +2598,50 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
onSpeedChange={handleSpeedChange}
/>
</div>
{/* 右键菜单 */}
{contextMenu.visible && (
<div
style={{
position: 'fixed',
left: contextMenu.position.x,
top: contextMenu.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()}
>
<div
onClick={() => {
setQuickCreateMenu({
visible: true,
position: contextMenu.position,
searchText: '',
selectedIndex: 0,
mode: 'replace',
replaceNodeId: contextMenu.nodeId
});
setContextMenu({ ...contextMenu, visible: false });
}}
style={{
padding: '8px 16px',
cursor: 'pointer',
color: '#cccccc',
fontSize: '13px',
transition: 'background-color 0.15s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { NodeTemplates, NodeTemplate } from '@esengine/behavior-tree';
import { Core } from '@esengine/ecs-framework';
import { EditorPluginManager, MessageHub } from '@esengine/editor-core';
@@ -8,6 +8,20 @@ interface BehaviorTreeNodePaletteProps {
onNodeSelect?: (template: NodeTemplate) => void;
}
/**
* 获取节点类型对应的颜色
*/
const getTypeColor = (type: string): string => {
switch (type) {
case 'composite': return '#1976d2';
case 'action': return '#388e3c';
case 'condition': return '#d32f2f';
case 'decorator': return '#fb8c00';
case 'blackboard': return '#8e24aa';
default: return '#7b1fa2';
}
};
/**
* 行为树节点面板
*
@@ -83,14 +97,18 @@ export const BehaviorTreeNodePalette: React.FC<BehaviorTreeNodePaletteProps> = (
}, []);
// 按类别分组(排除根节点类别)
const categories = ['all', ...new Set(allTemplates
.filter(t => t.category !== '根节点')
.map(t => t.category))];
const categories = useMemo(() =>
['all', ...new Set(allTemplates
.filter(t => t.category !== '根节点')
.map(t => t.category))]
, [allTemplates]);
const filteredTemplates = (selectedCategory === 'all'
? allTemplates
: allTemplates.filter(t => t.category === selectedCategory))
.filter(t => t.category !== '根节点');
const filteredTemplates = useMemo(() =>
(selectedCategory === 'all'
? allTemplates
: allTemplates.filter(t => t.category === selectedCategory))
.filter(t => t.category !== '根节点')
, [allTemplates, selectedCategory]);
const handleNodeClick = (template: NodeTemplate) => {
onNodeSelect?.(template);
@@ -108,17 +126,6 @@ export const BehaviorTreeNodePalette: React.FC<BehaviorTreeNodePaletteProps> = (
}
};
const getTypeColor = (type: string): string => {
switch (type) {
case 'composite': return '#1976d2';
case 'action': return '#388e3c';
case 'condition': return '#d32f2f';
case 'decorator': return '#fb8c00';
case 'blackboard': return '#8e24aa';
default: return '#7b1fa2';
}
};
return (
<div style={{
display: 'flex',

View File

@@ -318,29 +318,23 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
) : (
template.properties.map((prop, index) => (
<div key={index} style={{ marginBottom: '20px' }}>
<label style={{
display: 'block',
marginBottom: '8px',
fontSize: '13px',
fontWeight: 'bold',
color: '#cccccc'
}}>
<label
style={{
display: 'block',
marginBottom: '8px',
fontSize: '13px',
fontWeight: 'bold',
color: '#cccccc',
cursor: prop.description ? 'help' : 'default'
}}
title={prop.description}
>
{prop.label}
{prop.required && (
<span style={{ color: '#f48771', marginLeft: '4px' }}>*</span>
)}
</label>
{renderProperty(prop)}
{prop.description && prop.type !== 'boolean' && (
<div style={{
marginTop: '5px',
fontSize: '11px',
color: '#666',
lineHeight: '1.4'
}}>
{prop.description}
</div>
)}
</div>
))
)}

View File

@@ -140,6 +140,8 @@ const LogEntryItem = memo(({
LogEntryItem.displayName = 'LogEntryItem';
const MAX_LOGS = 1000;
export function ConsolePanel({ logService }: ConsolePanelProps) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [filter, setFilter] = useState('');
@@ -157,10 +159,16 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
const logContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setLogs(logService.getLogs());
setLogs(logService.getLogs().slice(-MAX_LOGS));
const unsubscribe = logService.subscribe((entry) => {
setLogs(prev => [...prev, entry]);
setLogs(prev => {
const newLogs = [...prev, entry];
if (newLogs.length > MAX_LOGS) {
return newLogs.slice(-MAX_LOGS);
}
return newLogs;
});
});
return unsubscribe;
@@ -348,14 +356,16 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
}
};
const levelCounts = {
const levelCounts = useMemo(() => ({
[LogLevel.Debug]: logs.filter(l => l.level === LogLevel.Debug).length,
[LogLevel.Info]: logs.filter(l => l.level === LogLevel.Info).length,
[LogLevel.Warn]: logs.filter(l => l.level === LogLevel.Warn).length,
[LogLevel.Error]: logs.filter(l => l.level === LogLevel.Error || l.level === LogLevel.Fatal).length
};
}), [logs]);
const remoteLogCount = logs.filter(l => l.source === 'remote').length;
const remoteLogCount = useMemo(() =>
logs.filter(l => l.source === 'remote').length
, [logs]);
return (
<div className="console-panel">

View File

@@ -1,7 +1,7 @@
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
import { IEditorPlugin, EditorPluginCategory, PanelPosition, MessageHub } from '@esengine/editor-core';
import type { MenuItem, ToolbarItem, PanelDescriptor, ISerializer } from '@esengine/editor-core';
import { BehaviorTreePersistence } from '@esengine/behavior-tree';
import { BehaviorTreeData } from '@esengine/behavior-tree';
/**
* 行为树编辑器插件
@@ -112,18 +112,15 @@ export class BehaviorTreePlugin implements IEditorPlugin {
getSerializers(): ISerializer[] {
return [
{
serialize: (data: any) => {
// 使用行为树持久化工具
const result = BehaviorTreePersistence.serialize(data.entity, data.pretty ?? true);
if (typeof result === 'string') {
const encoder = new TextEncoder();
return encoder.encode(result);
}
return result;
serialize: (data: BehaviorTreeData) => {
const json = this.serializeBehaviorTreeData(data);
const encoder = new TextEncoder();
return encoder.encode(json);
},
deserialize: (data: Uint8Array) => {
// 返回原始数据,让上层决定如何反序列化到场景
return data;
const decoder = new TextDecoder();
const json = decoder.decode(data);
return this.deserializeBehaviorTreeData(json);
},
getSupportedType: () => 'behavior-tree'
}
@@ -143,10 +140,9 @@ export class BehaviorTreePlugin implements IEditorPlugin {
}
async onBeforeSave(filePath: string, data: any): Promise<void> {
// 验证行为树数据
if (filePath.endsWith('.behavior-tree.json')) {
console.log('[BehaviorTreePlugin] Validating behavior tree before save');
const isValid = BehaviorTreePersistence.validate(JSON.stringify(data));
const isValid = this.validateBehaviorTreeData(data);
if (!isValid) {
throw new Error('Invalid behavior tree data');
}
@@ -159,25 +155,83 @@ export class BehaviorTreePlugin implements IEditorPlugin {
}
}
// 私有方法
private createNewBehaviorTree(): void {
console.log('[BehaviorTreePlugin] Creating new behavior tree');
// TODO: 实现创建新行为树的逻辑
}
private openBehaviorTree(): void {
console.log('[BehaviorTreePlugin] Opening behavior tree');
// TODO: 实现打开行为树的逻辑
}
private saveBehaviorTree(): void {
console.log('[BehaviorTreePlugin] Saving behavior tree');
// TODO: 实现保存行为树的逻辑
}
private validateBehaviorTree(): void {
console.log('[BehaviorTreePlugin] Validating behavior tree');
// TODO: 实现验证行为树的逻辑
}
private serializeBehaviorTreeData(treeData: BehaviorTreeData): string {
const serializable = {
id: treeData.id,
name: treeData.name,
rootNodeId: treeData.rootNodeId,
nodes: Array.from(treeData.nodes.entries()).map(([, node]) => ({
...node
})),
blackboardVariables: treeData.blackboardVariables
? Array.from(treeData.blackboardVariables.entries()).map(([key, value]) => ({
key,
value
}))
: []
};
return JSON.stringify(serializable, null, 2);
}
private deserializeBehaviorTreeData(json: string): BehaviorTreeData {
const parsed = JSON.parse(json);
const treeData: BehaviorTreeData = {
id: parsed.id,
name: parsed.name,
rootNodeId: parsed.rootNodeId,
nodes: new Map(),
blackboardVariables: new Map()
};
if (parsed.nodes) {
for (const node of parsed.nodes) {
treeData.nodes.set(node.id, node);
}
}
if (parsed.blackboardVariables) {
for (const variable of parsed.blackboardVariables) {
treeData.blackboardVariables!.set(variable.key, variable.value);
}
}
return treeData;
}
private validateBehaviorTreeData(data: any): boolean {
if (!data || typeof data !== 'object') {
return false;
}
if (!data.id || !data.name || !data.rootNodeId) {
return false;
}
if (!data.nodes || !Array.isArray(data.nodes)) {
return false;
}
const rootNode = data.nodes.find((n: any) => n.id === data.rootNodeId);
if (!rootNode) {
return false;
}
return true;
}
}

View File

@@ -1,5 +1,6 @@
import { EditorPluginManager } from '@esengine/editor-core';
import { EditorPluginManager, LocaleService, MessageHub } from '@esengine/editor-core';
import type { IEditorPlugin } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
import { TauriAPI } from '../api/tauri';
interface PluginPackageJson {
@@ -119,6 +120,28 @@ export class PluginLoader {
await pluginManager.installEditor(pluginInstance);
this.loadedPluginNames.add(packageJson.name);
// 同步插件的语言设置
try {
const localeService = Core.services.resolve(LocaleService);
const currentLocale = localeService.getCurrentLocale();
if (pluginInstance.setLocale) {
pluginInstance.setLocale(currentLocale);
console.log(`[PluginLoader] Set locale for plugin ${packageJson.name}: ${currentLocale}`);
}
} catch (error) {
console.warn(`[PluginLoader] Failed to set locale for plugin ${packageJson.name}:`, error);
}
// 通知节点面板重新加载模板
try {
const messageHub = Core.services.resolve(MessageHub);
const localeService = Core.services.resolve(LocaleService);
messageHub.publish('locale:changed', { locale: localeService.getCurrentLocale() });
console.log(`[PluginLoader] Published locale:changed event for plugin ${packageJson.name}`);
} catch (error) {
console.warn(`[PluginLoader] Failed to publish locale:changed event:`, error);
}
console.log(`[PluginLoader] Successfully loaded plugin: ${packageJson.name}`);
} catch (error) {
console.error(`[PluginLoader] Failed to load plugin from ${pluginPath}:`, error);

View File

@@ -307,3 +307,35 @@
.bt-node-uncommitted-warning:hover .bt-node-uncommitted-tooltip {
display: block;
}
/* 缺失执行器警告tooltip */
.bt-node-missing-executor-tooltip {
display: none;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
padding: 6px 10px;
background: rgba(244, 67, 54, 0.95);
color: #fff;
font-size: 11px;
white-space: nowrap;
border-radius: 4px;
pointer-events: none;
z-index: 1000;
}
.bt-node-missing-executor-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: rgba(244, 67, 54, 0.95);
}
.bt-node-missing-executor-warning:hover .bt-node-missing-executor-tooltip {
display: block;
}

File diff suppressed because it is too large Load Diff