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:
382
packages/behavior-tree/src/Services/AssetLoadingManager.ts
Normal file
382
packages/behavior-tree/src/Services/AssetLoadingManager.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { Entity, IService, createLogger } from '@esengine/ecs-framework';
|
||||
import {
|
||||
LoadingState,
|
||||
LoadingTask,
|
||||
LoadingTaskHandle,
|
||||
LoadingOptions,
|
||||
LoadingProgress,
|
||||
TimeoutError,
|
||||
CircularDependencyError,
|
||||
EntityDestroyedError
|
||||
} from './AssetLoadingTypes';
|
||||
|
||||
const logger = createLogger('AssetLoadingManager');
|
||||
|
||||
/**
|
||||
* 资产加载管理器
|
||||
*
|
||||
* 统一管理行为树资产的异步加载,提供:
|
||||
* - 超时检测和自动重试
|
||||
* - 循环引用检测
|
||||
* - 实体生命周期安全
|
||||
* - 加载状态追踪
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const manager = new AssetLoadingManager();
|
||||
*
|
||||
* const handle = manager.startLoading(
|
||||
* 'patrol',
|
||||
* parentEntity,
|
||||
* () => assetLoader.loadBehaviorTree('patrol'),
|
||||
* { timeoutMs: 5000, maxRetries: 3 }
|
||||
* );
|
||||
*
|
||||
* // 在系统的 process() 中轮询检查
|
||||
* const state = handle.getState();
|
||||
* if (state === LoadingState.Loaded) {
|
||||
* const entity = await handle.promise;
|
||||
* // 使用加载的实体
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class AssetLoadingManager implements IService {
|
||||
/** 正在进行的加载任务 */
|
||||
private tasks: Map<string, LoadingTask> = new Map();
|
||||
|
||||
/** 加载栈(用于循环检测) */
|
||||
private loadingStack: Set<string> = new Set();
|
||||
|
||||
/** 默认配置 */
|
||||
private defaultOptions: Required<Omit<LoadingOptions, 'parentAssetId'>> = {
|
||||
timeoutMs: 5000,
|
||||
maxRetries: 3,
|
||||
retryDelayBase: 100,
|
||||
maxRetryDelay: 2000
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始加载资产
|
||||
*
|
||||
* @param assetId 资产ID
|
||||
* @param parentEntity 父实体(用于生命周期检查)
|
||||
* @param loader 加载函数
|
||||
* @param options 加载选项
|
||||
* @returns 加载任务句柄
|
||||
*/
|
||||
startLoading(
|
||||
assetId: string,
|
||||
parentEntity: Entity,
|
||||
loader: () => Promise<Entity>,
|
||||
options: LoadingOptions = {}
|
||||
): LoadingTaskHandle {
|
||||
// 合并选项
|
||||
const finalOptions = {
|
||||
...this.defaultOptions,
|
||||
...options
|
||||
};
|
||||
|
||||
// 循环引用检测
|
||||
if (options.parentAssetId) {
|
||||
if (this.detectCircularDependency(assetId, options.parentAssetId)) {
|
||||
const error = new CircularDependencyError(
|
||||
`检测到循环引用: ${options.parentAssetId} → ${assetId}\n` +
|
||||
`加载栈: ${Array.from(this.loadingStack).join(' → ')}`
|
||||
);
|
||||
logger.error(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已有任务
|
||||
const existingTask = this.tasks.get(assetId);
|
||||
if (existingTask) {
|
||||
logger.debug(`资产 ${assetId} 已在加载中,返回现有任务`);
|
||||
return this.createHandle(existingTask);
|
||||
}
|
||||
|
||||
// 创建新任务
|
||||
const task: LoadingTask = {
|
||||
assetId,
|
||||
promise: null as any, // 稍后设置
|
||||
startTime: Date.now(),
|
||||
lastRetryTime: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: finalOptions.maxRetries,
|
||||
timeoutMs: finalOptions.timeoutMs,
|
||||
state: LoadingState.Pending,
|
||||
parentEntityId: parentEntity.id,
|
||||
parentEntity: parentEntity,
|
||||
parentAssetId: options.parentAssetId
|
||||
};
|
||||
|
||||
// 添加到加载栈(循环检测)
|
||||
this.loadingStack.add(assetId);
|
||||
|
||||
// 创建带超时和重试的Promise
|
||||
task.promise = this.loadWithTimeoutAndRetry(task, loader, finalOptions);
|
||||
task.state = LoadingState.Loading;
|
||||
|
||||
this.tasks.set(assetId, task);
|
||||
|
||||
logger.info(`开始加载资产: ${assetId}`, {
|
||||
timeoutMs: finalOptions.timeoutMs,
|
||||
maxRetries: finalOptions.maxRetries,
|
||||
parentAssetId: options.parentAssetId
|
||||
});
|
||||
|
||||
return this.createHandle(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* 带超时和重试的加载
|
||||
*/
|
||||
private async loadWithTimeoutAndRetry(
|
||||
task: LoadingTask,
|
||||
loader: () => Promise<Entity>,
|
||||
options: Required<Omit<LoadingOptions, 'parentAssetId'>>
|
||||
): Promise<Entity> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= task.maxRetries; attempt++) {
|
||||
// 检查父实体是否还存在
|
||||
if (task.parentEntity.isDestroyed) {
|
||||
const error = new EntityDestroyedError(
|
||||
`父实体已销毁,取消加载: ${task.assetId}`
|
||||
);
|
||||
task.state = LoadingState.Cancelled;
|
||||
this.cleanup(task.assetId);
|
||||
logger.warn(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
task.retryCount = attempt;
|
||||
task.lastRetryTime = Date.now();
|
||||
|
||||
logger.debug(`加载尝试 ${attempt + 1}/${task.maxRetries + 1}: ${task.assetId}`);
|
||||
|
||||
// 使用超时包装
|
||||
const result = await this.withTimeout(
|
||||
loader(),
|
||||
task.timeoutMs,
|
||||
`加载资产 ${task.assetId} 超时(${task.timeoutMs}ms)`
|
||||
);
|
||||
|
||||
// 加载成功
|
||||
task.state = LoadingState.Loaded;
|
||||
task.result = result;
|
||||
this.cleanup(task.assetId);
|
||||
|
||||
logger.info(`资产加载成功: ${task.assetId}`, {
|
||||
attempts: attempt + 1,
|
||||
elapsedMs: Date.now() - task.startTime
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
// 记录错误类型
|
||||
if (error instanceof TimeoutError) {
|
||||
task.state = LoadingState.Timeout;
|
||||
logger.warn(`资产加载超时: ${task.assetId} (尝试 ${attempt + 1})`);
|
||||
} else if (error instanceof EntityDestroyedError) {
|
||||
// 实体已销毁,不需要重试
|
||||
throw error;
|
||||
} else {
|
||||
logger.warn(`资产加载失败: ${task.assetId} (尝试 ${attempt + 1})`, error);
|
||||
}
|
||||
|
||||
// 最后一次尝试失败
|
||||
if (attempt === task.maxRetries) {
|
||||
task.state = LoadingState.Failed;
|
||||
task.error = lastError;
|
||||
this.cleanup(task.assetId);
|
||||
|
||||
logger.error(`资产加载最终失败: ${task.assetId}`, {
|
||||
attempts: attempt + 1,
|
||||
error: lastError.message
|
||||
});
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// 计算重试延迟(指数退避)
|
||||
const delayMs = Math.min(
|
||||
Math.pow(2, attempt) * options.retryDelayBase,
|
||||
options.maxRetryDelay
|
||||
);
|
||||
|
||||
logger.debug(`等待 ${delayMs}ms 后重试...`);
|
||||
await this.delay(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise 超时包装
|
||||
*/
|
||||
private withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string
|
||||
): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout | number;
|
||||
|
||||
const timeoutPromise = new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new TimeoutError(message));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
return Promise.race([
|
||||
promise.then(result => {
|
||||
clearTimeout(timeoutId as any);
|
||||
return result;
|
||||
}),
|
||||
timeoutPromise
|
||||
]).catch(error => {
|
||||
clearTimeout(timeoutId as any);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 循环依赖检测
|
||||
*/
|
||||
private detectCircularDependency(assetId: string, parentAssetId: string): boolean {
|
||||
// 如果父资产正在加载中,说明有循环
|
||||
if (this.loadingStack.has(parentAssetId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: 更复杂的循环检测(检查完整的依赖链)
|
||||
// 当前只检测直接循环(A→B→A)
|
||||
// 未来可以检测间接循环(A→B→C→A)
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务状态
|
||||
*/
|
||||
getTaskState(assetId: string): LoadingState {
|
||||
return this.tasks.get(assetId)?.state ?? LoadingState.Idle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务
|
||||
*/
|
||||
getTask(assetId: string): LoadingTask | undefined {
|
||||
return this.tasks.get(assetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消加载
|
||||
*/
|
||||
cancelLoading(assetId: string): void {
|
||||
const task = this.tasks.get(assetId);
|
||||
if (task) {
|
||||
task.state = LoadingState.Cancelled;
|
||||
this.cleanup(assetId);
|
||||
logger.info(`取消加载: ${assetId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理任务
|
||||
*/
|
||||
private cleanup(assetId: string): void {
|
||||
const task = this.tasks.get(assetId);
|
||||
if (task) {
|
||||
// 清除实体引用,帮助GC
|
||||
(task as any).parentEntity = null;
|
||||
}
|
||||
this.tasks.delete(assetId);
|
||||
this.loadingStack.delete(assetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务句柄
|
||||
*/
|
||||
private createHandle(task: LoadingTask): LoadingTaskHandle {
|
||||
return {
|
||||
assetId: task.assetId,
|
||||
|
||||
getState: () => task.state,
|
||||
|
||||
getError: () => task.error,
|
||||
|
||||
getProgress: (): LoadingProgress => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - task.startTime;
|
||||
const remaining = Math.max(0, task.timeoutMs - elapsed);
|
||||
|
||||
return {
|
||||
state: task.state,
|
||||
elapsedMs: elapsed,
|
||||
remainingTimeoutMs: remaining,
|
||||
retryCount: task.retryCount,
|
||||
maxRetries: task.maxRetries
|
||||
};
|
||||
},
|
||||
|
||||
cancel: () => this.cancelLoading(task.assetId),
|
||||
|
||||
promise: task.promise
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有正在加载的资产
|
||||
*/
|
||||
getLoadingAssets(): string[] {
|
||||
return Array.from(this.tasks.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取加载统计信息
|
||||
*/
|
||||
getStats(): {
|
||||
totalTasks: number;
|
||||
loadingTasks: number;
|
||||
failedTasks: number;
|
||||
timeoutTasks: number;
|
||||
} {
|
||||
const tasks = Array.from(this.tasks.values());
|
||||
|
||||
return {
|
||||
totalTasks: tasks.length,
|
||||
loadingTasks: tasks.filter(t => t.state === LoadingState.Loading).length,
|
||||
failedTasks: tasks.filter(t => t.state === LoadingState.Failed).length,
|
||||
timeoutTasks: tasks.filter(t => t.state === LoadingState.Timeout).length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有任务
|
||||
*/
|
||||
clear(): void {
|
||||
logger.info('清空所有加载任务', this.getStats());
|
||||
this.tasks.clear();
|
||||
this.loadingStack.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源
|
||||
*/
|
||||
dispose(): void {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
158
packages/behavior-tree/src/Services/AssetLoadingTypes.ts
Normal file
158
packages/behavior-tree/src/Services/AssetLoadingTypes.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Entity } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 资产加载状态
|
||||
*/
|
||||
export enum LoadingState {
|
||||
/** 未开始 */
|
||||
Idle = 'idle',
|
||||
/** 即将开始 */
|
||||
Pending = 'pending',
|
||||
/** 加载中 */
|
||||
Loading = 'loading',
|
||||
/** 加载成功 */
|
||||
Loaded = 'loaded',
|
||||
/** 加载失败 */
|
||||
Failed = 'failed',
|
||||
/** 加载超时 */
|
||||
Timeout = 'timeout',
|
||||
/** 已取消 */
|
||||
Cancelled = 'cancelled'
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载任务
|
||||
*/
|
||||
export interface LoadingTask {
|
||||
/** 资产ID */
|
||||
assetId: string;
|
||||
|
||||
/** 加载Promise */
|
||||
promise: Promise<Entity>;
|
||||
|
||||
/** 开始时间 */
|
||||
startTime: number;
|
||||
|
||||
/** 上次重试时间 */
|
||||
lastRetryTime: number;
|
||||
|
||||
/** 当前重试次数 */
|
||||
retryCount: number;
|
||||
|
||||
/** 最大重试次数 */
|
||||
maxRetries: number;
|
||||
|
||||
/** 超时时间(毫秒) */
|
||||
timeoutMs: number;
|
||||
|
||||
/** 当前状态 */
|
||||
state: LoadingState;
|
||||
|
||||
/** 错误信息 */
|
||||
error?: Error;
|
||||
|
||||
/** 父实体ID */
|
||||
parentEntityId: number;
|
||||
|
||||
/** 父实体引用(需要在使用前检查isDestroyed) */
|
||||
parentEntity: Entity;
|
||||
|
||||
/** 父资产ID(用于循环检测) */
|
||||
parentAssetId?: string;
|
||||
|
||||
/** 加载结果(缓存) */
|
||||
result?: Entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载任务句柄
|
||||
*/
|
||||
export interface LoadingTaskHandle {
|
||||
/** 资产ID */
|
||||
assetId: string;
|
||||
|
||||
/** 获取当前状态 */
|
||||
getState(): LoadingState;
|
||||
|
||||
/** 获取错误信息 */
|
||||
getError(): Error | undefined;
|
||||
|
||||
/** 获取加载进度信息 */
|
||||
getProgress(): LoadingProgress;
|
||||
|
||||
/** 取消加载 */
|
||||
cancel(): void;
|
||||
|
||||
/** 加载Promise */
|
||||
promise: Promise<Entity>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载进度信息
|
||||
*/
|
||||
export interface LoadingProgress {
|
||||
/** 当前状态 */
|
||||
state: LoadingState;
|
||||
|
||||
/** 已耗时(毫秒) */
|
||||
elapsedMs: number;
|
||||
|
||||
/** 剩余超时时间(毫秒) */
|
||||
remainingTimeoutMs: number;
|
||||
|
||||
/** 当前重试次数 */
|
||||
retryCount: number;
|
||||
|
||||
/** 最大重试次数 */
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载选项
|
||||
*/
|
||||
export interface LoadingOptions {
|
||||
/** 超时时间(毫秒),默认5000 */
|
||||
timeoutMs?: number;
|
||||
|
||||
/** 最大重试次数,默认3 */
|
||||
maxRetries?: number;
|
||||
|
||||
/** 父资产ID(用于循环检测) */
|
||||
parentAssetId?: string;
|
||||
|
||||
/** 重试延迟基数(毫秒),默认100 */
|
||||
retryDelayBase?: number;
|
||||
|
||||
/** 最大重试延迟(毫秒),默认2000 */
|
||||
maxRetryDelay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 超时错误
|
||||
*/
|
||||
export class TimeoutError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 循环依赖错误
|
||||
*/
|
||||
export class CircularDependencyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CircularDependencyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体已销毁错误
|
||||
*/
|
||||
export class EntityDestroyedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'EntityDestroyedError';
|
||||
}
|
||||
}
|
||||
227
packages/behavior-tree/src/Services/FileSystemAssetLoader.ts
Normal file
227
packages/behavior-tree/src/Services/FileSystemAssetLoader.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import type { IService } from '@esengine/ecs-framework';
|
||||
import { IAssetLoader } from './IAssetLoader';
|
||||
import { BehaviorTreeAsset } from '../Serialization/BehaviorTreeAsset';
|
||||
import { BehaviorTreeAssetSerializer, DeserializationOptions } from '../Serialization/BehaviorTreeAssetSerializer';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
|
||||
const logger = createLogger('FileSystemAssetLoader');
|
||||
|
||||
/**
|
||||
* 文件系统资产加载器配置
|
||||
*/
|
||||
export interface FileSystemAssetLoaderConfig {
|
||||
/** 资产基础路径 */
|
||||
basePath: string;
|
||||
|
||||
/** 资产格式 */
|
||||
format: 'json' | 'binary';
|
||||
|
||||
/** 文件扩展名(可选,默认根据格式自动设置) */
|
||||
extension?: string;
|
||||
|
||||
/** 是否启用缓存 */
|
||||
enableCache?: boolean;
|
||||
|
||||
/** 自定义文件读取函数(可选) */
|
||||
readFile?: (path: string) => Promise<string | Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件系统资产加载器
|
||||
*
|
||||
* 从文件系统加载行为树资产,支持 JSON 和 Binary 格式。
|
||||
* 提供资产缓存和预加载功能。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 创建加载器
|
||||
* const loader = new FileSystemAssetLoader({
|
||||
* basePath: 'assets/behavior-trees',
|
||||
* format: 'json',
|
||||
* enableCache: true
|
||||
* });
|
||||
*
|
||||
* // 加载资产
|
||||
* const asset = await loader.loadBehaviorTree('patrol');
|
||||
* ```
|
||||
*/
|
||||
export class FileSystemAssetLoader implements IAssetLoader, IService {
|
||||
private config: Required<FileSystemAssetLoaderConfig>;
|
||||
private cache: Map<string, BehaviorTreeAsset> = new Map();
|
||||
|
||||
constructor(config: FileSystemAssetLoaderConfig) {
|
||||
this.config = {
|
||||
basePath: config.basePath,
|
||||
format: config.format,
|
||||
extension: config.extension || (config.format === 'json' ? '.btree.json' : '.btree.bin'),
|
||||
enableCache: config.enableCache ?? true,
|
||||
readFile: config.readFile || this.defaultReadFile.bind(this)
|
||||
};
|
||||
|
||||
// 规范化路径
|
||||
this.config.basePath = this.config.basePath.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载行为树资产
|
||||
*/
|
||||
async loadBehaviorTree(assetId: string): Promise<BehaviorTreeAsset> {
|
||||
// 检查缓存
|
||||
if (this.config.enableCache && this.cache.has(assetId)) {
|
||||
logger.debug(`从缓存加载资产: ${assetId}`);
|
||||
return this.cache.get(assetId)!;
|
||||
}
|
||||
|
||||
logger.info(`加载行为树资产: ${assetId}`);
|
||||
|
||||
try {
|
||||
// 构建文件路径
|
||||
const filePath = this.resolveAssetPath(assetId);
|
||||
|
||||
// 读取文件
|
||||
const data = await this.config.readFile(filePath);
|
||||
|
||||
// 反序列化(自动根据 data 类型判断格式)
|
||||
const options: DeserializationOptions = {
|
||||
validate: true,
|
||||
strict: true
|
||||
};
|
||||
|
||||
const asset = BehaviorTreeAssetSerializer.deserialize(data, options);
|
||||
|
||||
// 缓存资产
|
||||
if (this.config.enableCache) {
|
||||
this.cache.set(assetId, asset);
|
||||
}
|
||||
|
||||
logger.info(`成功加载资产: ${assetId}`);
|
||||
return asset;
|
||||
} catch (error) {
|
||||
logger.error(`加载资产失败: ${assetId}`, error);
|
||||
throw new Error(`Failed to load behavior tree asset '${assetId}': ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查资产是否存在
|
||||
*/
|
||||
async exists(assetId: string): Promise<boolean> {
|
||||
// 如果在缓存中,直接返回 true
|
||||
if (this.config.enableCache && this.cache.has(assetId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = this.resolveAssetPath(assetId);
|
||||
// 尝试读取文件(如果文件不存在会抛出异常)
|
||||
await this.config.readFile(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载资产
|
||||
*/
|
||||
async preload(assetIds: string[]): Promise<void> {
|
||||
logger.info(`预加载 ${assetIds.length} 个资产...`);
|
||||
|
||||
const promises = assetIds.map(id => this.loadBehaviorTree(id).catch(error => {
|
||||
logger.warn(`预加载资产失败: ${id}`, error);
|
||||
}));
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
logger.info(`预加载完成`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载资产
|
||||
*/
|
||||
unload(assetId: string): void {
|
||||
if (this.cache.has(assetId)) {
|
||||
this.cache.delete(assetId);
|
||||
logger.debug(`卸载资产: ${assetId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空缓存
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
logger.info('缓存已清空');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的资产数量
|
||||
*/
|
||||
getCacheSize(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源
|
||||
*/
|
||||
dispose(): void {
|
||||
this.clearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析资产路径
|
||||
*/
|
||||
private resolveAssetPath(assetId: string): string {
|
||||
// 移除开头的斜杠
|
||||
const normalizedId = assetId.replace(/^\/+/, '');
|
||||
|
||||
// 构建完整路径
|
||||
return `${this.config.basePath}/${normalizedId}${this.config.extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认文件读取实现
|
||||
*
|
||||
* 注意:此实现依赖运行环境
|
||||
* - 浏览器:需要通过 fetch 或 XMLHttpRequest
|
||||
* - Node.js:需要使用 fs
|
||||
* - 游戏引擎:需要使用引擎的文件 API
|
||||
*
|
||||
* 用户应该提供自己的 readFile 实现
|
||||
*/
|
||||
private async defaultReadFile(path: string): Promise<string | Uint8Array> {
|
||||
// 检测运行环境
|
||||
if (typeof window !== 'undefined' && typeof fetch !== 'undefined') {
|
||||
// 浏览器环境
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (this.config.format === 'binary') {
|
||||
const buffer = await response.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
} else {
|
||||
return await response.text();
|
||||
}
|
||||
} else if (typeof require !== 'undefined') {
|
||||
// Node.js 环境
|
||||
try {
|
||||
const fs = require('fs').promises;
|
||||
if (this.config.format === 'binary') {
|
||||
const buffer = await fs.readFile(path);
|
||||
return new Uint8Array(buffer);
|
||||
} else {
|
||||
return await fs.readFile(path, 'utf-8');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read file '${path}': ${error}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'No default file reading implementation available. ' +
|
||||
'Please provide a custom readFile function in the config.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
175
packages/behavior-tree/src/Services/GlobalBlackboardService.ts
Normal file
175
packages/behavior-tree/src/Services/GlobalBlackboardService.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { IService } from '@esengine/ecs-framework';
|
||||
import { BlackboardValueType } from '../Types/TaskStatus';
|
||||
import { BlackboardVariable } from '../Components/BlackboardComponent';
|
||||
|
||||
/**
|
||||
* 全局黑板配置
|
||||
*/
|
||||
export interface GlobalBlackboardConfig {
|
||||
version: string;
|
||||
variables: BlackboardVariable[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局黑板服务
|
||||
*
|
||||
* 提供所有行为树共享的全局变量存储
|
||||
*
|
||||
* 使用方式:
|
||||
* ```typescript
|
||||
* // 注册服务(在 BehaviorTreePlugin.install 中自动完成)
|
||||
* core.services.registerSingleton(GlobalBlackboardService);
|
||||
*
|
||||
* // 获取服务
|
||||
* const blackboard = core.services.resolve(GlobalBlackboardService);
|
||||
* ```
|
||||
*/
|
||||
export class GlobalBlackboardService implements IService {
|
||||
private variables: Map<string, BlackboardVariable> = new Map();
|
||||
|
||||
dispose(): void {
|
||||
this.variables.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义全局变量
|
||||
*/
|
||||
defineVariable(
|
||||
name: string,
|
||||
type: BlackboardValueType,
|
||||
initialValue: any,
|
||||
options?: {
|
||||
readonly?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
): void {
|
||||
this.variables.set(name, {
|
||||
name,
|
||||
type,
|
||||
value: initialValue,
|
||||
readonly: options?.readonly ?? false,
|
||||
description: options?.description
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局变量值
|
||||
*/
|
||||
getValue<T = any>(name: string): T | undefined {
|
||||
const variable = this.variables.get(name);
|
||||
return variable?.value as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全局变量值
|
||||
*/
|
||||
setValue(name: string, value: any, force: boolean = false): boolean {
|
||||
const variable = this.variables.get(name);
|
||||
|
||||
if (!variable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (variable.readonly && !force) {
|
||||
return false;
|
||||
}
|
||||
|
||||
variable.value = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查全局变量是否存在
|
||||
*/
|
||||
hasVariable(name: string): boolean {
|
||||
return this.variables.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除全局变量
|
||||
*/
|
||||
removeVariable(name: string): boolean {
|
||||
return this.variables.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有变量名
|
||||
*/
|
||||
getVariableNames(): string[] {
|
||||
return Array.from(this.variables.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有变量
|
||||
*/
|
||||
getAllVariables(): BlackboardVariable[] {
|
||||
return Array.from(this.variables.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有全局变量
|
||||
*/
|
||||
clear(): void {
|
||||
this.variables.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置变量
|
||||
*/
|
||||
setVariables(values: Record<string, any>): void {
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const variable = this.variables.get(name);
|
||||
if (variable && !variable.readonly) {
|
||||
variable.value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取变量
|
||||
*/
|
||||
getVariables(names: string[]): Record<string, any> {
|
||||
const result: Record<string, any> = {};
|
||||
for (const name of names) {
|
||||
const value = this.getValue(name);
|
||||
if (value !== undefined) {
|
||||
result[name] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出配置
|
||||
*/
|
||||
exportConfig(): GlobalBlackboardConfig {
|
||||
return {
|
||||
version: '1.0',
|
||||
variables: Array.from(this.variables.values())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入配置
|
||||
*/
|
||||
importConfig(config: GlobalBlackboardConfig): void {
|
||||
this.variables.clear();
|
||||
for (const variable of config.variables) {
|
||||
this.variables.set(variable.name, variable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化为 JSON
|
||||
*/
|
||||
toJSON(): string {
|
||||
return JSON.stringify(this.exportConfig(), null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JSON 反序列化
|
||||
*/
|
||||
static fromJSON(json: string): GlobalBlackboardConfig {
|
||||
return JSON.parse(json);
|
||||
}
|
||||
}
|
||||
68
packages/behavior-tree/src/Services/IAssetLoader.ts
Normal file
68
packages/behavior-tree/src/Services/IAssetLoader.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { BehaviorTreeAsset } from '../Serialization/BehaviorTreeAsset';
|
||||
|
||||
/**
|
||||
* 资产加载器接口
|
||||
*
|
||||
* 提供可扩展的资产加载机制,允许用户自定义资产加载逻辑。
|
||||
* 支持从文件系统、网络、数据库、自定义打包格式等加载资产。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 使用默认的文件系统加载器
|
||||
* const loader = new FileSystemAssetLoader({
|
||||
* basePath: 'assets/behavior-trees',
|
||||
* format: 'json'
|
||||
* });
|
||||
* core.services.registerInstance(FileSystemAssetLoader, loader);
|
||||
*
|
||||
* // 或实现自定义加载器
|
||||
* class NetworkAssetLoader implements IAssetLoader {
|
||||
* async loadBehaviorTree(assetId: string): Promise<BehaviorTreeAsset> {
|
||||
* const response = await fetch(`/api/assets/${assetId}`);
|
||||
* return response.json();
|
||||
* }
|
||||
*
|
||||
* async exists(assetId: string): Promise<boolean> {
|
||||
* const response = await fetch(`/api/assets/${assetId}/exists`);
|
||||
* return response.json();
|
||||
* }
|
||||
* }
|
||||
* core.services.registerInstance(FileSystemAssetLoader, new NetworkAssetLoader());
|
||||
* ```
|
||||
*/
|
||||
export interface IAssetLoader {
|
||||
/**
|
||||
* 加载行为树资产
|
||||
*
|
||||
* @param assetId 资产逻辑ID,例如 'patrol' 或 'ai/patrol'
|
||||
* @returns 行为树资产对象
|
||||
* @throws 如果资产不存在或加载失败
|
||||
*/
|
||||
loadBehaviorTree(assetId: string): Promise<BehaviorTreeAsset>;
|
||||
|
||||
/**
|
||||
* 检查资产是否存在
|
||||
*
|
||||
* @param assetId 资产逻辑ID
|
||||
* @returns 资产是否存在
|
||||
*/
|
||||
exists(assetId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 预加载资产(可选)
|
||||
*
|
||||
* 用于提前加载资产到缓存,减少运行时延迟
|
||||
*
|
||||
* @param assetIds 要预加载的资产ID列表
|
||||
*/
|
||||
preload?(assetIds: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* 卸载资产(可选)
|
||||
*
|
||||
* 释放资产占用的内存
|
||||
*
|
||||
* @param assetId 资产ID
|
||||
*/
|
||||
unload?(assetId: string): void;
|
||||
}
|
||||
355
packages/behavior-tree/src/Services/WorkspaceService.ts
Normal file
355
packages/behavior-tree/src/Services/WorkspaceService.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { IService } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 资产类型
|
||||
*/
|
||||
export enum AssetType {
|
||||
BehaviorTree = 'behavior-tree',
|
||||
Blackboard = 'blackboard',
|
||||
Unknown = 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产注册信息
|
||||
*/
|
||||
export interface AssetRegistry {
|
||||
/** 资产唯一ID */
|
||||
id: string;
|
||||
|
||||
/** 资产名称 */
|
||||
name: string;
|
||||
|
||||
/** 资产相对路径(相对于工作区根目录) */
|
||||
path: string;
|
||||
|
||||
/** 资产类型 */
|
||||
type: AssetType;
|
||||
|
||||
/** 依赖的其他资产ID列表 */
|
||||
dependencies: string[];
|
||||
|
||||
/** 最后修改时间 */
|
||||
lastModified?: number;
|
||||
|
||||
/** 资产元数据 */
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作区配置
|
||||
*/
|
||||
export interface WorkspaceConfig {
|
||||
/** 工作区名称 */
|
||||
name: string;
|
||||
|
||||
/** 工作区版本 */
|
||||
version: string;
|
||||
|
||||
/** 工作区根目录(绝对路径) */
|
||||
rootPath: string;
|
||||
|
||||
/** 资产目录配置 */
|
||||
assetPaths: {
|
||||
/** 行为树目录 */
|
||||
behaviorTrees: string;
|
||||
|
||||
/** 黑板目录 */
|
||||
blackboards: string;
|
||||
};
|
||||
|
||||
/** 资产注册表 */
|
||||
assets: AssetRegistry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作区服务
|
||||
*
|
||||
* 管理项目的工作区配置和资产注册表,提供:
|
||||
* - 工作区配置的加载和保存
|
||||
* - 资产注册和查询
|
||||
* - 依赖关系追踪
|
||||
* - 循环依赖检测
|
||||
*/
|
||||
export class WorkspaceService implements IService {
|
||||
private config: WorkspaceConfig | null = null;
|
||||
private assetMap: Map<string, AssetRegistry> = new Map();
|
||||
private assetPathMap: Map<string, AssetRegistry> = new Map();
|
||||
|
||||
/**
|
||||
* 初始化工作区
|
||||
*/
|
||||
initialize(config: WorkspaceConfig): void {
|
||||
this.config = config;
|
||||
this.rebuildAssetMaps();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建资产映射表
|
||||
*/
|
||||
private rebuildAssetMaps(): void {
|
||||
this.assetMap.clear();
|
||||
this.assetPathMap.clear();
|
||||
|
||||
if (!this.config) return;
|
||||
|
||||
for (const asset of this.config.assets) {
|
||||
this.assetMap.set(asset.id, asset);
|
||||
this.assetPathMap.set(asset.path, asset);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作区配置
|
||||
*/
|
||||
getConfig(): WorkspaceConfig | null {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新工作区配置
|
||||
*/
|
||||
updateConfig(config: WorkspaceConfig): void {
|
||||
this.config = config;
|
||||
this.rebuildAssetMaps();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册资产
|
||||
*/
|
||||
registerAsset(asset: AssetRegistry): void {
|
||||
if (!this.config) {
|
||||
throw new Error('工作区未初始化');
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = this.config.assets.find(a => a.id === asset.id);
|
||||
if (existing) {
|
||||
// 更新现有资产
|
||||
Object.assign(existing, asset);
|
||||
} else {
|
||||
// 添加新资产
|
||||
this.config.assets.push(asset);
|
||||
}
|
||||
|
||||
this.rebuildAssetMaps();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消注册资产
|
||||
*/
|
||||
unregisterAsset(assetId: string): void {
|
||||
if (!this.config) return;
|
||||
|
||||
const index = this.config.assets.findIndex(a => a.id === assetId);
|
||||
if (index !== -1) {
|
||||
this.config.assets.splice(index, 1);
|
||||
this.rebuildAssetMaps();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过ID获取资产
|
||||
*/
|
||||
getAssetById(assetId: string): AssetRegistry | undefined {
|
||||
return this.assetMap.get(assetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过路径获取资产
|
||||
*/
|
||||
getAssetByPath(path: string): AssetRegistry | undefined {
|
||||
return this.assetPathMap.get(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有资产
|
||||
*/
|
||||
getAllAssets(): AssetRegistry[] {
|
||||
return this.config?.assets || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型获取资产
|
||||
*/
|
||||
getAssetsByType(type: AssetType): AssetRegistry[] {
|
||||
return this.getAllAssets().filter(a => a.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取行为树资产列表
|
||||
*/
|
||||
getBehaviorTreeAssets(): AssetRegistry[] {
|
||||
return this.getAssetsByType(AssetType.BehaviorTree);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取黑板资产列表
|
||||
*/
|
||||
getBlackboardAssets(): AssetRegistry[] {
|
||||
return this.getAssetsByType(AssetType.Blackboard);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资产的所有依赖(递归)
|
||||
*/
|
||||
getAssetDependencies(assetId: string, visited = new Set<string>()): AssetRegistry[] {
|
||||
if (visited.has(assetId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
visited.add(assetId);
|
||||
|
||||
const asset = this.getAssetById(assetId);
|
||||
if (!asset) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dependencies: AssetRegistry[] = [];
|
||||
|
||||
for (const depId of asset.dependencies) {
|
||||
const depAsset = this.getAssetById(depId);
|
||||
if (depAsset) {
|
||||
dependencies.push(depAsset);
|
||||
// 递归获取依赖的依赖
|
||||
dependencies.push(...this.getAssetDependencies(depId, visited));
|
||||
}
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测循环依赖
|
||||
*
|
||||
* @param assetId 要检查的资产ID
|
||||
* @returns 如果存在循环依赖,返回循环路径;否则返回 null
|
||||
*/
|
||||
detectCircularDependency(assetId: string): string[] | null {
|
||||
const visited = new Set<string>();
|
||||
const path: string[] = [];
|
||||
|
||||
const dfs = (currentId: string): boolean => {
|
||||
if (path.includes(currentId)) {
|
||||
// 找到循环
|
||||
path.push(currentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (visited.has(currentId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
visited.add(currentId);
|
||||
path.push(currentId);
|
||||
|
||||
const asset = this.getAssetById(currentId);
|
||||
if (asset) {
|
||||
for (const depId of asset.dependencies) {
|
||||
if (dfs(depId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path.pop();
|
||||
return false;
|
||||
};
|
||||
|
||||
return dfs(assetId) ? path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以添加依赖(不会造成循环依赖)
|
||||
*
|
||||
* @param assetId 资产ID
|
||||
* @param dependencyId 要添加的依赖ID
|
||||
* @returns 是否可以安全添加
|
||||
*/
|
||||
canAddDependency(assetId: string, dependencyId: string): boolean {
|
||||
const asset = this.getAssetById(assetId);
|
||||
if (!asset) return false;
|
||||
|
||||
// 临时添加依赖
|
||||
const originalDeps = [...asset.dependencies];
|
||||
asset.dependencies.push(dependencyId);
|
||||
|
||||
// 检测循环依赖
|
||||
const hasCircular = this.detectCircularDependency(assetId) !== null;
|
||||
|
||||
// 恢复原始依赖
|
||||
asset.dependencies = originalDeps;
|
||||
|
||||
return !hasCircular;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加资产依赖
|
||||
*/
|
||||
addAssetDependency(assetId: string, dependencyId: string): boolean {
|
||||
if (!this.canAddDependency(assetId, dependencyId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const asset = this.getAssetById(assetId);
|
||||
if (!asset) return false;
|
||||
|
||||
if (!asset.dependencies.includes(dependencyId)) {
|
||||
asset.dependencies.push(dependencyId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除资产依赖
|
||||
*/
|
||||
removeAssetDependency(assetId: string, dependencyId: string): void {
|
||||
const asset = this.getAssetById(assetId);
|
||||
if (!asset) return;
|
||||
|
||||
const index = asset.dependencies.indexOf(dependencyId);
|
||||
if (index !== -1) {
|
||||
asset.dependencies.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析资产路径(支持相对路径和绝对路径)
|
||||
*/
|
||||
resolveAssetPath(path: string): string {
|
||||
if (!this.config) return path;
|
||||
|
||||
// 如果是绝对路径,直接返回
|
||||
if (path.startsWith('/') || path.match(/^[A-Za-z]:/)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// 相对路径,拼接工作区根目录
|
||||
return `${this.config.rootPath}/${path}`.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资产的相对路径
|
||||
*/
|
||||
getRelativePath(absolutePath: string): string {
|
||||
if (!this.config) return absolutePath;
|
||||
|
||||
const rootPath = this.config.rootPath.replace(/\\/g, '/');
|
||||
const absPath = absolutePath.replace(/\\/g, '/');
|
||||
|
||||
if (absPath.startsWith(rootPath)) {
|
||||
return absPath.substring(rootPath.length + 1);
|
||||
}
|
||||
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
dispose(): void {
|
||||
this.config = null;
|
||||
this.assetMap.clear();
|
||||
this.assetPathMap.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user