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:
YHH
2025-10-27 09:29:11 +08:00
committed by GitHub
parent 0cd99209c4
commit 009f8af4e1
234 changed files with 21824 additions and 15295 deletions

View File

@@ -0,0 +1,15 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/index.ts'
],
coverageDirectory: 'coverage',
verbose: true,
testTimeout: 10000
};

View File

@@ -0,0 +1,62 @@
{
"name": "@esengine/behavior-tree",
"version": "1.0.0",
"description": "完全ECS化的行为树系统基于组件和实体的行为树实现",
"main": "bin/index.js",
"types": "bin/index.d.ts",
"exports": {
".": {
"types": "./bin/index.d.ts",
"import": "./bin/index.js",
"development": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
}
},
"files": [
"bin/**/*"
],
"keywords": [
"ecs",
"behavior-tree",
"ai",
"game-ai",
"entity-component-system"
],
"scripts": {
"clean": "rimraf bin dist tsconfig.tsbuildinfo",
"build:ts": "tsc",
"prebuild": "npm run clean",
"build": "npm run build:ts",
"build:watch": "tsc --watch",
"rebuild": "npm run clean && npm run build",
"test": "jest --config jest.config.cjs",
"test:watch": "jest --watch --config jest.config.cjs"
},
"author": "yhh",
"license": "MIT",
"peerDependencies": {
"@esengine/ecs-framework": "^2.2.8"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^20.19.17",
"jest": "^29.7.0",
"rimraf": "^5.0.0",
"ts-jest": "^29.4.0",
"typescript": "^5.8.3"
},
"dependencies": {
"tslib": "^2.8.1"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"type": "git",
"url": "https://github.com/esengine/ecs-framework.git",
"directory": "packages/behavior-tree"
}
}

View File

@@ -0,0 +1,547 @@
import { Entity, IScene } from '@esengine/ecs-framework';
import { BehaviorTreeNode } from './Components/BehaviorTreeNode';
import { CompositeNodeComponent } from './Components/CompositeNodeComponent';
import { DecoratorNodeComponent } from './Components/DecoratorNodeComponent';
import { BlackboardComponent } from './Components/BlackboardComponent';
import { NodeType, CompositeType, DecoratorType, BlackboardValueType } from './Types/TaskStatus';
// 导入动作组件
import { WaitAction } from './Components/Actions/WaitAction';
import { LogAction } from './Components/Actions/LogAction';
import { SetBlackboardValueAction } from './Components/Actions/SetBlackboardValueAction';
import { ModifyBlackboardValueAction, ModifyOperation } from './Components/Actions/ModifyBlackboardValueAction';
import { ExecuteAction, CustomActionFunction } 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, CustomConditionFunction } from './Components/Conditions/ExecuteCondition';
// 导入装饰器组件
import { RepeaterNode } from './Components/Decorators/RepeaterNode';
import { InverterNode } from './Components/Decorators/InverterNode';
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';
/**
* 行为树构建器
*
* 提供流式 API 来构建行为树结构
*
* @example
* ```typescript
* const aiRoot = BehaviorTreeBuilder.create(scene, 'AI')
* .blackboard()
* .defineVariable('health', BlackboardValueType.Number, 100)
* .defineVariable('target', BlackboardValueType.Object, null)
* .endBlackboard()
* .selector('MainSelector')
* .sequence('AttackSequence')
* .condition((entity, blackboard) => {
* return blackboard?.getValue('health') > 50;
* })
* .action('Attack', (entity) => TaskStatus.Success)
* .end()
* .action('Flee', (entity) => TaskStatus.Success)
* .end()
* .build();
* ```
*/
export class BehaviorTreeBuilder {
private scene: IScene;
private currentEntity: Entity;
private entityStack: Entity[] = [];
private blackboardEntity?: Entity;
private constructor(scene: IScene, rootName: string) {
this.scene = scene;
this.currentEntity = scene.createEntity(rootName);
}
/**
* 创建行为树构建器
*
* @param scene 场景实例
* @param rootName 根节点名称
* @returns 构建器实例
*/
static create(scene: IScene, rootName: string = 'BehaviorTreeRoot'): BehaviorTreeBuilder {
return new BehaviorTreeBuilder(scene, rootName);
}
/**
* 添加黑板组件到根节点
*/
blackboard(): BehaviorTreeBuilder {
this.blackboardEntity = this.currentEntity;
this.currentEntity.addComponent(new BlackboardComponent());
return this;
}
/**
* 定义黑板变量
*/
defineVariable(
name: string,
type: BlackboardValueType,
initialValue: any,
options?: { readonly?: boolean; description?: string }
): BehaviorTreeBuilder {
if (!this.blackboardEntity) {
throw new Error('Must call blackboard() first');
}
const blackboard = this.blackboardEntity.getComponent(BlackboardComponent);
if (blackboard) {
blackboard.defineVariable(name, type, initialValue, options);
}
return this;
}
/**
* 结束黑板定义
*/
endBlackboard(): BehaviorTreeBuilder {
this.blackboardEntity = undefined;
return this;
}
/**
* 创建序列节点
*/
sequence(name: string = 'Sequence'): BehaviorTreeBuilder {
return this.composite(name, CompositeType.Sequence);
}
/**
* 创建选择器节点
*/
selector(name: string = 'Selector'): BehaviorTreeBuilder {
return this.composite(name, CompositeType.Selector);
}
/**
* 创建并行节点
*/
parallel(name: string = 'Parallel'): BehaviorTreeBuilder {
return this.composite(name, CompositeType.Parallel);
}
/**
* 创建并行选择器节点
*/
parallelSelector(name: string = 'ParallelSelector'): BehaviorTreeBuilder {
return this.composite(name, CompositeType.ParallelSelector);
}
/**
* 创建随机序列节点
*/
randomSequence(name: string = 'RandomSequence'): BehaviorTreeBuilder {
return this.composite(name, CompositeType.RandomSequence);
}
/**
* 创建随机选择器节点
*/
randomSelector(name: string = 'RandomSelector'): BehaviorTreeBuilder {
return this.composite(name, CompositeType.RandomSelector);
}
/**
* 创建复合节点
*/
private composite(name: string, type: CompositeType): BehaviorTreeBuilder {
this.entityStack.push(this.currentEntity);
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Composite;
node.nodeName = name;
const composite = entity.addComponent(new CompositeNodeComponent());
composite.compositeType = type;
this.currentEntity = entity;
return this;
}
/**
* 创建反转装饰器
*/
inverter(name: string = 'Inverter'): BehaviorTreeBuilder {
this.entityStack.push(this.currentEntity);
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Decorator;
node.nodeName = name;
entity.addComponent(new InverterNode());
this.currentEntity = entity;
return this;
}
/**
* 创建重复装饰器
*/
repeater(name: string = 'Repeater', count: number = -1, endOnFailure: boolean = false): BehaviorTreeBuilder {
this.entityStack.push(this.currentEntity);
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Decorator;
node.nodeName = name;
const decorator = entity.addComponent(new RepeaterNode());
decorator.repeatCount = count;
decorator.endOnFailure = endOnFailure;
this.currentEntity = entity;
return this;
}
/**
* 创建直到成功装饰器
*/
untilSuccess(name: string = 'UntilSuccess'): BehaviorTreeBuilder {
this.entityStack.push(this.currentEntity);
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Decorator;
node.nodeName = name;
entity.addComponent(new UntilSuccessNode());
this.currentEntity = entity;
return this;
}
/**
* 创建直到失败装饰器
*/
untilFail(name: string = 'UntilFail'): BehaviorTreeBuilder {
this.entityStack.push(this.currentEntity);
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Decorator;
node.nodeName = name;
entity.addComponent(new UntilFailNode());
this.currentEntity = entity;
return this;
}
/**
* 创建总是成功装饰器
*/
alwaysSucceed(name: string = 'AlwaysSucceed'): BehaviorTreeBuilder {
this.entityStack.push(this.currentEntity);
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Decorator;
node.nodeName = name;
entity.addComponent(new AlwaysSucceedNode());
this.currentEntity = entity;
return this;
}
/**
* 创建总是失败装饰器
*/
alwaysFail(name: string = 'AlwaysFail'): BehaviorTreeBuilder {
this.entityStack.push(this.currentEntity);
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Decorator;
node.nodeName = name;
entity.addComponent(new AlwaysFailNode());
this.currentEntity = entity;
return this;
}
/**
* 创建条件装饰器
*/
conditional(name: string, conditionCode: string): BehaviorTreeBuilder {
this.entityStack.push(this.currentEntity);
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Decorator;
node.nodeName = name;
const decorator = entity.addComponent(new ConditionalNode());
decorator.conditionCode = conditionCode;
this.currentEntity = entity;
return this;
}
/**
* 创建冷却装饰器
*/
cooldown(name: string = 'Cooldown', cooldownTime: number = 1.0): BehaviorTreeBuilder {
this.entityStack.push(this.currentEntity);
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Decorator;
node.nodeName = name;
const decorator = entity.addComponent(new CooldownNode());
decorator.cooldownTime = cooldownTime;
this.currentEntity = entity;
return this;
}
/**
* 创建超时装饰器
*/
timeout(name: string = 'Timeout', timeoutDuration: number = 5.0): BehaviorTreeBuilder {
this.entityStack.push(this.currentEntity);
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Decorator;
node.nodeName = name;
const decorator = entity.addComponent(new TimeoutNode());
decorator.timeoutDuration = timeoutDuration;
this.currentEntity = entity;
return this;
}
/**
* 创建等待动作
*/
wait(waitTime: number, name: string = 'Wait'): BehaviorTreeBuilder {
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Action;
node.nodeName = name;
const action = entity.addComponent(new WaitAction());
action.waitTime = waitTime;
return this;
}
/**
* 创建日志动作
*/
log(message: string, level: 'log' | 'info' | 'warn' | 'error' = 'log', name: string = 'Log'): BehaviorTreeBuilder {
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Action;
node.nodeName = name;
const action = entity.addComponent(new LogAction());
action.message = message;
action.level = level;
return this;
}
/**
* 创建设置黑板值动作
*/
setBlackboardValue(variableName: string, value: any, name: string = 'SetValue'): BehaviorTreeBuilder {
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Action;
node.nodeName = name;
const action = entity.addComponent(new SetBlackboardValueAction());
action.variableName = variableName;
action.value = value;
return this;
}
/**
* 创建修改黑板值动作
*/
modifyBlackboardValue(
variableName: string,
operation: ModifyOperation,
operand: any,
name: string = 'ModifyValue'
): BehaviorTreeBuilder {
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Action;
node.nodeName = name;
const action = entity.addComponent(new ModifyBlackboardValueAction());
action.variableName = variableName;
action.operation = operation;
action.operand = operand;
return this;
}
/**
* 创建自定义动作
*/
action(name: string, func: CustomActionFunction): BehaviorTreeBuilder {
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Action;
node.nodeName = name;
const action = entity.addComponent(new ExecuteAction());
action.setFunction(func);
return this;
}
/**
* 创建黑板比较条件
*/
compareBlackboardValue(
variableName: string,
operator: CompareOperator,
compareValue: any,
name: string = 'Compare'
): BehaviorTreeBuilder {
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Condition;
node.nodeName = name;
const condition = entity.addComponent(new BlackboardCompareCondition());
condition.variableName = variableName;
condition.operator = operator;
condition.compareValue = compareValue;
return this;
}
/**
* 创建黑板变量存在条件
*/
checkBlackboardExists(variableName: string, checkNotNull: boolean = false, name: string = 'Exists'): BehaviorTreeBuilder {
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Condition;
node.nodeName = name;
const condition = entity.addComponent(new BlackboardExistsCondition());
condition.variableName = variableName;
condition.checkNotNull = checkNotNull;
return this;
}
/**
* 创建随机概率条件
*/
randomProbability(probability: number, name: string = 'Random'): BehaviorTreeBuilder {
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Condition;
node.nodeName = name;
const condition = entity.addComponent(new RandomProbabilityCondition());
condition.probability = probability;
return this;
}
/**
* 创建自定义条件
*/
condition(func: CustomConditionFunction, name: string = 'Condition'): BehaviorTreeBuilder {
const entity = this.scene.createEntity(name);
this.currentEntity.addChild(entity);
const node = entity.addComponent(new BehaviorTreeNode());
node.nodeType = NodeType.Condition;
node.nodeName = name;
const condition = entity.addComponent(new ExecuteCondition());
condition.setFunction(func);
return this;
}
/**
* 结束当前节点,返回父节点
*/
end(): BehaviorTreeBuilder {
if (this.entityStack.length === 0) {
throw new Error('No parent node to return to');
}
this.currentEntity = this.entityStack.pop()!;
return this;
}
/**
* 构建并返回根节点实体
*/
build(): Entity {
// 确保返回到根节点
while (this.entityStack.length > 0) {
this.currentEntity = this.entityStack.pop()!;
}
return this.currentEntity;
}
}

View File

@@ -0,0 +1,97 @@
import type { Core } from '@esengine/ecs-framework';
import type { ServiceContainer, IPlugin, IScene } from '@esengine/ecs-framework';
import { WorldManager } from '@esengine/ecs-framework';
import { LeafExecutionSystem } from './Systems/LeafExecutionSystem';
import { DecoratorExecutionSystem } from './Systems/DecoratorExecutionSystem';
import { CompositeExecutionSystem } from './Systems/CompositeExecutionSystem';
import { SubTreeExecutionSystem } from './Systems/SubTreeExecutionSystem';
import { GlobalBlackboardService } from './Services/GlobalBlackboardService';
/**
* 行为树插件
*
* 提供便捷方法向场景添加行为树系统
*
* @example
* ```typescript
* const core = Core.create();
* const plugin = new BehaviorTreePlugin();
* await core.pluginManager.install(plugin);
*
* // 为场景添加行为树系统
* const scene = new Scene();
* plugin.setupScene(scene);
* ```
*/
export class BehaviorTreePlugin implements IPlugin {
readonly name = '@esengine/behavior-tree';
readonly version = '1.0.0';
private worldManager: WorldManager | null = null;
private services: ServiceContainer | null = null;
/**
* 安装插件
*/
async install(core: Core, services: ServiceContainer): Promise<void> {
this.services = services;
// 注册全局黑板服务
services.registerSingleton(GlobalBlackboardService);
this.worldManager = services.resolve(WorldManager);
}
/**
* 卸载插件
*/
async uninstall(): Promise<void> {
// 注销全局黑板服务
if (this.services) {
this.services.unregister(GlobalBlackboardService);
}
this.worldManager = null;
this.services = null;
}
/**
* 为场景设置行为树系统
*
* 向场景添加所有必需的行为树系统:
* - LeafExecutionSystem (updateOrder: 100)
* - DecoratorExecutionSystem (updateOrder: 200)
* - CompositeExecutionSystem (updateOrder: 300)
* - SubTreeExecutionSystem (updateOrder: 300)
*
* @param scene 目标场景
*
* @example
* ```typescript
* const scene = new Scene();
* behaviorTreePlugin.setupScene(scene);
* ```
*/
public setupScene(scene: IScene): void {
scene.addSystem(new LeafExecutionSystem());
scene.addSystem(new DecoratorExecutionSystem());
scene.addSystem(new CompositeExecutionSystem());
scene.addSystem(new SubTreeExecutionSystem());
}
/**
* 为所有现有场景设置行为树系统
*/
public setupAllScenes(): void {
if (!this.worldManager) {
throw new Error('Plugin not installed');
}
const worlds = this.worldManager.getAllWorlds();
for (const world of worlds) {
for (const scene of world.getAllScenes()) {
this.setupScene(scene);
}
}
}
}

View File

@@ -0,0 +1,179 @@
import { Entity } from '@esengine/ecs-framework';
import { BehaviorTreeNode } from './Components/BehaviorTreeNode';
import { ActiveNode } from './Components/ActiveNode';
import { TaskStatus } from './Types/TaskStatus';
/**
* 行为树启动/停止辅助类
*
* 提供便捷方法来启动、停止和暂停行为树
*/
export class BehaviorTreeStarter {
/**
* 启动行为树
*
* 给根节点添加 ActiveNode 组件,使行为树开始执行
*
* @param rootEntity 行为树根节点实体
*
* @example
* ```typescript
* const aiRoot = scene.createEntity('aiRoot');
* // ... 构建行为树结构
* BehaviorTreeStarter.start(aiRoot);
* ```
*/
static start(rootEntity: Entity): void {
if (!rootEntity.hasComponent(BehaviorTreeNode)) {
throw new Error('Entity must have BehaviorTreeNode component');
}
if (!rootEntity.hasComponent(ActiveNode)) {
rootEntity.addComponent(new ActiveNode());
}
}
/**
* 停止行为树
*
* 移除所有节点的 ActiveNode 组件,停止执行
*
* @param rootEntity 行为树根节点实体
*
* @example
* ```typescript
* BehaviorTreeStarter.stop(aiRoot);
* ```
*/
static stop(rootEntity: Entity): void {
this.stopRecursive(rootEntity);
}
/**
* 递归停止所有子节点
*/
private static stopRecursive(entity: Entity): void {
// 移除活跃标记
if (entity.hasComponent(ActiveNode)) {
entity.removeComponentByType(ActiveNode);
}
// 重置节点状态
const node = entity.getComponent(BehaviorTreeNode);
if (node) {
node.reset();
}
// 递归处理子节点
for (const child of entity.children) {
this.stopRecursive(child);
}
}
/**
* 暂停行为树
*
* 移除 ActiveNode 但保留节点状态,可以恢复执行
*
* @param rootEntity 行为树根节点实体
*
* @example
* ```typescript
* // 暂停
* BehaviorTreeStarter.pause(aiRoot);
*
* // 恢复
* BehaviorTreeStarter.resume(aiRoot);
* ```
*/
static pause(rootEntity: Entity): void {
this.pauseRecursive(rootEntity);
}
/**
* 递归暂停所有子节点
*/
private static pauseRecursive(entity: Entity): void {
// 只移除活跃标记,不重置状态
if (entity.hasComponent(ActiveNode)) {
entity.removeComponentByType(ActiveNode);
}
// 递归处理子节点
for (const child of entity.children) {
this.pauseRecursive(child);
}
}
/**
* 恢复行为树执行
*
* 从暂停状态恢复,重新添加 ActiveNode 到之前正在执行的节点
*
* @param rootEntity 行为树根节点实体
*
* @example
* ```typescript
* BehaviorTreeStarter.resume(aiRoot);
* ```
*/
static resume(rootEntity: Entity): void {
this.resumeRecursive(rootEntity);
}
/**
* 递归恢复所有正在执行的节点
*/
private static resumeRecursive(entity: Entity): void {
const node = entity.getComponent(BehaviorTreeNode);
if (!node) {
return;
}
// 如果节点状态是 Running恢复活跃标记
if (node.status === TaskStatus.Running) {
if (!entity.hasComponent(ActiveNode)) {
entity.addComponent(new ActiveNode());
}
}
// 递归处理子节点
for (const child of entity.children) {
this.resumeRecursive(child);
}
}
/**
* 重启行为树
*
* 停止并重置所有节点,然后重新启动
*
* @param rootEntity 行为树根节点实体
*
* @example
* ```typescript
* BehaviorTreeStarter.restart(aiRoot);
* ```
*/
static restart(rootEntity: Entity): void {
this.stop(rootEntity);
this.start(rootEntity);
}
/**
* 检查行为树是否正在运行
*
* @param rootEntity 行为树根节点实体
* @returns 是否正在运行
*
* @example
* ```typescript
* if (BehaviorTreeStarter.isRunning(aiRoot)) {
* console.log('AI is active');
* }
* ```
*/
static isRunning(rootEntity: Entity): boolean {
return rootEntity.hasComponent(ActiveNode);
}
}

View File

@@ -0,0 +1,87 @@
import { Component, ECSComponent, Entity } from '@esengine/ecs-framework';
import { Serializable, Serialize, IgnoreSerialization } from '@esengine/ecs-framework';
import { TaskStatus, NodeType } from '../../Types/TaskStatus';
import { BlackboardComponent } from '../BlackboardComponent';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
/**
* 自定义动作函数类型
*/
export type CustomActionFunction = (
entity: Entity,
blackboard?: BlackboardComponent,
deltaTime?: number
) => TaskStatus;
/**
* 执行自定义函数动作组件
*
* 允许用户提供自定义的动作执行函数
*/
@BehaviorNode({
displayName: '自定义动作',
category: '动作',
type: NodeType.Action,
icon: 'Code',
description: '执行自定义代码',
color: '#FFC107'
})
@ECSComponent('ExecuteAction')
@Serializable({ version: 1 })
export class ExecuteAction extends Component {
@BehaviorProperty({
label: '动作代码',
type: 'code',
description: 'JavaScript 代码,返回 TaskStatus',
required: true
})
@Serialize()
actionCode?: string = 'return TaskStatus.Success;';
@Serialize()
parameters: Record<string, any> = {};
/** 编译后的函数(不序列化) */
@IgnoreSerialization()
private compiledFunction?: CustomActionFunction;
/**
* 获取或编译执行函数
*/
getFunction(): CustomActionFunction | undefined {
if (!this.compiledFunction && this.actionCode) {
try {
const func = new Function(
'entity',
'blackboard',
'deltaTime',
'parameters',
'TaskStatus',
`
const { Success, Failure, Running, Invalid } = TaskStatus;
try {
${this.actionCode}
} catch (error) {
return TaskStatus.Failure;
}
`
);
this.compiledFunction = (entity, blackboard, deltaTime) => {
return func(entity, blackboard, deltaTime, this.parameters, TaskStatus) || TaskStatus.Success;
};
} catch (error) {
return undefined;
}
}
return this.compiledFunction;
}
/**
* 设置自定义函数(运行时使用)
*/
setFunction(func: CustomActionFunction): void {
this.compiledFunction = func;
}
}

View File

@@ -0,0 +1,49 @@
import { Component, ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { NodeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
/**
* 日志动作组件
*
* 输出日志信息
*/
@BehaviorNode({
displayName: '日志',
category: '动作',
type: NodeType.Action,
icon: 'FileText',
description: '输出日志消息',
color: '#673AB7'
})
@ECSComponent('LogAction')
@Serializable({ version: 1 })
export class LogAction extends Component {
@BehaviorProperty({
label: '消息',
type: 'string',
required: true
})
@Serialize()
message: string = 'Hello';
@BehaviorProperty({
label: '级别',
type: 'select',
options: [
{ label: 'Log', value: 'log' },
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
{ label: 'Error', value: 'error' }
]
})
@Serialize()
level: 'log' | 'info' | 'warn' | 'error' = 'log';
@BehaviorProperty({
label: '包含实体信息',
type: 'boolean'
})
@Serialize()
includeEntityInfo: boolean = false;
}

View File

@@ -0,0 +1,76 @@
import { Component, ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { NodeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
/**
* 修改操作类型
*/
export enum ModifyOperation {
/** 加法 */
Add = 'add',
/** 减法 */
Subtract = 'subtract',
/** 乘法 */
Multiply = 'multiply',
/** 除法 */
Divide = 'divide',
/** 取模 */
Modulo = 'modulo',
/** 追加(数组/字符串) */
Append = 'append',
/** 移除(数组) */
Remove = 'remove'
}
/**
* 修改黑板变量值动作组件
*
* 对黑板变量执行数学或逻辑操作
*/
@BehaviorNode({
displayName: '修改变量',
category: '动作',
type: NodeType.Action,
icon: 'Calculator',
description: '对黑板变量执行数学或逻辑操作',
color: '#FF9800'
})
@ECSComponent('ModifyBlackboardValueAction')
@Serializable({ version: 1 })
export class ModifyBlackboardValueAction extends Component {
@BehaviorProperty({
label: '变量名',
type: 'variable',
required: true
})
@Serialize()
variableName: string = '';
@BehaviorProperty({
label: '操作类型',
type: 'select',
options: [
{ label: '加法', value: 'add' },
{ label: '减法', value: 'subtract' },
{ label: '乘法', value: 'multiply' },
{ label: '除法', value: 'divide' },
{ label: '取模', value: 'modulo' },
{ label: '追加', value: 'append' },
{ label: '移除', value: 'remove' }
]
})
@Serialize()
operation: ModifyOperation = ModifyOperation.Add;
@BehaviorProperty({
label: '操作数',
type: 'string',
description: '可以是固定值或变量引用 {{varName}}'
})
@Serialize()
operand: any = 0;
@Serialize()
force: boolean = false;
}

View File

@@ -0,0 +1,43 @@
import { Component, ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { NodeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
/**
* 设置黑板变量值动作组件
*
* 将指定值或另一个黑板变量的值设置到目标变量
*/
@BehaviorNode({
displayName: '设置变量',
category: '动作',
type: NodeType.Action,
icon: 'Edit',
description: '设置黑板变量的值',
color: '#3F51B5'
})
@ECSComponent('SetBlackboardValueAction')
@Serializable({ version: 1 })
export class SetBlackboardValueAction extends Component {
@BehaviorProperty({
label: '变量名',
type: 'variable',
required: true
})
@Serialize()
variableName: string = '';
@BehaviorProperty({
label: '值',
type: 'string',
description: '可以使用 {{varName}} 引用其他变量'
})
@Serialize()
value: any = '';
@Serialize()
sourceVariable?: string;
@Serialize()
force: boolean = false;
}

View File

@@ -0,0 +1,43 @@
import { Component, ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize, IgnoreSerialization } from '@esengine/ecs-framework';
import { NodeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
/**
* 等待动作组件
*
* 等待指定时间后返回成功
*/
@BehaviorNode({
displayName: '等待',
category: '动作',
type: NodeType.Action,
icon: 'Clock',
description: '等待指定时间',
color: '#9E9E9E'
})
@ECSComponent('WaitAction')
@Serializable({ version: 1 })
export class WaitAction extends Component {
@BehaviorProperty({
label: '等待时间',
type: 'number',
min: 0,
step: 0.1,
description: '等待时间(秒)',
required: true
})
@Serialize()
waitTime: number = 1.0;
/** 已等待时间(秒) */
@IgnoreSerialization()
elapsedTime: number = 0;
/**
* 重置等待状态
*/
reset(): void {
this.elapsedTime = 0;
}
}

View File

@@ -0,0 +1,20 @@
import { Component, ECSComponent } from '@esengine/ecs-framework';
/**
* 活跃节点标记组件
*
* 标记当前应该被执行的节点。
* 只有带有此组件的节点才会被各个执行系统处理。
*
* 这是一个标记组件Tag Component不包含数据只用于标识。
*
* 执行流程:
* 1. 初始时只有根节点带有 ActiveNode
* 2. 父节点决定激活哪个子节点时,为子节点添加 ActiveNode
* 3. 节点执行完成后移除 ActiveNode
* 4. 通过这种方式实现按需执行,避免每帧遍历整棵树
*/
@ECSComponent('ActiveNode')
export class ActiveNode extends Component {
// 标记组件,无需数据字段
}

View File

@@ -0,0 +1,61 @@
import { Component, ECSComponent, Serializable, Serialize } from '@esengine/ecs-framework';
/**
* 资产元数据组件
*
* 附加到从资产实例化的行为树根节点上,
* 用于标记资产ID和版本信息便于循环引用检测和调试。
*
* @example
* ```typescript
* const rootEntity = BehaviorTreeAssetLoader.instantiate(asset, scene);
*
* // 添加元数据
* const metadata = rootEntity.addComponent(new BehaviorTreeAssetMetadata());
* metadata.assetId = 'patrol';
* metadata.assetVersion = '1.0.0';
* ```
*/
@ECSComponent('BehaviorTreeAssetMetadata')
@Serializable({ version: 1 })
export class BehaviorTreeAssetMetadata extends Component {
/**
* 资产ID
*/
@Serialize()
assetId: string = '';
/**
* 资产版本
*/
@Serialize()
assetVersion: string = '';
/**
* 资产名称
*/
@Serialize()
assetName: string = '';
/**
* 加载时间
*/
@Serialize()
loadedAt: number = 0;
/**
* 资产描述
*/
@Serialize()
description: string = '';
/**
* 初始化
*/
initialize(assetId: string, assetVersion: string, assetName?: string): void {
this.assetId = assetId;
this.assetVersion = assetVersion;
this.assetName = assetName || assetId;
this.loadedAt = Date.now();
}
}

View File

@@ -0,0 +1,44 @@
import { Component, ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize, IgnoreSerialization } from '@esengine/ecs-framework';
import { TaskStatus, NodeType } from '../Types/TaskStatus';
/**
* 行为树节点基础组件
*
* 所有行为树节点都必须包含此组件
*/
@ECSComponent('BehaviorTreeNode')
@Serializable({ version: 1 })
export class BehaviorTreeNode extends Component {
/** 节点类型 */
@Serialize()
nodeType: NodeType = NodeType.Action;
/** 节点名称(用于调试) */
@Serialize()
nodeName: string = 'Node';
/** 当前执行状态 */
@IgnoreSerialization()
status: TaskStatus = TaskStatus.Invalid;
/** 当前执行的子节点索引(用于复合节点) */
@IgnoreSerialization()
currentChildIndex: number = 0;
/**
* 重置节点状态
*/
reset(): void {
this.status = TaskStatus.Invalid;
this.currentChildIndex = 0;
}
/**
* 标记节点为失效(递归重置子节点)
* 注意:此方法只重置当前节点,子节点需要在 System 中处理
*/
invalidate(): void {
this.reset();
}
}

View File

@@ -0,0 +1,201 @@
import { Component, ECSComponent, Core } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { BlackboardValueType } from '../Types/TaskStatus';
import { GlobalBlackboardService } from '../Services/GlobalBlackboardService';
/**
* 黑板变量定义
*/
export interface BlackboardVariable {
name: string;
type: BlackboardValueType;
value: any;
readonly?: boolean;
description?: string;
}
/**
* 黑板组件 - 用于节点间共享数据
*
* 支持分层查找:
* 1. 先查找本地变量
* 2. 如果找不到,自动查找全局 Blackboard
*
* 通常附加到行为树的根节点上
*/
@ECSComponent('Blackboard')
@Serializable({ version: 1 })
export class BlackboardComponent extends Component {
/** 存储的本地变量 */
@Serialize()
private variables: Map<string, BlackboardVariable> = new Map();
/** 是否启用全局 Blackboard 查找 */
private useGlobalBlackboard: boolean = true;
/**
* 定义一个新变量
*/
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);
if (variable !== undefined) {
return variable.value as T;
}
if (this.useGlobalBlackboard) {
return Core.services.resolve(GlobalBlackboardService).getValue<T>(name);
}
return undefined;
}
/**
* 获取本地变量值(不查找全局)
*/
getLocalValue<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) {
if (variable.readonly && !force) {
return false;
}
variable.value = value;
return true;
}
if (this.useGlobalBlackboard) {
return Core.services.resolve(GlobalBlackboardService).setValue(name, value, force);
}
return false;
}
/**
* 设置本地变量值(不影响全局)
*/
setLocalValue(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 {
if (this.variables.has(name)) {
return true;
}
if (this.useGlobalBlackboard) {
return Core.services.resolve(GlobalBlackboardService).hasVariable(name);
}
return false;
}
/**
* 检查本地变量是否存在
*/
hasLocalVariable(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());
}
/**
* 清空所有本地变量
*/
clear(): void {
this.variables.clear();
}
/**
* 启用/禁用全局 Blackboard 查找
*/
setUseGlobalBlackboard(enabled: boolean): void {
this.useGlobalBlackboard = enabled;
}
/**
* 是否启用全局 Blackboard 查找
*/
isUsingGlobalBlackboard(): boolean {
return this.useGlobalBlackboard;
}
/**
* 获取所有变量(包括本地和全局)
*/
getAllVariables(): BlackboardVariable[] {
const locals = Array.from(this.variables.values());
if (this.useGlobalBlackboard) {
const globals = Core.services.resolve(GlobalBlackboardService).getAllVariables();
const localNames = new Set(this.variables.keys());
const filteredGlobals = globals.filter(v => !localNames.has(v.name));
return [...locals, ...filteredGlobals];
}
return locals;
}
/**
* 获取全局 Blackboard 服务的引用
*/
static getGlobalBlackboard(): GlobalBlackboardService {
return Core.services.resolve(GlobalBlackboardService);
}
}

View File

@@ -0,0 +1,66 @@
import { Component, ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { CompositeType } from '../Types/TaskStatus';
/**
* 复合节点组件
*
* 用于标识复合节点类型Sequence, Selector, Parallel等
*/
@ECSComponent('CompositeNode')
@Serializable({ version: 1 })
export class CompositeNodeComponent extends Component {
/** 复合节点类型 */
@Serialize()
compositeType: CompositeType = CompositeType.Sequence;
/** 随机化的子节点索引顺序 */
protected shuffledIndices: number[] = [];
/** 是否在重启时重新洗牌(子类可选) */
protected reshuffleOnRestart: boolean = true;
/**
* 获取下一个子节点索引
*/
getNextChildIndex(currentIndex: number, totalChildren: number): number {
// 对于随机类型,使用洗牌后的索引
if (this.compositeType === CompositeType.RandomSequence ||
this.compositeType === CompositeType.RandomSelector) {
// 首次执行或需要重新洗牌
if (this.shuffledIndices.length === 0 || currentIndex === 0 && this.reshuffleOnRestart) {
this.shuffleIndices(totalChildren);
}
if (currentIndex < this.shuffledIndices.length) {
return this.shuffledIndices[currentIndex];
}
return totalChildren; // 结束
}
// 普通顺序执行
return currentIndex;
}
/**
* 洗牌子节点索引
*/
private shuffleIndices(count: number): void {
this.shuffledIndices = Array.from({ length: count }, (_, i) => i);
// Fisher-Yates 洗牌算法
for (let i = this.shuffledIndices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.shuffledIndices[i], this.shuffledIndices[j]] =
[this.shuffledIndices[j], this.shuffledIndices[i]];
}
}
/**
* 重置洗牌状态
*/
resetShuffle(): void {
this.shuffledIndices = [];
}
}

View File

@@ -0,0 +1,51 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { NodeType, CompositeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
import { CompositeNodeComponent } from '../CompositeNodeComponent';
/**
* 并行节点
*
* 同时执行所有子节点
*/
@BehaviorNode({
displayName: '并行',
category: '组合',
type: NodeType.Composite,
icon: 'Layers',
description: '同时执行所有子节点',
color: '#CDDC39'
})
@ECSComponent('ParallelNode')
@Serializable({ version: 1 })
export class ParallelNode extends CompositeNodeComponent {
@BehaviorProperty({
label: '成功策略',
type: 'select',
description: '多少个子节点成功时整体成功',
options: [
{ label: '全部成功', value: 'all' },
{ label: '任意一个成功', value: 'one' }
]
})
@Serialize()
successPolicy: 'all' | 'one' = 'all';
@BehaviorProperty({
label: '失败策略',
type: 'select',
description: '多少个子节点失败时整体失败',
options: [
{ label: '任意一个失败', value: 'one' },
{ label: '全部失败', value: 'all' }
]
})
@Serialize()
failurePolicy: 'one' | 'all' = 'one';
constructor() {
super();
this.compositeType = CompositeType.Parallel;
}
}

View File

@@ -0,0 +1,39 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { NodeType, CompositeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
import { CompositeNodeComponent } from '../CompositeNodeComponent';
/**
* 并行选择节点
*
* 并行执行子节点,任一成功则成功
*/
@BehaviorNode({
displayName: '并行选择',
category: '组合',
type: NodeType.Composite,
icon: 'Sparkles',
description: '并行执行子节点,任一成功则成功',
color: '#FFC107'
})
@ECSComponent('ParallelSelectorNode')
@Serializable({ version: 1 })
export class ParallelSelectorNode extends CompositeNodeComponent {
@BehaviorProperty({
label: '失败策略',
type: 'select',
description: '多少个子节点失败时整体失败',
options: [
{ label: '任意一个失败', value: 'one' },
{ label: '全部失败', value: 'all' }
]
})
@Serialize()
failurePolicy: 'one' | 'all' = 'all';
constructor() {
super();
this.compositeType = CompositeType.ParallelSelector;
}
}

View File

@@ -0,0 +1,35 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { NodeType, CompositeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
import { CompositeNodeComponent } from '../CompositeNodeComponent';
/**
* 随机选择节点
*
* 随机顺序执行子节点选择
*/
@BehaviorNode({
displayName: '随机选择',
category: '组合',
type: NodeType.Composite,
icon: 'Dices',
description: '随机顺序执行子节点选择',
color: '#F44336'
})
@ECSComponent('RandomSelectorNode')
@Serializable({ version: 1 })
export class RandomSelectorNode extends CompositeNodeComponent {
@BehaviorProperty({
label: '重启时重新洗牌',
type: 'boolean',
description: '每次重启时是否重新随机子节点顺序'
})
@Serialize()
override reshuffleOnRestart: boolean = true;
constructor() {
super();
this.compositeType = CompositeType.RandomSelector;
}
}

View File

@@ -0,0 +1,35 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { NodeType, CompositeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
import { CompositeNodeComponent } from '../CompositeNodeComponent';
/**
* 随机序列节点
*
* 随机顺序执行子节点序列
*/
@BehaviorNode({
displayName: '随机序列',
category: '组合',
type: NodeType.Composite,
icon: 'Shuffle',
description: '随机顺序执行子节点序列',
color: '#FF5722'
})
@ECSComponent('RandomSequenceNode')
@Serializable({ version: 1 })
export class RandomSequenceNode extends CompositeNodeComponent {
@BehaviorProperty({
label: '重启时重新洗牌',
type: 'boolean',
description: '每次重启时是否重新随机子节点顺序'
})
@Serialize()
override reshuffleOnRestart: boolean = true;
constructor() {
super();
this.compositeType = CompositeType.RandomSequence;
}
}

View File

@@ -0,0 +1,27 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable } from '@esengine/ecs-framework';
import { NodeType, CompositeType } from '../../Types/TaskStatus';
import { BehaviorNode } from '../../Decorators/BehaviorNodeDecorator';
import { CompositeNodeComponent } from '../CompositeNodeComponent';
/**
* 根节点
*
* 行为树的根节点,简单地激活第一个子节点
*/
@BehaviorNode({
displayName: '根节点',
category: '根节点',
type: NodeType.Composite,
icon: 'TreePine',
description: '行为树的根节点',
color: '#FFD700'
})
@ECSComponent('RootNode')
@Serializable({ version: 1 })
export class RootNode extends CompositeNodeComponent {
constructor() {
super();
this.compositeType = CompositeType.Sequence;
}
}

View File

@@ -0,0 +1,41 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { NodeType, CompositeType, AbortType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
import { CompositeNodeComponent } from '../CompositeNodeComponent';
/**
* 选择节点
*
* 按顺序执行子节点,任一成功则成功
*/
@BehaviorNode({
displayName: '选择',
category: '组合',
type: NodeType.Composite,
icon: 'GitBranch',
description: '按顺序执行子节点,任一成功则成功',
color: '#8BC34A'
})
@ECSComponent('SelectorNode')
@Serializable({ version: 1 })
export class SelectorNode extends CompositeNodeComponent {
@BehaviorProperty({
label: '中止类型',
type: 'select',
description: '条件变化时的中止行为',
options: [
{ label: '无', value: 'none' },
{ label: '自身', value: 'self' },
{ label: '低优先级', value: 'lower-priority' },
{ label: '两者', value: 'both' }
]
})
@Serialize()
abortType: AbortType = AbortType.None;
constructor() {
super();
this.compositeType = CompositeType.Selector;
}
}

View File

@@ -0,0 +1,41 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { NodeType, CompositeType, AbortType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
import { CompositeNodeComponent } from '../CompositeNodeComponent';
/**
* 序列节点
*
* 按顺序执行所有子节点,全部成功才成功
*/
@BehaviorNode({
displayName: '序列',
category: '组合',
type: NodeType.Composite,
icon: 'List',
description: '按顺序执行子节点,全部成功才成功',
color: '#4CAF50'
})
@ECSComponent('SequenceNode')
@Serializable({ version: 1 })
export class SequenceNode extends CompositeNodeComponent {
@BehaviorProperty({
label: '中止类型',
type: 'select',
description: '条件变化时的中止行为',
options: [
{ label: '无', value: 'none' },
{ label: '自身', value: 'self' },
{ label: '低优先级', value: 'lower-priority' },
{ label: '两者', value: 'both' }
]
})
@Serialize()
abortType: AbortType = AbortType.None;
constructor() {
super();
this.compositeType = CompositeType.Sequence;
}
}

View File

@@ -0,0 +1,172 @@
import { ECSComponent, Serializable, Serialize, Entity } from '@esengine/ecs-framework';
import { CompositeNodeComponent } from '../CompositeNodeComponent';
import { TaskStatus, NodeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
/**
* SubTree 节点 - 引用其他行为树作为子树
*
* 允许将其他行为树嵌入到当前树中,实现行为树的复用和模块化。
*
* 注意SubTreeNode 是一个特殊的叶子节点,它不会执行编辑器中静态连接的子节点,
* 只会执行从 assetId 动态加载的外部行为树文件。
*
* @example
* ```typescript
* const subTree = entity.addComponent(SubTreeNode);
* subTree.assetId = 'patrol';
* subTree.inheritParentBlackboard = true;
* ```
*/
@BehaviorNode({
displayName: '子树',
category: '组合',
type: NodeType.Composite,
icon: 'GitBranch',
description: '引用并执行外部行为树文件(不支持静态子节点)',
color: '#FF9800',
requiresChildren: false
})
@ECSComponent('SubTreeNode')
@Serializable({ version: 1 })
export class SubTreeNode extends CompositeNodeComponent {
/**
* 引用的子树资产ID
* 逻辑标识符,例如 'patrol' 或 'ai/patrol'
* 实际的文件路径由 AssetLoader 决定
*/
@BehaviorProperty({
label: '资产ID',
type: 'asset',
description: '要引用的行为树资产ID'
})
@Serialize()
assetId: string = '';
/**
* 是否将父黑板传递给子树
*
* - true: 子树可以访问和修改父树的黑板变量
* - false: 子树使用独立的黑板实例
*/
@BehaviorProperty({
label: '继承父黑板',
type: 'boolean',
description: '子树是否可以访问父树的黑板变量'
})
@Serialize()
inheritParentBlackboard: boolean = true;
/**
* 子树执行失败时是否传播失败状态
*
* - true: 子树失败时SubTree 节点返回 Failure
* - false: 子树失败时SubTree 节点返回 Success忽略失败
*/
@BehaviorProperty({
label: '传播失败',
type: 'boolean',
description: '子树失败时是否传播失败状态'
})
@Serialize()
propagateFailure: boolean = true;
/**
* 是否在行为树启动时预加载子树
*
* - true: 在根节点开始执行前预加载此子树,确保执行时子树已就绪
* - false: 运行时异步加载,执行到此节点时才开始加载(可能会有延迟)
*/
@BehaviorProperty({
label: '预加载',
type: 'boolean',
description: '在行为树启动时预加载子树,避免运行时加载延迟'
})
@Serialize()
preload: boolean = true;
/**
* 子树的根实体(运行时)
* 在执行时动态创建,执行结束后销毁
*/
private subTreeRoot?: Entity;
/**
* 子树是否已完成
*/
private subTreeCompleted: boolean = false;
/**
* 子树的最终状态
*/
private subTreeResult: TaskStatus = TaskStatus.Invalid;
/**
* 获取子树根实体
*/
getSubTreeRoot(): Entity | undefined {
return this.subTreeRoot;
}
/**
* 设置子树根实体(由执行系统调用)
*/
setSubTreeRoot(root: Entity | undefined): void {
this.subTreeRoot = root;
this.subTreeCompleted = false;
this.subTreeResult = TaskStatus.Invalid;
}
/**
* 标记子树完成(由执行系统调用)
*/
markSubTreeCompleted(result: TaskStatus): void {
this.subTreeCompleted = true;
this.subTreeResult = result;
}
/**
* 检查子树是否已完成
*/
isSubTreeCompleted(): boolean {
return this.subTreeCompleted;
}
/**
* 获取子树执行结果
*/
getSubTreeResult(): TaskStatus {
return this.subTreeResult;
}
/**
* 重置子树状态
*/
reset(): void {
this.subTreeRoot = undefined;
this.subTreeCompleted = false;
this.subTreeResult = TaskStatus.Invalid;
}
/**
* 重置完成状态(用于复用预加载的子树)
* 保留子树根引用,只重置完成标记
*/
resetCompletionState(): void {
this.subTreeCompleted = false;
this.subTreeResult = TaskStatus.Invalid;
}
/**
* 验证配置
*/
validate(): string[] {
const errors: string[] = [];
if (!this.assetId || this.assetId.trim() === '') {
errors.push('SubTree 节点必须指定资产ID');
}
return errors;
}
}

View File

@@ -0,0 +1,83 @@
import { Component, ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { NodeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
/**
* 比较运算符
*/
export enum CompareOperator {
/** 等于 */
Equal = 'equal',
/** 不等于 */
NotEqual = 'notEqual',
/** 大于 */
Greater = 'greater',
/** 大于等于 */
GreaterOrEqual = 'greaterOrEqual',
/** 小于 */
Less = 'less',
/** 小于等于 */
LessOrEqual = 'lessOrEqual',
/** 包含(字符串/数组) */
Contains = 'contains',
/** 正则匹配 */
Matches = 'matches'
}
/**
* 黑板变量比较条件组件
*
* 比较黑板变量与指定值或另一个变量
*/
@BehaviorNode({
displayName: '比较变量',
category: '条件',
type: NodeType.Condition,
icon: 'Scale',
description: '比较黑板变量与指定值',
color: '#2196F3'
})
@ECSComponent('BlackboardCompareCondition')
@Serializable({ version: 1 })
export class BlackboardCompareCondition extends Component {
@BehaviorProperty({
label: '变量名',
type: 'variable',
required: true
})
@Serialize()
variableName: string = '';
@BehaviorProperty({
label: '运算符',
type: 'select',
options: [
{ label: '等于', value: 'equal' },
{ label: '不等于', value: 'notEqual' },
{ label: '大于', value: 'greater' },
{ label: '大于等于', value: 'greaterOrEqual' },
{ label: '小于', value: 'less' },
{ label: '小于等于', value: 'lessOrEqual' },
{ label: '包含', value: 'contains' },
{ label: '正则匹配', value: 'matches' }
]
})
@Serialize()
operator: CompareOperator = CompareOperator.Equal;
@BehaviorProperty({
label: '比较值',
type: 'string',
description: '可以是固定值或变量引用 {{varName}}'
})
@Serialize()
compareValue: any = null;
@BehaviorProperty({
label: '反转结果',
type: 'boolean'
})
@Serialize()
invertResult: boolean = false;
}

View File

@@ -0,0 +1,45 @@
import { Component, ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { NodeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
/**
* 黑板变量存在性检查条件组件
*
* 检查黑板变量是否存在
*/
@BehaviorNode({
displayName: '检查变量存在',
category: '条件',
type: NodeType.Condition,
icon: 'Search',
description: '检查黑板变量是否存在',
color: '#00BCD4'
})
@ECSComponent('BlackboardExistsCondition')
@Serializable({ version: 1 })
export class BlackboardExistsCondition extends Component {
@BehaviorProperty({
label: '变量名',
type: 'variable',
required: true
})
@Serialize()
variableName: string = '';
@BehaviorProperty({
label: '检查非空',
type: 'boolean',
description: '检查值不为 null/undefined'
})
@Serialize()
checkNotNull: boolean = false;
@BehaviorProperty({
label: '反转结果',
type: 'boolean',
description: '检查不存在'
})
@Serialize()
invertResult: boolean = false;
}

View File

@@ -0,0 +1,92 @@
import { Component, ECSComponent, Entity } from '@esengine/ecs-framework';
import { Serializable, Serialize, IgnoreSerialization } from '@esengine/ecs-framework';
import { NodeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
import { BlackboardComponent } from '../BlackboardComponent';
/**
* 自定义条件函数类型
*/
export type CustomConditionFunction = (
entity: Entity,
blackboard?: BlackboardComponent,
deltaTime?: number
) => boolean;
/**
* 执行自定义条件组件
*
* 允许用户提供自定义的条件检查函数
*/
@BehaviorNode({
displayName: '自定义条件',
category: '条件',
type: NodeType.Condition,
icon: 'Code',
description: '执行自定义条件代码',
color: '#9C27B0'
})
@ECSComponent('ExecuteCondition')
@Serializable({ version: 1 })
export class ExecuteCondition extends Component {
@BehaviorProperty({
label: '条件代码',
type: 'code',
description: 'JavaScript 代码,返回 boolean',
required: true
})
@Serialize()
conditionCode?: string;
@Serialize()
parameters: Record<string, any> = {};
@BehaviorProperty({
label: '反转结果',
type: 'boolean'
})
@Serialize()
invertResult: boolean = false;
/** 编译后的函数(不序列化) */
@IgnoreSerialization()
private compiledFunction?: CustomConditionFunction;
/**
* 获取或编译条件函数
*/
getFunction(): CustomConditionFunction | undefined {
if (!this.compiledFunction && this.conditionCode) {
try {
const func = new Function(
'entity',
'blackboard',
'deltaTime',
'parameters',
`
try {
${this.conditionCode}
} catch (error) {
return false;
}
`
);
this.compiledFunction = (entity, blackboard, deltaTime) => {
return Boolean(func(entity, blackboard, deltaTime, this.parameters));
};
} catch (error) {
return undefined;
}
}
return this.compiledFunction;
}
/**
* 设置自定义函数(运行时使用)
*/
setFunction(func: CustomConditionFunction): void {
this.compiledFunction = func;
}
}

View File

@@ -0,0 +1,61 @@
import { Component, ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { NodeType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
/**
* 随机概率条件组件
*
* 根据概率返回成功或失败
*/
@BehaviorNode({
displayName: '随机概率',
category: '条件',
type: NodeType.Condition,
icon: 'Dice',
description: '根据概率返回成功或失败',
color: '#E91E63'
})
@ECSComponent('RandomProbabilityCondition')
@Serializable({ version: 1 })
export class RandomProbabilityCondition extends Component {
@BehaviorProperty({
label: '成功概率',
type: 'number',
min: 0,
max: 1,
step: 0.1,
description: '0.0 - 1.0',
required: true
})
@Serialize()
probability: number = 0.5;
@BehaviorProperty({
label: '总是重新随机',
type: 'boolean',
description: 'false则第一次随机后固定结果'
})
@Serialize()
alwaysRandomize: boolean = true;
/** 缓存的随机结果(不序列化) */
private cachedResult?: boolean;
/**
* 评估随机概率
*/
evaluate(): boolean {
if (this.alwaysRandomize || this.cachedResult === undefined) {
this.cachedResult = Math.random() < this.probability;
}
return this.cachedResult;
}
/**
* 重置缓存
*/
reset(): void {
this.cachedResult = undefined;
}
}

View File

@@ -0,0 +1,18 @@
import { Component, ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize } from '@esengine/ecs-framework';
import { DecoratorType } from '../Types/TaskStatus';
/**
* 装饰器节点组件基类
*
* 只包含通用的装饰器类型标识
* 具体的属性由各个子类自己定义
*/
@ECSComponent('DecoratorNode')
@Serializable({ version: 1 })
export class DecoratorNodeComponent extends Component {
/** 装饰器类型 */
@Serialize()
decoratorType: DecoratorType = DecoratorType.Inverter;
}

View File

@@ -0,0 +1,27 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable } from '@esengine/ecs-framework';
import { NodeType, DecoratorType } from '../../Types/TaskStatus';
import { BehaviorNode } from '../../Decorators/BehaviorNodeDecorator';
import { DecoratorNodeComponent } from '../DecoratorNodeComponent';
/**
* 总是失败节点
*
* 无论子节点结果如何都返回失败
*/
@BehaviorNode({
displayName: '总是失败',
category: '装饰器',
type: NodeType.Decorator,
icon: 'ThumbsDown',
description: '无论子节点结果如何都返回失败',
color: '#FF5722'
})
@ECSComponent('AlwaysFailNode')
@Serializable({ version: 1 })
export class AlwaysFailNode extends DecoratorNodeComponent {
constructor() {
super();
this.decoratorType = DecoratorType.AlwaysFail;
}
}

View File

@@ -0,0 +1,27 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable } from '@esengine/ecs-framework';
import { NodeType, DecoratorType } from '../../Types/TaskStatus';
import { BehaviorNode } from '../../Decorators/BehaviorNodeDecorator';
import { DecoratorNodeComponent } from '../DecoratorNodeComponent';
/**
* 总是成功节点
*
* 无论子节点结果如何都返回成功
*/
@BehaviorNode({
displayName: '总是成功',
category: '装饰器',
type: NodeType.Decorator,
icon: 'ThumbsUp',
description: '无论子节点结果如何都返回成功',
color: '#8BC34A'
})
@ECSComponent('AlwaysSucceedNode')
@Serializable({ version: 1 })
export class AlwaysSucceedNode extends DecoratorNodeComponent {
constructor() {
super();
this.decoratorType = DecoratorType.AlwaysSucceed;
}
}

View File

@@ -0,0 +1,89 @@
import { ECSComponent, Entity } from '@esengine/ecs-framework';
import { Serializable, Serialize, IgnoreSerialization } from '@esengine/ecs-framework';
import { NodeType, DecoratorType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
import { DecoratorNodeComponent } from '../DecoratorNodeComponent';
import { BlackboardComponent } from '../BlackboardComponent';
/**
* 条件装饰器节点
*
* 基于条件判断是否执行子节点
*/
@BehaviorNode({
displayName: '条件装饰器',
category: '装饰器',
type: NodeType.Decorator,
icon: 'Filter',
description: '基于条件判断是否执行子节点',
color: '#3F51B5'
})
@ECSComponent('ConditionalNode')
@Serializable({ version: 1 })
export class ConditionalNode extends DecoratorNodeComponent {
constructor() {
super();
this.decoratorType = DecoratorType.Conditional;
}
@BehaviorProperty({
label: '条件代码',
type: 'code',
description: 'JavaScript 代码,返回 boolean',
required: true
})
@Serialize()
conditionCode?: string;
@BehaviorProperty({
label: '重新评估条件',
type: 'boolean',
description: '每次执行时是否重新评估条件'
})
@Serialize()
shouldReevaluate: boolean = true;
/** 编译后的条件函数(不序列化) */
@IgnoreSerialization()
private compiledCondition?: (entity: Entity, blackboard?: BlackboardComponent) => boolean;
/**
* 评估条件
*/
evaluateCondition(entity: Entity, blackboard?: BlackboardComponent): boolean {
if (!this.conditionCode) {
return false;
}
if (!this.compiledCondition) {
try {
const func = new Function(
'entity',
'blackboard',
`
try {
return Boolean(${this.conditionCode});
} catch (error) {
return false;
}
`
);
this.compiledCondition = (entity, blackboard) => {
return Boolean(func(entity, blackboard));
};
} catch (error) {
return false;
}
}
return this.compiledCondition(entity, blackboard);
}
/**
* 设置条件函数(运行时使用)
*/
setConditionFunction(func: (entity: Entity, blackboard?: BlackboardComponent) => boolean): void {
this.compiledCondition = func;
}
}

View File

@@ -0,0 +1,67 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize, IgnoreSerialization } from '@esengine/ecs-framework';
import { NodeType, DecoratorType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
import { DecoratorNodeComponent } from '../DecoratorNodeComponent';
/**
* 冷却节点
*
* 在冷却时间内阻止子节点执行
*/
@BehaviorNode({
displayName: '冷却',
category: '装饰器',
type: NodeType.Decorator,
icon: 'Timer',
description: '在冷却时间内阻止子节点执行',
color: '#00BCD4'
})
@ECSComponent('CooldownNode')
@Serializable({ version: 1 })
export class CooldownNode extends DecoratorNodeComponent {
constructor() {
super();
this.decoratorType = DecoratorType.Cooldown;
}
@BehaviorProperty({
label: '冷却时间',
type: 'number',
min: 0,
step: 0.1,
description: '冷却时间(秒)',
required: true
})
@Serialize()
cooldownTime: number = 1.0;
/** 上次执行时间 */
@IgnoreSerialization()
lastExecutionTime: number = 0;
/**
* 检查是否可以执行
*/
canExecute(currentTime: number): boolean {
// 如果从未执行过,允许执行
if (this.lastExecutionTime === 0) {
return true;
}
return currentTime - this.lastExecutionTime >= this.cooldownTime;
}
/**
* 记录执行时间
*/
recordExecution(currentTime: number): void {
this.lastExecutionTime = currentTime;
}
/**
* 重置状态
*/
reset(): void {
this.lastExecutionTime = 0;
}
}

View File

@@ -0,0 +1,27 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable } from '@esengine/ecs-framework';
import { NodeType, DecoratorType } from '../../Types/TaskStatus';
import { BehaviorNode } from '../../Decorators/BehaviorNodeDecorator';
import { DecoratorNodeComponent } from '../DecoratorNodeComponent';
/**
* 反转节点
*
* 反转子节点的执行结果
*/
@BehaviorNode({
displayName: '反转',
category: '装饰器',
type: NodeType.Decorator,
icon: 'RotateCcw',
description: '反转子节点的执行结果',
color: '#607D8B'
})
@ECSComponent('InverterNode')
@Serializable({ version: 1 })
export class InverterNode extends DecoratorNodeComponent {
constructor() {
super();
this.decoratorType = DecoratorType.Inverter;
}
}

View File

@@ -0,0 +1,74 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize, IgnoreSerialization } from '@esengine/ecs-framework';
import { NodeType, DecoratorType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
import { DecoratorNodeComponent } from '../DecoratorNodeComponent';
/**
* 重复节点
*
* 重复执行子节点指定次数
*/
@BehaviorNode({
displayName: '重复',
category: '装饰器',
type: NodeType.Decorator,
icon: 'Repeat',
description: '重复执行子节点指定次数',
color: '#9E9E9E'
})
@ECSComponent('RepeaterNode')
@Serializable({ version: 1 })
export class RepeaterNode extends DecoratorNodeComponent {
constructor() {
super();
this.decoratorType = DecoratorType.Repeater;
}
@BehaviorProperty({
label: '重复次数',
type: 'number',
min: -1,
step: 1,
description: '-1表示无限重复',
required: true
})
@Serialize()
repeatCount: number = 1;
@BehaviorProperty({
label: '失败时停止',
type: 'boolean',
description: '子节点失败时是否停止重复'
})
@Serialize()
endOnFailure: boolean = false;
/** 当前已重复次数 */
@IgnoreSerialization()
currentRepeatCount: number = 0;
/**
* 增加重复计数
*/
incrementRepeat(): void {
this.currentRepeatCount++;
}
/**
* 检查是否应该继续重复
*/
shouldContinueRepeat(): boolean {
if (this.repeatCount === -1) {
return true;
}
return this.currentRepeatCount < this.repeatCount;
}
/**
* 重置状态
*/
reset(): void {
this.currentRepeatCount = 0;
}
}

View File

@@ -0,0 +1,68 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable, Serialize, IgnoreSerialization } from '@esengine/ecs-framework';
import { NodeType, DecoratorType } from '../../Types/TaskStatus';
import { BehaviorNode, BehaviorProperty } from '../../Decorators/BehaviorNodeDecorator';
import { DecoratorNodeComponent } from '../DecoratorNodeComponent';
/**
* 超时节点
*
* 子节点执行超时则返回失败
*/
@BehaviorNode({
displayName: '超时',
category: '装饰器',
type: NodeType.Decorator,
icon: 'Clock',
description: '子节点执行超时则返回失败',
color: '#FF9800'
})
@ECSComponent('TimeoutNode')
@Serializable({ version: 1 })
export class TimeoutNode extends DecoratorNodeComponent {
constructor() {
super();
this.decoratorType = DecoratorType.Timeout;
}
@BehaviorProperty({
label: '超时时间',
type: 'number',
min: 0,
step: 0.1,
description: '超时时间(秒)',
required: true
})
@Serialize()
timeoutDuration: number = 5.0;
/** 开始执行时间 */
@IgnoreSerialization()
startTime: number = 0;
/**
* 记录开始时间
*/
recordStartTime(currentTime: number): void {
if (this.startTime === 0) {
this.startTime = currentTime;
}
}
/**
* 检查是否超时
*/
isTimeout(currentTime: number): boolean {
if (this.startTime === 0) {
return false;
}
return currentTime - this.startTime >= this.timeoutDuration;
}
/**
* 重置状态
*/
reset(): void {
this.startTime = 0;
}
}

View File

@@ -0,0 +1,27 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable } from '@esengine/ecs-framework';
import { NodeType, DecoratorType } from '../../Types/TaskStatus';
import { BehaviorNode } from '../../Decorators/BehaviorNodeDecorator';
import { DecoratorNodeComponent } from '../DecoratorNodeComponent';
/**
* 直到失败节点
*
* 重复执行子节点直到失败
*/
@BehaviorNode({
displayName: '直到失败',
category: '装饰器',
type: NodeType.Decorator,
icon: 'XCircle',
description: '重复执行子节点直到失败',
color: '#F44336'
})
@ECSComponent('UntilFailNode')
@Serializable({ version: 1 })
export class UntilFailNode extends DecoratorNodeComponent {
constructor() {
super();
this.decoratorType = DecoratorType.UntilFail;
}
}

View File

@@ -0,0 +1,27 @@
import { ECSComponent } from '@esengine/ecs-framework';
import { Serializable } from '@esengine/ecs-framework';
import { NodeType, DecoratorType } from '../../Types/TaskStatus';
import { BehaviorNode } from '../../Decorators/BehaviorNodeDecorator';
import { DecoratorNodeComponent } from '../DecoratorNodeComponent';
/**
* 直到成功节点
*
* 重复执行子节点直到成功
*/
@BehaviorNode({
displayName: '直到成功',
category: '装饰器',
type: NodeType.Decorator,
icon: 'CheckCircle',
description: '重复执行子节点直到成功',
color: '#4CAF50'
})
@ECSComponent('UntilSuccessNode')
@Serializable({ version: 1 })
export class UntilSuccessNode extends DecoratorNodeComponent {
constructor() {
super();
this.decoratorType = DecoratorType.UntilSuccess;
}
}

View File

@@ -0,0 +1,36 @@
import { Component, ECSComponent } from '@esengine/ecs-framework';
/**
* 日志输出组件
*
* 存储运行时输出的日志信息用于在UI中显示
*/
@ECSComponent('LogOutput')
export class LogOutput extends Component {
/**
* 日志消息列表
*/
messages: Array<{
timestamp: number;
message: string;
level: 'log' | 'info' | 'warn' | 'error';
}> = [];
/**
* 添加日志消息
*/
addMessage(message: string, level: 'log' | 'info' | 'warn' | 'error' = 'log'): void {
this.messages.push({
timestamp: Date.now(),
message,
level
});
}
/**
* 清空日志
*/
clear(): void {
this.messages = [];
}
}

View File

@@ -0,0 +1,42 @@
import { Component } from '@esengine/ecs-framework';
/**
* 属性绑定组件
* 记录节点属性到黑板变量的绑定关系
*/
export class PropertyBindings extends Component {
/**
* 属性绑定映射
* key: 属性名称 (如 'message')
* value: 黑板变量名 (如 'test1')
*/
bindings: Map<string, string> = new Map();
/**
* 添加属性绑定
*/
addBinding(propertyName: string, blackboardKey: string): void {
this.bindings.set(propertyName, blackboardKey);
}
/**
* 获取属性绑定的黑板变量名
*/
getBinding(propertyName: string): string | undefined {
return this.bindings.get(propertyName);
}
/**
* 检查属性是否绑定到黑板变量
*/
hasBinding(propertyName: string): boolean {
return this.bindings.has(propertyName);
}
/**
* 清除所有绑定
*/
clearBindings(): void {
this.bindings.clear();
}
}

View File

@@ -0,0 +1,190 @@
import { NodeTemplate, PropertyDefinition } from '../Serialization/NodeTemplates';
import { NodeType } from '../Types/TaskStatus';
/**
* 行为树节点元数据
*/
export interface BehaviorNodeMetadata {
displayName: string;
category: string;
type: NodeType;
icon?: string;
description: string;
color?: string;
className?: string;
/**
* 是否需要子节点
* - true: 节点需要子节点(如 SequenceNode、DecoratorNode
* - false: 节点不需要子节点(如 ActionNode、SubTreeNode
* - undefined: 根据节点类型自动判断
*/
requiresChildren?: boolean;
}
/**
* 节点类注册表
*/
class NodeClassRegistry {
private static nodeClasses = new Map<string, {
metadata: BehaviorNodeMetadata;
constructor: any;
}>();
static registerNodeClass(constructor: any, metadata: BehaviorNodeMetadata): void {
const key = `${metadata.category}:${metadata.displayName}`;
this.nodeClasses.set(key, { metadata, constructor });
}
static getAllNodeClasses(): Array<{ metadata: BehaviorNodeMetadata; constructor: any }> {
return Array.from(this.nodeClasses.values());
}
static getNodeClass(category: string, displayName: string): any {
const key = `${category}:${displayName}`;
return this.nodeClasses.get(key)?.constructor;
}
static clear(): void {
this.nodeClasses.clear();
}
}
/**
* 行为树节点装饰器
*
* 用于标注一个类是可在编辑器中使用的行为树节点
*
* @example
* ```typescript
* @BehaviorNode({
* displayName: '等待',
* category: '动作',
* type: NodeType.Action,
* icon: 'Clock',
* description: '等待指定时间',
* color: '#9E9E9E'
* })
* class WaitNode extends Component {
* @BehaviorProperty({
* label: '持续时间',
* type: 'number',
* min: 0,
* step: 0.1,
* description: '等待时间(秒)'
* })
* duration: number = 1.0;
* }
* ```
*/
export function BehaviorNode(metadata: BehaviorNodeMetadata) {
return function <T extends { new (...args: any[]): any }>(constructor: T) {
const metadataWithClassName = {
...metadata,
className: constructor.name
};
NodeClassRegistry.registerNodeClass(constructor, metadataWithClassName);
return constructor;
};
}
/**
* 行为树属性装饰器
*
* 用于标注节点的可配置属性,这些属性会在编辑器中显示
*
* @example
* ```typescript
* @BehaviorNode({ ... })
* class MyNode {
* @BehaviorProperty({
* label: '速度',
* type: 'number',
* min: 0,
* max: 100,
* description: '移动速度'
* })
* speed: number = 10;
* }
* ```
*/
export function BehaviorProperty(config: Omit<PropertyDefinition, 'name' | 'defaultValue'>) {
return function (target: any, propertyKey: string) {
if (!target.constructor.__nodeProperties) {
target.constructor.__nodeProperties = [];
}
target.constructor.__nodeProperties.push({
name: propertyKey,
...config
});
};
}
/**
* @deprecated 使用 BehaviorProperty 代替
*/
export const NodeProperty = BehaviorProperty;
/**
* 获取所有注册的节点模板
*/
export function getRegisteredNodeTemplates(): NodeTemplate[] {
return NodeClassRegistry.getAllNodeClasses().map(({ metadata, constructor }) => {
// 从类的 __nodeProperties 收集属性定义
const propertyDefs = constructor.__nodeProperties || [];
const defaultConfig: any = {
nodeType: metadata.type.toLowerCase()
};
// 从类的默认值中提取配置,并补充 defaultValue
const instance = new constructor();
const properties: PropertyDefinition[] = propertyDefs.map((prop: PropertyDefinition) => {
const defaultValue = instance[prop.name];
if (defaultValue !== undefined) {
defaultConfig[prop.name] = defaultValue;
}
return {
...prop,
defaultValue: defaultValue !== undefined ? defaultValue : prop.defaultValue
};
});
// 添加子类型字段
switch (metadata.type) {
case NodeType.Composite:
defaultConfig.compositeType = metadata.displayName;
break;
case NodeType.Decorator:
defaultConfig.decoratorType = metadata.displayName;
break;
case NodeType.Action:
defaultConfig.actionType = metadata.displayName;
break;
case NodeType.Condition:
defaultConfig.conditionType = metadata.displayName;
break;
}
return {
type: metadata.type,
displayName: metadata.displayName,
category: metadata.category,
icon: metadata.icon,
description: metadata.description,
color: metadata.color,
className: metadata.className,
requiresChildren: metadata.requiresChildren,
defaultConfig,
properties
};
});
}
/**
* 清空所有注册的节点类
*/
export function clearRegisteredNodes(): void {
NodeClassRegistry.clear();
}
export { NodeClassRegistry };

View File

@@ -0,0 +1,45 @@
/**
* 注册所有内置节点
*
* 导入所有节点类以确保装饰器被执行
*/
// Actions
import './Components/Actions/ExecuteAction';
import './Components/Actions/WaitAction';
import './Components/Actions/LogAction';
import './Components/Actions/SetBlackboardValueAction';
import './Components/Actions/ModifyBlackboardValueAction';
// Conditions
import './Components/Conditions/BlackboardCompareCondition';
import './Components/Conditions/BlackboardExistsCondition';
import './Components/Conditions/RandomProbabilityCondition';
import './Components/Conditions/ExecuteCondition';
// Composites
import './Components/Composites/SequenceNode';
import './Components/Composites/SelectorNode';
import './Components/Composites/ParallelNode';
import './Components/Composites/ParallelSelectorNode';
import './Components/Composites/RandomSequenceNode';
import './Components/Composites/RandomSelectorNode';
import './Components/Composites/SubTreeNode';
// Decorators
import './Components/Decorators/InverterNode';
import './Components/Decorators/RepeaterNode';
import './Components/Decorators/UntilSuccessNode';
import './Components/Decorators/UntilFailNode';
import './Components/Decorators/AlwaysSucceedNode';
import './Components/Decorators/AlwaysFailNode';
import './Components/Decorators/ConditionalNode';
import './Components/Decorators/CooldownNode';
import './Components/Decorators/TimeoutNode';
/**
* 确保所有节点已注册
*/
export function ensureAllNodesRegistered(): void {
// 这个函数的调用会确保上面的 import 被执行
}

View 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)
};
}
}

View File

@@ -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} 个绑定`);
}
}

View File

@@ -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
};
}
}

View File

@@ -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');
}
}

View File

@@ -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;
}
}

View 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;
}
});
}
}

View 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();
}
}

View 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';
}
}

View 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.'
);
}
}
}

View 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);
}
}

View 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;
}

View 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();
}
}

View File

@@ -0,0 +1,704 @@
import { EntitySystem, Matcher, Entity } from '@esengine/ecs-framework';
import { BehaviorTreeNode } from '../Components/BehaviorTreeNode';
import { CompositeNodeComponent } from '../Components/CompositeNodeComponent';
import { ActiveNode } from '../Components/ActiveNode';
import { BlackboardComponent } from '../Components/BlackboardComponent';
import { TaskStatus, NodeType, CompositeType, AbortType } from '../Types/TaskStatus';
import { SequenceNode } from '../Components/Composites/SequenceNode';
import { SelectorNode } from '../Components/Composites/SelectorNode';
import { RootNode } from '../Components/Composites/RootNode';
import { SubTreeNode } from '../Components/Composites/SubTreeNode';
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';
/**
* 复合节点执行系统
*
* 负责处理所有活跃的复合节点
* 读取子节点状态,根据复合规则决定自己的状态和激活哪些子节点
*
* updateOrder: 300 (在叶子节点和装饰器之后执行)
*/
export class CompositeExecutionSystem extends EntitySystem {
constructor() {
super(Matcher.empty().all(BehaviorTreeNode, ActiveNode).exclude(RootNode, SubTreeNode));
this.updateOrder = 300;
}
protected override process(entities: readonly Entity[]): void {
for (const entity of entities) {
const node = entity.getComponent(BehaviorTreeNode)!;
// 只处理复合节点
if (node.nodeType !== NodeType.Composite) {
continue;
}
// 使用 getComponentByType 支持继承查找
const composite = entity.getComponentByType(CompositeNodeComponent);
if (!composite) {
this.logger.warn(`复合节点 ${entity.name} 没有找到复合节点组件`);
const components = entity.components.map(c => c.constructor.name).join(', ');
this.logger.warn(` 组件列表: ${components}`);
continue;
}
this.executeComposite(entity, node, composite);
}
}
/**
* 执行复合节点逻辑
*/
private executeComposite(entity: Entity, node: BehaviorTreeNode, composite: CompositeNodeComponent): void {
const children = entity.children;
if (children.length === 0) {
node.status = TaskStatus.Success;
this.completeNode(entity);
return;
}
// 根据复合节点类型处理
switch (composite.compositeType) {
case CompositeType.Sequence:
this.handleSequence(entity, node, children);
break;
case CompositeType.Selector:
this.handleSelector(entity, node, children);
break;
case CompositeType.Parallel:
this.handleParallel(entity, node, children);
break;
case CompositeType.ParallelSelector:
this.handleParallelSelector(entity, node, children);
break;
case CompositeType.RandomSequence:
this.handleRandomSequence(entity, node, composite, children);
break;
case CompositeType.RandomSelector:
this.handleRandomSelector(entity, node, composite, children);
break;
default:
node.status = TaskStatus.Failure;
this.completeNode(entity);
break;
}
}
/**
* 序列节点:所有子节点都成功才成功
*/
private handleSequence(entity: Entity, node: BehaviorTreeNode, children: readonly Entity[]): void {
// 检查是否需要中止
const sequenceNode = entity.getComponentByType(SequenceNode);
if (sequenceNode && sequenceNode.abortType !== AbortType.None) {
if (this.shouldAbort(entity, node, children, sequenceNode.abortType)) {
this.abortExecution(entity, node, children);
return;
}
}
// 检查当前子节点
if (node.currentChildIndex >= children.length) {
// 所有子节点都成功
node.status = TaskStatus.Success;
node.currentChildIndex = 0; // 只重置索引保持状态为Success
this.completeNode(entity);
return;
}
const currentChild = children[node.currentChildIndex];
const childNode = currentChild.getComponent(BehaviorTreeNode);
if (!childNode) {
node.status = TaskStatus.Failure;
this.completeNode(entity);
return;
}
// 如果子节点还没开始执行,激活它
if (childNode.status === TaskStatus.Invalid) {
if (!currentChild.hasComponent(ActiveNode)) {
currentChild.addComponent(new ActiveNode());
}
node.status = TaskStatus.Running;
return;
}
// 检查子节点状态
if (childNode.status === TaskStatus.Running) {
node.status = TaskStatus.Running;
} else if (childNode.status === TaskStatus.Failure) {
// 任一失败则失败
node.status = TaskStatus.Failure;
node.currentChildIndex = 0; // 只重置索引保持状态为Failure
this.completeNode(entity);
} else if (childNode.status === TaskStatus.Success) {
// 成功则移动到下一个子节点
// 重置已完成的子节点状态,以便下次行为树重新执行时从头开始
childNode.reset();
node.currentChildIndex++;
// 继续保持活跃,下一帧处理下一个子节点
node.status = TaskStatus.Running;
}
}
/**
* 选择器节点:任一子节点成功就成功
*/
private handleSelector(entity: Entity, node: BehaviorTreeNode, children: readonly Entity[]): void {
// 检查是否需要中止
const selectorNode = entity.getComponentByType(SelectorNode);
if (selectorNode && selectorNode.abortType !== AbortType.None) {
if (this.shouldAbort(entity, node, children, selectorNode.abortType)) {
this.abortExecution(entity, node, children);
return;
}
}
// 检查当前子节点
if (node.currentChildIndex >= children.length) {
// 所有子节点都失败
node.status = TaskStatus.Failure;
node.currentChildIndex = 0; // 只重置索引保持状态为Failure
this.completeNode(entity);
return;
}
const currentChild = children[node.currentChildIndex];
const childNode = currentChild.getComponent(BehaviorTreeNode);
if (!childNode) {
node.status = TaskStatus.Failure;
this.completeNode(entity);
return;
}
// 如果子节点还没开始执行,激活它
if (childNode.status === TaskStatus.Invalid) {
if (!currentChild.hasComponent(ActiveNode)) {
currentChild.addComponent(new ActiveNode());
}
node.status = TaskStatus.Running;
return;
}
// 检查子节点状态
if (childNode.status === TaskStatus.Running) {
node.status = TaskStatus.Running;
} else if (childNode.status === TaskStatus.Success) {
// 任一成功则成功
node.status = TaskStatus.Success;
node.currentChildIndex = 0; // 只重置索引保持状态为Success
this.completeNode(entity);
} else if (childNode.status === TaskStatus.Failure) {
// 失败则移动到下一个子节点
// 重置已完成的子节点状态,以便下次行为树重新执行时从头开始
childNode.reset();
node.currentChildIndex++;
// 继续保持活跃,下一帧处理下一个子节点
node.status = TaskStatus.Running;
}
}
/**
* 并行节点:所有子节点都执行,全部成功才成功
*/
private handleParallel(entity: Entity, node: BehaviorTreeNode, children: readonly Entity[]): void {
let hasRunning = false;
let hasFailed = false;
// 激活所有子节点
for (const child of children) {
if (!child.hasComponent(ActiveNode)) {
child.addComponent(new ActiveNode());
}
const childNode = child.getComponent(BehaviorTreeNode);
if (!childNode) continue;
if (childNode.status === TaskStatus.Running) {
hasRunning = true;
} else if (childNode.status === TaskStatus.Failure) {
hasFailed = true;
}
}
if (hasRunning) {
node.status = TaskStatus.Running;
} else if (hasFailed) {
node.status = TaskStatus.Failure;
node.currentChildIndex = 0; // 只重置索引保持状态为Failure
this.completeNode(entity);
} else {
// 所有子节点都成功
node.status = TaskStatus.Success;
node.currentChildIndex = 0; // 只重置索引保持状态为Success
this.completeNode(entity);
}
}
/**
* 并行选择器:任一成功则成功
*/
private handleParallelSelector(entity: Entity, node: BehaviorTreeNode, children: readonly Entity[]): void {
let hasRunning = false;
let hasSucceeded = false;
// 激活所有子节点
for (const child of children) {
if (!child.hasComponent(ActiveNode)) {
child.addComponent(new ActiveNode());
}
const childNode = child.getComponent(BehaviorTreeNode);
if (!childNode) continue;
if (childNode.status === TaskStatus.Running) {
hasRunning = true;
} else if (childNode.status === TaskStatus.Success) {
hasSucceeded = true;
}
}
if (hasSucceeded) {
// 任一成功则成功
node.status = TaskStatus.Success;
node.currentChildIndex = 0; // 只重置索引保持状态为Success
// 停止所有子节点
for (const child of children) {
child.removeComponentByType(ActiveNode);
}
this.completeNode(entity);
} else if (hasRunning) {
node.status = TaskStatus.Running;
} else {
// 所有子节点都失败
node.status = TaskStatus.Failure;
node.currentChildIndex = 0; // 只重置索引保持状态为Failure
this.completeNode(entity);
}
}
/**
* 随机序列
*/
private handleRandomSequence(
entity: Entity,
node: BehaviorTreeNode,
composite: CompositeNodeComponent,
children: readonly Entity[]
): void {
// 获取洗牌后的子节点索引
const childIndex = composite.getNextChildIndex(node.currentChildIndex, children.length);
if (childIndex >= children.length) {
// 所有子节点都成功
node.status = TaskStatus.Success;
node.currentChildIndex = 0; // 只重置索引保持状态为Success
composite.resetShuffle();
this.completeNode(entity);
return;
}
const currentChild = children[childIndex];
const childNode = currentChild.getComponent(BehaviorTreeNode);
if (!childNode) {
node.status = TaskStatus.Failure;
this.completeNode(entity);
return;
}
// 如果子节点还没开始执行,激活它
if (childNode.status === TaskStatus.Invalid) {
if (!currentChild.hasComponent(ActiveNode)) {
currentChild.addComponent(new ActiveNode());
}
node.status = TaskStatus.Running;
return;
}
// 检查子节点状态
if (childNode.status === TaskStatus.Running) {
node.status = TaskStatus.Running;
} else if (childNode.status === TaskStatus.Failure) {
node.status = TaskStatus.Failure;
node.currentChildIndex = 0; // 只重置索引保持状态为Failure
composite.resetShuffle();
this.completeNode(entity);
} else if (childNode.status === TaskStatus.Success) {
// 成功则移动到下一个子节点
// 重置已完成的子节点状态,以便下次行为树重新执行时从头开始
childNode.reset();
node.currentChildIndex++;
node.status = TaskStatus.Running;
}
}
/**
* 随机选择器
*/
private handleRandomSelector(
entity: Entity,
node: BehaviorTreeNode,
composite: CompositeNodeComponent,
children: readonly Entity[]
): void {
// 获取洗牌后的子节点索引
const childIndex = composite.getNextChildIndex(node.currentChildIndex, children.length);
if (childIndex >= children.length) {
// 所有子节点都失败
node.status = TaskStatus.Failure;
node.currentChildIndex = 0; // 只重置索引保持状态为Failure
composite.resetShuffle();
this.completeNode(entity);
return;
}
const currentChild = children[childIndex];
const childNode = currentChild.getComponent(BehaviorTreeNode);
if (!childNode) {
node.status = TaskStatus.Failure;
this.completeNode(entity);
return;
}
// 如果子节点还没开始执行,激活它
if (childNode.status === TaskStatus.Invalid) {
if (!currentChild.hasComponent(ActiveNode)) {
currentChild.addComponent(new ActiveNode());
}
node.status = TaskStatus.Running;
return;
}
// 检查子节点状态
if (childNode.status === TaskStatus.Running) {
node.status = TaskStatus.Running;
} else if (childNode.status === TaskStatus.Success) {
node.status = TaskStatus.Success;
node.currentChildIndex = 0; // 只重置索引保持状态为Success
composite.resetShuffle();
this.completeNode(entity);
} else if (childNode.status === TaskStatus.Failure) {
// 失败则移动到下一个子节点
// 重置已完成的子节点状态,以便下次行为树重新执行时从头开始
childNode.reset();
node.currentChildIndex++;
node.status = TaskStatus.Running;
}
}
/**
* 检查是否应该中止当前执行
*/
private shouldAbort(
entity: Entity,
node: BehaviorTreeNode,
children: readonly Entity[],
abortType: AbortType
): boolean {
const currentIndex = node.currentChildIndex;
// 如果还没开始执行任何子节点,不需要中止
if (currentIndex === 0) {
return false;
}
// Self: 检查当前执行路径中的条件节点是否失败
if (abortType === AbortType.Self || abortType === AbortType.Both) {
// 检查当前正在执行的分支之前的条件节点
for (let i = 0; i < currentIndex; i++) {
const child = children[i];
const childNode = child.getComponent(BehaviorTreeNode);
if (childNode && childNode.nodeType === NodeType.Condition) {
// 如果条件节点现在失败了,应该中止
if (childNode.status === TaskStatus.Failure) {
return true;
}
}
}
}
// LowerPriority: 检查高优先级分支的条件是否满足
if (abortType === AbortType.LowerPriority || abortType === AbortType.Both) {
// 检查当前索引之前的所有分支(优先级更高)
for (let i = 0; i < currentIndex; i++) {
const child = children[i];
const childNode = child.getComponent(BehaviorTreeNode);
if (!childNode) continue;
// 如果是条件节点且现在成功了
if (childNode.nodeType === NodeType.Condition) {
if (this.evaluateCondition(child, childNode)) {
return true;
}
}
// 如果是复合节点,检查其第一个子节点(通常是条件)
else if (childNode.nodeType === NodeType.Composite && child.children.length > 0) {
const firstGrandChild = child.children[0];
const firstGrandChildNode = firstGrandChild.getComponent(BehaviorTreeNode);
if (firstGrandChildNode && firstGrandChildNode.nodeType === NodeType.Condition) {
if (this.evaluateCondition(firstGrandChild, firstGrandChildNode)) {
return true;
}
}
}
}
}
return false;
}
/**
* 评估条件节点
*/
private evaluateCondition(entity: Entity, node: BehaviorTreeNode): boolean {
if (node.nodeType !== NodeType.Condition) {
return false;
}
let result = false;
if (entity.hasComponent(BlackboardCompareCondition)) {
result = this.evaluateBlackboardCompare(entity);
} else if (entity.hasComponent(BlackboardExistsCondition)) {
result = this.evaluateBlackboardExists(entity);
} else if (entity.hasComponent(RandomProbabilityCondition)) {
result = this.evaluateRandomProbability(entity);
} else if (entity.hasComponent(ExecuteCondition)) {
result = this.evaluateCustomCondition(entity);
}
return result;
}
/**
* 评估黑板比较条件
*/
private evaluateBlackboardCompare(entity: Entity): boolean {
const condition = entity.getComponent(BlackboardCompareCondition)!;
const blackboard = this.findBlackboard(entity);
if (!blackboard || !blackboard.hasVariable(condition.variableName)) {
return false;
}
const value = blackboard.getValue(condition.variableName);
let compareValue = condition.compareValue;
if (typeof compareValue === 'string') {
compareValue = this.resolveVariableReferences(compareValue, blackboard);
}
let result = false;
switch (condition.operator) {
case CompareOperator.Equal:
result = value === compareValue;
break;
case CompareOperator.NotEqual:
result = value !== compareValue;
break;
case CompareOperator.Greater:
result = value > compareValue;
break;
case CompareOperator.GreaterOrEqual:
result = value >= compareValue;
break;
case CompareOperator.Less:
result = value < compareValue;
break;
case CompareOperator.LessOrEqual:
result = value <= compareValue;
break;
case CompareOperator.Contains:
if (typeof value === 'string') {
result = value.includes(compareValue);
} else if (Array.isArray(value)) {
result = value.includes(compareValue);
}
break;
case CompareOperator.Matches:
if (typeof value === 'string' && typeof compareValue === 'string') {
const regex = new RegExp(compareValue);
result = regex.test(value);
}
break;
}
return condition.invertResult ? !result : result;
}
/**
* 评估黑板变量存在性
*/
private evaluateBlackboardExists(entity: Entity): boolean {
const condition = entity.getComponent(BlackboardExistsCondition)!;
const blackboard = this.findBlackboard(entity);
if (!blackboard) {
return false;
}
let result = blackboard.hasVariable(condition.variableName);
if (result && condition.checkNotNull) {
const value = blackboard.getValue(condition.variableName);
result = value !== null && value !== undefined;
}
return condition.invertResult ? !result : result;
}
/**
* 评估随机概率
*/
private evaluateRandomProbability(entity: Entity): boolean {
const condition = entity.getComponent(RandomProbabilityCondition)!;
return condition.evaluate();
}
/**
* 评估自定义条件
*/
private evaluateCustomCondition(entity: Entity): boolean {
const condition = entity.getComponent(ExecuteCondition)!;
const func = condition.getFunction();
if (!func) {
return false;
}
const blackboard = this.findBlackboard(entity);
const result = func(entity, blackboard, 0);
return condition.invertResult ? !result : result;
}
/**
* 解析字符串中的变量引用
*/
private resolveVariableReferences(value: string, blackboard: BlackboardComponent): any {
const pureMatch = value.match(/^{{\s*(\w+)\s*}}$/);
if (pureMatch) {
const varName = pureMatch[1];
if (blackboard.hasVariable(varName)) {
return blackboard.getValue(varName);
}
return value;
}
return value.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
if (blackboard.hasVariable(varName)) {
const val = blackboard.getValue(varName);
return val !== undefined ? String(val) : match;
}
return match;
});
}
/**
* 查找黑板组件
*/
private findBlackboard(entity: Entity): BlackboardComponent | undefined {
let current: Entity | null = entity;
while (current) {
const blackboard = current.getComponent(BlackboardComponent);
if (blackboard) {
return blackboard;
}
current = current.parent;
}
return undefined;
}
/**
* 中止当前执行
*/
private abortExecution(entity: Entity, node: BehaviorTreeNode, children: readonly Entity[]): void {
// 停止当前正在执行的子节点
const currentIndex = node.currentChildIndex;
if (currentIndex < children.length) {
const currentChild = children[currentIndex];
this.deactivateNode(currentChild);
}
// 重置节点状态,从头开始
node.currentChildIndex = 0;
node.status = TaskStatus.Running;
// 不需要 completeNode因为我们要继续执行从头开始
}
/**
* 递归停用节点及其所有子节点
*/
private deactivateNode(entity: Entity): void {
// 移除活跃标记
entity.removeComponentByType(ActiveNode);
// 重置节点状态
const node = entity.getComponent(BehaviorTreeNode);
if (node) {
node.reset();
}
// 递归停用所有子节点
for (const child of entity.children) {
this.deactivateNode(child);
}
}
/**
* 递归重置所有子节点的状态
*/
private resetAllChildren(entity: Entity): void {
for (const child of entity.children) {
const childNode = child.getComponent(BehaviorTreeNode);
if (childNode) {
childNode.reset();
}
// 递归重置孙子节点
this.resetAllChildren(child);
}
}
/**
* 完成节点执行
*/
private completeNode(entity: Entity): void {
entity.removeComponentByType(ActiveNode);
// 如果是复合节点完成,重置所有子节点状态
const node = entity.getComponent(BehaviorTreeNode);
if (node && node.nodeType === NodeType.Composite) {
this.resetAllChildren(entity);
}
// 通知父节点
if (entity.parent && entity.parent.hasComponent(BehaviorTreeNode)) {
if (!entity.parent.hasComponent(ActiveNode)) {
entity.parent.addComponent(new ActiveNode());
}
}
}
protected override getLoggerName(): string {
return 'CompositeExecutionSystem';
}
}

View File

@@ -0,0 +1,515 @@
import { EntitySystem, Matcher, Entity, Time } from '@esengine/ecs-framework';
import { BehaviorTreeNode } from '../Components/BehaviorTreeNode';
import { DecoratorNodeComponent } from '../Components/DecoratorNodeComponent';
import { BlackboardComponent } from '../Components/BlackboardComponent';
import { ActiveNode } from '../Components/ActiveNode';
import { PropertyBindings } from '../Components/PropertyBindings';
import { LogOutput } from '../Components/LogOutput';
import { TaskStatus, NodeType, DecoratorType } from '../Types/TaskStatus';
import { RepeaterNode } from '../Components/Decorators/RepeaterNode';
import { ConditionalNode } from '../Components/Decorators/ConditionalNode';
import { CooldownNode } from '../Components/Decorators/CooldownNode';
import { TimeoutNode } from '../Components/Decorators/TimeoutNode';
/**
* 装饰器节点执行系统
*
* 负责处理所有活跃的装饰器节点
* 读取子节点状态,根据装饰器规则决定自己的状态
*
* updateOrder: 200 (在叶子节点之后执行)
*/
export class DecoratorExecutionSystem extends EntitySystem {
constructor() {
super(Matcher.empty().all(BehaviorTreeNode, ActiveNode));
this.updateOrder = 200;
}
protected override process(entities: readonly Entity[]): void {
for (const entity of entities) {
const node = entity.getComponent(BehaviorTreeNode)!;
// 只处理装饰器节点
if (node.nodeType !== NodeType.Decorator) {
continue;
}
// 使用 getComponentByType 支持继承查找
const decorator = entity.getComponentByType(DecoratorNodeComponent);
if (!decorator) {
continue;
}
this.executeDecorator(entity, node, decorator);
}
}
/**
* 执行装饰器逻辑
*/
private executeDecorator(entity: Entity, node: BehaviorTreeNode, decorator: DecoratorNodeComponent): void {
const children = entity.children;
if (children.length === 0) {
this.logger.warn('装饰器节点没有子节点');
node.status = TaskStatus.Failure;
this.completeNode(entity);
return;
}
const child = children[0]; // 装饰器只有一个子节点
const childNode = child.getComponent(BehaviorTreeNode);
if (!childNode) {
node.status = TaskStatus.Failure;
this.completeNode(entity);
return;
}
// 根据装饰器类型处理
switch (decorator.decoratorType) {
case DecoratorType.Inverter:
this.handleInverter(entity, node, child, childNode);
break;
case DecoratorType.Repeater:
this.handleRepeater(entity, node, decorator, child, childNode);
break;
case DecoratorType.UntilSuccess:
this.handleUntilSuccess(entity, node, child, childNode);
break;
case DecoratorType.UntilFail:
this.handleUntilFail(entity, node, child, childNode);
break;
case DecoratorType.AlwaysSucceed:
this.handleAlwaysSucceed(entity, node, child, childNode);
break;
case DecoratorType.AlwaysFail:
this.handleAlwaysFail(entity, node, child, childNode);
break;
case DecoratorType.Conditional:
this.handleConditional(entity, node, decorator, child, childNode);
break;
case DecoratorType.Cooldown:
this.handleCooldown(entity, node, decorator, child, childNode);
break;
case DecoratorType.Timeout:
this.handleTimeout(entity, node, decorator, child, childNode);
break;
default:
node.status = TaskStatus.Failure;
this.completeNode(entity);
break;
}
}
/**
* 反转装饰器
*/
private handleInverter(entity: Entity, node: BehaviorTreeNode, child: Entity, childNode: BehaviorTreeNode): void {
if (!child.hasComponent(ActiveNode)) {
// 子节点未激活,激活它
child.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
} else {
// 子节点正在执行
node.status = TaskStatus.Running;
}
// 如果子节点完成了
if (childNode.status === TaskStatus.Success || childNode.status === TaskStatus.Failure) {
// 反转结果
node.status = childNode.status === TaskStatus.Success ? TaskStatus.Failure : TaskStatus.Success;
this.completeNode(entity);
}
}
/**
* 重复装饰器
*/
private handleRepeater(
entity: Entity,
node: BehaviorTreeNode,
decorator: DecoratorNodeComponent,
child: Entity,
childNode: BehaviorTreeNode
): void {
const repeater = decorator as RepeaterNode;
// 从 PropertyBindings 读取绑定的黑板变量值
const repeatCount = this.resolvePropertyValue(entity, 'repeatCount', repeater.repeatCount);
const endOnFailure = this.resolvePropertyValue(entity, 'endOnFailure', repeater.endOnFailure);
// 如果子节点未激活,激活它
if (!child.hasComponent(ActiveNode)) {
child.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
return;
}
// 子节点正在执行
if (childNode.status === TaskStatus.Running) {
node.status = TaskStatus.Running;
return;
}
// 子节点完成
if (childNode.status === TaskStatus.Failure && endOnFailure) {
node.status = TaskStatus.Failure;
repeater.reset();
this.completeNode(entity);
return;
}
// 增加重复计数
repeater.incrementRepeat();
// 检查是否继续重复(使用解析后的值)
const shouldContinue = (repeatCount === -1) || (repeater.currentRepeatCount < repeatCount);
if (shouldContinue) {
// 重置子节点并继续
childNode.invalidate();
child.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
} else {
// 完成
node.status = TaskStatus.Success;
repeater.reset();
this.completeNode(entity);
}
}
/**
* 直到成功装饰器
*/
private handleUntilSuccess(entity: Entity, node: BehaviorTreeNode, child: Entity, childNode: BehaviorTreeNode): void {
if (!child.hasComponent(ActiveNode)) {
child.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
return;
}
if (childNode.status === TaskStatus.Running) {
node.status = TaskStatus.Running;
return;
}
if (childNode.status === TaskStatus.Success) {
node.status = TaskStatus.Success;
this.completeNode(entity);
} else {
// 失败则重试
childNode.invalidate();
child.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
}
}
/**
* 直到失败装饰器
*/
private handleUntilFail(entity: Entity, node: BehaviorTreeNode, child: Entity, childNode: BehaviorTreeNode): void {
if (!child.hasComponent(ActiveNode)) {
child.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
return;
}
if (childNode.status === TaskStatus.Running) {
node.status = TaskStatus.Running;
return;
}
if (childNode.status === TaskStatus.Failure) {
node.status = TaskStatus.Success;
this.completeNode(entity);
} else {
// 成功则重试
childNode.invalidate();
child.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
}
}
/**
* 总是成功装饰器
*/
private handleAlwaysSucceed(entity: Entity, node: BehaviorTreeNode, child: Entity, childNode: BehaviorTreeNode): void {
if (!child.hasComponent(ActiveNode)) {
child.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
return;
}
if (childNode.status === TaskStatus.Running) {
node.status = TaskStatus.Running;
} else {
node.status = TaskStatus.Success;
this.completeNode(entity);
}
}
/**
* 总是失败装饰器
*/
private handleAlwaysFail(entity: Entity, node: BehaviorTreeNode, child: Entity, childNode: BehaviorTreeNode): void {
if (!child.hasComponent(ActiveNode)) {
child.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
return;
}
if (childNode.status === TaskStatus.Running) {
node.status = TaskStatus.Running;
} else {
node.status = TaskStatus.Failure;
this.completeNode(entity);
}
}
/**
* 条件装饰器
*/
private handleConditional(
entity: Entity,
node: BehaviorTreeNode,
decorator: DecoratorNodeComponent,
child: Entity,
childNode: BehaviorTreeNode
): void {
const conditional = decorator as ConditionalNode;
// 评估条件
const conditionMet = conditional.evaluateCondition(entity, this.findBlackboard(entity));
if (!conditionMet) {
// 条件不满足,直接失败
node.status = TaskStatus.Failure;
this.completeNode(entity);
return;
}
// 条件满足,执行子节点
if (!child.hasComponent(ActiveNode)) {
child.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
return;
}
node.status = childNode.status;
if (childNode.status !== TaskStatus.Running) {
this.completeNode(entity);
}
}
/**
* 冷却装饰器
*/
private handleCooldown(
entity: Entity,
node: BehaviorTreeNode,
decorator: DecoratorNodeComponent,
child: Entity,
childNode: BehaviorTreeNode
): void {
const cooldown = decorator as CooldownNode;
// 从 PropertyBindings 读取绑定的黑板变量值
const cooldownTime = this.resolvePropertyValue(entity, 'cooldownTime', cooldown.cooldownTime);
// 检查冷却(使用解析后的值)
// 如果从未执行过lastExecutionTime === 0允许执行
const timeSinceLastExecution = Time.totalTime - cooldown.lastExecutionTime;
const canExecute = (cooldown.lastExecutionTime === 0) || (timeSinceLastExecution >= cooldownTime);
// 添加调试日志
this.outputLog(
entity,
`[冷却检查] Time.totalTime=${Time.totalTime.toFixed(3)}, lastExecution=${cooldown.lastExecutionTime.toFixed(3)}, ` +
`cooldownTime=${cooldownTime}, timeSince=${timeSinceLastExecution.toFixed(3)}, canExecute=${canExecute}, childStatus=${childNode.status}`,
'info'
);
if (!canExecute) {
node.status = TaskStatus.Failure;
this.completeNode(entity);
return;
}
// 先检查子节点状态,再决定是否激活
if (childNode.status !== TaskStatus.Invalid && childNode.status !== TaskStatus.Running) {
// 子节点已经完成Success 或 Failure
node.status = childNode.status;
cooldown.recordExecution(Time.totalTime);
this.outputLog(
entity,
`[冷却记录] 记录执行时间: ${Time.totalTime.toFixed(3)}, 下次可执行时间: ${(Time.totalTime + cooldownTime).toFixed(3)}`,
'info'
);
this.completeNode(entity);
return;
}
// 子节点还没开始或正在执行
if (!child.hasComponent(ActiveNode)) {
child.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
return;
}
node.status = TaskStatus.Running;
}
/**
* 超时装饰器
*/
private handleTimeout(
entity: Entity,
node: BehaviorTreeNode,
decorator: DecoratorNodeComponent,
child: Entity,
childNode: BehaviorTreeNode
): void {
const timeout = decorator as TimeoutNode;
// 从 PropertyBindings 读取绑定的黑板变量值
const timeoutDuration = this.resolvePropertyValue(entity, 'timeoutDuration', timeout.timeoutDuration);
timeout.recordStartTime(Time.totalTime);
// 检查超时(使用解析后的值)
const isTimeout = timeout.startTime > 0 && (Time.totalTime - timeout.startTime >= timeoutDuration);
if (isTimeout) {
node.status = TaskStatus.Failure;
timeout.reset();
// 移除子节点的活跃标记
child.removeComponentByType(ActiveNode);
this.completeNode(entity);
return;
}
if (!child.hasComponent(ActiveNode)) {
child.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
return;
}
node.status = childNode.status;
if (childNode.status !== TaskStatus.Running) {
timeout.reset();
this.completeNode(entity);
}
}
/**
* 完成节点执行
*/
private completeNode(entity: Entity): void {
entity.removeComponentByType(ActiveNode);
// 通知父节点
if (entity.parent && entity.parent.hasComponent(BehaviorTreeNode)) {
if (!entity.parent.hasComponent(ActiveNode)) {
entity.parent.addComponent(new ActiveNode());
}
}
}
/**
* 查找黑板组件(向上遍历父节点)
*/
private findBlackboard(entity: Entity): BlackboardComponent | undefined {
let current: Entity | null = entity;
while (current) {
const blackboard = current.getComponent(BlackboardComponent);
if (blackboard) {
return blackboard;
}
current = current.parent;
}
return undefined;
}
/**
* 解析属性值
* 如果属性绑定到黑板变量,从黑板读取最新值
*/
private resolvePropertyValue(entity: Entity, propertyName: string, defaultValue: any): any {
const bindings = entity.getComponent(PropertyBindings);
if (!bindings || !bindings.hasBinding(propertyName)) {
return defaultValue;
}
const blackboardKey = bindings.getBinding(propertyName)!;
const blackboard = this.findBlackboard(entity);
if (!blackboard || !blackboard.hasVariable(blackboardKey)) {
return defaultValue;
}
return blackboard.getValue(blackboardKey);
}
/**
* 查找根实体
*/
private findRootEntity(entity: Entity): Entity | null {
let current: Entity | null = entity;
while (current) {
if (!current.parent) {
return current;
}
current = current.parent;
}
return null;
}
/**
* 统一的日志输出方法
* 同时输出到控制台和LogOutput组件确保用户在UI中能看到
*/
private outputLog(
entity: Entity,
message: string,
level: 'log' | 'info' | 'warn' | 'error' = 'info'
): void {
switch (level) {
case 'info':
this.logger.info(message);
break;
case 'warn':
this.logger.warn(message);
break;
case 'error':
this.logger.error(message);
break;
default:
this.logger.info(message);
break;
}
const rootEntity = this.findRootEntity(entity);
if (rootEntity) {
const logOutput = rootEntity.getComponent(LogOutput);
if (logOutput) {
logOutput.addMessage(message, level);
}
}
}
protected override getLoggerName(): string {
return 'DecoratorExecutionSystem';
}
}

View File

@@ -0,0 +1,565 @@
import { EntitySystem, Matcher, Entity, Time } from '@esengine/ecs-framework';
import { BehaviorTreeNode } from '../Components/BehaviorTreeNode';
import { BlackboardComponent } from '../Components/BlackboardComponent';
import { ActiveNode } from '../Components/ActiveNode';
import { PropertyBindings } from '../Components/PropertyBindings';
import { LogOutput } from '../Components/LogOutput';
import { TaskStatus, NodeType } from '../Types/TaskStatus';
// 导入具体的动作组件
import { WaitAction } from '../Components/Actions/WaitAction';
import { LogAction } from '../Components/Actions/LogAction';
import { SetBlackboardValueAction } from '../Components/Actions/SetBlackboardValueAction';
import { ModifyBlackboardValueAction, ModifyOperation } 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';
/**
* 叶子节点执行系统
*
* 负责执行所有活跃的叶子节点Action 和 Condition
* 只处理带有 ActiveNode 标记的节点
*
* updateOrder: 100 (最先执行)
*/
export class LeafExecutionSystem extends EntitySystem {
constructor() {
// 只处理活跃的叶子节点
super(Matcher.empty().all(BehaviorTreeNode, ActiveNode));
this.updateOrder = 100;
}
protected override process(entities: readonly Entity[]): void {
for (const entity of entities) {
const node = entity.getComponent(BehaviorTreeNode)!;
// 只处理叶子节点
if (node.nodeType === NodeType.Action) {
this.executeAction(entity, node);
} else if (node.nodeType === NodeType.Condition) {
this.executeCondition(entity, node);
}
}
}
/**
* 执行动作节点
*/
private executeAction(entity: Entity, node: BehaviorTreeNode): void {
let status = TaskStatus.Failure;
const { displayName, nodeIdShort } = this.getNodeInfo(entity);
// 检测实体有哪些动作组件并执行
if (entity.hasComponent(WaitAction)) {
status = this.executeWaitAction(entity);
} else if (entity.hasComponent(LogAction)) {
status = this.executeLogAction(entity);
} else if (entity.hasComponent(SetBlackboardValueAction)) {
status = this.executeSetBlackboardValue(entity);
} else if (entity.hasComponent(ModifyBlackboardValueAction)) {
status = this.executeModifyBlackboardValue(entity);
} else if (entity.hasComponent(ExecuteAction)) {
status = this.executeCustomAction(entity);
} else {
this.outputLog(entity, `动作节点没有找到任何已知的动作组件`, 'warn');
}
node.status = status;
// 输出节点执行后的状态
const statusText = status === TaskStatus.Success ? 'Success' :
status === TaskStatus.Failure ? 'Failure' :
status === TaskStatus.Running ? 'Running' : 'Unknown';
if (status !== TaskStatus.Running) {
this.outputLog(entity, `[${displayName}#${nodeIdShort}] 执行完成 -> ${statusText}`,
status === TaskStatus.Success ? 'info' : 'warn');
}
// 如果不是 Running 状态,节点执行完成
if (status !== TaskStatus.Running) {
this.deactivateNode(entity);
this.notifyParent(entity);
}
}
/**
* 执行等待动作
*/
private executeWaitAction(entity: Entity): TaskStatus {
const waitAction = entity.getComponent(WaitAction)!;
const node = entity.getComponent(BehaviorTreeNode);
const { displayName, nodeIdShort } = this.getNodeInfo(entity);
// 从 PropertyBindings 读取绑定的黑板变量值
const waitTime = this.resolvePropertyValue(entity, 'waitTime', waitAction.waitTime);
waitAction.elapsedTime += Time.deltaTime;
// 输出调试信息显示在UI中
this.outputLog(
entity,
`[${displayName}#${nodeIdShort}] deltaTime=${Time.deltaTime.toFixed(3)}s, ` +
`elapsed=${waitAction.elapsedTime.toFixed(3)}s/${waitTime.toFixed(3)}s`,
'info'
);
if (waitAction.elapsedTime >= waitTime) {
waitAction.reset();
this.outputLog(entity, `[${displayName}#${nodeIdShort}] 等待完成,返回成功`, 'info');
return TaskStatus.Success;
}
return TaskStatus.Running;
}
/**
* 执行日志动作
*/
private executeLogAction(entity: Entity): TaskStatus {
const logAction = entity.getComponent(LogAction)!;
const node = entity.getComponent(BehaviorTreeNode);
// 从 PropertyBindings 读取绑定的黑板变量值
let message = this.resolvePropertyValue(entity, 'message', logAction.message);
const { displayName, nodeIdShort } = this.getNodeInfo(entity);
// 在消息前添加节点ID信息
if (node) {
message = `[${displayName}#${nodeIdShort}] ${message}`;
}
if (logAction.includeEntityInfo) {
message = `[Entity: ${entity.name}] ${message}`;
}
// 输出到浏览器控制台
switch (logAction.level) {
case 'info':
console.info(message);
break;
case 'warn':
console.warn(message);
break;
case 'error':
console.error(message);
break;
default:
console.log(message);
break;
}
// 同时记录到LogOutput组件以便在UI中显示
const rootEntity = this.findRootEntity(entity);
if (rootEntity) {
const logOutput = rootEntity.getComponent(LogOutput);
if (logOutput) {
logOutput.addMessage(message, logAction.level);
}
}
return TaskStatus.Success;
}
/**
* 查找根实体
*/
private findRootEntity(entity: Entity): Entity | null {
let current: Entity | null = entity;
while (current) {
if (!current.parent) {
return current;
}
current = current.parent;
}
return null;
}
/**
* 执行设置黑板变量值
*/
private executeSetBlackboardValue(entity: Entity): TaskStatus {
const action = entity.getComponent(SetBlackboardValueAction)!;
const blackboard = this.findBlackboard(entity);
if (!blackboard) {
this.outputLog(entity, '未找到黑板组件', 'warn');
return TaskStatus.Failure;
}
let valueToSet: any;
// 如果指定了源变量,从中读取值
if (action.sourceVariable) {
if (!blackboard.hasVariable(action.sourceVariable)) {
this.outputLog(entity, `源变量不存在: ${action.sourceVariable}`, 'warn');
return TaskStatus.Failure;
}
valueToSet = blackboard.getValue(action.sourceVariable);
} else {
// 从 PropertyBindings 读取绑定的值
valueToSet = this.resolvePropertyValue(entity, 'value', action.value);
}
const success = blackboard.setValue(action.variableName, valueToSet, action.force);
return success ? TaskStatus.Success : TaskStatus.Failure;
}
/**
* 执行修改黑板变量值
*/
private executeModifyBlackboardValue(entity: Entity): TaskStatus {
const action = entity.getComponent(ModifyBlackboardValueAction)!;
const blackboard = this.findBlackboard(entity);
if (!blackboard) {
this.outputLog(entity, '未找到黑板组件', 'warn');
return TaskStatus.Failure;
}
if (!blackboard.hasVariable(action.variableName)) {
this.outputLog(entity, `变量不存在: ${action.variableName}`, 'warn');
return TaskStatus.Failure;
}
let currentValue = blackboard.getValue(action.variableName);
// 从 PropertyBindings 读取绑定的值
let operand = this.resolvePropertyValue(entity, 'operand', action.operand);
// 执行操作
let newValue: any;
switch (action.operation) {
case ModifyOperation.Add:
newValue = Number(currentValue) + Number(operand);
break;
case ModifyOperation.Subtract:
newValue = Number(currentValue) - Number(operand);
break;
case ModifyOperation.Multiply:
newValue = Number(currentValue) * Number(operand);
break;
case ModifyOperation.Divide:
if (Number(operand) === 0) {
this.outputLog(entity, '除数不能为0', 'warn');
return TaskStatus.Failure;
}
newValue = Number(currentValue) / Number(operand);
break;
case ModifyOperation.Modulo:
newValue = Number(currentValue) % Number(operand);
break;
case ModifyOperation.Append:
if (Array.isArray(currentValue)) {
newValue = [...currentValue, operand];
} else if (typeof currentValue === 'string') {
newValue = currentValue + operand;
} else {
this.outputLog(entity, `变量 ${action.variableName} 不支持 append 操作`, 'warn');
return TaskStatus.Failure;
}
break;
case ModifyOperation.Remove:
if (Array.isArray(currentValue)) {
newValue = currentValue.filter(item => item !== operand);
} else {
this.outputLog(entity, `变量 ${action.variableName} 不是数组,不支持 remove 操作`, 'warn');
return TaskStatus.Failure;
}
break;
default:
return TaskStatus.Failure;
}
const success = blackboard.setValue(action.variableName, newValue, action.force);
return success ? TaskStatus.Success : TaskStatus.Failure;
}
/**
* 执行自定义动作
*/
private executeCustomAction(entity: Entity): TaskStatus {
const action = entity.getComponent(ExecuteAction)!;
const func = action.getFunction();
if (!func) {
return TaskStatus.Failure;
}
const blackboard = this.findBlackboard(entity);
return func(entity, blackboard, Time.deltaTime);
}
/**
* 执行条件节点
*/
private executeCondition(entity: Entity, node: BehaviorTreeNode): void {
let result = false;
const { displayName, nodeIdShort } = this.getNodeInfo(entity);
// 检测实体有哪些条件组件并评估
if (entity.hasComponent(BlackboardCompareCondition)) {
result = this.evaluateBlackboardCompare(entity);
} else if (entity.hasComponent(BlackboardExistsCondition)) {
result = this.evaluateBlackboardExists(entity);
} else if (entity.hasComponent(RandomProbabilityCondition)) {
result = this.evaluateRandomProbability(entity);
} else if (entity.hasComponent(ExecuteCondition)) {
result = this.evaluateCustomCondition(entity);
} else {
this.outputLog(entity, '条件节点没有找到任何已知的条件组件', 'warn');
}
node.status = result ? TaskStatus.Success : TaskStatus.Failure;
// 输出条件评估结果
const statusText = result ? 'Success (true)' : 'Failure (false)';
this.outputLog(entity, `[${displayName}#${nodeIdShort}] 条件评估 -> ${statusText}`,
result ? 'info' : 'warn');
// 条件节点总是立即完成
this.deactivateNode(entity);
this.notifyParent(entity);
}
/**
* 评估黑板比较条件
*/
private evaluateBlackboardCompare(entity: Entity): boolean {
const condition = entity.getComponent(BlackboardCompareCondition)!;
const blackboard = this.findBlackboard(entity);
if (!blackboard || !blackboard.hasVariable(condition.variableName)) {
return false;
}
const value = blackboard.getValue(condition.variableName);
// 从 PropertyBindings 读取绑定的值
let compareValue = this.resolvePropertyValue(entity, 'compareValue', condition.compareValue);
let result = false;
switch (condition.operator) {
case CompareOperator.Equal:
result = value === compareValue;
break;
case CompareOperator.NotEqual:
result = value !== compareValue;
break;
case CompareOperator.Greater:
result = value > compareValue;
break;
case CompareOperator.GreaterOrEqual:
result = value >= compareValue;
break;
case CompareOperator.Less:
result = value < compareValue;
break;
case CompareOperator.LessOrEqual:
result = value <= compareValue;
break;
case CompareOperator.Contains:
if (typeof value === 'string') {
result = value.includes(compareValue);
} else if (Array.isArray(value)) {
result = value.includes(compareValue);
}
break;
case CompareOperator.Matches:
if (typeof value === 'string' && typeof compareValue === 'string') {
const regex = new RegExp(compareValue);
result = regex.test(value);
}
break;
}
return condition.invertResult ? !result : result;
}
/**
* 评估黑板变量存在性
*/
private evaluateBlackboardExists(entity: Entity): boolean {
const condition = entity.getComponent(BlackboardExistsCondition)!;
const blackboard = this.findBlackboard(entity);
if (!blackboard) {
return false;
}
let result = blackboard.hasVariable(condition.variableName);
if (result && condition.checkNotNull) {
const value = blackboard.getValue(condition.variableName);
result = value !== null && value !== undefined;
}
return condition.invertResult ? !result : result;
}
/**
* 评估随机概率
*/
private evaluateRandomProbability(entity: Entity): boolean {
const condition = entity.getComponent(RandomProbabilityCondition)!;
// 从 PropertyBindings 读取绑定的黑板变量值
const probability = this.resolvePropertyValue(entity, 'probability', condition.probability);
// 使用解析后的概率值进行评估
if (condition.alwaysRandomize || condition['cachedResult'] === undefined) {
condition['cachedResult'] = Math.random() < probability;
}
return condition['cachedResult'];
}
/**
* 评估自定义条件
*/
private evaluateCustomCondition(entity: Entity): boolean {
const condition = entity.getComponent(ExecuteCondition)!;
const func = condition.getFunction();
if (!func) {
return false;
}
const blackboard = this.findBlackboard(entity);
const result = func(entity, blackboard, Time.deltaTime);
return condition.invertResult ? !result : result;
}
/**
* 解析属性值
* 如果属性绑定到黑板变量,从黑板读取最新值
*/
private resolvePropertyValue(entity: Entity, propertyName: string, defaultValue: any): any {
// 检查实体是否有 PropertyBindings 组件
const bindings = entity.getComponent(PropertyBindings);
if (!bindings || !bindings.hasBinding(propertyName)) {
// 没有绑定,返回默认值
return defaultValue;
}
// 有绑定,从黑板读取值
const blackboardKey = bindings.getBinding(propertyName)!;
const blackboard = this.findBlackboard(entity);
if (!blackboard) {
this.outputLog(entity, `[属性绑定] 未找到黑板组件,实体: ${entity.name}`, 'warn');
return defaultValue;
}
if (!blackboard.hasVariable(blackboardKey)) {
this.outputLog(entity, `[属性绑定] 黑板变量不存在: ${blackboardKey}`, 'warn');
return defaultValue;
}
const value = blackboard.getValue(blackboardKey);
return value;
}
/**
* 移除节点的活跃标记
*/
private deactivateNode(entity: Entity): void {
entity.removeComponentByType(ActiveNode);
}
/**
* 通知父节点子节点已完成
*/
private notifyParent(entity: Entity): void {
if (entity.parent && entity.parent.hasComponent(BehaviorTreeNode)) {
// 为父节点添加活跃标记,让它在下一帧被处理
if (!entity.parent.hasComponent(ActiveNode)) {
entity.parent.addComponent(new ActiveNode());
}
}
}
/**
* 查找黑板组件(向上遍历父节点)
*/
private findBlackboard(entity: Entity): BlackboardComponent | undefined {
let current: Entity | null = entity;
while (current) {
const blackboard = current.getComponent(BlackboardComponent);
if (blackboard) {
return blackboard;
}
current = current.parent;
}
return undefined;
}
/**
* 从Entity提取节点显示名称和ID
*/
private getNodeInfo(entity: Entity): { displayName: string; nodeIdShort: string } {
let displayName = 'Node';
let nodeIdShort = '';
if (entity.name && entity.name.includes('#')) {
const parts = entity.name.split('#');
displayName = parts[0];
nodeIdShort = parts[1];
} else {
nodeIdShort = entity.id.toString().substring(0, 8);
}
return { displayName, nodeIdShort };
}
/**
* 统一的日志输出方法
* 同时输出到控制台和LogOutput组件确保用户在UI中能看到
*/
private outputLog(
entity: Entity,
message: string,
level: 'log' | 'info' | 'warn' | 'error' = 'info'
): void {
// 输出到浏览器控制台(方便开发调试)
switch (level) {
case 'info':
this.logger.info(message);
break;
case 'warn':
this.logger.warn(message);
break;
case 'error':
this.logger.error(message);
break;
default:
this.logger.info(message);
break;
}
// 输出到LogOutput组件显示在UI中
const rootEntity = this.findRootEntity(entity);
if (rootEntity) {
const logOutput = rootEntity.getComponent(LogOutput);
if (logOutput) {
logOutput.addMessage(message, level);
}
}
}
protected override getLoggerName(): string {
return 'LeafExecutionSystem';
}
}

View File

@@ -0,0 +1,388 @@
import { EntitySystem, Matcher, Entity, Core } from '@esengine/ecs-framework';
import { BehaviorTreeNode } from '../Components/BehaviorTreeNode';
import { RootNode } from '../Components/Composites/RootNode';
import { ActiveNode } from '../Components/ActiveNode';
import { TaskStatus, NodeType } from '../Types/TaskStatus';
import { SubTreeNode } from '../Components/Composites/SubTreeNode';
import { LogOutput } from '../Components/LogOutput';
import { FileSystemAssetLoader } from '../Services/FileSystemAssetLoader';
import { BehaviorTreeAssetLoader } from '../Serialization/BehaviorTreeAssetLoader';
import { BehaviorTreeAssetMetadata } from '../Components/AssetMetadata';
import { BlackboardComponent } from '../Components/BlackboardComponent';
/**
* 预加载状态
*/
enum PreloadState {
/** 未开始预加载 */
NotStarted,
/** 正在预加载 */
Loading,
/** 预加载完成 */
Completed,
/** 预加载失败 */
Failed
}
/**
* 根节点执行系统
*
* 专门处理根节点的执行逻辑
* 根节点的职责:
* 1. 扫描并预加载所有标记为 preload=true 的子树
* 2. 激活第一个子节点,并根据子节点的状态来设置自己的状态
*
* updateOrder: 350 (在所有其他执行系统之后)
*/
export class RootExecutionSystem extends EntitySystem {
/** 跟踪每个根节点的预加载状态 */
private preloadStates: Map<number, PreloadState> = new Map();
/** 跟踪预加载任务 */
private preloadTasks: Map<number, Promise<void>> = new Map();
/** AssetLoader 实例 */
private assetLoader?: FileSystemAssetLoader;
constructor() {
super(Matcher.empty().all(BehaviorTreeNode, ActiveNode));
this.updateOrder = 350;
}
protected override process(entities: readonly Entity[]): void {
for (const entity of entities) {
const node = entity.getComponent(BehaviorTreeNode)!;
// 只处理根节点
if (node.nodeType !== NodeType.Composite) {
continue;
}
// 检查是否是根节点
if (!entity.hasComponent(RootNode)) {
continue;
}
this.executeRoot(entity, node);
}
}
/**
* 执行根节点逻辑
*/
private executeRoot(entity: Entity, node: BehaviorTreeNode): void {
// 检查预加载状态
const preloadState = this.preloadStates.get(entity.id) || PreloadState.NotStarted;
if (preloadState === PreloadState.NotStarted) {
// 开始预加载
this.startPreload(entity, node);
return;
} else if (preloadState === PreloadState.Loading) {
// 正在预加载,等待
node.status = TaskStatus.Running;
return;
} else if (preloadState === PreloadState.Failed) {
// 预加载失败,标记为失败
node.status = TaskStatus.Failure;
entity.removeComponentByType(ActiveNode);
return;
}
// 预加载完成,执行正常逻辑
const children = entity.children;
// 如果没有子节点,标记为成功
if (children.length === 0) {
node.status = TaskStatus.Success;
return;
}
// 获取第一个子节点
const firstChild = children[0];
const childNode = firstChild.getComponent(BehaviorTreeNode);
if (!childNode) {
node.status = TaskStatus.Failure;
return;
}
// 激活第一个子节点(如果还没激活)
if (!firstChild.hasComponent(ActiveNode)) {
firstChild.addComponent(new ActiveNode());
node.status = TaskStatus.Running;
return;
}
// 根据第一个子节点的状态来设置根节点的状态
if (childNode.status === TaskStatus.Running) {
node.status = TaskStatus.Running;
} else if (childNode.status === TaskStatus.Success) {
node.status = TaskStatus.Success;
// 移除根节点的 ActiveNode结束整个行为树
entity.removeComponentByType(ActiveNode);
} else if (childNode.status === TaskStatus.Failure) {
node.status = TaskStatus.Failure;
// 移除根节点的 ActiveNode结束整个行为树
entity.removeComponentByType(ActiveNode);
}
}
/**
* 开始预加载子树
*/
private startPreload(rootEntity: Entity, node: BehaviorTreeNode): void {
// 扫描所有需要预加载的子树节点
const subTreeNodesToPreload = this.scanSubTreeNodes(rootEntity);
if (subTreeNodesToPreload.length === 0) {
// 没有需要预加载的子树,直接标记为完成
this.preloadStates.set(rootEntity.id, PreloadState.Completed);
this.outputLog(rootEntity, '没有需要预加载的子树', 'info');
return;
}
// 标记为正在加载
this.preloadStates.set(rootEntity.id, PreloadState.Loading);
node.status = TaskStatus.Running;
this.outputLog(
rootEntity,
`开始预加载 ${subTreeNodesToPreload.length} 个子树...`,
'info'
);
// 并行加载所有子树
const loadTask = this.preloadAllSubTrees(rootEntity, subTreeNodesToPreload);
this.preloadTasks.set(rootEntity.id, loadTask);
// 异步处理加载结果
loadTask.then(() => {
this.preloadStates.set(rootEntity.id, PreloadState.Completed);
this.outputLog(rootEntity, '所有子树预加载完成', 'info');
}).catch(error => {
this.preloadStates.set(rootEntity.id, PreloadState.Failed);
this.outputLog(rootEntity, `子树预加载失败: ${error.message}`, 'error');
});
}
/**
* 扫描所有需要预加载的子树节点
*/
private scanSubTreeNodes(entity: Entity): Array<{ entity: Entity; subTree: SubTreeNode }> {
const result: Array<{ entity: Entity; subTree: SubTreeNode }> = [];
// 检查当前实体
const subTree = entity.getComponent(SubTreeNode);
if (subTree && subTree.preload) {
result.push({ entity, subTree });
}
// 递归扫描子节点
for (const child of entity.children) {
result.push(...this.scanSubTreeNodes(child));
}
return result;
}
/**
* 预加载所有子树
*/
private async preloadAllSubTrees(
rootEntity: Entity,
subTreeNodes: Array<{ entity: Entity; subTree: SubTreeNode }>
): Promise<void> {
// 确保 AssetLoader 已初始化
if (!this.assetLoader) {
try {
this.assetLoader = Core.services.resolve(FileSystemAssetLoader);
} catch (error) {
throw new Error('AssetLoader 未配置,无法预加载子树');
}
}
// 并行加载所有子树
await Promise.all(
subTreeNodes.map(({ entity, subTree }) =>
this.preloadSingleSubTree(rootEntity, entity, subTree)
)
);
}
/**
* 预加载单个子树
*/
private async preloadSingleSubTree(
rootEntity: Entity,
subTreeEntity: Entity,
subTree: SubTreeNode
): Promise<void> {
try {
this.outputLog(rootEntity, `预加载子树: ${subTree.assetId}`, 'info');
// 加载资产
const asset = await this.assetLoader!.loadBehaviorTree(subTree.assetId);
// 实例化为 Entity 树(作为子树,跳过 RootNode
const subTreeRoot = BehaviorTreeAssetLoader.instantiate(asset, this.scene!, {
asSubTree: true
});
// 设置子树根实体
subTree.setSubTreeRoot(subTreeRoot);
// 将子树根实体设置为 SubTreeNode 的子节点,这样子树中的节点可以通过 parent 链找到主树的根节点
subTreeEntity.addChild(subTreeRoot);
// 添加资产元数据
const metadata = subTreeRoot.addComponent(new BehaviorTreeAssetMetadata());
metadata.initialize(subTree.assetId, '1.0.0');
// 处理黑板继承
if (subTree.inheritParentBlackboard) {
this.setupBlackboardInheritance(subTreeEntity, subTreeRoot);
}
// 输出子树内部结构(用于调试)
this.outputLog(rootEntity, `=== 预加载子树 ${subTree.assetId} 的内部结构 ===`, 'info');
this.logSubTreeStructure(rootEntity, subTreeRoot, 0);
this.outputLog(rootEntity, `=== 预加载子树结构结束 ===`, 'info');
this.outputLog(rootEntity, `✓ 子树 ${subTree.assetId} 预加载完成`, 'info');
} catch (error: any) {
this.outputLog(
rootEntity,
`✗ 子树 ${subTree.assetId} 预加载失败: ${error.message}`,
'error'
);
throw error;
}
}
/**
* 设置黑板继承
*/
private setupBlackboardInheritance(parentEntity: Entity, subTreeRoot: Entity): void {
const parentBlackboard = this.findBlackboard(parentEntity);
if (!parentBlackboard) {
return;
}
// 找到子树的黑板
const subTreeBlackboard = subTreeRoot.getComponent(BlackboardComponent);
if (subTreeBlackboard) {
subTreeBlackboard.setUseGlobalBlackboard(true);
}
}
/**
* 查找黑板组件
*/
private findBlackboard(entity: Entity): BlackboardComponent | undefined {
let current: Entity | null = entity;
while (current) {
const blackboard = current.getComponent(BlackboardComponent);
if (blackboard) {
return blackboard;
}
current = current.parent;
}
return undefined;
}
/**
* 查找根实体
*/
private findRootEntity(entity: Entity): Entity | null {
let current: Entity | null = entity;
while (current) {
if (!current.parent) {
return current;
}
current = current.parent;
}
return null;
}
/**
* 统一的日志输出方法
*/
private outputLog(
entity: Entity,
message: string,
level: 'log' | 'info' | 'warn' | 'error' = 'info'
): void {
// 输出到控制台
switch (level) {
case 'info':
this.logger.info(message);
break;
case 'warn':
this.logger.warn(message);
break;
case 'error':
this.logger.error(message);
break;
default:
this.logger.info(message);
break;
}
// 输出到LogOutput组件
const rootEntity = this.findRootEntity(entity);
if (rootEntity) {
const logOutput = rootEntity.getComponent(LogOutput);
if (logOutput) {
logOutput.addMessage(message, level);
}
}
}
/**
* 递归打印子树结构(用于调试)
*/
private logSubTreeStructure(parentEntity: Entity, entity: Entity, depth: number): void {
const indent = ' '.repeat(depth);
const btNode = entity.getComponent(BehaviorTreeNode);
// 获取节点的具体类型组件
const allComponents = entity.components.map(c => c.constructor.name);
const nodeTypeComponent = allComponents.find(name =>
name !== 'BehaviorTreeNode' && name !== 'ActiveNode' &&
name !== 'BlackboardComponent' && name !== 'LogOutput' &&
name !== 'PropertyBindings' && name !== 'BehaviorTreeAssetMetadata'
) || 'Unknown';
// 构建节点显示名称
let nodeName = entity.name;
if (nodeTypeComponent !== 'Unknown') {
nodeName = `${nodeName} [${nodeTypeComponent}]`;
}
this.outputLog(parentEntity, `${indent}└─ ${nodeName}`, 'info');
// 递归打印子节点
if (entity.children.length > 0) {
this.outputLog(parentEntity, `${indent} 子节点数: ${entity.children.length}`, 'info');
entity.children.forEach((child: Entity) => {
this.logSubTreeStructure(parentEntity, child, depth + 1);
});
}
}
/**
* 清理资源
*/
protected override onDestroy(): void {
this.preloadStates.clear();
this.preloadTasks.clear();
super.onDestroy();
}
protected override getLoggerName(): string {
return 'RootExecutionSystem';
}
}

View File

@@ -0,0 +1,667 @@
import { EntitySystem, Matcher, Entity, Core, createLogger } from '@esengine/ecs-framework';
import { SubTreeNode } from '../Components/Composites/SubTreeNode';
import { ActiveNode } from '../Components/ActiveNode';
import { BehaviorTreeNode } from '../Components/BehaviorTreeNode';
import { TaskStatus } from '../Types/TaskStatus';
import { IAssetLoader } from '../Services/IAssetLoader';
import { FileSystemAssetLoader } from '../Services/FileSystemAssetLoader';
import { BehaviorTreeAssetLoader } from '../Serialization/BehaviorTreeAssetLoader';
import { BlackboardComponent } from '../Components/BlackboardComponent';
import { LogOutput } from '../Components/LogOutput';
import { AssetLoadingManager } from '../Services/AssetLoadingManager';
import {
LoadingState,
LoadingTaskHandle,
CircularDependencyError,
EntityDestroyedError
} from '../Services/AssetLoadingTypes';
import { BehaviorTreeAssetMetadata } from '../Components/AssetMetadata';
/**
* SubTree 执行系统
*
* 处理 SubTree 节点的执行,包括:
* - 子树资产加载
* - 子树实例化
* - 黑板继承
* - 子树执行和状态管理
*
* updateOrder: 300 (与 CompositeExecutionSystem 同级)
*/
export class SubTreeExecutionSystem extends EntitySystem {
private assetLoader?: IAssetLoader;
private assetLoaderInitialized = false;
private hasLoggedMissingAssetLoader = false;
private loadingManager: AssetLoadingManager;
private loadingTasks: Map<number, LoadingTaskHandle> = new Map();
constructor(loadingManager?: AssetLoadingManager) {
super(Matcher.empty().all(SubTreeNode, ActiveNode, BehaviorTreeNode));
this.updateOrder = 300;
this.loadingManager = loadingManager || new AssetLoadingManager();
}
protected override onInitialize(): void {
// 延迟初始化 AssetLoader不在这里尝试获取
// 只在第一次真正需要处理 SubTree 节点时才获取
}
protected override process(entities: readonly Entity[]): void {
for (const entity of entities) {
const subTree = entity.getComponent(SubTreeNode)!;
const node = entity.getComponent(BehaviorTreeNode)!;
this.executeSubTree(entity, subTree, node);
}
}
/**
* 执行子树节点
*/
private executeSubTree(
entity: Entity,
subTree: SubTreeNode,
node: BehaviorTreeNode
): void {
// 验证配置
const errors = subTree.validate();
if (errors.length > 0) {
this.logger.error(`SubTree 节点配置错误: ${errors.join(', ')}`);
node.status = TaskStatus.Failure;
this.completeNode(entity);
return;
}
// 检查是否已有子树(可能是预加载的)
const existingSubTreeRoot = subTree.getSubTreeRoot();
if (existingSubTreeRoot) {
const subTreeNode = existingSubTreeRoot.getComponent(BehaviorTreeNode);
if (subTreeNode) {
const statusName = TaskStatus[subTreeNode.status];
const hasActive = existingSubTreeRoot.hasComponent(ActiveNode);
this.outputLog(
entity,
`检查预加载子树 ${subTree.assetId}: status=${statusName}, hasActive=${hasActive}`,
'info'
);
// 如果子树还没开始执行(状态是 Invalid需要激活它
if (subTreeNode.status === TaskStatus.Invalid) {
this.outputLog(entity, `使用预加载的子树: ${subTree.assetId}`, 'info');
// 检查子节点
this.outputLog(entity, `激活前:子树根节点 ${existingSubTreeRoot.name}${existingSubTreeRoot.children.length} 个子节点`, 'info');
if (existingSubTreeRoot.children.length > 0) {
const firstChild = existingSubTreeRoot.children[0];
this.outputLog(entity, ` 第一个子节点: ${firstChild.name}`, 'info');
}
// 激活根节点
if (!existingSubTreeRoot.hasComponent(ActiveNode)) {
existingSubTreeRoot.addComponent(new ActiveNode());
this.outputLog(entity, `为子树根节点添加 ActiveNode: ${existingSubTreeRoot.name}`, 'info');
}
const subTreeRootNode = existingSubTreeRoot.getComponent(BehaviorTreeNode);
if (subTreeRootNode) {
this.outputLog(entity, `设置子树根节点状态: ${existingSubTreeRoot.name} -> Running`, 'info');
subTreeRootNode.status = TaskStatus.Running;
}
// 再次检查(验证激活后子节点没有丢失)
this.outputLog(entity, `激活后:子树根节点 ${existingSubTreeRoot.name}${existingSubTreeRoot.children.length} 个子节点`, 'info');
this.outputLog(entity, `激活预加载的子树: ${subTree.assetId}`, 'info');
node.status = TaskStatus.Running;
return;
}
}
// 子树已激活或已完成,更新状态
this.updateSubTree(entity, subTree, node);
return;
}
// 子树未预加载,开始运行时加载
this.outputLog(entity, `子树未预加载,开始运行时加载: ${subTree.assetId}`, 'info');
this.loadAndInstantiateSubTree(entity, subTree, node);
}
/**
* 延迟初始化 AssetLoader
*/
private ensureAssetLoaderInitialized(): boolean {
if (!this.assetLoaderInitialized) {
try {
this.assetLoader = Core.services.resolve(FileSystemAssetLoader);
this.assetLoaderInitialized = true;
this.logger.debug('AssetLoader 已初始化');
} catch (error) {
this.assetLoaderInitialized = true;
this.assetLoader = undefined;
// 只在第一次失败时记录警告,避免重复日志
if (!this.hasLoggedMissingAssetLoader) {
this.logger.warn(
'AssetLoader 未配置。SubTree 节点需要 AssetLoader 来加载子树资产。\n' +
'如果您在编辑器中,请确保已打开项目并配置了项目路径。\n' +
'如果您在运行时环境,请确保已正确注册 FileSystemAssetLoader 服务。'
);
this.hasLoggedMissingAssetLoader = true;
}
return false;
}
}
return this.assetLoader !== undefined;
}
/**
* 加载并实例化子树(使用加载管理器)
*/
private loadAndInstantiateSubTree(
parentEntity: Entity,
subTree: SubTreeNode,
node: BehaviorTreeNode
): void {
// 延迟初始化 AssetLoader
if (!this.ensureAssetLoaderInitialized()) {
this.logger.debug('AssetLoader 不可用SubTree 节点执行失败');
node.status = TaskStatus.Failure;
this.completeNode(parentEntity);
return;
}
const assetId = subTree.assetId;
// 检查是否有正在进行的加载任务
let taskHandle = this.loadingTasks.get(parentEntity.id);
if (taskHandle) {
// 轮询检查状态
const state = taskHandle.getState();
switch (state) {
case LoadingState.Loading:
case LoadingState.Pending:
// 仍在加载中
node.status = TaskStatus.Running;
// 输出进度信息
const progress = taskHandle.getProgress();
if (progress.elapsedMs > 1000) {
this.logger.debug(
`子树加载中: ${assetId} (已耗时: ${Math.round(progress.elapsedMs / 1000)}s, ` +
`重试: ${progress.retryCount}/${progress.maxRetries})`
);
}
return;
case LoadingState.Loaded:
// 加载完成
this.onLoadingComplete(parentEntity, subTree, node, taskHandle);
return;
case LoadingState.Failed:
case LoadingState.Timeout:
// 加载失败
const error = taskHandle.getError();
this.outputLog(
parentEntity,
`子树加载失败: ${assetId} - ${error?.message || '未知错误'}`,
'error'
);
node.status = TaskStatus.Failure;
this.loadingTasks.delete(parentEntity.id);
this.completeNode(parentEntity);
return;
case LoadingState.Cancelled:
// 已取消(实体被销毁)
this.loadingTasks.delete(parentEntity.id);
return;
}
}
// 开始新的加载任务
this.startNewLoading(parentEntity, subTree, node);
}
/**
* 开始新的加载任务
*/
private startNewLoading(
parentEntity: Entity,
subTree: SubTreeNode,
node: BehaviorTreeNode
): void {
const assetId = subTree.assetId;
// 获取父树的资产ID用于循环检测
const parentAssetId = this.getParentTreeAssetId(parentEntity);
try {
// 使用加载管理器
const taskHandle = this.loadingManager.startLoading(
assetId,
parentEntity,
() => this.loadAsset(assetId),
{
timeoutMs: 5000,
maxRetries: 2,
parentAssetId: parentAssetId
}
);
this.loadingTasks.set(parentEntity.id, taskHandle);
node.status = TaskStatus.Running;
this.outputLog(
parentEntity,
`开始加载子树: ${assetId} (父树: ${parentAssetId || 'none'})`,
'info'
);
} catch (error) {
if (error instanceof CircularDependencyError) {
this.outputLog(parentEntity, `检测到循环引用: ${error.message}`, 'error');
} else {
this.outputLog(parentEntity, `启动加载失败: ${assetId}`, 'error');
}
node.status = TaskStatus.Failure;
this.completeNode(parentEntity);
}
}
/**
* 加载完成时的处理
*/
private onLoadingComplete(
parentEntity: Entity,
subTree: SubTreeNode,
node: BehaviorTreeNode,
taskHandle: LoadingTaskHandle
): void {
// 获取加载结果
taskHandle.promise.then(subTreeRoot => {
// 再次检查实体是否存在
if (parentEntity.isDestroyed) {
this.logger.warn(`父实体已销毁,丢弃加载结果: ${taskHandle.assetId}`);
subTreeRoot.destroy();
return;
}
// 设置子树
subTree.setSubTreeRoot(subTreeRoot);
// 将子树根实体设置为 SubTreeNode 的子节点,这样子树中的节点可以通过 parent 链找到主树的根节点
parentEntity.addChild(subTreeRoot);
// 添加资产元数据(用于循环检测)
const metadata = subTreeRoot.addComponent(new BehaviorTreeAssetMetadata());
metadata.initialize(taskHandle.assetId, '1.0.0');
// 处理黑板继承
if (subTree.inheritParentBlackboard) {
this.setupBlackboardInheritance(parentEntity, subTreeRoot);
}
this.outputLog(parentEntity, `子树 ${taskHandle.assetId} 加载成功并激活`, 'info');
// 打印子树结构(用于调试)
this.outputLog(parentEntity, `=== 子树 ${taskHandle.assetId} 内部结构 ===`, 'info');
this.logSubTreeStructure(parentEntity, subTreeRoot, 0);
this.outputLog(parentEntity, `=== 子树结构结束 ===`, 'info');
// 激活子树执行
this.startSubTreeExecution(subTreeRoot, parentEntity);
// 清理任务
this.loadingTasks.delete(parentEntity.id);
}).catch(error => {
// 这里不应该到达,因为错误应该在状态机中处理了
if (!(error instanceof EntityDestroyedError)) {
this.logger.error('意外错误:', error);
}
});
}
/**
* 加载资产
*/
private async loadAsset(assetId: string): Promise<Entity> {
if (!this.scene) {
throw new Error('Scene 不存在');
}
// 加载资产
const asset = await this.assetLoader!.loadBehaviorTree(assetId);
// 实例化为 Entity 树(作为子树,跳过 RootNode
const rootEntity = BehaviorTreeAssetLoader.instantiate(asset, this.scene, {
asSubTree: true
});
return rootEntity;
}
/**
* 设置黑板继承
*/
private setupBlackboardInheritance(parentEntity: Entity, subTreeRoot: Entity): void {
const parentBlackboard = this.findBlackboard(parentEntity);
if (!parentBlackboard) {
return;
}
// 找到子树的黑板
const subTreeBlackboard = subTreeRoot.getComponent(BlackboardComponent);
if (subTreeBlackboard) {
// 启用全局黑板查找(这样子树可以访问父树的变量)
subTreeBlackboard.setUseGlobalBlackboard(true);
}
}
/**
* 查找黑板
*/
private findBlackboard(entity: Entity): BlackboardComponent | undefined {
let current: Entity | null = entity;
while (current) {
const blackboard = current.getComponent(BlackboardComponent);
if (blackboard) {
return blackboard;
}
current = current.parent;
}
return undefined;
}
/**
* 开始子树执行
*/
private startSubTreeExecution(subTreeRoot: Entity, parentEntity?: Entity): void {
// 调试:检查子树根节点的子节点
if (parentEntity) {
this.outputLog(parentEntity, `子树根节点 ${subTreeRoot.name}${subTreeRoot.children.length} 个子节点`, 'info');
}
// 激活根节点
if (!subTreeRoot.hasComponent(ActiveNode)) {
subTreeRoot.addComponent(new ActiveNode());
if (parentEntity) {
this.outputLog(parentEntity, `为子树根节点添加 ActiveNode: ${subTreeRoot.name}`, 'info');
}
}
const node = subTreeRoot.getComponent(BehaviorTreeNode);
if (node) {
if (parentEntity) {
this.outputLog(parentEntity, `设置子树根节点状态: ${subTreeRoot.name} -> Running`, 'info');
}
node.status = TaskStatus.Running;
}
}
/**
* 更新子树状态
*/
private updateSubTree(
parentEntity: Entity,
subTree: SubTreeNode,
node: BehaviorTreeNode
): void {
const subTreeRoot = subTree.getSubTreeRoot();
if (!subTreeRoot) {
return;
}
// 检查子树是否完成
const subTreeNode = subTreeRoot.getComponent(BehaviorTreeNode);
if (!subTreeNode) {
return;
}
// 输出子树当前状态(调试)
const statusName = TaskStatus[subTreeNode.status];
this.outputLog(
parentEntity,
`子树 ${subTree.assetId} 当前状态: ${statusName}`,
'info'
);
if (subTreeNode.status !== TaskStatus.Running) {
// 子树完成
this.onSubTreeCompleted(parentEntity, subTree, node, subTreeNode.status);
} else {
// 子树仍在运行
node.status = TaskStatus.Running;
}
}
/**
* 子树完成时的处理
*/
private onSubTreeCompleted(
parentEntity: Entity,
subTree: SubTreeNode,
node: BehaviorTreeNode,
subTreeStatus: TaskStatus
): void {
this.outputLog(parentEntity, `子树完成,状态: ${TaskStatus[subTreeStatus]}`, 'info');
// 检查完成前 SubTreeNode 的子节点
this.outputLog(parentEntity, `完成前SubTreeNode ${parentEntity.name}${parentEntity.children.length} 个子节点`, 'info');
// 标记子树完成
subTree.markSubTreeCompleted(subTreeStatus);
// 决定父节点状态
if (subTreeStatus === TaskStatus.Success) {
node.status = TaskStatus.Success;
} else if (subTreeStatus === TaskStatus.Failure) {
if (subTree.propagateFailure) {
node.status = TaskStatus.Failure;
} else {
// 忽略失败,返回成功
node.status = TaskStatus.Success;
}
} else {
node.status = subTreeStatus;
}
// 清理子树
this.cleanupSubTree(subTree);
// 检查清理后 SubTreeNode 的子节点
this.outputLog(parentEntity, `清理后SubTreeNode ${parentEntity.name}${parentEntity.children.length} 个子节点`, 'info');
// 完成父节点
this.completeNode(parentEntity);
}
/**
* 清理子树
*/
private cleanupSubTree(subTree: SubTreeNode): void {
const subTreeRoot = subTree.getSubTreeRoot();
if (!subTreeRoot) {
return;
}
// 如果是预加载的子树,不销毁,只重置状态以便复用
if (subTree.preload) {
this.logger.debug(`重置预加载子树以便复用: ${subTree.assetId}`);
// 递归重置整个子树的所有节点
this.resetSubTreeRecursively(subTreeRoot);
// 重置 SubTreeNode 的完成状态,但保留 subTreeRoot 引用
subTree.resetCompletionState();
} else {
// 运行时加载的子树,销毁并清理
this.logger.debug(`销毁运行时加载的子树: ${subTree.assetId}`);
subTreeRoot.destroy();
subTree.setSubTreeRoot(undefined);
subTree.reset();
}
}
/**
* 递归重置子树的所有节点
*/
private resetSubTreeRecursively(entity: Entity): void {
// 移除 ActiveNode
if (entity.hasComponent(ActiveNode)) {
entity.removeComponentByType(ActiveNode);
}
// 重置节点状态
const node = entity.getComponent(BehaviorTreeNode);
if (node) {
node.status = TaskStatus.Invalid;
}
// 递归处理子节点
for (const child of entity.children) {
this.resetSubTreeRecursively(child);
}
}
/**
* 完成节点执行
*/
private completeNode(entity: Entity): void {
entity.removeComponentByType(ActiveNode);
// 通知父节点
if (entity.parent && entity.parent.hasComponent(BehaviorTreeNode)) {
if (!entity.parent.hasComponent(ActiveNode)) {
entity.parent.addComponent(new ActiveNode());
}
}
}
/**
* 获取父树的资产ID用于循环检测
*/
private getParentTreeAssetId(entity: Entity): string | undefined {
let current: Entity | null = entity;
while (current) {
// 查找带有资产元数据的组件
const metadata = current.getComponent(BehaviorTreeAssetMetadata);
if (metadata && metadata.assetId) {
return metadata.assetId;
}
current = current.parent;
}
return undefined;
}
/**
* 系统销毁时清理
*/
protected override onDestroy(): void {
// 取消所有正在加载的任务
for (const taskHandle of this.loadingTasks.values()) {
taskHandle.cancel();
}
this.loadingTasks.clear();
super.onDestroy();
}
/**
* 查找根实体
*/
private findRootEntity(entity: Entity): Entity | null {
let current: Entity | null = entity;
while (current) {
if (!current.parent) {
return current;
}
current = current.parent;
}
return null;
}
/**
* 统一的日志输出方法
* 同时输出到控制台和LogOutput组件确保用户在UI中能看到
*/
private outputLog(
entity: Entity,
message: string,
level: 'log' | 'info' | 'warn' | 'error' = 'info'
): void {
// 输出到浏览器控制台(方便开发调试)
switch (level) {
case 'info':
this.logger.info(message);
break;
case 'warn':
this.logger.warn(message);
break;
case 'error':
this.logger.error(message);
break;
default:
this.logger.info(message);
break;
}
// 输出到LogOutput组件显示在UI中
const rootEntity = this.findRootEntity(entity);
if (rootEntity) {
const logOutput = rootEntity.getComponent(LogOutput);
if (logOutput) {
logOutput.addMessage(message, level);
}
}
}
/**
* 递归打印子树结构(用于调试)
*/
private logSubTreeStructure(parentEntity: Entity, entity: Entity, depth: number): void {
const indent = ' '.repeat(depth);
const btNode = entity.getComponent(BehaviorTreeNode);
// 获取节点的具体类型组件
const allComponents = entity.components.map(c => c.constructor.name);
const nodeTypeComponent = allComponents.find(name =>
name !== 'BehaviorTreeNode' && name !== 'ActiveNode' &&
name !== 'BlackboardComponent' && name !== 'LogOutput' &&
name !== 'PropertyBindings' && name !== 'BehaviorTreeAssetMetadata'
) || 'Unknown';
// 构建节点显示名称
let nodeName = entity.name;
if (nodeTypeComponent !== 'Unknown') {
nodeName = `${nodeName} [${nodeTypeComponent}]`;
}
this.outputLog(parentEntity, `${indent}└─ ${nodeName}`, 'info');
// 递归打印子节点
if (entity.children.length > 0) {
this.outputLog(parentEntity, `${indent} 子节点数: ${entity.children.length}`, 'info');
entity.children.forEach((child: Entity) => {
this.logSubTreeStructure(parentEntity, child, depth + 1);
});
}
}
protected override getLoggerName(): string {
return 'SubTreeExecutionSystem';
}
}

View File

@@ -0,0 +1,98 @@
/**
* 任务执行状态
*/
export enum TaskStatus {
/** 无效状态 - 节点未初始化或已被重置 */
Invalid = 0,
/** 成功 - 节点执行成功完成 */
Success = 1,
/** 失败 - 节点执行失败 */
Failure = 2,
/** 运行中 - 节点正在执行,需要在后续帧继续 */
Running = 3
}
/**
* 节点类型
*/
export enum NodeType {
/** 复合节点 - 有多个子节点 */
Composite = 'composite',
/** 装饰器节点 - 有一个子节点 */
Decorator = 'decorator',
/** 动作节点 - 叶子节点 */
Action = 'action',
/** 条件节点 - 叶子节点 */
Condition = 'condition'
}
/**
* 复合节点类型
*/
export enum CompositeType {
/** 序列 - 按顺序执行,全部成功才成功 */
Sequence = 'sequence',
/** 选择 - 按顺序执行,任一成功则成功 */
Selector = 'selector',
/** 并行 - 同时执行所有子节点 */
Parallel = 'parallel',
/** 并行选择 - 并行执行,任一成功则成功 */
ParallelSelector = 'parallel-selector',
/** 随机序列 - 随机顺序执行序列 */
RandomSequence = 'random-sequence',
/** 随机选择 - 随机顺序执行选择 */
RandomSelector = 'random-selector'
}
/**
* 装饰器节点类型
*/
export enum DecoratorType {
/** 反转 - 反转子节点结果 */
Inverter = 'inverter',
/** 重复 - 重复执行子节点 */
Repeater = 'repeater',
/** 直到成功 - 重复直到成功 */
UntilSuccess = 'until-success',
/** 直到失败 - 重复直到失败 */
UntilFail = 'until-fail',
/** 总是成功 - 无论子节点结果都返回成功 */
AlwaysSucceed = 'always-succeed',
/** 总是失败 - 无论子节点结果都返回失败 */
AlwaysFail = 'always-fail',
/** 条件装饰器 - 基于条件执行子节点 */
Conditional = 'conditional',
/** 冷却 - 冷却时间内阻止执行 */
Cooldown = 'cooldown',
/** 超时 - 超时则返回失败 */
Timeout = 'timeout'
}
/**
* 中止类型
*
* 用于动态优先级和条件重新评估
*/
export enum AbortType {
/** 无 - 不中止任何节点 */
None = 'none',
/** 自身 - 条件变化时可以中止自身的执行 */
Self = 'self',
/** 低优先级 - 条件满足时可以中止低优先级的兄弟节点 */
LowerPriority = 'lower-priority',
/** 两者 - 可以中止自身和低优先级节点 */
Both = 'both'
}
/**
* 黑板变量类型
*/
export enum BlackboardValueType {
String = 'string',
Number = 'number',
Boolean = 'boolean',
Vector2 = 'vector2',
Vector3 = 'vector3',
Object = 'object',
Array = 'array'
}

View File

@@ -0,0 +1,92 @@
/**
* @esengine/behavior-tree
*
* 完全ECS化的行为树系统
*
* @packageDocumentation
*/
// 注册所有内置节点
import './RegisterAllNodes';
// 类型定义
export * from './Types/TaskStatus';
// 基础组件
export * from './Components/BehaviorTreeNode';
export * from './Components/BlackboardComponent';
export * from './Components/CompositeNodeComponent';
export * from './Components/DecoratorNodeComponent';
export * from './Components/ActiveNode';
export * from './Components/PropertyBindings';
export * from './Components/LogOutput';
export * from './Components/AssetMetadata';
// 动作组件
export * from './Components/Actions/WaitAction';
export * from './Components/Actions/LogAction';
export * from './Components/Actions/SetBlackboardValueAction';
export * from './Components/Actions/ModifyBlackboardValueAction';
export * from './Components/Actions/ExecuteAction';
// 条件组件
export * from './Components/Conditions/BlackboardCompareCondition';
export * from './Components/Conditions/BlackboardExistsCondition';
export * from './Components/Conditions/RandomProbabilityCondition';
export * from './Components/Conditions/ExecuteCondition';
// 组合节点
export * from './Components/Composites/RootNode';
export * from './Components/Composites/SequenceNode';
export * from './Components/Composites/SelectorNode';
export * from './Components/Composites/ParallelNode';
export * from './Components/Composites/ParallelSelectorNode';
export * from './Components/Composites/RandomSequenceNode';
export * from './Components/Composites/RandomSelectorNode';
export * from './Components/Composites/SubTreeNode';
// 装饰器节点
export * from './Components/Decorators/InverterNode';
export * from './Components/Decorators/RepeaterNode';
export * from './Components/Decorators/UntilSuccessNode';
export * from './Components/Decorators/UntilFailNode';
export * from './Components/Decorators/AlwaysSucceedNode';
export * from './Components/Decorators/AlwaysFailNode';
export * from './Components/Decorators/ConditionalNode';
export * from './Components/Decorators/CooldownNode';
export * from './Components/Decorators/TimeoutNode';
// 系统
export * from './Systems/RootExecutionSystem';
export * from './Systems/LeafExecutionSystem';
export * from './Systems/DecoratorExecutionSystem';
export * from './Systems/CompositeExecutionSystem';
export * from './Systems/SubTreeExecutionSystem';
// 服务
export * from './Services/GlobalBlackboardService';
export * from './Services/WorkspaceService';
export * from './Services/IAssetLoader';
export * from './Services/FileSystemAssetLoader';
export * from './Services/AssetLoadingManager';
export * from './Services/AssetLoadingTypes';
// 插件
export * from './BehaviorTreePlugin';
// 辅助工具
export * from './BehaviorTreeStarter';
export * from './BehaviorTreeBuilder';
// 序列化(编辑器支持)
export * from './Serialization/BehaviorTreePersistence';
export * from './Serialization/NodeTemplates';
// 资产系统(运行时)
export * from './Serialization/BehaviorTreeAsset';
export * from './Serialization/BehaviorTreeAssetSerializer';
export * from './Serialization/BehaviorTreeAssetLoader';
export * from './Serialization/EditorFormatConverter';
// 装饰器(扩展支持)
export * from './Decorators/BehaviorNodeDecorator';

View File

@@ -0,0 +1,311 @@
import { World, Scene, Entity } from '@esengine/ecs-framework';
import { AssetLoadingManager } from '../src/Services/AssetLoadingManager';
import {
LoadingState,
TimeoutError,
CircularDependencyError,
EntityDestroyedError
} from '../src/Services/AssetLoadingTypes';
describe('AssetLoadingManager', () => {
let manager: AssetLoadingManager;
let world: World;
let scene: Scene;
let parentEntity: Entity;
beforeEach(() => {
manager = new AssetLoadingManager();
world = new World();
scene = world.createScene('test');
parentEntity = scene.createEntity('parent');
});
afterEach(() => {
manager.dispose();
parentEntity.destroy();
});
describe('基本加载功能', () => {
test('成功加载资产', async () => {
const mockEntity = scene.createEntity('loaded');
const loader = jest.fn().mockResolvedValue(mockEntity);
const handle = manager.startLoading(
'test-asset',
parentEntity,
loader
);
expect(handle.getState()).toBe(LoadingState.Loading);
const result = await handle.promise;
expect(result).toBe(mockEntity);
expect(handle.getState()).toBe(LoadingState.Loaded);
expect(loader).toHaveBeenCalledTimes(1);
});
test('加载失败', async () => {
const mockError = new Error('Load failed');
const loader = jest.fn().mockRejectedValue(mockError);
const handle = manager.startLoading(
'test-asset',
parentEntity,
loader,
{ maxRetries: 0 }
);
await expect(handle.promise).rejects.toThrow('Load failed');
expect(handle.getState()).toBe(LoadingState.Failed);
expect(handle.getError()).toBe(mockError);
});
});
describe('超时机制', () => {
test('加载超时', async () => {
const loader = jest.fn().mockImplementation(() =>
new Promise(resolve => setTimeout(resolve, 10000))
);
const handle = manager.startLoading(
'test-asset',
parentEntity,
loader,
{ timeoutMs: 100, maxRetries: 0 }
);
await expect(handle.promise).rejects.toThrow(TimeoutError);
expect(handle.getState()).toBe(LoadingState.Timeout);
});
test('超时前完成', async () => {
const mockEntity = scene.createEntity('loaded');
const loader = jest.fn().mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve(mockEntity), 50))
);
const handle = manager.startLoading(
'test-asset',
parentEntity,
loader,
{ timeoutMs: 200 }
);
const result = await handle.promise;
expect(result).toBe(mockEntity);
expect(handle.getState()).toBe(LoadingState.Loaded);
});
});
describe('重试机制', () => {
test('失败后自动重试', async () => {
const mockEntity = scene.createEntity('loaded');
let attemptCount = 0;
const loader = jest.fn().mockImplementation(() => {
attemptCount++;
if (attemptCount < 3) {
return Promise.reject(new Error('Temporary error'));
}
return Promise.resolve(mockEntity);
});
const handle = manager.startLoading(
'test-asset',
parentEntity,
loader,
{ maxRetries: 3 }
);
const result = await handle.promise;
expect(result).toBe(mockEntity);
expect(loader).toHaveBeenCalledTimes(3);
expect(handle.getState()).toBe(LoadingState.Loaded);
});
test('重试次数用尽后失败', async () => {
const loader = jest.fn().mockRejectedValue(new Error('Persistent error'));
const handle = manager.startLoading(
'test-asset',
parentEntity,
loader,
{ maxRetries: 2 }
);
await expect(handle.promise).rejects.toThrow('Persistent error');
expect(loader).toHaveBeenCalledTimes(3); // 初始 + 2次重试
expect(handle.getState()).toBe(LoadingState.Failed);
});
});
describe('循环引用检测', () => {
test('检测直接循环引用', () => {
const loader = jest.fn().mockResolvedValue(scene.createEntity('loaded'));
// 先加载 assetA
const handleA = manager.startLoading(
'assetA',
parentEntity,
loader,
{ parentAssetId: undefined }
);
expect(handleA.getState()).toBe(LoadingState.Loading);
// 尝试在 assetA 的上下文中加载 assetB
// assetB 又尝试加载 assetA循环
expect(() => {
manager.startLoading(
'assetB',
parentEntity,
loader,
{ parentAssetId: 'assetB' } // assetB 的父是 assetB自我循环
);
}).toThrow(CircularDependencyError);
});
test('不误报非循环引用', () => {
const loader = jest.fn().mockResolvedValue(scene.createEntity('loaded'));
// assetA 加载 assetB正常
const handleA = manager.startLoading(
'assetA',
parentEntity,
loader
);
// assetB 加载 assetC正常不是循环
expect(() => {
manager.startLoading(
'assetC',
parentEntity,
loader,
{ parentAssetId: 'assetB' }
);
}).not.toThrow();
});
});
describe('实体生命周期安全', () => {
test('实体销毁后取消加载', async () => {
const loader = jest.fn().mockImplementation(() =>
new Promise(resolve => setTimeout(resolve, 100))
);
const handle = manager.startLoading(
'test-asset',
parentEntity,
loader
);
// 销毁实体
parentEntity.destroy();
// 等待一小段时间让检测生效
await new Promise(resolve => setTimeout(resolve, 50));
await expect(handle.promise).rejects.toThrow(EntityDestroyedError);
expect(handle.getState()).toBe(LoadingState.Cancelled);
});
});
describe('状态查询', () => {
test('获取加载进度', async () => {
const mockEntity = scene.createEntity('loaded');
const loader = jest.fn().mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve(mockEntity), 100))
);
const handle = manager.startLoading(
'test-asset',
parentEntity,
loader
);
const progress = handle.getProgress();
expect(progress.state).toBe(LoadingState.Loading);
expect(progress.elapsedMs).toBeGreaterThanOrEqual(0);
expect(progress.retryCount).toBe(0);
expect(progress.maxRetries).toBe(3);
await handle.promise;
});
test('获取统计信息', () => {
const loader = jest.fn().mockResolvedValue(scene.createEntity('loaded'));
manager.startLoading('asset1', parentEntity, loader);
manager.startLoading('asset2', parentEntity, loader);
const stats = manager.getStats();
expect(stats.totalTasks).toBe(2);
expect(stats.loadingTasks).toBe(2);
});
test('获取正在加载的资产列表', () => {
const loader = jest.fn().mockResolvedValue(scene.createEntity('loaded'));
manager.startLoading('asset1', parentEntity, loader);
manager.startLoading('asset2', parentEntity, loader);
const loadingAssets = manager.getLoadingAssets();
expect(loadingAssets).toContain('asset1');
expect(loadingAssets).toContain('asset2');
expect(loadingAssets.length).toBe(2);
});
});
describe('任务管理', () => {
test('取消加载任务', () => {
const loader = jest.fn().mockImplementation(() =>
new Promise(resolve => setTimeout(resolve, 1000))
);
const handle = manager.startLoading(
'test-asset',
parentEntity,
loader
);
expect(handle.getState()).toBe(LoadingState.Loading);
handle.cancel();
expect(handle.getState()).toBe(LoadingState.Cancelled);
});
test('清空所有任务', async () => {
const loader = jest.fn().mockResolvedValue(scene.createEntity('loaded'));
manager.startLoading('asset1', parentEntity, loader);
manager.startLoading('asset2', parentEntity, loader);
expect(manager.getLoadingAssets().length).toBe(2);
manager.clear();
expect(manager.getLoadingAssets().length).toBe(0);
});
test('复用已存在的加载任务', () => {
const loader = jest.fn().mockResolvedValue(scene.createEntity('loaded'));
const handle1 = manager.startLoading('test-asset', parentEntity, loader);
const handle2 = manager.startLoading('test-asset', parentEntity, loader);
// 应该返回同一个任务
expect(handle1.assetId).toBe(handle2.assetId);
expect(loader).toHaveBeenCalledTimes(1); // 只加载一次
});
});
});

View File

@@ -0,0 +1,21 @@
{
"extends": "../core/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./bin"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"bin",
"**/*.test.ts",
"**/*.spec.ts"
],
"references": [
{
"path": "../core"
}
]
}

View File

@@ -649,22 +649,49 @@ export class Entity {
/**
* 获取所有指定类型的组件
*
*
* @param type - 组件类型
* @returns 组件实例数组
*/
public getComponents<T extends Component>(type: ComponentType<T>): T[] {
const result: T[] = [];
for (const component of this.components) {
if (component instanceof type) {
result.push(component as T);
}
}
return result;
}
/**
* 获取指定基类的组件(支持继承查找)
*
* 与 getComponent() 不同,此方法使用 instanceof 检查,支持子类查找。
* 性能比位掩码查询稍慢,但支持继承层次结构。
*
* @param baseType - 组件基类类型
* @returns 第一个匹配的组件实例,如果不存在则返回 null
*
* @example
* ```typescript
* // 查找 CompositeNodeComponent 或其子类
* const composite = entity.getComponentByType(CompositeNodeComponent);
* if (composite) {
* // composite 可能是 SequenceNode, SelectorNode 等
* }
* ```
*/
public getComponentByType<T extends Component>(baseType: ComponentType<T>): T | null {
for (const component of this.components) {
if (component instanceof baseType) {
return component as T;
}
}
return null;
}
/**
* 添加子实体
*

View File

@@ -102,8 +102,6 @@ export class World {
this.name = this._config.name!;
this._createdAt = Date.now();
logger.info(`创建World: ${this.name}`);
}
// ===== Scene管理 =====
@@ -132,11 +130,10 @@ export class World {
}
this._scenes.set(sceneId, scene);
// 初始化Scene
scene.initialize();
logger.info(`在World '${this.name}' 中创建Scene: ${sceneId}`);
return scene;
}

View File

@@ -117,7 +117,6 @@ export class WorldManager implements IService {
const world = new World(worldConfig);
this._worlds.set(worldId, world);
logger.info(`创建World: ${worldId}`, { config: worldConfig });
return world;
}

View File

@@ -9,20 +9,27 @@
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"kill-dev": "node scripts/kill-dev-server.js",
"tauri:dev": "npm run kill-dev && tauri dev",
"tauri:build": "tauri build",
"version": "node scripts/sync-version.js && git add src-tauri/tauri.conf.json"
},
"dependencies": {
"@esengine/behavior-tree": "file:../behavior-tree",
"@esengine/ecs-framework": "file:../core",
"@esengine/editor-core": "file:../editor-core",
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-shell": "^2.0.0",
"flexlayout-react": "^0.8.17",
"i18next": "^25.6.0",
"json5": "^2.2.3",
"lucide-react": "^0.545.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-i18next": "^16.1.3",
"zustand": "^5.0.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2.2.0",

View File

@@ -0,0 +1,47 @@
/**
* 清理开发服务器进程
* 用于 Windows 平台自动清理残留的 Vite 进程
*/
import { execSync } from 'child_process';
const PORT = 5173;
try {
console.log(`正在查找占用端口 ${PORT} 的进程...`);
// Windows 命令
const result = execSync(`netstat -ano | findstr :${PORT}`, { encoding: 'utf8' });
// 解析 PID
const lines = result.split('\n');
const pids = new Set();
for (const line of lines) {
if (line.includes('LISTENING')) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && pid !== '0') {
pids.add(pid);
}
}
}
if (pids.size === 0) {
console.log(`✓ 端口 ${PORT} 未被占用`);
} else {
console.log(`发现 ${pids.size} 个进程占用端口 ${PORT}`);
for (const pid of pids) {
try {
// Windows 需要使用 /F /PID 而不是 //F //PID
execSync(`taskkill /F /PID ${pid}`, { encoding: 'utf8', stdio: 'ignore' });
console.log(`✓ 已终止进程 PID: ${pid}`);
} catch (e) {
console.log(`✗ 无法终止进程 PID: ${pid}`);
}
}
}
} catch (error) {
// 如果 netstat 没有找到结果,会抛出错误,这是正常的
console.log(`✓ 端口 ${PORT} 未被占用`);
}

View File

@@ -754,6 +754,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-shell",
"tauri-plugin-updater",
"tokio",

View File

@@ -16,6 +16,7 @@ tauri-build = { version = "2.0", features = [] }
tauri = { version = "2.0", features = ["protocol-asset"] }
tauri-plugin-shell = "2.0"
tauri-plugin-dialog = "2.0"
tauri-plugin-fs = "2.0"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -74,3 +74,55 @@ pub async fn get_profiler_status(
let server_lock = state.server.lock().await;
Ok(server_lock.is_some())
}
#[tauri::command]
pub async fn read_behavior_tree_file(file_path: String) -> Result<String, String> {
use std::fs;
// 使用 Rust 标准库直接读取文件,绕过 Tauri 的 scope 限制
fs::read_to_string(&file_path)
.map_err(|e| format!("Failed to read file {}: {}", file_path, e))
}
#[tauri::command]
pub async fn write_behavior_tree_file(file_path: String, content: String) -> Result<(), String> {
use std::fs;
// 使用 Rust 标准库直接写入文件
fs::write(&file_path, content)
.map_err(|e| format!("Failed to write file {}: {}", file_path, e))
}
#[tauri::command]
pub async fn read_global_blackboard(project_path: String) -> Result<String, String> {
use std::fs;
use std::path::Path;
let config_path = Path::new(&project_path).join(".ecs").join("global-blackboard.json");
if !config_path.exists() {
return Ok(String::from(r#"{"version":"1.0","variables":[]}"#));
}
fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read global blackboard: {}", e))
}
#[tauri::command]
pub async fn write_global_blackboard(project_path: String, content: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let ecs_dir = Path::new(&project_path).join(".ecs");
let config_path = ecs_dir.join("global-blackboard.json");
// 创建 .ecs 目录(如果不存在)
if !ecs_dir.exists() {
fs::create_dir_all(&ecs_dir)
.map_err(|e| format!("Failed to create .ecs directory: {}", e))?;
}
fs::write(&config_path, content)
.map_err(|e| format!("Failed to write global blackboard: {}", e))
}

View File

@@ -97,6 +97,68 @@ async fn open_scene_dialog(app: AppHandle) -> Result<Option<String>, String> {
Ok(file.map(|path| path.to_string()))
}
#[tauri::command]
async fn open_behavior_tree_dialog(app: AppHandle) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let file = app.dialog()
.file()
.set_title("Select Behavior Tree")
.add_filter("Behavior Tree Files", &["btree"])
.blocking_pick_file();
Ok(file.map(|path| path.to_string()))
}
#[tauri::command]
fn scan_behavior_trees(project_path: String) -> Result<Vec<String>, String> {
use std::path::Path;
use std::fs;
let behaviors_path = Path::new(&project_path).join(".ecs").join("behaviors");
if !behaviors_path.exists() {
fs::create_dir_all(&behaviors_path)
.map_err(|e| format!("Failed to create behaviors directory: {}", e))?;
return Ok(Vec::new());
}
let mut btree_files = Vec::new();
scan_directory_recursive(&behaviors_path, &behaviors_path, &mut btree_files)?;
Ok(btree_files)
}
fn scan_directory_recursive(
base_path: &std::path::Path,
current_path: &std::path::Path,
results: &mut Vec<String>
) -> Result<(), String> {
use std::fs;
let entries = fs::read_dir(current_path)
.map_err(|e| format!("Failed to read directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
if path.is_dir() {
scan_directory_recursive(base_path, &path, results)?;
} else if path.extension().and_then(|s| s.to_str()) == Some("btree") {
if let Ok(relative) = path.strip_prefix(base_path) {
let relative_str = relative.to_string_lossy()
.replace('\\', "/")
.trim_end_matches(".btree")
.to_string();
results.push(relative_str);
}
}
}
Ok(())
}
#[tauri::command]
fn scan_directory(path: String, pattern: String) -> Result<Vec<String>, String> {
use glob::glob;
@@ -147,6 +209,8 @@ struct DirectoryEntry {
name: String,
path: String,
is_dir: bool,
size: Option<u64>,
modified: Option<u64>,
}
#[tauri::command]
@@ -172,10 +236,36 @@ fn list_directory(path: String) -> Result<Vec<DirectoryEntry>, String> {
Ok(entry) => {
let entry_path = entry.path();
if let Some(name) = entry_path.file_name() {
let is_dir = entry_path.is_dir();
// 获取文件元数据
let (size, modified) = match fs::metadata(&entry_path) {
Ok(metadata) => {
let size = if is_dir {
None
} else {
Some(metadata.len())
};
let modified = metadata.modified()
.ok()
.and_then(|time| {
time.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
});
(size, modified)
}
Err(_) => (None, None),
};
entries.push(DirectoryEntry {
name: name.to_string_lossy().to_string(),
path: entry_path.to_string_lossy().to_string(),
is_dir: entry_path.is_dir(),
is_dir,
size,
modified,
});
}
}
@@ -283,6 +373,133 @@ async fn get_profiler_status(
Ok(server_lock.is_some())
}
#[tauri::command]
async fn read_behavior_tree_file(file_path: String) -> Result<String, String> {
use std::fs;
// 使用 Rust 标准库直接读取文件,绕过 Tauri 的 scope 限制
fs::read_to_string(&file_path)
.map_err(|e| format!("Failed to read file {}: {}", file_path, e))
}
#[tauri::command]
async fn write_behavior_tree_file(file_path: String, content: String) -> Result<(), String> {
use std::fs;
// 使用 Rust 标准库直接写入文件
fs::write(&file_path, content)
.map_err(|e| format!("Failed to write file {}: {}", file_path, e))
}
#[tauri::command]
async fn write_binary_file(file_path: String, content: Vec<u8>) -> Result<(), String> {
use std::fs;
// 写入二进制文件
fs::write(&file_path, content)
.map_err(|e| format!("Failed to write binary file {}: {}", file_path, e))
}
#[tauri::command]
async fn read_global_blackboard(project_path: String) -> Result<String, String> {
use std::fs;
use std::path::Path;
let config_path = Path::new(&project_path).join(".ecs").join("global-blackboard.json");
if !config_path.exists() {
return Ok(String::from(r#"{"version":"1.0","variables":[]}"#));
}
fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read global blackboard: {}", e))
}
#[tauri::command]
async fn write_global_blackboard(project_path: String, content: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let ecs_dir = Path::new(&project_path).join(".ecs");
let config_path = ecs_dir.join("global-blackboard.json");
// 创建 .ecs 目录(如果不存在)
if !ecs_dir.exists() {
fs::create_dir_all(&ecs_dir)
.map_err(|e| format!("Failed to create .ecs directory: {}", e))?;
}
fs::write(&config_path, content)
.map_err(|e| format!("Failed to write global blackboard: {}", e))
}
#[tauri::command]
fn open_file_with_default_app(file_path: String) -> Result<(), String> {
use std::process::Command;
#[cfg(target_os = "windows")]
{
Command::new("cmd")
.args(["/C", "start", "", &file_path])
.spawn()
.map_err(|e| format!("Failed to open file: {}", e))?;
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.arg(&file_path)
.spawn()
.map_err(|e| format!("Failed to open file: {}", e))?;
}
#[cfg(target_os = "linux")]
{
Command::new("xdg-open")
.arg(&file_path)
.spawn()
.map_err(|e| format!("Failed to open file: {}", e))?;
}
Ok(())
}
#[tauri::command]
fn show_in_folder(file_path: String) -> Result<(), String> {
use std::process::Command;
#[cfg(target_os = "windows")]
{
Command::new("explorer")
.args(["/select,", &file_path])
.spawn()
.map_err(|e| format!("Failed to show in folder: {}", e))?;
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.args(["-R", &file_path])
.spawn()
.map_err(|e| format!("Failed to show in folder: {}", e))?;
}
#[cfg(target_os = "linux")]
{
use std::path::Path;
let path = Path::new(&file_path);
let parent = path.parent()
.ok_or_else(|| "Failed to get parent directory".to_string())?;
Command::new("xdg-open")
.arg(parent)
.spawn()
.map_err(|e| format!("Failed to show in folder: {}", e))?;
}
Ok(())
}
fn main() {
let project_paths: Arc<Mutex<HashMap<String, String>>> = Arc::new(Mutex::new(HashMap::new()));
let project_paths_clone = Arc::clone(&project_paths);
@@ -294,6 +511,7 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.register_uri_scheme_protocol("project", move |_app, request| {
let project_paths = Arc::clone(&project_paths_clone);
@@ -357,14 +575,23 @@ fn main() {
open_project_dialog,
save_scene_dialog,
open_scene_dialog,
open_behavior_tree_dialog,
scan_directory,
scan_behavior_trees,
read_file_content,
list_directory,
set_project_base_path,
toggle_devtools,
start_profiler_server,
stop_profiler_server,
get_profiler_status
get_profiler_status,
read_behavior_tree_file,
write_behavior_tree_file,
write_binary_file,
read_global_blackboard,
write_global_blackboard,
open_file_with_default_app,
show_in_folder
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -46,7 +46,8 @@
"decorations": true,
"transparent": false,
"center": true,
"skipTaskbar": false
"skipTaskbar": false,
"dragDropEnabled": false
}
],
"security": {
@@ -72,8 +73,22 @@
"updater:default",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install"
]
"updater:allow-install",
"fs:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-read-dir",
"fs:allow-exists"
],
"scope": {
"allow": [
"$HOME/**",
"$APPDATA/**",
"$DESKTOP/**",
"$DOCUMENT/**",
"$DOWNLOAD/**"
]
}
}
]
}
@@ -82,6 +97,9 @@
"shell": {
"open": true
},
"fs": {
"requireLiteralLeadingDot": false
},
"updater": {
"active": true,
"endpoints": [

View File

@@ -1,9 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Core, Scene } from '@esengine/ecs-framework';
import * as ECSFramework from '@esengine/ecs-framework';
import { EditorPluginManager, UIRegistry, MessageHub, SerializerRegistry, EntityStoreService, ComponentRegistry, LocaleService, ProjectService, ComponentDiscoveryService, PropertyMetadataService, LogService, SettingsRegistry, SceneManagerService } from '@esengine/editor-core';
import { GlobalBlackboardService } from '@esengine/behavior-tree';
import { SceneInspectorPlugin } from './plugins/SceneInspectorPlugin';
import { ProfilerPlugin } from './plugins/ProfilerPlugin';
import { EditorAppearancePlugin } from './plugins/EditorAppearancePlugin';
import { BehaviorTreePlugin } from './plugins/BehaviorTreePlugin';
import { StartupPage } from './components/StartupPage';
import { SceneHierarchy } from './components/SceneHierarchy';
import { EntityInspector } from './components/EntityInspector';
@@ -16,9 +19,11 @@ import { SettingsWindow } from './components/SettingsWindow';
import { AboutDialog } from './components/AboutDialog';
import { ErrorDialog } from './components/ErrorDialog';
import { ConfirmDialog } from './components/ConfirmDialog';
import { BehaviorTreeWindow } from './components/BehaviorTreeWindow';
import { ToastProvider } from './components/Toast';
import { Viewport } from './components/Viewport';
import { MenuBar } from './components/MenuBar';
import { DockContainer, DockablePanel } from './components/DockContainer';
import { FlexLayoutDockContainer, FlexDockPanel } from './components/FlexLayoutDockContainer';
import { TauriAPI } from './api/tauri';
import { TauriFileAPI } from './adapters/TauriFileAPI';
import { SettingsService } from './services/SettingsService';
@@ -35,6 +40,9 @@ localeService.registerTranslations('en', en);
localeService.registerTranslations('zh', zh);
Core.services.registerInstance(LocaleService, localeService);
// 注册全局黑板服务
Core.services.registerSingleton(GlobalBlackboardService);
function App() {
const initRef = useRef(false);
const [initialized, setInitialized] = useState(false);
@@ -51,12 +59,14 @@ function App() {
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
const { t, locale, changeLocale } = useLocale();
const [status, setStatus] = useState(t('header.status.initializing'));
const [panels, setPanels] = useState<DockablePanel[]>([]);
const [panels, setPanels] = useState<FlexDockPanel[]>([]);
const [showPluginManager, setShowPluginManager] = useState(false);
const [showProfiler, setShowProfiler] = useState(false);
const [showPortManager, setShowPortManager] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showAbout, setShowAbout] = useState(false);
const [showBehaviorTreeEditor, setShowBehaviorTreeEditor] = useState(false);
const [behaviorTreeFilePath, setBehaviorTreeFilePath] = useState<string | null>(null);
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
const [isProfilerMode, setIsProfilerMode] = useState(false);
@@ -137,7 +147,7 @@ function App() {
initRef.current = true;
try {
(window as any).__ECS_FRAMEWORK__ = await import('@esengine/ecs-framework');
(window as any).__ECS_FRAMEWORK__ = ECSFramework;
const editorScene = new Scene();
Core.setScene(editorScene);
@@ -180,12 +190,15 @@ function App() {
await pluginMgr.installEditor(new SceneInspectorPlugin());
await pluginMgr.installEditor(new ProfilerPlugin());
await pluginMgr.installEditor(new EditorAppearancePlugin());
await pluginMgr.installEditor(new BehaviorTreePlugin());
messageHub.subscribe('ui:openWindow', (data: any) => {
if (data.windowId === 'profiler') {
setShowProfiler(true);
} else if (data.windowId === 'pluginManager') {
setShowPluginManager(true);
} else if (data.windowId === 'behavior-tree-editor') {
setShowBehaviorTreeEditor(true);
}
});
@@ -420,6 +433,11 @@ function App() {
}
}, [sceneManager, locale]);
const handleOpenBehaviorTree = useCallback((btreePath: string) => {
setBehaviorTreeFilePath(btreePath);
setShowBehaviorTreeEditor(true);
}, []);
const handleSaveScene = async () => {
if (!sceneManager) {
console.error('SceneManagerService not available');
@@ -498,28 +516,25 @@ function App() {
useEffect(() => {
if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
let corePanels: DockablePanel[];
let corePanels: FlexDockPanel[];
if (isProfilerMode) {
corePanels = [
{
id: 'scene-hierarchy',
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
position: 'left',
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />,
closable: false
},
{
id: 'inspector',
title: locale === 'zh' ? '检视器' : 'Inspector',
position: 'right',
content: <EntityInspector entityStore={entityStore} messageHub={messageHub} />,
closable: false
},
{
id: 'console',
title: locale === 'zh' ? '控制台' : 'Console',
position: 'bottom',
content: <ConsolePanel logService={logService} />,
closable: false
}
@@ -529,35 +544,24 @@ function App() {
{
id: 'scene-hierarchy',
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
position: 'left',
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />,
closable: false
},
{
id: 'inspector',
title: locale === 'zh' ? '检视器' : 'Inspector',
position: 'right',
content: <EntityInspector entityStore={entityStore} messageHub={messageHub} />,
closable: false
},
{
id: 'viewport',
title: locale === 'zh' ? '视口' : 'Viewport',
position: 'center',
content: <Viewport locale={locale} />,
closable: false
},
{
id: 'assets',
title: locale === 'zh' ? '资产' : 'Assets',
position: 'bottom',
content: <AssetBrowser projectPath={currentProjectPath} locale={locale} onOpenScene={handleOpenSceneByPath} />,
content: <AssetBrowser projectPath={currentProjectPath} locale={locale} onOpenScene={handleOpenSceneByPath} onOpenBehaviorTree={handleOpenBehaviorTree} />,
closable: false
},
{
id: 'console',
title: locale === 'zh' ? '控制台' : 'Console',
position: 'bottom',
content: <ConsolePanel logService={logService} />,
closable: false
}
@@ -568,7 +572,7 @@ function App() {
.filter(p => p.enabled)
.map(p => p.name);
const pluginPanels: DockablePanel[] = uiRegistry.getAllPanels()
const pluginPanels: FlexDockPanel[] = uiRegistry.getAllPanels()
.filter(panelDesc => {
if (!panelDesc.component) {
return false;
@@ -587,7 +591,6 @@ function App() {
return {
id: panelDesc.id,
title: (panelDesc as any).titleZh && locale === 'zh' ? (panelDesc as any).titleZh : panelDesc.title,
position: panelDesc.position as any,
content: <Component />,
closable: panelDesc.closable ?? true
};
@@ -596,15 +599,8 @@ function App() {
console.log('[App] Loading plugin panels:', pluginPanels);
setPanels([...corePanels, ...pluginPanels]);
}
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, isProfilerMode, handleOpenSceneByPath]);
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, isProfilerMode, handleOpenSceneByPath, handleOpenBehaviorTree]);
const handlePanelMove = (panelId: string, newPosition: any) => {
setPanels(prevPanels =>
prevPanels.map(panel =>
panel.id === panelId ? { ...panel, position: newPosition } : panel
)
);
};
if (!initialized) {
return (
@@ -689,7 +685,7 @@ function App() {
</div>
<div className="editor-content">
<DockContainer panels={panels} onPanelMove={handlePanelMove} />
<FlexLayoutDockContainer panels={panels} />
</div>
<div className="editor-footer">
@@ -721,6 +717,18 @@ function App() {
<AboutDialog onClose={() => setShowAbout(false)} locale={locale} />
)}
{showBehaviorTreeEditor && (
<BehaviorTreeWindow
isOpen={showBehaviorTreeEditor}
onClose={() => {
setShowBehaviorTreeEditor(false);
setBehaviorTreeFilePath(null);
}}
filePath={behaviorTreeFilePath}
projectPath={currentProjectPath}
/>
)}
{errorDialog && (
<ErrorDialog
title={errorDialog.title}
@@ -732,4 +740,12 @@ function App() {
);
}
export default App;
function AppWithToast() {
return (
<ToastProvider>
<App />
</ToastProvider>
);
}
export default AppWithToast;

View File

@@ -113,12 +113,47 @@ export class TauriAPI {
static async pathExists(path: string): Promise<boolean> {
return await invoke<boolean>('path_exists', { path });
}
/**
* 使用系统默认程序打开文件
* @param path 文件路径
*/
static async openFileWithSystemApp(path: string): Promise<void> {
await invoke('open_file_with_default_app', { filePath: path });
}
/**
* 在文件管理器中显示文件
* @param path 文件路径
*/
static async showInFolder(path: string): Promise<void> {
await invoke('show_in_folder', { filePath: path });
}
/**
* 打开行为树文件选择对话框
* @returns 用户选择的文件路径,取消则返回 null
*/
static async openBehaviorTreeDialog(): Promise<string | null> {
return await invoke<string | null>('open_behavior_tree_dialog');
}
/**
* 扫描项目中的所有行为树文件
* @param projectPath 项目路径
* @returns 行为树资产ID列表相对于 .ecs/behaviors 的路径,不含扩展名)
*/
static async scanBehaviorTrees(projectPath: string): Promise<string[]> {
return await invoke<string[]>('scan_behavior_trees', { projectPath });
}
}
export interface DirectoryEntry {
name: string;
path: string;
is_dir: boolean;
size?: number;
modified?: number;
}
/**

View File

@@ -1,9 +1,11 @@
import { useState, useEffect } from 'react';
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3 } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { FileTree } from './FileTree';
import { ResizablePanel } from './ResizablePanel';
import { ContextMenu, ContextMenuItem } from './ContextMenu';
import '../styles/AssetBrowser.css';
interface AssetItem {
@@ -17,39 +19,38 @@ interface AssetBrowserProps {
projectPath: string | null;
locale: string;
onOpenScene?: (scenePath: string) => void;
onOpenBehaviorTree?: (btreePath: string) => void;
}
type ViewMode = 'tree-split' | 'tree-only';
export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserProps) {
const [viewMode, setViewMode] = useState<ViewMode>('tree-split');
export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorTree }: AssetBrowserProps) {
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [assets, setAssets] = useState<AssetItem[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
const [contextMenu, setContextMenu] = useState<{
position: { x: number; y: number };
asset: AssetItem;
} | null>(null);
const translations = {
en: {
title: 'Assets',
title: 'Content Browser',
noProject: 'No project loaded',
loading: 'Loading...',
empty: 'No assets found',
search: 'Search...',
viewTreeSplit: 'Tree + List',
viewTreeOnly: 'Tree Only',
name: 'Name',
type: 'Type',
file: 'File',
folder: 'Folder'
},
zh: {
title: '资产',
title: '内容浏览器',
noProject: '没有加载项目',
loading: '加载中...',
empty: '没有找到资产',
search: '搜索...',
viewTreeSplit: '树形+列表',
viewTreeOnly: '纯树形',
name: '名称',
type: '类型',
file: '文件',
@@ -61,14 +62,14 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
useEffect(() => {
if (projectPath) {
if (viewMode === 'tree-split') {
loadAssets(projectPath);
}
setCurrentPath(projectPath);
loadAssets(projectPath);
} else {
setAssets([]);
setCurrentPath(null);
setSelectedPath(null);
}
}, [projectPath, viewMode]);
}, [projectPath]);
// Listen for asset reveal requests
useEffect(() => {
@@ -79,19 +80,17 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
const filePath = data.path;
if (filePath) {
setSelectedPath(filePath);
if (viewMode === 'tree-split') {
const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : null;
if (dirPath) {
loadAssets(dirPath);
}
const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : null;
if (dirPath) {
setCurrentPath(dirPath);
loadAssets(dirPath);
}
}
});
return () => unsubscribe();
}, [viewMode]);
}, []);
const loadAssets = async (path: string) => {
setLoading(true);
@@ -110,7 +109,10 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
};
});
setAssets(assetItems);
setAssets(assetItems.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
}));
} catch (error) {
console.error('Failed to load assets:', error);
setAssets([]);
@@ -119,69 +121,154 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
}
};
const handleTreeSelect = (path: string) => {
setSelectedPath(path);
if (viewMode === 'tree-split') {
loadAssets(path);
}
const handleFolderSelect = (path: string) => {
setCurrentPath(path);
loadAssets(path);
};
const handleAssetClick = (asset: AssetItem) => {
setSelectedPath(asset.path);
};
const handleAssetDoubleClick = (asset: AssetItem) => {
if (asset.type === 'file' && asset.extension === 'ecs') {
if (onOpenScene) {
const handleAssetDoubleClick = async (asset: AssetItem) => {
if (asset.type === 'folder') {
setCurrentPath(asset.path);
loadAssets(asset.path);
} else if (asset.type === 'file') {
if (asset.extension === 'ecs' && onOpenScene) {
onOpenScene(asset.path);
} else if (asset.extension === 'btree' && onOpenBehaviorTree) {
onOpenBehaviorTree(asset.path);
} else {
// 其他文件使用系统默认程序打开
try {
await TauriAPI.openFileWithSystemApp(asset.path);
} catch (error) {
console.error('Failed to open file:', error);
}
}
}
};
const handleContextMenu = (e: React.MouseEvent, asset: AssetItem) => {
e.preventDefault();
setContextMenu({
position: { x: e.clientX, y: e.clientY },
asset
});
};
const getContextMenuItems = (asset: AssetItem): ContextMenuItem[] => {
const items: ContextMenuItem[] = [];
// 打开
if (asset.type === 'file') {
items.push({
label: locale === 'zh' ? '打开' : 'Open',
icon: <File size={16} />,
onClick: () => handleAssetDoubleClick(asset)
});
}
// 在文件管理器中显示
items.push({
label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer',
icon: <FolderOpen size={16} />,
onClick: async () => {
try {
await TauriAPI.showInFolder(asset.path);
} catch (error) {
console.error('Failed to show in folder:', error);
}
}
});
items.push({ label: '', separator: true, onClick: () => {} });
// 复制路径
items.push({
label: locale === 'zh' ? '复制路径' : 'Copy Path',
icon: <Copy size={16} />,
onClick: () => {
navigator.clipboard.writeText(asset.path);
}
});
items.push({ label: '', separator: true, onClick: () => {} });
// 重命名
items.push({
label: locale === 'zh' ? '重命名' : 'Rename',
icon: <Edit3 size={16} />,
onClick: () => {
// TODO: 实现重命名功能
console.log('Rename:', asset.path);
},
disabled: true
});
// 删除
items.push({
label: locale === 'zh' ? '删除' : 'Delete',
icon: <Trash2 size={16} />,
onClick: () => {
// TODO: 实现删除功能
console.log('Delete:', asset.path);
},
disabled: true
});
return items;
};
const getBreadcrumbs = () => {
if (!currentPath || !projectPath) return [];
const relative = currentPath.replace(projectPath, '');
const parts = relative.split(/[/\\]/).filter(p => p);
const crumbs = [{ name: 'Content', path: projectPath }];
let accPath = projectPath;
for (const part of parts) {
accPath = `${accPath}${accPath.endsWith('\\') || accPath.endsWith('/') ? '' : '/'}${part}`;
crumbs.push({ name: part, path: accPath });
}
return crumbs;
};
const filteredAssets = searchQuery
? assets.filter(asset =>
asset.type === 'file' && asset.name.toLowerCase().includes(searchQuery.toLowerCase())
asset.name.toLowerCase().includes(searchQuery.toLowerCase())
)
: assets.filter(asset => asset.type === 'file');
: assets;
const getFileIcon = (extension?: string) => {
switch (extension?.toLowerCase()) {
const getFileIcon = (asset: AssetItem) => {
if (asset.type === 'folder') {
return <Folder className="asset-icon" style={{ color: '#ffa726' }} size={20} />;
}
const ext = asset.extension?.toLowerCase();
switch (ext) {
case 'ecs':
return <File className="asset-icon" style={{ color: '#66bb6a' }} size={20} />;
case 'btree':
return <FileText className="asset-icon" style={{ color: '#ab47bc' }} size={20} />;
case 'ts':
case 'tsx':
case 'js':
case 'jsx':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon">
<path d="M14 2H6C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V8L14 2Z" strokeWidth="2"/>
<path d="M14 2V8H20" strokeWidth="2"/>
<path d="M12 18L12 14M12 10L12 12" strokeWidth="2" strokeLinecap="round"/>
</svg>
);
return <FileCode className="asset-icon" style={{ color: '#42a5f5' }} size={20} />;
case 'json':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon">
<path d="M14 2H6C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V8L14 2Z" strokeWidth="2"/>
<path d="M14 2V8H20" strokeWidth="2"/>
</svg>
);
return <FileJson className="asset-icon" style={{ color: '#ffa726' }} size={20} />;
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon">
<rect x="3" y="3" width="18" height="18" rx="2" strokeWidth="2"/>
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor"/>
<path d="M21 15L16 10L5 21" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
return <FileImage className="asset-icon" style={{ color: '#ec407a' }} size={20} />;
default:
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon">
<path d="M14 2H6C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V8L14 2Z" strokeWidth="2"/>
<path d="M14 2V8H20" strokeWidth="2"/>
</svg>
);
return <File className="asset-icon" size={20} />;
}
};
@@ -198,114 +285,96 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
);
}
const renderListView = () => (
<div className="asset-browser-list">
<div className="asset-browser-toolbar">
<input
type="text"
className="asset-search"
placeholder={t.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{loading ? (
<div className="asset-browser-loading">
<p>{t.loading}</p>
</div>
) : filteredAssets.length === 0 ? (
<div className="asset-browser-empty">
<p>{t.empty}</p>
</div>
) : (
<div className="asset-list">
{filteredAssets.map((asset, index) => (
<div
key={index}
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
onClick={() => handleAssetClick(asset)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
>
{getFileIcon(asset.extension)}
<div className="asset-name" title={asset.name}>
{asset.name}
</div>
<div className="asset-type">
{asset.extension || t.file}
</div>
</div>
))}
</div>
)}
</div>
);
const breadcrumbs = getBreadcrumbs();
return (
<div className="asset-browser">
<div className="asset-browser-header">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<h3 style={{ margin: 0 }}>{t.title}</h3>
<div className="view-mode-buttons">
<button
className={`view-mode-btn ${viewMode === 'tree-split' ? 'active' : ''}`}
onClick={() => setViewMode('tree-split')}
title={t.viewTreeSplit}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="18"/>
<rect x="14" y="3" width="7" height="18"/>
</svg>
</button>
<button
className={`view-mode-btn ${viewMode === 'tree-only' ? 'active' : ''}`}
onClick={() => setViewMode('tree-only')}
title={t.viewTreeOnly}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18"/>
</svg>
</button>
</div>
</div>
<h3>{t.title}</h3>
</div>
<div className="asset-browser-content">
{viewMode === 'tree-only' ? (
<div className="asset-browser-tree-only">
<div className="asset-browser-toolbar">
<input
type="text"
className="asset-search"
placeholder={t.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
<ResizablePanel
direction="horizontal"
defaultSize={200}
minSize={150}
maxSize={400}
leftOrTop={
<div className="asset-browser-tree">
<FileTree
rootPath={projectPath}
onSelectFile={handleFolderSelect}
selectedPath={currentPath}
/>
</div>
<FileTree
rootPath={projectPath}
onSelectFile={handleTreeSelect}
selectedPath={selectedPath}
/>
</div>
) : (
<ResizablePanel
direction="horizontal"
defaultSize={200}
minSize={150}
maxSize={400}
leftOrTop={
<div className="asset-browser-tree">
<FileTree
rootPath={projectPath}
onSelectFile={handleTreeSelect}
selectedPath={selectedPath}
}
rightOrBottom={
<div className="asset-browser-list">
<div className="asset-browser-breadcrumb">
{breadcrumbs.map((crumb, index) => (
<span key={crumb.path}>
<span
className="breadcrumb-item"
onClick={() => {
setCurrentPath(crumb.path);
loadAssets(crumb.path);
}}
>
{crumb.name}
</span>
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
</span>
))}
</div>
<div className="asset-browser-toolbar">
<input
type="text"
className="asset-search"
placeholder={t.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
}
rightOrBottom={renderListView()}
/>
)}
{loading ? (
<div className="asset-browser-loading">
<p>{t.loading}</p>
</div>
) : filteredAssets.length === 0 ? (
<div className="asset-browser-empty">
<p>{t.empty}</p>
</div>
) : (
<div className="asset-list">
{filteredAssets.map((asset, index) => (
<div
key={index}
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
onClick={() => handleAssetClick(asset)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)}
>
{getFileIcon(asset)}
<div className="asset-name" title={asset.name}>
{asset.name}
</div>
<div className="asset-type">
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
</div>
</div>
))}
</div>
)}
</div>
}
/>
</div>
{contextMenu && (
<ContextMenu
items={getContextMenuItems(contextMenu.asset)}
position={contextMenu.position}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,141 @@
import { useState, useEffect } from 'react';
import { RefreshCw, Folder } from 'lucide-react';
import { TauriAPI } from '../api/tauri';
interface AssetPickerProps {
value: string;
onChange: (value: string) => void;
projectPath: string | null;
filter?: 'btree' | 'ecs';
label?: string;
}
/**
* 资产选择器组件
* 用于选择项目中的资产文件
*/
export function AssetPicker({ value, onChange, projectPath, filter = 'btree', label }: AssetPickerProps) {
const [assets, setAssets] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (projectPath) {
loadAssets();
}
}, [projectPath]);
const loadAssets = async () => {
if (!projectPath) return;
setLoading(true);
try {
if (filter === 'btree') {
const btrees = await TauriAPI.scanBehaviorTrees(projectPath);
setAssets(btrees);
}
} catch (error) {
console.error('Failed to load assets:', error);
setAssets([]);
} finally {
setLoading(false);
}
};
const handleBrowse = async () => {
try {
if (filter === 'btree') {
const path = await TauriAPI.openBehaviorTreeDialog();
if (path && projectPath) {
const behaviorsPath = `${projectPath}\\.ecs\\behaviors\\`.replace(/\\/g, '\\\\');
const relativePath = path.replace(behaviorsPath, '')
.replace(/\\/g, '/')
.replace('.btree', '');
onChange(relativePath);
await loadAssets();
}
}
} catch (error) {
console.error('Failed to browse asset:', error);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{label && (
<label style={{ fontSize: '11px', color: '#aaa', fontWeight: '500' }}>
{label}
</label>
)}
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={loading || !projectPath}
style={{
flex: 1,
padding: '4px 8px',
backgroundColor: '#1e1e1e',
border: '1px solid #3e3e42',
borderRadius: '3px',
color: '#cccccc',
fontSize: '12px',
cursor: loading || !projectPath ? 'not-allowed' : 'pointer'
}}
>
<option value="">{loading ? '加载中...' : '选择资产...'}</option>
{assets.map(asset => (
<option key={asset} value={asset}>
{asset}
</option>
))}
</select>
<button
onClick={loadAssets}
disabled={loading || !projectPath}
style={{
padding: '4px 8px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: loading || !projectPath ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
opacity: loading || !projectPath ? 0.5 : 1
}}
title="刷新资产列表"
>
<RefreshCw size={14} />
</button>
<button
onClick={handleBrowse}
disabled={loading || !projectPath}
style={{
padding: '4px 8px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: loading || !projectPath ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
opacity: loading || !projectPath ? 0.5 : 1
}}
title="浏览文件..."
>
<Folder size={14} />
</button>
</div>
{!projectPath && (
<div style={{ fontSize: '10px', color: '#ff6b6b', marginTop: '2px' }}>
</div>
)}
{value && assets.length > 0 && !assets.includes(value) && (
<div style={{ fontSize: '10px', color: '#ffa726', marginTop: '2px' }}>
警告: 资产 "{value}"
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,339 @@
import { useState, useEffect } from 'react';
import { X, Folder, File, Search, ArrowLeft, Grid, List, FileCode } from 'lucide-react';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import '../styles/AssetPickerDialog.css';
interface AssetPickerDialogProps {
projectPath: string;
fileExtension: string;
onSelect: (assetId: string) => void;
onClose: () => void;
locale: string;
/** 资产基础路径(相对于项目根目录),用于计算 assetId */
assetBasePath?: string;
}
interface AssetItem {
name: string;
path: string;
isDir: boolean;
extension?: string;
size?: number;
modified?: number;
}
type ViewMode = 'list' | 'grid';
export function AssetPickerDialog({ projectPath, fileExtension, onSelect, onClose, locale, assetBasePath }: AssetPickerDialogProps) {
// 计算实际的资产目录路径
const actualAssetPath = assetBasePath
? `${projectPath}/${assetBasePath}`.replace(/\\/g, '/').replace(/\/+/g, '/')
: projectPath;
const [currentPath, setCurrentPath] = useState(actualAssetPath);
const [assets, setAssets] = useState<AssetItem[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('list');
const translations = {
en: {
title: 'Select Asset',
loading: 'Loading...',
empty: 'No assets found',
select: 'Select',
cancel: 'Cancel',
search: 'Search...',
back: 'Back',
listView: 'List View',
gridView: 'Grid View'
},
zh: {
title: '选择资产',
loading: '加载中...',
empty: '没有找到资产',
select: '选择',
cancel: '取消',
search: '搜索...',
back: '返回上级',
listView: '列表视图',
gridView: '网格视图'
}
};
const t = translations[locale as keyof typeof translations] || translations.en;
useEffect(() => {
loadAssets(currentPath);
}, [currentPath]);
const loadAssets = async (path: string) => {
setLoading(true);
try {
const entries = await TauriAPI.listDirectory(path);
const assetItems: AssetItem[] = entries
.map((entry: DirectoryEntry) => {
const extension = entry.is_dir ? undefined :
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
return {
name: entry.name,
path: entry.path,
isDir: entry.is_dir,
extension,
size: entry.size,
modified: entry.modified
};
})
.filter(item => item.isDir || item.extension === fileExtension)
.sort((a, b) => {
if (a.isDir === b.isDir) return a.name.localeCompare(b.name);
return a.isDir ? -1 : 1;
});
setAssets(assetItems);
} catch (error) {
console.error('Failed to load assets:', error);
setAssets([]);
} finally {
setLoading(false);
}
};
// 过滤搜索结果
const filteredAssets = assets.filter(item =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// 格式化文件大小
const formatFileSize = (bytes?: number): string => {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// 格式化修改时间
const formatDate = (timestamp?: number): string => {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
// 返回上级目录
const handleGoBack = () => {
const parentPath = currentPath.split(/[/\\]/).slice(0, -1).join('/');
const minPath = actualAssetPath.replace(/[/\\]$/, '');
if (parentPath && parentPath !== minPath) {
setCurrentPath(parentPath);
} else if (currentPath !== actualAssetPath) {
setCurrentPath(actualAssetPath);
}
};
// 只能返回到资产基础目录,不能再往上
const canGoBack = currentPath !== actualAssetPath;
const handleItemClick = (item: AssetItem) => {
if (item.isDir) {
setCurrentPath(item.path);
} else {
setSelectedPath(item.path);
}
};
const handleItemDoubleClick = (item: AssetItem) => {
if (!item.isDir) {
const assetId = calculateAssetId(item.path);
onSelect(assetId);
}
};
const handleSelect = () => {
if (selectedPath) {
const assetId = calculateAssetId(selectedPath);
onSelect(assetId);
}
};
/**
* 计算资产ID
* 将绝对路径转换为相对于资产基础目录的assetId不含扩展名
*/
const calculateAssetId = (absolutePath: string): string => {
const normalized = absolutePath.replace(/\\/g, '/');
const baseNormalized = actualAssetPath.replace(/\\/g, '/');
// 获取相对于资产基础目录的路径
let relativePath = normalized;
if (normalized.startsWith(baseNormalized)) {
relativePath = normalized.substring(baseNormalized.length);
}
// 移除开头的斜杠
relativePath = relativePath.replace(/^\/+/, '');
// 移除文件扩展名
const assetId = relativePath.replace(new RegExp(`\\.${fileExtension}$`), '');
return assetId;
};
const getBreadcrumbs = () => {
const basePathNormalized = actualAssetPath.replace(/\\/g, '/');
const currentPathNormalized = currentPath.replace(/\\/g, '/');
const relative = currentPathNormalized.replace(basePathNormalized, '');
const parts = relative.split('/').filter(p => p);
// 根路径名称(显示"行为树"或"Assets"
const rootName = assetBasePath
? assetBasePath.split('/').pop() || 'Assets'
: 'Content';
const crumbs = [{ name: rootName, path: actualAssetPath }];
let accPath = actualAssetPath;
for (const part of parts) {
accPath = `${accPath}/${part}`;
crumbs.push({ name: part, path: accPath });
}
return crumbs;
};
const breadcrumbs = getBreadcrumbs();
return (
<div className="asset-picker-overlay" onClick={onClose}>
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
<div className="asset-picker-header">
<h3>{t.title}</h3>
<button className="asset-picker-close" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="asset-picker-toolbar">
<button
className="toolbar-button"
onClick={handleGoBack}
disabled={!canGoBack}
title={t.back}
>
<ArrowLeft size={16} />
</button>
<div className="asset-picker-breadcrumb">
{breadcrumbs.map((crumb, index) => (
<span key={crumb.path}>
<span
className="breadcrumb-item"
onClick={() => setCurrentPath(crumb.path)}
>
{crumb.name}
</span>
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
</span>
))}
</div>
<div className="view-mode-buttons">
<button
className={`toolbar-button ${viewMode === 'list' ? 'active' : ''}`}
onClick={() => setViewMode('list')}
title={t.listView}
>
<List size={16} />
</button>
<button
className={`toolbar-button ${viewMode === 'grid' ? 'active' : ''}`}
onClick={() => setViewMode('grid')}
title={t.gridView}
>
<Grid size={16} />
</button>
</div>
</div>
<div className="asset-picker-search">
<Search size={16} className="search-icon" />
<input
type="text"
placeholder={t.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input"
/>
{searchQuery && (
<button
className="search-clear"
onClick={() => setSearchQuery('')}
>
<X size={14} />
</button>
)}
</div>
<div className="asset-picker-content">
{loading ? (
<div className="asset-picker-loading">{t.loading}</div>
) : filteredAssets.length === 0 ? (
<div className="asset-picker-empty">{t.empty}</div>
) : (
<div className={`asset-picker-list ${viewMode}`}>
{filteredAssets.map((item, index) => (
<div
key={index}
className={`asset-picker-item ${selectedPath === item.path ? 'selected' : ''}`}
onClick={() => handleItemClick(item)}
onDoubleClick={() => handleItemDoubleClick(item)}
>
<div className="asset-icon">
{item.isDir ? (
<Folder size={viewMode === 'grid' ? 32 : 18} style={{ color: '#ffa726' }} />
) : (
<FileCode size={viewMode === 'grid' ? 32 : 18} style={{ color: '#66bb6a' }} />
)}
</div>
<div className="asset-info">
<span className="asset-name">{item.name}</span>
{viewMode === 'list' && !item.isDir && (
<div className="asset-meta">
{item.size && <span className="asset-size">{formatFileSize(item.size)}</span>}
{item.modified && <span className="asset-date">{formatDate(item.modified)}</span>}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
<div className="asset-picker-footer">
<div className="footer-info">
{filteredAssets.length} {locale === 'zh' ? '项' : 'items'}
</div>
<div className="footer-buttons">
<button className="asset-picker-cancel" onClick={onClose}>
{t.cancel}
</button>
<button
className="asset-picker-select"
onClick={handleSelect}
disabled={!selectedPath}
>
{t.select}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,831 @@
import { useState } from 'react';
import { Clipboard, Edit2, Trash2, ChevronDown, ChevronRight, Globe, Save, Folder, FileCode } from 'lucide-react';
import { save } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { Core } from '@esengine/ecs-framework';
import type { BlackboardValueType } from '@esengine/behavior-tree';
import { GlobalBlackboardService } from '@esengine/behavior-tree';
import { GlobalBlackboardTypeGenerator } from '../generators/GlobalBlackboardTypeGenerator';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('BehaviorTreeBlackboard');
type SimpleBlackboardType = 'number' | 'string' | 'boolean' | 'object';
interface BlackboardVariable {
key: string;
value: any;
type: SimpleBlackboardType;
}
interface BehaviorTreeBlackboardProps {
variables: Record<string, any>;
initialVariables?: Record<string, any>;
globalVariables?: Record<string, any>;
onVariableChange: (key: string, value: any) => void;
onVariableAdd: (key: string, value: any, type: SimpleBlackboardType) => void;
onVariableDelete: (key: string) => void;
onVariableRename?: (oldKey: string, newKey: string) => void;
onGlobalVariableChange?: (key: string, value: any) => void;
onGlobalVariableAdd?: (key: string, value: any, type: BlackboardValueType) => void;
onGlobalVariableDelete?: (key: string) => void;
projectPath?: string;
hasUnsavedGlobalChanges?: boolean;
onSaveGlobal?: () => void;
}
/**
* 行为树黑板变量面板
*
* 用于管理和调试行为树运行时的黑板变量
*/
export const BehaviorTreeBlackboard: React.FC<BehaviorTreeBlackboardProps> = ({
variables,
initialVariables,
globalVariables,
onVariableChange,
onVariableAdd,
onVariableDelete,
onVariableRename,
onGlobalVariableChange,
onGlobalVariableAdd,
onGlobalVariableDelete,
projectPath,
hasUnsavedGlobalChanges,
onSaveGlobal
}) => {
const [viewMode, setViewMode] = useState<'local' | 'global'>('local');
const isModified = (key: string): boolean => {
if (!initialVariables) return false;
return JSON.stringify(variables[key]) !== JSON.stringify(initialVariables[key]);
};
const handleExportTypeScript = async () => {
try {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
const config = globalBlackboard.exportConfig();
const tsCode = GlobalBlackboardTypeGenerator.generate(config);
const outputPath = await save({
filters: [{
name: 'TypeScript',
extensions: ['ts']
}],
defaultPath: 'GlobalBlackboard.ts'
});
if (outputPath) {
await invoke('write_file_content', {
path: outputPath,
content: tsCode
});
logger.info('TypeScript 类型定义已导出', outputPath);
}
} catch (error) {
logger.error('导出 TypeScript 失败', error);
}
};
const [isAdding, setIsAdding] = useState(false);
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const [newType, setNewType] = useState<BlackboardVariable['type']>('string');
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editingNewKey, setEditingNewKey] = useState('');
const [editValue, setEditValue] = useState('');
const [editType, setEditType] = useState<BlackboardVariable['type']>('string');
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const handleAddVariable = () => {
if (!newKey.trim()) return;
let parsedValue: any = newValue;
if (newType === 'number') {
parsedValue = parseFloat(newValue) || 0;
} else if (newType === 'boolean') {
parsedValue = newValue === 'true';
} else if (newType === 'object') {
try {
parsedValue = JSON.parse(newValue);
} catch {
parsedValue = {};
}
}
if (viewMode === 'global' && onGlobalVariableAdd) {
const globalType = newType as BlackboardValueType;
onGlobalVariableAdd(newKey, parsedValue, globalType);
} else {
onVariableAdd(newKey, parsedValue, newType);
}
setNewKey('');
setNewValue('');
setIsAdding(false);
};
const handleStartEdit = (key: string, value: any) => {
setEditingKey(key);
setEditingNewKey(key);
const currentType = getVariableType(value);
setEditType(currentType);
setEditValue(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
};
const handleSaveEdit = (key: string) => {
const newKey = editingNewKey.trim();
if (!newKey) return;
let parsedValue: any = editValue;
if (editType === 'number') {
parsedValue = parseFloat(editValue) || 0;
} else if (editType === 'boolean') {
parsedValue = editValue === 'true' || editValue === '1';
} else if (editType === 'object') {
try {
parsedValue = JSON.parse(editValue);
} catch {
return;
}
}
if (viewMode === 'global' && onGlobalVariableChange) {
if (newKey !== key && onGlobalVariableDelete) {
onGlobalVariableDelete(key);
}
onGlobalVariableChange(newKey, parsedValue);
} else {
if (newKey !== key && onVariableRename) {
onVariableRename(key, newKey);
}
onVariableChange(newKey, parsedValue);
}
setEditingKey(null);
};
const toggleGroup = (groupName: string) => {
setCollapsedGroups(prev => {
const newSet = new Set(prev);
if (newSet.has(groupName)) {
newSet.delete(groupName);
} else {
newSet.add(groupName);
}
return newSet;
});
};
const getVariableType = (value: any): BlackboardVariable['type'] => {
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'object') return 'object';
return 'string';
};
const currentVariables = viewMode === 'global' ? (globalVariables || {}) : variables;
const variableEntries = Object.entries(currentVariables);
const currentOnDelete = viewMode === 'global' ? onGlobalVariableDelete : onVariableDelete;
const groupedVariables: Record<string, Array<{ fullKey: string; varName: string; value: any }>> = variableEntries.reduce((groups, [key, value]) => {
const parts = key.split('.');
const groupName = (parts.length > 1 && parts[0]) ? parts[0] : 'default';
const varName = parts.length > 1 ? parts.slice(1).join('.') : key;
if (!groups[groupName]) {
groups[groupName] = [];
}
const group = groups[groupName];
if (group) {
group.push({ fullKey: key, varName, value });
}
return groups;
}, {} as Record<string, Array<{ fullKey: string; varName: string; value: any }>>);
const groupNames = Object.keys(groupedVariables).sort((a, b) => {
if (a === 'default') return 1;
if (b === 'default') return -1;
return a.localeCompare(b);
});
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#1e1e1e',
color: '#cccccc'
}}>
<style>{`
.blackboard-list::-webkit-scrollbar {
width: 8px;
}
.blackboard-list::-webkit-scrollbar-track {
background: #1e1e1e;
}
.blackboard-list::-webkit-scrollbar-thumb {
background: #3c3c3c;
border-radius: 4px;
}
.blackboard-list::-webkit-scrollbar-thumb:hover {
background: #4c4c4c;
}
`}</style>
{/* 标题栏 */}
<div style={{
backgroundColor: '#2d2d2d',
borderBottom: '1px solid #333'
}}>
<div style={{
padding: '10px 12px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{
fontSize: '13px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '6px',
color: '#ccc'
}}>
<Clipboard size={14} />
<span>Blackboard</span>
</div>
<div style={{
display: 'flex',
backgroundColor: '#1e1e1e',
borderRadius: '3px',
overflow: 'hidden'
}}>
<button
onClick={() => setViewMode('local')}
style={{
padding: '3px 8px',
backgroundColor: viewMode === 'local' ? '#0e639c' : 'transparent',
border: 'none',
color: viewMode === 'local' ? '#fff' : '#888',
cursor: 'pointer',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
gap: '3px'
}}
>
<Clipboard size={11} />
Local
</button>
<button
onClick={() => setViewMode('global')}
style={{
padding: '3px 8px',
backgroundColor: viewMode === 'global' ? '#0e639c' : 'transparent',
border: 'none',
color: viewMode === 'global' ? '#fff' : '#888',
cursor: 'pointer',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
gap: '3px'
}}
>
<Globe size={11} />
Global
</button>
</div>
</div>
{/* 工具栏 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#252525',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '8px'
}}>
<div style={{
flex: 1,
fontSize: '10px',
color: '#888',
display: 'flex',
alignItems: 'center',
gap: '4px',
minWidth: 0,
overflow: 'hidden'
}}>
{viewMode === 'global' && projectPath ? (
<>
<Folder size={10} style={{ flexShrink: 0 }} />
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>.ecs/global-blackboard.json</span>
</>
) : (
<span>
{viewMode === 'local' ? '当前行为树的本地变量' : '所有行为树共享的全局变量'}
</span>
)}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
flexShrink: 0
}}>
{viewMode === 'global' && onSaveGlobal && (
<>
<button
onClick={hasUnsavedGlobalChanges ? onSaveGlobal : undefined}
disabled={!hasUnsavedGlobalChanges}
style={{
padding: '4px 6px',
backgroundColor: hasUnsavedGlobalChanges ? '#ff9800' : '#4caf50',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: hasUnsavedGlobalChanges ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
opacity: hasUnsavedGlobalChanges ? 1 : 0.7
}}
title={hasUnsavedGlobalChanges ? '点击保存全局配置' : '全局配置已保存'}
>
<Save size={12} />
</button>
<button
onClick={handleExportTypeScript}
style={{
padding: '4px 6px',
backgroundColor: '#9c27b0',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center'
}}
title="导出为 TypeScript 类型定义"
>
<FileCode size={12} />
</button>
</>
)}
<button
onClick={() => setIsAdding(true)}
style={{
padding: '4px 6px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
fontSize: '11px',
display: 'flex',
alignItems: 'center'
}}
title="添加变量"
>
+
</button>
</div>
</div>
</div>
{/* 变量列表 */}
<div className="blackboard-list" style={{
flex: 1,
overflowY: 'auto',
padding: '10px'
}}>
{variableEntries.length === 0 && !isAdding && (
<div style={{
textAlign: 'center',
color: '#666',
fontSize: '12px',
padding: '20px'
}}>
No variables yet. Click "Add" to create one.
</div>
)}
{groupNames.map(groupName => {
const isCollapsed = collapsedGroups.has(groupName);
const groupVars = groupedVariables[groupName];
if (!groupVars) return null;
return (
<div key={groupName} style={{ marginBottom: '8px' }}>
{groupName !== 'default' && (
<div
onClick={() => toggleGroup(groupName)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 6px',
backgroundColor: '#252525',
borderRadius: '3px',
cursor: 'pointer',
marginBottom: '4px',
userSelect: 'none'
}}
>
{isCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
<span style={{
fontSize: '11px',
fontWeight: 'bold',
color: '#888'
}}>
{groupName} ({groupVars.length})
</span>
</div>
)}
{!isCollapsed && groupVars.map(({ fullKey: key, varName, value }) => {
const type = getVariableType(value);
const isEditing = editingKey === key;
const handleDragStart = (e: React.DragEvent) => {
const variableData = {
variableName: key,
variableValue: value,
variableType: type
};
e.dataTransfer.setData('application/blackboard-variable', JSON.stringify(variableData));
e.dataTransfer.effectAllowed = 'copy';
};
const typeColor =
type === 'number' ? '#4ec9b0' :
type === 'boolean' ? '#569cd6' :
type === 'object' ? '#ce9178' : '#d4d4d4';
const displayValue = type === 'object' ?
JSON.stringify(value) :
String(value);
const truncatedValue = displayValue.length > 30 ?
displayValue.substring(0, 30) + '...' :
displayValue;
return (
<div
key={key}
draggable={!isEditing}
onDragStart={handleDragStart}
style={{
marginBottom: '6px',
padding: '6px 8px',
backgroundColor: '#2d2d2d',
borderRadius: '3px',
borderLeft: `3px solid ${typeColor}`,
cursor: isEditing ? 'default' : 'grab'
}}
>
{isEditing ? (
<div>
<div style={{
fontSize: '10px',
color: '#666',
marginBottom: '4px'
}}>
Name
</div>
<input
type="text"
value={editingNewKey}
onChange={(e) => setEditingNewKey(e.target.value)}
style={{
width: '100%',
padding: '4px',
marginBottom: '4px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '2px',
color: '#9cdcfe',
fontSize: '11px',
fontFamily: 'monospace'
}}
placeholder="Variable name (e.g., player.health)"
/>
<div style={{
fontSize: '10px',
color: '#666',
marginBottom: '4px'
}}>
Type
</div>
<select
value={editType}
onChange={(e) => setEditType(e.target.value as BlackboardVariable['type'])}
style={{
width: '100%',
padding: '4px',
marginBottom: '4px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '2px',
color: '#cccccc',
fontSize: '10px'
}}
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="object">Object (JSON)</option>
</select>
<div style={{
fontSize: '10px',
color: '#666',
marginBottom: '4px'
}}>
Value
</div>
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
style={{
width: '100%',
minHeight: editType === 'object' ? '60px' : '24px',
padding: '4px',
backgroundColor: '#1e1e1e',
border: '1px solid #0e639c',
borderRadius: '2px',
color: '#cccccc',
fontSize: '11px',
fontFamily: 'monospace',
resize: 'vertical',
marginBottom: '4px'
}}
/>
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={() => handleSaveEdit(key)}
style={{
padding: '3px 8px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '2px',
color: '#fff',
cursor: 'pointer',
fontSize: '10px'
}}
>
Save
</button>
<button
onClick={() => setEditingKey(null)}
style={{
padding: '3px 8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '2px',
color: '#ccc',
cursor: 'pointer',
fontSize: '10px'
}}
>
Cancel
</button>
</div>
</div>
) : (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '11px',
color: '#9cdcfe',
fontWeight: 'bold',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
{varName} <span style={{
color: '#666',
fontWeight: 'normal',
fontSize: '10px'
}}>({type})</span>
{viewMode === 'local' && isModified(key) && (
<span style={{
fontSize: '9px',
color: '#ffbb00',
backgroundColor: 'rgba(255, 187, 0, 0.15)',
padding: '1px 4px',
borderRadius: '2px'
}} title="运行时修改的值,停止后会恢复">
</span>
)}
</div>
<div style={{
fontSize: '10px',
fontFamily: 'monospace',
color: typeColor,
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
backgroundColor: (viewMode === 'local' && isModified(key)) ? 'rgba(255, 187, 0, 0.1)' : 'transparent',
padding: '1px 3px',
borderRadius: '2px'
}} title={(viewMode === 'local' && isModified(key)) ? `初始值: ${JSON.stringify(initialVariables?.[key])}\n当前值: ${displayValue}` : displayValue}>
{truncatedValue}
</div>
</div>
<div style={{
display: 'flex',
gap: '2px',
flexShrink: 0
}}>
<button
onClick={() => handleStartEdit(key, value)}
style={{
padding: '2px',
backgroundColor: 'transparent',
border: 'none',
color: '#ccc',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title="Edit"
>
<Edit2 size={12} />
</button>
<button
onClick={() => currentOnDelete && currentOnDelete(key)}
style={{
padding: '2px',
backgroundColor: 'transparent',
border: 'none',
color: '#f44336',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
})}
{/* 添加新变量表单 */}
{isAdding && (
<div style={{
padding: '12px',
backgroundColor: '#2d2d2d',
borderRadius: '4px',
borderLeft: '3px solid #0e639c'
}}>
<div style={{
fontSize: '13px',
fontWeight: 'bold',
marginBottom: '10px',
color: '#9cdcfe'
}}>
New Variable
</div>
<input
type="text"
placeholder="Variable name"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
style={{
width: '100%',
padding: '6px',
marginBottom: '8px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '3px',
color: '#cccccc',
fontSize: '12px'
}}
/>
<select
value={newType}
onChange={(e) => setNewType(e.target.value as BlackboardVariable['type'])}
style={{
width: '100%',
padding: '6px',
marginBottom: '8px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '3px',
color: '#cccccc',
fontSize: '12px'
}}
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="object">Object (JSON)</option>
</select>
<textarea
placeholder={
newType === 'object' ? '{"key": "value"}' :
newType === 'boolean' ? 'true or false' :
newType === 'number' ? '0' : 'value'
}
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
style={{
width: '100%',
minHeight: newType === 'object' ? '80px' : '30px',
padding: '6px',
marginBottom: '8px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '3px',
color: '#cccccc',
fontSize: '12px',
fontFamily: 'monospace',
resize: 'vertical'
}}
/>
<div style={{ display: 'flex', gap: '5px' }}>
<button
onClick={handleAddVariable}
style={{
padding: '6px 12px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px'
}}
>
Create
</button>
<button
onClick={() => {
setIsAdding(false);
setNewKey('');
setNewValue('');
}}
style={{
padding: '6px 12px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '3px',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px'
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
{/* 底部信息 */}
<div style={{
padding: '8px 15px',
borderTop: '1px solid #333',
fontSize: '11px',
color: '#666',
backgroundColor: '#2d2d2d',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span>
{viewMode === 'local' ? 'Local' : 'Global'}: {variableEntries.length} variable{variableEntries.length !== 1 ? 's' : ''}
</span>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,336 @@
import React, { useEffect, useRef, useState } from 'react';
import { Play, Pause, Square, RotateCcw, Trash2, Copy } from 'lucide-react';
interface ExecutionLog {
timestamp: number;
message: string;
level: 'info' | 'success' | 'error' | 'warning';
nodeId?: string;
}
interface BehaviorTreeExecutionPanelProps {
logs: ExecutionLog[];
onClearLogs: () => void;
isRunning: boolean;
tickCount: number;
executionSpeed: number;
onSpeedChange: (speed: number) => void;
}
export const BehaviorTreeExecutionPanel: React.FC<BehaviorTreeExecutionPanelProps> = ({
logs,
onClearLogs,
isRunning,
tickCount,
executionSpeed,
onSpeedChange
}) => {
const logContainerRef = useRef<HTMLDivElement>(null);
const [copySuccess, setCopySuccess] = useState(false);
// 自动滚动到底部
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs]);
const getLevelColor = (level: string) => {
switch (level) {
case 'success': return '#4caf50';
case 'error': return '#f44336';
case 'warning': return '#ff9800';
default: return '#2196f3';
}
};
const getLevelIcon = (level: string) => {
switch (level) {
case 'success': return '✓';
case 'error': return '✗';
case 'warning': return '⚠';
default: return '';
}
};
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}.${date.getMilliseconds().toString().padStart(3, '0')}`;
};
const handleCopyLogs = () => {
const logsText = logs.map(log =>
`${formatTime(log.timestamp)} ${getLevelIcon(log.level)} ${log.message}`
).join('\n');
navigator.clipboard.writeText(logsText).then(() => {
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
}).catch(err => {
console.error('复制失败:', err);
});
};
return (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#1e1e1e',
color: '#d4d4d4',
fontFamily: 'Consolas, monospace',
fontSize: '12px'
}}>
{/* 标题栏 */}
<div style={{
padding: '8px 12px',
borderBottom: '1px solid #333',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#252526'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontWeight: 'bold' }}></span>
{isRunning && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '2px 8px',
backgroundColor: '#4caf50',
borderRadius: '3px',
fontSize: '11px'
}}>
<div style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: '#fff',
animation: 'pulse 1s infinite'
}} />
</div>
)}
<span style={{ color: '#888', fontSize: '11px' }}>
Tick: {tickCount}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{/* 速度控制 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ color: '#888', fontSize: '11px', minWidth: '60px' }}>
: {executionSpeed.toFixed(2)}x
</span>
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={() => onSpeedChange(0.05)}
style={{
padding: '2px 6px',
fontSize: '10px',
backgroundColor: executionSpeed === 0.05 ? '#0e639c' : 'transparent',
border: '1px solid #555',
borderRadius: '2px',
color: '#d4d4d4',
cursor: 'pointer'
}}
title="超慢速 (每秒3次)"
>
0.05x
</button>
<button
onClick={() => onSpeedChange(0.2)}
style={{
padding: '2px 6px',
fontSize: '10px',
backgroundColor: executionSpeed === 0.2 ? '#0e639c' : 'transparent',
border: '1px solid #555',
borderRadius: '2px',
color: '#d4d4d4',
cursor: 'pointer'
}}
title="慢速 (每秒12次)"
>
0.2x
</button>
<button
onClick={() => onSpeedChange(1.0)}
style={{
padding: '2px 6px',
fontSize: '10px',
backgroundColor: executionSpeed === 1.0 ? '#0e639c' : 'transparent',
border: '1px solid #555',
borderRadius: '2px',
color: '#d4d4d4',
cursor: 'pointer'
}}
title="正常速度 (每秒60次)"
>
1.0x
</button>
</div>
<input
type="range"
min="0.01"
max="2"
step="0.01"
value={executionSpeed}
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
style={{
width: '80px',
accentColor: '#0e639c'
}}
title="调整执行速度"
/>
</div>
<button
onClick={handleCopyLogs}
style={{
padding: '6px',
backgroundColor: copySuccess ? '#4caf50' : 'transparent',
border: '1px solid #555',
borderRadius: '3px',
color: logs.length === 0 ? '#666' : '#d4d4d4',
cursor: logs.length === 0 ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
opacity: logs.length === 0 ? 0.5 : 1,
transition: 'background-color 0.2s'
}}
title={copySuccess ? '已复制!' : '复制日志'}
disabled={logs.length === 0}
>
<Copy size={12} />
</button>
<button
onClick={onClearLogs}
style={{
padding: '6px',
backgroundColor: 'transparent',
border: '1px solid #555',
borderRadius: '3px',
color: '#d4d4d4',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px'
}}
title="清空日志"
>
<Trash2 size={12} />
</button>
</div>
</div>
{/* 日志内容 */}
<div
ref={logContainerRef}
className="execution-panel-logs"
style={{
flex: 1,
overflowY: 'auto',
padding: '8px',
backgroundColor: '#1e1e1e'
}}
>
{logs.length === 0 ? (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#666',
fontSize: '13px'
}}>
Play
</div>
) : (
logs.map((log, index) => (
<div
key={index}
style={{
display: 'flex',
gap: '8px',
padding: '4px 0',
borderBottom: index < logs.length - 1 ? '1px solid #2a2a2a' : 'none'
}}
>
<span style={{
color: '#666',
fontSize: '11px',
minWidth: '80px'
}}>
{formatTime(log.timestamp)}
</span>
<span style={{
color: getLevelColor(log.level),
fontWeight: 'bold',
minWidth: '16px'
}}>
{getLevelIcon(log.level)}
</span>
<span style={{
flex: 1,
color: log.level === 'error' ? '#f44336' : '#d4d4d4'
}}>
{log.message}
</span>
</div>
))
)}
</div>
{/* 底部状态栏 */}
<div style={{
padding: '6px 12px',
borderTop: '1px solid #333',
backgroundColor: '#252526',
fontSize: '11px',
color: '#888',
display: 'flex',
justifyContent: 'space-between'
}}>
<span>{logs.length} </span>
<span>{isRunning ? '正在运行' : '已停止'}</span>
</div>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* 自定义滚动条样式 */
.execution-panel-logs::-webkit-scrollbar {
width: 8px;
}
.execution-panel-logs::-webkit-scrollbar-track {
background: #1e1e1e;
}
.execution-panel-logs::-webkit-scrollbar-thumb {
background: #424242;
border-radius: 4px;
}
.execution-panel-logs::-webkit-scrollbar-thumb:hover {
background: #4e4e4e;
}
/* Firefox 滚动条样式 */
.execution-panel-logs {
scrollbar-width: thin;
scrollbar-color: #424242 #1e1e1e;
}
`}</style>
</div>
);
};

View File

@@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import '../styles/BehaviorTreeNameDialog.css';
interface BehaviorTreeNameDialogProps {
isOpen: boolean;
onConfirm: (name: string) => void;
onCancel: () => void;
defaultName?: string;
}
export const BehaviorTreeNameDialog: React.FC<BehaviorTreeNameDialogProps> = ({
isOpen,
onConfirm,
onCancel,
defaultName = ''
}) => {
const [name, setName] = useState(defaultName);
const [error, setError] = useState('');
useEffect(() => {
if (isOpen) {
setName(defaultName);
setError('');
}
}, [isOpen, defaultName]);
if (!isOpen) return null;
const validateName = (value: string): boolean => {
if (!value.trim()) {
setError('行为树名称不能为空');
return false;
}
const invalidChars = /[<>:"/\\|?*]/;
if (invalidChars.test(value)) {
setError('名称包含非法字符(不能包含 < > : " / \\ | ? *');
return false;
}
setError('');
return true;
};
const handleConfirm = () => {
if (validateName(name)) {
onConfirm(name.trim());
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleConfirm();
} else if (e.key === 'Escape') {
onCancel();
}
};
const handleNameChange = (value: string) => {
setName(value);
if (error) {
validateName(value);
}
};
return (
<div className="dialog-overlay">
<div className="dialog-content">
<div className="dialog-header">
<h3></h3>
</div>
<div className="dialog-body">
<label htmlFor="btree-name">:</label>
<input
id="btree-name"
type="text"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="请输入行为树名称"
autoFocus
/>
{error && <div className="dialog-error">{error}</div>}
<div className="dialog-hint">
将保存到项目目录: .ecs/behaviors/{name || '名称'}.btree
</div>
</div>
<div className="dialog-footer">
<button onClick={onCancel} className="dialog-button dialog-button-secondary">
</button>
<button onClick={handleConfirm} className="dialog-button dialog-button-primary">
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,209 @@
import React, { useState } from 'react';
import { NodeTemplates, NodeTemplate } from '@esengine/behavior-tree';
import { NodeIcon } from './NodeIcon';
interface BehaviorTreeNodePaletteProps {
onNodeSelect?: (template: NodeTemplate) => void;
}
/**
* 行为树节点面板
*
* 显示所有可用的行为树节点模板,支持拖拽创建
*/
export const BehaviorTreeNodePalette: React.FC<BehaviorTreeNodePaletteProps> = ({
onNodeSelect
}) => {
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const allTemplates = NodeTemplates.getAllTemplates();
// 按类别分组(排除根节点类别)
const categories = ['all', ...new Set(allTemplates
.filter(t => t.category !== '根节点')
.map(t => t.category))];
const filteredTemplates = (selectedCategory === 'all'
? allTemplates
: allTemplates.filter(t => t.category === selectedCategory))
.filter(t => t.category !== '根节点');
const handleNodeClick = (template: NodeTemplate) => {
onNodeSelect?.(template);
};
const handleDragStart = (e: React.DragEvent, template: NodeTemplate) => {
const templateJson = JSON.stringify(template);
e.dataTransfer.setData('application/behavior-tree-node', templateJson);
e.dataTransfer.setData('text/plain', templateJson);
e.dataTransfer.effectAllowed = 'copy';
const dragImage = e.currentTarget as HTMLElement;
if (dragImage) {
e.dataTransfer.setDragImage(dragImage, 50, 25);
}
};
const getTypeColor = (type: string): string => {
switch (type) {
case 'composite': return '#1976d2';
case 'action': return '#388e3c';
case 'condition': return '#d32f2f';
case 'decorator': return '#fb8c00';
case 'blackboard': return '#8e24aa';
default: return '#7b1fa2';
}
};
return (
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
backgroundColor: '#1e1e1e',
color: '#cccccc',
fontFamily: 'sans-serif'
}}>
<style>{`
.node-palette-list::-webkit-scrollbar {
width: 8px;
}
.node-palette-list::-webkit-scrollbar-track {
background: #1e1e1e;
}
.node-palette-list::-webkit-scrollbar-thumb {
background: #3c3c3c;
border-radius: 4px;
}
.node-palette-list::-webkit-scrollbar-thumb:hover {
background: #4c4c4c;
}
`}</style>
{/* 类别选择器 */}
<div style={{
padding: '10px',
borderBottom: '1px solid #333',
display: 'flex',
flexWrap: 'wrap',
gap: '5px'
}}>
{categories.map(category => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
style={{
padding: '5px 10px',
backgroundColor: selectedCategory === category ? '#0e639c' : '#3c3c3c',
color: '#cccccc',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '12px'
}}
>
{category}
</button>
))}
</div>
{/* 节点列表 */}
<div className="node-palette-list" style={{
flex: 1,
overflowY: 'auto',
padding: '10px'
}}>
{filteredTemplates.map((template, index) => {
const className = template.className || '';
return (
<div
key={index}
draggable={true}
onDragStart={(e) => handleDragStart(e, template)}
onClick={() => handleNodeClick(template)}
style={{
padding: '10px',
marginBottom: '8px',
backgroundColor: '#2d2d2d',
borderLeft: `4px solid ${getTypeColor(template.type || '')}`,
borderRadius: '3px',
cursor: 'grab',
transition: 'all 0.2s',
userSelect: 'none',
WebkitUserSelect: 'none'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#3d3d3d';
e.currentTarget.style.transform = 'translateX(2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#2d2d2d';
e.currentTarget.style.transform = 'translateX(0)';
}}
onMouseDown={(e) => {
e.currentTarget.style.cursor = 'grabbing';
}}
onMouseUp={(e) => {
e.currentTarget.style.cursor = 'grab';
}}
>
<div style={{
display: 'flex',
alignItems: 'flex-start',
marginBottom: '5px',
pointerEvents: 'none',
gap: '8px'
}}>
{template.icon && (
<span style={{ display: 'flex', alignItems: 'center', paddingTop: '2px' }}>
<NodeIcon iconName={template.icon} size={16} />
</span>
)}
<div style={{ flex: 1 }}>
<div style={{ fontSize: '14px', fontWeight: '500', marginBottom: '2px' }}>
{template.displayName}
</div>
{className && (
<div style={{
color: '#666',
fontSize: '10px',
fontFamily: 'Consolas, Monaco, monospace',
opacity: 0.8
}}>
{className}
</div>
)}
</div>
</div>
<div style={{
fontSize: '12px',
color: '#999',
lineHeight: '1.4',
pointerEvents: 'none'
}}>
{template.description}
</div>
<div style={{
marginTop: '5px',
fontSize: '11px',
color: '#666',
pointerEvents: 'none'
}}>
{template.category}
</div>
</div>
);
})}
</div>
{/* 帮助提示 */}
<div style={{
padding: '10px',
borderTop: '1px solid #333',
fontSize: '11px',
color: '#666',
textAlign: 'center'
}}>
</div>
</div>
);
};

View File

@@ -0,0 +1,407 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NodeTemplate, PropertyDefinition } from '@esengine/behavior-tree';
import {
List, GitBranch, Layers, Shuffle,
RotateCcw, Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer,
Clock, FileText, Edit, Calculator, Code,
Equal, Dices, Settings, Database, FolderOpen, TreePine,
LucideIcon
} from 'lucide-react';
import { AssetPickerDialog } from './AssetPickerDialog';
const iconMap: Record<string, LucideIcon> = {
List, GitBranch, Layers, Shuffle,
RotateCcw, Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer,
Clock, FileText, Edit, Calculator, Code,
Equal, Dices, Settings, Database, TreePine
};
interface BehaviorTreeNodePropertiesProps {
selectedNode?: {
template: NodeTemplate;
data: Record<string, any>;
};
onPropertyChange?: (propertyName: string, value: any) => void;
projectPath?: string | null;
}
/**
* 行为树节点属性编辑器
*
* 根据节点模板动态生成属性编辑界面
*/
export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProps> = ({
selectedNode,
onPropertyChange,
projectPath
}) => {
const { t } = useTranslation();
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
const [assetPickerProperty, setAssetPickerProperty] = useState<string | null>(null);
if (!selectedNode) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#666',
fontSize: '14px'
}}>
{t('behaviorTree.noNodeSelected')}
</div>
);
}
const { template, data } = selectedNode;
const handleChange = (propName: string, value: any) => {
onPropertyChange?.(propName, value);
};
const renderProperty = (prop: PropertyDefinition) => {
const value = data[prop.name] ?? prop.defaultValue;
switch (prop.type) {
case 'string':
case 'variable':
return (
<input
type="text"
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
placeholder={prop.description}
style={{
width: '100%',
padding: '6px',
backgroundColor: '#3c3c3c',
border: '1px solid #555',
borderRadius: '3px',
color: '#cccccc',
fontSize: '13px'
}}
/>
);
case 'number':
return (
<input
type="number"
value={value ?? ''}
onChange={(e) => handleChange(prop.name, parseFloat(e.target.value))}
min={prop.min}
max={prop.max}
step={prop.step || 1}
placeholder={prop.description}
style={{
width: '100%',
padding: '6px',
backgroundColor: '#3c3c3c',
border: '1px solid #555',
borderRadius: '3px',
color: '#cccccc',
fontSize: '13px'
}}
/>
);
case 'boolean':
return (
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={value || false}
onChange={(e) => handleChange(prop.name, e.target.checked)}
style={{ marginRight: '8px' }}
/>
<span style={{ fontSize: '13px' }}>{prop.description || '启用'}</span>
</label>
);
case 'select':
return (
<select
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
style={{
width: '100%',
padding: '6px',
backgroundColor: '#3c3c3c',
border: '1px solid #555',
borderRadius: '3px',
color: '#cccccc',
fontSize: '13px'
}}
>
<option value="">...</option>
{prop.options?.map((opt, idx) => (
<option key={idx} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
case 'code':
return (
<textarea
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
placeholder={prop.description}
rows={5}
style={{
width: '100%',
padding: '6px',
backgroundColor: '#3c3c3c',
border: '1px solid #555',
borderRadius: '3px',
color: '#cccccc',
fontSize: '13px',
fontFamily: 'monospace',
resize: 'vertical'
}}
/>
);
case 'blackboard':
return (
<div style={{ display: 'flex', gap: '5px' }}>
<input
type="text"
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
placeholder="黑板变量名"
style={{
flex: 1,
padding: '6px',
backgroundColor: '#3c3c3c',
border: '1px solid #555',
borderRadius: '3px',
color: '#cccccc',
fontSize: '13px'
}}
/>
<button
style={{
padding: '6px 12px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px'
}}
>
</button>
</div>
);
case 'asset':
return (
<div>
<div style={{ display: 'flex', gap: '5px' }}>
<input
type="text"
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
placeholder={prop.description || '资产ID'}
style={{
flex: 1,
padding: '6px',
backgroundColor: '#3c3c3c',
border: '1px solid #555',
borderRadius: '3px',
color: '#cccccc',
fontSize: '13px'
}}
/>
<button
onClick={() => {
setAssetPickerProperty(prop.name);
setAssetPickerOpen(true);
}}
disabled={!projectPath}
title={!projectPath ? '请先打开项目' : '浏览资产'}
style={{
padding: '6px 12px',
backgroundColor: projectPath ? '#0e639c' : '#555',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: projectPath ? 'pointer' : 'not-allowed',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
<FolderOpen size={14} />
</button>
</div>
{!projectPath && (
<div style={{
marginTop: '5px',
fontSize: '11px',
color: '#f48771',
lineHeight: '1.4'
}}>
使
</div>
)}
</div>
);
default:
return null;
}
};
return (
<div style={{
height: '100%',
backgroundColor: '#1e1e1e',
color: '#cccccc',
fontFamily: 'sans-serif',
display: 'flex',
flexDirection: 'column'
}}>
{/* 节点信息 */}
<div style={{
padding: '15px',
borderBottom: '1px solid #333'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '10px'
}}>
{template.icon && (() => {
const IconComponent = iconMap[template.icon];
return IconComponent ? (
<IconComponent
size={24}
color={template.color || '#cccccc'}
style={{ marginRight: '10px' }}
/>
) : (
<span style={{ marginRight: '10px', fontSize: '24px' }}>
{template.icon}
</span>
);
})()}
<div>
<h3 style={{ margin: 0, fontSize: '16px' }}>{template.displayName}</h3>
<div style={{ fontSize: '11px', color: '#666', marginTop: '2px' }}>
{template.category}
</div>
</div>
</div>
<div style={{ fontSize: '13px', color: '#999', lineHeight: '1.5' }}>
{template.description}
</div>
</div>
{/* 属性列表 */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '15px'
}}>
{template.properties.length === 0 ? (
<div style={{ color: '#666', fontSize: '13px', textAlign: 'center', paddingTop: '20px' }}>
{t('behaviorTree.noConfigurableProperties')}
</div>
) : (
template.properties.map((prop, index) => (
<div key={index} style={{ marginBottom: '20px' }}>
<label style={{
display: 'block',
marginBottom: '8px',
fontSize: '13px',
fontWeight: 'bold',
color: '#cccccc'
}}>
{prop.label}
{prop.required && (
<span style={{ color: '#f48771', marginLeft: '4px' }}>*</span>
)}
</label>
{renderProperty(prop)}
{prop.description && prop.type !== 'boolean' && (
<div style={{
marginTop: '5px',
fontSize: '11px',
color: '#666',
lineHeight: '1.4'
}}>
{prop.description}
</div>
)}
</div>
))
)}
</div>
{/* 操作按钮 */}
<div style={{
padding: '15px',
borderTop: '1px solid #333',
display: 'flex',
gap: '10px'
}}>
<button
style={{
flex: 1,
padding: '8px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
fontSize: '13px'
}}
>
{t('behaviorTree.apply')}
</button>
<button
style={{
flex: 1,
padding: '8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '3px',
color: '#cccccc',
cursor: 'pointer',
fontSize: '13px'
}}
>
{t('behaviorTree.reset')}
</button>
</div>
{/* 资产选择器对话框 */}
{assetPickerOpen && projectPath && assetPickerProperty && (
<AssetPickerDialog
projectPath={projectPath}
fileExtension="btree"
assetBasePath=".ecs/behaviors"
locale={t('locale') === 'zh' ? 'zh' : 'en'}
onSelect={(assetId) => {
// AssetPickerDialog 返回 assetId不含扩展名相对于 .ecs/behaviors 的路径)
handleChange(assetPickerProperty, assetId);
setAssetPickerOpen(false);
setAssetPickerProperty(null);
}}
onClose={() => {
setAssetPickerOpen(false);
setAssetPickerProperty(null);
}}
/>
)}
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useMemo, memo } from 'react';
import { LogService, LogEntry } from '@esengine/editor-core';
import { LogLevel } from '@esengine/ecs-framework';
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Maximize2, ChevronRight, ChevronDown, Wifi } from 'lucide-react';
@@ -9,6 +9,137 @@ interface ConsolePanelProps {
logService: LogService;
}
interface ParsedLogData {
isJSON: boolean;
jsonStr?: string;
extracted?: { prefix: string; json: string; suffix: string } | null;
}
const LogEntryItem = memo(({
log,
isExpanded,
onToggleExpand,
onOpenJsonViewer,
parsedData
}: {
log: LogEntry;
isExpanded: boolean;
onToggleExpand: (id: number) => void;
onOpenJsonViewer: (jsonStr: string) => void;
parsedData: ParsedLogData;
}) => {
const getLevelIcon = (level: LogLevel) => {
switch (level) {
case LogLevel.Debug:
return <Bug size={14} />;
case LogLevel.Info:
return <Info size={14} />;
case LogLevel.Warn:
return <AlertTriangle size={14} />;
case LogLevel.Error:
case LogLevel.Fatal:
return <XCircle size={14} />;
default:
return <AlertCircle size={14} />;
}
};
const getLevelClass = (level: LogLevel): string => {
switch (level) {
case LogLevel.Debug:
return 'log-entry-debug';
case LogLevel.Info:
return 'log-entry-info';
case LogLevel.Warn:
return 'log-entry-warn';
case LogLevel.Error:
case LogLevel.Fatal:
return 'log-entry-error';
default:
return '';
}
};
const formatTime = (date: Date): string => {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
const ms = date.getMilliseconds().toString().padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${ms}`;
};
const formatMessage = (message: string, isExpanded: boolean, parsedData: ParsedLogData): JSX.Element => {
const MAX_PREVIEW_LENGTH = 200;
const { isJSON, jsonStr, extracted } = parsedData;
const shouldTruncate = message.length > MAX_PREVIEW_LENGTH && !isExpanded;
return (
<div className="log-message-container">
<div className="log-message-text">
{shouldTruncate ? (
<>
{extracted && extracted.prefix && <span>{extracted.prefix} </span>}
<span className="log-message-preview">
{message.substring(0, MAX_PREVIEW_LENGTH)}...
</span>
</>
) : (
<span>{message}</span>
)}
</div>
{isJSON && jsonStr && (
<button
className="log-open-json-btn"
onClick={(e) => {
e.stopPropagation();
onOpenJsonViewer(jsonStr);
}}
title="Open in JSON Viewer"
>
<Maximize2 size={12} />
</button>
)}
</div>
);
};
const shouldShowExpander = log.message.length > 200;
return (
<div
className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${isExpanded ? 'log-entry-expanded' : ''}`}
>
{shouldShowExpander && (
<div
className="log-entry-expander"
onClick={() => onToggleExpand(log.id)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</div>
)}
<div className="log-entry-icon">
{getLevelIcon(log.level)}
</div>
<div className="log-entry-time">
{formatTime(log.timestamp)}
</div>
<div className={`log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
</div>
{log.clientId && (
<div className="log-entry-client" title={`Client: ${log.clientId}`}>
{log.clientId}
</div>
)}
<div className="log-entry-message">
{formatMessage(log.message, isExpanded, parsedData)}
</div>
</div>
);
});
LogEntryItem.displayName = 'LogEntryItem';
export function ConsolePanel({ logService }: ConsolePanelProps) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [filter, setFilter] = useState('');
@@ -64,54 +195,139 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
setLevelFilter(newFilter);
};
const filteredLogs = logs.filter(log => {
if (!levelFilter.has(log.level)) return false;
if (showRemoteOnly && log.source !== 'remote') return false;
if (filter && !log.message.toLowerCase().includes(filter.toLowerCase())) {
return false;
}
return true;
});
// 使用ref保存缓存避免每次都重新计算
const parsedLogsCacheRef = useRef<Map<number, ParsedLogData>>(new Map());
const getLevelIcon = (level: LogLevel) => {
switch (level) {
case LogLevel.Debug:
return <Bug size={14} />;
case LogLevel.Info:
return <Info size={14} />;
case LogLevel.Warn:
return <AlertTriangle size={14} />;
case LogLevel.Error:
case LogLevel.Fatal:
return <XCircle size={14} />;
default:
return <AlertCircle size={14} />;
}
};
const extractJSON = useMemo(() => {
return (message: string): { prefix: string; json: string; suffix: string } | null => {
// 快速路径:如果消息太短,直接返回
if (message.length < 2) return null;
const getLevelClass = (level: LogLevel): string => {
switch (level) {
case LogLevel.Debug:
return 'log-entry-debug';
case LogLevel.Info:
return 'log-entry-info';
case LogLevel.Warn:
return 'log-entry-warn';
case LogLevel.Error:
case LogLevel.Fatal:
return 'log-entry-error';
default:
return '';
}
};
const jsonStartChars = ['{', '['];
let startIndex = -1;
const formatTime = (date: Date): string => {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
const ms = date.getMilliseconds().toString().padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${ms}`;
};
for (const char of jsonStartChars) {
const index = message.indexOf(char);
if (index !== -1 && (startIndex === -1 || index < startIndex)) {
startIndex = index;
}
}
if (startIndex === -1) return null;
// 使用栈匹配算法更高效地找到JSON边界
const startChar = message[startIndex];
const endChar = startChar === '{' ? '}' : ']';
let depth = 0;
let inString = false;
let escape = false;
for (let i = startIndex; i < message.length; i++) {
const char = message[i];
if (escape) {
escape = false;
continue;
}
if (char === '\\') {
escape = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (char === startChar) {
depth++;
} else if (char === endChar) {
depth--;
if (depth === 0) {
// 找到匹配的结束符
const possibleJson = message.substring(startIndex, i + 1);
try {
JSON.parse(possibleJson);
return {
prefix: message.substring(0, startIndex).trim(),
json: possibleJson,
suffix: message.substring(i + 1).trim()
};
} catch {
return null;
}
}
}
}
return null;
};
}, []);
const parsedLogsCache = useMemo(() => {
const cache = parsedLogsCacheRef.current;
// 只处理新增的日志
for (const log of logs) {
// 如果已经缓存过,跳过
if (cache.has(log.id)) continue;
try {
JSON.parse(log.message);
cache.set(log.id, {
isJSON: true,
jsonStr: log.message,
extracted: null
});
} catch {
const extracted = extractJSON(log.message);
if (extracted) {
try {
JSON.parse(extracted.json);
cache.set(log.id, {
isJSON: true,
jsonStr: extracted.json,
extracted
});
} catch {
cache.set(log.id, {
isJSON: false,
extracted
});
}
} else {
cache.set(log.id, {
isJSON: false,
extracted: null
});
}
}
}
// 清理不再需要的缓存(日志被删除)
const logIds = new Set(logs.map(log => log.id));
for (const cachedId of cache.keys()) {
if (!logIds.has(cachedId)) {
cache.delete(cachedId);
}
}
return cache;
}, [logs, extractJSON]);
const filteredLogs = useMemo(() => {
return logs.filter(log => {
if (!levelFilter.has(log.level)) return false;
if (showRemoteOnly && log.source !== 'remote') return false;
if (filter && !log.message.toLowerCase().includes(filter.toLowerCase())) {
return false;
}
return true;
});
}, [logs, levelFilter, showRemoteOnly, filter]);
const toggleLogExpand = (logId: number) => {
const newExpanded = new Set(expandedLogs);
@@ -123,54 +339,6 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
setExpandedLogs(newExpanded);
};
const extractJSON = (message: string): { prefix: string; json: string; suffix: string } | null => {
const jsonStartChars = ['{', '['];
let startIndex = -1;
for (const char of jsonStartChars) {
const index = message.indexOf(char);
if (index !== -1 && (startIndex === -1 || index < startIndex)) {
startIndex = index;
}
}
if (startIndex === -1) return null;
for (let endIndex = message.length; endIndex > startIndex; endIndex--) {
const possibleJson = message.substring(startIndex, endIndex);
try {
JSON.parse(possibleJson);
return {
prefix: message.substring(0, startIndex).trim(),
json: possibleJson,
suffix: message.substring(endIndex).trim()
};
} catch {
continue;
}
}
return null;
};
const tryParseJSON = (message: string): { isJSON: boolean; parsed?: any; jsonStr?: string } => {
try {
const parsed = JSON.parse(message);
return { isJSON: true, parsed, jsonStr: message };
} catch {
const extracted = extractJSON(message);
if (extracted) {
try {
const parsed = JSON.parse(extracted.json);
return { isJSON: true, parsed, jsonStr: extracted.json };
} catch {
return { isJSON: false };
}
}
return { isJSON: false };
}
};
const openJsonViewer = (jsonStr: string) => {
try {
const parsed = JSON.parse(jsonStr);
@@ -180,43 +348,6 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
}
};
const formatMessage = (message: string, isExpanded: boolean): JSX.Element => {
const MAX_PREVIEW_LENGTH = 200;
const { isJSON, jsonStr } = tryParseJSON(message);
const extracted = extractJSON(message);
const shouldTruncate = message.length > MAX_PREVIEW_LENGTH && !isExpanded;
return (
<div className="log-message-container">
<div className="log-message-text">
{shouldTruncate ? (
<>
{extracted && extracted.prefix && <span>{extracted.prefix} </span>}
<span className="log-message-preview">
{message.substring(0, MAX_PREVIEW_LENGTH)}...
</span>
</>
) : (
<span>{message}</span>
)}
</div>
{isJSON && jsonStr && (
<button
className="log-open-json-btn"
onClick={(e) => {
e.stopPropagation();
openJsonViewer(jsonStr);
}}
title="Open in JSON Viewer"
>
<Maximize2 size={12} />
</button>
)}
</div>
);
};
const levelCounts = {
[LogLevel.Debug]: logs.filter(l => l.level === LogLevel.Debug).length,
[LogLevel.Info]: logs.filter(l => l.level === LogLevel.Info).length,
@@ -301,43 +432,16 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
<p>No logs to display</p>
</div>
) : (
filteredLogs.map(log => {
const isExpanded = expandedLogs.has(log.id);
const shouldShowExpander = log.message.length > 200;
return (
<div
key={log.id}
className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${isExpanded ? 'log-entry-expanded' : ''}`}
>
{shouldShowExpander && (
<div
className="log-entry-expander"
onClick={() => toggleLogExpand(log.id)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</div>
)}
<div className="log-entry-icon">
{getLevelIcon(log.level)}
</div>
<div className="log-entry-time">
{formatTime(log.timestamp)}
</div>
<div className={`log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
</div>
{log.clientId && (
<div className="log-entry-client" title={`Client: ${log.clientId}`}>
{log.clientId}
</div>
)}
<div className="log-entry-message">
{formatMessage(log.message, isExpanded)}
</div>
</div>
);
})
filteredLogs.map(log => (
<LogEntryItem
key={log.id}
log={log}
isExpanded={expandedLogs.has(log.id)}
onToggleExpand={toggleLogExpand}
onOpenJsonViewer={openJsonViewer}
parsedData={parsedLogsCache.get(log.id) || { isJSON: false }}
/>
))
)}
</div>
{jsonViewerData && (

View File

@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react';
import '../styles/ContextMenu.css';
export interface ContextMenuItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
disabled?: boolean;
separator?: boolean;
}
interface ContextMenuProps {
items: ContextMenuItem[];
position: { x: number; y: number };
onClose: () => void;
}
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState(position);
useEffect(() => {
if (menuRef.current) {
const menu = menuRef.current;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = position.x;
let y = position.y;
if (x + rect.width > viewportWidth) {
x = Math.max(0, viewportWidth - rect.width - 10);
}
if (y + rect.height > viewportHeight) {
y = Math.max(0, viewportHeight - rect.height - 10);
}
if (x !== position.x || y !== position.y) {
setAdjustedPosition({ x, y });
}
}
}, [position]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose]);
return (
<div
ref={menuRef}
className="context-menu"
style={{
left: `${adjustedPosition.x}px`,
top: `${adjustedPosition.y}px`
}}
>
{items.map((item, index) => {
if (item.separator) {
return <div key={index} className="context-menu-separator" />;
}
return (
<div
key={index}
className={`context-menu-item ${item.disabled ? 'disabled' : ''}`}
onClick={() => {
if (!item.disabled) {
item.onClick();
onClose();
}
}}
>
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
<span className="context-menu-label">{item.label}</span>
</div>
);
})}
</div>
);
}

View File

@@ -1,171 +0,0 @@
import { ReactNode } from 'react';
import { TabPanel, TabItem } from './TabPanel';
import { ResizablePanel } from './ResizablePanel';
import '../styles/DockContainer.css';
export type DockPosition = 'left' | 'right' | 'top' | 'bottom' | 'center';
export interface DockablePanel {
id: string;
title: string;
content: ReactNode;
position: DockPosition;
closable?: boolean;
}
interface DockContainerProps {
panels: DockablePanel[];
onPanelClose?: (panelId: string) => void;
onPanelMove?: (panelId: string, newPosition: DockPosition) => void;
}
export function DockContainer({ panels, onPanelClose }: DockContainerProps) {
const groupedPanels = panels.reduce((acc, panel) => {
if (!acc[panel.position]) {
acc[panel.position] = [];
}
acc[panel.position].push(panel);
return acc;
}, {} as Record<DockPosition, DockablePanel[]>);
const renderPanelGroup = (position: DockPosition) => {
const positionPanels = groupedPanels[position];
if (!positionPanels || positionPanels.length === 0) return null;
const tabs: TabItem[] = positionPanels.map(panel => ({
id: panel.id,
title: panel.title,
content: panel.content,
closable: panel.closable
}));
return (
<TabPanel
tabs={tabs}
onTabClose={onPanelClose}
/>
);
};
const leftPanel = groupedPanels['left'];
const rightPanel = groupedPanels['right'];
const topPanel = groupedPanels['top'];
const bottomPanel = groupedPanels['bottom'];
const hasLeft = leftPanel && leftPanel.length > 0;
const hasRight = rightPanel && rightPanel.length > 0;
const hasTop = topPanel && topPanel.length > 0;
const hasBottom = bottomPanel && bottomPanel.length > 0;
let content = (
<div className="dock-center">
{renderPanelGroup('center')}
</div>
);
if (hasTop || hasBottom) {
content = (
<ResizablePanel
direction="vertical"
defaultSize={200}
minSize={32}
maxSize={600}
storageKey="editor-panel-bottom-size"
leftOrTop={content}
rightOrBottom={
<div className="dock-bottom">
{renderPanelGroup('bottom')}
</div>
}
/>
);
}
if (hasTop) {
content = (
<ResizablePanel
direction="vertical"
defaultSize={200}
minSize={32}
maxSize={600}
storageKey="editor-panel-top-size"
leftOrTop={
<div className="dock-top">
{renderPanelGroup('top')}
</div>
}
rightOrBottom={content}
/>
);
}
if (hasLeft || hasRight) {
if (hasLeft && hasRight) {
content = (
<ResizablePanel
direction="horizontal"
defaultSize={250}
minSize={150}
maxSize={400}
storageKey="editor-panel-left-size"
leftOrTop={
<div className="dock-left">
{renderPanelGroup('left')}
</div>
}
rightOrBottom={
<ResizablePanel
direction="horizontal"
side="right"
defaultSize={280}
minSize={200}
maxSize={500}
storageKey="editor-panel-right-size"
leftOrTop={content}
rightOrBottom={
<div className="dock-right">
{renderPanelGroup('right')}
</div>
}
/>
}
/>
);
} else if (hasLeft) {
content = (
<ResizablePanel
direction="horizontal"
defaultSize={250}
minSize={150}
maxSize={400}
storageKey="editor-panel-left-size"
leftOrTop={
<div className="dock-left">
{renderPanelGroup('left')}
</div>
}
rightOrBottom={content}
/>
);
} else {
content = (
<ResizablePanel
direction="horizontal"
side="right"
defaultSize={280}
minSize={200}
maxSize={500}
storageKey="editor-panel-right-size"
leftOrTop={content}
rightOrBottom={
<div className="dock-right">
{renderPanelGroup('right')}
</div>
}
/>
);
}
}
return <div className="dock-container">{content}</div>;
}

View File

@@ -0,0 +1,456 @@
import { useState, useEffect } from 'react';
import { X, FileJson, Binary, Info, File, FolderTree, FolderOpen, Code } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import '../styles/ExportRuntimeDialog.css';
interface ExportRuntimeDialogProps {
isOpen: boolean;
onClose: () => void;
onExport: (options: ExportOptions) => void;
hasProject: boolean;
availableFiles: string[];
currentFileName?: string;
projectPath?: string;
}
export interface ExportOptions {
mode: 'single' | 'workspace';
assetOutputPath: string;
typeOutputPath: string;
selectedFiles: string[];
fileFormats: Map<string, 'json' | 'binary'>;
}
/**
* 导出运行时资产对话框
*/
export const ExportRuntimeDialog: React.FC<ExportRuntimeDialogProps> = ({
isOpen,
onClose,
onExport,
hasProject,
availableFiles,
currentFileName,
projectPath
}) => {
const [selectedMode, setSelectedMode] = useState<'single' | 'workspace'>('workspace');
const [assetOutputPath, setAssetOutputPath] = useState('');
const [typeOutputPath, setTypeOutputPath] = useState('');
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [fileFormats, setFileFormats] = useState<Map<string, 'json' | 'binary'>>(new Map());
const [selectAll, setSelectAll] = useState(true);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
const [exportMessage, setExportMessage] = useState('');
// 从 localStorage 加载上次的路径
useEffect(() => {
if (isOpen && projectPath) {
const savedAssetPath = localStorage.getItem('export-asset-path');
const savedTypePath = localStorage.getItem('export-type-path');
if (savedAssetPath) {
setAssetOutputPath(savedAssetPath);
}
if (savedTypePath) {
setTypeOutputPath(savedTypePath);
}
}
}, [isOpen, projectPath]);
useEffect(() => {
if (isOpen) {
if (selectedMode === 'workspace') {
const newSelectedFiles = new Set(availableFiles);
setSelectedFiles(newSelectedFiles);
setSelectAll(true);
const newFormats = new Map<string, 'json' | 'binary'>();
availableFiles.forEach(file => {
newFormats.set(file, 'binary');
});
setFileFormats(newFormats);
} else {
setSelectedFiles(new Set());
setSelectAll(false);
if (currentFileName) {
const newFormats = new Map<string, 'json' | 'binary'>();
newFormats.set(currentFileName, 'binary');
setFileFormats(newFormats);
}
}
}
}, [isOpen, selectedMode, availableFiles, currentFileName]);
if (!isOpen) return null;
const handleSelectAll = () => {
if (selectAll) {
setSelectedFiles(new Set());
setSelectAll(false);
} else {
setSelectedFiles(new Set(availableFiles));
setSelectAll(true);
}
};
const handleToggleFile = (file: string) => {
const newSelected = new Set(selectedFiles);
if (newSelected.has(file)) {
newSelected.delete(file);
} else {
newSelected.add(file);
}
setSelectedFiles(newSelected);
setSelectAll(newSelected.size === availableFiles.length);
};
const handleFileFormatChange = (file: string, format: 'json' | 'binary') => {
const newFormats = new Map(fileFormats);
newFormats.set(file, format);
setFileFormats(newFormats);
};
const handleBrowseAssetPath = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: '选择资产输出目录',
defaultPath: assetOutputPath || projectPath
});
if (selected) {
const path = selected as string;
setAssetOutputPath(path);
localStorage.setItem('export-asset-path', path);
}
} catch (error) {
console.error('Failed to browse asset path:', error);
}
};
const handleBrowseTypePath = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: '选择类型定义输出目录',
defaultPath: typeOutputPath || projectPath
});
if (selected) {
const path = selected as string;
setTypeOutputPath(path);
localStorage.setItem('export-type-path', path);
}
} catch (error) {
console.error('Failed to browse type path:', error);
}
};
const handleExport = async () => {
if (!assetOutputPath) {
setExportMessage('错误:请选择资产输出路径');
return;
}
if (!typeOutputPath) {
setExportMessage('错误:请选择类型定义输出路径');
return;
}
if (selectedMode === 'workspace' && selectedFiles.size === 0) {
setExportMessage('错误:请至少选择一个文件');
return;
}
if (selectedMode === 'single' && !currentFileName) {
setExportMessage('错误:没有可导出的当前文件');
return;
}
// 保存路径到 localStorage
localStorage.setItem('export-asset-path', assetOutputPath);
localStorage.setItem('export-type-path', typeOutputPath);
setIsExporting(true);
setExportProgress(0);
setExportMessage('正在导出...');
try {
await onExport({
mode: selectedMode,
assetOutputPath,
typeOutputPath,
selectedFiles: selectedMode === 'workspace' ? Array.from(selectedFiles) : [currentFileName!],
fileFormats
});
setExportProgress(100);
setExportMessage('导出成功!');
} catch (error) {
setExportMessage(`导出失败:${error}`);
} finally {
setIsExporting(false);
}
};
return (
<div className="export-dialog-overlay">
<div className="export-dialog" style={{ maxWidth: '700px', width: '90%' }}>
<div className="export-dialog-header">
<h3></h3>
<button onClick={onClose} className="export-dialog-close">
<X size={20} />
</button>
</div>
<div className="export-dialog-content" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
{/* Tab 页签 */}
<div className="export-mode-tabs">
<button
className={`export-mode-tab ${selectedMode === 'workspace' ? 'active' : ''}`}
onClick={() => hasProject ? setSelectedMode('workspace') : null}
disabled={!hasProject}
>
<FolderTree size={16} />
</button>
<button
className={`export-mode-tab ${selectedMode === 'single' ? 'active' : ''}`}
onClick={() => setSelectedMode('single')}
>
<File size={16} />
</button>
</div>
{/* 资产输出路径 */}
<div className="export-section">
<h4></h4>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={assetOutputPath}
onChange={(e) => setAssetOutputPath(e.target.value)}
placeholder="选择资产输出目录(.btree.bin / .btree.json..."
style={{
flex: 1,
padding: '8px 12px',
backgroundColor: '#2d2d2d',
border: '1px solid #3a3a3a',
borderRadius: '4px',
color: '#cccccc',
fontSize: '12px'
}}
/>
<button
onClick={handleBrowseAssetPath}
style={{
padding: '8px 16px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<FolderOpen size={14} />
</button>
</div>
</div>
{/* TypeScript 类型定义输出路径 */}
<div className="export-section">
<h4>TypeScript </h4>
<div style={{ marginBottom: '12px', fontSize: '11px', color: '#999', lineHeight: '1.5' }}>
{selectedMode === 'workspace' ? (
<>
<br />
.d.ts<br />
GlobalBlackboard.ts
</>
) : (
'将导出当前行为树的黑板变量类型(.d.ts'
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={typeOutputPath}
onChange={(e) => setTypeOutputPath(e.target.value)}
placeholder="选择类型定义输出目录..."
style={{
flex: 1,
padding: '8px 12px',
backgroundColor: '#2d2d2d',
border: '1px solid #3a3a3a',
borderRadius: '4px',
color: '#cccccc',
fontSize: '12px'
}}
/>
<button
onClick={handleBrowseTypePath}
style={{
padding: '8px 16px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<FolderOpen size={14} />
</button>
</div>
</div>
{/* 文件列表 */}
{selectedMode === 'workspace' && availableFiles.length > 0 && (
<div className="export-section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h4 style={{ margin: 0, fontSize: '13px', color: '#ccc' }}>
({selectedFiles.size}/{availableFiles.length})
</h4>
<button
onClick={handleSelectAll}
style={{
padding: '4px 12px',
backgroundColor: '#3a3a3a',
border: 'none',
borderRadius: '3px',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px'
}}
>
{selectAll ? '取消全选' : '全选'}
</button>
</div>
<div className="export-file-list">
{availableFiles.map((file) => (
<div
key={file}
className={`export-file-item ${selectedFiles.has(file) ? 'selected' : ''}`}
>
<input
type="checkbox"
className="export-file-checkbox"
checked={selectedFiles.has(file)}
onChange={() => handleToggleFile(file)}
/>
<div className="export-file-name">
<File size={14} />
{file}.btree
</div>
<select
className="export-file-format"
value={fileFormats.get(file) || 'binary'}
onChange={(e) => handleFileFormatChange(file, e.target.value as 'json' | 'binary')}
onClick={(e) => e.stopPropagation()}
>
<option value="binary"></option>
<option value="json">JSON</option>
</select>
</div>
))}
</div>
</div>
)}
{/* 单文件模式 */}
{selectedMode === 'single' && (
<div className="export-section">
<h4></h4>
{currentFileName ? (
<div className="export-file-list">
<div className="export-file-item selected">
<div className="export-file-name" style={{ paddingLeft: '8px' }}>
<File size={14} />
{currentFileName}.btree
</div>
<select
className="export-file-format"
value={fileFormats.get(currentFileName) || 'binary'}
onChange={(e) => handleFileFormatChange(currentFileName, e.target.value as 'json' | 'binary')}
>
<option value="binary"></option>
<option value="json">JSON</option>
</select>
</div>
</div>
) : (
<div style={{
padding: '40px 20px',
textAlign: 'center',
color: '#999',
fontSize: '13px',
backgroundColor: '#252525',
borderRadius: '6px',
border: '1px solid #3a3a3a'
}}>
<File size={32} style={{ margin: '0 auto 12px', opacity: 0.5 }} />
<div></div>
<div style={{ fontSize: '11px', marginTop: '8px' }}>
</div>
</div>
)}
</div>
)}
</div>
<div className="export-dialog-footer">
{exportMessage && (
<div style={{
flex: 1,
fontSize: '12px',
color: exportMessage.startsWith('错误') ? '#f48771' : exportMessage.includes('成功') ? '#89d185' : '#ccc',
paddingLeft: '8px'
}}>
{exportMessage}
</div>
)}
{isExporting && (
<div style={{
flex: 1,
height: '4px',
backgroundColor: '#3a3a3a',
borderRadius: '2px',
overflow: 'hidden',
marginRight: '12px'
}}>
<div style={{
height: '100%',
width: `${exportProgress}%`,
backgroundColor: '#0e639c',
transition: 'width 0.3s'
}}></div>
</div>
)}
<button onClick={onClose} className="export-dialog-btn export-dialog-btn-cancel">
</button>
<button
onClick={handleExport}
className="export-dialog-btn export-dialog-btn-primary"
disabled={isExporting}
style={{ opacity: isExporting ? 0.5 : 1 }}
>
{isExporting ? '导出中...' : '导出'}
</button>
</div>
</div>
</div>
);
};

View File

@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react';
import { Folder, ChevronRight, ChevronDown } from 'lucide-react';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import '../styles/FileTree.css';
interface TreeNode {
name: string;
path: string;
type: 'file' | 'folder';
extension?: string;
type: 'folder';
children?: TreeNode[];
expanded?: boolean;
loaded?: boolean;
@@ -34,8 +34,20 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
setLoading(true);
try {
const entries = await TauriAPI.listDirectory(path);
const nodes = entriesToNodes(entries);
setTree(nodes);
const children = entriesToNodes(entries);
// 创建根节点
const rootName = path.split(/[/\\]/).filter(p => p).pop() || 'Project';
const rootNode: TreeNode = {
name: rootName,
path: path,
type: 'folder',
children: children,
expanded: true,
loaded: true
};
setTree([rootNode]);
} catch (error) {
console.error('Failed to load directory:', error);
setTree([]);
@@ -45,17 +57,17 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
};
const entriesToNodes = (entries: DirectoryEntry[]): TreeNode[] => {
return entries.map(entry => ({
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' : 'file',
extension: !entry.is_dir && entry.name.includes('.')
? entry.name.split('.').pop()
: undefined,
children: entry.is_dir ? [] : undefined,
expanded: false,
loaded: false
}));
// 只显示文件夹,过滤掉文件
return entries
.filter(entry => entry.is_dir)
.map(entry => ({
name: entry.name,
path: entry.path,
type: 'folder' as const,
children: [],
expanded: false,
loaded: false
}));
};
const loadChildren = async (node: TreeNode): Promise<TreeNode[]> => {
@@ -72,7 +84,7 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
const updateTree = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
const newNodes: TreeNode[] = [];
for (const node of nodes) {
if (node.path === nodePath && node.type === 'folder') {
if (node.path === nodePath) {
if (!node.loaded) {
const children = await loadChildren(node);
newNodes.push({
@@ -105,28 +117,7 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
const handleNodeClick = (node: TreeNode) => {
onSelectFile?.(node.path);
if (node.type === 'folder') {
toggleNode(node.path);
}
};
const getFileIcon = (extension?: string) => {
switch (extension?.toLowerCase()) {
case 'ts':
case 'tsx':
case 'js':
case 'jsx':
return '📄';
case 'json':
return '📋';
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
return '🖼️';
default:
return '📄';
}
toggleNode(node.path);
};
const renderNode = (node: TreeNode, level: number = 0) => {
@@ -140,17 +131,15 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
style={{ paddingLeft: `${indent}px` }}
onClick={() => handleNodeClick(node)}
>
{node.type === 'folder' && (
<span className="tree-arrow">
{node.expanded ? '▼' : '▶'}
</span>
)}
<span className="tree-arrow">
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className="tree-icon">
{node.type === 'folder' ? '📁' : getFileIcon(node.extension)}
<Folder size={16} />
</span>
<span className="tree-label">{node.name}</span>
</div>
{node.type === 'folder' && node.expanded && node.children && (
{node.expanded && node.children && (
<div className="tree-children">
{node.children.map(child => renderNode(child, level + 1))}
</div>
@@ -164,7 +153,7 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
}
if (!rootPath || tree.length === 0) {
return <div className="file-tree empty">No files</div>;
return <div className="file-tree empty">No folders</div>;
}
return (

View File

@@ -0,0 +1,161 @@
import { useRef, useCallback, ReactNode, useMemo } from 'react';
import { Layout, Model, TabNode, IJsonModel, Actions, IJsonTabSetNode, IJsonRowNode } from 'flexlayout-react';
import 'flexlayout-react/style/light.css';
import '../styles/FlexLayoutDock.css';
export interface FlexDockPanel {
id: string;
title: string;
content: ReactNode;
closable?: boolean;
}
interface FlexLayoutDockContainerProps {
panels: FlexDockPanel[];
onPanelClose?: (panelId: string) => void;
}
export function FlexLayoutDockContainer({ panels, onPanelClose }: FlexLayoutDockContainerProps) {
const createDefaultLayout = useCallback((): IJsonModel => {
const leftPanels = panels.filter(p => p.id.includes('hierarchy'));
const rightPanels = panels.filter(p => p.id.includes('inspector'));
const bottomPanels = panels.filter(p => p.id.includes('console') || p.id.includes('asset'))
.sort((a, b) => {
// 控制台排在前面
if (a.id.includes('console')) return -1;
if (b.id.includes('console')) return 1;
return 0;
});
const centerPanels = panels.filter(p =>
!leftPanels.includes(p) && !rightPanels.includes(p) && !bottomPanels.includes(p)
);
// Build center column children
const centerColumnChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
if (centerPanels.length > 0) {
centerColumnChildren.push({
type: 'tabset',
weight: 70,
children: centerPanels.map(p => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false,
})),
});
}
if (bottomPanels.length > 0) {
centerColumnChildren.push({
type: 'tabset',
weight: 30,
children: bottomPanels.map(p => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false,
})),
});
}
// Build main row children
const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
if (leftPanels.length > 0) {
mainRowChildren.push({
type: 'tabset',
weight: 20,
children: leftPanels.map(p => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false,
})),
});
}
if (centerColumnChildren.length > 0) {
if (centerColumnChildren.length === 1) {
const centerChild = centerColumnChildren[0];
if (centerChild && centerChild.type === 'tabset') {
mainRowChildren.push({
type: 'tabset',
weight: 60,
children: centerChild.children
} as IJsonTabSetNode);
} else if (centerChild) {
mainRowChildren.push({
type: 'row',
weight: 60,
children: centerChild.children
} as IJsonRowNode);
}
} else {
mainRowChildren.push({
type: 'row',
weight: 60,
children: centerColumnChildren,
});
}
}
if (rightPanels.length > 0) {
mainRowChildren.push({
type: 'tabset',
weight: 20,
children: rightPanels.map(p => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false,
})),
});
}
return {
global: {
tabEnableClose: true,
tabEnableRename: false,
tabSetEnableMaximize: false,
tabSetEnableDrop: true,
tabSetEnableDrag: true,
tabSetEnableDivide: true,
borderEnableDrop: true,
},
borders: [],
layout: {
type: 'row',
weight: 100,
children: mainRowChildren,
},
};
}, [panels]);
const model = useMemo(() => Model.fromJson(createDefaultLayout()), [createDefaultLayout]);
const factory = useCallback((node: TabNode) => {
const component = node.getComponent();
const panel = panels.find(p => p.id === component);
return panel?.content || <div>Panel not found</div>;
}, [panels]);
const onAction = useCallback((action: any) => {
if (action.type === Actions.DELETE_TAB) {
const tabId = action.data.node;
if (onPanelClose) {
onPanelClose(tabId);
}
}
return action;
}, [onPanelClose]);
return (
<div className="flexlayout-dock-container">
<Layout
model={model}
factory={factory}
onAction={onAction}
/>
</div>
);
}

View File

@@ -1,11 +1,13 @@
import { useState, useRef, useEffect } from 'react';
import { UIRegistry, MessageHub, EditorPluginManager } from '@esengine/editor-core';
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import '../styles/MenuBar.css';
interface MenuItem {
label?: string;
shortcut?: string;
icon?: string;
disabled?: boolean;
separator?: boolean;
submenu?: MenuItem[];
@@ -212,15 +214,9 @@ export function MenuBar({
{ label: t('selectAll'), shortcut: 'Ctrl+A', disabled: true }
],
window: [
{ label: t('sceneHierarchy'), disabled: true },
{ label: t('inspector'), disabled: true },
{ label: t('assets'), disabled: true },
{ label: t('console'), disabled: true },
{ label: t('viewport'), disabled: true },
{ separator: true },
...pluginMenuItems.map(item => ({
label: item.label || '',
shortcut: item.shortcut,
icon: item.icon,
disabled: item.disabled,
onClick: item.onClick
})),
@@ -282,6 +278,7 @@ export function MenuBar({
if (item.separator) {
return <div key={index} className="menu-separator" />;
}
const IconComponent = item.icon ? (LucideIcons as any)[item.icon] : null;
return (
<button
key={index}
@@ -289,7 +286,10 @@ export function MenuBar({
onClick={() => handleMenuItemClick(item)}
disabled={item.disabled}
>
<span>{item.label || ''}</span>
<span className="menu-item-content">
{IconComponent && <IconComponent size={16} />}
<span>{item.label || ''}</span>
</span>
{item.shortcut && <span className="menu-shortcut">{item.shortcut}</span>}
</button>
);

View File

@@ -0,0 +1,27 @@
import React from 'react';
import * as LucideIcons from 'lucide-react';
interface NodeIconProps {
iconName?: string;
size?: number;
color?: string;
}
/**
* 节点图标组件
*
* 根据图标名称渲染对应的 Lucide 图标
*/
export const NodeIcon: React.FC<NodeIconProps> = ({ iconName, size = 16, color }) => {
if (!iconName) {
return null;
}
const IconComponent = (LucideIcons as any)[iconName];
if (!IconComponent) {
return <span>{iconName}</span>;
}
return <IconComponent size={size} color={color} />;
};

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { EditorPluginManager, IEditorPluginMetadata, EditorPluginCategory } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import { Package, CheckCircle, XCircle, Search, Grid, List, ChevronDown, ChevronRight, X } from 'lucide-react';
import '../styles/PluginManagerWindow.css';
@@ -9,11 +10,11 @@ interface PluginManagerWindowProps {
}
const categoryIcons: Record<EditorPluginCategory, string> = {
[EditorPluginCategory.Tool]: '🔧',
[EditorPluginCategory.Window]: '🪟',
[EditorPluginCategory.Inspector]: '🔍',
[EditorPluginCategory.System]: '⚙️',
[EditorPluginCategory.ImportExport]: '📦'
[EditorPluginCategory.Tool]: 'Wrench',
[EditorPluginCategory.Window]: 'LayoutGrid',
[EditorPluginCategory.Inspector]: 'Search',
[EditorPluginCategory.System]: 'Settings',
[EditorPluginCategory.ImportExport]: 'Package'
};
const categoryNames: Record<EditorPluginCategory, string> = {
@@ -86,70 +87,80 @@ export function PluginManagerWindow({ pluginManager, onClose }: PluginManagerWin
const enabledCount = plugins.filter(p => p.enabled).length;
const disabledCount = plugins.filter(p => !p.enabled).length;
const renderPluginCard = (plugin: IEditorPluginMetadata) => (
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-card-header">
<div className="plugin-card-icon">
{plugin.icon || <Package size={24} />}
const renderPluginCard = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-card-header">
<div className="plugin-card-icon">
{IconComponent ? <IconComponent size={24} /> : <Package size={24} />}
</div>
<div className="plugin-card-info">
<div className="plugin-card-title">{plugin.displayName}</div>
<div className="plugin-card-version">v{plugin.version}</div>
</div>
<button
className={`plugin-toggle ${plugin.enabled ? 'enabled' : 'disabled'}`}
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
>
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
</button>
</div>
<div className="plugin-card-info">
<div className="plugin-card-title">{plugin.displayName}</div>
<div className="plugin-card-version">v{plugin.version}</div>
{plugin.description && (
<div className="plugin-card-description">{plugin.description}</div>
)}
<div className="plugin-card-footer">
<span className="plugin-card-category">
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[plugin.category]];
return CategoryIcon ? <CategoryIcon size={14} style={{ marginRight: '4px' }} /> : null;
})()}
{categoryNames[plugin.category]}
</span>
{plugin.installedAt && (
<span className="plugin-card-installed">
Installed: {new Date(plugin.installedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
);
};
const renderPluginList = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-list-icon">
{IconComponent ? <IconComponent size={20} /> : <Package size={20} />}
</div>
<div className="plugin-list-info">
<div className="plugin-list-name">
{plugin.displayName}
<span className="plugin-list-version">v{plugin.version}</span>
</div>
{plugin.description && (
<div className="plugin-list-description">{plugin.description}</div>
)}
</div>
<div className="plugin-list-status">
{plugin.enabled ? (
<span className="status-badge enabled">Enabled</span>
) : (
<span className="status-badge disabled">Disabled</span>
)}
</div>
<button
className={`plugin-toggle ${plugin.enabled ? 'enabled' : 'disabled'}`}
className="plugin-list-toggle"
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
>
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
{plugin.enabled ? 'Disable' : 'Enable'}
</button>
</div>
{plugin.description && (
<div className="plugin-card-description">{plugin.description}</div>
)}
<div className="plugin-card-footer">
<span className="plugin-card-category">
{categoryIcons[plugin.category]} {categoryNames[plugin.category]}
</span>
{plugin.installedAt && (
<span className="plugin-card-installed">
Installed: {new Date(plugin.installedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
);
const renderPluginList = (plugin: IEditorPluginMetadata) => (
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-list-icon">
{plugin.icon || <Package size={20} />}
</div>
<div className="plugin-list-info">
<div className="plugin-list-name">
{plugin.displayName}
<span className="plugin-list-version">v{plugin.version}</span>
</div>
{plugin.description && (
<div className="plugin-list-description">{plugin.description}</div>
)}
</div>
<div className="plugin-list-status">
{plugin.enabled ? (
<span className="status-badge enabled">Enabled</span>
) : (
<span className="status-badge disabled">Disabled</span>
)}
</div>
<button
className="plugin-list-toggle"
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
>
{plugin.enabled ? 'Disable' : 'Enable'}
</button>
</div>
);
);
};
return (
<div className="plugin-manager-overlay" onClick={onClose}>
@@ -227,7 +238,12 @@ export function PluginManagerWindow({ pluginManager, onClose }: PluginManagerWin
<button className="plugin-category-toggle">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<span className="plugin-category-icon">{categoryIcons[cat]}</span>
<span className="plugin-category-icon">
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[cat]];
return CategoryIcon ? <CategoryIcon size={16} /> : null;
})()}
</span>
<span className="plugin-category-name">{categoryNames[cat]}</span>
<span className="plugin-category-count">
{categoryPlugins.length}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { EditorPluginManager, IEditorPluginMetadata, EditorPluginCategory } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import { Package, CheckCircle, XCircle, Search, Grid, List, ChevronDown, ChevronRight } from 'lucide-react';
import '../styles/PluginPanel.css';
@@ -8,11 +9,11 @@ interface PluginPanelProps {
}
const categoryIcons: Record<EditorPluginCategory, string> = {
[EditorPluginCategory.Tool]: '🔧',
[EditorPluginCategory.Window]: '🪟',
[EditorPluginCategory.Inspector]: '🔍',
[EditorPluginCategory.System]: '⚙️',
[EditorPluginCategory.ImportExport]: '📦'
[EditorPluginCategory.Tool]: 'Wrench',
[EditorPluginCategory.Window]: 'LayoutGrid',
[EditorPluginCategory.Inspector]: 'Search',
[EditorPluginCategory.System]: 'Settings',
[EditorPluginCategory.ImportExport]: 'Package'
};
const categoryNames: Record<EditorPluginCategory, string> = {
@@ -85,11 +86,13 @@ export function PluginPanel({ pluginManager }: PluginPanelProps) {
const enabledCount = plugins.filter(p => p.enabled).length;
const disabledCount = plugins.filter(p => !p.enabled).length;
const renderPluginCard = (plugin: IEditorPluginMetadata) => (
const renderPluginCard = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-card-header">
<div className="plugin-card-icon">
{plugin.icon || <Package size={24} />}
{IconComponent ? <IconComponent size={24} /> : <Package size={24} />}
</div>
<div className="plugin-card-info">
<div className="plugin-card-title">{plugin.displayName}</div>
@@ -108,7 +111,11 @@ export function PluginPanel({ pluginManager }: PluginPanelProps) {
)}
<div className="plugin-card-footer">
<span className="plugin-card-category">
{categoryIcons[plugin.category]} {categoryNames[plugin.category]}
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[plugin.category]];
return CategoryIcon ? <CategoryIcon size={14} style={{ marginRight: '4px' }} /> : null;
})()}
{categoryNames[plugin.category]}
</span>
{plugin.installedAt && (
<span className="plugin-card-installed">
@@ -117,12 +124,15 @@ export function PluginPanel({ pluginManager }: PluginPanelProps) {
)}
</div>
</div>
);
);
};
const renderPluginList = (plugin: IEditorPluginMetadata) => (
const renderPluginList = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-list-icon">
{plugin.icon || <Package size={20} />}
{IconComponent ? <IconComponent size={20} /> : <Package size={20} />}
</div>
<div className="plugin-list-info">
<div className="plugin-list-name">
@@ -148,7 +158,8 @@ export function PluginPanel({ pluginManager }: PluginPanelProps) {
{plugin.enabled ? 'Disable' : 'Enable'}
</button>
</div>
);
);
};
return (
<div className="plugin-panel">
@@ -215,7 +226,12 @@ export function PluginPanel({ pluginManager }: PluginPanelProps) {
<button className="plugin-category-toggle">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<span className="plugin-category-icon">{categoryIcons[cat]}</span>
<span className="plugin-category-icon">
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[cat]];
return CategoryIcon ? <CategoryIcon size={16} /> : null;
})()}
</span>
<span className="plugin-category-name">{categoryNames[cat]}</span>
<span className="plugin-category-count">
{categoryPlugins.length}

View File

@@ -125,7 +125,7 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
});
// 请求第一个实体的详情以获取场景名称
if (!remoteSceneName && data.entities.length > 0) {
if (!remoteSceneName && data.entities.length > 0 && data.entities[0]) {
profilerService.requestEntityDetails(data.entities[0].id);
}
} else if (!connected) {

View File

@@ -1,62 +0,0 @@
import { useState, ReactNode } from 'react';
import '../styles/TabPanel.css';
export interface TabItem {
id: string;
title: string;
content: ReactNode;
closable?: boolean;
}
interface TabPanelProps {
tabs: TabItem[];
activeTabId?: string;
onTabChange?: (tabId: string) => void;
onTabClose?: (tabId: string) => void;
}
export function TabPanel({ tabs, activeTabId, onTabChange, onTabClose }: TabPanelProps) {
const [activeTab, setActiveTab] = useState(activeTabId || tabs[0]?.id);
const handleTabClick = (tabId: string) => {
setActiveTab(tabId);
onTabChange?.(tabId);
};
const handleCloseTab = (e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
onTabClose?.(tabId);
};
const currentTab = tabs.find(tab => tab.id === activeTab);
return (
<div className="tab-panel">
<div className="tab-header">
<div className="tab-list">
{tabs.map(tab => (
<div
key={tab.id}
className={`tab-item ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => handleTabClick(tab.id)}
>
<span className="tab-title">{tab.title}</span>
{tab.closable && (
<button
className="tab-close"
onClick={(e) => handleCloseTab(e, tab.id)}
title="Close"
>
×
</button>
)}
</div>
))}
</div>
</div>
<div className="tab-content">
{currentTab?.content}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react';
import '../styles/Toast.css';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}
interface ToastContextValue {
showToast: (message: string, type?: ToastType, duration?: number) => void;
hideToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
};
interface ToastProviderProps {
children: ReactNode;
}
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = `toast-${Date.now()}-${Math.random()}`;
const toast: Toast = { id, message, type, duration };
setToasts(prev => [...prev, toast]);
if (duration > 0) {
setTimeout(() => {
hideToast(id);
}, duration);
}
}, []);
const hideToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const getIcon = (type: ToastType) => {
switch (type) {
case 'success':
return <CheckCircle size={20} />;
case 'error':
return <XCircle size={20} />;
case 'warning':
return <AlertCircle size={20} />;
case 'info':
return <Info size={20} />;
}
};
return (
<ToastContext.Provider value={{ showToast, hideToast }}>
{children}
<div className="toast-container">
{toasts.map(toast => (
<div key={toast.id} className={`toast toast-${toast.type}`}>
<div className="toast-icon">
{getIcon(toast.type)}
</div>
<div className="toast-message">{toast.message}</div>
<button
className="toast-close"
onClick={() => hideToast(toast.id)}
aria-label="关闭"
>
<X size={16} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};

View File

@@ -0,0 +1,501 @@
import { GlobalBlackboardConfig, BlackboardValueType } from '@esengine/behavior-tree';
/**
* 类型生成配置选项
*/
export interface TypeGenerationOptions {
/** 常量名称大小写风格 */
constantCase?: 'UPPER_SNAKE' | 'camelCase' | 'PascalCase';
/** 常量对象名称 */
constantsName?: string;
/** 接口名称 */
interfaceName?: string;
/** 类型别名名称 */
typeAliasName?: string;
/** 包装类名称 */
wrapperClassName?: string;
/** 默认值对象名称 */
defaultsName?: string;
/** 导入路径 */
importPath?: string;
/** 是否生成常量对象 */
includeConstants?: boolean;
/** 是否生成接口 */
includeInterface?: boolean;
/** 是否生成类型别名 */
includeTypeAlias?: boolean;
/** 是否生成包装类 */
includeWrapperClass?: boolean;
/** 是否生成默认值 */
includeDefaults?: boolean;
/** 自定义头部注释 */
customHeader?: string;
/** 使用单引号还是双引号 */
quoteStyle?: 'single' | 'double';
/** 是否在文件末尾添加换行 */
trailingNewline?: boolean;
}
/**
* 全局黑板 TypeScript 类型生成器
*
* 将全局黑板配置导出为 TypeScript 类型定义,提供:
* - 编译时类型检查
* - IDE 自动补全
* - 避免拼写错误
* - 重构友好
*/
export class GlobalBlackboardTypeGenerator {
/**
* 默认生成选项
*/
static readonly DEFAULT_OPTIONS: Required<TypeGenerationOptions> = {
constantCase: 'UPPER_SNAKE',
constantsName: 'GlobalVars',
interfaceName: 'GlobalBlackboardTypes',
typeAliasName: 'GlobalVariableName',
wrapperClassName: 'TypedGlobalBlackboard',
defaultsName: 'GlobalBlackboardDefaults',
importPath: '@esengine/behavior-tree',
includeConstants: true,
includeInterface: true,
includeTypeAlias: true,
includeWrapperClass: true,
includeDefaults: true,
customHeader: '',
quoteStyle: 'single',
trailingNewline: true
};
/**
* 生成 TypeScript 类型定义代码
*
* @param config 全局黑板配置
* @param options 生成选项
* @returns TypeScript 代码字符串
*
* @example
* ```typescript
* // 使用默认选项
* const code = GlobalBlackboardTypeGenerator.generate(config);
*
* // 自定义命名
* const code = GlobalBlackboardTypeGenerator.generate(config, {
* constantsName: 'GameVars',
* wrapperClassName: 'GameBlackboard'
* });
*
* // 只生成接口和类型别名,不生成包装类
* const code = GlobalBlackboardTypeGenerator.generate(config, {
* includeWrapperClass: false,
* includeDefaults: false
* });
* ```
*/
static generate(config: GlobalBlackboardConfig, options?: TypeGenerationOptions): string {
const opts = { ...this.DEFAULT_OPTIONS, ...options };
const now = new Date().toLocaleString('zh-CN', { hour12: false });
const variables = config.variables || [];
const parts: string[] = [];
// 生成文件头部注释
parts.push(this.generateHeader(now, opts));
// 根据配置生成各个部分
if (opts.includeConstants) {
parts.push(this.generateConstants(variables, opts));
}
if (opts.includeInterface) {
parts.push(this.generateInterface(variables, opts));
}
if (opts.includeTypeAlias) {
parts.push(this.generateTypeAliases(opts));
}
if (opts.includeWrapperClass) {
parts.push(this.generateTypedClass(opts));
}
if (opts.includeDefaults) {
parts.push(this.generateDefaults(variables, opts));
}
// 组合所有部分
let code = parts.join('\n\n');
// 添加文件末尾换行
if (opts.trailingNewline && !code.endsWith('\n')) {
code += '\n';
}
return code;
}
/**
* 生成文件头部注释
*/
private static generateHeader(timestamp: string, opts: Required<TypeGenerationOptions>): string {
const customHeader = opts.customHeader || `/**
* 全局黑板类型定义
*
* ⚠️ 此文件由编辑器自动生成,请勿手动修改!
* 生成时间: ${timestamp}
*/`;
return `${customHeader}
import { GlobalBlackboardService } from '${opts.importPath}';`;
}
/**
* 生成常量对象
*/
private static generateConstants(variables: any[], opts: Required<TypeGenerationOptions>): string {
const quote = opts.quoteStyle === 'single' ? "'" : '"';
if (variables.length === 0) {
return `/**
* 全局变量名称常量
*/
export const ${opts.constantsName} = {} as const;`;
}
// 按命名空间分组
const grouped = this.groupVariablesByNamespace(variables);
if (Object.keys(grouped).length === 1 && grouped[''] !== undefined) {
// 无命名空间,扁平结构
const entries = variables
.map(v => ` ${this.transformName(v.name, opts.constantCase)}: ${quote}${v.name}${quote}`)
.join(',\n');
return `/**
* 全局变量名称常量
* 使用常量避免拼写错误
*/
export const ${opts.constantsName} = {
${entries}
} as const;`;
} else {
// 有命名空间,分组结构
const namespaces = Object.entries(grouped)
.map(([namespace, vars]) => {
if (namespace === '') {
// 根级别变量
return vars
.map(v => ` ${this.transformName(v.name, opts.constantCase)}: ${quote}${v.name}${quote}`)
.join(',\n');
} else {
// 命名空间变量
const nsName = this.toPascalCase(namespace);
const entries = vars
.map(v => {
const shortName = v.name.substring(namespace.length + 1);
return ` ${this.transformName(shortName, opts.constantCase)}: ${quote}${v.name}${quote}`;
})
.join(',\n');
return ` ${nsName}: {\n${entries}\n }`;
}
})
.join(',\n');
return `/**
* 全局变量名称常量
* 使用常量避免拼写错误
*/
export const ${opts.constantsName} = {
${namespaces}
} as const;`;
}
}
/**
* 生成接口定义
*/
private static generateInterface(variables: any[], opts: Required<TypeGenerationOptions>): string {
if (variables.length === 0) {
return `/**
* 全局变量类型定义
*/
export interface ${opts.interfaceName} {}`;
}
const properties = variables
.map(v => {
const tsType = this.mapBlackboardTypeToTS(v.type);
const comment = v.description ? ` /** ${v.description} */\n` : '';
return `${comment} ${v.name}: ${tsType};`;
})
.join('\n');
return `/**
* 全局变量类型定义
*/
export interface ${opts.interfaceName} {
${properties}
}`;
}
/**
* 生成类型别名
*/
private static generateTypeAliases(opts: Required<TypeGenerationOptions>): string {
return `/**
* 全局变量名称联合类型
*/
export type ${opts.typeAliasName} = keyof ${opts.interfaceName};`;
}
/**
* 生成类型安全包装类
*/
private static generateTypedClass(opts: Required<TypeGenerationOptions>): string {
return `/**
* 类型安全的全局黑板服务包装器
*
* @example
* \`\`\`typescript
* // 游戏运行时使用
* const service = core.services.resolve(GlobalBlackboardService);
* const gb = new ${opts.wrapperClassName}(service);
*
* // 类型安全的获取
* const hp = gb.getValue('playerHP'); // 类型: number | undefined
*
* // 类型安全的设置
* gb.setValue('playerHP', 100); // ✅ 正确
* gb.setValue('playerHP', 'invalid'); // ❌ 编译错误
* \`\`\`
*/
export class ${opts.wrapperClassName} {
constructor(private service: GlobalBlackboardService) {}
/**
* 获取全局变量(类型安全)
*/
getValue<K extends ${opts.typeAliasName}>(
name: K
): ${opts.interfaceName}[K] | undefined {
return this.service.getValue(name);
}
/**
* 设置全局变量(类型安全)
*/
setValue<K extends ${opts.typeAliasName}>(
name: K,
value: ${opts.interfaceName}[K]
): boolean {
return this.service.setValue(name, value);
}
/**
* 检查全局变量是否存在
*/
hasVariable(name: ${opts.typeAliasName}): boolean {
return this.service.hasVariable(name);
}
/**
* 获取所有变量名
*/
getVariableNames(): ${opts.typeAliasName}[] {
return this.service.getVariableNames() as ${opts.typeAliasName}[];
}
}`;
}
/**
* 生成默认值配置
*/
private static generateDefaults(variables: any[], opts: Required<TypeGenerationOptions>): string {
if (variables.length === 0) {
return `/**
* 默认值配置
*/
export const ${opts.defaultsName}: ${opts.interfaceName} = {};`;
}
const properties = variables
.map(v => {
const value = this.formatValue(v.value, v.type, opts);
return ` ${v.name}: ${value}`;
})
.join(',\n');
return `/**
* 默认值配置
*
* 可在游戏启动时用于初始化全局黑板
*
* @example
* \`\`\`typescript
* // 获取服务
* const service = core.services.resolve(GlobalBlackboardService);
*
* // 初始化配置
* const config = {
* version: '1.0',
* variables: Object.entries(${opts.defaultsName}).map(([name, value]) => ({
* name,
* type: typeof value as BlackboardValueType,
* value
* }))
* };
* service.importConfig(config);
* \`\`\`
*/
export const ${opts.defaultsName}: ${opts.interfaceName} = {
${properties}
};`;
}
/**
* 按命名空间分组变量
*/
private static groupVariablesByNamespace(variables: any[]): Record<string, any[]> {
const groups: Record<string, any[]> = { '': [] };
for (const variable of variables) {
const dotIndex = variable.name.indexOf('.');
if (dotIndex === -1) {
groups['']!.push(variable);
} else {
const namespace = variable.name.substring(0, dotIndex);
if (!groups[namespace]) {
groups[namespace] = [];
}
groups[namespace]!.push(variable);
}
}
return groups;
}
/**
* 将变量名转换为常量名UPPER_SNAKE_CASE
*/
private static toConstantName(name: string): string {
// player.hp -> PLAYER_HP
// playerHP -> PLAYER_HP
return name
.replace(/\./g, '_')
.replace(/([a-z])([A-Z])/g, '$1_$2')
.toUpperCase();
}
/**
* 转换为 PascalCase
*/
private static toPascalCase(str: string): string {
return str
.split(/[._-]/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join('');
}
/**
* 映射黑板类型到 TypeScript 类型
*/
private static mapBlackboardTypeToTS(type: BlackboardValueType): string {
switch (type) {
case BlackboardValueType.Number:
return 'number';
case BlackboardValueType.String:
return 'string';
case BlackboardValueType.Boolean:
return 'boolean';
case BlackboardValueType.Vector2:
return '{ x: number; y: number }';
case BlackboardValueType.Vector3:
return '{ x: number; y: number; z: number }';
case BlackboardValueType.Object:
return 'any';
case BlackboardValueType.Array:
return 'any[]';
default:
return 'any';
}
}
/**
* 格式化值为 TypeScript 字面量
*/
private static formatValue(value: any, type: BlackboardValueType, opts: Required<TypeGenerationOptions>): string {
if (value === null || value === undefined) {
return 'undefined';
}
const quote = opts.quoteStyle === 'single' ? "'" : '"';
const escapeRegex = opts.quoteStyle === 'single' ? /'/g : /"/g;
const escapeChar = opts.quoteStyle === 'single' ? "\\'" : '\\"';
switch (type) {
case BlackboardValueType.String:
return `${quote}${value.toString().replace(escapeRegex, escapeChar)}${quote}`;
case BlackboardValueType.Number:
case BlackboardValueType.Boolean:
return String(value);
case BlackboardValueType.Vector2:
if (typeof value === 'object' && value.x !== undefined && value.y !== undefined) {
return `{ x: ${value.x}, y: ${value.y} }`;
}
return '{ x: 0, y: 0 }';
case BlackboardValueType.Vector3:
if (typeof value === 'object' && value.x !== undefined && value.y !== undefined && value.z !== undefined) {
return `{ x: ${value.x}, y: ${value.y}, z: ${value.z} }`;
}
return '{ x: 0, y: 0, z: 0 }';
case BlackboardValueType.Array:
return '[]';
case BlackboardValueType.Object:
return '{}';
default:
return 'undefined';
}
}
/**
* 根据指定的大小写风格转换变量名
*/
private static transformName(name: string, caseStyle: 'UPPER_SNAKE' | 'camelCase' | 'PascalCase'): string {
switch (caseStyle) {
case 'UPPER_SNAKE':
return this.toConstantName(name);
case 'camelCase':
return this.toCamelCase(name);
case 'PascalCase':
return this.toPascalCase(name);
default:
return name;
}
}
/**
* 转换为 camelCase
*/
private static toCamelCase(str: string): string {
const parts = str.split(/[._-]/);
if (parts.length === 0) return str;
return (parts[0] || '').toLowerCase() + parts.slice(1)
.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join('');
}
}

View File

@@ -0,0 +1,292 @@
import { BlackboardValueType } from '@esengine/behavior-tree';
/**
* 局部黑板变量信息
*/
export interface LocalBlackboardVariable {
name: string;
type: string;
value: any;
}
/**
* 局部黑板类型生成配置
*/
export interface LocalTypeGenerationOptions {
/** 行为树名称 */
behaviorTreeName: string;
/** 是否生成常量枚举 */
includeConstants?: boolean;
/** 是否生成默认值 */
includeDefaults?: boolean;
/** 是否生成辅助函数 */
includeHelpers?: boolean;
/** 使用单引号还是双引号 */
quoteStyle?: 'single' | 'double';
}
/**
* 局部黑板 TypeScript 类型生成器
*
* 为行为树的局部黑板变量生成类型安全的 TypeScript 定义
*/
export class LocalBlackboardTypeGenerator {
/**
* 生成局部黑板的 TypeScript 类型定义
*
* @param variables 黑板变量列表
* @param options 生成配置
* @returns TypeScript 代码
*/
static generate(
variables: Record<string, any>,
options: LocalTypeGenerationOptions
): string {
const opts = {
includeConstants: true,
includeDefaults: true,
includeHelpers: true,
quoteStyle: 'single' as const,
...options
};
const quote = opts.quoteStyle === 'single' ? "'" : '"';
const now = new Date().toLocaleString('zh-CN', { hour12: false });
const treeName = opts.behaviorTreeName;
const interfaceName = `${this.toPascalCase(treeName)}Blackboard`;
const constantsName = `${this.toPascalCase(treeName)}Vars`;
const defaultsName = `${this.toPascalCase(treeName)}Defaults`;
const parts: string[] = [];
// 文件头部注释
parts.push(`/**
* 行为树黑板变量类型定义
*
* 行为树: ${treeName}
* ⚠️ 此文件由编辑器自动生成,请勿手动修改!
* 生成时间: ${now}
*/`);
const varEntries = Object.entries(variables);
// 如果没有变量
if (varEntries.length === 0) {
parts.push(`\n/**
* 黑板变量类型定义(空)
*/
export interface ${interfaceName} {}`);
return parts.join('\n') + '\n';
}
// 生成常量枚举
if (opts.includeConstants) {
const constants = varEntries
.map(([name]) => ` ${this.toConstantName(name)}: ${quote}${name}${quote}`)
.join(',\n');
parts.push(`\n/**
* 黑板变量名称常量
* 使用常量避免拼写错误
*
* @example
* \`\`\`typescript
* // 使用常量代替字符串
* const hp = blackboard.getValue(${constantsName}.PLAYER_HP); // ✅ 类型安全
* const hp = blackboard.getValue('playerHP'); // ❌ 容易拼写错误
* \`\`\`
*/
export const ${constantsName} = {
${constants}
} as const;`);
}
// 生成类型接口
const interfaceProps = varEntries
.map(([name, value]) => {
const tsType = this.inferType(value);
return ` ${name}: ${tsType};`;
})
.join('\n');
parts.push(`\n/**
* 黑板变量类型定义
*/
export interface ${interfaceName} {
${interfaceProps}
}`);
// 生成变量名联合类型
parts.push(`\n/**
* 黑板变量名称联合类型
*/
export type ${this.toPascalCase(treeName)}VariableName = keyof ${interfaceName};`);
// 生成默认值
if (opts.includeDefaults) {
const defaultProps = varEntries
.map(([name, value]) => {
const formattedValue = this.formatValue(value, opts.quoteStyle);
return ` ${name}: ${formattedValue}`;
})
.join(',\n');
parts.push(`\n/**
* 黑板变量默认值
*
* 可用于初始化行为树黑板
*
* @example
* \`\`\`typescript
* // 创建行为树时使用默认值
* const blackboard = { ...${defaultsName} };
* const tree = new BehaviorTree(rootNode, blackboard);
* \`\`\`
*/
export const ${defaultsName}: ${interfaceName} = {
${defaultProps}
};`);
}
// 生成辅助函数
if (opts.includeHelpers) {
parts.push(`\n/**
* 创建类型安全的黑板访问器
*
* @example
* \`\`\`typescript
* const blackboard = create${this.toPascalCase(treeName)}Blackboard();
*
* // 类型安全的访问
* const hp = blackboard.playerHP; // 类型: number
* blackboard.playerHP = 100; // ✅ 正确
* blackboard.playerHP = 'invalid'; // ❌ 编译错误
* \`\`\`
*/
export function create${this.toPascalCase(treeName)}Blackboard(
initialValues?: Partial<${interfaceName}>
): ${interfaceName} {
return { ...${defaultsName}, ...initialValues };
}
/**
* 类型守卫:检查变量名是否有效
*/
export function is${this.toPascalCase(treeName)}Variable(
name: string
): name is ${this.toPascalCase(treeName)}VariableName {
return name in ${defaultsName};
}`);
}
return parts.join('\n') + '\n';
}
/**
* 推断 TypeScript 类型
*/
private static inferType(value: any): string {
if (value === null || value === undefined) {
return 'any';
}
const type = typeof value;
switch (type) {
case 'number':
return 'number';
case 'string':
return 'string';
case 'boolean':
return 'boolean';
case 'object':
if (Array.isArray(value)) {
if (value.length === 0) {
return 'any[]';
}
const elementType = this.inferType(value[0]);
return `${elementType}[]`;
}
// 检查是否是 Vector2 或 Vector3
if ('x' in value && 'y' in value) {
if ('z' in value) {
return '{ x: number; y: number; z: number }';
}
return '{ x: number; y: number }';
}
return 'any';
default:
return 'any';
}
}
/**
* 格式化值为 TypeScript 字面量
*/
private static formatValue(value: any, quoteStyle: 'single' | 'double'): string {
if (value === null) {
return 'null';
}
if (value === undefined) {
return 'undefined';
}
const quote = quoteStyle === 'single' ? "'" : '"';
const type = typeof value;
switch (type) {
case 'string':
const escaped = value
.replace(/\\/g, '\\\\')
.replace(quoteStyle === 'single' ? /'/g : /"/g,
quoteStyle === 'single' ? "\\'" : '\\"');
return `${quote}${escaped}${quote}`;
case 'number':
case 'boolean':
return String(value);
case 'object':
if (Array.isArray(value)) {
if (value.length === 0) {
return '[]';
}
const items = value.map(v => this.formatValue(v, quoteStyle)).join(', ');
return `[${items}]`;
}
// Vector2/Vector3
if ('x' in value && 'y' in value) {
if ('z' in value) {
return `{ x: ${value.x}, y: ${value.y}, z: ${value.z} }`;
}
return `{ x: ${value.x}, y: ${value.y} }`;
}
// 普通对象
return '{}';
default:
return 'undefined';
}
}
/**
* 转换为 UPPER_SNAKE_CASE
*/
private static toConstantName(name: string): string {
return name
.replace(/\./g, '_')
.replace(/([a-z])([A-Z])/g, '$1_$2')
.toUpperCase();
}
/**
* 转换为 PascalCase
*/
private static toPascalCase(str: string): string {
return str
.split(/[._-]/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join('');
}
}

View File

@@ -0,0 +1,20 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zh from './locales/zh.json';
import en from './locales/en.json';
i18n
.use(initReactI18next)
.init({
resources: {
zh: { translation: zh },
en: { translation: en }
},
lng: 'zh',
fallbackLng: 'en',
interpolation: {
escapeValue: false
}
});
export default i18n;

Some files were not shown because too many files have changed in this diff Show More