refactor(behavior-tree)!: 迁移到 Runtime 执行器架构 (#196)
* refactor(behavior-tree)!: 迁移到 Runtime 执行器架构 * fix(behavior-tree): 修复LogAction中的ReDoS安全漏洞 * feat(behavior-tree): 完善行为树核心功能并修复类型错误
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user