Compare commits

..

3 Commits

Author SHA1 Message Date
YHH 3c7c3c98af style(core): 统一代码风格并强化命名规范 2025-10-31 23:53:39 +08:00
YHH be7b3afb4a style(core): 统一代码风格并强化命名规范 2025-10-31 18:33:31 +08:00
YHH 3e037f4ae0 style(core): 统一代码风格并强化命名规范 2025-10-31 18:29:53 +08:00
667 changed files with 66105 additions and 90702 deletions
+73
View File
@@ -0,0 +1,73 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"semi": ["error", "always"],
"quotes": ["error", "single", { "avoidEscape": true }],
"indent": ["error", 4, { "SwitchCase": 1 }],
"no-trailing-spaces": "error",
"eol-last": ["error", "always"],
"comma-dangle": ["error", "none"],
"object-curly-spacing": ["error", "always"],
"array-bracket-spacing": ["error", "never"],
"arrow-parens": ["error", "always"],
"no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1 }],
"no-console": "off",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/no-unsafe-return": "warn",
"@typescript-eslint/no-unsafe-argument": "warn",
"@typescript-eslint/no-unsafe-function-type": "error",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/naming-convention": [
"error",
{
"selector": "memberLike",
"modifiers": ["private"],
"format": ["camelCase"],
"leadingUnderscore": "require"
},
{
"selector": "memberLike",
"modifiers": ["public"],
"format": ["camelCase"],
"leadingUnderscore": "forbid"
},
{
"selector": "memberLike",
"modifiers": ["protected"],
"format": ["camelCase"],
"leadingUnderscore": "require"
}
]
},
"ignorePatterns": [
"node_modules/",
"dist/",
"bin/",
"build/",
"coverage/",
"thirdparty/",
"examples/lawn-mower-demo/",
"extensions/",
"*.min.js",
"*.d.ts"
]
}
+17 -57
View File
@@ -6,7 +6,7 @@ on:
paths:
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'package-lock.json'
- 'tsconfig.json'
- 'jest.config.*'
- '.github/workflows/ci.yml'
@@ -15,7 +15,7 @@ on:
paths:
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'package-lock.json'
- 'tsconfig.json'
- 'jest.config.*'
- '.github/workflows/ci.yml'
@@ -23,69 +23,34 @@ on:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'pnpm'
cache: 'npm'
- name: Install dependencies
run: pnpm install
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install wasm-pack
run: cargo install wasm-pack
- name: Build core package first
run: pnpm run build:core
- name: Build engine WASM package
run: |
cd packages/engine
pnpm run build
- name: Copy WASM files to ecs-engine-bindgen
run: |
mkdir -p packages/ecs-engine-bindgen/src/wasm
cp packages/engine/pkg/es_engine.js packages/ecs-engine-bindgen/src/wasm/
cp packages/engine/pkg/es_engine.d.ts packages/ecs-engine-bindgen/src/wasm/
cp packages/engine/pkg/es_engine_bg.wasm packages/ecs-engine-bindgen/src/wasm/
cp packages/engine/pkg/es_engine_bg.wasm.d.ts packages/ecs-engine-bindgen/src/wasm/
- name: Build dependent packages for type declarations
run: |
cd packages/platform-common && pnpm run build
cd ../asset-system && pnpm run build
cd ../components && pnpm run build
- name: Build ecs-engine-bindgen
run: |
cd packages/ecs-engine-bindgen && pnpm run build
run: npm ci
- name: Type check
run: pnpm run type-check
run: npm run type-check
- name: Lint check
run: pnpm run lint
run: npm run lint
- name: Build core package first
run: npm run build:core
- name: Run tests with coverage
run: pnpm run test:ci
run: npm run test:ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
continue-on-error: true
with:
file: ./coverage/lcov.info
flags: unittests
@@ -95,30 +60,25 @@ jobs:
build:
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'pnpm'
cache: 'npm'
- name: Install dependencies
run: pnpm install
run: npm ci
- name: Build project
run: pnpm run build
run: npm run build
- name: Build npm package
run: pnpm run build:npm
run: npm run build:npm
- name: Upload build artifacts
uses: actions/upload-artifact@v4
@@ -127,4 +87,4 @@ jobs:
path: |
bin/
dist/
retention-days: 7
retention-days: 7
+4 -10
View File
@@ -14,34 +14,28 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'pnpm'
cache: 'npm'
- name: Install dependencies
run: pnpm install
run: npm ci
- name: Run tests with coverage
run: |
cd packages/core
pnpm run test:coverage
npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
continue-on-error: true
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/core/coverage/coverage-final.json
flags: core
name: core-coverage
fail_ci_if_error: false
fail_ci_if_error: true
verbose: true
- name: Upload coverage artifact
+2 -7
View File
@@ -17,20 +17,15 @@ jobs:
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'pnpm'
cache: 'npm'
- name: Install commitlint
run: |
pnpm add -D @commitlint/config-conventional @commitlint/cli
npm install --save-dev @commitlint/config-conventional @commitlint/cli
- name: Validate PR commits
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
+5 -10
View File
@@ -29,31 +29,26 @@ jobs:
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'pnpm'
cache: 'npm'
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Install dependencies
run: pnpm install
run: npm ci
- name: Build core package
run: pnpm run build:core
run: npm run build:core
- name: Generate API documentation
run: pnpm run docs:api
run: npm run docs:api
- name: Build documentation
run: pnpm run docs:build
run: npm run docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
+19 -49
View File
@@ -33,16 +33,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'pnpm'
cache: 'npm'
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -62,64 +57,39 @@ jobs:
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install frontend dependencies
run: pnpm install
run: npm ci
- name: Update version in config files (for manual trigger)
if: github.event_name == 'workflow_dispatch'
run: |
cd packages/editor-app
node -e "const pkg=require('./package.json'); pkg.version='${{ github.event.inputs.version }}'; require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2)+'\n')"
# 临时更新版本号用于构建(不提交到仓库)
npm version ${{ github.event.inputs.version }} --no-git-tag-version
node scripts/sync-version.js
- name: Cache TypeScript build
uses: actions/cache@v4
with:
path: |
packages/core/bin
packages/editor-core/dist
packages/behavior-tree/bin
key: ${{ runner.os }}-ts-build-${{ hashFiles('packages/core/src/**', 'packages/editor-core/src/**', 'packages/behavior-tree/src/**') }}
restore-keys: |
${{ runner.os }}-ts-build-
- name: Build core package
run: pnpm run build:core
run: npm run build:core
- name: Build editor-core package
run: |
cd packages/editor-core
pnpm run build
npm run build
- name: Build behavior-tree package
run: |
cd packages/behavior-tree
pnpm run build
- name: Install wasm-pack
run: cargo install wasm-pack
- name: Build engine package (Rust WASM)
run: |
cd packages/engine
pnpm run build
- name: Copy WASM files to ecs-engine-bindgen
run: |
mkdir -p packages/ecs-engine-bindgen/src/wasm
cp packages/engine/pkg/es_engine.js packages/ecs-engine-bindgen/src/wasm/
cp packages/engine/pkg/es_engine.d.ts packages/ecs-engine-bindgen/src/wasm/
cp packages/engine/pkg/es_engine_bg.wasm packages/ecs-engine-bindgen/src/wasm/
cp packages/engine/pkg/es_engine_bg.wasm.d.ts packages/ecs-engine-bindgen/src/wasm/
- name: Build dependent packages for ecs-engine-bindgen
run: |
cd packages/platform-common && pnpm run build
cd ../asset-system && pnpm run build
cd ../components && pnpm run build
- name: Build ecs-engine-bindgen package
run: |
cd packages/ecs-engine-bindgen
pnpm run build
- name: Build platform-web package
run: |
cd packages/platform-web
pnpm run build
- name: Bundle runtime files for Tauri
run: |
cd packages/editor-app
node scripts/bundle-runtime.mjs
npm run build
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0.5
@@ -156,7 +126,7 @@ jobs:
- name: Update version files
run: |
cd packages/editor-app
node -e "const pkg=require('./package.json'); pkg.version='${{ github.event.inputs.version }}'; require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2)+'\n')"
npm version ${{ github.event.inputs.version }} --no-git-tag-version
node scripts/sync-version.js
- name: Create Pull Request
+4 -9
View File
@@ -41,26 +41,21 @@ jobs:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache: 'npm'
- name: Install dependencies
run: pnpm install
run: npm ci
- name: Build core package (if needed)
if: ${{ github.event.inputs.package == 'behavior-tree' || github.event.inputs.package == 'editor-core' }}
run: |
cd packages/core
pnpm run build
npm run build
# - name: Run tests
# run: |
@@ -83,7 +78,7 @@ jobs:
- name: Build package
run: |
cd packages/${{ github.event.inputs.package }}
pnpm run build:npm
npm run build:npm
- name: Publish to npm
env:
+3 -8
View File
@@ -22,24 +22,19 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'pnpm'
cache: 'npm'
- name: Install dependencies
run: pnpm install
run: npm ci
- name: Build core package
run: |
cd packages/core
pnpm run build:npm
npm run build:npm
- name: Check bundle size
uses: andresz1/size-limit-action@v1
+2 -3
View File
@@ -16,7 +16,6 @@ dist/
*.tmp
*.temp
.cache/
.build-cache/
# IDE 配置
.idea/
@@ -49,9 +48,9 @@ logs/
coverage/
*.lcov
# 包管理器锁文件(忽略yarn保留pnpm
# 包管理器锁文件(保留npm的,忽略其他的
yarn.lock
package-lock.json
pnpm-lock.yaml
# 文档生成
docs/api/
-2
View File
@@ -1,2 +0,0 @@
link-workspace-packages=true
prefer-workspace-packages=true
+2 -51
View File
@@ -44,13 +44,7 @@ class MyService implements IService {
### 访问服务容器
ECS Framework 提供了三级服务容器
> **版本说明**World 服务容器功能在 v2.2.13+ 版本中可用
#### Core 级别服务容器
应用程序全局服务容器,可以通过 `Core.services` 访问:
Core 类内置了服务容器,可以通过 `Core.services` 访问
```typescript
import { Core } from '@esengine/ecs-framework';
@@ -58,53 +52,10 @@ import { Core } from '@esengine/ecs-framework';
// 初始化Core
Core.create({ debug: true });
// 访问全局服务容器
// 访问服务容器
const container = Core.services;
```
#### World 级别服务容器
每个 World 拥有独立的服务容器,用于管理 World 范围内的服务:
```typescript
import { World } from '@esengine/ecs-framework';
// 创建 World
const world = new World({ name: 'GameWorld' });
// 访问 World 级别的服务容器
const worldContainer = world.services;
// 注册 World 级别的服务
world.services.registerSingleton(RoomManager);
```
#### Scene 级别服务容器
每个 Scene 拥有独立的服务容器,用于管理 Scene 范围内的服务:
```typescript
// 访问 Scene 级别的服务容器
const sceneContainer = scene.services;
// 注册 Scene 级别的服务
scene.services.registerSingleton(PhysicsSystem);
```
#### 服务容器层级
```
Core.services (应用程序全局)
└─ World.services (World 级别)
└─ Scene.services (Scene 级别)
```
不同级别的服务容器是独立的,服务不会自动向上或向下查找。选择合适的容器级别:
- **Core.services**: 应用程序级别的全局服务(配置、插件管理器等)
- **World.services**: World 级别的服务(房间管理器、多人游戏状态等)
- **Scene.services**: Scene 级别的服务(ECS 系统、场景特定逻辑等)
### 注册服务
#### 注册单例服务
+1 -1
View File
@@ -435,7 +435,7 @@ const worldManager = Core.services.resolve(WorldManager);
// {
// maxWorlds: 50,
// autoCleanup: true,
// cleanupFrameInterval: 1800 // 间隔多少帧清理闲置 World
// cleanupInterval: 30000 // 30 秒
// }
```
+24 -30
View File
@@ -10,43 +10,38 @@ export default [
parser: tseslint.parser,
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
project: true,
tsconfigRootDir: import.meta.dirname
sourceType: 'module'
}
},
rules: {
'semi': ['warn', 'always'],
'quotes': ['warn', 'single', { avoidEscape: true }],
'indent': ['warn', 4, {
SwitchCase: 1,
ignoredNodes: [
'PropertyDefinition[decorators.length > 0]',
'TSTypeParameterInstantiation'
]
}],
'semi': 'warn',
'quotes': 'warn',
'indent': 'off',
'no-trailing-spaces': 'warn',
'eol-last': ['warn', 'always'],
'comma-dangle': ['warn', 'never'],
'object-curly-spacing': ['warn', 'always'],
'array-bracket-spacing': ['warn', 'never'],
'arrow-parens': ['warn', 'always'],
'no-multiple-empty-lines': ['warn', { max: 2, maxEOF: 1 }],
'eol-last': 'warn',
'comma-dangle': 'warn',
'object-curly-spacing': 'warn',
'array-bracket-spacing': 'warn',
'arrow-parens': 'warn',
'prefer-const': 'warn',
'no-multiple-empty-lines': 'warn',
'no-console': 'off',
'no-empty': 'warn',
'no-case-declarations': 'warn',
'no-useless-catch': 'warn',
'no-prototype-builtins': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unsafe-function-type': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-non-null-assertion': 'off'
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-require-imports': 'warn',
'@typescript-eslint/no-this-alias': 'warn',
'no-case-declarations': 'warn',
'no-prototype-builtins': 'warn',
'no-empty': 'warn',
'no-useless-catch': 'warn'
}
},
{
@@ -65,8 +60,7 @@ export default [
'examples/lawn-mower-demo/**',
'extensions/**',
'**/*.min.js',
'**/*.d.ts',
'**/wasm/**'
'**/*.d.ts'
]
}
];
+27061
View File
File diff suppressed because it is too large Load Diff
+16 -8
View File
@@ -18,23 +18,35 @@
"scripts": {
"bootstrap": "lerna bootstrap",
"clean": "lerna run clean",
"build": "npm run build:core && npm run build:math",
"build": "npm run build:core && npm run build:math && npm run build:network-shared && npm run build:network-client && npm run build:network-server",
"build:core": "cd packages/core && npm run build",
"build:math": "cd packages/math && npm run build",
"build:npm": "npm run build:npm:core && npm run build:npm:math",
"build:network-shared": "cd packages/network-shared && npm run build",
"build:network-client": "cd packages/network-client && npm run build",
"build:network-server": "cd packages/network-server && npm run build",
"build:npm": "npm run build:npm:core && npm run build:npm:math && npm run build:npm:network-shared && npm run build:npm:network-client && npm run build:npm:network-server",
"build:npm:core": "cd packages/core && npm run build:npm",
"build:npm:math": "cd packages/math && npm run build:npm",
"build:npm:network-shared": "cd packages/network-shared && npm run build:npm",
"build:npm:network-client": "cd packages/network-client && npm run build:npm",
"build:npm:network-server": "cd packages/network-server && npm run build:npm",
"test": "lerna run test",
"test:coverage": "lerna run test:coverage",
"test:ci": "lerna run test:ci",
"prepare:publish": "npm run build:npm && node scripts/pre-publish-check.cjs",
"sync:versions": "node scripts/sync-versions.cjs",
"publish:all": "npm run prepare:publish && npm run publish:all:dist",
"publish:all:dist": "npm run publish:core && npm run publish:math",
"publish:all:dist": "npm run publish:core && npm run publish:math && npm run publish:network-shared && npm run publish:network-client && npm run publish:network-server",
"publish:core": "cd packages/core && npm run publish:npm",
"publish:core:patch": "cd packages/core && npm run publish:patch",
"publish:math": "cd packages/math && npm run publish:npm",
"publish:math:patch": "cd packages/math && npm run publish:patch",
"publish:network-shared": "cd packages/network-shared && npm run publish:npm",
"publish:network-shared:patch": "cd packages/network-shared && npm run publish:patch",
"publish:network-client": "cd packages/network-client && npm run publish:npm",
"publish:network-client:patch": "cd packages/network-client && npm run publish:patch",
"publish:network-server": "cd packages/network-server && npm run publish:npm",
"publish:network-server:patch": "cd packages/network-server && npm run publish:patch",
"publish": "lerna publish",
"version": "lerna version",
"release": "semantic-release",
@@ -53,16 +65,13 @@
"format:check": "prettier --check \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
"type-check": "lerna run type-check",
"lint": "eslint \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
"lint:fix": "eslint \"packages/**/src/**/*.{ts,tsx,js,jsx}\" --fix",
"build:wasm": "cd packages/engine && wasm-pack build --dev --out-dir pkg",
"build:wasm:release": "cd packages/engine && wasm-pack build --release --out-dir pkg"
"lint:fix": "eslint \"packages/**/src/**/*.{ts,tsx,js,jsx}\" --fix"
},
"author": "yhh",
"license": "MIT",
"devDependencies": {
"@commitlint/cli": "^18.6.0",
"@commitlint/config-conventional": "^18.6.0",
"@eslint/js": "^9.39.1",
"@iconify/json": "^2.2.388",
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-node-resolve": "^16.0.1",
@@ -95,7 +104,6 @@
"typedoc": "^0.28.13",
"typedoc-plugin-markdown": "^4.9.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.47.0",
"unplugin-icons": "^22.3.0",
"vitepress": "^1.6.4"
},
-52
View File
@@ -1,52 +0,0 @@
{
"name": "@esengine/asset-system",
"version": "1.0.0",
"description": "Asset management system for ES Engine",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "rollup -c",
"build:npm": "npm run build",
"clean": "rimraf dist",
"type-check": "npx tsc --noEmit"
},
"keywords": [
"ecs",
"asset",
"resource",
"bundle"
],
"author": "yhh",
"license": "MIT",
"peerDependencies": {
"@esengine/ecs-framework": "^2.0.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^11.1.6",
"rimraf": "^5.0.0",
"rollup": "^4.42.0",
"rollup-plugin-dts": "^6.2.1",
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/esengine/ecs-framework.git",
"directory": "packages/asset-system"
}
}
-49
View File
@@ -1,49 +0,0 @@
import typescript from '@rollup/plugin-typescript';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import dts from 'rollup-plugin-dts';
const external = ['@esengine/ecs-framework'];
export default [
// ESM and CJS builds
{
input: 'src/index.ts',
output: [
{
file: 'dist/index.js',
format: 'cjs',
sourcemap: true,
exports: 'named'
},
{
file: 'dist/index.mjs',
format: 'es',
sourcemap: true
}
],
external,
plugins: [
resolve({
preferBuiltins: false,
browser: true
}),
commonjs(),
typescript({
tsconfig: './tsconfig.json',
declaration: false,
declarationMap: false
})
]
},
// Type definitions
{
input: 'src/index.ts',
output: {
file: 'dist/index.d.ts',
format: 'es'
},
external,
plugins: [dts()]
}
];
@@ -1,130 +0,0 @@
/**
* Asset cache implementation
* 资产缓存实现
*/
import { AssetGUID } from '../types/AssetTypes';
/**
* Cache entry
* 缓存条目
*/
interface CacheEntry {
guid: AssetGUID;
asset: unknown;
lastAccessTime: number;
accessCount: number;
}
/**
* Asset cache implementation
* 资产缓存实现
*/
export class AssetCache {
private readonly _cache = new Map<AssetGUID, CacheEntry>();
constructor() {
// 无配置,无限制缓存 / No config, unlimited cache
}
/**
* Get cached asset
* 获取缓存的资产
*/
get<T = unknown>(guid: AssetGUID): T | null {
const entry = this._cache.get(guid);
if (!entry) return null;
// 更新访问信息 / Update access info
entry.lastAccessTime = Date.now();
entry.accessCount++;
return entry.asset as T;
}
/**
* Set cached asset
* 设置缓存的资产
*/
set<T = unknown>(guid: AssetGUID, asset: T): void {
const now = Date.now();
const entry: CacheEntry = {
guid,
asset,
lastAccessTime: now,
accessCount: 1
};
// 如果已存在,更新 / Update if exists
const oldEntry = this._cache.get(guid);
if (oldEntry) {
entry.accessCount = oldEntry.accessCount + 1;
}
this._cache.set(guid, entry);
}
/**
* Check if asset is cached
* 检查资产是否缓存
*/
has(guid: AssetGUID): boolean {
return this._cache.has(guid);
}
/**
* Remove asset from cache
* 从缓存移除资产
*/
remove(guid: AssetGUID): void {
this._cache.delete(guid);
}
/**
* Clear all cache
* 清空所有缓存
*/
clear(): void {
this._cache.clear();
}
/**
* Get cache size
* 获取缓存大小
*/
getSize(): number {
return this._cache.size;
}
/**
* Get all cached GUIDs
* 获取所有缓存的GUID
*/
getAllGuids(): AssetGUID[] {
return Array.from(this._cache.keys());
}
/**
* Get cache statistics
* 获取缓存统计
*/
getStatistics(): {
count: number;
entries: Array<{
guid: AssetGUID;
accessCount: number;
lastAccessTime: number;
}>;
} {
const entries = Array.from(this._cache.values()).map((entry) => ({
guid: entry.guid,
accessCount: entry.accessCount,
lastAccessTime: entry.lastAccessTime
}));
return {
count: this._cache.size,
entries
};
}
}
@@ -1,431 +0,0 @@
/**
* Asset database for managing asset metadata
* 用于管理资产元数据的资产数据库
*/
import {
AssetGUID,
AssetType,
IAssetMetadata,
IAssetCatalogEntry
} from '../types/AssetTypes';
/**
* Asset database implementation
* 资产数据库实现
*/
export class AssetDatabase {
private readonly _metadata = new Map<AssetGUID, IAssetMetadata>();
private readonly _pathToGuid = new Map<string, AssetGUID>();
private readonly _typeToGuids = new Map<AssetType, Set<AssetGUID>>();
private readonly _labelToGuids = new Map<string, Set<AssetGUID>>();
private readonly _dependencies = new Map<AssetGUID, Set<AssetGUID>>();
private readonly _dependents = new Map<AssetGUID, Set<AssetGUID>>();
/**
* Add asset to database
* 添加资产到数据库
*/
addAsset(metadata: IAssetMetadata): void {
const { guid, path, type, labels, dependencies } = metadata;
// 存储元数据 / Store metadata
this._metadata.set(guid, metadata);
this._pathToGuid.set(path, guid);
// 按类型索引 / Index by type
if (!this._typeToGuids.has(type)) {
this._typeToGuids.set(type, new Set());
}
this._typeToGuids.get(type)!.add(guid);
// 按标签索引 / Index by labels
labels.forEach((label) => {
if (!this._labelToGuids.has(label)) {
this._labelToGuids.set(label, new Set());
}
this._labelToGuids.get(label)!.add(guid);
});
// 建立依赖关系 / Establish dependencies
this.updateDependencies(guid, dependencies);
}
/**
* Remove asset from database
* 从数据库移除资产
*/
removeAsset(guid: AssetGUID): void {
const metadata = this._metadata.get(guid);
if (!metadata) return;
// 清理元数据 / Clean up metadata
this._metadata.delete(guid);
this._pathToGuid.delete(metadata.path);
// 清理类型索引 / Clean up type index
const typeSet = this._typeToGuids.get(metadata.type);
if (typeSet) {
typeSet.delete(guid);
if (typeSet.size === 0) {
this._typeToGuids.delete(metadata.type);
}
}
// 清理标签索引 / Clean up label indices
metadata.labels.forEach((label) => {
const labelSet = this._labelToGuids.get(label);
if (labelSet) {
labelSet.delete(guid);
if (labelSet.size === 0) {
this._labelToGuids.delete(label);
}
}
});
// 清理依赖关系 / Clean up dependencies
this.clearDependencies(guid);
}
/**
* Update asset metadata
* 更新资产元数据
*/
updateAsset(guid: AssetGUID, updates: Partial<IAssetMetadata>): void {
const metadata = this._metadata.get(guid);
if (!metadata) return;
// 如果路径改变,更新索引 / Update index if path changed
if (updates.path && updates.path !== metadata.path) {
this._pathToGuid.delete(metadata.path);
this._pathToGuid.set(updates.path, guid);
}
// 如果类型改变,更新索引 / Update index if type changed
if (updates.type && updates.type !== metadata.type) {
const oldTypeSet = this._typeToGuids.get(metadata.type);
if (oldTypeSet) {
oldTypeSet.delete(guid);
}
if (!this._typeToGuids.has(updates.type)) {
this._typeToGuids.set(updates.type, new Set());
}
this._typeToGuids.get(updates.type)!.add(guid);
}
// 如果依赖改变,更新关系 / Update relations if dependencies changed
if (updates.dependencies) {
this.updateDependencies(guid, updates.dependencies);
}
// 合并更新 / Merge updates
Object.assign(metadata, updates);
metadata.lastModified = Date.now();
metadata.version++;
}
/**
* Get asset metadata
* 获取资产元数据
*/
getMetadata(guid: AssetGUID): IAssetMetadata | undefined {
return this._metadata.get(guid);
}
/**
* Get metadata by path
* 通过路径获取元数据
*/
getMetadataByPath(path: string): IAssetMetadata | undefined {
const guid = this._pathToGuid.get(path);
return guid ? this._metadata.get(guid) : undefined;
}
/**
* Find assets by type
* 按类型查找资产
*/
findAssetsByType(type: AssetType): AssetGUID[] {
const guids = this._typeToGuids.get(type);
return guids ? Array.from(guids) : [];
}
/**
* Find assets by label
* 按标签查找资产
*/
findAssetsByLabel(label: string): AssetGUID[] {
const guids = this._labelToGuids.get(label);
return guids ? Array.from(guids) : [];
}
/**
* Find assets by multiple labels (AND operation)
* 按多个标签查找资产(AND操作)
*/
findAssetsByLabels(labels: string[]): AssetGUID[] {
if (labels.length === 0) return [];
let result: Set<AssetGUID> | null = null;
for (const label of labels) {
const labelGuids = this._labelToGuids.get(label);
if (!labelGuids || labelGuids.size === 0) return [];
if (!result) {
result = new Set(labelGuids);
} else {
// 交集 / Intersection
const intersection = new Set<AssetGUID>();
labelGuids.forEach((guid) => {
if (result!.has(guid)) {
intersection.add(guid);
}
});
result = intersection;
}
}
return result ? Array.from(result) : [];
}
/**
* Search assets by query
* 通过查询搜索资产
*/
searchAssets(query: {
name?: string;
type?: AssetType;
labels?: string[];
path?: string;
}): AssetGUID[] {
let results = Array.from(this._metadata.keys());
// 按名称过滤 / Filter by name
if (query.name) {
const nameLower = query.name.toLowerCase();
results = results.filter((guid) => {
const metadata = this._metadata.get(guid)!;
return metadata.name.toLowerCase().includes(nameLower);
});
}
// 按类型过滤 / Filter by type
if (query.type) {
const typeGuids = this._typeToGuids.get(query.type);
if (!typeGuids) return [];
results = results.filter((guid) => typeGuids.has(guid));
}
// 按标签过滤 / Filter by labels
if (query.labels && query.labels.length > 0) {
const labelResults = this.findAssetsByLabels(query.labels);
const labelSet = new Set(labelResults);
results = results.filter((guid) => labelSet.has(guid));
}
// 按路径过滤 / Filter by path
if (query.path) {
const pathLower = query.path.toLowerCase();
results = results.filter((guid) => {
const metadata = this._metadata.get(guid)!;
return metadata.path.toLowerCase().includes(pathLower);
});
}
return results;
}
/**
* Get asset dependencies
* 获取资产依赖
*/
getDependencies(guid: AssetGUID): AssetGUID[] {
const deps = this._dependencies.get(guid);
return deps ? Array.from(deps) : [];
}
/**
* Get asset dependents (assets that depend on this one)
* 获取资产的依赖者(依赖此资产的其他资产)
*/
getDependents(guid: AssetGUID): AssetGUID[] {
const deps = this._dependents.get(guid);
return deps ? Array.from(deps) : [];
}
/**
* Get all dependencies recursively
* 递归获取所有依赖
*/
getAllDependencies(guid: AssetGUID, visited = new Set<AssetGUID>()): AssetGUID[] {
if (visited.has(guid)) return [];
visited.add(guid);
const result: AssetGUID[] = [];
const directDeps = this.getDependencies(guid);
for (const dep of directDeps) {
result.push(dep);
const transitiveDeps = this.getAllDependencies(dep, visited);
result.push(...transitiveDeps);
}
return result;
}
/**
* Check for circular dependencies
* 检查循环依赖
*/
hasCircularDependency(guid: AssetGUID): boolean {
const visited = new Set<AssetGUID>();
const recursionStack = new Set<AssetGUID>();
const checkCycle = (current: AssetGUID): boolean => {
visited.add(current);
recursionStack.add(current);
const deps = this.getDependencies(current);
for (const dep of deps) {
if (!visited.has(dep)) {
if (checkCycle(dep)) return true;
} else if (recursionStack.has(dep)) {
return true;
}
}
recursionStack.delete(current);
return false;
};
return checkCycle(guid);
}
/**
* Update dependencies
* 更新依赖关系
*/
private updateDependencies(guid: AssetGUID, newDependencies: AssetGUID[]): void {
// 清除旧的依赖关系 / Clear old dependencies
this.clearDependencies(guid);
// 建立新的依赖关系 / Establish new dependencies
if (newDependencies.length > 0) {
this._dependencies.set(guid, new Set(newDependencies));
// 更新被依赖关系 / Update dependent relations
newDependencies.forEach((dep) => {
if (!this._dependents.has(dep)) {
this._dependents.set(dep, new Set());
}
this._dependents.get(dep)!.add(guid);
});
}
}
/**
* Clear dependencies
* 清除依赖关系
*/
private clearDependencies(guid: AssetGUID): void {
// 清除依赖 / Clear dependencies
const deps = this._dependencies.get(guid);
if (deps) {
deps.forEach((dep) => {
const dependents = this._dependents.get(dep);
if (dependents) {
dependents.delete(guid);
if (dependents.size === 0) {
this._dependents.delete(dep);
}
}
});
this._dependencies.delete(guid);
}
// 清除被依赖 / Clear dependents
const dependents = this._dependents.get(guid);
if (dependents) {
dependents.forEach((dependent) => {
const dependencies = this._dependencies.get(dependent);
if (dependencies) {
dependencies.delete(guid);
if (dependencies.size === 0) {
this._dependencies.delete(dependent);
}
}
});
this._dependents.delete(guid);
}
}
/**
* Get database statistics
* 获取数据库统计
*/
getStatistics(): {
totalAssets: number;
assetsByType: Map<AssetType, number>;
totalDependencies: number;
assetsWithDependencies: number;
circularDependencies: number;
} {
const assetsByType = new Map<AssetType, number>();
this._typeToGuids.forEach((guids, type) => {
assetsByType.set(type, guids.size);
});
let circularDependencies = 0;
this._metadata.forEach((_, guid) => {
if (this.hasCircularDependency(guid)) {
circularDependencies++;
}
});
return {
totalAssets: this._metadata.size,
assetsByType,
totalDependencies: Array.from(this._dependencies.values()).reduce(
(sum, deps) => sum + deps.size,
0
),
assetsWithDependencies: this._dependencies.size,
circularDependencies
};
}
/**
* Export to catalog entries
* 导出为目录条目
*/
exportToCatalog(): IAssetCatalogEntry[] {
const entries: IAssetCatalogEntry[] = [];
this._metadata.forEach((metadata) => {
entries.push({
guid: metadata.guid,
path: metadata.path,
type: metadata.type,
size: metadata.size,
hash: metadata.hash
});
});
return entries;
}
/**
* Clear database
* 清空数据库
*/
clear(): void {
this._metadata.clear();
this._pathToGuid.clear();
this._typeToGuids.clear();
this._labelToGuids.clear();
this._dependencies.clear();
this._dependents.clear();
}
}
@@ -1,193 +0,0 @@
/**
* Priority-based asset loading queue
* 基于优先级的资产加载队列
*/
import { AssetGUID, IAssetLoadOptions } from '../types/AssetTypes';
import { IAssetLoadQueue } from '../interfaces/IAssetManager';
/**
* Queue item
* 队列项
*/
interface QueueItem {
guid: AssetGUID;
priority: number;
options?: IAssetLoadOptions;
timestamp: number;
}
/**
* Asset load queue implementation
* 资产加载队列实现
*/
export class AssetLoadQueue implements IAssetLoadQueue {
private readonly _queue: QueueItem[] = [];
private readonly _guidToIndex = new Map<AssetGUID, number>();
/**
* Add to queue
* 添加到队列
*/
enqueue(guid: AssetGUID, priority: number, options?: IAssetLoadOptions): void {
// 检查是否已在队列中 / Check if already in queue
if (this._guidToIndex.has(guid)) {
this.reprioritize(guid, priority);
return;
}
const item: QueueItem = {
guid,
priority,
options,
timestamp: Date.now()
};
// 二分查找插入位置 / Binary search for insertion position
const index = this.findInsertIndex(priority);
this._queue.splice(index, 0, item);
// 更新索引映射 / Update index mapping
this.updateIndices(index);
}
/**
* Remove from queue
* 从队列移除
*/
dequeue(): { guid: AssetGUID; options?: IAssetLoadOptions } | null {
if (this._queue.length === 0) return null;
const item = this._queue.shift();
if (!item) return null;
// 更新索引映射 / Update index mapping
this._guidToIndex.delete(item.guid);
this.updateIndices(0);
return {
guid: item.guid,
options: item.options
};
}
/**
* Check if queue is empty
* 检查队列是否为空
*/
isEmpty(): boolean {
return this._queue.length === 0;
}
/**
* Get queue size
* 获取队列大小
*/
getSize(): number {
return this._queue.length;
}
/**
* Clear queue
* 清空队列
*/
clear(): void {
this._queue.length = 0;
this._guidToIndex.clear();
}
/**
* Reprioritize item
* 重新设置优先级
*/
reprioritize(guid: AssetGUID, newPriority: number): void {
const index = this._guidToIndex.get(guid);
if (index === undefined) return;
const item = this._queue[index];
if (!item || item.priority === newPriority) return;
// 移除旧项 / Remove old item
this._queue.splice(index, 1);
this._guidToIndex.delete(guid);
// 重新插入 / Reinsert with new priority
item.priority = newPriority;
const newIndex = this.findInsertIndex(newPriority);
this._queue.splice(newIndex, 0, item);
// 更新索引 / Update indices
this.updateIndices(Math.min(index, newIndex));
}
/**
* Find insertion index for priority
* 查找优先级的插入索引
*/
private findInsertIndex(priority: number): number {
let left = 0;
let right = this._queue.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
// 高优先级在前 / Higher priority first
if (this._queue[mid].priority >= priority) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
/**
* Update indices after modification
* 修改后更新索引
*/
private updateIndices(startIndex: number): void {
for (let i = startIndex; i < this._queue.length; i++) {
this._guidToIndex.set(this._queue[i].guid, i);
}
}
/**
* Get queue items (for debugging)
* 获取队列项(用于调试)
*/
getItems(): ReadonlyArray<{
guid: AssetGUID;
priority: number;
waitTime: number;
}> {
const now = Date.now();
return this._queue.map((item) => ({
guid: item.guid,
priority: item.priority,
waitTime: now - item.timestamp
}));
}
/**
* Remove specific item from queue
* 从队列中移除特定项
*/
remove(guid: AssetGUID): boolean {
const index = this._guidToIndex.get(guid);
if (index === undefined) return false;
this._queue.splice(index, 1);
this._guidToIndex.delete(guid);
this.updateIndices(index);
return true;
}
/**
* Check if guid is in queue
* 检查guid是否在队列中
*/
contains(guid: AssetGUID): boolean {
return this._guidToIndex.has(guid);
}
}
@@ -1,541 +0,0 @@
/**
* Asset manager implementation
* 资产管理器实现
*/
import {
AssetGUID,
AssetHandle,
AssetType,
AssetState,
IAssetLoadOptions,
IAssetLoadResult,
IAssetReferenceInfo,
IAssetPreloadGroup,
IAssetLoadProgress,
IAssetMetadata,
AssetLoadError,
IAssetCatalog
} from '../types/AssetTypes';
import {
IAssetManager,
IAssetLoadQueue
} from '../interfaces/IAssetManager';
import { IAssetLoader, IAssetLoaderFactory } from '../interfaces/IAssetLoader';
import { AssetCache } from './AssetCache';
import { AssetLoadQueue } from './AssetLoadQueue';
import { AssetLoaderFactory } from '../loaders/AssetLoaderFactory';
import { AssetDatabase } from './AssetDatabase';
/**
* Asset entry in the manager
* 管理器中的资产条目
*/
interface AssetEntry {
guid: AssetGUID;
handle: AssetHandle;
asset: unknown;
metadata: IAssetMetadata;
state: AssetState;
referenceCount: number;
lastAccessTime: number;
loadPromise?: Promise<IAssetLoadResult>;
}
/**
* Asset manager implementation
* 资产管理器实现
*/
export class AssetManager implements IAssetManager {
private readonly _assets = new Map<AssetGUID, AssetEntry>();
private readonly _handleToGuid = new Map<AssetHandle, AssetGUID>();
private readonly _pathToGuid = new Map<string, AssetGUID>();
private readonly _cache: AssetCache;
private readonly _loadQueue: IAssetLoadQueue;
private readonly _loaderFactory: IAssetLoaderFactory;
private readonly _database: AssetDatabase;
private _nextHandle: AssetHandle = 1;
private _statistics = {
loadedCount: 0,
failedCount: 0
};
private _isDisposed = false;
private _loadingCount = 0;
constructor(catalog?: IAssetCatalog) {
this._cache = new AssetCache();
this._loadQueue = new AssetLoadQueue();
this._loaderFactory = new AssetLoaderFactory();
this._database = new AssetDatabase();
// 如果提供了目录,初始化数据库 / Initialize database if catalog provided
if (catalog) {
this.initializeFromCatalog(catalog);
}
}
/**
* Initialize from catalog
* 从目录初始化
*/
private initializeFromCatalog(catalog: IAssetCatalog): void {
catalog.entries.forEach((entry, guid) => {
const metadata: IAssetMetadata = {
guid,
path: entry.path,
type: entry.type,
name: entry.path.split('/').pop() || '',
size: entry.size,
hash: entry.hash,
dependencies: [],
labels: [],
tags: new Map(),
lastModified: Date.now(),
version: 1
};
this._database.addAsset(metadata);
this._pathToGuid.set(entry.path, guid);
});
}
/**
* Load asset by GUID
* 通过GUID加载资产
*/
async loadAsset<T = unknown>(
guid: AssetGUID,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<T>> {
// 检查是否已加载 / Check if already loaded
const entry = this._assets.get(guid);
if (entry) {
if (entry.state === AssetState.Loaded && !options?.forceReload) {
entry.lastAccessTime = Date.now();
return {
asset: entry.asset as T,
handle: entry.handle,
metadata: entry.metadata,
loadTime: 0
};
}
if (entry.state === AssetState.Loading && entry.loadPromise) {
return entry.loadPromise as Promise<IAssetLoadResult<T>>;
}
}
// 获取元数据 / Get metadata
const metadata = this._database.getMetadata(guid);
if (!metadata) {
throw AssetLoadError.fileNotFound(guid, 'Unknown');
}
// 创建加载器 / Create loader
const loader = this._loaderFactory.createLoader(metadata.type);
if (!loader) {
throw AssetLoadError.unsupportedType(guid, metadata.type);
}
// 开始加载 / Start loading
const loadStartTime = performance.now();
const newEntry: AssetEntry = {
guid,
handle: this._nextHandle++,
asset: null,
metadata,
state: AssetState.Loading,
referenceCount: 0,
lastAccessTime: Date.now()
};
this._assets.set(guid, newEntry);
this._handleToGuid.set(newEntry.handle, guid);
this._loadingCount++;
// 创建加载Promise / Create loading promise
const loadPromise = this.performLoad<T>(loader, metadata, options, loadStartTime, newEntry);
newEntry.loadPromise = loadPromise;
try {
const result = await loadPromise;
return result;
} catch (error) {
this._statistics.failedCount++;
newEntry.state = AssetState.Failed;
throw error;
} finally {
this._loadingCount--;
delete newEntry.loadPromise;
}
}
/**
* Perform asset loading
* 执行资产加载
*/
private async performLoad<T>(
loader: IAssetLoader,
metadata: IAssetMetadata,
options: IAssetLoadOptions | undefined,
startTime: number,
entry: AssetEntry
): Promise<IAssetLoadResult<T>> {
// 加载依赖 / Load dependencies
if (metadata.dependencies.length > 0) {
await this.loadDependencies(metadata.dependencies, options);
}
// 执行加载 / Execute loading
const result = await loader.load(metadata.path, metadata, options);
// 更新条目 / Update entry
entry.asset = result.asset;
entry.state = AssetState.Loaded;
// 缓存资产 / Cache asset
this._cache.set(metadata.guid, result.asset);
// 更新统计 / Update statistics
this._statistics.loadedCount++;
const loadResult: IAssetLoadResult<T> = {
asset: result.asset as T,
handle: entry.handle,
metadata,
loadTime: performance.now() - startTime
};
return loadResult;
}
/**
* Load dependencies
* 加载依赖
*/
private async loadDependencies(
dependencies: AssetGUID[],
options?: IAssetLoadOptions
): Promise<void> {
const promises = dependencies.map((dep) => this.loadAsset(dep, options));
await Promise.all(promises);
}
/**
* Load asset by path
* 通过路径加载资产
*/
async loadAssetByPath<T = unknown>(
path: string,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<T>> {
const guid = this._pathToGuid.get(path);
if (!guid) {
// 尝试从数据库查找 / Try to find from database
let metadata = this._database.getMetadataByPath(path);
if (!metadata) {
// 动态创建元数据 / Create metadata dynamically
const fileExt = path.substring(path.lastIndexOf('.')).toLowerCase();
let assetType = AssetType.Custom;
// 根据文件扩展名确定资产类型 / Determine asset type by file extension
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(fileExt)) {
assetType = AssetType.Texture;
} else if (['.json'].includes(fileExt)) {
assetType = AssetType.Json;
} else if (['.txt', '.md', '.xml', '.yaml'].includes(fileExt)) {
assetType = AssetType.Text;
}
// 生成唯一GUID / Generate unique GUID
const dynamicGuid = `dynamic_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
metadata = {
guid: dynamicGuid,
path: path,
type: assetType,
name: path.split('/').pop() || path.split('\\').pop() || 'unnamed',
size: 0, // 动态加载时未知大小 / Unknown size for dynamic loading
hash: '',
dependencies: [],
labels: [],
tags: new Map(),
lastModified: Date.now(),
version: 1
};
// 注册到数据库 / Register to database
this._database.addAsset(metadata);
this._pathToGuid.set(path, metadata.guid);
} else {
this._pathToGuid.set(path, metadata.guid);
}
return this.loadAsset<T>(metadata.guid, options);
}
return this.loadAsset<T>(guid, options);
}
/**
* Load multiple assets
* 批量加载资产
*/
async loadAssets(
guids: AssetGUID[],
options?: IAssetLoadOptions
): Promise<Map<AssetGUID, IAssetLoadResult>> {
const results = new Map<AssetGUID, IAssetLoadResult>();
// 并行加载所有资产 / Load all assets in parallel
const promises = guids.map(async (guid) => {
try {
const result = await this.loadAsset(guid, options);
results.set(guid, result);
} catch (error) {
console.error(`Failed to load asset ${guid}:`, error);
}
});
await Promise.all(promises);
return results;
}
/**
* Preload asset group
* 预加载资产组
*/
async preloadGroup(
group: IAssetPreloadGroup,
onProgress?: (progress: IAssetLoadProgress) => void
): Promise<void> {
const totalCount = group.assets.length;
let loadedCount = 0;
let loadedBytes = 0;
let totalBytes = 0;
// 计算总大小 / Calculate total size
for (const guid of group.assets) {
const metadata = this._database.getMetadata(guid);
if (metadata) {
totalBytes += metadata.size;
}
}
// 加载每个资产 / Load each asset
for (const guid of group.assets) {
const metadata = this._database.getMetadata(guid);
if (!metadata) continue;
if (onProgress) {
onProgress({
currentAsset: metadata.name,
loadedCount,
totalCount,
loadedBytes,
totalBytes,
progress: loadedCount / totalCount
});
}
await this.loadAsset(guid, { priority: group.priority });
loadedCount++;
loadedBytes += metadata.size;
}
// 最终进度 / Final progress
if (onProgress) {
onProgress({
currentAsset: '',
loadedCount: totalCount,
totalCount,
loadedBytes: totalBytes,
totalBytes,
progress: 1
});
}
}
/**
* Get loaded asset
* 获取已加载的资产
*/
getAsset<T = unknown>(guid: AssetGUID): T | null {
const entry = this._assets.get(guid);
if (entry && entry.state === AssetState.Loaded) {
entry.lastAccessTime = Date.now();
return entry.asset as T;
}
return null;
}
/**
* Get asset by handle
* 通过句柄获取资产
*/
getAssetByHandle<T = unknown>(handle: AssetHandle): T | null {
const guid = this._handleToGuid.get(handle);
if (!guid) return null;
return this.getAsset<T>(guid);
}
/**
* Check if asset is loaded
* 检查资产是否已加载
*/
isLoaded(guid: AssetGUID): boolean {
const entry = this._assets.get(guid);
return entry?.state === AssetState.Loaded;
}
/**
* Get asset state
* 获取资产状态
*/
getAssetState(guid: AssetGUID): AssetState {
const entry = this._assets.get(guid);
return entry?.state || AssetState.Unloaded;
}
/**
* Unload asset
* 卸载资产
*/
unloadAsset(guid: AssetGUID): void {
const entry = this._assets.get(guid);
if (!entry) return;
// 检查引用计数 / Check reference count
if (entry.referenceCount > 0) {
return;
}
// 获取加载器以释放资源 / Get loader to dispose resources
const loader = this._loaderFactory.createLoader(entry.metadata.type);
if (loader) {
loader.dispose(entry.asset);
}
// 清理条目 / Clean up entry
this._handleToGuid.delete(entry.handle);
this._assets.delete(guid);
this._cache.remove(guid);
// 更新统计 / Update statistics
this._statistics.loadedCount--;
entry.state = AssetState.Unloaded;
}
/**
* Unload all assets
* 卸载所有资产
*/
unloadAllAssets(): void {
const guids = Array.from(this._assets.keys());
guids.forEach((guid) => this.unloadAsset(guid));
}
/**
* Unload unused assets
* 卸载未使用的资产
*/
unloadUnusedAssets(): void {
const guids = Array.from(this._assets.keys());
guids.forEach((guid) => {
const entry = this._assets.get(guid);
if (entry && entry.referenceCount === 0) {
this.unloadAsset(guid);
}
});
}
/**
* Add reference to asset
* 增加资产引用
*/
addReference(guid: AssetGUID): void {
const entry = this._assets.get(guid);
if (entry) {
entry.referenceCount++;
}
}
/**
* Remove reference from asset
* 移除资产引用
*/
removeReference(guid: AssetGUID): void {
const entry = this._assets.get(guid);
if (entry && entry.referenceCount > 0) {
entry.referenceCount--;
}
}
/**
* Get reference info
* 获取引用信息
*/
getReferenceInfo(guid: AssetGUID): IAssetReferenceInfo | null {
const entry = this._assets.get(guid);
if (!entry) return null;
return {
guid,
handle: entry.handle,
referenceCount: entry.referenceCount,
lastAccessTime: entry.lastAccessTime,
state: entry.state
};
}
/**
* Register custom loader
* 注册自定义加载器
*/
registerLoader(type: AssetType, loader: IAssetLoader): void {
this._loaderFactory.registerLoader(type, loader);
}
/**
* Get asset statistics
* 获取资产统计信息
*/
getStatistics(): { loadedCount: number; loadQueue: number; failedCount: number } {
return {
loadedCount: this._statistics.loadedCount,
loadQueue: this._loadQueue.getSize(),
failedCount: this._statistics.failedCount
};
}
/**
* Clear cache
* 清空缓存
*/
clearCache(): void {
this._cache.clear();
}
/**
* Dispose manager
* 释放管理器
*/
dispose(): void {
if (this._isDisposed) return;
this.unloadAllAssets();
this._cache.clear();
this._loadQueue.clear();
this._assets.clear();
this._handleToGuid.clear();
this._pathToGuid.clear();
this._isDisposed = true;
}
}
@@ -1,243 +0,0 @@
/**
* Asset path resolver for different platforms and protocols
* 不同平台和协议的资产路径解析器
*/
import { AssetPlatform } from '../types/AssetTypes';
import { PathValidator } from '../utils/PathValidator';
/**
* Asset path resolver configuration
* 资产路径解析器配置
*/
export interface IAssetPathConfig {
/** Base URL for web assets | Web资产的基础URL */
baseUrl?: string;
/** Asset directory path | 资产目录路径 */
assetDir?: string;
/** Asset host for asset:// protocol | 资产协议的主机名 */
assetHost?: string;
/** Current platform | 当前平台 */
platform?: AssetPlatform;
/** Custom path transformer | 自定义路径转换器 */
pathTransformer?: (path: string) => string;
}
/**
* Asset path resolver
* 资产路径解析器
*/
export class AssetPathResolver {
private config: IAssetPathConfig;
constructor(config: IAssetPathConfig = {}) {
this.config = {
baseUrl: '',
assetDir: 'assets',
platform: AssetPlatform.H5,
...config
};
}
/**
* Update configuration
* 更新配置
*/
updateConfig(config: Partial<IAssetPathConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* Resolve asset path to full URL
* 解析资产路径为完整URL
*/
resolve(path: string): string {
// Validate input path
const validation = PathValidator.validate(path);
if (!validation.valid) {
console.warn(`Invalid asset path: ${path} - ${validation.reason}`);
// Sanitize the path instead of throwing
path = PathValidator.sanitize(path);
if (!path) {
throw new Error(`Cannot resolve invalid path: ${validation.reason}`);
}
}
// Already a full URL
// 已经是完整URL
if (this.isAbsoluteUrl(path)) {
return path;
}
// Data URL
// 数据URL
if (path.startsWith('data:')) {
return path;
}
// Normalize the path
path = PathValidator.normalize(path);
// Apply custom transformer if provided
// 应用自定义转换器(如果提供)
if (this.config.pathTransformer) {
path = this.config.pathTransformer(path);
// Re-validate after transformation
const postTransform = PathValidator.validate(path);
if (!postTransform.valid) {
throw new Error(`Path transformer produced invalid path: ${postTransform.reason}`);
}
}
// Platform-specific resolution
// 平台特定解析
switch (this.config.platform) {
case AssetPlatform.H5:
return this.resolveH5Path(path);
case AssetPlatform.WeChat:
return this.resolveWeChatPath(path);
case AssetPlatform.Playable:
return this.resolvePlayablePath(path);
case AssetPlatform.Android:
case AssetPlatform.iOS:
return this.resolveMobilePath(path);
case AssetPlatform.Editor:
return this.resolveEditorPath(path);
default:
return this.resolveH5Path(path);
}
}
/**
* Resolve path for H5 platform
* 解析H5平台路径
*/
private resolveH5Path(path: string): string {
// Remove leading slash if present
// 移除开头的斜杠(如果存在)
path = path.replace(/^\//, '');
// Combine with base URL and asset directory
// 与基础URL和资产目录结合
const base = this.config.baseUrl || (typeof window !== 'undefined' ? window.location.origin : '');
const assetDir = this.config.assetDir || 'assets';
return `${base}/${assetDir}/${path}`.replace(/\/+/g, '/');
}
/**
* Resolve path for WeChat Mini Game
* 解析微信小游戏路径
*/
private resolveWeChatPath(path: string): string {
// WeChat mini games use relative paths
// 微信小游戏使用相对路径
return `${this.config.assetDir}/${path}`.replace(/\/+/g, '/');
}
/**
* Resolve path for Playable Ads platform
* 解析试玩广告平台路径
*/
private resolvePlayablePath(path: string): string {
// Playable ads typically use base64 embedded resources or relative paths
// 试玩广告通常使用base64内嵌资源或相对路径
// If custom transformer is provided (e.g., for base64 encoding)
// 如果提供了自定义转换器(例如用于base64编码)
if (this.config.pathTransformer) {
return this.config.pathTransformer(path);
}
// Default to relative path without directory prefix
// 默认使用不带目录前缀的相对路径
return path;
}
/**
* Resolve path for mobile platform (Android/iOS)
* 解析移动平台路径(Android/iOS
*/
private resolveMobilePath(path: string): string {
// Mobile platforms use relative paths or file:// protocol
// 移动平台使用相对路径或file://协议
return `./${this.config.assetDir}/${path}`.replace(/\/+/g, '/');
}
/**
* Resolve path for Editor platform (Tauri)
* 解析编辑器平台路径(Tauri)
*/
private resolveEditorPath(path: string): string {
// For Tauri editor, use pathTransformer if provided
// 对于Tauri编辑器,使用pathTransformer(如果提供)
if (this.config.pathTransformer) {
return this.config.pathTransformer(path);
}
// Use configurable asset host or default to 'localhost'
// 使用可配置的资产主机或默认为 'localhost'
const host = this.config.assetHost || 'localhost';
const sanitizedPath = PathValidator.sanitize(path);
return `asset://${host}/${sanitizedPath}`;
}
/**
* Check if path is absolute URL
* 检查路径是否为绝对URL
*/
private isAbsoluteUrl(path: string): boolean {
return /^(https?:\/\/|file:\/\/|asset:\/\/)/.test(path);
}
/**
* Get asset directory from path
* 从路径获取资产目录
*/
getAssetDirectory(path: string): string {
const resolved = this.resolve(path);
const lastSlash = resolved.lastIndexOf('/');
return lastSlash >= 0 ? resolved.substring(0, lastSlash) : '';
}
/**
* Get asset filename from path
* 从路径获取资产文件名
*/
getAssetFilename(path: string): string {
const resolved = this.resolve(path);
const lastSlash = resolved.lastIndexOf('/');
return lastSlash >= 0 ? resolved.substring(lastSlash + 1) : resolved;
}
/**
* Join paths
* 连接路径
*/
join(...paths: string[]): string {
return paths.join('/').replace(/\/+/g, '/');
}
/**
* Normalize path
* 规范化路径
*/
normalize(path: string): string {
return path.replace(/\\/g, '/').replace(/\/+/g, '/');
}
}
/**
* Global asset path resolver instance
* 全局资产路径解析器实例
*/
export const globalPathResolver = new AssetPathResolver();
@@ -1,338 +0,0 @@
/**
* Asset reference for lazy loading
* 用于懒加载的资产引用
*/
import { AssetGUID, IAssetLoadOptions, AssetState } from '../types/AssetTypes';
import { IAssetManager } from '../interfaces/IAssetManager';
/**
* Asset reference class for lazy loading
* 懒加载资产引用类
*/
export class AssetReference<T = unknown> {
private _guid: AssetGUID;
private _asset?: T;
private _loadPromise?: Promise<T>;
private _manager?: IAssetManager;
private _isReleased = false;
private _autoRelease = false;
/**
* Constructor
* 构造函数
*/
constructor(guid: AssetGUID, manager?: IAssetManager) {
this._guid = guid;
this._manager = manager;
}
/**
* Get asset GUID
* 获取资产GUID
*/
get guid(): AssetGUID {
return this._guid;
}
/**
* Check if asset is loaded
* 检查资产是否已加载
*/
get isLoaded(): boolean {
return this._asset !== undefined && !this._isReleased;
}
/**
* Get asset synchronously (returns null if not loaded)
* 同步获取资产(如果未加载则返回null)
*/
get asset(): T | null {
return this._asset ?? null;
}
/**
* Set asset manager
* 设置资产管理器
*/
setManager(manager: IAssetManager): void {
this._manager = manager;
}
/**
* Load asset asynchronously
* 异步加载资产
*/
async loadAsync(options?: IAssetLoadOptions): Promise<T> {
if (this._isReleased) {
throw new Error(`Asset reference ${this._guid} has been released`);
}
// 如果已经加载,直接返回 / Return if already loaded
if (this._asset !== undefined) {
return this._asset;
}
// 如果正在加载,返回加载Promise / Return loading promise if loading
if (this._loadPromise) {
return this._loadPromise;
}
if (!this._manager) {
throw new Error('Asset manager not set for AssetReference');
}
// 开始加载 / Start loading
this._loadPromise = this.performLoad(options);
try {
const asset = await this._loadPromise;
return asset;
} finally {
this._loadPromise = undefined;
}
}
/**
* Perform asset loading
* 执行资产加载
*/
private async performLoad(options?: IAssetLoadOptions): Promise<T> {
if (!this._manager) {
throw new Error('Asset manager not set');
}
const result = await this._manager.loadAsset<T>(this._guid, options);
this._asset = result.asset;
// 增加引用计数 / Increase reference count
this._manager.addReference(this._guid);
return this._asset;
}
/**
* Release asset reference
* 释放资产引用
*/
release(): void {
if (this._isReleased) return;
if (this._manager && this._asset !== undefined) {
// 减少引用计数 / Decrease reference count
this._manager.removeReference(this._guid);
// 如果引用计数为0,可以考虑卸载 / Consider unloading if reference count is 0
const refInfo = this._manager.getReferenceInfo(this._guid);
if (refInfo && refInfo.referenceCount === 0 && this._autoRelease) {
this._manager.unloadAsset(this._guid);
}
}
this._asset = undefined;
this._isReleased = true;
}
/**
* Set auto-release mode
* 设置自动释放模式
*/
setAutoRelease(autoRelease: boolean): void {
this._autoRelease = autoRelease;
}
/**
* Validate reference
* 验证引用
*/
validate(): boolean {
if (!this._manager) return false;
const state = this._manager.getAssetState(this._guid);
return state !== AssetState.Failed;
}
/**
* Get asset state
* 获取资产状态
*/
getState(): AssetState {
if (this._isReleased) return AssetState.Unloaded;
if (!this._manager) return AssetState.Unloaded;
return this._manager.getAssetState(this._guid);
}
/**
* Clone reference
* 克隆引用
*/
clone(): AssetReference<T> {
const newRef = new AssetReference<T>(this._guid, this._manager);
newRef.setAutoRelease(this._autoRelease);
return newRef;
}
/**
* Convert to JSON
* 转换为JSON
*/
toJSON(): { guid: AssetGUID } {
return { guid: this._guid };
}
/**
* Create from JSON
* 从JSON创建
*/
static fromJSON<T = unknown>(
json: { guid: AssetGUID },
manager?: IAssetManager
): AssetReference<T> {
return new AssetReference<T>(json.guid, manager);
}
}
/**
* Weak asset reference that doesn't prevent unloading
* 不阻止卸载的弱资产引用
*/
export class WeakAssetReference<T = unknown> {
private _guid: AssetGUID;
private _manager?: IAssetManager;
constructor(guid: AssetGUID, manager?: IAssetManager) {
this._guid = guid;
this._manager = manager;
}
/**
* Get asset GUID
* 获取资产GUID
*/
get guid(): AssetGUID {
return this._guid;
}
/**
* Try get asset without loading
* 尝试获取资产而不加载
*/
tryGet(): T | null {
if (!this._manager) return null;
return this._manager.getAsset<T>(this._guid);
}
/**
* Load asset if not loaded
* 如果未加载则加载资产
*/
async loadAsync(options?: IAssetLoadOptions): Promise<T> {
if (!this._manager) {
throw new Error('Asset manager not set');
}
const result = await this._manager.loadAsset<T>(this._guid, options);
// 不增加引用计数 / Don't increase reference count for weak reference
return result.asset;
}
/**
* Check if asset is loaded
* 检查资产是否已加载
*/
isLoaded(): boolean {
if (!this._manager) return false;
return this._manager.isLoaded(this._guid);
}
/**
* Set asset manager
* 设置资产管理器
*/
setManager(manager: IAssetManager): void {
this._manager = manager;
}
}
/**
* Asset reference array for managing multiple references
* 用于管理多个引用的资产引用数组
*/
export class AssetReferenceArray<T = unknown> {
private _references: AssetReference<T>[] = [];
private _manager?: IAssetManager;
constructor(guids: AssetGUID[] = [], manager?: IAssetManager) {
this._manager = manager;
this._references = guids.map((guid) => new AssetReference<T>(guid, manager));
}
/**
* Add reference
* 添加引用
*/
add(guid: AssetGUID): void {
this._references.push(new AssetReference<T>(guid, this._manager));
}
/**
* Remove reference
* 移除引用
*/
remove(guid: AssetGUID): boolean {
const index = this._references.findIndex((ref) => ref.guid === guid);
if (index >= 0) {
this._references[index].release();
this._references.splice(index, 1);
return true;
}
return false;
}
/**
* Load all assets
* 加载所有资产
*/
async loadAllAsync(options?: IAssetLoadOptions): Promise<T[]> {
const promises = this._references.map((ref) => ref.loadAsync(options));
return Promise.all(promises);
}
/**
* Release all references
* 释放所有引用
*/
releaseAll(): void {
this._references.forEach((ref) => ref.release());
this._references = [];
}
/**
* Get all loaded assets
* 获取所有已加载的资产
*/
getLoadedAssets(): T[] {
return this._references
.filter((ref) => ref.isLoaded)
.map((ref) => ref.asset!)
.filter((asset) => asset !== null);
}
/**
* Get reference count
* 获取引用数量
*/
get count(): number {
return this._references.length;
}
/**
* Set asset manager
* 设置资产管理器
*/
setManager(manager: IAssetManager): void {
this._manager = manager;
this._references.forEach((ref) => ref.setManager(manager));
}
}
-51
View File
@@ -1,51 +0,0 @@
/**
* Asset System for ECS Framework
* ECS框架的资产系统
*/
// Types
export * from './types/AssetTypes';
// Interfaces
export * from './interfaces/IAssetLoader';
export * from './interfaces/IAssetManager';
// Core
export { AssetManager } from './core/AssetManager';
export { AssetCache } from './core/AssetCache';
export { AssetDatabase } from './core/AssetDatabase';
export { AssetLoadQueue } from './core/AssetLoadQueue';
export { AssetReference, WeakAssetReference, AssetReferenceArray } from './core/AssetReference';
export { AssetPathResolver, globalPathResolver } from './core/AssetPathResolver';
export type { IAssetPathConfig } from './core/AssetPathResolver';
// Loaders
export { AssetLoaderFactory } from './loaders/AssetLoaderFactory';
export { TextureLoader } from './loaders/TextureLoader';
export { JsonLoader } from './loaders/JsonLoader';
export { TextLoader } from './loaders/TextLoader';
export { BinaryLoader } from './loaders/BinaryLoader';
// Integration
export { EngineIntegration } from './integration/EngineIntegration';
export type { IEngineBridge } from './integration/EngineIntegration';
// Default instance
import { AssetManager } from './core/AssetManager';
/**
* Default asset manager instance
* 默认资产管理器实例
*/
export const assetManager = new AssetManager();
/**
* Initialize asset system with catalog
* 使用目录初始化资产系统
*/
export function initializeAssetSystem(catalog?: any): AssetManager {
if (catalog) {
return new AssetManager(catalog);
}
return assetManager;
}
@@ -1,217 +0,0 @@
/**
* Engine integration for asset system
* 资产系统的引擎集成
*/
import { AssetManager } from '../core/AssetManager';
import { AssetGUID } from '../types/AssetTypes';
import { ITextureAsset } from '../interfaces/IAssetLoader';
/**
* Engine bridge interface
* 引擎桥接接口
*/
export interface IEngineBridge {
/**
* Load texture to GPU
* 加载纹理到GPU
*/
loadTexture(id: number, url: string): Promise<void>;
/**
* Load multiple textures
* 批量加载纹理
*/
loadTextures(requests: Array<{ id: number; url: string }>): Promise<void>;
/**
* Unload texture from GPU
* 从GPU卸载纹理
*/
unloadTexture(id: number): void;
/**
* Get texture info
* 获取纹理信息
*/
getTextureInfo(id: number): { width: number; height: number } | null;
}
/**
* Asset system engine integration
* 资产系统引擎集成
*/
export class EngineIntegration {
private _assetManager: AssetManager;
private _engineBridge?: IEngineBridge;
private _textureIdMap = new Map<AssetGUID, number>();
private _pathToTextureId = new Map<string, number>();
constructor(assetManager: AssetManager, engineBridge?: IEngineBridge) {
this._assetManager = assetManager;
this._engineBridge = engineBridge;
}
/**
* Set engine bridge
* 设置引擎桥接
*/
setEngineBridge(bridge: IEngineBridge): void {
this._engineBridge = bridge;
}
/**
* Load texture for component
* 为组件加载纹理
*/
async loadTextureForComponent(texturePath: string): Promise<number> {
// 检查是否已有纹理ID / Check if texture ID exists
const existingId = this._pathToTextureId.get(texturePath);
if (existingId) {
return existingId;
}
// 通过资产系统加载 / Load through asset system
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(texturePath);
const textureAsset = result.asset;
// 如果有引擎桥接,上传到GPU / Upload to GPU if bridge exists
if (this._engineBridge && textureAsset.data) {
await this._engineBridge.loadTexture(textureAsset.textureId, texturePath);
}
// 缓存映射 / Cache mapping
this._pathToTextureId.set(texturePath, textureAsset.textureId);
return textureAsset.textureId;
}
/**
* Load texture by GUID
* 通过GUID加载纹理
*/
async loadTextureByGuid(guid: AssetGUID): Promise<number> {
// 检查是否已有纹理ID / Check if texture ID exists
const existingId = this._textureIdMap.get(guid);
if (existingId) {
return existingId;
}
// 通过资产系统加载 / Load through asset system
const result = await this._assetManager.loadAsset<ITextureAsset>(guid);
const textureAsset = result.asset;
// 如果有引擎桥接,上传到GPU / Upload to GPU if bridge exists
if (this._engineBridge && textureAsset.data) {
const metadata = result.metadata;
await this._engineBridge.loadTexture(textureAsset.textureId, metadata.path);
}
// 缓存映射 / Cache mapping
this._textureIdMap.set(guid, textureAsset.textureId);
return textureAsset.textureId;
}
/**
* Batch load textures
* 批量加载纹理
*/
async loadTexturesBatch(paths: string[]): Promise<Map<string, number>> {
const results = new Map<string, number>();
// 收集需要加载的纹理 / Collect textures to load
const toLoad: string[] = [];
for (const path of paths) {
const existingId = this._pathToTextureId.get(path);
if (existingId) {
results.set(path, existingId);
} else {
toLoad.push(path);
}
}
if (toLoad.length === 0) {
return results;
}
// 并行加载所有纹理 / Load all textures in parallel
const loadPromises = toLoad.map(async (path) => {
try {
const id = await this.loadTextureForComponent(path);
results.set(path, id);
} catch (error) {
console.error(`Failed to load texture: ${path}`, error);
results.set(path, 0); // 使用默认纹理ID / Use default texture ID
}
});
await Promise.all(loadPromises);
return results;
}
/**
* Unload texture
* 卸载纹理
*/
unloadTexture(textureId: number): void {
// 从引擎卸载 / Unload from engine
if (this._engineBridge) {
this._engineBridge.unloadTexture(textureId);
}
// 清理映射 / Clean up mappings
for (const [path, id] of this._pathToTextureId.entries()) {
if (id === textureId) {
this._pathToTextureId.delete(path);
break;
}
}
for (const [guid, id] of this._textureIdMap.entries()) {
if (id === textureId) {
this._textureIdMap.delete(guid);
// 也从资产管理器卸载 / Also unload from asset manager
this._assetManager.unloadAsset(guid);
break;
}
}
}
/**
* Get texture ID for path
* 获取路径的纹理ID
*/
getTextureId(path: string): number | null {
return this._pathToTextureId.get(path) || null;
}
/**
* Preload textures for scene
* 为场景预加载纹理
*/
async preloadSceneTextures(texturePaths: string[]): Promise<void> {
await this.loadTexturesBatch(texturePaths);
}
/**
* Clear all texture mappings
* 清空所有纹理映射
*/
clearTextureMappings(): void {
this._textureIdMap.clear();
this._pathToTextureId.clear();
}
/**
* Get statistics
* 获取统计信息
*/
getStatistics(): {
loadedTextures: number;
} {
return {
loadedTextures: this._pathToTextureId.size
};
}
}
@@ -1,222 +0,0 @@
/**
* Asset loader interfaces
* 资产加载器接口
*/
import {
AssetType,
AssetGUID,
IAssetLoadOptions,
IAssetMetadata,
IAssetLoadResult
} from '../types/AssetTypes';
/**
* Base asset loader interface
* 基础资产加载器接口
*/
export interface IAssetLoader<T = unknown> {
/** 支持的资产类型 / Supported asset type */
readonly supportedType: AssetType;
/** 支持的文件扩展名 / Supported file extensions */
readonly supportedExtensions: string[];
/**
* Load an asset from the given path
* 从指定路径加载资产
*/
load(
path: string,
metadata: IAssetMetadata,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<T>>;
/**
* Validate if the loader can handle this asset
* 验证加载器是否可以处理此资产
*/
canLoad(path: string, metadata: IAssetMetadata): boolean;
/**
* Dispose loaded asset and free resources
* 释放已加载的资产并释放资源
*/
dispose(asset: T): void;
}
/**
* Asset loader factory interface
* 资产加载器工厂接口
*/
export interface IAssetLoaderFactory {
/**
* Create loader for specific asset type
* 为特定资产类型创建加载器
*/
createLoader(type: AssetType): IAssetLoader | null;
/**
* Register custom loader
* 注册自定义加载器
*/
registerLoader(type: AssetType, loader: IAssetLoader): void;
/**
* Unregister loader
* 注销加载器
*/
unregisterLoader(type: AssetType): void;
/**
* Check if loader exists for type
* 检查类型是否有加载器
*/
hasLoader(type: AssetType): boolean;
}
/**
* Texture asset interface
* 纹理资产接口
*/
export interface ITextureAsset {
/** WebGL纹理ID / WebGL texture ID */
textureId: number;
/** 宽度 / Width */
width: number;
/** 高度 / Height */
height: number;
/** 格式 / Format */
format: 'rgba' | 'rgb' | 'alpha';
/** 是否有Mipmap / Has mipmaps */
hasMipmaps: boolean;
/** 原始数据(如果可用) / Raw image data if available */
data?: ImageData | HTMLImageElement;
}
/**
* Mesh asset interface
* 网格资产接口
*/
export interface IMeshAsset {
/** 顶点数据 / Vertex data */
vertices: Float32Array;
/** 索引数据 / Index data */
indices: Uint16Array | Uint32Array;
/** 法线数据 / Normal data */
normals?: Float32Array;
/** UV坐标 / UV coordinates */
uvs?: Float32Array;
/** 切线数据 / Tangent data */
tangents?: Float32Array;
/** 边界盒 / Axis-aligned bounding box */
bounds: {
min: [number, number, number];
max: [number, number, number];
};
}
/**
* Audio asset interface
* 音频资产接口
*/
export interface IAudioAsset {
/** 音频缓冲区 / Audio buffer */
buffer: AudioBuffer;
/** 时长(秒) / Duration in seconds */
duration: number;
/** 采样率 / Sample rate */
sampleRate: number;
/** 声道数 / Number of channels */
channels: number;
}
/**
* Material asset interface
* 材质资产接口
*/
export interface IMaterialAsset {
/** 着色器名称 / Shader name */
shader: string;
/** 材质属性 / Material properties */
properties: Map<string, unknown>;
/** 纹理映射 / Texture slot mappings */
textures: Map<string, AssetGUID>;
/** 渲染状态 / Render states */
renderStates: {
cullMode?: 'none' | 'front' | 'back';
blendMode?: 'none' | 'alpha' | 'additive' | 'multiply';
depthTest?: boolean;
depthWrite?: boolean;
};
}
/**
* Prefab asset interface
* 预制体资产接口
*/
export interface IPrefabAsset {
/** 根实体数据 / Serialized entity hierarchy */
root: unknown;
/** 包含的组件类型 / Component types used in prefab */
componentTypes: string[];
/** 引用的资产 / All referenced assets */
referencedAssets: AssetGUID[];
}
/**
* Scene asset interface
* 场景资产接口
*/
export interface ISceneAsset {
/** 场景名称 / Scene name */
name: string;
/** 实体列表 / Serialized entity list */
entities: unknown[];
/** 场景设置 / Scene settings */
settings: {
/** 环境光 / Ambient light */
ambientLight?: [number, number, number];
/** 雾效 / Fog settings */
fog?: {
enabled: boolean;
color: [number, number, number];
density: number;
};
/** 天空盒 / Skybox asset */
skybox?: AssetGUID;
};
/** 引用的资产 / All referenced assets */
referencedAssets: AssetGUID[];
}
/**
* JSON asset interface
* JSON资产接口
*/
export interface IJsonAsset {
/** JSON数据 / JSON data */
data: unknown;
}
/**
* Text asset interface
* 文本资产接口
*/
export interface ITextAsset {
/** 文本内容 / Text content */
content: string;
/** 编码格式 / Encoding */
encoding: 'utf8' | 'utf16' | 'ascii';
}
/**
* Binary asset interface
* 二进制资产接口
*/
export interface IBinaryAsset {
/** 二进制数据 / Binary data */
data: ArrayBuffer;
/** MIME类型 / MIME type */
mimeType?: string;
}
@@ -1,328 +0,0 @@
/**
* Asset manager interfaces
* 资产管理器接口
*/
import {
AssetGUID,
AssetHandle,
AssetType,
AssetState,
IAssetLoadOptions,
IAssetLoadResult,
IAssetReferenceInfo,
IAssetPreloadGroup,
IAssetLoadProgress
} from '../types/AssetTypes';
import { IAssetLoader } from './IAssetLoader';
/**
* Asset manager interface
* 资产管理器接口
*/
export interface IAssetManager {
/**
* Load asset by GUID
* 通过GUID加载资产
*/
loadAsset<T = unknown>(
guid: AssetGUID,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<T>>;
/**
* Load asset by path
* 通过路径加载资产
*/
loadAssetByPath<T = unknown>(
path: string,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<T>>;
/**
* Load multiple assets
* 批量加载资产
*/
loadAssets(
guids: AssetGUID[],
options?: IAssetLoadOptions
): Promise<Map<AssetGUID, IAssetLoadResult>>;
/**
* Preload asset group
* 预加载资产组
*/
preloadGroup(
group: IAssetPreloadGroup,
onProgress?: (progress: IAssetLoadProgress) => void
): Promise<void>;
/**
* Get loaded asset
* 获取已加载的资产
*/
getAsset<T = unknown>(guid: AssetGUID): T | null;
/**
* Get asset by handle
* 通过句柄获取资产
*/
getAssetByHandle<T = unknown>(handle: AssetHandle): T | null;
/**
* Check if asset is loaded
* 检查资产是否已加载
*/
isLoaded(guid: AssetGUID): boolean;
/**
* Get asset state
* 获取资产状态
*/
getAssetState(guid: AssetGUID): AssetState;
/**
* Unload asset
* 卸载资产
*/
unloadAsset(guid: AssetGUID): void;
/**
* Unload all assets
* 卸载所有资产
*/
unloadAllAssets(): void;
/**
* Unload unused assets
* 卸载未使用的资产
*/
unloadUnusedAssets(): void;
/**
* Add reference to asset
* 增加资产引用
*/
addReference(guid: AssetGUID): void;
/**
* Remove reference from asset
* 移除资产引用
*/
removeReference(guid: AssetGUID): void;
/**
* Get reference info
* 获取引用信息
*/
getReferenceInfo(guid: AssetGUID): IAssetReferenceInfo | null;
/**
* Register custom loader
* 注册自定义加载器
*/
registerLoader(type: AssetType, loader: IAssetLoader): void;
/**
* Get asset statistics
* 获取资产统计信息
*/
getStatistics(): {
loadedCount: number;
loadQueue: number;
failedCount: number;
};
/**
* Clear cache
* 清空缓存
*/
clearCache(): void;
/**
* Dispose manager
* 释放管理器
*/
dispose(): void;
}
/**
* Asset cache interface
* 资产缓存接口
*/
export interface IAssetCache {
/**
* Get cached asset
* 获取缓存的资产
*/
get<T = unknown>(guid: AssetGUID): T | null;
/**
* Set cached asset
* 设置缓存的资产
*/
set<T = unknown>(guid: AssetGUID, asset: T, size: number): void;
/**
* Check if asset is cached
* 检查资产是否已缓存
*/
has(guid: AssetGUID): boolean;
/**
* Remove from cache
* 从缓存中移除
*/
remove(guid: AssetGUID): void;
/**
* Clear all cache
* 清空所有缓存
*/
clear(): void;
/**
* Get cache size
* 获取缓存大小
*/
getSize(): number;
/**
* Get cached asset count
* 获取缓存资产数量
*/
getCount(): number;
/**
* Evict assets based on policy
* 根据策略驱逐资产
*/
evict(targetSize: number): void;
}
/**
* Asset loading queue interface
* 资产加载队列接口
*/
export interface IAssetLoadQueue {
/**
* Add to queue
* 添加到队列
*/
enqueue(
guid: AssetGUID,
priority: number,
options?: IAssetLoadOptions
): void;
/**
* Remove from queue
* 从队列移除
*/
dequeue(): {
guid: AssetGUID;
options?: IAssetLoadOptions;
} | null;
/**
* Check if queue is empty
* 检查队列是否为空
*/
isEmpty(): boolean;
/**
* Get queue size
* 获取队列大小
*/
getSize(): number;
/**
* Clear queue
* 清空队列
*/
clear(): void;
/**
* Reprioritize item
* 重新设置优先级
*/
reprioritize(guid: AssetGUID, newPriority: number): void;
}
/**
* Asset dependency resolver interface
* 资产依赖解析器接口
*/
export interface IAssetDependencyResolver {
/**
* Resolve dependencies for asset
* 解析资产的依赖
*/
resolveDependencies(guid: AssetGUID): Promise<AssetGUID[]>;
/**
* Get direct dependencies
* 获取直接依赖
*/
getDirectDependencies(guid: AssetGUID): AssetGUID[];
/**
* Get all dependencies recursively
* 递归获取所有依赖
*/
getAllDependencies(guid: AssetGUID): AssetGUID[];
/**
* Check for circular dependencies
* 检查循环依赖
*/
hasCircularDependency(guid: AssetGUID): boolean;
/**
* Build dependency graph
* 构建依赖图
*/
buildDependencyGraph(guids: AssetGUID[]): Map<AssetGUID, AssetGUID[]>;
}
/**
* Asset streaming interface
* 资产流式加载接口
*/
export interface IAssetStreaming {
/**
* Start streaming assets
* 开始流式加载资产
*/
startStreaming(guids: AssetGUID[]): void;
/**
* Stop streaming
* 停止流式加载
*/
stopStreaming(): void;
/**
* Pause streaming
* 暂停流式加载
*/
pauseStreaming(): void;
/**
* Resume streaming
* 恢复流式加载
*/
resumeStreaming(): void;
/**
* Set streaming budget per frame
* 设置每帧流式加载预算
*/
setFrameBudget(milliseconds: number): void;
/**
* Get streaming progress
* 获取流式加载进度
*/
getProgress(): IAssetLoadProgress;
}
@@ -1,90 +0,0 @@
/**
* Asset loader factory implementation
* 资产加载器工厂实现
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, IAssetLoaderFactory } from '../interfaces/IAssetLoader';
import { TextureLoader } from './TextureLoader';
import { JsonLoader } from './JsonLoader';
import { TextLoader } from './TextLoader';
import { BinaryLoader } from './BinaryLoader';
/**
* Asset loader factory
* 资产加载器工厂
*/
export class AssetLoaderFactory implements IAssetLoaderFactory {
private readonly _loaders = new Map<AssetType, IAssetLoader>();
constructor() {
// 注册默认加载器 / Register default loaders
this.registerDefaultLoaders();
}
/**
* Register default loaders
* 注册默认加载器
*/
private registerDefaultLoaders(): void {
// 纹理加载器 / Texture loader
this._loaders.set(AssetType.Texture, new TextureLoader());
// JSON加载器 / JSON loader
this._loaders.set(AssetType.Json, new JsonLoader());
// 文本加载器 / Text loader
this._loaders.set(AssetType.Text, new TextLoader());
// 二进制加载器 / Binary loader
this._loaders.set(AssetType.Binary, new BinaryLoader());
}
/**
* Create loader for specific asset type
* 为特定资产类型创建加载器
*/
createLoader(type: AssetType): IAssetLoader | null {
return this._loaders.get(type) || null;
}
/**
* Register custom loader
* 注册自定义加载器
*/
registerLoader(type: AssetType, loader: IAssetLoader): void {
this._loaders.set(type, loader);
}
/**
* Unregister loader
* 注销加载器
*/
unregisterLoader(type: AssetType): void {
this._loaders.delete(type);
}
/**
* Check if loader exists for type
* 检查类型是否有加载器
*/
hasLoader(type: AssetType): boolean {
return this._loaders.has(type);
}
/**
* Get all registered loaders
* 获取所有注册的加载器
*/
getRegisteredTypes(): AssetType[] {
return Array.from(this._loaders.keys());
}
/**
* Clear all loaders
* 清空所有加载器
*/
clear(): void {
this._loaders.clear();
}
}
@@ -1,165 +0,0 @@
/**
* Binary asset loader
* 二进制资产加载器
*/
import {
AssetType,
IAssetLoadOptions,
IAssetMetadata,
IAssetLoadResult,
AssetLoadError
} from '../types/AssetTypes';
import { IAssetLoader, IBinaryAsset } from '../interfaces/IAssetLoader';
/**
* Binary loader implementation
* 二进制加载器实现
*/
export class BinaryLoader implements IAssetLoader<IBinaryAsset> {
readonly supportedType = AssetType.Binary;
readonly supportedExtensions = [
'.bin', '.dat', '.raw', '.bytes',
'.wasm', '.so', '.dll', '.dylib'
];
/**
* Load binary asset
* 加载二进制资产
*/
async load(
path: string,
metadata: IAssetMetadata,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<IBinaryAsset>> {
const startTime = performance.now();
try {
const response = await this.fetchWithTimeout(path, options?.timeout);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 获取MIME类型 / Get MIME type
const mimeType = response.headers.get('content-type') || undefined;
// 获取总大小用于进度回调 / Get total size for progress callback
const contentLength = response.headers.get('content-length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
// 读取响应 / Read response
let data: ArrayBuffer;
if (options?.onProgress && total > 0) {
data = await this.readResponseWithProgress(response, total, options.onProgress);
} else {
data = await response.arrayBuffer();
}
const asset: IBinaryAsset = {
data,
mimeType
};
return {
asset,
handle: 0,
metadata,
loadTime: performance.now() - startTime
};
} catch (error) {
if (error instanceof Error) {
throw new AssetLoadError(
`Failed to load binary: ${error.message}`,
metadata.guid,
AssetType.Binary,
error
);
}
throw AssetLoadError.fileNotFound(metadata.guid, path);
}
}
/**
* Fetch with timeout
* 带超时的fetch
*/
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
mode: 'cors',
credentials: 'same-origin'
});
return response;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Read response with progress
* 带进度读取响应
*/
private async readResponseWithProgress(
response: Response,
total: number,
onProgress: (progress: number) => void
): Promise<ArrayBuffer> {
const reader = response.body?.getReader();
if (!reader) {
return response.arrayBuffer();
}
const chunks: Uint8Array[] = [];
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
receivedLength += value.length;
// 报告进度 / Report progress
onProgress(receivedLength / total);
}
// 合并chunks到ArrayBuffer / Merge chunks into ArrayBuffer
const result = new Uint8Array(receivedLength);
let position = 0;
for (const chunk of chunks) {
result.set(chunk, position);
position += chunk.length;
}
return result.buffer;
}
/**
* Validate if the loader can handle this asset
* 验证加载器是否可以处理此资产
*/
canLoad(path: string, _metadata: IAssetMetadata): boolean {
const ext = path.toLowerCase().substring(path.lastIndexOf('.'));
return this.supportedExtensions.includes(ext);
}
/**
* Estimate memory usage for the asset
* 估算资产的内存使用量
*/
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: IBinaryAsset): void {
// ArrayBuffer无法直接释放,但可以清空引用 / Can't directly release ArrayBuffer, but clear reference
(asset as any).data = null;
}
}
@@ -1,162 +0,0 @@
/**
* JSON asset loader
* JSON资产加载器
*/
import {
AssetType,
IAssetLoadOptions,
IAssetMetadata,
IAssetLoadResult,
AssetLoadError
} from '../types/AssetTypes';
import { IAssetLoader, IJsonAsset } from '../interfaces/IAssetLoader';
/**
* JSON loader implementation
* JSON加载器实现
*/
export class JsonLoader implements IAssetLoader<IJsonAsset> {
readonly supportedType = AssetType.Json;
readonly supportedExtensions = ['.json', '.jsonc'];
/**
* Load JSON asset
* 加载JSON资产
*/
async load(
path: string,
metadata: IAssetMetadata,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<IJsonAsset>> {
const startTime = performance.now();
try {
const response = await this.fetchWithTimeout(path, options?.timeout);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 获取总大小用于进度回调 / Get total size for progress callback
const contentLength = response.headers.get('content-length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
// 读取响应 / Read response
let jsonData: unknown;
if (options?.onProgress && total > 0) {
jsonData = await this.readResponseWithProgress(response, total, options.onProgress);
} else {
jsonData = await response.json();
}
const asset: IJsonAsset = {
data: jsonData
};
return {
asset,
handle: 0,
metadata,
loadTime: performance.now() - startTime
};
} catch (error) {
if (error instanceof Error) {
throw new AssetLoadError(
`Failed to load JSON: ${error.message}`,
metadata.guid,
AssetType.Json,
error
);
}
throw AssetLoadError.fileNotFound(metadata.guid, path);
}
}
/**
* Fetch with timeout
* 带超时的fetch
*/
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
mode: 'cors',
credentials: 'same-origin'
});
return response;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Read response with progress
* 带进度读取响应
*/
private async readResponseWithProgress(
response: Response,
total: number,
onProgress: (progress: number) => void
): Promise<unknown> {
const reader = response.body?.getReader();
if (!reader) {
return response.json();
}
const chunks: Uint8Array[] = [];
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
receivedLength += value.length;
// 报告进度 / Report progress
onProgress(receivedLength / total);
}
// 合并chunks / Merge chunks
const allChunks = new Uint8Array(receivedLength);
let position = 0;
for (const chunk of chunks) {
allChunks.set(chunk, position);
position += chunk.length;
}
// 解码为字符串并解析JSON / Decode to string and parse JSON
const decoder = new TextDecoder('utf-8');
const jsonString = decoder.decode(allChunks);
return JSON.parse(jsonString);
}
/**
* Validate if the loader can handle this asset
* 验证加载器是否可以处理此资产
*/
canLoad(path: string, _metadata: IAssetMetadata): boolean {
const ext = path.toLowerCase().substring(path.lastIndexOf('.'));
return this.supportedExtensions.includes(ext);
}
/**
* Estimate memory usage for the asset
* 估算资产的内存使用量
*/
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: IJsonAsset): void {
// JSON资产通常不需要特殊清理 / JSON assets usually don't need special cleanup
// 但可以清空引用以帮助GC / But can clear references to help GC
(asset as any).data = null;
}
}
@@ -1,172 +0,0 @@
/**
* Text asset loader
* 文本资产加载器
*/
import {
AssetType,
IAssetLoadOptions,
IAssetMetadata,
IAssetLoadResult,
AssetLoadError
} from '../types/AssetTypes';
import { IAssetLoader, ITextAsset } from '../interfaces/IAssetLoader';
/**
* Text loader implementation
* 文本加载器实现
*/
export class TextLoader implements IAssetLoader<ITextAsset> {
readonly supportedType = AssetType.Text;
readonly supportedExtensions = ['.txt', '.text', '.md', '.csv', '.xml', '.html', '.css', '.js', '.ts'];
/**
* Load text asset
* 加载文本资产
*/
async load(
path: string,
metadata: IAssetMetadata,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<ITextAsset>> {
const startTime = performance.now();
try {
const response = await this.fetchWithTimeout(path, options?.timeout);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 获取总大小用于进度回调 / Get total size for progress callback
const contentLength = response.headers.get('content-length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
// 读取响应 / Read response
let content: string;
if (options?.onProgress && total > 0) {
content = await this.readResponseWithProgress(response, total, options.onProgress);
} else {
content = await response.text();
}
// 检测编码 / Detect encoding
const encoding = this.detectEncoding(content);
const asset: ITextAsset = {
content,
encoding
};
return {
asset,
handle: 0,
metadata,
loadTime: performance.now() - startTime
};
} catch (error) {
if (error instanceof Error) {
throw new AssetLoadError(
`Failed to load text: ${error.message}`,
metadata.guid,
AssetType.Text,
error
);
}
throw AssetLoadError.fileNotFound(metadata.guid, path);
}
}
/**
* Fetch with timeout
* 带超时的fetch
*/
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
mode: 'cors',
credentials: 'same-origin'
});
return response;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Read response with progress
* 带进度读取响应
*/
private async readResponseWithProgress(
response: Response,
total: number,
onProgress: (progress: number) => void
): Promise<string> {
const reader = response.body?.getReader();
if (!reader) {
return response.text();
}
const decoder = new TextDecoder('utf-8');
let result = '';
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
receivedLength += value.length;
result += decoder.decode(value, { stream: true });
// 报告进度 / Report progress
onProgress(receivedLength / total);
}
return result;
}
/**
* Detect text encoding
* 检测文本编码
*/
private detectEncoding(content: string): 'utf8' | 'utf16' | 'ascii' {
// 简单的编码检测 / Simple encoding detection
// 检查是否包含非ASCII字符 / Check for non-ASCII characters
for (let i = 0; i < content.length; i++) {
const charCode = content.charCodeAt(i);
if (charCode > 127) {
// 包含非ASCII字符,可能是UTF-8或UTF-16 / Contains non-ASCII, likely UTF-8 or UTF-16
return charCode > 255 ? 'utf16' : 'utf8';
}
}
return 'ascii';
}
/**
* Validate if the loader can handle this asset
* 验证加载器是否可以处理此资产
*/
canLoad(path: string, _metadata: IAssetMetadata): boolean {
const ext = path.toLowerCase().substring(path.lastIndexOf('.'));
return this.supportedExtensions.includes(ext);
}
/**
* Estimate memory usage for the asset
* 估算资产的内存使用量
*/
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: ITextAsset): void {
// 清空内容以帮助GC / Clear content to help GC
(asset as any).content = '';
}
}
@@ -1,216 +0,0 @@
/**
* Texture asset loader
* 纹理资产加载器
*/
import {
AssetType,
IAssetLoadOptions,
IAssetMetadata,
IAssetLoadResult,
AssetLoadError
} from '../types/AssetTypes';
import { IAssetLoader, ITextureAsset } from '../interfaces/IAssetLoader';
/**
* Texture loader implementation
* 纹理加载器实现
*/
export class TextureLoader implements IAssetLoader<ITextureAsset> {
readonly supportedType = AssetType.Texture;
readonly supportedExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'];
private static _nextTextureId = 1;
private readonly _loadedTextures = new Map<string, ITextureAsset>();
/**
* Load texture asset
* 加载纹理资产
*/
async load(
path: string,
metadata: IAssetMetadata,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<ITextureAsset>> {
const startTime = performance.now();
// 检查缓存 / Check cache
if (!options?.forceReload && this._loadedTextures.has(path)) {
const cached = this._loadedTextures.get(path)!;
return {
asset: cached,
handle: cached.textureId,
metadata,
loadTime: 0
};
}
try {
// 创建图像元素 / Create image element
const image = await this.loadImage(path, options);
// 创建纹理资产 / Create texture asset
const textureAsset: ITextureAsset = {
textureId: TextureLoader._nextTextureId++,
width: image.width,
height: image.height,
format: 'rgba', // 默认格式 / Default format
hasMipmaps: false,
data: image
};
// 缓存纹理 / Cache texture
this._loadedTextures.set(path, textureAsset);
// 触发引擎纹理加载(如果有引擎桥接) / Trigger engine texture loading if bridge exists
if (typeof window !== 'undefined' && (window as any).engineBridge) {
await this.uploadToGPU(textureAsset, path);
}
return {
asset: textureAsset,
handle: textureAsset.textureId,
metadata,
loadTime: performance.now() - startTime
};
} catch (error) {
throw AssetLoadError.fileNotFound(metadata.guid, path);
}
}
/**
* Load image from URL
* 从URL加载图像
*/
private async loadImage(url: string, options?: IAssetLoadOptions): Promise<HTMLImageElement> {
// For Tauri asset URLs, use fetch to load the image
// 对于Tauri资产URL,使用fetch加载图像
if (url.startsWith('http://asset.localhost/') || url.startsWith('asset://')) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
// Clean up blob URL after loading
// 加载后清理blob URL
URL.revokeObjectURL(blobUrl);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(blobUrl);
reject(new Error(`Failed to load image from blob: ${url}`));
};
image.src = blobUrl;
});
} catch (error) {
throw new Error(`Failed to load Tauri asset: ${url} - ${error}`);
}
}
// For regular URLs, use standard Image loading
// 对于常规URL,使用标准Image加载
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
// 超时处理 / Timeout handling
const timeout = options?.timeout || 30000;
const timeoutId = setTimeout(() => {
reject(new Error(`Image load timeout: ${url}`));
}, timeout);
// 进度回调 / Progress callback
if (options?.onProgress) {
// 图像加载没有真正的进度事件,模拟进度 / Images don't have real progress events, simulate
let progress = 0;
const progressInterval = setInterval(() => {
progress = Math.min(progress + 0.1, 0.9);
options.onProgress!(progress);
}, 100);
image.onload = () => {
clearInterval(progressInterval);
clearTimeout(timeoutId);
options.onProgress!(1);
resolve(image);
};
image.onerror = () => {
clearInterval(progressInterval);
clearTimeout(timeoutId);
reject(new Error(`Failed to load image: ${url}`));
};
} else {
image.onload = () => {
clearTimeout(timeoutId);
resolve(image);
};
image.onerror = () => {
clearTimeout(timeoutId);
reject(new Error(`Failed to load image: ${url}`));
};
}
image.src = url;
});
}
/**
* Upload texture to GPU
* 上传纹理到GPU
*/
private async uploadToGPU(textureAsset: ITextureAsset, path: string): Promise<void> {
const bridge = (window as any).engineBridge;
if (bridge && bridge.loadTexture) {
await bridge.loadTexture(textureAsset.textureId, path);
}
}
/**
* Validate if the loader can handle this asset
* 验证加载器是否可以处理此资产
*/
canLoad(path: string, _metadata: IAssetMetadata): boolean {
const ext = path.toLowerCase().substring(path.lastIndexOf('.'));
return this.supportedExtensions.includes(ext);
}
/**
* Estimate memory usage for the asset
* 估算资产的内存使用量
*/
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: ITextureAsset): void {
// 从缓存中移除 / Remove from cache
for (const [path, cached] of this._loadedTextures.entries()) {
if (cached === asset) {
this._loadedTextures.delete(path);
break;
}
}
// 释放GPU资源 / Release GPU resources
if (typeof window !== 'undefined' && (window as any).engineBridge) {
const bridge = (window as any).engineBridge;
if (bridge.unloadTexture) {
bridge.unloadTexture(asset.textureId);
}
}
// 清理图像数据 / Clean up image data
if (asset.data instanceof HTMLImageElement) {
asset.data.src = '';
}
}
}
@@ -1,404 +0,0 @@
/**
* Core asset system types and enums
* 核心资产系统类型和枚举
*/
/**
* Unique identifier for assets across the project
* 项目中资产的唯一标识符
*/
export type AssetGUID = string;
/**
* Runtime asset handle for efficient access
* 运行时资产句柄,用于高效访问
*/
export type AssetHandle = number;
/**
* Asset loading state
* 资产加载状态
*/
export enum AssetState {
/** 未加载 */
Unloaded = 'unloaded',
/** 加载中 */
Loading = 'loading',
/** 已加载 */
Loaded = 'loaded',
/** 加载失败 */
Failed = 'failed',
/** 释放中 */
Disposing = 'disposing'
}
/**
* Asset types supported by the system
* 系统支持的资产类型
*/
export enum AssetType {
/** 纹理 */
Texture = 'texture',
/** 网格 */
Mesh = 'mesh',
/** 材质 */
Material = 'material',
/** 着色器 */
Shader = 'shader',
/** 音频 */
Audio = 'audio',
/** 字体 */
Font = 'font',
/** 预制体 */
Prefab = 'prefab',
/** 场景 */
Scene = 'scene',
/** 脚本 */
Script = 'script',
/** 动画片段 */
AnimationClip = 'animation',
/** 行为树 */
BehaviorTree = 'behaviortree',
/** JSON数据 */
Json = 'json',
/** 文本 */
Text = 'text',
/** 二进制 */
Binary = 'binary',
/** 自定义 */
Custom = 'custom'
}
/**
* Platform variants for assets
* 资产的平台变体
*/
export enum AssetPlatform {
/** H5平台(浏览器) */
H5 = 'h5',
/** 微信小游戏 */
WeChat = 'wechat',
/** 试玩广告(Playable Ads */
Playable = 'playable',
/** Android平台 */
Android = 'android',
/** iOS平台 */
iOS = 'ios',
/** 编辑器(Tauri桌面) */
Editor = 'editor'
}
/**
* Quality levels for asset variants
* 资产变体的质量级别
*/
export enum AssetQuality {
/** 低质量 */
Low = 'low',
/** 中等质量 */
Medium = 'medium',
/** 高质量 */
High = 'high',
/** 超高质量 */
Ultra = 'ultra'
}
/**
* Asset metadata stored in the database
* 存储在数据库中的资产元数据
*/
export interface IAssetMetadata {
/** 全局唯一标识符 */
guid: AssetGUID;
/** 资产路径 */
path: string;
/** 资产类型 */
type: AssetType;
/** 资产名称 */
name: string;
/** 文件大小(字节) / File size in bytes */
size: number;
/** 内容哈希值 / Content hash for versioning */
hash: string;
/** 依赖的其他资产 / Dependencies on other assets */
dependencies: AssetGUID[];
/** 资产标签 / User-defined labels for categorization */
labels: string[];
/** 自定义标签 / Custom metadata tags */
tags: Map<string, string>;
/** 导入设置 / Import-time settings */
importSettings?: Record<string, unknown>;
/** 最后修改时间 / Unix timestamp of last modification */
lastModified: number;
/** 版本号 / Asset version number */
version: number;
}
/**
* Asset variant descriptor
* 资产变体描述符
*/
export interface IAssetVariant {
/** 目标平台 */
platform: AssetPlatform;
/** 质量级别 */
quality: AssetQuality;
/** 本地化语言 / Language code for localized assets */
locale?: string;
/** 主题变体 / Theme identifier (e.g., 'dark', 'light') */
theme?: string;
}
/**
* Asset load options
* 资产加载选项
*/
export interface IAssetLoadOptions {
/** 加载优先级(0-100,越高越优先) / Priority level 0-100, higher loads first */
priority?: number;
/** 是否异步加载 / Use async loading */
async?: boolean;
/** 指定加载的变体 / Specific variant to load */
variant?: IAssetVariant;
/** 强制重新加载 / Force reload even if cached */
forceReload?: boolean;
/** 超时时间(毫秒) / Timeout in milliseconds */
timeout?: number;
/** 进度回调 / Progress callback (0-1) */
onProgress?: (progress: number) => void;
}
/**
* Asset bundle manifest
* 资产包清单
*/
export interface IAssetBundleManifest {
/** 包名称 */
name: string;
/** 版本号 */
version: string;
/** 内容哈希 / Content hash for integrity check */
hash: string;
/** 压缩类型 */
compression?: 'none' | 'gzip' | 'brotli';
/** 包含的资产列表 / Assets contained in this bundle */
assets: AssetGUID[];
/** 依赖的其他包 / Other bundles this depends on */
dependencies: string[];
/** 包大小(字节) / Bundle size in bytes */
size: number;
/** 创建时间戳 / Creation timestamp */
createdAt: number;
}
/**
* Asset loading result
* 资产加载结果
*/
export interface IAssetLoadResult<T = unknown> {
/** 加载的资产实例 */
asset: T;
/** 资产句柄 */
handle: AssetHandle;
/** 资产元数据 */
metadata: IAssetMetadata;
/** 加载耗时(毫秒) / Load time in milliseconds */
loadTime: number;
}
/**
* Asset loading error
* 资产加载错误
*/
export class AssetLoadError extends Error {
constructor(
message: string,
public readonly guid: AssetGUID,
public readonly type: AssetType,
public readonly cause?: Error
) {
super(message);
this.name = 'AssetLoadError';
Object.setPrototypeOf(this, new.target.prototype);
}
/**
* Factory method for file not found error
* 文件未找到错误的工厂方法
*/
static fileNotFound(guid: AssetGUID, path: string): AssetLoadError {
return new AssetLoadError(`Asset file not found: ${path}`, guid, AssetType.Custom);
}
/**
* Factory method for unsupported type error
* 不支持的类型错误的工厂方法
*/
static unsupportedType(guid: AssetGUID, type: AssetType): AssetLoadError {
return new AssetLoadError(`Unsupported asset type: ${type}`, guid, type);
}
/**
* Factory method for load timeout error
* 加载超时错误的工厂方法
*/
static loadTimeout(guid: AssetGUID, type: AssetType, timeout: number): AssetLoadError {
return new AssetLoadError(`Asset load timeout after ${timeout}ms`, guid, type);
}
/**
* Factory method for dependency failed error
* 依赖加载失败错误的工厂方法
*/
static dependencyFailed(guid: AssetGUID, type: AssetType, depGuid: AssetGUID): AssetLoadError {
return new AssetLoadError(`Dependency failed to load: ${depGuid}`, guid, type);
}
}
/**
* Asset reference counting info
* 资产引用计数信息
*/
export interface IAssetReferenceInfo {
/** 资产GUID */
guid: AssetGUID;
/** 资产句柄 */
handle: AssetHandle;
/** 引用计数 */
referenceCount: number;
/** 最后访问时间 / Unix timestamp of last access */
lastAccessTime: number;
/** 当前状态 */
state: AssetState;
}
/**
* Asset import options
* 资产导入选项
*/
export interface IAssetImportOptions {
/** 资产类型 */
type: AssetType;
/** 生成Mipmap / Generate mipmaps for textures */
generateMipmaps?: boolean;
/** 纹理压缩格式 / Texture compression format */
compression?: 'none' | 'dxt' | 'etc2' | 'astc';
/** 最大纹理尺寸 / Maximum texture dimension */
maxTextureSize?: number;
/** 生成LOD / Generate LODs for meshes */
generateLODs?: boolean;
/** 优化网格 / Optimize mesh geometry */
optimizeMesh?: boolean;
/** 音频格式 / Audio encoding format */
audioFormat?: 'mp3' | 'ogg' | 'wav';
/** 自定义处理器 / Custom processor plugin name */
customProcessor?: string;
}
/**
* Asset usage statistics
* 资产使用统计
*/
export interface IAssetUsageStats {
/** 资产GUID */
guid: AssetGUID;
/** 加载次数 */
loadCount: number;
/** 总加载时间(毫秒) / Total time spent loading in ms */
totalLoadTime: number;
/** 平均加载时间(毫秒) / Average load time in ms */
averageLoadTime: number;
/** 最后使用时间 / Unix timestamp of last use */
lastUsedTime: number;
/** 被引用的资产列表 / Assets that reference this one */
referencedBy: AssetGUID[];
}
/**
* Asset preload group
* 资产预加载组
*/
export interface IAssetPreloadGroup {
/** 组名称 */
name: string;
/** 包含的资产 */
assets: AssetGUID[];
/** 加载优先级 / Load priority 0-100 */
priority: number;
/** 是否必需 / Must be loaded before scene start */
required: boolean;
}
/**
* Asset loading progress info
* 资产加载进度信息
*/
export interface IAssetLoadProgress {
/** 当前加载的资产 */
currentAsset: string;
/** 已加载数量 */
loadedCount: number;
/** 总数量 */
totalCount: number;
/** 已加载字节数 */
loadedBytes: number;
/** 总字节数 */
totalBytes: number;
/** 进度百分比(0-1 / Progress value 0-1 */
progress: number;
}
/**
* Asset catalog entry for runtime lookups
* 运行时查找的资产目录条目
*/
export interface IAssetCatalogEntry {
/** 资产GUID */
guid: AssetGUID;
/** 资产路径 */
path: string;
/** 资产类型 */
type: AssetType;
/** 所在包名称 / Bundle containing this asset */
bundleName?: string;
/** 可用变体 / Available variants */
variants?: IAssetVariant[];
/** 大小(字节) / Size in bytes */
size: number;
/** 内容哈希 / Content hash */
hash: string;
}
/**
* Runtime asset catalog
* 运行时资产目录
*/
export interface IAssetCatalog {
/** 版本号 */
version: string;
/** 创建时间戳 / Creation timestamp */
createdAt: number;
/** 所有目录条目 / All catalog entries */
entries: Map<AssetGUID, IAssetCatalogEntry>;
/** 此目录中的包 / Bundles in this catalog */
bundles: Map<string, IAssetBundleManifest>;
}
/**
* Asset hot-reload event
* 资产热重载事件
*/
export interface IAssetHotReloadEvent {
/** 资产GUID */
guid: AssetGUID;
/** 资产路径 */
path: string;
/** 资产类型 */
type: AssetType;
/** 旧版本哈希 / Previous version hash */
oldHash: string;
/** 新版本哈希 / New version hash */
newHash: string;
/** 时间戳 */
timestamp: number;
}
@@ -1,165 +0,0 @@
/**
* Path Validator
* 路径验证器
*
* Validates and sanitizes asset paths for security
* 验证并清理资产路径以确保安全
*/
export class PathValidator {
// Dangerous path patterns
private static readonly DANGEROUS_PATTERNS = [
/\.\.[/\\]/g, // Path traversal attempts (..)
/^[/\\]/, // Absolute paths on Unix
/^[a-zA-Z]:[/\\]/, // Absolute paths on Windows
/[<>:"|?*]/, // Invalid characters for Windows paths
/\0/, // Null bytes
/%00/, // URL encoded null bytes
/\.\.%2[fF]/ // URL encoded path traversal
];
// Valid path characters (alphanumeric, dash, underscore, dot, slash)
private static readonly VALID_PATH_REGEX = /^[a-zA-Z0-9\-_./\\@]+$/;
// Maximum path length
private static readonly MAX_PATH_LENGTH = 260;
/**
* Validate if a path is safe
* 验证路径是否安全
*/
static validate(path: string): { valid: boolean; reason?: string } {
// Check for null/undefined/empty
if (!path || typeof path !== 'string') {
return { valid: false, reason: 'Path is empty or invalid' };
}
// Check length
if (path.length > this.MAX_PATH_LENGTH) {
return { valid: false, reason: `Path exceeds maximum length of ${this.MAX_PATH_LENGTH} characters` };
}
// Check for dangerous patterns
for (const pattern of this.DANGEROUS_PATTERNS) {
if (pattern.test(path)) {
return { valid: false, reason: 'Path contains dangerous pattern' };
}
}
// Check for valid characters
if (!this.VALID_PATH_REGEX.test(path)) {
return { valid: false, reason: 'Path contains invalid characters' };
}
return { valid: true };
}
/**
* Sanitize a path
* 清理路径
*/
static sanitize(path: string): string {
if (!path || typeof path !== 'string') {
return '';
}
// Remove dangerous patterns
let sanitized = path;
// Remove path traversal (apply repeatedly until fully removed)
let prev;
do {
prev = sanitized;
sanitized = sanitized.replace(/\.\.[/\\]/g, '');
} while (sanitized !== prev);
// Remove leading slashes
sanitized = sanitized.replace(/^[/\\]+/, '');
// Remove null bytes
sanitized = sanitized.replace(/\0/g, '');
sanitized = sanitized.replace(/%00/g, '');
// Remove invalid Windows characters
sanitized = sanitized.replace(/[<>:"|?*]/g, '_');
// Normalize slashes
sanitized = sanitized.replace(/\\/g, '/');
// Remove double slashes
sanitized = sanitized.replace(/\/+/g, '/');
// Trim whitespace
sanitized = sanitized.trim();
// Truncate if too long
if (sanitized.length > this.MAX_PATH_LENGTH) {
sanitized = sanitized.substring(0, this.MAX_PATH_LENGTH);
}
return sanitized;
}
/**
* Check if path is trying to escape the base directory
* 检查路径是否试图逃离基础目录
*/
static isPathTraversal(path: string): boolean {
const normalized = path.replace(/\\/g, '/');
return normalized.includes('../') || normalized.includes('..\\');
}
/**
* Normalize a path for consistent handling
* 规范化路径以便一致处理
*/
static normalize(path: string): string {
if (!path) return '';
// Sanitize first
let normalized = this.sanitize(path);
// Convert backslashes to forward slashes
normalized = normalized.replace(/\\/g, '/');
// Remove trailing slash (except for root)
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
/**
* Join path segments safely
* 安全地连接路径段
*/
static join(...segments: string[]): string {
const validSegments = segments
.filter((s) => s && typeof s === 'string')
.map((s) => this.sanitize(s))
.filter((s) => s.length > 0);
if (validSegments.length === 0) {
return '';
}
return this.normalize(validSegments.join('/'));
}
/**
* Get file extension safely
* 安全地获取文件扩展名
*/
static getExtension(path: string): string {
const sanitized = this.sanitize(path);
const lastDot = sanitized.lastIndexOf('.');
const lastSlash = sanitized.lastIndexOf('/');
if (lastDot > lastSlash && lastDot > 0) {
return sanitized.substring(lastDot + 1).toLowerCase();
}
return '';
}
}
-36
View File
@@ -1,36 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
@@ -1,59 +0,0 @@
{
"name": "@esengine/behavior-tree-editor",
"version": "1.0.0",
"description": "Behavior Tree Editor Plugin for ECS Framework",
"type": "module",
"main": "dist/index.esm.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"scripts": {
"clean": "rimraf bin dist tsconfig.tsbuildinfo",
"prebuild": "npm run clean",
"build": "npm run build:tsc && npm run copy:css && npm run build:rollup",
"build:tsc": "tsc",
"copy:css": "node scripts/copy-css.js",
"build:rollup": "rollup -c",
"dev": "rollup -c -w"
},
"keywords": [
"ecs",
"behavior-tree",
"editor",
"plugin"
],
"author": "",
"license": "MIT",
"devDependencies": {
"@esengine/behavior-tree": "workspace:*",
"@esengine/ecs-framework": "workspace:*",
"@esengine/editor-core": "workspace:*",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-node-resolve": "^15.3.0",
"@types/react": "^18.3.18",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"rimraf": "^6.0.1",
"rollup": "^4.28.1",
"rollup-plugin-copy": "^3.5.0",
"rollup-plugin-dts": "^6.1.1",
"rollup-plugin-postcss": "^4.0.2",
"typescript": "^5.8.2",
"zustand": "^5.0.2"
},
"peerDependencies": {
"@esengine/behavior-tree": "*",
"@esengine/ecs-framework": "*",
"@esengine/editor-core": "*",
"@tauri-apps/api": "*",
"@tauri-apps/plugin-dialog": "*",
"@tauri-apps/plugin-http": "*",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"tsyringe": "*",
"zustand": "^5.0.2"
},
"dependencies": {
"mobx": "^6.15.0",
"mobx-react-lite": "^4.1.1"
}
}
@@ -1,66 +0,0 @@
const resolve = require('@rollup/plugin-node-resolve');
const commonjs = require('@rollup/plugin-commonjs');
const dts = require('rollup-plugin-dts').default;
const postcss = require('rollup-plugin-postcss');
const external = [
'react',
'react/jsx-runtime',
'zustand',
'zustand/middleware',
'lucide-react',
'@esengine/ecs-framework',
'@esengine/editor-core',
'@esengine/behavior-tree',
'tsyringe',
'@tauri-apps/api/core',
'@tauri-apps/plugin-dialog'
];
module.exports = [
{
input: 'bin/index.js',
output: {
file: 'dist/index.esm.js',
format: 'es',
sourcemap: true,
exports: 'named',
inlineDynamicImports: true
},
plugins: [
resolve({
extensions: ['.js', '.jsx']
}),
postcss({
inject: true,
minimize: false
}),
commonjs()
],
external,
onwarn(warning, warn) {
if (warning.code === 'CIRCULAR_DEPENDENCY' || warning.code === 'THIS_IS_UNDEFINED') {
return;
}
warn(warning);
}
},
// 类型定义构建
{
input: 'bin/index.d.ts',
output: {
file: 'dist/index.d.ts',
format: 'es'
},
plugins: [
dts({
respectExternal: true
})
],
external: [
...external,
/\.css$/
]
}
];
@@ -1,25 +0,0 @@
import { readdirSync, statSync, copyFileSync, mkdirSync } from 'fs';
import { join, dirname, relative } from 'path';
function copyCSS(srcDir, destDir) {
const files = readdirSync(srcDir);
for (const file of files) {
const srcPath = join(srcDir, file);
const stat = statSync(srcPath);
if (stat.isDirectory()) {
copyCSS(srcPath, destDir);
} else if (file.endsWith('.css')) {
const relativePath = relative('src', srcPath);
const destPath = join(destDir, relativePath);
mkdirSync(dirname(destPath), { recursive: true });
copyFileSync(srcPath, destPath);
console.log(`Copied: ${relativePath}`);
}
}
}
copyCSS('src', 'bin');
console.log('CSS files copied successfully!');
@@ -1,105 +0,0 @@
import { singleton } from 'tsyringe';
import { Core, createLogger } from '@esengine/ecs-framework';
import { CompilerRegistry, IEditorModule, IModuleContext, PanelPosition } from '@esengine/editor-core';
import { BehaviorTreeService } from './services/BehaviorTreeService';
import { BehaviorTreeCompiler } from './compiler/BehaviorTreeCompiler';
import { BehaviorTreeNodeInspectorProvider } from './providers/BehaviorTreeNodeInspectorProvider';
import { BehaviorTreeEditorPanel } from './components/panels/BehaviorTreeEditorPanel';
const logger = createLogger('BehaviorTreeModule');
@singleton()
export class BehaviorTreeModule implements IEditorModule {
readonly id = 'behavior-tree';
readonly name = 'Behavior Tree Editor';
readonly version = '1.0.0';
async load(context: IModuleContext): Promise<void> {
logger.info('[BehaviorTreeModule] Loading behavior tree editor module...');
this.registerServices(context);
this.registerCompilers();
this.registerInspectors(context);
this.registerCommands(context);
this.registerPanels(context);
this.subscribeEvents(context);
logger.info('[BehaviorTreeModule] Behavior tree editor module loaded');
}
private registerServices(context: IModuleContext): void {
context.container.register(BehaviorTreeService, { useClass: BehaviorTreeService });
logger.info('[BehaviorTreeModule] Services registered');
}
private registerCompilers(): void {
const compilerRegistry = Core.services.resolve(CompilerRegistry);
if (compilerRegistry) {
const compiler = new BehaviorTreeCompiler();
compilerRegistry.register(compiler);
logger.info('[BehaviorTreeModule] Compiler registered');
}
}
private registerInspectors(context: IModuleContext): void {
const provider = new BehaviorTreeNodeInspectorProvider();
context.inspectorRegistry.register(provider);
logger.info('[BehaviorTreeModule] Inspector provider registered');
}
async unload(): Promise<void> {
logger.info('[BehaviorTreeModule] Unloading behavior tree editor module...');
}
private registerCommands(context: IModuleContext): void {
context.commands.register({
id: 'behavior-tree.new',
label: 'New Behavior Tree',
icon: 'file-plus',
execute: async () => {
const service = context.container.resolve(BehaviorTreeService);
await service.createNew();
}
});
context.commands.register({
id: 'behavior-tree.open',
label: 'Open Behavior Tree',
icon: 'folder-open',
execute: async () => {
logger.info('Open behavior tree');
}
});
context.commands.register({
id: 'behavior-tree.save',
label: 'Save Behavior Tree',
icon: 'save',
keybinding: { key: 'S', ctrl: true },
execute: async () => {
logger.info('Save behavior tree');
}
});
}
private registerPanels(context: IModuleContext): void {
logger.info('[BehaviorTreeModule] Registering panels...');
context.panels.register({
id: 'behavior-tree-editor',
title: '行为树编辑器',
icon: 'GitBranch',
component: BehaviorTreeEditorPanel,
position: PanelPosition.Center,
defaultSize: 400,
closable: true,
isDynamic: true
});
logger.info('[BehaviorTreeModule] Panel registered: behavior-tree-editor');
}
private subscribeEvents(_context: IModuleContext): void {
// 文件加载由 BehaviorTreeEditorPanel 处理
}
}
@@ -1,158 +0,0 @@
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
import {
IEditorPlugin,
EditorPluginCategory,
CompilerRegistry,
InspectorRegistry,
PanelPosition,
type FileCreationTemplate,
type FileActionHandler,
type PanelDescriptor
} from '@esengine/editor-core';
import { BehaviorTreeService } from './services/BehaviorTreeService';
import { FileSystemService } from './services/FileSystemService';
import { BehaviorTreeCompiler } from './compiler/BehaviorTreeCompiler';
import { BehaviorTreeNodeInspectorProvider } from './providers/BehaviorTreeNodeInspectorProvider';
import { BehaviorTreeEditorPanel } from './components/panels/BehaviorTreeEditorPanel';
import { useBehaviorTreeDataStore } from './stores';
import { createElement } from 'react';
import { GitBranch } from 'lucide-react';
import { createRootNode } from './domain/constants/RootNode';
import type { IService, ServiceType } from '@esengine/ecs-framework';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('BehaviorTreePlugin');
export class BehaviorTreePlugin implements IEditorPlugin {
readonly name = '@esengine/behavior-tree-editor';
readonly version = '1.0.0';
readonly displayName = 'Behavior Tree Editor';
readonly category = EditorPluginCategory.Tool;
readonly description = 'Visual behavior tree editor for game AI development';
readonly icon = 'GitBranch';
private services?: ServiceContainer;
private registeredServices: Set<ServiceType<IService>> = new Set();
private fileActionHandler?: FileActionHandler;
private fileCreationTemplate?: FileCreationTemplate;
async install(core: Core, services: ServiceContainer): Promise<void> {
this.services = services;
this.registerServices(services);
this.registerCompilers(services);
this.registerInspectors(services);
this.registerFileActions(services);
}
async uninstall(): Promise<void> {
if (this.services) {
for (const serviceType of this.registeredServices) {
this.services.unregister(serviceType);
}
}
this.registeredServices.clear();
useBehaviorTreeDataStore.getState().reset();
this.services = undefined;
}
registerPanels(): PanelDescriptor[] {
return [
{
id: 'behavior-tree-editor',
title: 'Behavior Tree Editor',
position: PanelPosition.Center,
closable: true,
component: BehaviorTreeEditorPanel,
order: 100,
isDynamic: true // 标记为动态面板
}
];
}
private registerServices(services: ServiceContainer): void {
// 先注册 FileSystemServiceBehaviorTreeService 依赖它)
if (services.isRegistered(FileSystemService)) {
services.unregister(FileSystemService);
}
services.registerSingleton(FileSystemService);
this.registeredServices.add(FileSystemService);
// 再注册 BehaviorTreeService
if (services.isRegistered(BehaviorTreeService)) {
services.unregister(BehaviorTreeService);
}
services.registerSingleton(BehaviorTreeService);
this.registeredServices.add(BehaviorTreeService);
}
private registerCompilers(services: ServiceContainer): void {
try {
const compilerRegistry = services.resolve(CompilerRegistry);
const compiler = new BehaviorTreeCompiler();
compilerRegistry.register(compiler);
logger.info('Successfully registered BehaviorTreeCompiler');
} catch (error) {
logger.error('Failed to register compiler:', error);
}
}
private registerInspectors(services: ServiceContainer): void {
const inspectorRegistry = services.resolve(InspectorRegistry);
if (inspectorRegistry) {
const provider = new BehaviorTreeNodeInspectorProvider();
inspectorRegistry.register(provider);
}
}
private registerFileActions(services: ServiceContainer): void {
this.fileCreationTemplate = {
label: 'Behavior Tree',
extension: 'btree',
defaultFileName: 'NewBehaviorTree',
icon: createElement(GitBranch, { size: 16 }),
createContent: (fileName: string) => {
// 创建根节点
const rootNode = createRootNode();
const rootNodeData = {
id: rootNode.id,
type: rootNode.template.type,
displayName: rootNode.template.displayName,
data: rootNode.data,
position: {
x: rootNode.position.x,
y: rootNode.position.y
},
children: []
};
const emptyTree = {
name: fileName.replace('.btree', ''),
nodes: [rootNodeData],
connections: [],
variables: {}
};
return JSON.stringify(emptyTree, null, 2);
}
};
this.fileActionHandler = {
extensions: ['btree'],
onDoubleClick: async (filePath: string) => {
const service = services.resolve(BehaviorTreeService);
if (service) {
await service.loadFromFile(filePath);
}
}
};
}
registerFileActionHandlers(): FileActionHandler[] {
return this.fileActionHandler ? [this.fileActionHandler] : [];
}
registerFileCreationTemplates(): FileCreationTemplate[] {
return this.fileCreationTemplate ? [this.fileCreationTemplate] : [];
}
}
@@ -1,203 +0,0 @@
import { ICommand } from './ICommand';
/**
* 命令历史记录配置
*/
export interface CommandManagerConfig {
/**
* 最大历史记录数量
*/
maxHistorySize?: number;
/**
* 是否自动合并相似命令
*/
autoMerge?: boolean;
}
/**
* 命令管理器
* 管理命令的执行、撤销、重做以及历史记录
*/
export class CommandManager {
private undoStack: ICommand[] = [];
private redoStack: ICommand[] = [];
private readonly config: Required<CommandManagerConfig>;
private isExecuting = false;
constructor(config: CommandManagerConfig = {}) {
this.config = {
maxHistorySize: config.maxHistorySize ?? 100,
autoMerge: config.autoMerge ?? true
};
}
/**
* 执行命令
*/
execute(command: ICommand): void {
if (this.isExecuting) {
throw new Error('不能在命令执行过程中执行新命令');
}
this.isExecuting = true;
try {
command.execute();
if (this.config.autoMerge && this.undoStack.length > 0) {
const lastCommand = this.undoStack[this.undoStack.length - 1];
if (lastCommand && lastCommand.canMergeWith(command)) {
const mergedCommand = lastCommand.mergeWith(command);
this.undoStack[this.undoStack.length - 1] = mergedCommand;
this.redoStack = [];
return;
}
}
this.undoStack.push(command);
this.redoStack = [];
if (this.undoStack.length > this.config.maxHistorySize) {
this.undoStack.shift();
}
} finally {
this.isExecuting = false;
}
}
/**
* 撤销上一个命令
*/
undo(): void {
if (this.isExecuting) {
throw new Error('不能在命令执行过程中撤销');
}
const command = this.undoStack.pop();
if (!command) {
return;
}
this.isExecuting = true;
try {
command.undo();
this.redoStack.push(command);
} catch (error) {
this.undoStack.push(command);
throw error;
} finally {
this.isExecuting = false;
}
}
/**
* 重做上一个被撤销的命令
*/
redo(): void {
if (this.isExecuting) {
throw new Error('不能在命令执行过程中重做');
}
const command = this.redoStack.pop();
if (!command) {
return;
}
this.isExecuting = true;
try {
command.execute();
this.undoStack.push(command);
} catch (error) {
this.redoStack.push(command);
throw error;
} finally {
this.isExecuting = false;
}
}
/**
* 检查是否可以撤销
*/
canUndo(): boolean {
return this.undoStack.length > 0;
}
/**
* 检查是否可以重做
*/
canRedo(): boolean {
return this.redoStack.length > 0;
}
/**
* 获取撤销栈的描述列表
*/
getUndoHistory(): string[] {
return this.undoStack.map((cmd) => cmd.getDescription());
}
/**
* 获取重做栈的描述列表
*/
getRedoHistory(): string[] {
return this.redoStack.map((cmd) => cmd.getDescription());
}
/**
* 清空所有历史记录
*/
clear(): void {
this.undoStack = [];
this.redoStack = [];
}
/**
* 批量执行命令(作为单一操作,可以一次撤销)
*/
executeBatch(commands: ICommand[]): void {
if (commands.length === 0) {
return;
}
const batchCommand = new BatchCommand(commands);
this.execute(batchCommand);
}
}
/**
* 批量命令
* 将多个命令组合为一个命令
*/
class BatchCommand implements ICommand {
constructor(private readonly commands: ICommand[]) {}
execute(): void {
for (const command of this.commands) {
command.execute();
}
}
undo(): void {
for (let i = this.commands.length - 1; i >= 0; i--) {
const command = this.commands[i];
if (command) {
command.undo();
}
}
}
getDescription(): string {
return `批量操作 (${this.commands.length} 个命令)`;
}
canMergeWith(): boolean {
return false;
}
mergeWith(): ICommand {
throw new Error('批量命令不支持合并');
}
}
@@ -1,31 +0,0 @@
/**
* 命令接口
* 实现命令模式,支持撤销/重做功能
*/
export interface ICommand {
/**
* 执行命令
*/
execute(): void;
/**
* 撤销命令
*/
undo(): void;
/**
* 获取命令描述(用于显示历史记录)
*/
getDescription(): string;
/**
* 检查命令是否可以合并
* 用于优化撤销/重做历史,例如连续的移动操作可以合并为一个
*/
canMergeWith(other: ICommand): boolean;
/**
* 与另一个命令合并
*/
mergeWith(other: ICommand): ICommand;
}
@@ -1,17 +0,0 @@
import { BehaviorTree } from '../../domain/models/BehaviorTree';
/**
* 行为树状态接口
* 命令通过此接口操作状态
*/
export interface ITreeState {
/**
* 获取当前行为树
*/
getTree(): BehaviorTree;
/**
* 设置行为树
*/
setTree(tree: BehaviorTree): void;
}
@@ -1,36 +0,0 @@
import { Connection } from '../../../domain/models/Connection';
import { BaseCommand } from '@esengine/editor-core';
import { ITreeState } from '../ITreeState';
/**
* 添加连接命令
*/
export class AddConnectionCommand extends BaseCommand {
constructor(
private readonly state: ITreeState,
private readonly connection: Connection
) {
super();
}
execute(): void {
const tree = this.state.getTree();
const newTree = tree.addConnection(this.connection);
this.state.setTree(newTree);
}
undo(): void {
const tree = this.state.getTree();
const newTree = tree.removeConnection(
this.connection.from,
this.connection.to,
this.connection.fromProperty,
this.connection.toProperty
);
this.state.setTree(newTree);
}
getDescription(): string {
return `添加连接: ${this.connection.from} -> ${this.connection.to}`;
}
}
@@ -1,34 +0,0 @@
import { Node } from '../../../domain/models/Node';
import { BaseCommand } from '@esengine/editor-core';
import { ITreeState } from '../ITreeState';
/**
* 创建节点命令
*/
export class CreateNodeCommand extends BaseCommand {
private createdNodeId: string;
constructor(
private readonly state: ITreeState,
private readonly node: Node
) {
super();
this.createdNodeId = node.id;
}
execute(): void {
const tree = this.state.getTree();
const newTree = tree.addNode(this.node);
this.state.setTree(newTree);
}
undo(): void {
const tree = this.state.getTree();
const newTree = tree.removeNode(this.createdNodeId);
this.state.setTree(newTree);
}
getDescription(): string {
return `创建节点: ${this.node.template.displayName}`;
}
}
@@ -1,38 +0,0 @@
import { Node } from '../../../domain/models/Node';
import { BaseCommand } from '@esengine/editor-core';
import { ITreeState } from '../ITreeState';
/**
* 删除节点命令
*/
export class DeleteNodeCommand extends BaseCommand {
private deletedNode: Node | null = null;
constructor(
private readonly state: ITreeState,
private readonly nodeId: string
) {
super();
}
execute(): void {
const tree = this.state.getTree();
this.deletedNode = tree.getNode(this.nodeId);
const newTree = tree.removeNode(this.nodeId);
this.state.setTree(newTree);
}
undo(): void {
if (!this.deletedNode) {
throw new Error('无法撤销:未保存已删除的节点');
}
const tree = this.state.getTree();
const newTree = tree.addNode(this.deletedNode);
this.state.setTree(newTree);
}
getDescription(): string {
return `删除节点: ${this.deletedNode?.template.displayName ?? this.nodeId}`;
}
}
@@ -1,74 +0,0 @@
import { Position } from '../../../domain/value-objects/Position';
import { BaseCommand, ICommand } from '@esengine/editor-core';
import { ITreeState } from '../ITreeState';
/**
* 移动节点命令
* 支持合并连续的移动操作
*/
export class MoveNodeCommand extends BaseCommand {
private oldPosition: Position;
constructor(
private readonly state: ITreeState,
private readonly nodeId: string,
private readonly newPosition: Position
) {
super();
const tree = this.state.getTree();
const node = tree.getNode(nodeId);
this.oldPosition = node.position;
}
execute(): void {
const tree = this.state.getTree();
const newTree = tree.updateNode(this.nodeId, (node) =>
node.moveToPosition(this.newPosition)
);
this.state.setTree(newTree);
}
undo(): void {
const tree = this.state.getTree();
const newTree = tree.updateNode(this.nodeId, (node) =>
node.moveToPosition(this.oldPosition)
);
this.state.setTree(newTree);
}
getDescription(): string {
return `移动节点: ${this.nodeId}`;
}
/**
* 移动命令可以合并
*/
canMergeWith(other: ICommand): boolean {
if (!(other instanceof MoveNodeCommand)) {
return false;
}
return this.nodeId === other.nodeId;
}
/**
* 合并移动命令
* 保留初始位置,更新最终位置
*/
mergeWith(other: ICommand): ICommand {
if (!(other instanceof MoveNodeCommand)) {
throw new Error('只能与 MoveNodeCommand 合并');
}
if (this.nodeId !== other.nodeId) {
throw new Error('只能合并同一节点的移动命令');
}
const merged = new MoveNodeCommand(
this.state,
this.nodeId,
other.newPosition
);
merged.oldPosition = this.oldPosition;
return merged;
}
}
@@ -1,50 +0,0 @@
import { Connection } from '../../../domain/models/Connection';
import { BaseCommand } from '@esengine/editor-core';
import { ITreeState } from '../ITreeState';
/**
* 移除连接命令
*/
export class RemoveConnectionCommand extends BaseCommand {
private removedConnection: Connection | null = null;
constructor(
private readonly state: ITreeState,
private readonly from: string,
private readonly to: string,
private readonly fromProperty?: string,
private readonly toProperty?: string
) {
super();
}
execute(): void {
const tree = this.state.getTree();
const connection = tree.connections.find((c) =>
c.matches(this.from, this.to, this.fromProperty, this.toProperty)
);
if (!connection) {
throw new Error(`连接不存在: ${this.from} -> ${this.to}`);
}
this.removedConnection = connection;
const newTree = tree.removeConnection(this.from, this.to, this.fromProperty, this.toProperty);
this.state.setTree(newTree);
}
undo(): void {
if (!this.removedConnection) {
throw new Error('无法撤销:未保存已删除的连接');
}
const tree = this.state.getTree();
const newTree = tree.addConnection(this.removedConnection);
this.state.setTree(newTree);
}
getDescription(): string {
return `移除连接: ${this.from} -> ${this.to}`;
}
}
@@ -1,40 +0,0 @@
import { BaseCommand } from '@esengine/editor-core';
import { ITreeState } from '../ITreeState';
/**
* 更新节点数据命令
*/
export class UpdateNodeDataCommand extends BaseCommand {
private oldData: Record<string, unknown>;
constructor(
private readonly state: ITreeState,
private readonly nodeId: string,
private readonly newData: Record<string, unknown>
) {
super();
const tree = this.state.getTree();
const node = tree.getNode(nodeId);
this.oldData = node.data;
}
execute(): void {
const tree = this.state.getTree();
const newTree = tree.updateNode(this.nodeId, (node) =>
node.updateData(this.newData)
);
this.state.setTree(newTree);
}
undo(): void {
const tree = this.state.getTree();
const newTree = tree.updateNode(this.nodeId, (node) =>
node.updateData(this.oldData)
);
this.state.setTree(newTree);
}
getDescription(): string {
return `更新节点数据: ${this.nodeId}`;
}
}
@@ -1,6 +0,0 @@
export { CreateNodeCommand } from './CreateNodeCommand';
export { DeleteNodeCommand } from './DeleteNodeCommand';
export { AddConnectionCommand } from './AddConnectionCommand';
export { RemoveConnectionCommand } from './RemoveConnectionCommand';
export { MoveNodeCommand } from './MoveNodeCommand';
export { UpdateNodeDataCommand } from './UpdateNodeDataCommand';
@@ -1,253 +0,0 @@
import { Node as BehaviorTreeNode } from '../../domain/models/Node';
import { Connection } from '../../domain/models/Connection';
import { ExecutionLog } from '../../utils/BehaviorTreeExecutor';
import { BlackboardValue } from '../../domain/models/Blackboard';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('ExecutionHooks');
type BlackboardVariables = Record<string, BlackboardValue>;
type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
export interface ExecutionContext {
nodes: BehaviorTreeNode[];
connections: Connection[];
blackboardVariables: BlackboardVariables;
rootNodeId: string;
tickCount: number;
}
export interface NodeStatusChangeEvent {
nodeId: string;
status: NodeExecutionStatus;
previousStatus?: NodeExecutionStatus;
timestamp: number;
}
export interface IExecutionHooks {
beforePlay?(context: ExecutionContext): void | Promise<void>;
afterPlay?(context: ExecutionContext): void | Promise<void>;
beforePause?(): void | Promise<void>;
afterPause?(): void | Promise<void>;
beforeResume?(): void | Promise<void>;
afterResume?(): void | Promise<void>;
beforeStop?(): void | Promise<void>;
afterStop?(): void | Promise<void>;
beforeStep?(deltaTime: number): void | Promise<void>;
afterStep?(deltaTime: number): void | Promise<void>;
onTick?(tickCount: number, deltaTime: number): void | Promise<void>;
onNodeStatusChange?(event: NodeStatusChangeEvent): void | Promise<void>;
onExecutionComplete?(logs: ExecutionLog[]): void | Promise<void>;
onBlackboardUpdate?(variables: BlackboardVariables): void | Promise<void>;
onError?(error: Error, context?: string): void | Promise<void>;
}
export class ExecutionHooksManager {
private hooks: Set<IExecutionHooks> = new Set();
register(hook: IExecutionHooks): void {
this.hooks.add(hook);
}
unregister(hook: IExecutionHooks): void {
this.hooks.delete(hook);
}
clear(): void {
this.hooks.clear();
}
async triggerBeforePlay(context: ExecutionContext): Promise<void> {
for (const hook of this.hooks) {
if (hook.beforePlay) {
try {
await hook.beforePlay(context);
} catch (error) {
logger.error('Error in beforePlay hook:', error);
}
}
}
}
async triggerAfterPlay(context: ExecutionContext): Promise<void> {
for (const hook of this.hooks) {
if (hook.afterPlay) {
try {
await hook.afterPlay(context);
} catch (error) {
logger.error('Error in afterPlay hook:', error);
}
}
}
}
async triggerBeforePause(): Promise<void> {
for (const hook of this.hooks) {
if (hook.beforePause) {
try {
await hook.beforePause();
} catch (error) {
logger.error('Error in beforePause hook:', error);
}
}
}
}
async triggerAfterPause(): Promise<void> {
for (const hook of this.hooks) {
if (hook.afterPause) {
try {
await hook.afterPause();
} catch (error) {
logger.error('Error in afterPause hook:', error);
}
}
}
}
async triggerBeforeResume(): Promise<void> {
for (const hook of this.hooks) {
if (hook.beforeResume) {
try {
await hook.beforeResume();
} catch (error) {
logger.error('Error in beforeResume hook:', error);
}
}
}
}
async triggerAfterResume(): Promise<void> {
for (const hook of this.hooks) {
if (hook.afterResume) {
try {
await hook.afterResume();
} catch (error) {
logger.error('Error in afterResume hook:', error);
}
}
}
}
async triggerBeforeStop(): Promise<void> {
for (const hook of this.hooks) {
if (hook.beforeStop) {
try {
await hook.beforeStop();
} catch (error) {
logger.error('Error in beforeStop hook:', error);
}
}
}
}
async triggerAfterStop(): Promise<void> {
for (const hook of this.hooks) {
if (hook.afterStop) {
try {
await hook.afterStop();
} catch (error) {
logger.error('Error in afterStop hook:', error);
}
}
}
}
async triggerBeforeStep(deltaTime: number): Promise<void> {
for (const hook of this.hooks) {
if (hook.beforeStep) {
try {
await hook.beforeStep(deltaTime);
} catch (error) {
logger.error('Error in beforeStep hook:', error);
}
}
}
}
async triggerAfterStep(deltaTime: number): Promise<void> {
for (const hook of this.hooks) {
if (hook.afterStep) {
try {
await hook.afterStep(deltaTime);
} catch (error) {
logger.error('Error in afterStep hook:', error);
}
}
}
}
async triggerOnTick(tickCount: number, deltaTime: number): Promise<void> {
for (const hook of this.hooks) {
if (hook.onTick) {
try {
await hook.onTick(tickCount, deltaTime);
} catch (error) {
logger.error('Error in onTick hook:', error);
}
}
}
}
async triggerOnNodeStatusChange(event: NodeStatusChangeEvent): Promise<void> {
for (const hook of this.hooks) {
if (hook.onNodeStatusChange) {
try {
await hook.onNodeStatusChange(event);
} catch (error) {
logger.error('Error in onNodeStatusChange hook:', error);
}
}
}
}
async triggerOnExecutionComplete(logs: ExecutionLog[]): Promise<void> {
for (const hook of this.hooks) {
if (hook.onExecutionComplete) {
try {
await hook.onExecutionComplete(logs);
} catch (error) {
logger.error('Error in onExecutionComplete hook:', error);
}
}
}
}
async triggerOnBlackboardUpdate(variables: BlackboardVariables): Promise<void> {
for (const hook of this.hooks) {
if (hook.onBlackboardUpdate) {
try {
await hook.onBlackboardUpdate(variables);
} catch (error) {
logger.error('Error in onBlackboardUpdate hook:', error);
}
}
}
}
async triggerOnError(error: Error, context?: string): Promise<void> {
for (const hook of this.hooks) {
if (hook.onError) {
try {
await hook.onError(error, context);
} catch (err) {
logger.error('Error in onError hook:', err);
}
}
}
}
}
@@ -1,42 +0,0 @@
import { BlackboardValue } from '../../domain/models/Blackboard';
type BlackboardVariables = Record<string, BlackboardValue>;
export class BlackboardManager {
private initialVariables: BlackboardVariables = {};
private currentVariables: BlackboardVariables = {};
setInitialVariables(variables: BlackboardVariables): void {
this.initialVariables = JSON.parse(JSON.stringify(variables)) as BlackboardVariables;
}
getInitialVariables(): BlackboardVariables {
return { ...this.initialVariables };
}
setCurrentVariables(variables: BlackboardVariables): void {
this.currentVariables = { ...variables };
}
getCurrentVariables(): BlackboardVariables {
return { ...this.currentVariables };
}
updateVariable(key: string, value: BlackboardValue): void {
this.currentVariables[key] = value;
}
restoreInitialVariables(): BlackboardVariables {
this.currentVariables = { ...this.initialVariables };
return this.getInitialVariables();
}
hasChanges(): boolean {
return JSON.stringify(this.currentVariables) !== JSON.stringify(this.initialVariables);
}
clear(): void {
this.initialVariables = {};
this.currentVariables = {};
}
}
@@ -1,552 +0,0 @@
import { BehaviorTreeExecutor, ExecutionStatus, ExecutionLog } from '../../utils/BehaviorTreeExecutor';
import { BehaviorTreeNode, Connection } from '../../stores';
import type { NodeExecutionStatus } from '../../stores';
import { BlackboardValue } from '../../domain/models/Blackboard';
import { DOMCache } from '../../utils/DOMCache';
import { EditorEventBus, EditorEvent } from '../../infrastructure/events/EditorEventBus';
import { ExecutionHooksManager } from '../interfaces/IExecutionHooks';
import type { Breakpoint } from '../../types/Breakpoint';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('ExecutionController');
export type ExecutionMode = 'idle' | 'running' | 'paused';
type BlackboardVariables = Record<string, BlackboardValue>;
interface ExecutionControllerConfig {
rootNodeId: string;
projectPath: string | null;
onLogsUpdate: (logs: ExecutionLog[]) => void;
onBlackboardUpdate: (variables: BlackboardVariables) => void;
onTickCountUpdate: (count: number) => void;
onExecutionStatusUpdate: (statuses: Map<string, NodeExecutionStatus>, orders: Map<string, number>) => void;
onBreakpointHit?: (nodeId: string, nodeName: string) => void;
eventBus?: EditorEventBus;
hooksManager?: ExecutionHooksManager;
}
export class ExecutionController {
private executor: BehaviorTreeExecutor | null = null;
private mode: ExecutionMode = 'idle';
private animationFrameId: number | null = null;
private lastTickTime: number = 0;
private speed: number = 1.0;
private tickCount: number = 0;
private domCache: DOMCache = new DOMCache();
private eventBus?: EditorEventBus;
private hooksManager?: ExecutionHooksManager;
private config: ExecutionControllerConfig;
private currentNodes: BehaviorTreeNode[] = [];
private currentConnections: Connection[] = [];
private currentBlackboard: BlackboardVariables = {};
private stepByStepMode: boolean = true;
private pendingStatusUpdates: ExecutionStatus[] = [];
private currentlyDisplayedIndex: number = 0;
private lastStepTime: number = 0;
private stepInterval: number = 200;
// 存储断点回调的引用
private breakpointCallback: ((nodeId: string, nodeName: string) => void) | null = null;
constructor(config: ExecutionControllerConfig) {
this.config = config;
this.executor = new BehaviorTreeExecutor();
this.eventBus = config.eventBus;
this.hooksManager = config.hooksManager;
}
getMode(): ExecutionMode {
return this.mode;
}
getTickCount(): number {
return this.tickCount;
}
getSpeed(): number {
return this.speed;
}
setSpeed(speed: number): void {
this.speed = speed;
this.lastTickTime = 0;
}
async play(
nodes: BehaviorTreeNode[],
blackboardVariables: BlackboardVariables,
connections: Connection[]
): Promise<void> {
if (this.mode === 'running') return;
this.currentNodes = nodes;
this.currentConnections = connections;
this.currentBlackboard = blackboardVariables;
const context = {
nodes,
connections,
blackboardVariables,
rootNodeId: this.config.rootNodeId,
tickCount: 0
};
try {
await this.hooksManager?.triggerBeforePlay(context);
this.mode = 'running';
this.tickCount = 0;
this.lastTickTime = 0;
if (!this.executor) {
this.executor = new BehaviorTreeExecutor();
}
this.executor.buildTree(
nodes,
this.config.rootNodeId,
blackboardVariables,
connections,
this.handleExecutionStatusUpdate.bind(this)
);
// 设置断点触发回调(使用存储的回调)
if (this.breakpointCallback) {
this.executor.setBreakpointCallback(this.breakpointCallback);
}
this.executor.start();
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
this.eventBus?.emit(EditorEvent.EXECUTION_STARTED, context);
await this.hooksManager?.triggerAfterPlay(context);
} catch (error) {
console.error('Error in play:', error);
await this.hooksManager?.triggerOnError(error as Error, 'play');
throw error;
}
}
async pause(): Promise<void> {
try {
if (this.mode === 'running') {
await this.hooksManager?.triggerBeforePause();
this.mode = 'paused';
if (this.executor) {
this.executor.pause();
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.eventBus?.emit(EditorEvent.EXECUTION_PAUSED);
await this.hooksManager?.triggerAfterPause();
} else if (this.mode === 'paused') {
await this.hooksManager?.triggerBeforeResume();
this.mode = 'running';
this.lastTickTime = 0;
if (this.executor) {
this.executor.resume();
}
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
this.eventBus?.emit(EditorEvent.EXECUTION_RESUMED);
await this.hooksManager?.triggerAfterResume();
}
} catch (error) {
console.error('Error in pause/resume:', error);
await this.hooksManager?.triggerOnError(error as Error, 'pause');
throw error;
}
}
async stop(): Promise<void> {
try {
await this.hooksManager?.triggerBeforeStop();
this.mode = 'idle';
this.tickCount = 0;
this.lastTickTime = 0;
this.lastStepTime = 0;
this.pendingStatusUpdates = [];
this.currentlyDisplayedIndex = 0;
this.domCache.clearAllStatusTimers();
this.domCache.clearStatusCache();
this.config.onExecutionStatusUpdate(new Map(), new Map());
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.executor) {
this.executor.stop();
}
this.eventBus?.emit(EditorEvent.EXECUTION_STOPPED);
await this.hooksManager?.triggerAfterStop();
} catch (error) {
console.error('Error in stop:', error);
await this.hooksManager?.triggerOnError(error as Error, 'stop');
throw error;
}
}
async reset(): Promise<void> {
await this.stop();
if (this.executor) {
this.executor.cleanup();
}
}
async step(): Promise<void> {
if (this.mode === 'running') {
await this.pause();
}
if (this.mode === 'idle') {
if (!this.currentNodes.length) {
logger.warn('No tree loaded for step execution');
return;
}
if (!this.executor) {
this.executor = new BehaviorTreeExecutor();
}
this.executor.buildTree(
this.currentNodes,
this.config.rootNodeId,
this.currentBlackboard,
this.currentConnections,
this.handleExecutionStatusUpdate.bind(this)
);
if (this.breakpointCallback) {
this.executor.setBreakpointCallback(this.breakpointCallback);
}
this.executor.start();
}
try {
await this.hooksManager?.triggerBeforeStep?.(0);
if (this.stepByStepMode && this.pendingStatusUpdates.length > 0) {
if (this.currentlyDisplayedIndex < this.pendingStatusUpdates.length) {
this.displayNextNode();
} else {
this.executeSingleTick();
}
} else {
this.executeSingleTick();
}
this.eventBus?.emit(EditorEvent.EXECUTION_STEPPED, { tickCount: this.tickCount });
await this.hooksManager?.triggerAfterStep?.(0);
} catch (error) {
console.error('Error in step:', error);
await this.hooksManager?.triggerOnError(error as Error, 'step');
}
this.mode = 'paused';
}
private executeSingleTick(): void {
if (!this.executor) return;
const deltaTime = 16.67 / 1000;
this.executor.tick(deltaTime);
this.tickCount = this.executor.getTickCount();
this.config.onTickCountUpdate(this.tickCount);
}
updateBlackboardVariable(key: string, value: BlackboardValue): void {
if (this.executor && this.mode !== 'idle') {
this.executor.updateBlackboardVariable(key, value);
}
}
getBlackboardVariables(): BlackboardVariables {
if (this.executor) {
return this.executor.getBlackboardVariables();
}
return {};
}
updateNodes(nodes: BehaviorTreeNode[]): void {
if (this.mode === 'idle' || !this.executor) {
return;
}
this.currentNodes = nodes;
this.executor.buildTree(
nodes,
this.config.rootNodeId,
this.currentBlackboard,
this.currentConnections,
this.handleExecutionStatusUpdate.bind(this)
);
// 设置断点触发回调(使用存储的回调)
if (this.breakpointCallback) {
this.executor.setBreakpointCallback(this.breakpointCallback);
}
this.executor.start();
}
clearDOMCache(): void {
this.domCache.clearAll();
}
destroy(): void {
this.stop();
if (this.executor) {
this.executor.destroy();
this.executor = null;
}
}
private tickLoop(currentTime: number): void {
if (this.mode !== 'running') {
return;
}
if (!this.executor) {
return;
}
if (this.stepByStepMode) {
this.handleStepByStepExecution(currentTime);
} else {
this.handleNormalExecution(currentTime);
}
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
}
private handleNormalExecution(currentTime: number): void {
const baseTickInterval = 16.67;
const scaledTickInterval = baseTickInterval / this.speed;
if (this.lastTickTime === 0) {
this.lastTickTime = currentTime;
}
const elapsed = currentTime - this.lastTickTime;
if (elapsed >= scaledTickInterval) {
const deltaTime = baseTickInterval / 1000;
this.executor!.tick(deltaTime);
this.tickCount = this.executor!.getTickCount();
this.config.onTickCountUpdate(this.tickCount);
this.lastTickTime = currentTime;
}
}
private handleStepByStepExecution(currentTime: number): void {
if (this.lastStepTime === 0) {
this.lastStepTime = currentTime;
}
const stepElapsed = currentTime - this.lastStepTime;
const actualStepInterval = this.stepInterval / this.speed;
if (stepElapsed >= actualStepInterval) {
if (this.currentlyDisplayedIndex < this.pendingStatusUpdates.length) {
this.displayNextNode();
this.lastStepTime = currentTime;
} else {
if (this.lastTickTime === 0) {
this.lastTickTime = currentTime;
}
const tickElapsed = currentTime - this.lastTickTime;
const baseTickInterval = 16.67;
const scaledTickInterval = baseTickInterval / this.speed;
if (tickElapsed >= scaledTickInterval) {
const deltaTime = baseTickInterval / 1000;
this.executor!.tick(deltaTime);
this.tickCount = this.executor!.getTickCount();
this.config.onTickCountUpdate(this.tickCount);
this.lastTickTime = currentTime;
}
}
}
}
private displayNextNode(): void {
if (this.currentlyDisplayedIndex >= this.pendingStatusUpdates.length) {
return;
}
const statusesToDisplay = this.pendingStatusUpdates.slice(0, this.currentlyDisplayedIndex + 1);
const currentNode = this.pendingStatusUpdates[this.currentlyDisplayedIndex];
if (!currentNode) {
return;
}
const statusMap = new Map<string, NodeExecutionStatus>();
const orderMap = new Map<string, number>();
statusesToDisplay.forEach((s) => {
statusMap.set(s.nodeId, s.status);
if (s.executionOrder !== undefined) {
orderMap.set(s.nodeId, s.executionOrder);
}
});
const nodeName = this.currentNodes.find((n) => n.id === currentNode.nodeId)?.template.displayName || 'Unknown';
logger.info(`[StepByStep] Displaying ${this.currentlyDisplayedIndex + 1}/${this.pendingStatusUpdates.length} | ${nodeName} | Order: ${currentNode.executionOrder} | ID: ${currentNode.nodeId}`);
this.config.onExecutionStatusUpdate(statusMap, orderMap);
this.currentlyDisplayedIndex++;
}
private handleExecutionStatusUpdate(
statuses: ExecutionStatus[],
logs: ExecutionLog[],
runtimeBlackboardVars?: BlackboardVariables
): void {
this.config.onLogsUpdate([...logs]);
if (runtimeBlackboardVars) {
this.config.onBlackboardUpdate(runtimeBlackboardVars);
}
if (this.stepByStepMode) {
const statusesWithOrder = statuses.filter((s) => s.executionOrder !== undefined);
if (statusesWithOrder.length > 0) {
const minOrder = Math.min(...statusesWithOrder.map((s) => s.executionOrder!));
if (minOrder === 1 || this.pendingStatusUpdates.length === 0) {
this.pendingStatusUpdates = statusesWithOrder.sort((a, b) =>
(a.executionOrder || 0) - (b.executionOrder || 0)
);
this.currentlyDisplayedIndex = 0;
this.lastStepTime = 0;
} else {
const maxExistingOrder = this.pendingStatusUpdates.length > 0
? Math.max(...this.pendingStatusUpdates.map((s) => s.executionOrder || 0))
: 0;
const newStatuses = statusesWithOrder.filter((s) =>
(s.executionOrder || 0) > maxExistingOrder
);
if (newStatuses.length > 0) {
logger.info(`[StepByStep] Appending ${newStatuses.length} new nodes, orders:`, newStatuses.map((s) => s.executionOrder));
this.pendingStatusUpdates = [
...this.pendingStatusUpdates,
...newStatuses
].sort((a, b) => (a.executionOrder || 0) - (b.executionOrder || 0));
}
}
}
} else {
const statusMap = new Map<string, NodeExecutionStatus>();
const orderMap = new Map<string, number>();
statuses.forEach((s) => {
statusMap.set(s.nodeId, s.status);
if (s.executionOrder !== undefined) {
orderMap.set(s.nodeId, s.executionOrder);
}
});
this.config.onExecutionStatusUpdate(statusMap, orderMap);
}
}
private updateConnectionStyles(
statusMap: Record<string, NodeExecutionStatus>,
connections?: Connection[]
): void {
if (!connections) return;
connections.forEach((conn) => {
const connKey = `${conn.from}-${conn.to}`;
const pathElement = this.domCache.getConnection(connKey);
if (!pathElement) {
return;
}
const fromStatus = statusMap[conn.from];
const toStatus = statusMap[conn.to];
const isActive = fromStatus === 'running' || toStatus === 'running';
if (conn.connectionType === 'property') {
this.domCache.setConnectionAttribute(connKey, 'stroke', '#9c27b0');
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2');
} else if (isActive) {
this.domCache.setConnectionAttribute(connKey, 'stroke', '#ffa726');
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '3');
} else {
const isExecuted = this.domCache.hasNodeClass(conn.from, 'executed') &&
this.domCache.hasNodeClass(conn.to, 'executed');
if (isExecuted) {
this.domCache.setConnectionAttribute(connKey, 'stroke', '#4caf50');
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2.5');
} else {
this.domCache.setConnectionAttribute(connKey, 'stroke', '#0e639c');
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2');
}
}
});
}
setConnections(connections: Connection[]): void {
if (this.mode !== 'idle') {
const currentStatuses: Record<string, NodeExecutionStatus> = {};
connections.forEach((conn) => {
const fromStatus = this.domCache.getLastStatus(conn.from);
const toStatus = this.domCache.getLastStatus(conn.to);
if (fromStatus) currentStatuses[conn.from] = fromStatus;
if (toStatus) currentStatuses[conn.to] = toStatus;
});
this.updateConnectionStyles(currentStatuses, connections);
}
}
setBreakpoints(breakpoints: Map<string, Breakpoint>): void {
if (this.executor) {
this.executor.setBreakpoints(breakpoints);
}
}
/**
* 设置断点触发回调
*/
setBreakpointCallback(callback: (nodeId: string, nodeName: string) => void): void {
this.breakpointCallback = callback;
// 如果 executor 已存在,立即设置
if (this.executor) {
this.executor.setBreakpointCallback(callback);
}
}
}
@@ -1,223 +0,0 @@
import { GlobalBlackboardConfig, BlackboardValueType, BlackboardVariable } from '@esengine/behavior-tree';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('GlobalBlackboardService');
export type GlobalBlackboardValue =
| string
| number
| boolean
| { x: number; y: number }
| { x: number; y: number; z: number }
| Record<string, string | number | boolean>
| Array<string | number | boolean>;
export interface GlobalBlackboardVariable {
key: string;
type: BlackboardValueType;
defaultValue: GlobalBlackboardValue;
description?: string;
}
/**
* 全局黑板服务
* 管理跨行为树共享的全局变量
*/
export class GlobalBlackboardService {
private static instance: GlobalBlackboardService;
private variables: Map<string, GlobalBlackboardVariable> = new Map();
private changeCallbacks: Array<() => void> = [];
private projectPath: string | null = null;
private constructor() {}
static getInstance(): GlobalBlackboardService {
if (!this.instance) {
this.instance = new GlobalBlackboardService();
}
return this.instance;
}
/**
* 设置项目路径
*/
setProjectPath(path: string | null): void {
this.projectPath = path;
}
/**
* 获取项目路径
*/
getProjectPath(): string | null {
return this.projectPath;
}
/**
* 添加全局变量
*/
addVariable(variable: GlobalBlackboardVariable): void {
if (this.variables.has(variable.key)) {
throw new Error(`全局变量 "${variable.key}" 已存在`);
}
this.variables.set(variable.key, variable);
this.notifyChange();
}
/**
* 更新全局变量
*/
updateVariable(key: string, updates: Partial<Omit<GlobalBlackboardVariable, 'key'>>): void {
const variable = this.variables.get(key);
if (!variable) {
throw new Error(`全局变量 "${key}" 不存在`);
}
this.variables.set(key, { ...variable, ...updates });
this.notifyChange();
}
/**
* 删除全局变量
*/
deleteVariable(key: string): boolean {
const result = this.variables.delete(key);
if (result) {
this.notifyChange();
}
return result;
}
/**
* 重命名全局变量
*/
renameVariable(oldKey: string, newKey: string): void {
if (!this.variables.has(oldKey)) {
throw new Error(`全局变量 "${oldKey}" 不存在`);
}
if (this.variables.has(newKey)) {
throw new Error(`全局变量 "${newKey}" 已存在`);
}
const variable = this.variables.get(oldKey)!;
this.variables.delete(oldKey);
this.variables.set(newKey, { ...variable, key: newKey });
this.notifyChange();
}
/**
* 获取全局变量
*/
getVariable(key: string): GlobalBlackboardVariable | undefined {
return this.variables.get(key);
}
/**
* 获取所有全局变量
*/
getAllVariables(): GlobalBlackboardVariable[] {
return Array.from(this.variables.values());
}
getVariablesMap(): Record<string, GlobalBlackboardValue> {
const map: Record<string, GlobalBlackboardValue> = {};
for (const [, variable] of this.variables) {
map[variable.key] = variable.defaultValue;
}
return map;
}
/**
* 检查变量是否存在
*/
hasVariable(key: string): boolean {
return this.variables.has(key);
}
/**
* 清空所有变量
*/
clear(): void {
this.variables.clear();
this.notifyChange();
}
/**
* 导出为全局黑板配置
*/
toConfig(): GlobalBlackboardConfig {
const variables: BlackboardVariable[] = [];
for (const variable of this.variables.values()) {
variables.push({
name: variable.key,
type: variable.type,
value: variable.defaultValue,
description: variable.description
});
}
return { version: '1.0', variables };
}
/**
* 从配置导入
*/
fromConfig(config: GlobalBlackboardConfig): void {
this.variables.clear();
if (config.variables && Array.isArray(config.variables)) {
for (const variable of config.variables) {
this.variables.set(variable.name, {
key: variable.name,
type: variable.type,
defaultValue: variable.value as GlobalBlackboardValue,
description: variable.description
});
}
}
this.notifyChange();
}
/**
* 序列化为 JSON
*/
toJSON(): string {
return JSON.stringify(this.toConfig(), null, 2);
}
/**
* 从 JSON 反序列化
*/
fromJSON(json: string): void {
try {
const config = JSON.parse(json) as GlobalBlackboardConfig;
this.fromConfig(config);
} catch (error) {
logger.error('Failed to parse global blackboard JSON:', error);
throw new Error('无效的全局黑板配置格式');
}
}
/**
* 监听变化
*/
onChange(callback: () => void): () => void {
this.changeCallbacks.push(callback);
return () => {
const index = this.changeCallbacks.indexOf(callback);
if (index > -1) {
this.changeCallbacks.splice(index, 1);
}
};
}
private notifyChange(): void {
this.changeCallbacks.forEach((cb) => {
try {
cb();
} catch (error) {
logger.error('Error in global blackboard change callback:', error);
}
});
}
}
@@ -1,555 +0,0 @@
import { create } from 'zustand';
import { NodeTemplates, NodeTemplate } from '@esengine/behavior-tree';
import { BehaviorTree } from '../../domain/models/BehaviorTree';
import { Node } from '../../domain/models/Node';
import { Connection, ConnectionType } from '../../domain/models/Connection';
import { Blackboard, BlackboardValue } from '../../domain/models/Blackboard';
import { ITreeState } from '../commands/ITreeState';
import { createRootNode, createRootNodeTemplate, ROOT_NODE_ID } from '../../domain/constants/RootNode';
import { Position } from '../../domain/value-objects/Position';
import { DEFAULT_EDITOR_CONFIG } from '../../config/editorConstants';
const createInitialTree = (): BehaviorTree => {
const rootNode = createRootNode();
return new BehaviorTree([rootNode], [], Blackboard.empty(), ROOT_NODE_ID);
};
/**
* 节点执行状态
*/
export type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
/**
* 行为树数据状态
* 唯一的业务数据源
*/
interface BehaviorTreeDataState {
/**
* 当前行为树(领域对象)
*/
tree: BehaviorTree;
/**
* 缓存的节点数组(避免每次创建新数组)
*/
cachedNodes: Node[];
/**
* 缓存的连接数组(避免每次创建新数组)
*/
cachedConnections: Connection[];
/**
* 文件是否已打开
*/
isOpen: boolean;
/**
* 当前文件路径
*/
currentFilePath: string | null;
/**
* 当前文件名
*/
currentFileName: string;
/**
* 黑板变量(运行时)
*/
blackboardVariables: Record<string, BlackboardValue>;
/**
* 初始黑板变量
*/
initialBlackboardVariables: Record<string, BlackboardValue>;
/**
* 节点初始数据快照(用于执行重置)
*/
initialNodesData: Map<string, Record<string, unknown>>;
/**
* 是否正在执行
*/
isExecuting: boolean;
/**
* 节点执行状态
*/
nodeExecutionStatuses: Map<string, NodeExecutionStatus>;
/**
* 节点执行顺序
*/
nodeExecutionOrders: Map<string, number>;
/**
* 画布状态(持久化)
*/
canvasOffset: { x: number; y: number };
canvasScale: number;
/**
* 强制更新计数器
*/
forceUpdateCounter: number;
/**
* 设置行为树
*/
setTree: (tree: BehaviorTree) => void;
/**
* 重置为空树
*/
reset: () => void;
/**
* 设置文件打开状态
*/
setIsOpen: (isOpen: boolean) => void;
/**
* 设置当前文件信息
*/
setCurrentFile: (filePath: string | null, fileName: string) => void;
/**
* 从 JSON 导入
*/
importFromJSON: (json: string) => void;
/**
* 导出为 JSON
*/
exportToJSON: (metadata: { name: string; description: string }) => string;
/**
* 黑板相关
*/
setBlackboardVariables: (variables: Record<string, BlackboardValue>) => void;
setInitialBlackboardVariables: (variables: Record<string, BlackboardValue>) => void;
updateBlackboardVariable: (name: string, value: BlackboardValue) => void;
/**
* 执行相关
*/
setIsExecuting: (isExecuting: boolean) => void;
saveNodesDataSnapshot: () => void;
restoreNodesData: () => void;
setNodeExecutionStatus: (nodeId: string, status: NodeExecutionStatus) => void;
updateNodeExecutionStatuses: (statuses: Map<string, NodeExecutionStatus>, orders?: Map<string, number>) => void;
clearNodeExecutionStatuses: () => void;
/**
* 画布状态
*/
setCanvasOffset: (offset: { x: number; y: number }) => void;
setCanvasScale: (scale: number) => void;
resetView: () => void;
/**
* 强制更新
*/
triggerForceUpdate: () => void;
/**
* 子节点排序
*/
sortChildrenByPosition: () => void;
/**
* 获取所有节点(数组形式)
*/
getNodes: () => Node[];
/**
* 获取指定节点
*/
getNode: (nodeId: string) => Node | undefined;
/**
* 检查节点是否存在
*/
hasNode: (nodeId: string) => boolean;
/**
* 获取所有连接
*/
getConnections: () => Connection[];
/**
* 获取黑板
*/
getBlackboard: () => Blackboard;
/**
* 获取根节点 ID
*/
getRootNodeId: () => string | null;
}
/**
* 行为树数据 Store
* 实现 ITreeState 接口,供命令使用
*/
export const useBehaviorTreeDataStore = create<BehaviorTreeDataState>((set, get) => {
const initialTree = createInitialTree();
return {
tree: initialTree,
cachedNodes: Array.from(initialTree.nodes),
cachedConnections: Array.from(initialTree.connections),
isOpen: false,
currentFilePath: null,
currentFileName: 'Untitled',
blackboardVariables: {},
initialBlackboardVariables: {},
initialNodesData: new Map(),
isExecuting: false,
nodeExecutionStatuses: new Map(),
nodeExecutionOrders: new Map(),
canvasOffset: { x: 0, y: 0 },
canvasScale: 1,
forceUpdateCounter: 0,
setTree: (tree: BehaviorTree) => {
set({
tree,
cachedNodes: Array.from(tree.nodes),
cachedConnections: Array.from(tree.connections)
});
},
reset: () => {
const newTree = createInitialTree();
set({
tree: newTree,
cachedNodes: Array.from(newTree.nodes),
cachedConnections: Array.from(newTree.connections),
isOpen: false,
currentFilePath: null,
currentFileName: 'Untitled',
blackboardVariables: {},
initialBlackboardVariables: {},
initialNodesData: new Map(),
isExecuting: false,
nodeExecutionStatuses: new Map(),
nodeExecutionOrders: new Map(),
canvasOffset: { x: 0, y: 0 },
canvasScale: 1,
forceUpdateCounter: 0
});
},
setIsOpen: (isOpen: boolean) => set({ isOpen }),
setCurrentFile: (filePath: string | null, fileName: string) => set({
currentFilePath: filePath,
currentFileName: fileName
}),
importFromJSON: (json: string) => {
const data = JSON.parse(json) as {
nodes?: Array<{
id: string;
template?: { className?: string };
data: Record<string, unknown>;
position: { x: number; y: number };
children?: string[];
}>;
connections?: Array<{
from: string;
to: string;
connectionType?: string;
fromProperty?: string;
toProperty?: string;
}>;
blackboard?: Record<string, BlackboardValue>;
canvasState?: { offset?: { x: number; y: number }; scale?: number };
};
const blackboardData = data.blackboard || {};
// 导入节点
const loadedNodes: Node[] = (data.nodes || []).map((nodeObj) => {
// 根节点也需要保留文件中的 children 数据
if (nodeObj.id === ROOT_NODE_ID) {
const position = new Position(
nodeObj.position.x || DEFAULT_EDITOR_CONFIG.defaultRootNodePosition.x,
nodeObj.position.y || DEFAULT_EDITOR_CONFIG.defaultRootNodePosition.y
);
return new Node(
ROOT_NODE_ID,
createRootNodeTemplate(),
{ nodeType: 'root' },
position,
nodeObj.children || []
);
}
const className = nodeObj.template?.className;
let template = nodeObj.template;
if (className) {
const allTemplates = NodeTemplates.getAllTemplates();
const latestTemplate = allTemplates.find((t) => t.className === className);
if (latestTemplate) {
template = latestTemplate;
}
}
const position = new Position(nodeObj.position.x, nodeObj.position.y);
return new Node(nodeObj.id, template as NodeTemplate, nodeObj.data, position, nodeObj.children || []);
});
const loadedConnections: Connection[] = (data.connections || []).map((connObj) => {
return new Connection(
connObj.from,
connObj.to,
(connObj.connectionType || 'node') as ConnectionType,
connObj.fromProperty,
connObj.toProperty
);
});
const loadedBlackboard = Blackboard.fromObject(blackboardData);
// 创建新的行为树
const tree = new BehaviorTree(
loadedNodes,
loadedConnections,
loadedBlackboard,
ROOT_NODE_ID
);
set({
tree,
cachedNodes: Array.from(tree.nodes),
cachedConnections: Array.from(tree.connections),
isOpen: true,
blackboardVariables: blackboardData,
initialBlackboardVariables: blackboardData,
canvasOffset: data.canvasState?.offset || { x: 0, y: 0 },
canvasScale: data.canvasState?.scale || 1
});
},
exportToJSON: (metadata: { name: string; description: string }) => {
const state = get();
const now = new Date().toISOString();
const data = {
version: '1.0.0',
metadata: {
name: metadata.name,
description: metadata.description,
createdAt: now,
modifiedAt: now
},
nodes: state.getNodes().map((n) => n.toObject()),
connections: state.getConnections().map((c) => c.toObject()),
blackboard: state.getBlackboard().toObject(),
canvasState: {
offset: state.canvasOffset,
scale: state.canvasScale
}
};
return JSON.stringify(data, null, 2);
},
setBlackboardVariables: (variables: Record<string, BlackboardValue>) => {
const newBlackboard = Blackboard.fromObject(variables);
const currentTree = get().tree;
const newTree = new BehaviorTree(
currentTree.nodes as Node[],
currentTree.connections as Connection[],
newBlackboard,
currentTree.rootNodeId
);
set({
tree: newTree,
cachedNodes: Array.from(newTree.nodes),
cachedConnections: Array.from(newTree.connections),
blackboardVariables: variables
});
},
setInitialBlackboardVariables: (variables: Record<string, BlackboardValue>) =>
set({ initialBlackboardVariables: variables }),
updateBlackboardVariable: (name: string, value: BlackboardValue) => {
const state = get();
const newBlackboard = Blackboard.fromObject(state.blackboardVariables);
newBlackboard.setValue(name, value);
const variables = newBlackboard.toObject();
const currentTree = state.tree;
const newTree = new BehaviorTree(
currentTree.nodes as Node[],
currentTree.connections as Connection[],
newBlackboard,
currentTree.rootNodeId
);
set({
tree: newTree,
cachedNodes: Array.from(newTree.nodes),
cachedConnections: Array.from(newTree.connections),
blackboardVariables: variables
});
},
setIsExecuting: (isExecuting: boolean) => set({ isExecuting }),
saveNodesDataSnapshot: () => {
const snapshot = new Map<string, Record<string, unknown>>();
get().getNodes().forEach((node) => {
snapshot.set(node.id, { ...node.data });
});
set({ initialNodesData: snapshot });
},
restoreNodesData: () => {
const state = get();
const snapshot = state.initialNodesData;
if (snapshot.size === 0) return;
const updatedNodes = state.getNodes().map((node) => {
const savedData = snapshot.get(node.id);
if (savedData) {
return new Node(node.id, node.template, savedData, node.position, Array.from(node.children));
}
return node;
});
const newTree = new BehaviorTree(
updatedNodes,
state.getConnections(),
state.getBlackboard(),
state.getRootNodeId()
);
set({
tree: newTree,
cachedNodes: Array.from(newTree.nodes),
cachedConnections: Array.from(newTree.connections),
initialNodesData: new Map()
});
},
setNodeExecutionStatus: (nodeId: string, status: NodeExecutionStatus) => {
const newStatuses = new Map(get().nodeExecutionStatuses);
newStatuses.set(nodeId, status);
set({ nodeExecutionStatuses: newStatuses });
},
updateNodeExecutionStatuses: (statuses: Map<string, NodeExecutionStatus>, orders?: Map<string, number>) => {
set({
nodeExecutionStatuses: new Map(statuses),
nodeExecutionOrders: orders ? new Map(orders) : new Map()
});
},
clearNodeExecutionStatuses: () => {
set({
nodeExecutionStatuses: new Map(),
nodeExecutionOrders: new Map()
});
},
setCanvasOffset: (offset: { x: number; y: number }) => set({ canvasOffset: offset }),
setCanvasScale: (scale: number) => set({ canvasScale: scale }),
resetView: () => set({ canvasOffset: { x: 0, y: 0 }, canvasScale: 1 }),
triggerForceUpdate: () => set((state) => ({ forceUpdateCounter: state.forceUpdateCounter + 1 })),
sortChildrenByPosition: () => {
const state = get();
const nodes = state.getNodes();
const nodeMap = new Map<string, Node>();
nodes.forEach((node) => nodeMap.set(node.id, node));
const sortedNodes = nodes.map((node) => {
if (node.children.length <= 1) {
return node;
}
const sortedChildren = Array.from(node.children).sort((a, b) => {
const nodeA = nodeMap.get(a);
const nodeB = nodeMap.get(b);
if (!nodeA || !nodeB) return 0;
return nodeA.position.x - nodeB.position.x;
});
return new Node(node.id, node.template, node.data, node.position, sortedChildren);
});
const newTree = new BehaviorTree(
sortedNodes,
state.getConnections(),
state.getBlackboard(),
state.getRootNodeId()
);
set({
tree: newTree,
cachedNodes: Array.from(newTree.nodes),
cachedConnections: Array.from(newTree.connections)
});
},
getNodes: () => {
return get().cachedNodes;
},
getNode: (nodeId: string) => {
try {
return get().tree.getNode(nodeId);
} catch {
return undefined;
}
},
hasNode: (nodeId: string) => {
return get().tree.hasNode(nodeId);
},
getConnections: () => {
return get().cachedConnections;
},
getBlackboard: () => {
return get().tree.blackboard;
},
getRootNodeId: () => {
return get().tree.rootNodeId;
}
};
});
/**
* TreeState 适配器
* 将 Zustand Store 适配为 ITreeState 接口
*/
export class TreeStateAdapter implements ITreeState {
private static instance: TreeStateAdapter | null = null;
private constructor() {}
static getInstance(): TreeStateAdapter {
if (!TreeStateAdapter.instance) {
TreeStateAdapter.instance = new TreeStateAdapter();
}
return TreeStateAdapter.instance;
}
getTree(): BehaviorTree {
return useBehaviorTreeDataStore.getState().tree;
}
setTree(tree: BehaviorTree): void {
useBehaviorTreeDataStore.getState().setTree(tree);
}
}
@@ -1,42 +0,0 @@
import { Connection, ConnectionType } from '../../domain/models/Connection';
import { CommandManager } from '@esengine/editor-core';
import { AddConnectionCommand } from '../commands/tree/AddConnectionCommand';
import { ITreeState } from '../commands/ITreeState';
import { IValidator } from '../../domain/interfaces/IValidator';
/**
* 添加连接用例
*/
export class AddConnectionUseCase {
constructor(
private readonly commandManager: CommandManager,
private readonly treeState: ITreeState,
private readonly validator: IValidator
) {}
/**
* 执行添加连接操作
*/
execute(
from: string,
to: string,
connectionType: ConnectionType = 'node',
fromProperty?: string,
toProperty?: string
): Connection {
const connection = new Connection(from, to, connectionType, fromProperty, toProperty);
const tree = this.treeState.getTree();
const validationResult = this.validator.validateConnection(connection, tree);
if (!validationResult.isValid) {
const errorMessages = validationResult.errors.map((e) => e.message).join(', ');
throw new Error(`连接验证失败: ${errorMessages}`);
}
const command = new AddConnectionCommand(this.treeState, connection);
this.commandManager.execute(command);
return connection;
}
}
@@ -1,42 +0,0 @@
import { NodeTemplate } from '@esengine/behavior-tree';
import { Node } from '../../domain/models/Node';
import { Position } from '../../domain/value-objects/Position';
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
import { CommandManager } from '@esengine/editor-core';
import { CreateNodeCommand } from '../commands/tree/CreateNodeCommand';
import { ITreeState } from '../commands/ITreeState';
/**
* 创建节点用例
*/
export class CreateNodeUseCase {
constructor(
private readonly nodeFactory: INodeFactory,
private readonly commandManager: CommandManager,
private readonly treeState: ITreeState
) {}
/**
* 执行创建节点操作
*/
execute(template: NodeTemplate, position: Position, data?: Record<string, unknown>): Node {
const node = this.nodeFactory.createNode(template, position, data);
const command = new CreateNodeCommand(this.treeState, node);
this.commandManager.execute(command);
return node;
}
/**
* 根据类型创建节点
*/
executeByType(nodeType: string, position: Position, data?: Record<string, unknown>): Node {
const node = this.nodeFactory.createNodeByType(nodeType, position, data);
const command = new CreateNodeCommand(this.treeState, node);
this.commandManager.execute(command);
return node;
}
}
@@ -1,76 +0,0 @@
import { CommandManager, ICommand } from '@esengine/editor-core';
import { DeleteNodeCommand } from '../commands/tree/DeleteNodeCommand';
import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand';
import { ITreeState } from '../commands/ITreeState';
/**
* 删除节点用例
* 删除节点时会自动删除相关连接
*/
export class DeleteNodeUseCase {
constructor(
private readonly commandManager: CommandManager,
private readonly treeState: ITreeState
) {}
/**
* 删除单个节点
*/
execute(nodeId: string): void {
const tree = this.treeState.getTree();
const relatedConnections = tree.connections.filter(
(conn) => conn.from === nodeId || conn.to === nodeId
);
const commands: ICommand[] = [];
relatedConnections.forEach((conn) => {
commands.push(
new RemoveConnectionCommand(
this.treeState,
conn.from,
conn.to,
conn.fromProperty,
conn.toProperty
)
);
});
commands.push(new DeleteNodeCommand(this.treeState, nodeId));
this.commandManager.executeBatch(commands);
}
/**
* 批量删除节点
*/
executeBatch(nodeIds: string[]): void {
const tree = this.treeState.getTree();
const commands: ICommand[] = [];
const nodeIdSet = new Set(nodeIds);
const relatedConnections = tree.connections.filter(
(conn) => nodeIdSet.has(conn.from) || nodeIdSet.has(conn.to)
);
relatedConnections.forEach((conn) => {
commands.push(
new RemoveConnectionCommand(
this.treeState,
conn.from,
conn.to,
conn.fromProperty,
conn.toProperty
)
);
});
nodeIds.forEach((nodeId) => {
commands.push(new DeleteNodeCommand(this.treeState, nodeId));
});
this.commandManager.executeBatch(commands);
}
}
@@ -1,32 +0,0 @@
import { Position } from '../../domain/value-objects/Position';
import { CommandManager } from '@esengine/editor-core';
import { MoveNodeCommand } from '../commands/tree/MoveNodeCommand';
import { ITreeState } from '../commands/ITreeState';
/**
* 移动节点用例
*/
export class MoveNodeUseCase {
constructor(
private readonly commandManager: CommandManager,
private readonly treeState: ITreeState
) {}
/**
* 移动单个节点
*/
execute(nodeId: string, newPosition: Position): void {
const command = new MoveNodeCommand(this.treeState, nodeId, newPosition);
this.commandManager.execute(command);
}
/**
* 批量移动节点
*/
executeBatch(moves: Array<{ nodeId: string; position: Position }>): void {
const commands = moves.map(
({ nodeId, position }) => new MoveNodeCommand(this.treeState, nodeId, position)
);
this.commandManager.executeBatch(commands);
}
}
@@ -1,27 +0,0 @@
import { CommandManager } from '@esengine/editor-core';
import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand';
import { ITreeState } from '../commands/ITreeState';
/**
* 移除连接用例
*/
export class RemoveConnectionUseCase {
constructor(
private readonly commandManager: CommandManager,
private readonly treeState: ITreeState
) {}
/**
* 执行移除连接操作
*/
execute(from: string, to: string, fromProperty?: string, toProperty?: string): void {
const command = new RemoveConnectionCommand(
this.treeState,
from,
to,
fromProperty,
toProperty
);
this.commandManager.execute(command);
}
}
@@ -1,21 +0,0 @@
import { CommandManager } from '@esengine/editor-core';
import { UpdateNodeDataCommand } from '../commands/tree/UpdateNodeDataCommand';
import { ITreeState } from '../commands/ITreeState';
/**
* 更新节点数据用例
*/
export class UpdateNodeDataUseCase {
constructor(
private readonly commandManager: CommandManager,
private readonly treeState: ITreeState
) {}
/**
* 更新节点数据
*/
execute(nodeId: string, data: Record<string, unknown>): void {
const command = new UpdateNodeDataCommand(this.treeState, nodeId, data);
this.commandManager.execute(command);
}
}
@@ -1,32 +0,0 @@
import { IValidator, ValidationResult } from '../../domain/interfaces/IValidator';
import { ITreeState } from '../commands/ITreeState';
/**
* 验证行为树用例
*/
export class ValidateTreeUseCase {
constructor(
private readonly validator: IValidator,
private readonly treeState: ITreeState
) {}
/**
* 验证当前行为树
*/
execute(): ValidationResult {
const tree = this.treeState.getTree();
return this.validator.validateTree(tree);
}
/**
* 验证并抛出错误(如果验证失败)
*/
executeAndThrow(): void {
const result = this.execute();
if (!result.isValid) {
const errorMessages = result.errors.map((e) => e.message).join('\n');
throw new Error(`行为树验证失败:\n${errorMessages}`);
}
}
}
@@ -1,7 +0,0 @@
export { CreateNodeUseCase } from './CreateNodeUseCase';
export { DeleteNodeUseCase } from './DeleteNodeUseCase';
export { AddConnectionUseCase } from './AddConnectionUseCase';
export { RemoveConnectionUseCase } from './RemoveConnectionUseCase';
export { MoveNodeUseCase } from './MoveNodeUseCase';
export { UpdateNodeDataUseCase } from './UpdateNodeDataUseCase';
export { ValidateTreeUseCase } from './ValidateTreeUseCase';
@@ -1,658 +0,0 @@
import React, { useState, useEffect } from 'react';
import { ICompiler, CompileResult, CompilerContext, IFileSystem } from '@esengine/editor-core';
import { File, FolderTree, FolderOpen } from 'lucide-react';
import { GlobalBlackboardTypeGenerator } from '../generators/GlobalBlackboardTypeGenerator';
import { EditorFormatConverter, BehaviorTreeAssetSerializer } from '@esengine/behavior-tree';
import { useBehaviorTreeDataStore } from '../application/state/BehaviorTreeDataStore';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('BehaviorTreeCompiler');
export interface BehaviorTreeCompileOptions {
mode: 'single' | 'workspace';
assetOutputPath: string;
typeOutputPath: string;
selectedFiles: string[];
fileFormats: Map<string, 'json' | 'binary'>;
currentFile?: string;
currentFilePath?: string;
}
export class BehaviorTreeCompiler implements ICompiler<BehaviorTreeCompileOptions> {
readonly id = 'behavior-tree';
readonly name = '行为树编译器';
readonly description = '将行为树文件编译为运行时资产和TypeScript类型定义';
private projectPath: string | null = null;
private currentOptions: BehaviorTreeCompileOptions | null = null;
async compile(options: BehaviorTreeCompileOptions, context: CompilerContext): Promise<CompileResult> {
this.projectPath = context.projectPath;
this.currentOptions = options;
const fileSystem = context.moduleContext.fileSystem;
if (!this.projectPath) {
return {
success: false,
message: '错误:没有打开的项目',
errors: ['请先打开一个项目']
};
}
try {
const outputFiles: string[] = [];
const errors: string[] = [];
if (options.mode === 'workspace') {
for (const fileId of options.selectedFiles) {
const format = options.fileFormats.get(fileId) || 'binary';
const result = await this.compileFile(fileId, options.assetOutputPath, options.typeOutputPath, format, fileSystem);
if (result.success) {
outputFiles.push(...(result.outputFiles || []));
} else {
errors.push(`${fileId}: ${result.message}`);
}
}
const globalTypeResult = await this.generateGlobalBlackboardTypes(options.typeOutputPath, fileSystem);
if (globalTypeResult.success) {
outputFiles.push(...(globalTypeResult.outputFiles || []));
} else {
errors.push(globalTypeResult.message);
}
} else {
const currentFileName = this.getCurrentFileName();
const currentFilePath = this.currentOptions?.currentFilePath;
if (!currentFileName) {
return {
success: false,
message: '错误:没有打开的行为树文件',
errors: ['请先打开一个行为树文件']
};
}
const format = options.fileFormats.get(currentFileName) || 'binary';
const result = await this.compileFileWithPath(
currentFileName,
currentFilePath || `${this.projectPath}/.ecs/behaviors/${currentFileName}.btree`,
options.assetOutputPath,
options.typeOutputPath,
format,
fileSystem
);
if (result.success) {
outputFiles.push(...(result.outputFiles || []));
} else {
errors.push(result.message);
}
}
if (errors.length > 0) {
return {
success: false,
message: `编译完成,但有 ${errors.length} 个错误`,
outputFiles,
errors
};
}
return {
success: true,
message: `成功编译 ${outputFiles.length} 个文件`,
outputFiles
};
} catch (error) {
return {
success: false,
message: `编译失败: ${error}`,
errors: [String(error)]
};
}
}
private async compileFile(
fileId: string,
assetOutputPath: string,
typeOutputPath: string,
format: 'json' | 'binary',
fileSystem: IFileSystem
): Promise<CompileResult> {
const btreePath = `${this.projectPath}/.ecs/behaviors/${fileId}.btree`;
return this.compileFileWithPath(fileId, btreePath, assetOutputPath, typeOutputPath, format, fileSystem);
}
private async compileFileWithPath(
fileId: string,
btreePath: string,
assetOutputPath: string,
typeOutputPath: string,
format: 'json' | 'binary',
fileSystem: IFileSystem
): Promise<CompileResult> {
try {
logger.info(`Reading file: ${btreePath}`);
const fileContent = await fileSystem.readFile(btreePath);
const treeData = JSON.parse(fileContent);
const editorFormat = this.convertToEditorFormat(treeData, fileId);
const asset = EditorFormatConverter.toAsset(editorFormat);
let runtimeAsset: string | Uint8Array;
const extension = format === 'json' ? '.btree.json' : '.btree.bin';
const assetPath = `${assetOutputPath}/${fileId}${extension}`;
if (format === 'json') {
runtimeAsset = BehaviorTreeAssetSerializer.serialize(asset, { format: 'json', pretty: true });
await fileSystem.writeFile(assetPath, runtimeAsset as string);
} else {
runtimeAsset = BehaviorTreeAssetSerializer.serialize(asset, { format: 'binary' });
await fileSystem.writeBinary(assetPath, runtimeAsset as Uint8Array);
}
const blackboardVars = treeData.blackboard || {};
logger.info(`${fileId} blackboard vars:`, blackboardVars);
const typeContent = this.generateBlackboardTypes(fileId, blackboardVars);
const typePath = `${typeOutputPath}/${fileId}.ts`;
await fileSystem.writeFile(typePath, typeContent);
logger.info(`Generated type file: ${typePath}`);
return {
success: true,
message: `成功编译 ${fileId}`,
outputFiles: [assetPath, typePath]
};
} catch (error) {
return {
success: false,
message: `编译 ${fileId} 失败: ${error}`,
errors: [String(error)]
};
}
}
/**
* 将存储的 JSON 数据转换为 EditorFormat
* @param treeData - 从文件读取的原始数据
* @param fileId - 文件标识符
* @returns 编辑器格式数据
*/
private convertToEditorFormat(treeData: any, fileId: string): any {
// 如果已经是新格式(包含 nodes 数组),直接使用
if (treeData.nodes && Array.isArray(treeData.nodes)) {
return {
version: treeData.version || '1.0.0',
metadata: treeData.metadata || {
name: fileId,
description: ''
},
nodes: treeData.nodes,
connections: treeData.connections || [],
blackboard: treeData.blackboard || {}
};
}
// 兼容旧格式,返回默认结构
return {
version: '1.0.0',
metadata: {
name: fileId,
description: ''
},
nodes: [],
connections: [],
blackboard: treeData.blackboard || {}
};
}
private async generateGlobalBlackboardTypes(
typeOutputPath: string,
fileSystem: IFileSystem
): Promise<CompileResult> {
try {
if (!this.projectPath) {
throw new Error('No project path');
}
const btreeFiles = await fileSystem.scanFiles(`${this.projectPath}/.ecs/behaviors`, '*.btree');
const variables: any[] = [];
for (const fileId of btreeFiles) {
const btreePath = `${this.projectPath}/.ecs/behaviors/${fileId}.btree`;
const fileContent = await fileSystem.readFile(btreePath);
const treeData = JSON.parse(fileContent);
const blackboard = treeData.blackboard || {};
for (const [key, value] of Object.entries(blackboard)) {
variables.push({
name: key,
type: this.inferType(value),
defaultValue: value
});
}
}
const config = {
version: '1.0.0',
variables
};
const typeContent = GlobalBlackboardTypeGenerator.generate(config);
const typePath = `${typeOutputPath}/GlobalBlackboard.ts`;
await fileSystem.writeFile(typePath, typeContent);
return {
success: true,
message: '成功生成全局黑板类型',
outputFiles: [typePath]
};
} catch (error) {
return {
success: false,
message: `生成全局黑板类型失败: ${error}`,
errors: [String(error)]
};
}
}
private generateBlackboardTypes(behaviorName: string, blackboardVars: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`export interface ${behaviorName}Blackboard {`);
for (const [key, value] of Object.entries(blackboardVars)) {
const type = this.inferType(value);
lines.push(` ${key}: ${type};`);
}
lines.push('}');
return lines.join('\n');
}
private inferType(value: unknown): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'string') return 'string';
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return 'boolean';
if (Array.isArray(value)) return 'unknown[]';
if (typeof value === 'object') return 'Record<string, unknown>';
return 'unknown';
}
private getCurrentFileName(): string | null {
if (this.currentOptions?.currentFile) {
return this.currentOptions.currentFile;
}
return null;
}
validateOptions(options: BehaviorTreeCompileOptions): string | null {
if (!options.assetOutputPath) {
return '请选择资产输出路径';
}
if (!options.typeOutputPath) {
return '请选择类型定义输出路径';
}
if (options.mode === 'workspace' && options.selectedFiles.length === 0) {
return '请至少选择一个文件';
}
if (options.mode === 'single' && !this.getCurrentFileName()) {
return '没有打开的行为树文件';
}
return null;
}
createConfigUI(onOptionsChange: (options: BehaviorTreeCompileOptions) => void, context: CompilerContext): React.ReactElement {
return <BehaviorTreeCompileConfigUI onOptionsChange={onOptionsChange} context={context} />;
}
}
interface ConfigUIProps {
onOptionsChange: (options: BehaviorTreeCompileOptions) => void;
context: CompilerContext;
}
function BehaviorTreeCompileConfigUI({ onOptionsChange, context }: ConfigUIProps) {
const { projectPath, moduleContext } = context;
const { fileSystem, dialog } = moduleContext;
const [mode, setMode] = useState<'single' | 'workspace'>('workspace');
const [assetOutputPath, setAssetOutputPath] = useState('');
const [typeOutputPath, setTypeOutputPath] = useState('');
const [availableFiles, setAvailableFiles] = useState<string[]>([]);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [fileFormats, setFileFormats] = useState<Map<string, 'json' | 'binary'>>(new Map());
const [selectAll, setSelectAll] = useState(true);
useEffect(() => {
const loadFiles = async () => {
if (projectPath) {
const files = await fileSystem.scanFiles(`${projectPath}/.ecs/behaviors`, '*.btree');
setAvailableFiles(files);
setSelectedFiles(new Set(files));
const formats = new Map<string, 'json' | 'binary'>();
files.forEach((file: string) => formats.set(file, 'binary'));
setFileFormats(formats);
}
};
loadFiles();
const savedAssetPath = localStorage.getItem('export-asset-path');
const savedTypePath = localStorage.getItem('export-type-path');
// Set default paths based on projectPath if no saved paths
if (savedAssetPath) {
setAssetOutputPath(savedAssetPath);
} else if (projectPath) {
const defaultAssetPath = `${projectPath}/assets/behaviors`;
setAssetOutputPath(defaultAssetPath);
}
if (savedTypePath) {
setTypeOutputPath(savedTypePath);
} else if (projectPath) {
const defaultTypePath = `${projectPath}/src/types/behaviors`;
setTypeOutputPath(defaultTypePath);
}
}, [projectPath]);
const currentFilePath = useBehaviorTreeDataStore((state) => state.currentFilePath);
const currentFileName = useBehaviorTreeDataStore((state) => state.currentFileName);
useEffect(() => {
onOptionsChange({
mode,
assetOutputPath,
typeOutputPath,
selectedFiles: mode === 'workspace' ? Array.from(selectedFiles) : [],
fileFormats,
currentFile: currentFileName || undefined,
currentFilePath: currentFilePath || undefined
});
}, [mode, assetOutputPath, typeOutputPath, selectedFiles, fileFormats, onOptionsChange, currentFileName, currentFilePath]);
const handleBrowseAssetPath = async () => {
const selected = await dialog.openDialog({
directory: true,
multiple: false,
title: '选择资产输出目录',
defaultPath: assetOutputPath || projectPath || undefined
});
if (selected && typeof selected === 'string') {
setAssetOutputPath(selected);
localStorage.setItem('export-asset-path', selected);
}
};
const handleBrowseTypePath = async () => {
const selected = await dialog.openDialog({
directory: true,
multiple: false,
title: '选择类型定义输出目录',
defaultPath: typeOutputPath || projectPath || undefined
});
if (selected && typeof selected === 'string') {
setTypeOutputPath(selected);
localStorage.setItem('export-type-path', selected);
}
};
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);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* 模式选择 */}
<div style={{ display: 'flex', gap: '8px', borderBottom: '1px solid #3e3e3e', paddingBottom: '8px' }}>
<button
onClick={() => setMode('workspace')}
style={{
flex: 1,
padding: '8px 16px',
background: mode === 'workspace' ? '#0e639c' : '#3a3a3a',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
fontSize: '13px'
}}
>
<FolderTree size={16} />
</button>
<button
onClick={() => setMode('single')}
style={{
flex: 1,
padding: '8px 16px',
background: mode === 'single' ? '#0e639c' : '#3a3a3a',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
fontSize: '13px'
}}
>
<File size={16} />
</button>
</div>
{/* 模式说明 */}
<div style={{
padding: '8px 12px',
background: '#1e3a5f',
borderRadius: '4px',
fontSize: '11px',
color: '#8ac3ff',
lineHeight: '1.5'
}}>
{mode === 'workspace' ? (
<>
<strong></strong> <code style={{ background: '#0d2744', padding: '2px 4px', borderRadius: '2px' }}>{projectPath}/.ecs/behaviors/</code> .btree
</>
) : (
<>
<strong></strong>
{currentFilePath && (
<div style={{ marginTop: '4px', wordBreak: 'break-all' }}>
<code style={{ background: '#0d2744', padding: '2px 4px', borderRadius: '2px' }}>{currentFilePath}</code>
</div>
)}
{!currentFilePath && (
<div style={{ marginTop: '4px', color: '#ffaa00' }}>
</div>
)}
</>
)}
</div>
{/* 资产输出路径 */}
<div>
<div style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600, color: '#ccc' }}>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={assetOutputPath}
onChange={(e) => setAssetOutputPath(e.target.value)}
placeholder="选择资产输出目录..."
style={{
flex: 1,
padding: '8px 12px',
background: '#2d2d2d',
border: '1px solid #3a3a3a',
borderRadius: '4px',
color: '#ccc',
fontSize: '12px'
}}
/>
<button
onClick={handleBrowseAssetPath}
style={{
padding: '8px 16px',
background: '#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>
<div style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600, color: '#ccc' }}>
TypeScript
</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',
background: '#2d2d2d',
border: '1px solid #3a3a3a',
borderRadius: '4px',
color: '#ccc',
fontSize: '12px'
}}
/>
<button
onClick={handleBrowseTypePath}
style={{
padding: '8px 16px',
background: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<FolderOpen size={14} />
</button>
</div>
</div>
{/* 文件列表 */}
{mode === 'workspace' && availableFiles.length > 0 && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<div style={{ fontSize: '13px', fontWeight: 600, color: '#ccc' }}>
({selectedFiles.size}/{availableFiles.length})
</div>
<button
onClick={handleSelectAll}
style={{
padding: '4px 12px',
background: '#3a3a3a',
border: 'none',
borderRadius: '3px',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px'
}}
>
{selectAll ? '取消全选' : '全选'}
</button>
</div>
<div style={{ maxHeight: '200px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '4px' }}>
{availableFiles.map((file) => (
<div
key={file}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px',
background: selectedFiles.has(file) ? '#2a2d2e' : '#1e1e1e',
border: `1px solid ${selectedFiles.has(file) ? '#0e639c' : '#3a3a3a'}`,
borderRadius: '4px',
fontSize: '12px'
}}
>
<input
type="checkbox"
checked={selectedFiles.has(file)}
onChange={() => handleToggleFile(file)}
style={{ cursor: 'pointer' }}
/>
<File size={14} style={{ color: '#ab47bc' }} />
<span style={{ flex: 1, color: '#ccc' }}>{file}.btree</span>
<select
value={fileFormats.get(file) || 'binary'}
onChange={(e) => handleFileFormatChange(file, e.target.value as 'json' | 'binary')}
onClick={(e) => e.stopPropagation()}
style={{
padding: '4px 8px',
background: '#2d2d2d',
border: '1px solid #3a3a3a',
borderRadius: '3px',
color: '#ccc',
fontSize: '11px',
cursor: 'pointer'
}}
>
<option value="binary"></option>
<option value="json">JSON</option>
</select>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -1,731 +0,0 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { NodeTemplate, BlackboardValueType } from '@esengine/behavior-tree';
import { useBehaviorTreeDataStore, BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
import { useUIStore } from '../stores';
import { showToast as notificationShowToast } from '../services/NotificationService';
import { BlackboardValue } from '../domain/models/Blackboard';
import { GlobalBlackboardService } from '../application/services/GlobalBlackboardService';
import { BehaviorTreeCanvas } from './canvas/BehaviorTreeCanvas';
import { ConnectionLayer } from './connections/ConnectionLayer';
import { NodeFactory } from '../infrastructure/factories/NodeFactory';
import { TreeValidator } from '../domain/services/TreeValidator';
import { useNodeOperations } from '../hooks/useNodeOperations';
import { useConnectionOperations } from '../hooks/useConnectionOperations';
import { useCommandHistory } from '../hooks/useCommandHistory';
import { useNodeDrag } from '../hooks/useNodeDrag';
import { usePortConnection } from '../hooks/usePortConnection';
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { useDropHandler } from '../hooks/useDropHandler';
import { useCanvasMouseEvents } from '../hooks/useCanvasMouseEvents';
import { useContextMenu } from '../hooks/useContextMenu';
import { useQuickCreateMenu } from '../hooks/useQuickCreateMenu';
import { EditorToolbar } from './toolbar/EditorToolbar';
import { QuickCreateMenu } from './menu/QuickCreateMenu';
import { NodeContextMenu } from './menu/NodeContextMenu';
import { BehaviorTreeNode as BehaviorTreeNodeComponent } from './nodes/BehaviorTreeNode';
import { BlackboardPanel } from './blackboard/BlackboardPanel';
import { getPortPosition as getPortPositionUtil } from '../utils/portUtils';
import { useExecutionController } from '../hooks/useExecutionController';
import { useNodeTracking } from '../hooks/useNodeTracking';
import { useEditorHandlers } from '../hooks/useEditorHandlers';
import { ICON_MAP, DEFAULT_EDITOR_CONFIG } from '../config/editorConstants';
import '../styles/BehaviorTreeNode.css';
type BlackboardVariables = Record<string, BlackboardValue>;
interface BehaviorTreeEditorProps {
onNodeSelect?: (node: BehaviorTreeNode) => void;
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
blackboardVariables?: BlackboardVariables;
projectPath?: string | null;
showToolbar?: boolean;
showToast?: (message: string, type?: 'success' | 'error' | 'warning' | 'info') => void;
currentFileName?: string;
hasUnsavedChanges?: boolean;
onSave?: () => void;
onOpen?: () => void;
onExport?: () => void;
onCopyToClipboard?: () => void;
}
export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
onNodeSelect,
onNodeCreate,
blackboardVariables = {},
projectPath = null,
showToolbar = true,
showToast: showToastProp,
currentFileName,
hasUnsavedChanges = false,
onSave,
onOpen,
onExport,
onCopyToClipboard
}) => {
// 使用传入的 showToast 或回退到 NotificationService
const showToast = showToastProp || notificationShowToast;
// 数据 store(行为树数据 - 唯一数据源)
const {
canvasOffset,
canvasScale,
triggerForceUpdate,
sortChildrenByPosition,
setBlackboardVariables,
setInitialBlackboardVariables,
setIsExecuting,
initialBlackboardVariables,
isExecuting,
saveNodesDataSnapshot,
restoreNodesData,
nodeExecutionStatuses,
nodeExecutionOrders,
resetView
} = useBehaviorTreeDataStore();
// 使用缓存的节点和连接数组(store 中已经优化,只在 tree 真正变化时更新)
const nodes = useBehaviorTreeDataStore((state) => state.cachedNodes);
const connections = useBehaviorTreeDataStore((state) => state.cachedConnections);
// UI storeUI 交互状态)
const {
selectedNodeIds,
selectedConnection,
draggingNodeId,
dragStartPositions,
isDraggingNode,
dragDelta,
connectingFrom,
connectingFromProperty,
connectingToPos,
isBoxSelecting,
boxSelectStart,
boxSelectEnd,
setSelectedNodeIds,
setSelectedConnection,
startDragging,
stopDragging,
setIsDraggingNode,
setDragDelta,
setConnectingFrom,
setConnectingFromProperty,
setConnectingToPos,
clearConnecting,
setIsBoxSelecting,
setBoxSelectStart,
setBoxSelectEnd,
clearBoxSelect
} = useUIStore();
const canvasRef = useRef<HTMLDivElement>(null);
const stopExecutionRef = useRef<(() => void) | null>(null);
const justFinishedBoxSelectRef = useRef(false);
const [blackboardCollapsed, setBlackboardCollapsed] = useState(false);
const [globalVariables, setGlobalVariables] = useState<Record<string, BlackboardValue>>({});
const updateVariable = useBehaviorTreeDataStore((state) => state.updateBlackboardVariable);
const globalBlackboardService = useMemo(() => GlobalBlackboardService.getInstance(), []);
useEffect(() => {
if (projectPath) {
globalBlackboardService.setProjectPath(projectPath);
setGlobalVariables(globalBlackboardService.getVariablesMap());
}
const unsubscribe = globalBlackboardService.onChange(() => {
setGlobalVariables(globalBlackboardService.getVariablesMap());
});
return () => unsubscribe();
}, [globalBlackboardService, projectPath]);
const handleGlobalVariableAdd = useCallback((key: string, value: any, type: string) => {
try {
let bbType: BlackboardValueType;
switch (type) {
case 'number':
bbType = BlackboardValueType.Number;
break;
case 'boolean':
bbType = BlackboardValueType.Boolean;
break;
case 'object':
bbType = BlackboardValueType.Object;
break;
default:
bbType = BlackboardValueType.String;
}
globalBlackboardService.addVariable({ key, type: bbType, defaultValue: value });
showToast(`全局变量 "${key}" 已添加`, 'success');
} catch (error) {
showToast(`添加全局变量失败: ${error}`, 'error');
}
}, [globalBlackboardService, showToast]);
const handleGlobalVariableChange = useCallback((key: string, value: any) => {
try {
globalBlackboardService.updateVariable(key, { defaultValue: value });
} catch (error) {
showToast(`更新全局变量失败: ${error}`, 'error');
}
}, [globalBlackboardService, showToast]);
const handleGlobalVariableDelete = useCallback((key: string) => {
try {
globalBlackboardService.deleteVariable(key);
showToast(`全局变量 "${key}" 已删除`, 'success');
} catch (error) {
showToast(`删除全局变量失败: ${error}`, 'error');
}
}, [globalBlackboardService, showToast]);
// 监听框选状态变化,当框选结束时设置标记
useEffect(() => {
if (!isBoxSelecting && justFinishedBoxSelectRef.current) {
// 框选刚结束,在下一个事件循环清除标记
setTimeout(() => {
justFinishedBoxSelectRef.current = false;
}, 0);
} else if (isBoxSelecting) {
// 正在框选
justFinishedBoxSelectRef.current = true;
}
}, [isBoxSelecting]);
// Node factory
const nodeFactory = useMemo(() => new NodeFactory(), []);
// 验证器
const validator = useMemo(() => new TreeValidator(), []);
// 命令历史
const { commandManager, canUndo, canRedo, undo, redo } = useCommandHistory();
// 节点操作
const nodeOperations = useNodeOperations(
nodeFactory,
commandManager
);
// 连接操作
const connectionOperations = useConnectionOperations(
validator,
commandManager
);
// 上下文菜单
const contextMenu = useContextMenu();
// 执行控制器
const {
executionMode,
executionSpeed,
handlePlay,
handlePause,
handleStop,
handleStep,
handleSpeedChange,
controller
} = useExecutionController({
rootNodeId: ROOT_NODE_ID,
projectPath: projectPath || '',
blackboardVariables,
nodes,
connections,
initialBlackboardVariables,
onBlackboardUpdate: setBlackboardVariables,
onInitialBlackboardSave: setInitialBlackboardVariables,
onExecutingChange: setIsExecuting,
onSaveNodesDataSnapshot: saveNodesDataSnapshot,
onRestoreNodesData: restoreNodesData,
sortChildrenByPosition
});
const executorRef = useRef(null);
const { uncommittedNodeIds } = useNodeTracking({ nodes, executionMode });
// 快速创建菜单
const quickCreateMenu = useQuickCreateMenu({
nodeOperations,
connectionOperations,
canvasRef,
canvasOffset,
canvasScale,
connectingFrom,
connectingFromProperty,
clearConnecting,
nodes,
connections,
executionMode,
onStop: () => stopExecutionRef.current?.(),
onNodeCreate,
showToast
});
const {
handleNodeClick,
handleResetView,
handleClearCanvas
} = useEditorHandlers({
isDraggingNode,
selectedNodeIds,
setSelectedNodeIds,
resetView,
resetTree: useBehaviorTreeDataStore.getState().reset,
triggerForceUpdate,
onNodeSelect
});
// 添加缺少的处理函数
const handleCanvasClick = (e: React.MouseEvent) => {
// 如果正在框选或者刚刚结束框选,不要清空选择
// 因为 click 事件会在 mouseup 之后触发,会清空框选的结果
if (!isDraggingNode && !isBoxSelecting && !justFinishedBoxSelectRef.current) {
setSelectedNodeIds([]);
setSelectedConnection(null);
}
// 关闭右键菜单
contextMenu.closeContextMenu();
};
const handleCanvasContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
contextMenu.handleCanvasContextMenu(e);
};
const handleNodeContextMenu = (e: React.MouseEvent, node: BehaviorTreeNode) => {
e.preventDefault();
contextMenu.handleNodeContextMenu(e, node);
};
const handleConnectionClick = (e: React.MouseEvent, fromId: string, toId: string) => {
setSelectedConnection({ from: fromId, to: toId });
setSelectedNodeIds([]);
};
const handleCanvasDoubleClick = (e: React.MouseEvent) => {
quickCreateMenu.openQuickCreateMenu(
{ x: e.clientX, y: e.clientY },
'create'
);
};
// 黑板变量管理
const handleBlackboardVariableAdd = (key: string, value: any) => {
const newVariables = { ...blackboardVariables, [key]: value };
setBlackboardVariables(newVariables);
};
const handleBlackboardVariableChange = (key: string, value: any) => {
const newVariables = { ...blackboardVariables, [key]: value };
setBlackboardVariables(newVariables);
};
const handleBlackboardVariableDelete = (key: string) => {
const newVariables = { ...blackboardVariables };
delete newVariables[key];
setBlackboardVariables(newVariables);
};
const handleResetBlackboardVariable = (name: string) => {
const initialValue = initialBlackboardVariables[name];
if (initialValue !== undefined) {
updateVariable(name, initialValue);
}
};
const handleResetAllBlackboardVariables = () => {
setBlackboardVariables(initialBlackboardVariables);
};
const handleBlackboardVariableRename = (oldKey: string, newKey: string) => {
if (oldKey === newKey) return;
const newVariables = { ...blackboardVariables };
newVariables[newKey] = newVariables[oldKey];
delete newVariables[oldKey];
setBlackboardVariables(newVariables);
};
// 节点拖拽
const {
handleNodeMouseDown,
handleNodeMouseMove,
handleNodeMouseUp
} = useNodeDrag({
canvasRef,
canvasOffset,
canvasScale,
nodes,
selectedNodeIds,
draggingNodeId,
dragStartPositions,
isDraggingNode,
dragDelta,
nodeOperations,
setSelectedNodeIds,
startDragging,
stopDragging,
setIsDraggingNode,
setDragDelta,
setIsBoxSelecting,
setBoxSelectStart,
setBoxSelectEnd,
sortChildrenByPosition
});
// 端口连接
const {
handlePortMouseDown,
handlePortMouseUp,
handleNodeMouseUpForConnection
} = usePortConnection({
canvasRef,
canvasOffset,
canvasScale,
nodes,
connections,
connectingFrom,
connectingFromProperty,
connectionOperations,
setConnectingFrom,
setConnectingFromProperty,
clearConnecting,
sortChildrenByPosition,
showToast
});
// 键盘快捷键
useKeyboardShortcuts({
selectedNodeIds,
selectedConnection,
connections,
nodeOperations,
connectionOperations,
setSelectedNodeIds,
setSelectedConnection
});
// 拖放处理
const {
isDragging,
handleDrop,
handleDragOver,
handleDragLeave,
handleDragEnter
} = useDropHandler({
canvasRef,
canvasOffset,
canvasScale,
nodeOperations,
onNodeCreate
});
// 画布鼠标事件
const {
handleCanvasMouseMove,
handleCanvasMouseUp,
handleCanvasMouseDown
} = useCanvasMouseEvents({
canvasRef,
canvasOffset,
canvasScale,
connectingFrom,
connectingFromProperty,
connectingToPos,
isBoxSelecting,
boxSelectStart,
boxSelectEnd,
nodes,
selectedNodeIds,
quickCreateMenu: quickCreateMenu.quickCreateMenu,
setConnectingToPos,
setIsBoxSelecting,
setBoxSelectStart,
setBoxSelectEnd,
setSelectedNodeIds,
setSelectedConnection,
setQuickCreateMenu: quickCreateMenu.setQuickCreateMenu,
clearConnecting,
clearBoxSelect,
showToast
});
const handleCombinedMouseMove = (e: React.MouseEvent) => {
handleCanvasMouseMove(e);
handleNodeMouseMove(e);
};
const handleCombinedMouseUp = (e: React.MouseEvent) => {
handleCanvasMouseUp(e);
handleNodeMouseUp();
};
const getPortPosition = (nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') =>
getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType, draggingNodeId, dragDelta, selectedNodeIds);
stopExecutionRef.current = handleStop;
return (
<div style={{
width: '100%',
height: '100%',
flex: 1,
backgroundColor: '#1e1e1e',
display: 'flex',
flexDirection: 'column'
}}>
{showToolbar && (
<EditorToolbar
executionMode={executionMode}
canUndo={canUndo}
canRedo={canRedo}
hasUnsavedChanges={hasUnsavedChanges}
onPlay={handlePlay}
onPause={handlePause}
onStop={handleStop}
onStep={handleStep}
onReset={handleStop}
onUndo={undo}
onRedo={redo}
onResetView={handleResetView}
onSave={onSave}
onOpen={onOpen}
onExport={onExport}
onCopyToClipboard={onCopyToClipboard}
/>
)}
{/* 主内容区:画布 + 黑板面板 */}
<div style={{
flex: 1,
display: 'flex',
overflow: 'hidden',
position: 'relative'
}}>
{/* 画布区域 */}
<div style={{
flex: 1,
position: 'relative',
overflow: 'hidden'
}}>
<BehaviorTreeCanvas
ref={canvasRef}
config={DEFAULT_EDITOR_CONFIG}
onClick={handleCanvasClick}
onContextMenu={handleCanvasContextMenu}
onDoubleClick={handleCanvasDoubleClick}
onMouseMove={handleCombinedMouseMove}
onMouseDown={handleCanvasMouseDown}
onMouseUp={handleCombinedMouseUp}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
>
{/* 连接线层 */}
<ConnectionLayer
connections={connections}
nodes={nodes}
selectedConnection={selectedConnection}
getPortPosition={getPortPosition}
onConnectionClick={(e, fromId, toId) => {
setSelectedConnection({ from: fromId, to: toId });
setSelectedNodeIds([]);
}}
/>
{/* 正在拖拽的连接线预览 */}
{connectingFrom && connectingToPos && (
<svg style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
overflow: 'visible',
zIndex: 150
}}>
{(() => {
// 获取正在连接的端口类型
const fromPortType = canvasRef.current?.getAttribute('data-connecting-from-port-type') || '';
// 根据端口类型判断是从输入还是输出端口开始
let portType: 'input' | 'output' = 'output';
if (fromPortType === 'node-input' || fromPortType === 'property-input') {
portType = 'input';
}
const fromPos = getPortPosition(
connectingFrom,
connectingFromProperty || undefined,
portType
);
if (!fromPos) return null;
const isPropertyConnection = !!connectingFromProperty;
const x1 = fromPos.x;
const y1 = fromPos.y;
const x2 = connectingToPos.x;
const y2 = connectingToPos.y;
// 使用贝塞尔曲线渲染
let pathD: string;
if (isPropertyConnection) {
// 属性连接使用水平贝塞尔曲线
const controlX1 = x1 + (x2 - x1) * 0.5;
const controlX2 = x1 + (x2 - x1) * 0.5;
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
} else {
// 节点连接使用垂直贝塞尔曲线
const controlY = y1 + (y2 - y1) * 0.5;
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
}
return (
<path
d={pathD}
stroke={isPropertyConnection ? '#ab47bc' : '#00bcd4'}
strokeWidth="2.5"
fill="none"
strokeDasharray={isPropertyConnection ? '5,5' : 'none'}
strokeLinecap="round"
/>
);
})()}
</svg>
)}
{/* 节点层 */}
{nodes.map((node: BehaviorTreeNode) => (
<BehaviorTreeNodeComponent
key={node.id}
node={node}
isSelected={selectedNodeIds.includes(node.id)}
isBeingDragged={draggingNodeId === node.id}
dragDelta={dragDelta}
uncommittedNodeIds={uncommittedNodeIds}
blackboardVariables={blackboardVariables}
initialBlackboardVariables={initialBlackboardVariables}
isExecuting={isExecuting}
executionStatus={nodeExecutionStatuses.get(node.id)}
executionOrder={nodeExecutionOrders.get(node.id)}
connections={connections}
nodes={nodes}
executorRef={executorRef}
iconMap={ICON_MAP}
draggingNodeId={draggingNodeId}
onNodeClick={handleNodeClick}
onContextMenu={handleNodeContextMenu}
onNodeMouseDown={handleNodeMouseDown}
onNodeMouseUpForConnection={handleNodeMouseUpForConnection}
onPortMouseDown={handlePortMouseDown}
onPortMouseUp={handlePortMouseUp}
/>
))}
</BehaviorTreeCanvas>
{/* 框选区域 - 在画布外层,这样才能显示在节点上方 */}
{isBoxSelecting && boxSelectStart && boxSelectEnd && canvasRef.current && (() => {
const rect = canvasRef.current.getBoundingClientRect();
const minX = Math.min(boxSelectStart.x, boxSelectEnd.x);
const minY = Math.min(boxSelectStart.y, boxSelectEnd.y);
const maxX = Math.max(boxSelectStart.x, boxSelectEnd.x);
const maxY = Math.max(boxSelectStart.y, boxSelectEnd.y);
return (
<div style={{
position: 'fixed',
left: rect.left + minX * canvasScale + canvasOffset.x,
top: rect.top + minY * canvasScale + canvasOffset.y,
width: (maxX - minX) * canvasScale,
height: (maxY - minY) * canvasScale,
border: '1px dashed #4a90e2',
backgroundColor: 'rgba(74, 144, 226, 0.1)',
pointerEvents: 'none',
zIndex: 9999
}} />
);
})()}
{/* 右键菜单 */}
<NodeContextMenu
visible={contextMenu.contextMenu.visible}
position={contextMenu.contextMenu.position}
nodeId={contextMenu.contextMenu.nodeId}
isBlackboardVariable={contextMenu.contextMenu.nodeId ? nodes.find((n) => n.id === contextMenu.contextMenu.nodeId)?.data.nodeType === 'blackboard-variable' : false}
onReplaceNode={() => {
if (contextMenu.contextMenu.nodeId) {
quickCreateMenu.openQuickCreateMenu(
contextMenu.contextMenu.position,
'replace',
contextMenu.contextMenu.nodeId
);
}
contextMenu.closeContextMenu();
}}
onDeleteNode={() => {
if (contextMenu.contextMenu.nodeId) {
nodeOperations.deleteNode(contextMenu.contextMenu.nodeId);
}
contextMenu.closeContextMenu();
}}
onCreateNode={() => {
quickCreateMenu.openQuickCreateMenu(
contextMenu.contextMenu.position,
'create'
);
contextMenu.closeContextMenu();
}}
/>
{/* 快速创建菜单 */}
<QuickCreateMenu
visible={quickCreateMenu.quickCreateMenu.visible}
position={quickCreateMenu.quickCreateMenu.position}
searchText={quickCreateMenu.quickCreateMenu.searchText}
selectedIndex={quickCreateMenu.quickCreateMenu.selectedIndex}
mode={quickCreateMenu.quickCreateMenu.mode}
iconMap={ICON_MAP}
onSearchChange={(text) => quickCreateMenu.setQuickCreateMenu((prev) => ({ ...prev, searchText: text }))}
onIndexChange={(index) => quickCreateMenu.setQuickCreateMenu((prev) => ({ ...prev, selectedIndex: index }))}
onNodeSelect={(template) => {
if (quickCreateMenu.quickCreateMenu.mode === 'create') {
quickCreateMenu.handleQuickCreateNode(template);
} else {
quickCreateMenu.handleReplaceNode(template);
}
}}
onClose={() => quickCreateMenu.setQuickCreateMenu((prev) => ({ ...prev, visible: false }))}
/>
</div>
{/* 黑板面板(侧边栏) */}
<div style={{
width: blackboardCollapsed ? '48px' : '300px',
flexShrink: 0,
transition: 'width 0.2s ease'
}}>
<BlackboardPanel
variables={blackboardVariables}
initialVariables={initialBlackboardVariables}
globalVariables={globalVariables}
onVariableAdd={handleBlackboardVariableAdd}
onVariableChange={handleBlackboardVariableChange}
onVariableDelete={handleBlackboardVariableDelete}
onVariableRename={handleBlackboardVariableRename}
onGlobalVariableChange={handleGlobalVariableChange}
onGlobalVariableAdd={handleGlobalVariableAdd}
onGlobalVariableDelete={handleGlobalVariableDelete}
isCollapsed={blackboardCollapsed}
onToggleCollapse={() => setBlackboardCollapsed(!blackboardCollapsed)}
/>
</div>
</div>
</div>
);
};
@@ -1,790 +0,0 @@
import React, { useState } from 'react';
import { Clipboard, Edit2, Trash2, ChevronDown, ChevronRight, Globe, GripVertical, ChevronLeft, Plus, Copy } from 'lucide-react';
type SimpleBlackboardType = 'number' | 'string' | 'boolean' | 'object';
interface BlackboardPanelProps {
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: SimpleBlackboardType) => void;
onGlobalVariableDelete?: (key: string) => void;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
}
/**
* 黑板面板组件 - 内嵌在编辑器侧边
* 支持本地变量和全局变量的管理
*/
export const BlackboardPanel: React.FC<BlackboardPanelProps> = ({
variables,
initialVariables,
globalVariables,
onVariableChange,
onVariableAdd,
onVariableDelete,
onVariableRename,
onGlobalVariableChange,
onGlobalVariableAdd,
onGlobalVariableDelete,
isCollapsed = false,
onToggleCollapse
}) => {
const [viewMode, setViewMode] = useState<'local' | 'global'>('local');
const [isAdding, setIsAdding] = useState(false);
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const [newType, setNewType] = useState<SimpleBlackboardType>('string');
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editingNewKey, setEditingNewKey] = useState('');
const [editValue, setEditValue] = useState('');
const [editType, setEditType] = useState<SimpleBlackboardType>('string');
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const isModified = (key: string): boolean => {
if (!initialVariables || viewMode !== 'local') return false;
return JSON.stringify(variables[key]) !== JSON.stringify(initialVariables[key]);
};
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) {
onGlobalVariableAdd(newKey, parsedValue, newType);
} 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): SimpleBlackboardType => {
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] = [];
}
groups[groupName].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);
});
// 复制变量到剪贴板
const handleCopyVariable = (key: string, value: any) => {
const text = `${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`;
navigator.clipboard.writeText(text);
};
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#1e1e1e',
color: '#cccccc',
borderLeft: '1px solid #333',
transition: 'width 0.2s ease'
}}>
{/* 标题栏 */}
<div style={{
padding: '10px 12px',
backgroundColor: '#252525',
borderBottom: '1px solid #333',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{
fontSize: '13px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '6px',
color: '#ccc'
}}>
<Clipboard size={14} />
{!isCollapsed && <span>Blackboard</span>}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
{!isCollapsed && (
<div style={{
display: 'flex',
backgroundColor: '#1e1e1e',
borderRadius: '3px',
overflow: 'hidden'
}}>
<button
onClick={() => setViewMode('local')}
style={{
padding: '3px 8px',
backgroundColor: viewMode === 'local' ? '#007acc' : 'transparent',
border: 'none',
color: viewMode === 'local' ? '#fff' : '#888',
cursor: 'pointer',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
gap: '3px',
transition: 'all 0.15s'
}}
onMouseEnter={(e) => {
if (viewMode !== 'local') {
e.currentTarget.style.backgroundColor = '#2a2a2a';
}
}}
onMouseLeave={(e) => {
if (viewMode !== 'local') {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<Clipboard size={11} />
Local
</button>
<button
onClick={() => setViewMode('global')}
style={{
padding: '3px 8px',
backgroundColor: viewMode === 'global' ? '#007acc' : 'transparent',
border: 'none',
color: viewMode === 'global' ? '#fff' : '#888',
cursor: 'pointer',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
gap: '3px',
transition: 'all 0.15s'
}}
onMouseEnter={(e) => {
if (viewMode !== 'global') {
e.currentTarget.style.backgroundColor = '#2a2a2a';
}
}}
onMouseLeave={(e) => {
if (viewMode !== 'global') {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<Globe size={11} />
Global
</button>
</div>
)}
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
style={{
padding: '4px',
backgroundColor: 'transparent',
border: 'none',
color: '#888',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
borderRadius: '2px',
transition: 'all 0.15s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#3c3c3c';
e.currentTarget.style.color = '#ccc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = '#888';
}}
title={isCollapsed ? 'Expand' : 'Collapse'}
>
<ChevronLeft size={14} style={{
transform: isCollapsed ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s'
}} />
</button>
)}
</div>
</div>
{!isCollapsed && (
<>
{/* 工具栏 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#222',
borderBottom: '1px solid #333',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '8px'
}}>
<div style={{
flex: 1,
fontSize: '10px',
color: '#888'
}}>
{viewMode === 'local' ? '当前行为树的本地变量' : '所有行为树共享的全局变量'}
</div>
<button
onClick={() => setIsAdding(true)}
style={{
padding: '4px 8px',
backgroundColor: '#007acc',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'background-color 0.15s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#005a9e'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#007acc'}
>
<Plus size={12} />
Add
</button>
</div>
{/* 变量列表 */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '10px'
}}>
{variableEntries.length === 0 && !isAdding && (
<div style={{
textAlign: 'center',
color: '#666',
fontSize: '12px',
padding: '20px'
}}>
No variables yet
</div>
)}
{/* 添加新变量表单 */}
{isAdding && (
<div style={{
marginBottom: '10px',
padding: '10px',
backgroundColor: '#2d2d2d',
borderRadius: '4px',
border: '1px solid #3c3c3c'
}}>
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Name</div>
<input
type="text"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="variable.name"
style={{
width: '100%',
padding: '6px',
marginBottom: '8px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '3px',
color: '#9cdcfe',
fontSize: '12px',
fontFamily: 'monospace'
}}
autoFocus
/>
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Type</div>
<select
value={newType}
onChange={(e) => setNewType(e.target.value as SimpleBlackboardType)}
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>
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Value</div>
<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={{
flex: 1,
padding: '6px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px'
}}
>
Create
</button>
<button
onClick={() => {
setIsAdding(false);
setNewKey('');
setNewValue('');
}}
style={{
flex: 1,
padding: '6px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '3px',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px'
}}
>
Cancel
</button>
</div>
</div>
)}
{/* 分组显示变量 */}
{groupNames.map((groupName) => {
const isGroupCollapsed = 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'
}}
>
{isGroupCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
<span style={{
fontSize: '11px',
fontWeight: 'bold',
color: '#888'
}}>
{groupName} ({groupVars.length})
</span>
</div>
)}
{!isGroupCollapsed && 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'
}}
/>
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Type</div>
<select
value={editType}
onChange={(e) => setEditType(e.target.value as SimpleBlackboardType)}
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={{
flex: 1,
padding: '3px 8px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '2px',
color: '#fff',
cursor: 'pointer',
fontSize: '10px'
}}
>
Save
</button>
<button
onClick={() => setEditingKey(null)}
style={{
flex: 1,
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'
}}>
<GripVertical size={10} style={{ opacity: 0.3, flexShrink: 0 }} />
{varName}
<span style={{
color: '#666',
fontWeight: 'normal',
fontSize: '10px'
}}>({type})</span>
{isModified(key) && (
<span style={{
fontSize: '9px',
color: '#ff9800',
fontWeight: 'bold'
}}>*</span>
)}
</div>
<div style={{
fontSize: '10px',
color: '#888',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontFamily: 'monospace'
}}>
{truncatedValue}
</div>
</div>
<div style={{ display: 'flex', gap: '2px', flexShrink: 0 }}>
<button
onClick={() => handleCopyVariable(key, value)}
style={{
padding: '4px',
backgroundColor: 'transparent',
border: 'none',
color: '#888',
cursor: 'pointer',
borderRadius: '2px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#3c3c3c';
e.currentTarget.style.color = '#ccc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = '#888';
}}
title="Copy"
>
<Copy size={11} />
</button>
<button
onClick={() => handleStartEdit(key, value)}
style={{
padding: '4px',
backgroundColor: 'transparent',
border: 'none',
color: '#888',
cursor: 'pointer',
borderRadius: '2px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#3c3c3c';
e.currentTarget.style.color = '#ccc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = '#888';
}}
title="Edit"
>
<Edit2 size={11} />
</button>
<button
onClick={() => currentOnDelete && currentOnDelete(key)}
style={{
padding: '4px',
backgroundColor: 'transparent',
border: 'none',
color: '#888',
cursor: 'pointer',
borderRadius: '2px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#5a1a1a';
e.currentTarget.style.color = '#f48771';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = '#888';
}}
title="Delete"
>
<Trash2 size={11} />
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
{/* 底部信息栏 */}
<div style={{
padding: '8px 12px',
borderTop: '1px solid #333',
fontSize: '11px',
color: '#666',
backgroundColor: '#252525'
}}>
{viewMode === 'local' ? 'Local' : 'Global'}: {variableEntries.length} variable{variableEntries.length !== 1 ? 's' : ''}
</div>
</>
)}
{/* 滚动条样式 */}
<style>{`
.blackboard-scrollable::-webkit-scrollbar {
width: 8px;
}
.blackboard-scrollable::-webkit-scrollbar-track {
background: #1e1e1e;
}
.blackboard-scrollable::-webkit-scrollbar-thumb {
background: #3c3c3c;
border-radius: 4px;
}
.blackboard-scrollable::-webkit-scrollbar-thumb:hover {
background: #4c4c4c;
}
`}</style>
</div>
);
};
@@ -1,216 +0,0 @@
import React, { useRef, useCallback, forwardRef, useState, useEffect } from 'react';
import { useCanvasInteraction } from '../../hooks/useCanvasInteraction';
import { EditorConfig } from '../../types';
import { GridBackground } from './GridBackground';
/**
* 画布组件属性
*/
interface BehaviorTreeCanvasProps {
/**
* 编辑器配置
*/
config: EditorConfig;
/**
* 子组件
*/
children: React.ReactNode;
/**
* 画布点击事件
*/
onClick?: (e: React.MouseEvent) => void;
/**
* 画布双击事件
*/
onDoubleClick?: (e: React.MouseEvent) => void;
/**
* 画布右键事件
*/
onContextMenu?: (e: React.MouseEvent) => void;
/**
* 鼠标移动事件
*/
onMouseMove?: (e: React.MouseEvent) => void;
/**
* 鼠标按下事件
*/
onMouseDown?: (e: React.MouseEvent) => void;
/**
* 鼠标抬起事件
*/
onMouseUp?: (e: React.MouseEvent) => void;
/**
* 鼠标离开事件
*/
onMouseLeave?: (e: React.MouseEvent) => void;
/**
* 拖放事件
*/
onDrop?: (e: React.DragEvent) => void;
/**
* 拖动悬停事件
*/
onDragOver?: (e: React.DragEvent) => void;
/**
* 拖动进入事件
*/
onDragEnter?: (e: React.DragEvent) => void;
/**
* 拖动离开事件
*/
onDragLeave?: (e: React.DragEvent) => void;
}
/**
* 行为树画布组件
* 负责画布的渲染、缩放、平移等基础功能
*/
export const BehaviorTreeCanvas = forwardRef<HTMLDivElement, BehaviorTreeCanvasProps>(({
config,
children,
onClick,
onDoubleClick,
onContextMenu,
onMouseMove,
onMouseDown,
onMouseUp,
onMouseLeave,
onDrop,
onDragOver,
onDragEnter,
onDragLeave
}, forwardedRef) => {
const internalRef = useRef<HTMLDivElement>(null);
const canvasRef = (forwardedRef as React.RefObject<HTMLDivElement>) || internalRef;
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const {
canvasOffset,
canvasScale,
isPanning,
handleWheel,
startPanning,
updatePanning,
stopPanning
} = useCanvasInteraction();
// 监听画布尺寸变化
useEffect(() => {
const updateSize = () => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
setCanvasSize({
width: rect.width,
height: rect.height
});
}
};
updateSize();
const resizeObserver = new ResizeObserver(updateSize);
if (canvasRef.current) {
resizeObserver.observe(canvasRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [canvasRef]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button === 1 || (e.button === 0 && e.altKey)) {
e.preventDefault();
startPanning(e.clientX, e.clientY);
}
onMouseDown?.(e);
}, [startPanning, onMouseDown]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isPanning) {
updatePanning(e.clientX, e.clientY);
}
onMouseMove?.(e);
}, [isPanning, updatePanning, onMouseMove]);
const handleMouseUp = useCallback((e: React.MouseEvent) => {
if (isPanning) {
stopPanning();
}
onMouseUp?.(e);
}, [isPanning, stopPanning, onMouseUp]);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
onContextMenu?.(e);
}, [onContextMenu]);
return (
<div
ref={canvasRef}
className="behavior-tree-canvas"
style={{
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
cursor: isPanning ? 'grabbing' : 'default',
backgroundColor: '#1a1a1a'
}}
onWheel={handleWheel}
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={handleContextMenu}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={onMouseLeave}
onDrop={onDrop}
onDragOver={onDragOver}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
>
{/* 网格背景 */}
{config.showGrid && canvasSize.width > 0 && canvasSize.height > 0 && (
<GridBackground
canvasOffset={canvasOffset}
canvasScale={canvasScale}
width={canvasSize.width}
height={canvasSize.height}
/>
)}
{/* 内容容器(应用变换) */}
<div
className="canvas-content"
style={{
position: 'absolute',
transformOrigin: '0 0',
transform: `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasScale})`,
width: '100%',
height: '100%'
}}
>
{children}
</div>
</div>
);
});
BehaviorTreeCanvas.displayName = 'BehaviorTreeCanvas';
@@ -1,127 +0,0 @@
import React, { useMemo } from 'react';
interface GridBackgroundProps {
canvasOffset: { x: number; y: number };
canvasScale: number;
width: number;
height: number;
}
/**
* 编辑器网格背景
*/
export const GridBackground: React.FC<GridBackgroundProps> = ({
canvasOffset,
canvasScale,
width,
height
}) => {
const gridPattern = useMemo(() => {
// 基础网格大小(未缩放)
const baseGridSize = 20;
const baseDotSize = 1.5;
// 根据缩放级别调整网格大小
const gridSize = baseGridSize * canvasScale;
const dotSize = Math.max(baseDotSize, baseDotSize * canvasScale);
// 计算网格偏移(考虑画布偏移)
const offsetX = canvasOffset.x % gridSize;
const offsetY = canvasOffset.y % gridSize;
// 计算需要渲染的网格点数量
const cols = Math.ceil(width / gridSize) + 2;
const rows = Math.ceil(height / gridSize) + 2;
const dots: Array<{ x: number; y: number }> = [];
for (let i = -1; i < rows; i++) {
for (let j = -1; j < cols; j++) {
dots.push({
x: j * gridSize + offsetX,
y: i * gridSize + offsetY
});
}
}
return { dots, dotSize, gridSize };
}, [canvasOffset, canvasScale, width, height]);
// 大网格(每5个小格一个大格)
const majorGridPattern = useMemo(() => {
const majorGridSize = gridPattern.gridSize * 5;
const offsetX = canvasOffset.x % majorGridSize;
const offsetY = canvasOffset.y % majorGridSize;
const lines: Array<{ type: 'h' | 'v'; pos: number }> = [];
// 垂直线
const vCols = Math.ceil(width / majorGridSize) + 2;
for (let i = -1; i < vCols; i++) {
lines.push({
type: 'v',
pos: i * majorGridSize + offsetX
});
}
// 水平线
const hRows = Math.ceil(height / majorGridSize) + 2;
for (let i = -1; i < hRows; i++) {
lines.push({
type: 'h',
pos: i * majorGridSize + offsetY
});
}
return lines;
}, [canvasOffset, canvasScale, width, height, gridPattern.gridSize]);
return (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none'
}}
>
{/* 主网格线 */}
{majorGridPattern.map((line, idx) => (
line.type === 'v' ? (
<line
key={`v-${idx}`}
x1={line.pos}
y1={0}
x2={line.pos}
y2={height}
stroke="rgba(255, 255, 255, 0.03)"
strokeWidth="1"
/>
) : (
<line
key={`h-${idx}`}
x1={0}
y1={line.pos}
x2={width}
y2={line.pos}
stroke="rgba(255, 255, 255, 0.03)"
strokeWidth="1"
/>
)
))}
{/* 点阵网格 */}
{gridPattern.dots.map((dot, idx) => (
<circle
key={idx}
cx={dot.x}
cy={dot.y}
r={gridPattern.dotSize}
fill="rgba(255, 255, 255, 0.15)"
/>
))}
</svg>
);
};
@@ -1 +0,0 @@
export { BehaviorTreeCanvas } from './BehaviorTreeCanvas';
@@ -1,181 +0,0 @@
import React, { useState, useRef, useEffect, ReactNode } from 'react';
import { GripVertical } from 'lucide-react';
interface DraggablePanelProps {
title: string | ReactNode;
icon?: ReactNode;
isVisible: boolean;
onClose: () => void;
width?: number;
maxHeight?: number;
initialPosition?: { x: number; y: number };
headerActions?: ReactNode;
children: ReactNode;
footer?: ReactNode | false;
}
/**
* 可拖动面板通用组件
* 提供标题栏拖动、关闭按钮等基础功能
*/
export const DraggablePanel: React.FC<DraggablePanelProps> = ({
title,
icon,
isVisible,
onClose,
width = 400,
maxHeight = 600,
initialPosition = { x: 20, y: 100 },
headerActions,
children,
footer
}) => {
const [position, setPosition] = useState(initialPosition);
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isVisible) return;
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
// 限制面板在视口内
const maxX = window.innerWidth - width;
const maxY = window.innerHeight - 100;
setPosition({
x: Math.max(0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
});
};
const handleMouseUp = () => {
setIsDragging(false);
};
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragOffset, width]);
const handleMouseDown = (e: React.MouseEvent) => {
if (!panelRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top
});
setIsDragging(true);
};
if (!isVisible) return null;
return (
<div
ref={panelRef}
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: `${width}px`,
maxHeight: `${maxHeight}px`,
backgroundColor: '#1e1e1e',
border: '1px solid #3f3f3f',
borderRadius: '8px',
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
userSelect: isDragging ? 'none' : 'auto'
}}
>
{/* 可拖动标题栏 */}
<div
onMouseDown={handleMouseDown}
style={{
padding: '12px 16px',
borderBottom: '1px solid #3f3f3f',
backgroundColor: '#252525',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
cursor: isDragging ? 'grabbing' : 'grab',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<GripVertical size={14} color="#666" style={{ flexShrink: 0 }} />
{icon}
{typeof title === 'string' ? (
<span style={{
fontSize: '14px',
fontWeight: 'bold',
color: '#fff'
}}>
{title}
</span>
) : (
title
)}
</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{headerActions}
<button
onClick={onClose}
onMouseDown={(e) => e.stopPropagation()}
style={{
padding: '4px 8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#ccc',
fontSize: '11px',
cursor: 'pointer'
}}
>
</button>
</div>
</div>
{/* 内容区域 */}
<div style={{
flex: 1,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column'
}}>
{children}
</div>
{/* 页脚 */}
{footer && (
<div style={{
borderTop: '1px solid #3f3f3f',
backgroundColor: '#252525',
borderBottomLeftRadius: '8px',
borderBottomRightRadius: '8px'
}}>
{footer}
</div>
)}
</div>
);
};
@@ -1,83 +0,0 @@
import React, { useMemo } from 'react';
import { ConnectionRenderer } from './ConnectionRenderer';
import { ConnectionViewData } from '../../types';
import { Node } from '../../domain/models/Node';
import { Connection } from '../../domain/models/Connection';
interface ConnectionLayerProps {
connections: Connection[];
nodes: Node[];
selectedConnection?: { from: string; to: string } | null;
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
onConnectionClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
onConnectionContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
}
export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
connections,
nodes,
selectedConnection,
getPortPosition,
onConnectionClick,
onConnectionContextMenu
}) => {
const nodeMap = useMemo(() => {
return new Map(nodes.map((node) => [node.id, node]));
}, [nodes]);
const connectionViewData = useMemo(() => {
return connections
.map((connection) => {
const fromNode = nodeMap.get(connection.from);
const toNode = nodeMap.get(connection.to);
if (!fromNode || !toNode) {
return null;
}
return { connection, fromNode, toNode };
})
.filter((item): item is NonNullable<typeof item> => item !== null);
}, [connections, nodeMap]);
const isConnectionSelected = (connection: { from: string; to: string }) => {
return selectedConnection?.from === connection.from &&
selectedConnection?.to === connection.to;
};
if (connectionViewData.length === 0) {
return null;
}
return (
<svg
className="connection-layer"
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
overflow: 'visible',
zIndex: 0
}}
>
<g style={{ pointerEvents: 'auto' }}>
{connectionViewData.map(({ connection, fromNode, toNode }) => {
const viewData: ConnectionViewData = {
connection,
isSelected: isConnectionSelected(connection)
};
return (
<ConnectionRenderer
key={`${connection.from}-${connection.to}`}
connectionData={viewData}
fromNode={fromNode}
toNode={toNode}
getPortPosition={getPortPosition}
onClick={onConnectionClick}
onContextMenu={onConnectionContextMenu}
/>
);
})}
</g>
</svg>
);
};
@@ -1,164 +0,0 @@
import React, { useMemo } from 'react';
import { ConnectionViewData } from '../../types';
import { Node } from '../../domain/models/Node';
interface ConnectionRendererProps {
connectionData: ConnectionViewData;
fromNode: Node;
toNode: Node;
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
onClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
onContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
}
const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
connectionData,
fromNode,
toNode,
getPortPosition,
onClick,
onContextMenu
}) => {
const { connection, isSelected } = connectionData;
const pathData = useMemo(() => {
let fromPos, toPos;
if (connection.connectionType === 'property') {
// 属性连接:使用 fromProperty 和 toProperty
fromPos = getPortPosition(connection.from, connection.fromProperty);
toPos = getPortPosition(connection.to, connection.toProperty);
} else {
// 节点连接:使用输出和输入端口
fromPos = getPortPosition(connection.from, undefined, 'output');
toPos = getPortPosition(connection.to, undefined, 'input');
}
if (!fromPos || !toPos) {
return null;
}
const x1 = fromPos.x;
const y1 = fromPos.y;
const x2 = toPos.x;
const y2 = toPos.y;
let pathD: string;
if (connection.connectionType === 'property') {
const controlX1 = x1 + (x2 - x1) * 0.5;
const controlX2 = x1 + (x2 - x1) * 0.5;
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
} else {
const controlY = y1 + (y2 - y1) * 0.5;
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
}
return {
path: pathD,
midX: (x1 + x2) / 2,
midY: (y1 + y2) / 2
};
}, [connection, fromNode, toNode, getPortPosition]);
const isPropertyConnection = connection.connectionType === 'property';
const color = isPropertyConnection ? '#ab47bc' : '#00bcd4';
const glowColor = isPropertyConnection ? 'rgba(171, 71, 188, 0.6)' : 'rgba(0, 188, 212, 0.6)';
const strokeColor = isSelected ? '#FFD700' : color;
const strokeWidth = isSelected ? 3.5 : 2.5;
const gradientId = `gradient-${connection.from}-${connection.to}`;
if (!pathData) {
return null;
}
const pathD = pathData.path;
const endPosMatch = pathD.match(/C [0-9.-]+ [0-9.-]+, [0-9.-]+ [0-9.-]+, ([0-9.-]+) ([0-9.-]+)/);
const endX = endPosMatch ? parseFloat(endPosMatch[1]) : 0;
const endY = endPosMatch ? parseFloat(endPosMatch[2]) : 0;
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e, connection.from, connection.to);
};
const handleContextMenu = (e: React.MouseEvent) => {
e.stopPropagation();
onContextMenu?.(e, connection.from, connection.to);
};
return (
<g
className="connection"
onClick={handleClick}
onContextMenu={handleContextMenu}
style={{ cursor: 'pointer' }}
data-connection-from={connection.from}
data-connection-to={connection.to}
>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.8" />
<stop offset="50%" stopColor={strokeColor} stopOpacity="1" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.8" />
</linearGradient>
</defs>
<path
d={pathData.path}
fill="none"
stroke="transparent"
strokeWidth={24}
/>
<path
d={pathData.path}
fill="none"
stroke={glowColor}
strokeWidth={strokeWidth + 2}
strokeLinecap="round"
opacity={isSelected ? 0.4 : 0.2}
/>
<path
d={pathData.path}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<circle
cx={endX}
cy={endY}
r="5"
fill={strokeColor}
stroke="rgba(0, 0, 0, 0.3)"
strokeWidth="1"
/>
{isSelected && (
<>
<circle
cx={pathData.midX}
cy={pathData.midY}
r="8"
fill={strokeColor}
opacity="0.3"
/>
<circle
cx={pathData.midX}
cy={pathData.midY}
r="5"
fill={strokeColor}
stroke="rgba(0, 0, 0, 0.5)"
strokeWidth="2"
/>
</>
)}
</g>
);
};
export const ConnectionRenderer = ConnectionRendererComponent;
@@ -1,2 +0,0 @@
export { ConnectionRenderer } from './ConnectionRenderer';
export { ConnectionLayer } from './ConnectionLayer';
@@ -1,94 +0,0 @@
import React from 'react';
import { Trash2, Replace, Plus } from 'lucide-react';
interface NodeContextMenuProps {
visible: boolean;
position: { x: number; y: number };
nodeId: string | null;
isBlackboardVariable?: boolean;
onReplaceNode?: () => void;
onDeleteNode?: () => void;
onCreateNode?: () => void;
}
export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
visible,
position,
nodeId,
isBlackboardVariable = false,
onReplaceNode,
onDeleteNode,
onCreateNode
}) => {
if (!visible) return null;
const menuItemStyle = {
padding: '8px 16px',
cursor: 'pointer',
color: '#cccccc',
fontSize: '13px',
transition: 'background-color 0.15s',
display: 'flex',
alignItems: 'center',
gap: '8px'
};
return (
<div
style={{
position: 'fixed',
left: position.x,
top: position.y,
backgroundColor: '#2d2d30',
border: '1px solid #454545',
borderRadius: '4px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
zIndex: 10000,
minWidth: '150px',
padding: '4px 0'
}}
onClick={(e) => e.stopPropagation()}
>
{nodeId ? (
<>
{onReplaceNode && (
<div
onClick={onReplaceNode}
style={menuItemStyle}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Replace size={14} />
</div>
)}
{onDeleteNode && (
<div
onClick={onDeleteNode}
style={{ ...menuItemStyle, color: '#f48771' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#5a1a1a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Trash2 size={14} />
</div>
)}
</>
) : (
<>
{onCreateNode && (
<div
onClick={onCreateNode}
style={menuItemStyle}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Plus size={14} />
</div>
)}
</>
)}
</div>
);
};
@@ -1,348 +0,0 @@
import React, { useRef, useEffect, useState, useMemo } from 'react';
import { NodeTemplate } from '@esengine/behavior-tree';
import { Search, X, LucideIcon, ChevronDown, ChevronRight } from 'lucide-react';
import { NodeFactory } from '../../infrastructure/factories/NodeFactory';
interface QuickCreateMenuProps {
visible: boolean;
position: { x: number; y: number };
searchText: string;
selectedIndex: number;
mode: 'create' | 'replace';
iconMap: Record<string, LucideIcon>;
onSearchChange: (text: string) => void;
onIndexChange: (index: number) => void;
onNodeSelect: (template: NodeTemplate) => void;
onClose: () => void;
}
interface CategoryGroup {
category: string;
templates: NodeTemplate[];
isExpanded: boolean;
}
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
visible,
position,
searchText,
selectedIndex,
iconMap,
onSearchChange,
onIndexChange,
onNodeSelect,
onClose
}) => {
const selectedNodeRef = useRef<HTMLDivElement>(null);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const [shouldAutoScroll, setShouldAutoScroll] = useState(false);
const nodeFactory = useMemo(() => new NodeFactory(), []);
const allTemplates = useMemo(() => nodeFactory.getAllTemplates(), [nodeFactory]);
const searchTextLower = searchText.toLowerCase();
const filteredTemplates = searchTextLower
? allTemplates.filter((t: NodeTemplate) => {
const className = t.className || '';
return t.displayName.toLowerCase().includes(searchTextLower) ||
t.description.toLowerCase().includes(searchTextLower) ||
t.category.toLowerCase().includes(searchTextLower) ||
className.toLowerCase().includes(searchTextLower);
})
: allTemplates;
const categoryGroups: CategoryGroup[] = React.useMemo(() => {
const groups = new Map<string, NodeTemplate[]>();
filteredTemplates.forEach((template: NodeTemplate) => {
const category = template.category || '未分类';
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push(template);
});
return Array.from(groups.entries()).map(([category, templates]) => ({
category,
templates,
isExpanded: searchTextLower ? true : expandedCategories.has(category)
})).sort((a, b) => a.category.localeCompare(b.category));
}, [filteredTemplates, expandedCategories, searchTextLower]);
const flattenedTemplates = React.useMemo(() => {
return categoryGroups.flatMap((group) =>
group.isExpanded ? group.templates : []
);
}, [categoryGroups]);
const toggleCategory = (category: string) => {
setExpandedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
return newSet;
});
};
useEffect(() => {
if (allTemplates.length > 0 && expandedCategories.size === 0) {
const categories = new Set(allTemplates.map((t) => t.category || '未分类'));
setExpandedCategories(categories);
}
}, [allTemplates, expandedCategories.size]);
useEffect(() => {
if (shouldAutoScroll && selectedNodeRef.current) {
selectedNodeRef.current.scrollIntoView({
block: 'nearest',
behavior: 'smooth'
});
setShouldAutoScroll(false);
}
}, [selectedIndex, shouldAutoScroll]);
if (!visible) return null;
let globalIndex = -1;
return (
<>
<style>{`
.quick-create-menu-list::-webkit-scrollbar {
width: 8px;
}
.quick-create-menu-list::-webkit-scrollbar-track {
background: #1e1e1e;
}
.quick-create-menu-list::-webkit-scrollbar-thumb {
background: #3c3c3c;
border-radius: 4px;
}
.quick-create-menu-list::-webkit-scrollbar-thumb:hover {
background: #4c4c4c;
}
.category-header {
transition: background-color 0.15s;
}
.category-header:hover {
background-color: #3c3c3c;
}
`}</style>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '300px',
maxHeight: '500px',
backgroundColor: '#2d2d2d',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* 搜索框 */}
<div style={{
padding: '12px',
borderBottom: '1px solid #3c3c3c',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<Search size={16} style={{ color: '#999', flexShrink: 0 }} />
<input
type="text"
placeholder="搜索节点..."
autoFocus
value={searchText}
onChange={(e) => {
onSearchChange(e.target.value);
onIndexChange(0);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setShouldAutoScroll(true);
onIndexChange(Math.min(selectedIndex + 1, flattenedTemplates.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setShouldAutoScroll(true);
onIndexChange(Math.max(selectedIndex - 1, 0));
} else if (e.key === 'Enter' && flattenedTemplates.length > 0) {
e.preventDefault();
const selectedTemplate = flattenedTemplates[selectedIndex];
if (selectedTemplate) {
onNodeSelect(selectedTemplate);
}
}
}}
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: '#ccc',
fontSize: '14px',
padding: '4px'
}}
/>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: '#999',
cursor: 'pointer',
padding: '4px',
display: 'flex',
alignItems: 'center'
}}
>
<X size={16} />
</button>
</div>
{/* 节点列表 */}
<div
className="quick-create-menu-list"
style={{
flex: 1,
overflowY: 'auto',
padding: '4px'
}}
>
{categoryGroups.length === 0 ? (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#666',
fontSize: '12px'
}}>
</div>
) : (
categoryGroups.map((group) => {
return (
<div key={group.category} style={{ marginBottom: '4px' }}>
<div
className="category-header"
onClick={() => toggleCategory(group.category)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
backgroundColor: '#1e1e1e',
borderRadius: '3px',
cursor: 'pointer',
userSelect: 'none'
}}
>
{group.isExpanded ? (
<ChevronDown size={14} style={{ color: '#999', flexShrink: 0 }} />
) : (
<ChevronRight size={14} style={{ color: '#999', flexShrink: 0 }} />
)}
<span style={{
color: '#aaa',
fontSize: '12px',
fontWeight: '600',
flex: 1
}}>
{group.category}
</span>
<span style={{
color: '#666',
fontSize: '11px',
backgroundColor: '#2d2d2d',
padding: '2px 6px',
borderRadius: '10px'
}}>
{group.templates.length}
</span>
</div>
{group.isExpanded && (
<div style={{ paddingLeft: '8px', paddingTop: '4px' }}>
{group.templates.map((template: NodeTemplate) => {
globalIndex++;
const IconComponent = template.icon ? iconMap[template.icon] : null;
const className = template.className || '';
const isSelected = globalIndex === selectedIndex;
return (
<div
key={template.className || template.displayName}
ref={isSelected ? selectedNodeRef : null}
onClick={() => onNodeSelect(template)}
onMouseEnter={() => onIndexChange(globalIndex)}
style={{
padding: '8px 12px',
marginBottom: '4px',
backgroundColor: isSelected ? '#0e639c' : '#1e1e1e',
borderLeft: `3px solid ${template.color || '#666'}`,
borderRadius: '3px',
cursor: 'pointer',
transition: 'all 0.15s',
transform: isSelected ? 'translateX(2px)' : 'translateX(0)'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px'
}}>
{IconComponent && (
<IconComponent size={14} style={{ color: template.color || '#999', flexShrink: 0 }} />
)}
<div style={{ flex: 1 }}>
<div style={{
color: '#ccc',
fontSize: '13px',
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: '11px',
color: '#999',
lineHeight: '1.4'
}}>
{template.description}
</div>
</div>
);
})}
</div>
)}
</div>
);
})
)}
</div>
</div>
</>
);
};
@@ -1,407 +0,0 @@
import React from 'react';
import {
TreePine,
Database,
AlertTriangle,
AlertCircle,
LucideIcon
} from 'lucide-react';
import { PropertyDefinition } from '@esengine/behavior-tree';
import { Node as BehaviorTreeNodeType } from '../../domain/models/Node';
import { Connection } from '../../domain/models/Connection';
import { ROOT_NODE_ID } from '../../domain/constants/RootNode';
import type { NodeExecutionStatus } from '../../stores';
import { BehaviorTreeExecutor } from '../../utils/BehaviorTreeExecutor';
import { BlackboardValue } from '../../domain/models/Blackboard';
type BlackboardVariables = Record<string, BlackboardValue>;
interface BehaviorTreeNodeProps {
node: BehaviorTreeNodeType;
isSelected: boolean;
isBeingDragged: boolean;
dragDelta: { dx: number; dy: number };
uncommittedNodeIds: Set<string>;
blackboardVariables: BlackboardVariables;
initialBlackboardVariables: BlackboardVariables;
isExecuting: boolean;
executionStatus?: NodeExecutionStatus;
executionOrder?: number;
connections: Connection[];
nodes: BehaviorTreeNodeType[];
executorRef: React.RefObject<BehaviorTreeExecutor | null>;
iconMap: Record<string, LucideIcon>;
draggingNodeId: string | null;
onNodeClick: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
onContextMenu: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
onNodeMouseDown: (e: React.MouseEvent, nodeId: string) => void;
onNodeMouseUpForConnection: (e: React.MouseEvent, nodeId: string) => void;
onPortMouseDown: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
onPortMouseUp: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
}
const BehaviorTreeNodeComponent: React.FC<BehaviorTreeNodeProps> = ({
node,
isSelected,
isBeingDragged,
dragDelta,
uncommittedNodeIds,
blackboardVariables,
initialBlackboardVariables,
isExecuting,
executionStatus,
executionOrder,
connections,
nodes,
executorRef,
iconMap,
draggingNodeId,
onNodeClick,
onContextMenu,
onNodeMouseDown,
onNodeMouseUpForConnection,
onPortMouseDown,
onPortMouseUp
}) => {
const isRoot = node.id === ROOT_NODE_ID;
const isBlackboardVariable = node.data.nodeType === 'blackboard-variable';
const posX = node.position.x + (isBeingDragged ? dragDelta.dx : 0);
const posY = node.position.y + (isBeingDragged ? dragDelta.dy : 0);
const isUncommitted = uncommittedNodeIds.has(node.id);
const nodeClasses = [
'bt-node',
isSelected && 'selected',
isRoot && 'root',
isUncommitted && 'uncommitted',
executionStatus && executionStatus !== 'idle' && executionStatus
].filter(Boolean).join(' ');
return (
<div
key={node.id}
data-node-id={node.id}
className={nodeClasses}
onClick={(e) => onNodeClick(e, node)}
onContextMenu={(e) => onContextMenu(e, node)}
onMouseDown={(e) => onNodeMouseDown(e, node.id)}
onMouseUp={(e) => onNodeMouseUpForConnection(e, node.id)}
onDragStart={(e) => e.preventDefault()}
style={{
left: posX,
top: posY,
transform: 'translate(-50%, -50%)',
cursor: isRoot ? 'default' : (draggingNodeId === node.id ? 'grabbing' : 'grab'),
transition: draggingNodeId === node.id ? 'none' : 'all 0.2s',
zIndex: isRoot ? 50 : (draggingNodeId === node.id ? 100 : (isSelected ? 10 : 1))
}}
>
{/* 执行顺序角标 - 使用绝对定位,不影响节点布局 */}
{executionOrder !== undefined && (
<div
className="bt-node-execution-badge"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
backgroundColor: '#2196f3',
color: '#fff',
borderRadius: '50%',
minWidth: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 'bold',
padding: '0 6px',
boxShadow: '0 2px 8px rgba(33, 150, 243, 0.5)',
border: '2px solid #1a1a1d',
zIndex: 10,
pointerEvents: 'none'
}}
title={`执行顺序: ${executionOrder}`}
>
{executionOrder}
</div>
)}
{isBlackboardVariable ? (
(() => {
const varName = node.data.variableName as string;
const currentValue = blackboardVariables[varName];
const initialValue = initialBlackboardVariables[varName];
const isModified = isExecuting && JSON.stringify(currentValue) !== JSON.stringify(initialValue);
return (
<>
<div className="bt-node-header blackboard">
<Database size={16} className="bt-node-header-icon" />
<div className="bt-node-header-title">
{varName || 'Variable'}
</div>
{isModified && (
<span style={{
fontSize: '9px',
color: '#ffbb00',
backgroundColor: 'rgba(255, 187, 0, 0.2)',
padding: '2px 4px',
borderRadius: '2px',
marginLeft: '4px'
}}>
</span>
)}
</div>
<div className="bt-node-body">
<div
className="bt-node-blackboard-value"
style={{
backgroundColor: isModified ? 'rgba(255, 187, 0, 0.15)' : 'transparent',
border: isModified ? '1px solid rgba(255, 187, 0, 0.3)' : 'none',
borderRadius: '2px',
padding: '2px 4px'
}}
title={isModified ? `初始值: ${JSON.stringify(initialValue)}\n当前值: ${JSON.stringify(currentValue)}` : undefined}
>
{JSON.stringify(currentValue)}
</div>
</div>
<div
data-port="true"
data-node-id={node.id}
data-property="__value__"
data-port-type="variable-output"
onMouseDown={(e) => onPortMouseDown(e, node.id, '__value__')}
onMouseUp={(e) => onPortMouseUp(e, node.id, '__value__')}
className="bt-node-port bt-node-port-variable-output"
title="Output"
/>
</>
);
})()
) : (
<>
<div className={`bt-node-header ${isRoot ? 'root' : (node.template.type || 'action')}`}>
{isRoot ? (
<TreePine size={16} className="bt-node-header-icon" />
) : (
node.template.icon && (() => {
const IconComponent = iconMap[node.template.icon];
return IconComponent ? (
<IconComponent size={16} className="bt-node-header-icon" />
) : (
<span className="bt-node-header-icon">{node.template.icon}</span>
);
})()
)}
<div className="bt-node-header-title">
<div>{isRoot ? 'ROOT' : node.template.displayName}</div>
<div className="bt-node-id" title={node.id}>
#{node.id}
</div>
</div>
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
<div
className="bt-node-missing-executor-warning"
style={{
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
cursor: 'help',
pointerEvents: 'auto',
position: 'relative'
}}
onClick={(e) => e.stopPropagation()}
>
<AlertCircle
size={14}
style={{
color: '#f44336',
flexShrink: 0
}}
/>
<div className="bt-node-missing-executor-tooltip">
"{node.template.className}"
</div>
</div>
)}
{isUncommitted && (
<div
className="bt-node-uncommitted-warning"
style={{
marginLeft: (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) ? '4px' : 'auto',
display: 'flex',
alignItems: 'center',
cursor: 'help',
pointerEvents: 'auto',
position: 'relative'
}}
onClick={(e) => e.stopPropagation()}
>
<AlertTriangle
size={14}
style={{
color: '#ff5722',
flexShrink: 0
}}
/>
<div className="bt-node-uncommitted-tooltip">
</div>
</div>
)}
{!isRoot && !isUncommitted && node.template.type === 'composite' &&
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) &&
!nodes.some((n) =>
connections.some((c) => c.from === node.id && c.to === n.id)
) && (
<div
className="bt-node-empty-warning-container"
style={{
marginLeft: ((!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) || isUncommitted) ? '4px' : 'auto',
display: 'flex',
alignItems: 'center',
cursor: 'help',
pointerEvents: 'auto',
position: 'relative'
}}
onClick={(e) => e.stopPropagation()}
>
<AlertTriangle
size={14}
style={{
color: '#ff9800',
flexShrink: 0
}}
/>
<div className="bt-node-empty-warning-tooltip">
</div>
</div>
)}
</div>
<div className="bt-node-body">
{!isRoot && (
<div className="bt-node-category">
{node.template.category}
</div>
)}
{node.template.properties.length > 0 && (
<div className="bt-node-properties">
{node.template.properties.map((prop: PropertyDefinition, idx: number) => {
const hasConnection = connections.some(
(conn: Connection) => conn.toProperty === prop.name && conn.to === node.id
);
const propValue = node.data[prop.name];
return (
<div key={idx} className="bt-node-property">
<div
data-port="true"
data-node-id={node.id}
data-property={prop.name}
data-port-type="property-input"
onMouseDown={(e) => onPortMouseDown(e, node.id, prop.name)}
onMouseUp={(e) => onPortMouseUp(e, node.id, prop.name)}
className={`bt-node-port bt-node-port-property ${hasConnection ? 'connected' : ''}`}
title={prop.description || prop.name}
/>
<span
className="bt-node-property-label"
title={prop.description}
>
{prop.name}:
</span>
{propValue !== undefined && (
<span className="bt-node-property-value">
{String(propValue)}
</span>
)}
</div>
);
})}
</div>
)}
</div>
{!isRoot && (
<div
data-port="true"
data-node-id={node.id}
data-port-type="node-input"
onMouseDown={(e) => onPortMouseDown(e, node.id)}
onMouseUp={(e) => onPortMouseUp(e, node.id)}
className="bt-node-port bt-node-port-input"
title="Input"
/>
)}
{(isRoot || node.template.type === 'composite' || node.template.type === 'decorator') &&
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) && (
<div
data-port="true"
data-node-id={node.id}
data-port-type="node-output"
onMouseDown={(e) => onPortMouseDown(e, node.id)}
onMouseUp={(e) => onPortMouseUp(e, node.id)}
className="bt-node-port bt-node-port-output"
title="Output"
/>
)}
</>
)}
</div>
);
};
/**
* 使用 React.memo 优化节点组件性能
* 只在关键 props 变化时重新渲染
*/
export const BehaviorTreeNode = React.memo(BehaviorTreeNodeComponent, (prevProps, nextProps) => {
// 如果节点本身变化,需要重新渲染
if (prevProps.node.id !== nextProps.node.id ||
prevProps.node.position.x !== nextProps.node.position.x ||
prevProps.node.position.y !== nextProps.node.position.y ||
prevProps.node.template.className !== nextProps.node.template.className) {
return false;
}
if (prevProps.isSelected !== nextProps.isSelected ||
prevProps.isBeingDragged !== nextProps.isBeingDragged ||
prevProps.executionStatus !== nextProps.executionStatus ||
prevProps.executionOrder !== nextProps.executionOrder ||
prevProps.draggingNodeId !== nextProps.draggingNodeId) {
return false;
}
// 如果正在被拖拽,且 dragDelta 变化,需要重新渲染
if (nextProps.isBeingDragged &&
(prevProps.dragDelta.dx !== nextProps.dragDelta.dx ||
prevProps.dragDelta.dy !== nextProps.dragDelta.dy)) {
return false;
}
// 如果执行状态变化,需要重新渲染
if (prevProps.isExecuting !== nextProps.isExecuting) {
return false;
}
// 检查 uncommittedNodeIds 中是否包含当前节点
const prevUncommitted = prevProps.uncommittedNodeIds.has(nextProps.node.id);
const nextUncommitted = nextProps.uncommittedNodeIds.has(nextProps.node.id);
if (prevUncommitted !== nextUncommitted) {
return false;
}
// 节点数据变化时需要重新渲染
if (JSON.stringify(prevProps.node.data) !== JSON.stringify(nextProps.node.data)) {
return false;
}
// 其他情况不重新渲染
return true;
});
@@ -1,219 +0,0 @@
import React, { useMemo } from 'react';
import * as LucideIcons from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { NodeViewData } from '../../types';
/**
* 图标映射
*/
const iconMap: Record<string, LucideIcon> = {
TreePine: LucideIcons.TreePine,
GitBranch: LucideIcons.GitBranch,
Shuffle: LucideIcons.Shuffle,
Repeat: LucideIcons.Repeat,
RotateCcw: LucideIcons.RotateCcw,
FlipHorizontal: LucideIcons.FlipHorizontal,
CheckCircle: LucideIcons.CheckCircle,
XCircle: LucideIcons.XCircle,
Play: LucideIcons.Play,
Pause: LucideIcons.Pause,
Square: LucideIcons.Square,
Circle: LucideIcons.Circle,
Diamond: LucideIcons.Diamond,
Box: LucideIcons.Box,
Flag: LucideIcons.Flag,
Target: LucideIcons.Target
};
/**
* 节点渲染器属性
*/
interface BehaviorTreeNodeRendererProps {
/**
* 节点视图数据
*/
nodeData: NodeViewData;
/**
* 节点点击事件
*/
onClick?: (e: React.MouseEvent, nodeId: string) => void;
/**
* 节点双击事件
*/
onDoubleClick?: (e: React.MouseEvent, nodeId: string) => void;
/**
* 节点右键事件
*/
onContextMenu?: (e: React.MouseEvent, nodeId: string) => void;
/**
* 鼠标按下事件
*/
onMouseDown?: (e: React.MouseEvent, nodeId: string) => void;
}
/**
* 行为树节点渲染器
* 负责单个节点的渲染
*/
export const BehaviorTreeNodeRenderer: React.FC<BehaviorTreeNodeRendererProps> = ({
nodeData,
onClick,
onDoubleClick,
onContextMenu,
onMouseDown
}) => {
const { node, isSelected, isDragging, executionStatus } = nodeData;
const { template, position } = node;
const IconComponent = iconMap[template.icon || 'Box'] || LucideIcons.Box;
const nodeStyle = useMemo(() => {
let borderColor = template.color || '#4a9eff';
const backgroundColor = '#2a2a2a';
let boxShadow = 'none';
if (isSelected) {
boxShadow = `0 0 0 2px ${borderColor}`;
}
if (executionStatus === 'running') {
borderColor = '#ffa500';
boxShadow = `0 0 10px ${borderColor}`;
} else if (executionStatus === 'success') {
borderColor = '#00ff00';
} else if (executionStatus === 'failure') {
borderColor = '#ff0000';
}
return {
position: 'absolute' as const,
left: position.x,
top: position.y,
minWidth: '180px',
padding: '12px',
backgroundColor,
borderRadius: '8px',
border: `2px solid ${borderColor}`,
boxShadow,
cursor: 'pointer',
userSelect: 'none' as const,
transition: 'box-shadow 0.2s',
opacity: isDragging ? 0.7 : 1
};
}, [template.color, position, isSelected, isDragging, executionStatus]);
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e, node.id);
};
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onDoubleClick?.(e, node.id);
};
const handleContextMenu = (e: React.MouseEvent) => {
e.stopPropagation();
onContextMenu?.(e, node.id);
};
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
onMouseDown?.(e, node.id);
};
return (
<div
className="behavior-tree-node"
style={nodeStyle}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleContextMenu}
onMouseDown={handleMouseDown}
data-node-id={node.id}
>
{/* 节点头部 */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px'
}}>
<IconComponent size={20} color={template.color || '#4a9eff'} />
<div style={{
fontSize: '14px',
fontWeight: 'bold',
color: '#ffffff',
flex: 1
}}>
{template.displayName}
</div>
</div>
{/* 节点类型 */}
{template.category && (
<div style={{
fontSize: '11px',
color: '#888888',
marginBottom: '4px'
}}>
{template.category}
</div>
)}
{/* 节点描述 */}
{template.description && (
<div style={{
fontSize: '12px',
color: '#cccccc',
marginTop: '8px',
lineHeight: '1.4'
}}>
{template.description}
</div>
)}
{/* 输入连接点 */}
<div
className="node-input-pin"
style={{
position: 'absolute',
top: '50%',
left: '-6px',
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: template.color || '#4a9eff',
border: '2px solid #1a1a1a',
transform: 'translateY(-50%)',
cursor: 'pointer'
}}
data-pin-type="input"
data-node-id={node.id}
/>
{/* 输出连接点 */}
<div
className="node-output-pin"
style={{
position: 'absolute',
top: '50%',
right: '-6px',
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: template.color || '#4a9eff',
border: '2px solid #1a1a1a',
transform: 'translateY(-50%)',
cursor: 'pointer'
}}
data-pin-type="output"
data-node-id={node.id}
/>
</div>
);
};
@@ -1 +0,0 @@
export { BehaviorTreeNodeRenderer } from './BehaviorTreeNodeRenderer';
@@ -1,126 +0,0 @@
/* 行为树编辑器面板样式 */
.behavior-tree-editor-panel {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background-color: #1a1a1a;
}
/* 空状态 */
.behavior-tree-editor-empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background-color: #1a1a1a;
}
.empty-state {
text-align: center;
color: #666;
}
.empty-state svg {
color: #444;
margin-bottom: 16px;
}
.empty-state p {
margin: 8px 0;
font-size: 14px;
}
.empty-state .hint {
font-size: 12px;
color: #555;
}
/* 工具栏 */
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 16px;
background-color: #2d2d30;
border-bottom: 1px solid #3e3e42;
}
.toolbar-left,
.toolbar-center,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.file-name {
color: #cccccc;
font-size: 14px;
font-weight: 500;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background-color: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: #cccccc;
cursor: pointer;
transition: all 0.2s;
}
.toolbar-btn:hover:not(:disabled) {
background-color: #3e3e42;
border-color: #464647;
}
.toolbar-btn:active:not(:disabled) {
background-color: #2a2d2e;
}
.toolbar-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 画布容器 */
.editor-canvas-container {
flex: 1;
position: relative;
overflow: hidden;
}
/* 节点层 */
.nodes-layer {
position: relative;
width: 100%;
height: 100%;
}
/* 行为树画布 */
.behavior-tree-canvas {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.canvas-grid {
position: absolute;
inset: 0;
pointer-events: none;
}
.canvas-content {
position: absolute;
width: 100%;
height: 100%;
}
@@ -1,303 +0,0 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Core, createLogger } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { open, save } from '@tauri-apps/plugin-dialog';
import { useBehaviorTreeDataStore } from '../../stores';
import { BehaviorTreeEditor } from '../BehaviorTreeEditor';
import { BehaviorTreeService } from '../../services/BehaviorTreeService';
import { showToast } from '../../services/NotificationService';
import { FolderOpen } from 'lucide-react';
import { Node as BehaviorTreeNode } from '../../domain/models/Node';
import { BehaviorTree } from '../../domain/models/BehaviorTree';
import './BehaviorTreeEditorPanel.css';
const logger = createLogger('BehaviorTreeEditorPanel');
/**
* 行为树编辑器面板组件
* 提供完整的行为树编辑功能,包括:
* - 节点的创建、删除、移动
* - 连接管理
* - 黑板变量管理
* - 文件保存和加载
*/
interface BehaviorTreeEditorPanelProps {
/** 项目路径,用于文件系统操作 */
projectPath?: string | null;
/** 导出对话框打开回调 */
onOpenExportDialog?: () => void;
/** 获取可用文件列表回调 */
onGetAvailableFiles?: () => string[];
}
export const BehaviorTreeEditorPanel: React.FC<BehaviorTreeEditorPanelProps> = ({
projectPath,
onOpenExportDialog
// onGetAvailableFiles - 保留用于未来的批量导出功能
}) => {
const isOpen = useBehaviorTreeDataStore((state) => state.isOpen);
const blackboardVariables = useBehaviorTreeDataStore((state) => state.blackboardVariables);
// 文件状态管理
const [currentFilePath, setCurrentFilePath] = useState<string | null>(null);
const [currentFileName, setCurrentFileName] = useState<string>('');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string>('');
// 监听树的变化来检测未保存更改
const tree = useBehaviorTreeDataStore((state) => state.tree);
const storeFilePath = useBehaviorTreeDataStore((state) => state.currentFilePath);
const storeFileName = useBehaviorTreeDataStore((state) => state.currentFileName);
// 初始化时从 store 读取文件信息(解决时序问题)
useEffect(() => {
if (storeFilePath && !currentFilePath) {
setCurrentFilePath(storeFilePath);
setCurrentFileName(storeFileName);
const loadedTree = useBehaviorTreeDataStore.getState().tree;
setLastSavedSnapshot(JSON.stringify(loadedTree));
setHasUnsavedChanges(false);
}
}, [storeFilePath, storeFileName, currentFilePath]);
useEffect(() => {
if (isOpen && lastSavedSnapshot) {
const currentSnapshot = JSON.stringify(tree);
setHasUnsavedChanges(currentSnapshot !== lastSavedSnapshot);
}
}, [tree, lastSavedSnapshot, isOpen]);
useEffect(() => {
try {
const messageHub = Core.services.resolve(MessageHub);
// 订阅文件打开事件
const unsubscribeFileOpened = messageHub.subscribe('behavior-tree:file-opened', (data: { filePath: string; fileName: string }) => {
setCurrentFilePath(data.filePath);
setCurrentFileName(data.fileName);
const loadedTree = useBehaviorTreeDataStore.getState().tree;
setLastSavedSnapshot(JSON.stringify(loadedTree));
setHasUnsavedChanges(false);
});
// 订阅节点属性更改事件
const unsubscribePropertyChanged = messageHub.subscribe('behavior-tree:node-property-changed',
(data: { nodeId: string; propertyName: string; value: any }) => {
const state = useBehaviorTreeDataStore.getState();
const node = state.getNode(data.nodeId);
if (node) {
const newData = { ...node.data, [data.propertyName]: data.value };
// 更新节点数据
const updatedNode = new BehaviorTreeNode(
node.id,
node.template,
newData,
node.position,
Array.from(node.children)
);
// 更新树
const nodes = state.getNodes().map((n) =>
n.id === data.nodeId ? updatedNode : n
);
const newTree = new BehaviorTree(
nodes,
state.getConnections(),
state.getBlackboard(),
state.getRootNodeId()
);
state.setTree(newTree);
setHasUnsavedChanges(true);
// 强制刷新画布
state.triggerForceUpdate();
}
}
);
return () => {
unsubscribeFileOpened();
unsubscribePropertyChanged();
};
} catch (error) {
logger.error('Failed to subscribe to events:', error);
}
}, []);
const handleNodeSelect = useCallback((node: BehaviorTreeNode) => {
try {
const messageHub = Core.services.resolve(MessageHub);
messageHub.publish('behavior-tree:node-selected', { data: node });
} catch (error) {
logger.error('Failed to publish node selection:', error);
}
}, []);
const handleSave = useCallback(async () => {
try {
let filePath = currentFilePath;
if (!filePath) {
const selected = await save({
filters: [{ name: 'Behavior Tree', extensions: ['btree'] }],
defaultPath: projectPath || undefined,
title: '保存行为树'
});
if (!selected) return;
filePath = selected;
}
const service = Core.services.resolve(BehaviorTreeService);
await service.saveToFile(filePath);
setCurrentFilePath(filePath);
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || 'Untitled';
setCurrentFileName(fileName);
setLastSavedSnapshot(JSON.stringify(tree));
setHasUnsavedChanges(false);
showToast(`文件已保存: ${fileName}.btree`, 'success');
} catch (error) {
logger.error('Failed to save file:', error);
showToast(`保存失败: ${error}`, 'error');
}
}, [currentFilePath, projectPath, tree]);
const handleOpen = useCallback(async () => {
try {
if (hasUnsavedChanges) {
const confirmed = window.confirm('当前文件有未保存的更改,是否继续打开新文件?');
if (!confirmed) return;
}
const selected = await open({
filters: [{ name: 'Behavior Tree', extensions: ['btree'] }],
multiple: false,
directory: false,
defaultPath: projectPath || undefined,
title: '打开行为树'
});
if (!selected) return;
const filePath = selected as string;
const service = Core.services.resolve(BehaviorTreeService);
await service.loadFromFile(filePath);
setCurrentFilePath(filePath);
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || 'Untitled';
setCurrentFileName(fileName);
const loadedTree = useBehaviorTreeDataStore.getState().tree;
setLastSavedSnapshot(JSON.stringify(loadedTree));
setHasUnsavedChanges(false);
showToast(`文件已打开: ${fileName}.btree`, 'success');
} catch (error) {
logger.error('Failed to open file:', error);
showToast(`打开失败: ${error}`, 'error');
}
}, [hasUnsavedChanges, projectPath]);
const handleExport = useCallback(() => {
if (onOpenExportDialog) {
onOpenExportDialog();
return;
}
try {
const messageHub = Core.services.resolve(MessageHub);
messageHub.publish('compiler:open-dialog', {
compilerId: 'behavior-tree',
currentFileName: currentFileName || undefined,
projectPath: projectPath || undefined
});
} catch (error) {
logger.error('Failed to open export dialog:', error);
showToast(`无法打开导出对话框: ${error}`, 'error');
}
}, [onOpenExportDialog, currentFileName, projectPath]);
const handleCopyToClipboard = useCallback(async () => {
try {
const store = useBehaviorTreeDataStore.getState();
const metadata = { name: currentFileName || 'Untitled', description: '' };
const jsonContent = store.exportToJSON(metadata);
await navigator.clipboard.writeText(jsonContent);
showToast('已复制到剪贴板', 'success');
} catch (error) {
logger.error('Failed to copy to clipboard:', error);
showToast(`复制失败: ${error}`, 'error');
}
}, [currentFileName]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
handleSave();
}
if (e.ctrlKey && e.key === 'o') {
e.preventDefault();
handleOpen();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSave, handleOpen]);
if (!isOpen) {
return (
<div className="behavior-tree-editor-empty">
<div className="empty-state">
<FolderOpen size={48} />
<p>No behavior tree file opened</p>
<p className="hint">Double-click a .btree file to edit</p>
<button
onClick={handleOpen}
style={{
marginTop: '16px',
padding: '8px 16px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
fontSize: '13px',
display: 'inline-flex',
alignItems: 'center',
gap: '8px'
}}
>
<FolderOpen size={16} />
</button>
</div>
</div>
);
}
return (
<div className="behavior-tree-editor-panel">
<BehaviorTreeEditor
blackboardVariables={blackboardVariables}
projectPath={projectPath}
showToolbar={true}
currentFileName={currentFileName}
hasUnsavedChanges={hasUnsavedChanges}
onNodeSelect={handleNodeSelect}
onSave={handleSave}
onOpen={handleOpen}
onExport={handleExport}
onCopyToClipboard={handleCopyToClipboard}
/>
</div>
);
};
@@ -1,45 +0,0 @@
.behavior-tree-properties-panel {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: var(--bg-secondary);
}
.properties-panel-tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.properties-panel-tabs .tab-button {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
color: var(--text-secondary);
font-size: 13px;
transition: all 0.2s;
}
.properties-panel-tabs .tab-button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.properties-panel-tabs .tab-button.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
background: var(--bg-secondary);
}
.properties-panel-content {
flex: 1;
overflow: hidden;
}
@@ -1,478 +0,0 @@
import React from 'react';
import { Play, Pause, Square, SkipForward, Undo, Redo, ZoomIn, Save, FolderOpen, Download, Clipboard, Home } from 'lucide-react';
type ExecutionMode = 'idle' | 'running' | 'paused';
interface EditorToolbarProps {
executionMode: ExecutionMode;
canUndo: boolean;
canRedo: boolean;
hasUnsavedChanges?: boolean;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
onStep: () => void;
onReset: () => void;
onUndo: () => void;
onRedo: () => void;
onResetView: () => void;
onSave?: () => void;
onOpen?: () => void;
onExport?: () => void;
onCopyToClipboard?: () => void;
onGoToRoot?: () => void;
}
export const EditorToolbar: React.FC<EditorToolbarProps> = ({
executionMode,
canUndo,
canRedo,
hasUnsavedChanges = false,
onPlay,
onPause,
onStop,
onStep,
onReset,
onUndo,
onRedo,
onResetView,
onSave,
onOpen,
onExport,
onCopyToClipboard,
onGoToRoot
}) => {
return (
<div style={{
position: 'absolute',
top: '12px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: '6px',
backgroundColor: '#2a2a2a',
padding: '6px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
border: '1px solid #3f3f3f',
zIndex: 100
}}>
{/* 文件操作组 */}
<div style={{
display: 'flex',
gap: '4px',
padding: '2px',
backgroundColor: '#1e1e1e',
borderRadius: '6px'
}}>
{onOpen && (
<button
onClick={onOpen}
style={{
padding: '6px 8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#ccc',
cursor: 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="打开文件 (Ctrl+O)"
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
>
<FolderOpen size={14} />
</button>
)}
{onSave && (
<button
onClick={onSave}
style={{
padding: '6px 8px',
backgroundColor: hasUnsavedChanges ? '#2563eb' : '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: hasUnsavedChanges ? '#fff' : '#ccc',
cursor: 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title={`保存 (Ctrl+S)${hasUnsavedChanges ? ' - 有未保存的更改' : ''}`}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = hasUnsavedChanges ? '#1d4ed8' : '#4a4a4a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = hasUnsavedChanges ? '#2563eb' : '#3c3c3c'}
>
<Save size={14} />
</button>
)}
{onExport && (
<button
onClick={onExport}
style={{
padding: '6px 8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#ccc',
cursor: 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="导出运行时配置"
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
>
<Download size={14} />
</button>
)}
{onCopyToClipboard && (
<button
onClick={onCopyToClipboard}
style={{
padding: '6px 8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#ccc',
cursor: 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="复制JSON到剪贴板"
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
>
<Clipboard size={14} />
</button>
)}
</div>
{/* 分隔符 */}
<div style={{
width: '1px',
backgroundColor: '#444',
margin: '2px 0'
}} />
{/* 执行控制组 */}
<div style={{
display: 'flex',
gap: '4px',
padding: '2px',
backgroundColor: '#1e1e1e',
borderRadius: '6px'
}}>
{/* 播放按钮 */}
<button
onClick={onPlay}
disabled={executionMode === 'running'}
style={{
padding: '6px 10px',
backgroundColor: executionMode === 'running' ? '#2a2a2a' : '#16a34a',
border: 'none',
borderRadius: '4px',
color: executionMode === 'running' ? '#666' : '#fff',
cursor: executionMode === 'running' ? 'not-allowed' : 'pointer',
fontSize: '13px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s'
}}
title="运行 (Play)"
onMouseEnter={(e) => {
if (executionMode !== 'running') {
e.currentTarget.style.backgroundColor = '#15803d';
}
}}
onMouseLeave={(e) => {
if (executionMode !== 'running') {
e.currentTarget.style.backgroundColor = '#16a34a';
}
}}
>
<Play size={14} fill="currentColor" />
</button>
{/* 暂停按钮 */}
<button
onClick={onPause}
disabled={executionMode === 'idle'}
style={{
padding: '6px 10px',
backgroundColor: executionMode === 'idle' ? '#2a2a2a' : '#f59e0b',
border: 'none',
borderRadius: '4px',
color: executionMode === 'idle' ? '#666' : '#fff',
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title={executionMode === 'paused' ? '继续' : '暂停'}
onMouseEnter={(e) => {
if (executionMode !== 'idle') {
e.currentTarget.style.backgroundColor = '#d97706';
}
}}
onMouseLeave={(e) => {
if (executionMode !== 'idle') {
e.currentTarget.style.backgroundColor = '#f59e0b';
}
}}
>
{executionMode === 'paused' ? <Play size={14} fill="currentColor" /> : <Pause size={14} fill="currentColor" />}
</button>
{/* 停止按钮 */}
<button
onClick={onStop}
disabled={executionMode === 'idle'}
style={{
padding: '6px 10px',
backgroundColor: executionMode === 'idle' ? '#2a2a2a' : '#dc2626',
border: 'none',
borderRadius: '4px',
color: executionMode === 'idle' ? '#666' : '#fff',
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="停止"
onMouseEnter={(e) => {
if (executionMode !== 'idle') {
e.currentTarget.style.backgroundColor = '#b91c1c';
}
}}
onMouseLeave={(e) => {
if (executionMode !== 'idle') {
e.currentTarget.style.backgroundColor = '#dc2626';
}
}}
>
<Square size={14} fill="currentColor" />
</button>
{/* 单步执行按钮 */}
<button
onClick={onStep}
disabled={executionMode !== 'idle' && executionMode !== 'paused'}
style={{
padding: '6px 10px',
backgroundColor: (executionMode !== 'idle' && executionMode !== 'paused') ? '#2a2a2a' : '#3b82f6',
border: 'none',
borderRadius: '4px',
color: (executionMode !== 'idle' && executionMode !== 'paused') ? '#666' : '#fff',
cursor: (executionMode !== 'idle' && executionMode !== 'paused') ? 'not-allowed' : 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="单步执行"
onMouseEnter={(e) => {
if (executionMode === 'idle' || executionMode === 'paused') {
e.currentTarget.style.backgroundColor = '#2563eb';
}
}}
onMouseLeave={(e) => {
if (executionMode === 'idle' || executionMode === 'paused') {
e.currentTarget.style.backgroundColor = '#3b82f6';
}
}}
>
<SkipForward size={14} />
</button>
</div>
{/* 分隔符 */}
<div style={{
width: '1px',
backgroundColor: '#444',
margin: '2px 0'
}} />
{/* 视图控制 */}
<button
onClick={onResetView}
style={{
padding: '6px 10px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#ccc',
cursor: 'pointer',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s'
}}
title="重置视图 (滚轮缩放, Alt+拖动平移)"
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
>
<ZoomIn size={13} />
<span>Reset View</span>
</button>
{/* 分隔符 */}
<div style={{
width: '1px',
backgroundColor: '#444',
margin: '2px 0'
}} />
{/* 历史控制组 */}
<div style={{
display: 'flex',
gap: '4px',
padding: '2px',
backgroundColor: '#1e1e1e',
borderRadius: '6px'
}}>
<button
onClick={onUndo}
disabled={!canUndo}
style={{
padding: '6px 8px',
backgroundColor: canUndo ? '#3c3c3c' : '#2a2a2a',
border: 'none',
borderRadius: '4px',
color: canUndo ? '#ccc' : '#666',
cursor: canUndo ? 'pointer' : 'not-allowed',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="撤销 (Ctrl+Z)"
onMouseEnter={(e) => {
if (canUndo) {
e.currentTarget.style.backgroundColor = '#4a4a4a';
}
}}
onMouseLeave={(e) => {
if (canUndo) {
e.currentTarget.style.backgroundColor = '#3c3c3c';
}
}}
>
<Undo size={14} />
</button>
<button
onClick={onRedo}
disabled={!canRedo}
style={{
padding: '6px 8px',
backgroundColor: canRedo ? '#3c3c3c' : '#2a2a2a',
border: 'none',
borderRadius: '4px',
color: canRedo ? '#ccc' : '#666',
cursor: canRedo ? 'pointer' : 'not-allowed',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="重做 (Ctrl+Shift+Z / Ctrl+Y)"
onMouseEnter={(e) => {
if (canRedo) {
e.currentTarget.style.backgroundColor = '#4a4a4a';
}
}}
onMouseLeave={(e) => {
if (canRedo) {
e.currentTarget.style.backgroundColor = '#3c3c3c';
}
}}
>
<Redo size={14} />
</button>
</div>
{/* 状态指示器 */}
<div style={{
padding: '6px 12px',
backgroundColor: '#1e1e1e',
borderRadius: '6px',
fontSize: '11px',
color: '#999',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontWeight: 500,
minWidth: '70px'
}}>
<span style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor:
executionMode === 'running' ? '#16a34a' :
executionMode === 'paused' ? '#f59e0b' : '#666',
boxShadow: executionMode !== 'idle' ? `0 0 8px ${
executionMode === 'running' ? '#16a34a' :
executionMode === 'paused' ? '#f59e0b' : 'transparent'
}` : 'none',
transition: 'all 0.2s'
}} />
<span style={{
color: executionMode === 'running' ? '#16a34a' :
executionMode === 'paused' ? '#f59e0b' : '#888'
}}>
{executionMode === 'idle' ? 'Idle' :
executionMode === 'running' ? 'Running' : 'Paused'}
</span>
</div>
{onGoToRoot && (
<>
<div style={{
width: '1px',
backgroundColor: '#444',
margin: '2px 0'
}} />
<button
onClick={onGoToRoot}
style={{
padding: '6px 10px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#ccc',
cursor: 'pointer',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s'
}}
title="回到根节点"
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
>
<Home size={13} />
<span>Root</span>
</button>
</>
)}
</div>
);
};
@@ -1,60 +0,0 @@
import { NodeTemplate, NodeType } 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, TreePine,
LucideIcon
} from 'lucide-react';
export const ICON_MAP: 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
};
export const ROOT_NODE_TEMPLATE: NodeTemplate = {
type: NodeType.Composite,
displayName: '根节点',
category: '根节点',
icon: 'TreePine',
description: '行为树根节点',
color: '#FFD700',
defaultConfig: {
nodeType: 'root'
},
properties: []
};
export const DEFAULT_EDITOR_CONFIG = {
enableSnapping: false,
gridSize: 20,
minZoom: 0.1,
maxZoom: 3,
showGrid: true,
showMinimap: false,
defaultRootNodePosition: {
x: 400,
y: 100
}
};
@@ -1,47 +0,0 @@
/**
* 行为树编辑器常量定义
*/
// 根节点 ID
export const ROOT_NODE_ID = 'root';
// 节点类型
export enum NodeType {
Root = 'root',
Sequence = 'sequence',
Selector = 'selector',
Parallel = 'parallel',
Decorator = 'decorator',
Action = 'action',
Condition = 'condition'
}
// 端口类型
export enum PortType {
Input = 'input',
Output = 'output'
}
// 编辑器默认配置
export const DEFAULT_EDITOR_CONFIG = {
showGrid: true,
gridSize: 20,
snapToGrid: true,
canvasBackground: '#1a1a1a',
connectionColor: '#4a9eff',
nodeSpacing: { x: 200, y: 100 },
nodeWidth: 160,
nodeHeight: 60,
portSize: 8
};
// 颜色配置
export const NODE_COLORS = {
[NodeType.Root]: '#666',
[NodeType.Sequence]: '#4a9eff',
[NodeType.Selector]: '#ffb84d',
[NodeType.Parallel]: '#b84dff',
[NodeType.Decorator]: '#4dffb8',
[NodeType.Action]: '#ff4d4d',
[NodeType.Condition]: '#4dff9e'
};
@@ -1,25 +0,0 @@
import { Node } from '../models/Node';
import { Position } from '../value-objects/Position';
import { NodeTemplate } from '@esengine/behavior-tree';
export const ROOT_NODE_ID = 'root-node';
export const createRootNodeTemplate = (): NodeTemplate => ({
type: 'root',
displayName: '根节点',
category: '根节点',
icon: 'TreePine',
description: '行为树根节点',
color: '#FFD700',
maxChildren: 1,
defaultConfig: {
nodeType: 'root'
},
properties: []
});
export const createRootNode = (): Node => {
const template = createRootNodeTemplate();
const position = new Position(400, 100);
return new Node(ROOT_NODE_ID, template, { nodeType: 'root' }, position, []);
};
@@ -1,10 +0,0 @@
/**
* 领域错误基类
*/
export abstract class DomainError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
Object.setPrototypeOf(this, new.target.prototype);
}
}
@@ -1,10 +0,0 @@
import { DomainError } from './DomainError';
/**
* 节点未找到错误
*/
export class NodeNotFoundError extends DomainError {
constructor(public readonly nodeId: string) {
super(`节点未找到: ${nodeId}`);
}
}
@@ -1,52 +0,0 @@
import { DomainError } from './DomainError';
/**
* 验证错误
* 当业务规则验证失败时抛出
*/
export class ValidationError extends DomainError {
constructor(
message: string,
public readonly field?: string,
public readonly value?: unknown
) {
super(message);
}
static rootNodeMaxChildren(): ValidationError {
return new ValidationError(
'根节点只能连接一个子节点',
'children'
);
}
static decoratorNodeMaxChildren(): ValidationError {
return new ValidationError(
'装饰节点只能连接一个子节点',
'children'
);
}
static leafNodeNoChildren(): ValidationError {
return new ValidationError(
'叶子节点不能有子节点',
'children'
);
}
static circularReference(nodeId: string): ValidationError {
return new ValidationError(
`检测到循环引用,节点 ${nodeId} 不能连接到自己或其子节点`,
'connection',
nodeId
);
}
static invalidConnection(from: string, to: string, reason: string): ValidationError {
return new ValidationError(
`无效的连接:${reason}`,
'connection',
{ from, to }
);
}
}
@@ -1,3 +0,0 @@
export { DomainError } from './DomainError';
export { ValidationError } from './ValidationError';
export { NodeNotFoundError } from './NodeNotFoundError';
@@ -1,5 +0,0 @@
export * from './models';
export * from './value-objects';
export * from './interfaces';
export { DomainError, ValidationError as DomainValidationError, NodeNotFoundError } from './errors';
export * from './services';
@@ -1,32 +0,0 @@
import { NodeTemplate } from '@esengine/behavior-tree';
import { Node } from '../models/Node';
import { Position } from '../value-objects';
/**
* 节点工厂接口
* 负责创建不同类型的节点
*/
export interface INodeFactory {
/**
* 创建节点
*/
createNode(
template: NodeTemplate,
position: Position,
data?: Record<string, unknown>
): Node;
/**
* 根据模板类型创建节点
*/
createNodeByType(
nodeType: string,
position: Position,
data?: Record<string, unknown>
): Node;
/**
* 克隆节点
*/
cloneNode(node: Node, newPosition?: Position): Node;
}
@@ -1,27 +0,0 @@
import { BehaviorTree } from '../models/BehaviorTree';
/**
* 仓储接口
* 负责行为树的持久化
*/
export interface IBehaviorTreeRepository {
/**
* 保存行为树
*/
save(tree: BehaviorTree, path: string): Promise<void>;
/**
* 加载行为树
*/
load(path: string): Promise<BehaviorTree>;
/**
* 检查文件是否存在
*/
exists(path: string): Promise<boolean>;
/**
* 删除行为树文件
*/
delete(path: string): Promise<void>;
}
@@ -1,30 +0,0 @@
import { BehaviorTree } from '../models/BehaviorTree';
/**
* 序列化格式
*/
export type SerializationFormat = 'json' | 'binary';
/**
* 序列化接口
* 负责行为树的序列化和反序列化
*/
export interface ISerializer {
/**
* 序列化行为树
*/
serialize(tree: BehaviorTree, format: SerializationFormat): string | Uint8Array;
/**
* 反序列化行为树
*/
deserialize(data: string | Uint8Array, format: SerializationFormat): BehaviorTree;
/**
* 导出为运行时资产格式
*/
exportToRuntimeAsset(
tree: BehaviorTree,
format: SerializationFormat
): string | Uint8Array;
}
@@ -1,46 +0,0 @@
import { BehaviorTree } from '../models/BehaviorTree';
import { Node } from '../models/Node';
import { Connection } from '../models/Connection';
/**
* 验证结果
*/
export interface ValidationResult {
isValid: boolean;
errors: ValidationError[];
}
/**
* 验证错误详情
*/
export interface ValidationError {
message: string;
nodeId?: string;
field?: string;
}
/**
* 验证器接口
* 负责行为树的验证逻辑
*/
export interface IValidator {
/**
* 验证整个行为树
*/
validateTree(tree: BehaviorTree): ValidationResult;
/**
* 验证节点
*/
validateNode(node: Node): ValidationResult;
/**
* 验证连接
*/
validateConnection(connection: Connection, tree: BehaviorTree): ValidationResult;
/**
* 验证是否会产生循环引用
*/
validateNoCycles(tree: BehaviorTree): ValidationResult;
}
@@ -1,4 +0,0 @@
export { type INodeFactory } from './INodeFactory';
export { type ISerializer, type SerializationFormat } from './ISerializer';
export { type IBehaviorTreeRepository } from './IRepository';
export { type IValidator, type ValidationResult, type ValidationError } from './IValidator';
@@ -1,353 +0,0 @@
import { Node } from './Node';
import { Connection } from './Connection';
import { Blackboard } from './Blackboard';
import { ValidationError, NodeNotFoundError } from '../errors';
/**
* 行为树聚合根
* 管理整个行为树的节点、连接和黑板
*/
export class BehaviorTree {
private readonly _nodes: Map<string, Node>;
private readonly _connections: Connection[];
private readonly _blackboard: Blackboard;
private readonly _rootNodeId: string | null;
constructor(
nodes: Node[] = [],
connections: Connection[] = [],
blackboard: Blackboard = Blackboard.empty(),
rootNodeId: string | null = null
) {
this._nodes = new Map(nodes.map((node) => [node.id, node]));
this._connections = [...connections];
this._blackboard = blackboard;
this._rootNodeId = rootNodeId;
this.validateTree();
}
get nodes(): ReadonlyArray<Node> {
return Array.from(this._nodes.values());
}
get connections(): ReadonlyArray<Connection> {
return this._connections;
}
get blackboard(): Blackboard {
return this._blackboard;
}
get rootNodeId(): string | null {
return this._rootNodeId;
}
/**
* 获取指定节点
*/
getNode(nodeId: string): Node {
const node = this._nodes.get(nodeId);
if (!node) {
throw new NodeNotFoundError(nodeId);
}
return node;
}
/**
* 检查节点是否存在
*/
hasNode(nodeId: string): boolean {
return this._nodes.has(nodeId);
}
/**
* 添加节点
*/
addNode(node: Node): BehaviorTree {
if (this._nodes.has(node.id)) {
throw new ValidationError(`节点 ${node.id} 已存在`);
}
if (node.isRoot()) {
if (this._rootNodeId) {
throw new ValidationError('行为树只能有一个根节点');
}
return new BehaviorTree(
[...this.nodes, node],
this._connections,
this._blackboard,
node.id
);
}
return new BehaviorTree(
[...this.nodes, node],
this._connections,
this._blackboard,
this._rootNodeId
);
}
/**
* 移除节点
* 会同时移除相关的连接
*/
removeNode(nodeId: string): BehaviorTree {
if (!this._nodes.has(nodeId)) {
throw new NodeNotFoundError(nodeId);
}
const node = this.getNode(nodeId);
const newNodes = Array.from(this.nodes.filter((n) => n.id !== nodeId));
const newConnections = this._connections.filter(
(conn) => conn.from !== nodeId && conn.to !== nodeId
);
const newRootNodeId = node.isRoot() ? null : this._rootNodeId;
return new BehaviorTree(
newNodes,
newConnections,
this._blackboard,
newRootNodeId
);
}
/**
* 更新节点
*/
updateNode(nodeId: string, updater: (node: Node) => Node): BehaviorTree {
const node = this.getNode(nodeId);
const updatedNode = updater(node);
const newNodes = Array.from(this.nodes.map((n) => n.id === nodeId ? updatedNode : n));
return new BehaviorTree(
newNodes,
this._connections,
this._blackboard,
this._rootNodeId
);
}
/**
* 添加连接
* 会验证连接的合法性
*/
addConnection(connection: Connection): BehaviorTree {
const fromNode = this.getNode(connection.from);
const toNode = this.getNode(connection.to);
if (this.hasConnection(connection.from, connection.to)) {
throw new ValidationError(`连接已存在:${connection.from} -> ${connection.to}`);
}
if (this.wouldCreateCycle(connection.from, connection.to)) {
throw ValidationError.circularReference(connection.to);
}
if (connection.isNodeConnection()) {
if (!fromNode.canAddChild()) {
if (fromNode.isRoot()) {
throw ValidationError.rootNodeMaxChildren();
}
if (fromNode.nodeType.isDecorator()) {
throw ValidationError.decoratorNodeMaxChildren();
}
throw new ValidationError(`节点 ${connection.from} 无法添加更多子节点`);
}
if (toNode.nodeType.getMaxChildren() === 0 && toNode.nodeType.isLeaf()) {
}
const updatedFromNode = fromNode.addChild(connection.to);
const newNodes = Array.from(this.nodes.map((n) =>
n.id === connection.from ? updatedFromNode : n
));
return new BehaviorTree(
newNodes,
[...this._connections, connection],
this._blackboard,
this._rootNodeId
);
}
return new BehaviorTree(
Array.from(this.nodes),
[...this._connections, connection],
this._blackboard,
this._rootNodeId
);
}
/**
* 移除连接
*/
removeConnection(from: string, to: string, fromProperty?: string, toProperty?: string): BehaviorTree {
const connection = this._connections.find((c) => c.matches(from, to, fromProperty, toProperty));
if (!connection) {
throw new ValidationError(`连接不存在:${from} -> ${to}`);
}
const newConnections = this._connections.filter((c) => !c.matches(from, to, fromProperty, toProperty));
if (connection.isNodeConnection()) {
const fromNode = this.getNode(from);
const updatedFromNode = fromNode.removeChild(to);
const newNodes = Array.from(this.nodes.map((n) =>
n.id === from ? updatedFromNode : n
));
return new BehaviorTree(
newNodes,
newConnections,
this._blackboard,
this._rootNodeId
);
}
return new BehaviorTree(
Array.from(this.nodes),
newConnections,
this._blackboard,
this._rootNodeId
);
}
/**
* 检查是否存在连接
*/
hasConnection(from: string, to: string): boolean {
return this._connections.some((c) => c.from === from && c.to === to);
}
/**
* 检查是否会创建循环引用
*/
private wouldCreateCycle(from: string, to: string): boolean {
const visited = new Set<string>();
const queue: string[] = [to];
while (queue.length > 0) {
const current = queue.shift()!;
if (current === from) {
return true;
}
if (visited.has(current)) {
continue;
}
visited.add(current);
const childConnections = this._connections.filter((c) => c.from === current && c.isNodeConnection());
childConnections.forEach((conn) => queue.push(conn.to));
}
return false;
}
/**
* 更新黑板
*/
updateBlackboard(updater: (blackboard: Blackboard) => Blackboard): BehaviorTree {
return new BehaviorTree(
Array.from(this.nodes),
this._connections,
updater(this._blackboard),
this._rootNodeId
);
}
/**
* 获取节点的子节点
*/
getChildren(nodeId: string): Node[] {
const node = this.getNode(nodeId);
return node.children.map((childId) => this.getNode(childId));
}
/**
* 获取节点的父节点
*/
getParent(nodeId: string): Node | null {
const parentConnection = this._connections.find(
(c) => c.to === nodeId && c.isNodeConnection()
);
if (!parentConnection) {
return null;
}
return this.getNode(parentConnection.from);
}
/**
* 验证树的完整性
*/
private validateTree(): void {
const rootNodes = this.nodes.filter((n) => n.isRoot());
if (rootNodes.length > 1) {
throw new ValidationError('行为树只能有一个根节点');
}
if (rootNodes.length === 1 && rootNodes[0] && this._rootNodeId !== rootNodes[0].id) {
throw new ValidationError('根节点ID不匹配');
}
this._connections.forEach((conn) => {
if (!this._nodes.has(conn.from)) {
throw new NodeNotFoundError(conn.from);
}
if (!this._nodes.has(conn.to)) {
throw new NodeNotFoundError(conn.to);
}
});
}
/**
* 转换为普通对象
*/
toObject(): {
nodes: ReturnType<Node['toObject']>[];
connections: ReturnType<Connection['toObject']>[];
blackboard: Record<string, unknown>;
rootNodeId: string | null;
} {
return {
nodes: this.nodes.map((n) => n.toObject()),
connections: this._connections.map((c) => c.toObject()),
blackboard: this._blackboard.toObject(),
rootNodeId: this._rootNodeId
};
}
/**
* 从普通对象创建行为树
*/
static fromObject(obj: {
nodes: Parameters<typeof Node.fromObject>[0][];
connections: Parameters<typeof Connection.fromObject>[0][];
blackboard: Record<string, unknown>;
rootNodeId: string | null;
}): BehaviorTree {
return new BehaviorTree(
obj.nodes.map((n) => Node.fromObject(n)),
obj.connections.map((c) => Connection.fromObject(c)),
Blackboard.fromObject(obj.blackboard),
obj.rootNodeId
);
}
/**
* 创建空行为树
*/
static empty(): BehaviorTree {
return new BehaviorTree();
}
}
@@ -1,122 +0,0 @@
/**
* 黑板值类型
*/
export type BlackboardValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[];
/**
* 黑板领域实体
* 管理行为树的全局变量
*/
export class Blackboard {
private _variables: Map<string, BlackboardValue>;
constructor(variables: Record<string, BlackboardValue> = {}) {
this._variables = new Map(Object.entries(variables));
}
/**
* 获取变量值
*/
get(key: string): BlackboardValue {
return this._variables.get(key);
}
/**
* 设置变量值
*/
set(key: string, value: BlackboardValue): Blackboard {
const newVariables = new Map(this._variables);
newVariables.set(key, value);
return new Blackboard(Object.fromEntries(newVariables));
}
/**
* 设置变量值(别名方法)
*/
setValue(key: string, value: BlackboardValue): void {
this._variables.set(key, value);
}
/**
* 删除变量
*/
delete(key: string): Blackboard {
const newVariables = new Map(this._variables);
newVariables.delete(key);
return new Blackboard(Object.fromEntries(newVariables));
}
/**
* 检查变量是否存在
*/
has(key: string): boolean {
return this._variables.has(key);
}
/**
* 获取所有变量名
*/
keys(): string[] {
return Array.from(this._variables.keys());
}
/**
* 获取所有变量
*/
getAll(): Record<string, BlackboardValue> {
return Object.fromEntries(this._variables);
}
/**
* 批量设置变量
*/
setAll(variables: Record<string, BlackboardValue>): Blackboard {
const newVariables = new Map(this._variables);
Object.entries(variables).forEach(([key, value]) => {
newVariables.set(key, value);
});
return new Blackboard(Object.fromEntries(newVariables));
}
/**
* 清空所有变量
*/
clear(): Blackboard {
return new Blackboard();
}
/**
* 获取变量数量
*/
size(): number {
return this._variables.size;
}
/**
* 克隆黑板
*/
clone(): Blackboard {
return new Blackboard(this.getAll());
}
/**
* 转换为普通对象
*/
toObject(): Record<string, BlackboardValue> {
return this.getAll();
}
/**
* 从普通对象创建黑板
*/
static fromObject(obj: Record<string, unknown>): Blackboard {
return new Blackboard(obj as Record<string, BlackboardValue>);
}
/**
* 创建空黑板
*/
static empty(): Blackboard {
return new Blackboard();
}
}
@@ -1,140 +0,0 @@
import { ValidationError } from '../errors';
/**
* 连接类型
*/
export type ConnectionType = 'node' | 'property';
/**
* 连接领域实体
* 表示两个节点之间的连接关系
*/
export class Connection {
private readonly _from: string;
private readonly _to: string;
private readonly _fromProperty?: string;
private readonly _toProperty?: string;
private readonly _connectionType: ConnectionType;
constructor(
from: string,
to: string,
connectionType: ConnectionType = 'node',
fromProperty?: string,
toProperty?: string
) {
if (from === to) {
throw ValidationError.circularReference(from);
}
if (connectionType === 'property' && (!fromProperty || !toProperty)) {
throw new ValidationError('属性连接必须指定源属性和目标属性');
}
this._from = from;
this._to = to;
this._connectionType = connectionType;
this._fromProperty = fromProperty;
this._toProperty = toProperty;
}
get from(): string {
return this._from;
}
get to(): string {
return this._to;
}
get fromProperty(): string | undefined {
return this._fromProperty;
}
get toProperty(): string | undefined {
return this._toProperty;
}
get connectionType(): ConnectionType {
return this._connectionType;
}
/**
* 检查是否为节点连接
*/
isNodeConnection(): boolean {
return this._connectionType === 'node';
}
/**
* 检查是否为属性连接
*/
isPropertyConnection(): boolean {
return this._connectionType === 'property';
}
/**
* 检查连接是否匹配指定的条件
*/
matches(from: string, to: string, fromProperty?: string, toProperty?: string): boolean {
if (this._from !== from || this._to !== to) {
return false;
}
if (this._connectionType === 'property') {
return this._fromProperty === fromProperty && this._toProperty === toProperty;
}
return true;
}
/**
* 相等性比较
*/
equals(other: Connection): boolean {
return (
this._from === other._from &&
this._to === other._to &&
this._connectionType === other._connectionType &&
this._fromProperty === other._fromProperty &&
this._toProperty === other._toProperty
);
}
/**
* 转换为普通对象
*/
toObject(): {
from: string;
to: string;
fromProperty?: string;
toProperty?: string;
connectionType: ConnectionType;
} {
return {
from: this._from,
to: this._to,
connectionType: this._connectionType,
...(this._fromProperty && { fromProperty: this._fromProperty }),
...(this._toProperty && { toProperty: this._toProperty })
};
}
/**
* 从普通对象创建连接
*/
static fromObject(obj: {
from: string;
to: string;
fromProperty?: string;
toProperty?: string;
connectionType: ConnectionType;
}): Connection {
return new Connection(
obj.from,
obj.to,
obj.connectionType,
obj.fromProperty,
obj.toProperty
);
}
}
@@ -1,190 +0,0 @@
import { NodeTemplate } from '@esengine/behavior-tree';
import { Position, NodeType } from '../value-objects';
import { ValidationError } from '../errors';
/**
* 行为树节点领域实体
* 封装节点的业务逻辑和验证规则
*/
export class Node {
private readonly _id: string;
private readonly _template: NodeTemplate;
private _data: Record<string, unknown>;
private _position: Position;
private _children: string[];
private readonly _nodeType: NodeType;
constructor(
id: string,
template: NodeTemplate,
data: Record<string, unknown>,
position: Position,
children: string[] = []
) {
this._id = id;
this._template = template;
this._data = { ...data };
this._position = position;
this._children = [...children];
this._nodeType = NodeType.fromString(template.type);
}
get id(): string {
return this._id;
}
get template(): NodeTemplate {
return this._template;
}
get data(): Record<string, unknown> {
return { ...this._data };
}
get position(): Position {
return this._position;
}
get children(): ReadonlyArray<string> {
return this._children;
}
get nodeType(): NodeType {
return this._nodeType;
}
/**
* 更新节点位置
*/
moveToPosition(newPosition: Position): Node {
return new Node(
this._id,
this._template,
this._data,
newPosition,
this._children
);
}
/**
* 更新节点数据
*/
updateData(data: Record<string, unknown>): Node {
return new Node(
this._id,
this._template,
{ ...this._data, ...data },
this._position,
this._children
);
}
/**
* 添加子节点
* @throws ValidationError 如果违反业务规则
*/
addChild(childId: string): Node {
// 使用模板定义的约束,undefined 表示无限制
const maxChildren = (this._template.maxChildren ?? Infinity) as number;
if (maxChildren === 0) {
throw ValidationError.leafNodeNoChildren();
}
if (this._children.length >= maxChildren) {
if (this._nodeType.isRoot()) {
throw ValidationError.rootNodeMaxChildren();
}
if (this._nodeType.isDecorator()) {
throw ValidationError.decoratorNodeMaxChildren();
}
throw new ValidationError(`节点 ${this._id} 已达到最大子节点数 ${maxChildren}`);
}
if (this._children.includes(childId)) {
throw new ValidationError(`子节点 ${childId} 已存在`);
}
return new Node(
this._id,
this._template,
this._data,
this._position,
[...this._children, childId]
);
}
/**
* 移除子节点
*/
removeChild(childId: string): Node {
return new Node(
this._id,
this._template,
this._data,
this._position,
this._children.filter((id) => id !== childId)
);
}
/**
* 检查是否可以添加子节点
*/
canAddChild(): boolean {
// 使用模板定义的最大子节点数,undefined 表示无限制
const maxChildren = (this._template.maxChildren ?? Infinity) as number;
return this._children.length < maxChildren;
}
/**
* 检查是否有子节点
*/
hasChildren(): boolean {
return this._children.length > 0;
}
/**
* 检查是否为根节点
*/
isRoot(): boolean {
return this._nodeType.isRoot();
}
/**
* 转换为普通对象(用于序列化)
*/
toObject(): {
id: string;
template: NodeTemplate;
data: Record<string, unknown>;
position: { x: number; y: number };
children: string[];
} {
return {
id: this._id,
template: this._template,
data: this._data,
position: this._position.toObject(),
children: [...this._children]
};
}
/**
* 从普通对象创建节点
*/
static fromObject(obj: {
id: string;
template: NodeTemplate;
data: Record<string, unknown>;
position: { x: number; y: number };
children: string[];
}): Node {
return new Node(
obj.id,
obj.template,
obj.data,
Position.fromObject(obj.position),
obj.children
);
}
}
@@ -1,4 +0,0 @@
export { Node } from './Node';
export { Connection, type ConnectionType } from './Connection';
export { Blackboard, type BlackboardValue } from './Blackboard';
export { BehaviorTree } from './BehaviorTree';

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