Feature/physics and tilemap enhancement (#247)

* feat(behavior-tree,tilemap): 修复编辑器连线缩放问题并增强插件系统

* feat(node-editor,blueprint): 新增通用节点编辑器和蓝图可视化脚本系统

* feat(editor,tilemap): 优化编辑器UI样式和Tilemap编辑器功能

* fix: 修复CodeQL安全警告和CI类型检查错误

* fix: 修复CodeQL安全警告和CI类型检查错误

* fix: 修复CodeQL安全警告和CI类型检查错误
This commit is contained in:
YHH
2025-11-29 23:00:48 +08:00
committed by GitHub
parent f03b73b58e
commit 359886c72f
198 changed files with 33879 additions and 13121 deletions

View File

@@ -6,17 +6,22 @@
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
import { ComponentRegistry, Core } from '@esengine/ecs-framework';
import type { IRuntimeModuleLoader, SystemContext } from '@esengine/ecs-components';
import type { AssetManager } from '@esengine/asset-system';
import { BehaviorTreeRuntimeComponent } from './execution/BehaviorTreeRuntimeComponent';
import { BehaviorTreeExecutionSystem } from './execution/BehaviorTreeExecutionSystem';
import { BehaviorTreeAssetManager } from './execution/BehaviorTreeAssetManager';
import { GlobalBlackboardService } from './Services/GlobalBlackboardService';
import { BehaviorTreeLoader } from './loaders/BehaviorTreeLoader';
import { BehaviorTreeAssetType } from './index';
/**
* Behavior Tree Runtime Module
* 行为树运行时模块
*/
export class BehaviorTreeRuntimeModule implements IRuntimeModuleLoader {
private _loaderRegistered = false;
registerComponents(registry: typeof ComponentRegistry): void {
registry.register(BehaviorTreeRuntimeComponent);
}
@@ -31,8 +36,28 @@ export class BehaviorTreeRuntimeModule implements IRuntimeModuleLoader {
}
createSystems(scene: IScene, context: SystemContext): void {
// 注册行为树加载器到 AssetManager
// Register behavior tree loader to AssetManager
const assetManager = context.assetManager as AssetManager | undefined;
console.log('[BehaviorTreeRuntimeModule] createSystems called, assetManager:', assetManager ? 'exists' : 'null');
if (!this._loaderRegistered && assetManager) {
assetManager.registerLoader(BehaviorTreeAssetType, new BehaviorTreeLoader());
this._loaderRegistered = true;
console.log('[BehaviorTreeRuntimeModule] Registered BehaviorTreeLoader for type:', BehaviorTreeAssetType);
}
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(Core);
// 设置 AssetManager 引用
// Set AssetManager reference
if (assetManager) {
behaviorTreeSystem.setAssetManager(assetManager);
console.log('[BehaviorTreeRuntimeModule] Set assetManager on behaviorTreeSystem');
} else {
console.warn('[BehaviorTreeRuntimeModule] assetManager is null, cannot set on behaviorTreeSystem');
}
if (context.isEditor) {
behaviorTreeSystem.enabled = false;
}

View File

@@ -15,6 +15,17 @@ interface EditorNode {
children?: string[];
}
/**
* 编辑器连接数据接口
*/
interface EditorConnection {
from: string;
to: string;
connectionType: 'node' | 'property';
fromProperty?: string;
toProperty?: string;
}
/**
* 编辑器行为树数据接口
*/
@@ -27,6 +38,7 @@ interface EditorBehaviorTreeData {
modifiedAt?: string;
};
nodes: EditorNode[];
connections?: EditorConnection[];
blackboard?: Record<string, any>;
}
@@ -57,10 +69,18 @@ export class EditorToBehaviorTreeDataConverter {
throw new Error('Behavior tree must have a root node');
}
// 转换所有节点
// 构建属性绑定映射nodeId -> { propertyName -> blackboardKey }
const propertyBindingsMap = this.buildPropertyBindingsMap(editorData);
// 转换所有节点(过滤掉不可执行的节点,如黑板变量节点)
const nodesMap = new Map<string, BehaviorNodeData>();
for (const editorNode of editorData.nodes) {
const behaviorNodeData = this.convertNode(editorNode);
// 跳过黑板变量节点,它们只用于编辑器的可视化绑定
if (this.isNonExecutableNode(editorNode)) {
continue;
}
const propertyBindings = propertyBindingsMap.get(editorNode.id);
const behaviorNodeData = this.convertNode(editorNode, propertyBindings);
nodesMap.set(behaviorNodeData.id, behaviorNodeData);
}
@@ -79,19 +99,81 @@ export class EditorToBehaviorTreeDataConverter {
}
/**
* 转换单个节点
* 从连接数据构建属性绑定映射
* 处理 connectionType === 'property' 的连接,将黑板变量节点连接到目标节点的属性
*/
private static convertNode(editorNode: EditorNode): BehaviorNodeData {
private static buildPropertyBindingsMap(
editorData: EditorBehaviorTreeData
): Map<string, Record<string, string>> {
const bindingsMap = new Map<string, Record<string, string>>();
if (!editorData.connections) {
return bindingsMap;
}
// 构建节点 ID 到变量名的映射(用于黑板变量节点)
const nodeToVariableMap = new Map<string, string>();
for (const node of editorData.nodes) {
if (node.data['nodeType'] === 'blackboard-variable' && node.data['variableName']) {
nodeToVariableMap.set(node.id, node.data['variableName']);
}
}
// 处理属性连接
for (const conn of editorData.connections) {
if (conn.connectionType === 'property' && conn.toProperty) {
const variableName = nodeToVariableMap.get(conn.from);
if (variableName) {
// 获取或创建目标节点的绑定记录
let bindings = bindingsMap.get(conn.to);
if (!bindings) {
bindings = {};
bindingsMap.set(conn.to, bindings);
}
// 将属性绑定到黑板变量
bindings[conn.toProperty] = variableName;
}
}
}
return bindingsMap;
}
/**
* 转换单个节点
* @param editorNode 编辑器节点数据
* @param propertyBindings 从连接中提取的属性绑定(可选)
*/
private static convertNode(
editorNode: EditorNode,
propertyBindings?: Record<string, string>
): BehaviorNodeData {
const nodeType = this.mapNodeType(editorNode.template.type);
const config = this.extractConfig(editorNode.data);
const bindings = this.extractBindings(editorNode.data);
// 从节点数据中提取绑定
const dataBindings = this.extractBindings(editorNode.data);
// 合并连接绑定和数据绑定(连接绑定优先)
const bindings = { ...dataBindings, ...propertyBindings };
const abortType = this.extractAbortType(editorNode.data);
// 获取 implementationType优先从 template.className其次从 data 中的类型字段
let implementationType: string | undefined = editorNode.template.className;
if (!implementationType) {
// 尝试从 data 中提取类型
implementationType = this.extractImplementationType(editorNode.data, nodeType);
}
if (!implementationType) {
console.warn(`[EditorToBehaviorTreeDataConverter] Node ${editorNode.id} has no implementationType, using fallback`);
// 根据节点类型使用默认实现
implementationType = this.getDefaultImplementationType(nodeType);
}
return {
id: editorNode.id,
name: editorNode.template.displayName || editorNode.template.className,
name: editorNode.template.displayName || editorNode.template.className || implementationType,
nodeType,
implementationType: editorNode.template.className,
implementationType,
children: editorNode.children || [],
config,
...(Object.keys(bindings).length > 0 && { bindings }),
@@ -99,6 +181,64 @@ export class EditorToBehaviorTreeDataConverter {
};
}
/**
* 检查是否为不可执行的节点(如黑板变量节点)
* 这些节点只在编辑器中使用,不参与运行时执行
*/
private static isNonExecutableNode(editorNode: EditorNode): boolean {
const nodeType = editorNode.data['nodeType'];
// 黑板变量节点不需要执行,只用于可视化绑定
return nodeType === 'blackboard-variable';
}
/**
* 从节点数据中提取实现类型
*
* 优先级:
* 1. template.className标准方式
* 2. data 中的类型字段compositeType, actionType 等)
* 3. 特殊节点类型的默认值(如 Root
*/
private static extractImplementationType(data: Record<string, any>, nodeType: NodeType): string | undefined {
// 节点类型到数据字段的映射
const typeFieldMap: Record<NodeType, string> = {
[NodeType.Composite]: 'compositeType',
[NodeType.Decorator]: 'decoratorType',
[NodeType.Action]: 'actionType',
[NodeType.Condition]: 'conditionType',
[NodeType.Root]: '', // Root 没有对应的数据字段
};
const field = typeFieldMap[nodeType];
if (field && data[field]) {
return data[field];
}
// Root 节点的特殊处理
if (nodeType === NodeType.Root) {
return 'Root';
}
return undefined;
}
/**
* 获取节点类型的默认实现
* 当无法确定具体实现类型时使用
*/
private static getDefaultImplementationType(nodeType: NodeType): string {
// 节点类型到默认实现的映射
const defaultImplementations: Record<NodeType, string> = {
[NodeType.Root]: 'Root',
[NodeType.Composite]: 'Sequence',
[NodeType.Decorator]: 'Inverter',
[NodeType.Action]: 'Wait',
[NodeType.Condition]: 'AlwaysTrue',
};
return defaultImplementations[nodeType] || 'Unknown';
}
/**
* 映射节点类型
*/

View File

@@ -1,22 +1,9 @@
/**
* Behavior Tree Unified Plugin
* 行为树统一插件
* Behavior Tree Plugin Descriptor
* 行为树插件描述符
*/
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
import { ComponentRegistry, Core } from '@esengine/ecs-framework';
import type {
IPluginLoader,
IRuntimeModuleLoader,
PluginDescriptor,
SystemContext
} from '@esengine/editor-runtime';
// Runtime imports
import { BehaviorTreeRuntimeComponent } from '../execution/BehaviorTreeRuntimeComponent';
import { BehaviorTreeExecutionSystem } from '../execution/BehaviorTreeExecutionSystem';
import { BehaviorTreeAssetManager } from '../execution/BehaviorTreeAssetManager';
import { GlobalBlackboardService } from '../Services/GlobalBlackboardService';
import type { PluginDescriptor } from '@esengine/editor-runtime';
/**
* 插件描述符
@@ -49,50 +36,3 @@ export const descriptor: PluginDescriptor = {
],
icon: 'GitBranch'
};
/**
* Behavior Tree Runtime Module
* 行为树运行时模块
*/
export class BehaviorTreeRuntimeModule implements IRuntimeModuleLoader {
registerComponents(registry: typeof ComponentRegistry): void {
registry.register(BehaviorTreeRuntimeComponent);
}
registerServices(services: ServiceContainer): void {
if (!services.isRegistered(GlobalBlackboardService)) {
services.registerSingleton(GlobalBlackboardService);
}
if (!services.isRegistered(BehaviorTreeAssetManager)) {
services.registerSingleton(BehaviorTreeAssetManager);
}
}
createSystems(scene: IScene, context: SystemContext): void {
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(Core);
// 编辑器模式下默认禁用
if (context.isEditor) {
behaviorTreeSystem.enabled = false;
}
scene.addSystem(behaviorTreeSystem);
// 保存引用
context.behaviorTreeSystem = behaviorTreeSystem;
}
}
/**
* Behavior Tree Plugin Loader
* 行为树插件加载器
*
* 注意editorModule 在 ./index.ts 中通过 createBehaviorTreePlugin() 设置
*/
export const BehaviorTreePlugin: IPluginLoader = {
descriptor,
runtimeModule: new BehaviorTreeRuntimeModule(),
// editorModule 将在 index.ts 中设置
};
export default BehaviorTreePlugin;

View File

@@ -462,8 +462,13 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
handleNodeMouseUp();
};
const getPortPosition = (nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') =>
getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType, draggingNodeId, dragDelta, selectedNodeIds);
// 使用 useCallback 包装 getPortPosition确保在 canvasScale/canvasOffset 变化时更新
// Use useCallback to wrap getPortPosition to ensure updates when canvasScale/canvasOffset changes
const getPortPosition = useCallback(
(nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') =>
getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType, draggingNodeId, dragDelta, selectedNodeIds),
[canvasOffset, canvasScale, nodes, draggingNodeId, dragDelta, selectedNodeIds]
);
stopExecutionRef.current = handleStop;

View File

@@ -11,6 +11,9 @@ interface ConnectionLayerProps {
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
onConnectionClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
onConnectionContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
/** 用于强制刷新连线(当 canvasScale 等变化时) */
/** Used to force refresh connections (when canvasScale etc. changes) */
refreshKey?: number;
}
export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
connections,

View File

@@ -1,4 +1,4 @@
import { React, useMemo } from '@esengine/editor-runtime';
import { React } from '@esengine/editor-runtime';
import { ConnectionViewData } from '../../types';
import { Node } from '../../domain/models/Node';
@@ -20,45 +20,40 @@ const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
}) => {
const { connection, isSelected } = connectionData;
const pathData = useMemo(() => {
let fromPos, toPos;
// 直接计算路径数据,不使用 useMemo
// getPortPosition 使用节点数据直接计算,不依赖缩放状态
let fromPos, toPos;
if (connection.connectionType === 'property') {
// 属性连接:使用 fromProperty 和 toProperty
fromPos = getPortPosition(connection.from, connection.fromProperty);
toPos = getPortPosition(connection.to, connection.toProperty);
} else {
// 节点连接:使用输出和输入端口
fromPos = getPortPosition(connection.from, undefined, 'output');
toPos = getPortPosition(connection.to, undefined, 'input');
}
if (connection.connectionType === 'property') {
fromPos = getPortPosition(connection.from, connection.fromProperty);
toPos = getPortPosition(connection.to, connection.toProperty);
} else {
fromPos = getPortPosition(connection.from, undefined, 'output');
toPos = getPortPosition(connection.to, undefined, 'input');
}
if (!fromPos || !toPos) {
return null;
}
if (!fromPos || !toPos) {
return null;
}
const x1 = fromPos.x;
const y1 = fromPos.y;
const x2 = toPos.x;
const y2 = toPos.y;
const x1 = fromPos.x;
const y1 = fromPos.y;
const x2 = toPos.x;
const y2 = toPos.y;
let pathD: string;
let pathD: string;
if (connection.connectionType === 'property') {
const controlX1 = x1 + (x2 - x1) * 0.5;
const controlX2 = x1 + (x2 - x1) * 0.5;
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
} else {
const controlY = y1 + (y2 - y1) * 0.5;
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
}
if (connection.connectionType === 'property') {
const controlX1 = x1 + (x2 - x1) * 0.5;
const controlX2 = x1 + (x2 - x1) * 0.5;
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
} else {
const controlY = y1 + (y2 - y1) * 0.5;
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
}
return {
path: pathD,
midX: (x1 + x2) / 2,
midY: (y1 + y2) / 2
};
}, [connection, fromNode, toNode, getPortPosition]);
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
const isPropertyConnection = connection.connectionType === 'property';
@@ -69,11 +64,6 @@ const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
const gradientId = `gradient-${connection.from}-${connection.to}`;
if (!pathData) {
return null;
}
const pathD = pathData.path;
const endPosMatch = pathD.match(/C [0-9.-]+ [0-9.-]+, [0-9.-]+ [0-9.-]+, ([0-9.-]+) ([0-9.-]+)/);
const endX = endPosMatch ? parseFloat(endPosMatch[1]) : 0;
const endY = endPosMatch ? parseFloat(endPosMatch[2]) : 0;
@@ -106,14 +96,14 @@ const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
</defs>
<path
d={pathData.path}
d={pathD}
fill="none"
stroke="transparent"
strokeWidth={24}
/>
<path
d={pathData.path}
d={pathD}
fill="none"
stroke={glowColor}
strokeWidth={strokeWidth + 2}
@@ -122,7 +112,7 @@ const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
/>
<path
d={pathData.path}
d={pathD}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth}
@@ -141,15 +131,15 @@ const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
{isSelected && (
<>
<circle
cx={pathData.midX}
cy={pathData.midY}
cx={midX}
cy={midY}
r="8"
fill={strokeColor}
opacity="0.3"
/>
<circle
cx={pathData.midX}
cy={pathData.midY}
cx={midX}
cy={midY}
r="5"
fill={strokeColor}
stroke="rgba(0, 0, 0, 0.5)"

View File

@@ -19,6 +19,11 @@ import {
IInspectorRegistry,
MessageHub,
IMessageHub,
FileActionRegistry,
IDialogService,
IFileSystemService,
type IDialog,
type IFileSystem,
createLogger,
PluginAPI,
} from '@esengine/editor-runtime';
@@ -36,14 +41,14 @@ import { useBehaviorTreeDataStore } from './stores';
import { createRootNode } from './domain/constants/RootNode';
import { PluginContext } from './PluginContext';
// Import runtime module and descriptor
import { BehaviorTreeRuntimeModule, descriptor } from './BehaviorTreePlugin';
// Import descriptor from local file, runtime module from main module
import { descriptor } from './BehaviorTreePlugin';
import { BehaviorTreeRuntimeModule } from '../BehaviorTreeRuntimeModule';
// 导入编辑器 CSS 样式
// 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM
// Import editor CSS styles (automatically handled and injected by vite)
import './styles/BehaviorTreeNode.css';
import './styles/Toast.css';
import './components/panels/BehaviorTreeEditorPanel.css';
import './components/panels/BehaviorTreePropertiesPanel.css';
const logger = createLogger('BehaviorTreeEditorModule');
@@ -53,6 +58,7 @@ const logger = createLogger('BehaviorTreeEditorModule');
*/
export class BehaviorTreeEditorModule implements IEditorModuleLoader {
private services?: ServiceContainer;
private unsubscribers: Array<() => void> = [];
async install(services: ServiceContainer): Promise<void> {
this.services = services;
@@ -69,10 +75,102 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
// 注册节点检视器
this.registerInspectorProviders(services);
// 注册资产创建消息映射
this.registerAssetCreationMappings(services);
// 订阅创建资产消息
this.subscribeToMessages(services);
logger.info('BehaviorTree editor module installed');
}
private registerAssetCreationMappings(services: ServiceContainer): void {
try {
const fileActionRegistry = services.resolve(FileActionRegistry);
if (fileActionRegistry) {
fileActionRegistry.registerAssetCreationMapping({
extension: '.btree',
createMessage: 'behavior-tree:create-asset'
});
}
} catch (error) {
logger.warn('FileActionRegistry not available:', error);
}
}
private subscribeToMessages(services: ServiceContainer): void {
try {
const messageHub = services.resolve<MessageHub>(IMessageHub);
if (messageHub) {
const unsubscribe = messageHub.subscribe('behavior-tree:create-asset', async (payload: {
entityId?: string;
onChange?: (value: string | null) => void;
}) => {
await this.handleCreateBehaviorTreeAsset(services, payload);
});
this.unsubscribers.push(unsubscribe);
}
} catch (error) {
logger.warn('MessageHub not available:', error);
}
}
private async handleCreateBehaviorTreeAsset(
services: ServiceContainer,
payload: { entityId?: string; onChange?: (value: string | null) => void }
): Promise<void> {
try {
const dialog = services.resolve<IDialog>(IDialogService);
const fileSystem = services.resolve<IFileSystem>(IFileSystemService);
const messageHub = services.resolve<MessageHub>(IMessageHub);
if (!dialog || !fileSystem) {
logger.error('Dialog or FileSystem service not available');
return;
}
const filePath = await dialog.saveDialog({
title: 'Create Behavior Tree Asset',
filters: [{ name: 'Behavior Tree', extensions: ['btree'] }],
defaultPath: 'new-behavior-tree.btree'
});
if (!filePath) {
return;
}
// 获取默认行为树内容
const templates = this.getFileCreationTemplates();
const btreeTemplate = templates.find(t => t.extension === 'btree');
const content = btreeTemplate
? await btreeTemplate.getContent(filePath.split(/[\\/]/).pop() || 'new-behavior-tree.btree')
: '{}';
await fileSystem.writeFile(filePath, content);
if (payload.onChange) {
payload.onChange(filePath);
}
// 打开行为树编辑器
if (messageHub) {
messageHub.publish('dynamic-panel:open', {
panelId: 'behavior-tree-editor',
title: `Behavior Tree - ${filePath.split(/[\\/]/).pop()}`
});
}
logger.info('Created behavior tree asset:', filePath);
} catch (error) {
logger.error('Failed to create behavior tree asset:', error);
}
}
async uninstall(): Promise<void> {
// 清理订阅
this.unsubscribers.forEach(unsub => unsub());
this.unsubscribers = [];
if (this.services) {
this.services.unregister(FileSystemService);
this.services.unregister(BehaviorTreeService);
@@ -155,7 +253,7 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
label: 'Behavior Tree',
extension: 'btree',
icon: 'GitBranch',
create: async (filePath: string) => {
getContent: (fileName: string) => {
const rootNode = createRootNode();
const rootNodeData = {
id: rootNode.id,
@@ -170,16 +268,13 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
};
const emptyTree = {
name: filePath.replace(/.*[/\\]/, '').replace('.btree', ''),
name: fileName.replace('.btree', ''),
nodes: [rootNodeData],
connections: [],
variables: {}
};
const content = JSON.stringify(emptyTree, null, 2);
// Write using Tauri FS API
const { writeTextFile } = await import('@tauri-apps/plugin-fs');
await writeTextFile(filePath, content);
return JSON.stringify(emptyTree, null, 2);
}
}];
}

View File

@@ -3,9 +3,14 @@ import { Node as BehaviorTreeNode } from '../domain/models/Node';
const logger = createLogger('portUtils');
// 端口偏移常量(与 CSS 保持一致)
const NODE_PORT_OFFSET = 8; // top: -8px / bottom: -8px
/**
* 获取端口在画布世界坐标系中的位置
* 直接从 DOM 元素获取实际渲染位置,避免硬编码和手动计算
*
* 由于 SVG 和节点都在同一个 transform 容器内,直接使用节点的世界坐标计算。
* 这种方式不受缩放影响,因为不依赖 getBoundingClientRect。
*/
export function getPortPosition(
canvasRef: RefObject<HTMLDivElement>,
@@ -20,41 +25,85 @@ export function getPortPosition(
selectedNodeIds?: string[]
): { x: number; y: number } | null {
const canvas = canvasRef.current;
if (!canvas) return null;
const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId);
if (!node) return null;
// 构造端口选择器
let portSelector: string;
if (propertyName) {
// 属性端口:使用 data-property 属性定位
portSelector = `[data-node-id="${nodeId}"][data-property="${propertyName}"]`;
} else {
// 节点端口:使用 data-port-type 属性定位
const portTypeAttr = portType === 'input' ? 'node-input' : 'node-output';
portSelector = `[data-node-id="${nodeId}"][data-port-type="${portTypeAttr}"]`;
}
const portElement = canvas.querySelector(portSelector) as HTMLElement;
if (!portElement) {
logger.warn(`Port not found: ${portSelector}`);
if (!canvas) {
return null;
}
// 获取端口和画布的屏幕矩形
const portRect = portElement.getBoundingClientRect();
const canvasRect = canvas.getBoundingClientRect();
const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId);
if (!node) {
return null;
}
// 计算端口中心相对于画布的屏幕坐标
const screenX = portRect.left + portRect.width / 2 - canvasRect.left;
const screenY = portRect.top + portRect.height / 2 - canvasRect.top;
// 获取节点 DOM 元素来获取尺寸
const nodeElement = canvas.querySelector(`[data-node-id="${nodeId}"].bt-node`) as HTMLElement;
if (!nodeElement) {
return null;
}
// 转换为世界坐标
// 屏幕坐标到世界坐标的转换world = (screen - offset) / scale
const worldX = (screenX - canvasOffset.x) / canvasScale;
const worldY = (screenY - canvasOffset.y) / canvasScale;
// 使用 offsetWidth/offsetHeight 获取未缩放的原始尺寸
const nodeWidth = nodeElement.offsetWidth;
const nodeHeight = nodeElement.offsetHeight;
return { x: worldX, y: worldY };
// 节点世界坐标(考虑拖拽偏移)
let nodeX = node.position.x;
let nodeY = node.position.y;
if (draggingNodeId && dragDelta) {
const isBeingDragged = draggingNodeId === nodeId ||
(selectedNodeIds && selectedNodeIds.includes(nodeId) && selectedNodeIds.includes(draggingNodeId));
if (isBeingDragged) {
nodeX += dragDelta.dx;
nodeY += dragDelta.dy;
}
}
// 节点使用 transform: translate(-50%, -50%) 居中,所以 (nodeX, nodeY) 是视觉中心
if (propertyName) {
// 属性端口:需要找到端口在节点内的相对位置
const portElement = nodeElement.querySelector(`[data-property="${propertyName}"]`) as HTMLElement;
if (!portElement) {
return null;
}
// 使用 offsetLeft/offsetTop 获取相对于 offsetParent 的位置
// 需要累加到节点元素
let offsetX = 0;
let offsetY = 0;
let el: HTMLElement | null = portElement;
while (el && el !== nodeElement) {
offsetX += el.offsetLeft;
offsetY += el.offsetTop;
el = el.offsetParent as HTMLElement | null;
}
// 端口中心相对于节点左上角的偏移
const portCenterX = offsetX + portElement.offsetWidth / 2;
const portCenterY = offsetY + portElement.offsetHeight / 2;
// 节点左上角世界坐标
const nodeLeft = nodeX - nodeWidth / 2;
const nodeTop = nodeY - nodeHeight / 2;
return {
x: nodeLeft + portCenterX,
y: nodeTop + portCenterY
};
} else {
// 节点端口(输入/输出)
if (portType === 'input') {
// 输入端口在顶部中央
return {
x: nodeX,
y: nodeY - nodeHeight / 2 - NODE_PORT_OFFSET
};
} else {
// 输出端口在底部中央
return {
x: nodeX,
y: nodeY + nodeHeight / 2 + NODE_PORT_OFFSET
};
}
}
}

View File

@@ -1,4 +1,5 @@
import { EntitySystem, Matcher, Entity, Time, Core, ECSSystem } from '@esengine/ecs-framework';
import type { AssetManager } from '@esengine/asset-system';
import { BehaviorTreeRuntimeComponent } from './BehaviorTreeRuntimeComponent';
import { BehaviorTreeAssetManager } from './BehaviorTreeAssetManager';
import { NodeExecutorRegistry, NodeExecutionContext } from './NodeExecutor';
@@ -14,10 +15,13 @@ import './Executors';
*/
@ECSSystem('BehaviorTreeExecution')
export class BehaviorTreeExecutionSystem extends EntitySystem {
private assetManager: BehaviorTreeAssetManager | null = null;
private btAssetManager: BehaviorTreeAssetManager | null = null;
private executorRegistry: NodeExecutorRegistry;
private coreInstance: typeof Core | null = null;
/** 引用 asset-system 的 AssetManager由 BehaviorTreeRuntimeModule 设置) */
private _assetManager: AssetManager | null = null;
constructor(coreInstance?: typeof Core) {
super(Matcher.empty().all(BehaviorTreeRuntimeComponent));
this.coreInstance = coreInstance || null;
@@ -25,12 +29,102 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
this.registerBuiltInExecutors();
}
private getAssetManager(): BehaviorTreeAssetManager {
if (!this.assetManager) {
const core = this.coreInstance || Core;
this.assetManager = core.services.resolve(BehaviorTreeAssetManager);
/**
* 设置 AssetManager 引用
* Set AssetManager reference
*/
setAssetManager(assetManager: AssetManager | null): void {
this._assetManager = assetManager;
}
/**
* 启动所有 autoStart 的行为树(用于预览模式)
* Start all autoStart behavior trees (for preview mode)
*
* 由于编辑器模式下系统默认禁用,实体添加时 onAdded 不会处理自动启动。
* 预览开始时需要手动调用此方法来启动所有需要自动启动的行为树。
*/
startAllAutoStartTrees(): void {
if (!this.scene) {
this.logger.warn('Scene not available, cannot start auto-start trees');
return;
}
return this.assetManager;
const entities = this.scene.entities.findEntitiesWithComponent(BehaviorTreeRuntimeComponent);
for (const entity of entities) {
const runtime = entity.getComponent(BehaviorTreeRuntimeComponent);
if (runtime && runtime.autoStart && runtime.treeAssetId && !runtime.isRunning) {
this.ensureAssetLoaded(runtime.treeAssetId).then(() => {
if (runtime && runtime.autoStart && !runtime.isRunning) {
runtime.start();
this.logger.debug(`Auto-started behavior tree for entity: ${entity.name}`);
}
}).catch(e => {
this.logger.error(`Failed to load behavior tree for entity ${entity.name}:`, e);
});
}
}
}
/**
* 当实体添加到系统时,处理自动启动
* Handle auto-start when entity is added to system
*/
protected override onAdded(entity: Entity): void {
// 只有在系统启用时才自动启动
// Only auto-start when system is enabled
if (!this.enabled) return;
const runtime = entity.getComponent(BehaviorTreeRuntimeComponent);
if (runtime && runtime.autoStart && runtime.treeAssetId && !runtime.isRunning) {
// 先尝试加载资产(如果是文件路径)
this.ensureAssetLoaded(runtime.treeAssetId).then(() => {
// 检查实体是否仍然有效
if (runtime && runtime.autoStart && !runtime.isRunning) {
runtime.start();
this.logger.debug(`Auto-started behavior tree for entity: ${entity.name}`);
}
}).catch(e => {
this.logger.error(`Failed to load behavior tree for entity ${entity.name}:`, e);
});
}
}
/**
* 确保行为树资产已加载
* Ensure behavior tree asset is loaded
*/
private async ensureAssetLoaded(assetIdOrPath: string): Promise<void> {
const btAssetManager = this.getBTAssetManager();
// 如果资产已存在,直接返回
if (btAssetManager.hasAsset(assetIdOrPath)) {
return;
}
// 使用 AssetManager 加载(必须通过 setAssetManager 设置)
// Use AssetManager (must be set via setAssetManager)
if (!this._assetManager) {
this.logger.warn(`AssetManager not set, cannot load: ${assetIdOrPath}`);
return;
}
try {
const result = await this._assetManager.loadAssetByPath(assetIdOrPath);
if (result && result.asset) {
this.logger.debug(`Behavior tree loaded via AssetManager: ${assetIdOrPath}`);
}
} catch (e) {
this.logger.warn(`Failed to load via AssetManager: ${assetIdOrPath}`, e);
}
}
private getBTAssetManager(): BehaviorTreeAssetManager {
if (!this.btAssetManager) {
const core = this.coreInstance || Core;
this.btAssetManager = core.services.resolve(BehaviorTreeAssetManager);
}
return this.btAssetManager;
}
/**
@@ -64,7 +158,7 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
continue;
}
const treeData = this.getAssetManager().getAsset(runtime.treeAssetId);
const treeData = this.getBTAssetManager().getAsset(runtime.treeAssetId);
if (!treeData) {
this.logger.warn(`未找到行为树资产: ${runtime.treeAssetId}`);
continue;
@@ -76,6 +170,12 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
runtime.needsReset = false;
}
// 初始化黑板变量(如果行为树定义了默认值)
// Initialize blackboard variables from tree definition
if (treeData.blackboardVariables && treeData.blackboardVariables.size > 0) {
runtime.initializeBlackboard(treeData.blackboardVariables);
}
this.executeTree(entity, runtime, treeData);
}
}

View File

@@ -0,0 +1,37 @@
import { TaskStatus, NodeType } from '../../Types/TaskStatus';
import { INodeExecutor, NodeExecutionContext } from '../NodeExecutor';
import { NodeExecutorMetadata } from '../NodeMetadata';
/**
* 根节点执行器
*
* 行为树的入口节点,执行其唯一的子节点
*/
@NodeExecutorMetadata({
implementationType: 'Root',
nodeType: NodeType.Root,
displayName: '根节点',
description: '行为树的入口节点',
category: 'Root',
childrenConstraints: {
min: 1,
max: 1
}
})
export class RootExecutor implements INodeExecutor {
execute(context: NodeExecutionContext): TaskStatus {
const { nodeData } = context;
// 根节点必须有且仅有一个子节点
if (!nodeData.children || nodeData.children.length === 0) {
return TaskStatus.Failure;
}
const childId = nodeData.children[0]!;
return context.executeChild(childId);
}
reset(_context: NodeExecutionContext): void {
// 根节点没有需要重置的状态
}
}

View File

@@ -1,3 +1,4 @@
export { RootExecutor } from './RootExecutor';
export { SequenceExecutor } from './SequenceExecutor';
export { SelectorExecutor } from './SelectorExecutor';
export { ParallelExecutor } from './ParallelExecutor';

View File

@@ -7,6 +7,10 @@
* @packageDocumentation
*/
// Asset type constant for behavior tree
// 行为树资产类型常量
export const BehaviorTreeAssetType = 'behaviortree' as const;
// Types
export * from './Types/TaskStatus';

View File

@@ -0,0 +1,110 @@
/**
* Behavior Tree Asset Loader
* 行为树资产加载器
*
* 实现 IAssetLoader 接口,用于通过 AssetManager 加载行为树文件
*/
import type {
IAssetLoader,
IAssetMetadata,
IAssetLoadOptions,
IAssetLoadResult
} from '@esengine/asset-system';
import { Core } from '@esengine/ecs-framework';
import { BehaviorTreeData } from '../execution/BehaviorTreeData';
import { BehaviorTreeAssetManager } from '../execution/BehaviorTreeAssetManager';
import { EditorToBehaviorTreeDataConverter } from '../Serialization/EditorToBehaviorTreeDataConverter';
import { BehaviorTreeAssetType } from '../index';
/**
* 行为树资产接口
*/
export interface IBehaviorTreeAsset {
/** 行为树数据 */
data: BehaviorTreeData;
/** 文件路径 */
path: string;
}
/**
* 行为树加载器
* Behavior tree loader implementing IAssetLoader interface
*/
export class BehaviorTreeLoader implements IAssetLoader<IBehaviorTreeAsset> {
readonly supportedType = BehaviorTreeAssetType;
readonly supportedExtensions = ['.btree'];
/**
* 加载行为树资产
* Load behavior tree asset
*/
async load(
path: string,
metadata: IAssetMetadata,
_options?: IAssetLoadOptions
): Promise<IAssetLoadResult<IBehaviorTreeAsset>> {
// 获取文件系统服务
const IFileSystemServiceKey = Symbol.for('IFileSystemService');
const fileSystem = Core.services.tryResolve(IFileSystemServiceKey) as IFileSystem | null;
if (!fileSystem) {
throw new Error('FileSystem service not available');
}
// 读取文件内容
const content = await fileSystem.readFile(path);
// 转换为运行时数据
const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content);
// 使用文件路径作为 ID
treeData.id = path;
// 注册到 BehaviorTreeAssetManager保持兼容性
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
if (btAssetManager) {
btAssetManager.loadAsset(treeData);
}
const asset: IBehaviorTreeAsset = {
data: treeData,
path
};
return {
asset,
handle: 0, // 由 AssetManager 分配
metadata,
loadTime: 0
};
}
/**
* 检查是否可以加载
* Check if can load this asset
*/
canLoad(path: string, _metadata: IAssetMetadata): boolean {
return path.endsWith('.btree');
}
/**
* 释放资产
* Dispose asset
*/
dispose(asset: IBehaviorTreeAsset): void {
// 从 BehaviorTreeAssetManager 卸载
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
if (btAssetManager && asset.data) {
btAssetManager.unloadAsset(asset.data.id);
}
}
}
/**
* 文件系统接口(简化版,仅用于类型)
*/
interface IFileSystem {
readFile(path: string): Promise<string>;
exists(path: string): Promise<boolean>;
}