Feature/ecs behavior tree (#188)
* feat(behavior-tree): 完全 ECS 化的行为树系统 * feat(editor-app): 添加行为树可视化编辑器 * chore: 移除 Cocos Creator 扩展目录 * feat(editor-app): 行为树编辑器功能增强 * fix(editor-app): 修复 TypeScript 类型错误 * feat(editor-app): 使用 FlexLayout 重构面板系统并优化资产浏览器 * feat(editor-app): 改进编辑器UI样式并修复行为树执行顺序 * feat(behavior-tree,editor-app): 添加装饰器系统并优化编辑器性能 * feat(behavior-tree,editor-app): 添加属性绑定系统 * feat(editor-app,behavior-tree): 优化编辑器UI并改进行为树功能 * feat(editor-app,behavior-tree): 添加全局黑板系统并增强资产浏览器功能 * feat(behavior-tree,editor-app): 添加运行时资产导出系统 * feat(behavior-tree,editor-app): 添加SubTree系统和资产选择器 * feat(behavior-tree,editor-app): 优化系统架构并改进编辑器文件管理 * fix(behavior-tree,editor-app): 修复SubTree节点错误显示空节点警告 * fix(editor-app): 修复局部黑板类型定义文件扩展名错误
This commit is contained in:
287
packages/behavior-tree/src/Serialization/BehaviorTreeAsset.ts
Normal file
287
packages/behavior-tree/src/Serialization/BehaviorTreeAsset.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { NodeType, BlackboardValueType } from '../Types/TaskStatus';
|
||||
|
||||
/**
|
||||
* 行为树资产元数据
|
||||
*/
|
||||
export interface AssetMetadata {
|
||||
name: string;
|
||||
description?: string;
|
||||
version: string;
|
||||
createdAt?: string;
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 黑板变量定义
|
||||
*/
|
||||
export interface BlackboardVariableDefinition {
|
||||
name: string;
|
||||
type: BlackboardValueType;
|
||||
defaultValue: any;
|
||||
readonly?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树节点数据(运行时格式)
|
||||
*/
|
||||
export interface BehaviorTreeNodeData {
|
||||
id: string;
|
||||
name: string;
|
||||
nodeType: NodeType;
|
||||
|
||||
// 节点类型特定数据
|
||||
data: Record<string, any>;
|
||||
|
||||
// 子节点ID列表
|
||||
children: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性绑定定义
|
||||
*/
|
||||
export interface PropertyBinding {
|
||||
nodeId: string;
|
||||
propertyName: string;
|
||||
variableName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树资产(运行时格式)
|
||||
*
|
||||
* 这是用于游戏运行时的优化格式,不包含编辑器UI信息
|
||||
*/
|
||||
export interface BehaviorTreeAsset {
|
||||
/**
|
||||
* 资产格式版本
|
||||
*/
|
||||
version: string;
|
||||
|
||||
/**
|
||||
* 元数据
|
||||
*/
|
||||
metadata: AssetMetadata;
|
||||
|
||||
/**
|
||||
* 根节点ID
|
||||
*/
|
||||
rootNodeId: string;
|
||||
|
||||
/**
|
||||
* 所有节点数据(扁平化存储,通过children建立层级)
|
||||
*/
|
||||
nodes: BehaviorTreeNodeData[];
|
||||
|
||||
/**
|
||||
* 黑板变量定义
|
||||
*/
|
||||
blackboard: BlackboardVariableDefinition[];
|
||||
|
||||
/**
|
||||
* 属性绑定
|
||||
*/
|
||||
propertyBindings?: PropertyBinding[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产验证结果
|
||||
*/
|
||||
export interface AssetValidationResult {
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产验证器
|
||||
*/
|
||||
export class BehaviorTreeAssetValidator {
|
||||
/**
|
||||
* 验证资产数据的完整性和正确性
|
||||
*/
|
||||
static validate(asset: BehaviorTreeAsset): AssetValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 检查版本
|
||||
if (!asset.version) {
|
||||
errors.push('Missing version field');
|
||||
}
|
||||
|
||||
// 检查元数据
|
||||
if (!asset.metadata || !asset.metadata.name) {
|
||||
errors.push('Missing or invalid metadata');
|
||||
}
|
||||
|
||||
// 检查根节点
|
||||
if (!asset.rootNodeId) {
|
||||
errors.push('Missing rootNodeId');
|
||||
}
|
||||
|
||||
// 检查节点列表
|
||||
if (!asset.nodes || !Array.isArray(asset.nodes)) {
|
||||
errors.push('Missing or invalid nodes array');
|
||||
} else {
|
||||
const nodeIds = new Set<string>();
|
||||
const rootNode = asset.nodes.find(n => n.id === asset.rootNodeId);
|
||||
|
||||
if (!rootNode) {
|
||||
errors.push(`Root node '${asset.rootNodeId}' not found in nodes array`);
|
||||
}
|
||||
|
||||
// 检查节点ID唯一性
|
||||
for (const node of asset.nodes) {
|
||||
if (!node.id) {
|
||||
errors.push('Node missing id field');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nodeIds.has(node.id)) {
|
||||
errors.push(`Duplicate node id: ${node.id}`);
|
||||
}
|
||||
nodeIds.add(node.id);
|
||||
|
||||
// 检查节点类型
|
||||
if (!node.nodeType) {
|
||||
errors.push(`Node ${node.id} missing nodeType`);
|
||||
}
|
||||
|
||||
// 检查子节点引用
|
||||
if (node.children) {
|
||||
for (const childId of node.children) {
|
||||
if (!asset.nodes.find(n => n.id === childId)) {
|
||||
errors.push(`Node ${node.id} references non-existent child: ${childId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有孤立节点
|
||||
const referencedNodes = new Set<string>([asset.rootNodeId]);
|
||||
const collectReferencedNodes = (nodeId: string) => {
|
||||
const node = asset.nodes.find(n => n.id === nodeId);
|
||||
if (node && node.children) {
|
||||
for (const childId of node.children) {
|
||||
referencedNodes.add(childId);
|
||||
collectReferencedNodes(childId);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectReferencedNodes(asset.rootNodeId);
|
||||
|
||||
for (const node of asset.nodes) {
|
||||
if (!referencedNodes.has(node.id)) {
|
||||
warnings.push(`Orphaned node detected: ${node.id} (${node.name})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查黑板定义
|
||||
if (asset.blackboard && Array.isArray(asset.blackboard)) {
|
||||
const varNames = new Set<string>();
|
||||
for (const variable of asset.blackboard) {
|
||||
if (!variable.name) {
|
||||
errors.push('Blackboard variable missing name');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (varNames.has(variable.name)) {
|
||||
errors.push(`Duplicate blackboard variable: ${variable.name}`);
|
||||
}
|
||||
varNames.add(variable.name);
|
||||
|
||||
if (!variable.type) {
|
||||
errors.push(`Blackboard variable ${variable.name} missing type`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查属性绑定
|
||||
if (asset.propertyBindings && Array.isArray(asset.propertyBindings)) {
|
||||
const nodeIds = new Set(asset.nodes.map(n => n.id));
|
||||
const varNames = new Set(asset.blackboard?.map(v => v.name) || []);
|
||||
|
||||
for (const binding of asset.propertyBindings) {
|
||||
if (!nodeIds.has(binding.nodeId)) {
|
||||
errors.push(`Property binding references non-existent node: ${binding.nodeId}`);
|
||||
}
|
||||
|
||||
if (!varNames.has(binding.variableName)) {
|
||||
errors.push(`Property binding references non-existent variable: ${binding.variableName}`);
|
||||
}
|
||||
|
||||
if (!binding.propertyName) {
|
||||
errors.push('Property binding missing propertyName');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
warnings: warnings.length > 0 ? warnings : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资产统计信息
|
||||
*/
|
||||
static getStats(asset: BehaviorTreeAsset): {
|
||||
nodeCount: number;
|
||||
actionCount: number;
|
||||
conditionCount: number;
|
||||
compositeCount: number;
|
||||
decoratorCount: number;
|
||||
blackboardVariableCount: number;
|
||||
propertyBindingCount: number;
|
||||
maxDepth: number;
|
||||
} {
|
||||
let actionCount = 0;
|
||||
let conditionCount = 0;
|
||||
let compositeCount = 0;
|
||||
let decoratorCount = 0;
|
||||
|
||||
for (const node of asset.nodes) {
|
||||
switch (node.nodeType) {
|
||||
case NodeType.Action:
|
||||
actionCount++;
|
||||
break;
|
||||
case NodeType.Condition:
|
||||
conditionCount++;
|
||||
break;
|
||||
case NodeType.Composite:
|
||||
compositeCount++;
|
||||
break;
|
||||
case NodeType.Decorator:
|
||||
decoratorCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算最大深度
|
||||
const getDepth = (nodeId: string, currentDepth: number = 0): number => {
|
||||
const node = asset.nodes.find(n => n.id === nodeId);
|
||||
if (!node || !node.children || node.children.length === 0) {
|
||||
return currentDepth;
|
||||
}
|
||||
|
||||
let maxChildDepth = currentDepth;
|
||||
for (const childId of node.children) {
|
||||
const childDepth = getDepth(childId, currentDepth + 1);
|
||||
maxChildDepth = Math.max(maxChildDepth, childDepth);
|
||||
}
|
||||
return maxChildDepth;
|
||||
};
|
||||
|
||||
return {
|
||||
nodeCount: asset.nodes.length,
|
||||
actionCount,
|
||||
conditionCount,
|
||||
compositeCount,
|
||||
decoratorCount,
|
||||
blackboardVariableCount: asset.blackboard?.length || 0,
|
||||
propertyBindingCount: asset.propertyBindings?.length || 0,
|
||||
maxDepth: getDepth(asset.rootNodeId)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { Entity, IScene, createLogger } from '@esengine/ecs-framework';
|
||||
import type { BehaviorTreeAsset, BehaviorTreeNodeData, BlackboardVariableDefinition, PropertyBinding } from './BehaviorTreeAsset';
|
||||
import { BehaviorTreeNode } from '../Components/BehaviorTreeNode';
|
||||
import { BlackboardComponent } from '../Components/BlackboardComponent';
|
||||
import { PropertyBindings } from '../Components/PropertyBindings';
|
||||
import { NodeType } from '../Types/TaskStatus';
|
||||
|
||||
// 导入所有节点组件
|
||||
import { RootNode } from '../Components/Composites/RootNode';
|
||||
import { SequenceNode } from '../Components/Composites/SequenceNode';
|
||||
import { SelectorNode } from '../Components/Composites/SelectorNode';
|
||||
import { ParallelNode } from '../Components/Composites/ParallelNode';
|
||||
import { ParallelSelectorNode } from '../Components/Composites/ParallelSelectorNode';
|
||||
import { RandomSequenceNode } from '../Components/Composites/RandomSequenceNode';
|
||||
import { RandomSelectorNode } from '../Components/Composites/RandomSelectorNode';
|
||||
|
||||
import { InverterNode } from '../Components/Decorators/InverterNode';
|
||||
import { RepeaterNode } from '../Components/Decorators/RepeaterNode';
|
||||
import { UntilSuccessNode } from '../Components/Decorators/UntilSuccessNode';
|
||||
import { UntilFailNode } from '../Components/Decorators/UntilFailNode';
|
||||
import { AlwaysSucceedNode } from '../Components/Decorators/AlwaysSucceedNode';
|
||||
import { AlwaysFailNode } from '../Components/Decorators/AlwaysFailNode';
|
||||
import { ConditionalNode } from '../Components/Decorators/ConditionalNode';
|
||||
import { CooldownNode } from '../Components/Decorators/CooldownNode';
|
||||
import { TimeoutNode } from '../Components/Decorators/TimeoutNode';
|
||||
|
||||
import { WaitAction } from '../Components/Actions/WaitAction';
|
||||
import { LogAction } from '../Components/Actions/LogAction';
|
||||
import { SetBlackboardValueAction } from '../Components/Actions/SetBlackboardValueAction';
|
||||
import { ModifyBlackboardValueAction } from '../Components/Actions/ModifyBlackboardValueAction';
|
||||
import { ExecuteAction } from '../Components/Actions/ExecuteAction';
|
||||
|
||||
import { BlackboardCompareCondition, CompareOperator } from '../Components/Conditions/BlackboardCompareCondition';
|
||||
import { BlackboardExistsCondition } from '../Components/Conditions/BlackboardExistsCondition';
|
||||
import { RandomProbabilityCondition } from '../Components/Conditions/RandomProbabilityCondition';
|
||||
import { ExecuteCondition } from '../Components/Conditions/ExecuteCondition';
|
||||
import { AbortType } from '../Types/TaskStatus';
|
||||
|
||||
const logger = createLogger('BehaviorTreeAssetLoader');
|
||||
|
||||
/**
|
||||
* 实例化选项
|
||||
*/
|
||||
export interface InstantiateOptions {
|
||||
/**
|
||||
* 实体名称前缀
|
||||
*/
|
||||
namePrefix?: string;
|
||||
|
||||
/**
|
||||
* 是否共享黑板(如果为true,将使用全局黑板服务)
|
||||
*/
|
||||
sharedBlackboard?: boolean;
|
||||
|
||||
/**
|
||||
* 黑板变量覆盖(用于运行时动态设置初始值)
|
||||
*/
|
||||
blackboardOverrides?: Record<string, any>;
|
||||
|
||||
/**
|
||||
* 是否作为子树实例化
|
||||
* 如果为 true,根节点不会添加 RootNode 组件,避免触发预加载逻辑
|
||||
*/
|
||||
asSubTree?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树资产加载器
|
||||
*
|
||||
* 将BehaviorTreeAsset实例化为可运行的Entity树
|
||||
*/
|
||||
export class BehaviorTreeAssetLoader {
|
||||
/**
|
||||
* 从资产实例化行为树
|
||||
*
|
||||
* @param asset 行为树资产
|
||||
* @param scene 目标场景
|
||||
* @param options 实例化选项
|
||||
* @returns 根实体
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const asset = await loadAssetFromFile('enemy-ai.btree.bin');
|
||||
* const aiRoot = BehaviorTreeAssetLoader.instantiate(asset, scene);
|
||||
* BehaviorTreeStarter.start(aiRoot);
|
||||
* ```
|
||||
*/
|
||||
static instantiate(
|
||||
asset: BehaviorTreeAsset,
|
||||
scene: IScene,
|
||||
options: InstantiateOptions = {}
|
||||
): Entity {
|
||||
logger.info(`开始实例化行为树: ${asset.metadata.name}`);
|
||||
|
||||
// 创建节点映射
|
||||
const nodeMap = new Map<string, BehaviorTreeNodeData>();
|
||||
for (const node of asset.nodes) {
|
||||
nodeMap.set(node.id, node);
|
||||
}
|
||||
|
||||
// 查找根节点
|
||||
const rootNodeData = nodeMap.get(asset.rootNodeId);
|
||||
if (!rootNodeData) {
|
||||
throw new Error(`未找到根节点: ${asset.rootNodeId}`);
|
||||
}
|
||||
|
||||
// 创建实体映射
|
||||
const entityMap = new Map<string, Entity>();
|
||||
|
||||
// 递归创建实体树
|
||||
const rootEntity = this.createEntityTree(
|
||||
rootNodeData,
|
||||
nodeMap,
|
||||
entityMap,
|
||||
scene,
|
||||
options.namePrefix,
|
||||
options.asSubTree
|
||||
);
|
||||
|
||||
// 添加黑板
|
||||
this.setupBlackboard(rootEntity, asset.blackboard, options.blackboardOverrides);
|
||||
|
||||
// 设置属性绑定
|
||||
if (asset.propertyBindings && asset.propertyBindings.length > 0) {
|
||||
this.setupPropertyBindings(asset.propertyBindings, entityMap);
|
||||
}
|
||||
|
||||
logger.info(`行为树实例化完成: ${asset.nodes.length} 个节点`);
|
||||
|
||||
return rootEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归创建实体树
|
||||
*/
|
||||
private static createEntityTree(
|
||||
nodeData: BehaviorTreeNodeData,
|
||||
nodeMap: Map<string, BehaviorTreeNodeData>,
|
||||
entityMap: Map<string, Entity>,
|
||||
scene: IScene,
|
||||
namePrefix?: string,
|
||||
asSubTree?: boolean,
|
||||
isRootOfSubTree: boolean = true
|
||||
): Entity {
|
||||
const entityName = namePrefix ? `${namePrefix}_${nodeData.name}` : nodeData.name;
|
||||
const entity = scene.createEntity(entityName);
|
||||
|
||||
// 记录实体
|
||||
entityMap.set(nodeData.id, entity);
|
||||
|
||||
// 添加BehaviorTreeNode组件
|
||||
const btNode = entity.addComponent(new BehaviorTreeNode());
|
||||
btNode.nodeType = nodeData.nodeType;
|
||||
btNode.nodeName = nodeData.name;
|
||||
|
||||
// 添加节点特定组件(如果是子树的根节点,跳过 RootNode)
|
||||
this.addNodeComponents(entity, nodeData, asSubTree && isRootOfSubTree);
|
||||
|
||||
// 递归创建子节点
|
||||
for (const childId of nodeData.children) {
|
||||
const childData = nodeMap.get(childId);
|
||||
if (!childData) {
|
||||
logger.warn(`子节点未找到: ${childId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const childEntity = this.createEntityTree(
|
||||
childData,
|
||||
nodeMap,
|
||||
entityMap,
|
||||
scene,
|
||||
namePrefix,
|
||||
asSubTree,
|
||||
false // 子节点不是根节点
|
||||
);
|
||||
entity.addChild(childEntity);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加节点特定组件
|
||||
* @param skipRootNode 是否跳过添加 RootNode 组件(用于子树)
|
||||
*/
|
||||
private static addNodeComponents(entity: Entity, nodeData: BehaviorTreeNodeData, skipRootNode: boolean = false): void {
|
||||
const { nodeType, data, name } = nodeData;
|
||||
|
||||
logger.debug(`addNodeComponents: name=${name}, data.nodeType=${data.nodeType}, skipRootNode=${skipRootNode}`);
|
||||
|
||||
// 根据节点类型和名称添加对应组件
|
||||
if (data.nodeType === 'root' || name === '根节点' || name === 'Root') {
|
||||
if (!skipRootNode) {
|
||||
logger.debug(`添加 RootNode 组件: ${name}`);
|
||||
entity.addComponent(new RootNode());
|
||||
} else {
|
||||
// 子树的根节点,使用第一个子节点的类型(通常是 SequenceNode)
|
||||
logger.debug(`跳过为子树根节点添加 RootNode: ${name}`);
|
||||
// 添加一个默认的 SequenceNode 作为子树的根
|
||||
this.addCompositeComponent(entity, '序列', data);
|
||||
}
|
||||
}
|
||||
// 组合节点
|
||||
else if (nodeType === NodeType.Composite) {
|
||||
this.addCompositeComponent(entity, name, data);
|
||||
}
|
||||
// 装饰器节点
|
||||
else if (nodeType === NodeType.Decorator) {
|
||||
this.addDecoratorComponent(entity, name, data);
|
||||
}
|
||||
// 动作节点
|
||||
else if (nodeType === NodeType.Action) {
|
||||
this.addActionComponent(entity, name, data);
|
||||
}
|
||||
// 条件节点
|
||||
else if (nodeType === NodeType.Condition) {
|
||||
this.addConditionComponent(entity, name, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加组合节点组件
|
||||
*/
|
||||
private static addCompositeComponent(entity: Entity, name: string, data: Record<string, any>): void {
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
if (nameLower.includes('sequence') || nameLower.includes('序列')) {
|
||||
const node = entity.addComponent(new SequenceNode());
|
||||
node.abortType = (data.abortType as AbortType) ?? AbortType.None;
|
||||
} else if (nameLower.includes('selector') || nameLower.includes('选择')) {
|
||||
const node = entity.addComponent(new SelectorNode());
|
||||
node.abortType = (data.abortType as AbortType) ?? AbortType.None;
|
||||
} else if (nameLower.includes('parallelselector') || nameLower.includes('并行选择')) {
|
||||
const node = entity.addComponent(new ParallelSelectorNode());
|
||||
node.failurePolicy = data.failurePolicy ?? 'one';
|
||||
} else if (nameLower.includes('parallel') || nameLower.includes('并行')) {
|
||||
const node = entity.addComponent(new ParallelNode());
|
||||
node.successPolicy = data.successPolicy ?? 'all';
|
||||
node.failurePolicy = data.failurePolicy ?? 'one';
|
||||
} else if (nameLower.includes('randomsequence') || nameLower.includes('随机序列')) {
|
||||
entity.addComponent(new RandomSequenceNode());
|
||||
} else if (nameLower.includes('randomselector') || nameLower.includes('随机选择')) {
|
||||
entity.addComponent(new RandomSelectorNode());
|
||||
} else {
|
||||
logger.warn(`未知的组合节点类型: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加装饰器组件
|
||||
*/
|
||||
private static addDecoratorComponent(entity: Entity, name: string, data: Record<string, any>): void {
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
if (nameLower.includes('inverter') || nameLower.includes('反转')) {
|
||||
entity.addComponent(new InverterNode());
|
||||
} else if (nameLower.includes('repeater') || nameLower.includes('重复')) {
|
||||
const node = entity.addComponent(new RepeaterNode());
|
||||
node.repeatCount = data.repeatCount ?? -1;
|
||||
node.endOnFailure = data.endOnFailure ?? false;
|
||||
} else if (nameLower.includes('untilsuccess') || nameLower.includes('直到成功')) {
|
||||
entity.addComponent(new UntilSuccessNode());
|
||||
} else if (nameLower.includes('untilfail') || nameLower.includes('直到失败')) {
|
||||
entity.addComponent(new UntilFailNode());
|
||||
} else if (nameLower.includes('alwayssucceed') || nameLower.includes('总是成功')) {
|
||||
entity.addComponent(new AlwaysSucceedNode());
|
||||
} else if (nameLower.includes('alwaysfail') || nameLower.includes('总是失败')) {
|
||||
entity.addComponent(new AlwaysFailNode());
|
||||
} else if (nameLower.includes('conditional') || nameLower.includes('条件装饰')) {
|
||||
const node = entity.addComponent(new ConditionalNode());
|
||||
node.conditionCode = data.conditionCode ?? '';
|
||||
node.shouldReevaluate = data.shouldReevaluate ?? true;
|
||||
} else if (nameLower.includes('cooldown') || nameLower.includes('冷却')) {
|
||||
const node = entity.addComponent(new CooldownNode());
|
||||
node.cooldownTime = data.cooldownTime ?? 1.0;
|
||||
} else if (nameLower.includes('timeout') || nameLower.includes('超时')) {
|
||||
const node = entity.addComponent(new TimeoutNode());
|
||||
node.timeoutDuration = data.timeoutDuration ?? 1.0;
|
||||
} else {
|
||||
logger.warn(`未知的装饰器类型: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加动作组件
|
||||
*/
|
||||
private static addActionComponent(entity: Entity, name: string, data: Record<string, any>): void {
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
if (nameLower.includes('wait') || nameLower.includes('等待')) {
|
||||
const action = entity.addComponent(new WaitAction());
|
||||
action.waitTime = data.waitTime ?? 1.0;
|
||||
} else if (nameLower.includes('log') || nameLower.includes('日志')) {
|
||||
const action = entity.addComponent(new LogAction());
|
||||
action.message = data.message ?? '';
|
||||
action.level = data.level ?? 'log';
|
||||
} else if (nameLower.includes('setblackboard') || nameLower.includes('setvalue') || nameLower.includes('设置变量')) {
|
||||
const action = entity.addComponent(new SetBlackboardValueAction());
|
||||
action.variableName = data.variableName ?? '';
|
||||
action.value = data.value;
|
||||
} else if (nameLower.includes('modifyblackboard') || nameLower.includes('modifyvalue') || nameLower.includes('修改变量')) {
|
||||
const action = entity.addComponent(new ModifyBlackboardValueAction());
|
||||
action.variableName = data.variableName ?? '';
|
||||
action.operation = data.operation ?? 'add';
|
||||
action.operand = data.operand ?? 0;
|
||||
} else if (nameLower.includes('execute') || nameLower.includes('自定义')) {
|
||||
const action = entity.addComponent(new ExecuteAction());
|
||||
action.actionCode = data.actionCode ?? 'return TaskStatus.Success;';
|
||||
} else {
|
||||
logger.warn(`未知的动作类型: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加条件组件
|
||||
*/
|
||||
private static addConditionComponent(entity: Entity, name: string, data: Record<string, any>): void {
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
if (nameLower.includes('compare') || nameLower.includes('比较变量')) {
|
||||
const condition = entity.addComponent(new BlackboardCompareCondition());
|
||||
condition.variableName = data.variableName ?? '';
|
||||
condition.operator = (data.operator as CompareOperator) ?? CompareOperator.Equal;
|
||||
condition.compareValue = data.compareValue;
|
||||
condition.invertResult = data.invertResult ?? false;
|
||||
} else if (nameLower.includes('exists') || nameLower.includes('变量存在')) {
|
||||
const condition = entity.addComponent(new BlackboardExistsCondition());
|
||||
condition.variableName = data.variableName ?? '';
|
||||
condition.checkNotNull = data.checkNotNull ?? false;
|
||||
condition.invertResult = data.invertResult ?? false;
|
||||
} else if (nameLower.includes('random') || nameLower.includes('概率')) {
|
||||
const condition = entity.addComponent(new RandomProbabilityCondition());
|
||||
condition.probability = data.probability ?? 0.5;
|
||||
} else if (nameLower.includes('execute') || nameLower.includes('执行条件')) {
|
||||
const condition = entity.addComponent(new ExecuteCondition());
|
||||
condition.conditionCode = data.conditionCode ?? '';
|
||||
condition.invertResult = data.invertResult ?? false;
|
||||
} else {
|
||||
logger.warn(`未知的条件类型: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置黑板
|
||||
*/
|
||||
private static setupBlackboard(
|
||||
rootEntity: Entity,
|
||||
blackboardDef: BlackboardVariableDefinition[],
|
||||
overrides?: Record<string, any>
|
||||
): void {
|
||||
const blackboard = rootEntity.addComponent(new BlackboardComponent());
|
||||
|
||||
for (const variable of blackboardDef) {
|
||||
const value = overrides && overrides[variable.name] !== undefined
|
||||
? overrides[variable.name]
|
||||
: variable.defaultValue;
|
||||
|
||||
blackboard.defineVariable(
|
||||
variable.name,
|
||||
variable.type,
|
||||
value,
|
||||
{
|
||||
readonly: variable.readonly,
|
||||
description: variable.description
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`已设置黑板: ${blackboardDef.length} 个变量`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置属性绑定
|
||||
*/
|
||||
private static setupPropertyBindings(
|
||||
bindings: PropertyBinding[],
|
||||
entityMap: Map<string, Entity>
|
||||
): void {
|
||||
for (const binding of bindings) {
|
||||
const entity = entityMap.get(binding.nodeId);
|
||||
if (!entity) {
|
||||
logger.warn(`属性绑定引用的节点不存在: ${binding.nodeId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let propertyBindings = entity.getComponent(PropertyBindings);
|
||||
if (!propertyBindings) {
|
||||
propertyBindings = entity.addComponent(new PropertyBindings());
|
||||
}
|
||||
|
||||
propertyBindings.addBinding(binding.propertyName, binding.variableName);
|
||||
}
|
||||
|
||||
logger.info(`已设置属性绑定: ${bindings.length} 个绑定`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { encode, decode } from '@msgpack/msgpack';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import type { BehaviorTreeAsset } from './BehaviorTreeAsset';
|
||||
import { BehaviorTreeAssetValidator } from './BehaviorTreeAsset';
|
||||
import { EditorFormatConverter, type EditorFormat } from './EditorFormatConverter';
|
||||
|
||||
const logger = createLogger('BehaviorTreeAssetSerializer');
|
||||
|
||||
/**
|
||||
* 序列化格式
|
||||
*/
|
||||
export type SerializationFormat = 'json' | 'binary';
|
||||
|
||||
/**
|
||||
* 序列化选项
|
||||
*/
|
||||
export interface SerializationOptions {
|
||||
/**
|
||||
* 序列化格式
|
||||
*/
|
||||
format: SerializationFormat;
|
||||
|
||||
/**
|
||||
* 是否美化JSON输出(仅format='json'时有效)
|
||||
*/
|
||||
pretty?: boolean;
|
||||
|
||||
/**
|
||||
* 是否在序列化前验证资产
|
||||
*/
|
||||
validate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反序列化选项
|
||||
*/
|
||||
export interface DeserializationOptions {
|
||||
/**
|
||||
* 是否在反序列化后验证资产
|
||||
*/
|
||||
validate?: boolean;
|
||||
|
||||
/**
|
||||
* 是否严格模式(验证失败抛出异常)
|
||||
*/
|
||||
strict?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树资产序列化器
|
||||
*
|
||||
* 支持JSON和二进制(MessagePack)两种格式
|
||||
*/
|
||||
export class BehaviorTreeAssetSerializer {
|
||||
/**
|
||||
* 序列化资产
|
||||
*
|
||||
* @param asset 行为树资产
|
||||
* @param options 序列化选项
|
||||
* @returns 序列化后的数据(字符串或Uint8Array)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // JSON格式
|
||||
* const jsonData = BehaviorTreeAssetSerializer.serialize(asset, { format: 'json', pretty: true });
|
||||
*
|
||||
* // 二进制格式
|
||||
* const binaryData = BehaviorTreeAssetSerializer.serialize(asset, { format: 'binary' });
|
||||
* ```
|
||||
*/
|
||||
static serialize(
|
||||
asset: BehaviorTreeAsset,
|
||||
options: SerializationOptions = { format: 'json', pretty: true }
|
||||
): string | Uint8Array {
|
||||
// 验证资产(如果需要)
|
||||
if (options.validate !== false) {
|
||||
const validation = BehaviorTreeAssetValidator.validate(asset);
|
||||
if (!validation.valid) {
|
||||
const errors = validation.errors?.join(', ') || 'Unknown error';
|
||||
throw new Error(`资产验证失败: ${errors}`);
|
||||
}
|
||||
|
||||
if (validation.warnings && validation.warnings.length > 0) {
|
||||
logger.warn(`资产验证警告: ${validation.warnings.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 根据格式序列化
|
||||
if (options.format === 'json') {
|
||||
return this.serializeToJSON(asset, options.pretty);
|
||||
} else {
|
||||
return this.serializeToBinary(asset);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化为JSON格式
|
||||
*/
|
||||
private static serializeToJSON(asset: BehaviorTreeAsset, pretty: boolean = true): string {
|
||||
try {
|
||||
const json = pretty
|
||||
? JSON.stringify(asset, null, 2)
|
||||
: JSON.stringify(asset);
|
||||
|
||||
logger.info(`已序列化为JSON: ${json.length} 字符`);
|
||||
return json;
|
||||
} catch (error) {
|
||||
throw new Error(`JSON序列化失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化为二进制格式(MessagePack)
|
||||
*/
|
||||
private static serializeToBinary(asset: BehaviorTreeAsset): Uint8Array {
|
||||
try {
|
||||
const binary = encode(asset);
|
||||
logger.info(`已序列化为二进制: ${binary.length} 字节`);
|
||||
return binary;
|
||||
} catch (error) {
|
||||
throw new Error(`二进制序列化失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 反序列化资产
|
||||
*
|
||||
* @param data 序列化的数据(字符串或Uint8Array)
|
||||
* @param options 反序列化选项
|
||||
* @returns 行为树资产
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 从JSON加载
|
||||
* const asset = BehaviorTreeAssetSerializer.deserialize(jsonString);
|
||||
*
|
||||
* // 从二进制加载
|
||||
* const asset = BehaviorTreeAssetSerializer.deserialize(binaryData);
|
||||
* ```
|
||||
*/
|
||||
static deserialize(
|
||||
data: string | Uint8Array,
|
||||
options: DeserializationOptions = { validate: true, strict: true }
|
||||
): BehaviorTreeAsset {
|
||||
let asset: BehaviorTreeAsset;
|
||||
|
||||
try {
|
||||
if (typeof data === 'string') {
|
||||
asset = this.deserializeFromJSON(data);
|
||||
} else {
|
||||
asset = this.deserializeFromBinary(data);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`反序列化失败: ${error}`);
|
||||
}
|
||||
|
||||
// 验证资产(如果需要)
|
||||
if (options.validate !== false) {
|
||||
const validation = BehaviorTreeAssetValidator.validate(asset);
|
||||
|
||||
if (!validation.valid) {
|
||||
const errors = validation.errors?.join(', ') || 'Unknown error';
|
||||
if (options.strict) {
|
||||
throw new Error(`资产验证失败: ${errors}`);
|
||||
} else {
|
||||
logger.error(`资产验证失败: ${errors}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (validation.warnings && validation.warnings.length > 0) {
|
||||
logger.warn(`资产验证警告: ${validation.warnings.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JSON反序列化
|
||||
*/
|
||||
private static deserializeFromJSON(json: string): BehaviorTreeAsset {
|
||||
try {
|
||||
const data = JSON.parse(json);
|
||||
|
||||
// 检测是否是编辑器格式(EditorFormat)
|
||||
// 编辑器格式有 nodes/connections/blackboard,但没有 rootNodeId
|
||||
// 运行时资产格式有 rootNodeId
|
||||
const isEditorFormat = !data.rootNodeId && data.nodes && data.connections;
|
||||
|
||||
if (isEditorFormat) {
|
||||
logger.info('检测到编辑器格式,正在转换为运行时资产格式...');
|
||||
const editorData = data as EditorFormat;
|
||||
const asset = EditorFormatConverter.toAsset(editorData);
|
||||
logger.info(`已从编辑器格式转换: ${asset.nodes.length} 个节点`);
|
||||
return asset;
|
||||
} else {
|
||||
const asset = data as BehaviorTreeAsset;
|
||||
logger.info(`已从运行时资产格式反序列化: ${asset.nodes.length} 个节点`);
|
||||
return asset;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`JSON解析失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从二进制反序列化
|
||||
*/
|
||||
private static deserializeFromBinary(binary: Uint8Array): BehaviorTreeAsset {
|
||||
try {
|
||||
const asset = decode(binary) as BehaviorTreeAsset;
|
||||
logger.info(`已从二进制反序列化: ${asset.nodes.length} 个节点`);
|
||||
return asset;
|
||||
} catch (error) {
|
||||
throw new Error(`二进制解码失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测数据格式
|
||||
*
|
||||
* @param data 序列化的数据
|
||||
* @returns 格式类型
|
||||
*/
|
||||
static detectFormat(data: string | Uint8Array): SerializationFormat {
|
||||
if (typeof data === 'string') {
|
||||
return 'json';
|
||||
} else {
|
||||
return 'binary';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取序列化数据的信息(不完全反序列化)
|
||||
*
|
||||
* @param data 序列化的数据
|
||||
* @returns 资产元信息
|
||||
*/
|
||||
static getInfo(data: string | Uint8Array): {
|
||||
format: SerializationFormat;
|
||||
name: string;
|
||||
version: string;
|
||||
nodeCount: number;
|
||||
blackboardVariableCount: number;
|
||||
size: number;
|
||||
} | null {
|
||||
try {
|
||||
const format = this.detectFormat(data);
|
||||
let asset: BehaviorTreeAsset;
|
||||
|
||||
if (format === 'json') {
|
||||
asset = JSON.parse(data as string);
|
||||
} else {
|
||||
asset = decode(data as Uint8Array) as BehaviorTreeAsset;
|
||||
}
|
||||
|
||||
const size = typeof data === 'string' ? data.length : data.length;
|
||||
|
||||
return {
|
||||
format,
|
||||
name: asset.metadata.name,
|
||||
version: asset.version,
|
||||
nodeCount: asset.nodes.length,
|
||||
blackboardVariableCount: asset.blackboard.length,
|
||||
size
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`获取资产信息失败: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换格式
|
||||
*
|
||||
* @param data 源数据
|
||||
* @param targetFormat 目标格式
|
||||
* @param pretty 是否美化JSON(仅当目标格式为json时有效)
|
||||
* @returns 转换后的数据
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // JSON转二进制
|
||||
* const binary = BehaviorTreeAssetSerializer.convert(jsonString, 'binary');
|
||||
*
|
||||
* // 二进制转JSON
|
||||
* const json = BehaviorTreeAssetSerializer.convert(binaryData, 'json', true);
|
||||
* ```
|
||||
*/
|
||||
static convert(
|
||||
data: string | Uint8Array,
|
||||
targetFormat: SerializationFormat,
|
||||
pretty: boolean = true
|
||||
): string | Uint8Array {
|
||||
const asset = this.deserialize(data, { validate: false });
|
||||
|
||||
return this.serialize(asset, {
|
||||
format: targetFormat,
|
||||
pretty,
|
||||
validate: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个资产数据的大小
|
||||
*
|
||||
* @param jsonData JSON格式数据
|
||||
* @param binaryData 二进制格式数据
|
||||
* @returns 压缩率(百分比)
|
||||
*/
|
||||
static compareSize(jsonData: string, binaryData: Uint8Array): {
|
||||
jsonSize: number;
|
||||
binarySize: number;
|
||||
compressionRatio: number;
|
||||
savedBytes: number;
|
||||
} {
|
||||
const jsonSize = jsonData.length;
|
||||
const binarySize = binaryData.length;
|
||||
const savedBytes = jsonSize - binarySize;
|
||||
const compressionRatio = (savedBytes / jsonSize) * 100;
|
||||
|
||||
return {
|
||||
jsonSize,
|
||||
binarySize,
|
||||
compressionRatio,
|
||||
savedBytes
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Entity, IScene, SceneSerializer, SerializedScene, SerializedEntity } from '@esengine/ecs-framework';
|
||||
import { BehaviorTreeNode } from '../Components/BehaviorTreeNode';
|
||||
|
||||
/**
|
||||
* 行为树持久化工具
|
||||
*
|
||||
* 使用框架的序列化系统进行二进制/JSON序列化
|
||||
*/
|
||||
export class BehaviorTreePersistence {
|
||||
/**
|
||||
* 序列化行为树(JSON格式)
|
||||
*
|
||||
* @param rootEntity 行为树根实体
|
||||
* @param pretty 是否格式化
|
||||
* @returns 序列化数据(JSON字符串或二进制)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const data = BehaviorTreePersistence.serialize(aiRoot);
|
||||
* ```
|
||||
*/
|
||||
static serialize(rootEntity: Entity, pretty: boolean = true): string | Uint8Array {
|
||||
if (!rootEntity.hasComponent(BehaviorTreeNode)) {
|
||||
throw new Error('Entity must have BehaviorTreeNode component');
|
||||
}
|
||||
|
||||
if (!rootEntity.scene) {
|
||||
throw new Error('Entity must be attached to a scene');
|
||||
}
|
||||
|
||||
// 使用 SceneSerializer,但只序列化这棵行为树
|
||||
// 创建一个临时场景包含只这个实体树
|
||||
return SceneSerializer.serialize(rootEntity.scene, {
|
||||
format: 'json',
|
||||
pretty: pretty,
|
||||
includeMetadata: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从序列化数据加载行为树
|
||||
*
|
||||
* @param scene 场景实例
|
||||
* @param data 序列化数据(JSON字符串或二进制)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 从文件读取
|
||||
* const json = await readFile('behavior-tree.json');
|
||||
*
|
||||
* // 恢复行为树到场景
|
||||
* BehaviorTreePersistence.deserialize(scene, json);
|
||||
* ```
|
||||
*/
|
||||
static deserialize(scene: IScene, data: string | Uint8Array): void {
|
||||
SceneSerializer.deserialize(scene, data, {
|
||||
strategy: 'merge'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化为 JSON 字符串
|
||||
*
|
||||
* @param rootEntity 行为树根实体
|
||||
* @param pretty 是否格式化
|
||||
* @returns JSON 字符串
|
||||
*/
|
||||
static toJSON(rootEntity: Entity, pretty: boolean = true): string {
|
||||
const data = this.serialize(rootEntity, pretty);
|
||||
return JSON.stringify(data, null, pretty ? 2 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JSON 字符串加载
|
||||
*
|
||||
* @param scene 场景实例
|
||||
* @param json JSON 字符串
|
||||
*/
|
||||
static fromJSON(scene: IScene, json: string): void {
|
||||
this.deserialize(scene, json);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到文件(需要 Tauri 环境)
|
||||
*
|
||||
* @param rootEntity 行为树根实体
|
||||
* @param filePath 文件路径
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await BehaviorTreePersistence.saveToFile(aiRoot, 'ai-behavior.json');
|
||||
* ```
|
||||
*/
|
||||
static async saveToFile(rootEntity: Entity, filePath: string): Promise<void> {
|
||||
const json = this.toJSON(rootEntity, true);
|
||||
|
||||
// 需要在 Tauri 环境中使用
|
||||
// const { writeTextFile } = await import('@tauri-apps/api/fs');
|
||||
// await writeTextFile(filePath, json);
|
||||
|
||||
throw new Error('saveToFile requires Tauri environment. Use toJSON() for manual saving.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件加载(需要 Tauri 环境)
|
||||
*
|
||||
* @param scene 场景实例
|
||||
* @param filePath 文件路径
|
||||
* @returns 恢复的根实体
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const aiRoot = await BehaviorTreePersistence.loadFromFile(scene, 'ai-behavior.json');
|
||||
* ```
|
||||
*/
|
||||
static async loadFromFile(scene: IScene, filePath: string): Promise<Entity> {
|
||||
// 需要在 Tauri 环境中使用
|
||||
// const { readTextFile } = await import('@tauri-apps/api/fs');
|
||||
// const json = await readTextFile(filePath);
|
||||
// return this.fromJSON(scene, json);
|
||||
|
||||
throw new Error('loadFromFile requires Tauri environment. Use fromJSON() for manual loading.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证是否为有效的行为树数据
|
||||
*
|
||||
* @param data 序列化数据(字符串格式)
|
||||
* @returns 是否有效
|
||||
*/
|
||||
static validate(data: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as SerializedScene;
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查必要字段
|
||||
if (!parsed.name ||
|
||||
typeof parsed.version !== 'number' ||
|
||||
!Array.isArray(parsed.entities) ||
|
||||
!Array.isArray(parsed.componentTypeRegistry)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否至少有一个实体包含 BehaviorTreeNode 组件
|
||||
const hasBehaviorTreeNode = parsed.entities.some((entity: SerializedEntity) => {
|
||||
return entity.components.some(
|
||||
(comp: any) => comp.type === 'BehaviorTreeNode'
|
||||
);
|
||||
});
|
||||
|
||||
return hasBehaviorTreeNode;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆行为树
|
||||
*
|
||||
* @param scene 场景实例
|
||||
* @param rootEntity 要克隆的行为树根实体
|
||||
* @returns 克隆的新实体
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const clonedAI = BehaviorTreePersistence.clone(scene, originalAI);
|
||||
* ```
|
||||
*/
|
||||
static clone(scene: IScene, rootEntity: Entity): Entity {
|
||||
const data = this.serialize(rootEntity);
|
||||
const entityCountBefore = scene.entities.count;
|
||||
|
||||
this.deserialize(scene, data);
|
||||
|
||||
// 找到新添加的根实体(最后添加的实体)
|
||||
const entities = Array.from(scene.entities.buffer);
|
||||
for (let i = entities.length - 1; i >= entityCountBefore; i--) {
|
||||
const entity = entities[i];
|
||||
if (entity.hasComponent(BehaviorTreeNode) && !entity.parent) {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to find cloned root entity');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import type { BehaviorTreeAsset, AssetMetadata, BehaviorTreeNodeData, BlackboardVariableDefinition, PropertyBinding } from './BehaviorTreeAsset';
|
||||
import { NodeType, BlackboardValueType } from '../Types/TaskStatus';
|
||||
|
||||
const logger = createLogger('EditorFormatConverter');
|
||||
|
||||
/**
|
||||
* 编辑器节点格式
|
||||
*/
|
||||
export interface EditorNode {
|
||||
id: string;
|
||||
template: {
|
||||
displayName: string;
|
||||
category: string;
|
||||
type: NodeType;
|
||||
[key: string]: any;
|
||||
};
|
||||
data: Record<string, any>;
|
||||
position: { x: number; y: number };
|
||||
children: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器连接格式
|
||||
*/
|
||||
export interface EditorConnection {
|
||||
from: string;
|
||||
to: string;
|
||||
fromProperty?: string;
|
||||
toProperty?: string;
|
||||
connectionType: 'node' | 'property';
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器格式
|
||||
*/
|
||||
export interface EditorFormat {
|
||||
version?: string;
|
||||
metadata?: {
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt?: string;
|
||||
modifiedAt?: string;
|
||||
};
|
||||
nodes: EditorNode[];
|
||||
connections: EditorConnection[];
|
||||
blackboard: Record<string, any>;
|
||||
canvasState?: {
|
||||
offset: { x: number; y: number };
|
||||
scale: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器格式转换器
|
||||
*
|
||||
* 将编辑器格式转换为运行时资产格式
|
||||
*/
|
||||
export class EditorFormatConverter {
|
||||
/**
|
||||
* 转换编辑器格式为资产格式
|
||||
*
|
||||
* @param editorData 编辑器数据
|
||||
* @param metadata 可选的元数据覆盖
|
||||
* @returns 行为树资产
|
||||
*/
|
||||
static toAsset(editorData: EditorFormat, metadata?: Partial<AssetMetadata>): BehaviorTreeAsset {
|
||||
logger.info('开始转换编辑器格式到资产格式');
|
||||
|
||||
// 查找根节点
|
||||
const rootNode = this.findRootNode(editorData.nodes);
|
||||
if (!rootNode) {
|
||||
throw new Error('未找到根节点');
|
||||
}
|
||||
|
||||
// 转换元数据
|
||||
const assetMetadata: AssetMetadata = {
|
||||
name: metadata?.name || editorData.metadata?.name || 'Untitled Behavior Tree',
|
||||
description: metadata?.description || editorData.metadata?.description,
|
||||
version: metadata?.version || editorData.version || '1.0.0',
|
||||
createdAt: metadata?.createdAt || editorData.metadata?.createdAt,
|
||||
modifiedAt: metadata?.modifiedAt || new Date().toISOString()
|
||||
};
|
||||
|
||||
// 转换节点
|
||||
const nodes = this.convertNodes(editorData.nodes);
|
||||
|
||||
// 转换黑板
|
||||
const blackboard = this.convertBlackboard(editorData.blackboard);
|
||||
|
||||
// 转换属性绑定
|
||||
const propertyBindings = this.convertPropertyBindings(
|
||||
editorData.connections,
|
||||
editorData.nodes,
|
||||
blackboard
|
||||
);
|
||||
|
||||
const asset: BehaviorTreeAsset = {
|
||||
version: '1.0.0',
|
||||
metadata: assetMetadata,
|
||||
rootNodeId: rootNode.id,
|
||||
nodes,
|
||||
blackboard,
|
||||
propertyBindings: propertyBindings.length > 0 ? propertyBindings : undefined
|
||||
};
|
||||
|
||||
logger.info(`转换完成: ${nodes.length}个节点, ${blackboard.length}个黑板变量, ${propertyBindings.length}个属性绑定`);
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找根节点
|
||||
*/
|
||||
private static findRootNode(nodes: EditorNode[]): EditorNode | null {
|
||||
return nodes.find(node =>
|
||||
node.template.category === '根节点' ||
|
||||
node.data.nodeType === 'root'
|
||||
) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换节点列表
|
||||
*/
|
||||
private static convertNodes(editorNodes: EditorNode[]): BehaviorTreeNodeData[] {
|
||||
return editorNodes.map(node => this.convertNode(node));
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换单个节点
|
||||
*/
|
||||
private static convertNode(editorNode: EditorNode): BehaviorTreeNodeData {
|
||||
// 复制data,去除编辑器特有的字段
|
||||
const data = { ...editorNode.data };
|
||||
|
||||
// 移除可能存在的UI相关字段
|
||||
delete data.nodeType; // 这个信息已经在nodeType字段中
|
||||
|
||||
return {
|
||||
id: editorNode.id,
|
||||
name: editorNode.template.displayName || editorNode.data.name || 'Node',
|
||||
nodeType: editorNode.template.type,
|
||||
data,
|
||||
children: editorNode.children || []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换黑板变量
|
||||
*/
|
||||
private static convertBlackboard(blackboard: Record<string, any>): BlackboardVariableDefinition[] {
|
||||
const variables: BlackboardVariableDefinition[] = [];
|
||||
|
||||
for (const [name, value] of Object.entries(blackboard)) {
|
||||
// 推断类型
|
||||
const type = this.inferBlackboardType(value);
|
||||
|
||||
variables.push({
|
||||
name,
|
||||
type,
|
||||
defaultValue: value
|
||||
});
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断黑板变量类型
|
||||
*/
|
||||
private static inferBlackboardType(value: any): BlackboardValueType {
|
||||
if (typeof value === 'number') {
|
||||
return BlackboardValueType.Number;
|
||||
} else if (typeof value === 'string') {
|
||||
return BlackboardValueType.String;
|
||||
} else if (typeof value === 'boolean') {
|
||||
return BlackboardValueType.Boolean;
|
||||
} else {
|
||||
return BlackboardValueType.Object;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换属性绑定
|
||||
*/
|
||||
private static convertPropertyBindings(
|
||||
connections: EditorConnection[],
|
||||
nodes: EditorNode[],
|
||||
blackboard: BlackboardVariableDefinition[]
|
||||
): PropertyBinding[] {
|
||||
const bindings: PropertyBinding[] = [];
|
||||
const blackboardVarNames = new Set(blackboard.map(v => v.name));
|
||||
|
||||
// 只处理属性类型的连接
|
||||
const propertyConnections = connections.filter(conn => conn.connectionType === 'property');
|
||||
|
||||
for (const conn of propertyConnections) {
|
||||
const fromNode = nodes.find(n => n.id === conn.from);
|
||||
const toNode = nodes.find(n => n.id === conn.to);
|
||||
|
||||
if (!fromNode || !toNode || !conn.toProperty) {
|
||||
logger.warn(`跳过无效的属性连接: from=${conn.from}, to=${conn.to}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let variableName: string | undefined;
|
||||
|
||||
// 检查 from 节点是否是黑板变量节点
|
||||
if (fromNode.data.nodeType === 'blackboard-variable') {
|
||||
variableName = fromNode.data.variableName;
|
||||
} else if (conn.fromProperty) {
|
||||
variableName = conn.fromProperty;
|
||||
}
|
||||
|
||||
if (!variableName) {
|
||||
logger.warn(`无法确定变量名: from节点=${fromNode.template.displayName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!blackboardVarNames.has(variableName)) {
|
||||
logger.warn(`属性绑定引用了不存在的黑板变量: ${variableName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
bindings.push({
|
||||
nodeId: toNode.id,
|
||||
propertyName: conn.toProperty,
|
||||
variableName
|
||||
});
|
||||
}
|
||||
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从资产格式转换回编辑器格式(用于加载)
|
||||
*
|
||||
* @param asset 行为树资产
|
||||
* @returns 编辑器格式数据
|
||||
*/
|
||||
static fromAsset(asset: BehaviorTreeAsset): EditorFormat {
|
||||
logger.info('开始转换资产格式到编辑器格式');
|
||||
|
||||
// 转换节点
|
||||
const nodes = this.convertNodesFromAsset(asset.nodes);
|
||||
|
||||
// 转换黑板
|
||||
const blackboard: Record<string, any> = {};
|
||||
for (const variable of asset.blackboard) {
|
||||
blackboard[variable.name] = variable.defaultValue;
|
||||
}
|
||||
|
||||
// 转换属性绑定为连接
|
||||
const connections = this.convertPropertyBindingsToConnections(
|
||||
asset.propertyBindings || [],
|
||||
asset.nodes
|
||||
);
|
||||
|
||||
// 添加节点连接(基于children关系)
|
||||
const nodeConnections = this.buildNodeConnections(asset.nodes);
|
||||
connections.push(...nodeConnections);
|
||||
|
||||
const editorData: EditorFormat = {
|
||||
version: asset.metadata.version,
|
||||
metadata: {
|
||||
name: asset.metadata.name,
|
||||
description: asset.metadata.description,
|
||||
createdAt: asset.metadata.createdAt,
|
||||
modifiedAt: asset.metadata.modifiedAt
|
||||
},
|
||||
nodes,
|
||||
connections,
|
||||
blackboard,
|
||||
canvasState: {
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1
|
||||
}
|
||||
};
|
||||
|
||||
logger.info(`转换完成: ${nodes.length}个节点, ${connections.length}个连接`);
|
||||
|
||||
return editorData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从资产格式转换节点
|
||||
*/
|
||||
private static convertNodesFromAsset(assetNodes: BehaviorTreeNodeData[]): EditorNode[] {
|
||||
return assetNodes.map((node, index) => {
|
||||
// 简单的自动布局:按索引计算位置
|
||||
const position = {
|
||||
x: 100 + (index % 5) * 250,
|
||||
y: 100 + Math.floor(index / 5) * 150
|
||||
};
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
template: {
|
||||
displayName: node.name,
|
||||
category: this.inferCategory(node.nodeType),
|
||||
type: node.nodeType
|
||||
},
|
||||
data: { ...node.data },
|
||||
position,
|
||||
children: node.children
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断节点分类
|
||||
*/
|
||||
private static inferCategory(nodeType: NodeType): string {
|
||||
switch (nodeType) {
|
||||
case NodeType.Action:
|
||||
return '动作';
|
||||
case NodeType.Condition:
|
||||
return '条件';
|
||||
case NodeType.Composite:
|
||||
return '组合';
|
||||
case NodeType.Decorator:
|
||||
return '装饰器';
|
||||
default:
|
||||
return '其他';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将属性绑定转换为连接
|
||||
*/
|
||||
private static convertPropertyBindingsToConnections(
|
||||
bindings: PropertyBinding[],
|
||||
nodes: BehaviorTreeNodeData[]
|
||||
): EditorConnection[] {
|
||||
const connections: EditorConnection[] = [];
|
||||
|
||||
for (const binding of bindings) {
|
||||
// 需要找到代表这个黑板变量的节点(如果有的话)
|
||||
// 这里简化处理,在实际使用中可能需要更复杂的逻辑
|
||||
connections.push({
|
||||
from: 'blackboard', // 占位符,实际使用时需要更复杂的处理
|
||||
to: binding.nodeId,
|
||||
toProperty: binding.propertyName,
|
||||
connectionType: 'property'
|
||||
});
|
||||
}
|
||||
|
||||
return connections;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据children关系构建节点连接
|
||||
*/
|
||||
private static buildNodeConnections(nodes: BehaviorTreeNodeData[]): EditorConnection[] {
|
||||
const connections: EditorConnection[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
for (const childId of node.children) {
|
||||
connections.push({
|
||||
from: node.id,
|
||||
to: childId,
|
||||
connectionType: 'node'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return connections;
|
||||
}
|
||||
}
|
||||
81
packages/behavior-tree/src/Serialization/NodeTemplates.ts
Normal file
81
packages/behavior-tree/src/Serialization/NodeTemplates.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NodeType } from '../Types/TaskStatus';
|
||||
import { getRegisteredNodeTemplates } from '../Decorators/BehaviorNodeDecorator';
|
||||
|
||||
/**
|
||||
* 节点数据JSON格式(用于编辑器)
|
||||
*/
|
||||
export interface NodeDataJSON {
|
||||
nodeType: string;
|
||||
compositeType?: string;
|
||||
decoratorType?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性定义(用于编辑器)
|
||||
*/
|
||||
export interface PropertyDefinition {
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'blackboard' | 'code' | 'variable' | 'asset';
|
||||
label: string;
|
||||
description?: string;
|
||||
defaultValue?: any;
|
||||
options?: Array<{ label: string; value: any }>;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点模板(用于编辑器)
|
||||
*/
|
||||
export interface NodeTemplate {
|
||||
type: NodeType;
|
||||
displayName: string;
|
||||
category: string;
|
||||
icon?: string;
|
||||
description: string;
|
||||
color?: string;
|
||||
className?: string;
|
||||
requiresChildren?: boolean;
|
||||
defaultConfig: Partial<NodeDataJSON>;
|
||||
properties: PropertyDefinition[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器节点模板库
|
||||
*
|
||||
* 使用装饰器系统管理所有节点
|
||||
*/
|
||||
export class NodeTemplates {
|
||||
/**
|
||||
* 获取所有节点模板(通过装饰器注册)
|
||||
*/
|
||||
static getAllTemplates(): NodeTemplate[] {
|
||||
return getRegisteredNodeTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型和子类型获取模板
|
||||
*/
|
||||
static getTemplate(type: NodeType, subType: string): NodeTemplate | undefined {
|
||||
return this.getAllTemplates().find(t => {
|
||||
if (t.type !== type) return false;
|
||||
const config: any = t.defaultConfig;
|
||||
|
||||
switch (type) {
|
||||
case NodeType.Composite:
|
||||
return config.compositeType === subType;
|
||||
case NodeType.Decorator:
|
||||
return config.decoratorType === subType;
|
||||
case NodeType.Action:
|
||||
return config.actionType === subType;
|
||||
case NodeType.Condition:
|
||||
return config.conditionType === subType;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user