* feat: 添加 Tilemap 编辑器插件和组件生命周期支持 * feat(editor-core): 添加声明式插件注册 API * feat(editor-core): 改进tiledmap结构合并tileset进tiledmapeditor * feat: 添加 editor-runtime SDK 和插件系统改进 * fix(ci): 修复SceneResourceManager里变量未使用问题
406 lines
19 KiB
TypeScript
406 lines
19 KiB
TypeScript
import { React, Icons } from '@esengine/editor-runtime';
|
|
import type { LucideIcon } from '@esengine/editor-runtime';
|
|
import { PropertyDefinition } from '@esengine/behavior-tree';
|
|
|
|
import { Node as BehaviorTreeNodeType } from '../../domain/models/Node';
|
|
import { Connection } from '../../domain/models/Connection';
|
|
import { ROOT_NODE_ID } from '../../domain/constants/RootNode';
|
|
import type { NodeExecutionStatus } from '../../stores';
|
|
|
|
import { BehaviorTreeExecutor } from '../../utils/BehaviorTreeExecutor';
|
|
import { BlackboardValue } from '../../domain/models/Blackboard';
|
|
|
|
const { TreePine, Database, AlertTriangle, AlertCircle } = Icons;
|
|
|
|
type BlackboardVariables = Record<string, BlackboardValue>;
|
|
|
|
interface BehaviorTreeNodeProps {
|
|
node: BehaviorTreeNodeType;
|
|
isSelected: boolean;
|
|
isBeingDragged: boolean;
|
|
dragDelta: { dx: number; dy: number };
|
|
uncommittedNodeIds: Set<string>;
|
|
blackboardVariables: BlackboardVariables;
|
|
initialBlackboardVariables: BlackboardVariables;
|
|
isExecuting: boolean;
|
|
executionStatus?: NodeExecutionStatus;
|
|
executionOrder?: number;
|
|
connections: Connection[];
|
|
nodes: BehaviorTreeNodeType[];
|
|
executorRef: React.RefObject<BehaviorTreeExecutor | null>;
|
|
iconMap: Record<string, LucideIcon>;
|
|
draggingNodeId: string | null;
|
|
onNodeClick: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
|
|
onContextMenu: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
|
|
onNodeMouseDown: (e: React.MouseEvent, nodeId: string) => void;
|
|
onNodeMouseUpForConnection: (e: React.MouseEvent, nodeId: string) => void;
|
|
onPortMouseDown: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
|
|
onPortMouseUp: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
|
|
}
|
|
|
|
const BehaviorTreeNodeComponent: React.FC<BehaviorTreeNodeProps> = ({
|
|
node,
|
|
isSelected,
|
|
isBeingDragged,
|
|
dragDelta,
|
|
uncommittedNodeIds,
|
|
blackboardVariables,
|
|
initialBlackboardVariables,
|
|
isExecuting,
|
|
executionStatus,
|
|
executionOrder,
|
|
connections,
|
|
nodes,
|
|
executorRef,
|
|
iconMap,
|
|
draggingNodeId,
|
|
onNodeClick,
|
|
onContextMenu,
|
|
onNodeMouseDown,
|
|
onNodeMouseUpForConnection,
|
|
onPortMouseDown,
|
|
onPortMouseUp
|
|
}) => {
|
|
const isRoot = node.id === ROOT_NODE_ID;
|
|
const isBlackboardVariable = node.data.nodeType === 'blackboard-variable';
|
|
|
|
const posX = node.position.x + (isBeingDragged ? dragDelta.dx : 0);
|
|
const posY = node.position.y + (isBeingDragged ? dragDelta.dy : 0);
|
|
|
|
const isUncommitted = uncommittedNodeIds.has(node.id);
|
|
const nodeClasses = [
|
|
'bt-node',
|
|
isSelected && 'selected',
|
|
isRoot && 'root',
|
|
isUncommitted && 'uncommitted',
|
|
executionStatus && executionStatus !== 'idle' && executionStatus
|
|
].filter(Boolean).join(' ');
|
|
|
|
return (
|
|
<div
|
|
key={node.id}
|
|
data-node-id={node.id}
|
|
className={nodeClasses}
|
|
onClick={(e) => onNodeClick(e, node)}
|
|
onContextMenu={(e) => onContextMenu(e, node)}
|
|
onMouseDown={(e) => onNodeMouseDown(e, node.id)}
|
|
onMouseUp={(e) => onNodeMouseUpForConnection(e, node.id)}
|
|
onDragStart={(e) => e.preventDefault()}
|
|
style={{
|
|
left: posX,
|
|
top: posY,
|
|
transform: 'translate(-50%, -50%)',
|
|
cursor: isRoot ? 'default' : (draggingNodeId === node.id ? 'grabbing' : 'grab'),
|
|
transition: draggingNodeId === node.id ? 'none' : 'all 0.2s',
|
|
zIndex: isRoot ? 50 : (draggingNodeId === node.id ? 100 : (isSelected ? 10 : 1))
|
|
}}
|
|
>
|
|
{/* 执行顺序角标 - 使用绝对定位,不影响节点布局 */}
|
|
{executionOrder !== undefined && (
|
|
<div
|
|
className="bt-node-execution-badge"
|
|
style={{
|
|
position: 'absolute',
|
|
top: '-8px',
|
|
right: '-8px',
|
|
backgroundColor: '#2196f3',
|
|
color: '#fff',
|
|
borderRadius: '50%',
|
|
minWidth: '24px',
|
|
height: '24px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: '12px',
|
|
fontWeight: 'bold',
|
|
padding: '0 6px',
|
|
boxShadow: '0 2px 8px rgba(33, 150, 243, 0.5)',
|
|
border: '2px solid #1a1a1d',
|
|
zIndex: 10,
|
|
pointerEvents: 'none'
|
|
}}
|
|
title={`执行顺序: ${executionOrder}`}
|
|
>
|
|
{executionOrder}
|
|
</div>
|
|
)}
|
|
{isBlackboardVariable ? (
|
|
(() => {
|
|
const varName = node.data.variableName as string;
|
|
const currentValue = blackboardVariables[varName];
|
|
const initialValue = initialBlackboardVariables[varName];
|
|
const isModified = isExecuting && JSON.stringify(currentValue) !== JSON.stringify(initialValue);
|
|
|
|
return (
|
|
<>
|
|
<div className="bt-node-header blackboard">
|
|
<Database size={16} className="bt-node-header-icon" />
|
|
<div className="bt-node-header-title">
|
|
{varName || 'Variable'}
|
|
</div>
|
|
{isModified && (
|
|
<span style={{
|
|
fontSize: '9px',
|
|
color: '#ffbb00',
|
|
backgroundColor: 'rgba(255, 187, 0, 0.2)',
|
|
padding: '2px 4px',
|
|
borderRadius: '2px',
|
|
marginLeft: '4px'
|
|
}}>
|
|
运行时
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="bt-node-body">
|
|
<div
|
|
className="bt-node-blackboard-value"
|
|
style={{
|
|
backgroundColor: isModified ? 'rgba(255, 187, 0, 0.15)' : 'transparent',
|
|
border: isModified ? '1px solid rgba(255, 187, 0, 0.3)' : 'none',
|
|
borderRadius: '2px',
|
|
padding: '2px 4px'
|
|
}}
|
|
title={isModified ? `初始值: ${JSON.stringify(initialValue)}\n当前值: ${JSON.stringify(currentValue)}` : undefined}
|
|
>
|
|
{JSON.stringify(currentValue)}
|
|
</div>
|
|
</div>
|
|
<div
|
|
data-port="true"
|
|
data-node-id={node.id}
|
|
data-property="__value__"
|
|
data-port-type="variable-output"
|
|
onMouseDown={(e) => onPortMouseDown(e, node.id, '__value__')}
|
|
onMouseUp={(e) => onPortMouseUp(e, node.id, '__value__')}
|
|
className="bt-node-port bt-node-port-variable-output"
|
|
title="Output"
|
|
/>
|
|
</>
|
|
);
|
|
})()
|
|
) : (
|
|
<>
|
|
<div className={`bt-node-header ${isRoot ? 'root' : (node.template.type || 'action')}`}>
|
|
{isRoot ? (
|
|
<TreePine size={16} className="bt-node-header-icon" />
|
|
) : (
|
|
node.template.icon && (() => {
|
|
const IconComponent = iconMap[node.template.icon];
|
|
return IconComponent ? (
|
|
<IconComponent size={16} className="bt-node-header-icon" />
|
|
) : (
|
|
<span className="bt-node-header-icon">{node.template.icon}</span>
|
|
);
|
|
})()
|
|
)}
|
|
<div className="bt-node-header-title">
|
|
<div>{isRoot ? 'ROOT' : node.template.displayName}</div>
|
|
<div className="bt-node-id" title={node.id}>
|
|
#{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: (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) ? '4px' : 'auto',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
cursor: 'help',
|
|
pointerEvents: 'auto',
|
|
position: 'relative'
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<AlertTriangle
|
|
size={14}
|
|
style={{
|
|
color: '#ff5722',
|
|
flexShrink: 0
|
|
}}
|
|
/>
|
|
<div className="bt-node-uncommitted-tooltip">
|
|
未生效节点:运行时添加的节点,需重新运行才能生效
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!isRoot && !isUncommitted && node.template.type === 'composite' &&
|
|
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) &&
|
|
!nodes.some((n) =>
|
|
connections.some((c) => c.from === node.id && c.to === n.id)
|
|
) && (
|
|
<div
|
|
className="bt-node-empty-warning-container"
|
|
style={{
|
|
marginLeft: ((!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) || isUncommitted) ? '4px' : 'auto',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
cursor: 'help',
|
|
pointerEvents: 'auto',
|
|
position: 'relative'
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<AlertTriangle
|
|
size={14}
|
|
style={{
|
|
color: '#ff9800',
|
|
flexShrink: 0
|
|
}}
|
|
/>
|
|
<div className="bt-node-empty-warning-tooltip">
|
|
空节点:没有子节点,执行时会直接跳过
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bt-node-body">
|
|
{!isRoot && (
|
|
<div className="bt-node-category">
|
|
{node.template.category}
|
|
</div>
|
|
)}
|
|
|
|
{node.template.properties.length > 0 && (
|
|
<div className="bt-node-properties">
|
|
{node.template.properties.map((prop: PropertyDefinition, idx: number) => {
|
|
const hasConnection = connections.some(
|
|
(conn: Connection) => conn.toProperty === prop.name && conn.to === node.id
|
|
);
|
|
const propValue = node.data[prop.name];
|
|
|
|
return (
|
|
<div key={idx} className="bt-node-property">
|
|
<div
|
|
data-port="true"
|
|
data-node-id={node.id}
|
|
data-property={prop.name}
|
|
data-port-type="property-input"
|
|
onMouseDown={(e) => onPortMouseDown(e, node.id, prop.name)}
|
|
onMouseUp={(e) => onPortMouseUp(e, node.id, prop.name)}
|
|
className={`bt-node-port bt-node-port-property ${hasConnection ? 'connected' : ''}`}
|
|
title={prop.description || prop.name}
|
|
/>
|
|
<span
|
|
className="bt-node-property-label"
|
|
title={prop.description}
|
|
>
|
|
{prop.name}:
|
|
</span>
|
|
{propValue !== undefined && (
|
|
<span className="bt-node-property-value">
|
|
{String(propValue)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!isRoot && (
|
|
<div
|
|
data-port="true"
|
|
data-node-id={node.id}
|
|
data-port-type="node-input"
|
|
onMouseDown={(e) => onPortMouseDown(e, node.id)}
|
|
onMouseUp={(e) => onPortMouseUp(e, node.id)}
|
|
className="bt-node-port bt-node-port-input"
|
|
title="Input"
|
|
/>
|
|
)}
|
|
|
|
{(isRoot || node.template.type === 'composite' || node.template.type === 'decorator') &&
|
|
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) && (
|
|
<div
|
|
data-port="true"
|
|
data-node-id={node.id}
|
|
data-port-type="node-output"
|
|
onMouseDown={(e) => onPortMouseDown(e, node.id)}
|
|
onMouseUp={(e) => onPortMouseUp(e, node.id)}
|
|
className="bt-node-port bt-node-port-output"
|
|
title="Output"
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 使用 React.memo 优化节点组件性能
|
|
* 只在关键 props 变化时重新渲染
|
|
*/
|
|
export const BehaviorTreeNode = React.memo(BehaviorTreeNodeComponent, (prevProps, nextProps) => {
|
|
// 如果节点本身变化,需要重新渲染
|
|
if (prevProps.node.id !== nextProps.node.id ||
|
|
prevProps.node.position.x !== nextProps.node.position.x ||
|
|
prevProps.node.position.y !== nextProps.node.position.y ||
|
|
prevProps.node.template.className !== nextProps.node.template.className) {
|
|
return false;
|
|
}
|
|
|
|
if (prevProps.isSelected !== nextProps.isSelected ||
|
|
prevProps.isBeingDragged !== nextProps.isBeingDragged ||
|
|
prevProps.executionStatus !== nextProps.executionStatus ||
|
|
prevProps.executionOrder !== nextProps.executionOrder ||
|
|
prevProps.draggingNodeId !== nextProps.draggingNodeId) {
|
|
return false;
|
|
}
|
|
|
|
// 如果正在被拖拽,且 dragDelta 变化,需要重新渲染
|
|
if (nextProps.isBeingDragged &&
|
|
(prevProps.dragDelta.dx !== nextProps.dragDelta.dx ||
|
|
prevProps.dragDelta.dy !== nextProps.dragDelta.dy)) {
|
|
return false;
|
|
}
|
|
|
|
// 如果执行状态变化,需要重新渲染
|
|
if (prevProps.isExecuting !== nextProps.isExecuting) {
|
|
return false;
|
|
}
|
|
|
|
// 检查 uncommittedNodeIds 中是否包含当前节点
|
|
const prevUncommitted = prevProps.uncommittedNodeIds.has(nextProps.node.id);
|
|
const nextUncommitted = nextProps.uncommittedNodeIds.has(nextProps.node.id);
|
|
if (prevUncommitted !== nextUncommitted) {
|
|
return false;
|
|
}
|
|
|
|
// 节点数据变化时需要重新渲染
|
|
if (JSON.stringify(prevProps.node.data) !== JSON.stringify(nextProps.node.data)) {
|
|
return false;
|
|
}
|
|
|
|
// 其他情况不重新渲染
|
|
return true;
|
|
});
|