Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c7c3c98af | ||
|
|
be7b3afb4a | ||
|
|
3e037f4ae0 |
@@ -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"
|
||||
]
|
||||
}
|
||||
+37
-74
@@ -6,9 +6,8 @@ on:
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
- 'turbo.json'
|
||||
- 'jest.config.*'
|
||||
- '.github/workflows/ci.yml'
|
||||
pull_request:
|
||||
@@ -16,112 +15,76 @@ on:
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
- 'turbo.json'
|
||||
- 'jest.config.*'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
# 缓存 Rust 编译结果
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: packages/engine
|
||||
cache-on-failure: true
|
||||
|
||||
# 缓存 wasm-pack
|
||||
- name: Cache wasm-pack
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cargo/bin/wasm-pack
|
||||
key: wasm-pack-${{ runner.os }}
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: |
|
||||
if ! command -v wasm-pack &> /dev/null; then
|
||||
cargo install wasm-pack
|
||||
fi
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
run: npm ci
|
||||
|
||||
# 缓存 Turbo
|
||||
- name: Cache Turbo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||
turbo-${{ runner.os }}-
|
||||
|
||||
# 构建所有包
|
||||
- name: Build all packages
|
||||
run: 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: Type check
|
||||
run: pnpm run type-check
|
||||
run: npm run type-check
|
||||
|
||||
# Lint 检查
|
||||
- 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
|
||||
name: codecov-umbrella
|
||||
fail_ci_if_error: false
|
||||
|
||||
# 构建 npm 包
|
||||
- name: Build npm packages
|
||||
run: pnpm run build:npm
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
|
||||
- name: Build npm package
|
||||
run: npm run build:npm
|
||||
|
||||
# 上传构建产物
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: |
|
||||
packages/*/dist/
|
||||
packages/*/bin/
|
||||
retention-days: 7
|
||||
bin/
|
||||
dist/
|
||||
retention-days: 7
|
||||
@@ -14,34 +14,28 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- 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
|
||||
|
||||
@@ -17,20 +17,15 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- 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
|
||||
|
||||
@@ -29,31 +29,26 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- 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
|
||||
|
||||
@@ -33,16 +33,11 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- 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,36 +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: Install wasm-pack
|
||||
run: cargo install wasm-pack
|
||||
- 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-
|
||||
|
||||
# 使用 Turborepo 自动按依赖顺序构建所有包
|
||||
# 这会自动处理:core -> asset-system -> editor-core -> ui -> 等等
|
||||
- name: Build all packages with Turborepo
|
||||
run: pnpm run build
|
||||
- name: Build core package
|
||||
run: npm run build:core
|
||||
|
||||
- name: Copy WASM files to ecs-engine-bindgen
|
||||
shell: bash
|
||||
- name: Build editor-core package
|
||||
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/
|
||||
cd packages/editor-core
|
||||
npm run build
|
||||
|
||||
- name: Bundle runtime files for Tauri
|
||||
- name: Build behavior-tree package
|
||||
run: |
|
||||
cd packages/editor-app
|
||||
node scripts/bundle-runtime.mjs
|
||||
cd packages/behavior-tree
|
||||
npm run build
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0.5
|
||||
@@ -128,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
|
||||
@@ -140,16 +138,16 @@ jobs:
|
||||
delete-branch: true
|
||||
title: "chore(editor): Release v${{ github.event.inputs.version }}"
|
||||
body: |
|
||||
## Release v${{ github.event.inputs.version }}
|
||||
## 🚀 Release v${{ github.event.inputs.version }}
|
||||
|
||||
This PR updates the editor version after successful release build.
|
||||
|
||||
### Changes
|
||||
- Updated `packages/editor-app/package.json` → `${{ github.event.inputs.version }}`
|
||||
- Updated `packages/editor-app/src-tauri/tauri.conf.json` → `${{ github.event.inputs.version }}`
|
||||
- ✅ Updated `packages/editor-app/package.json` → `${{ github.event.inputs.version }}`
|
||||
- ✅ Updated `packages/editor-app/src-tauri/tauri.conf.json` → `${{ github.event.inputs.version }}`
|
||||
|
||||
### Release
|
||||
- [GitHub Release](https://github.com/${{ github.repository }}/releases/tag/editor-v${{ github.event.inputs.version }})
|
||||
- 📦 [GitHub Release](https://github.com/${{ github.repository }}/releases/tag/editor-v${{ github.event.inputs.version }})
|
||||
|
||||
---
|
||||
*This PR was automatically created by the release workflow.*
|
||||
|
||||
@@ -11,10 +11,6 @@ on:
|
||||
- core
|
||||
- behavior-tree
|
||||
- editor-core
|
||||
- node-editor
|
||||
- blueprint
|
||||
- tilemap
|
||||
- physics-rapier2d
|
||||
version_type:
|
||||
description: '版本更新类型'
|
||||
required: true
|
||||
@@ -45,32 +41,21 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- 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 != 'core' && github.event.inputs.package != 'node-editor' }}
|
||||
if: ${{ github.event.inputs.package == 'behavior-tree' || github.event.inputs.package == 'editor-core' }}
|
||||
run: |
|
||||
cd packages/core
|
||||
pnpm run build
|
||||
|
||||
- name: Build node-editor package (if needed for blueprint)
|
||||
if: ${{ github.event.inputs.package == 'blueprint' }}
|
||||
run: |
|
||||
cd packages/node-editor
|
||||
pnpm run build
|
||||
npm run build
|
||||
|
||||
# - name: Run tests
|
||||
# run: |
|
||||
@@ -82,33 +67,25 @@ jobs:
|
||||
run: |
|
||||
cd packages/${{ github.event.inputs.package }}
|
||||
if [ "${{ github.event.inputs.version_type }}" = "custom" ]; then
|
||||
NEW_VERSION=${{ github.event.inputs.custom_version }}
|
||||
npm version ${{ github.event.inputs.custom_version }} --no-git-tag-version --allow-same-version
|
||||
else
|
||||
# Get current version and bump it
|
||||
CURRENT=$(node -p "require('./package.json').version")
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||
case "${{ github.event.inputs.version_type }}" in
|
||||
major) NEW_VERSION="$((MAJOR+1)).0.0" ;;
|
||||
minor) NEW_VERSION="$MAJOR.$((MINOR+1)).0" ;;
|
||||
patch) NEW_VERSION="$MAJOR.$MINOR.$((PATCH+1))" ;;
|
||||
esac
|
||||
npm version ${{ github.event.inputs.version_type }} --no-git-tag-version
|
||||
fi
|
||||
# Update package.json using node
|
||||
node -e "const fs=require('fs'); const pkg=JSON.parse(fs.readFileSync('package.json')); pkg.version='$NEW_VERSION'; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)+'\n')"
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "发布版本: $NEW_VERSION"
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
cd packages/${{ github.event.inputs.package }}
|
||||
pnpm run build:npm
|
||||
npm run build:npm
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
cd packages/${{ github.event.inputs.package }}/dist
|
||||
pnpm publish --access public --no-git-checks
|
||||
npm publish --access public
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
|
||||
@@ -22,24 +22,19 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- 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
-6
@@ -16,10 +16,6 @@ dist/
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
.build-cache/
|
||||
|
||||
# Turborepo
|
||||
.turbo/
|
||||
|
||||
# IDE 配置
|
||||
.idea/
|
||||
@@ -52,9 +48,9 @@ logs/
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# 包管理器锁文件(忽略yarn,保留pnpm)
|
||||
# 包管理器锁文件(保留npm的,忽略其他的)
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
|
||||
# 文档生成
|
||||
docs/api/
|
||||
|
||||
@@ -37,6 +37,9 @@ This project follows the [Conventional Commits](https://www.conventionalcommits.
|
||||
|
||||
- **core**: 核心包 @esengine/ecs-framework
|
||||
- **math**: 数学库包
|
||||
- **network-client**: 网络客户端包
|
||||
- **network-server**: 网络服务端包
|
||||
- **network-shared**: 网络共享包
|
||||
- **editor**: 编辑器
|
||||
- **docs**: 文档
|
||||
|
||||
|
||||
@@ -28,15 +28,11 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
},
|
||||
title: 'ESEngine',
|
||||
description: '高性能 TypeScript ECS 框架 - 为游戏开发而生',
|
||||
title: 'ECS Framework',
|
||||
description: '高性能TypeScript ECS框架 - 为游戏开发而生',
|
||||
lang: 'zh-CN',
|
||||
|
||||
appearance: 'force-dark',
|
||||
|
||||
themeConfig: {
|
||||
siteTitle: 'ESEngine',
|
||||
|
||||
nav: [
|
||||
{ text: '首页', link: '/' },
|
||||
{ text: '快速开始', link: '/guide/getting-started' },
|
||||
@@ -69,7 +65,6 @@ export default defineConfig({
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '实体类 (Entity)', link: '/guide/entity' },
|
||||
{ text: '层级系统 (Hierarchy)', link: '/guide/hierarchy' },
|
||||
{ text: '组件系统 (Component)', link: '/guide/component' },
|
||||
{ text: '实体查询系统', link: '/guide/entity-query' },
|
||||
{
|
||||
@@ -223,7 +218,7 @@ export default defineConfig({
|
||||
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: '在这个页面上'
|
||||
label: '目录'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -232,9 +227,7 @@ export default defineConfig({
|
||||
['link', { rel: 'icon', href: '/favicon.ico' }]
|
||||
],
|
||||
|
||||
// 使用自定义域名 esengine.cn 时,base 设置为 '/'
|
||||
// 如果部署到 GitHub Pages 子路径,改为 '/ecs-framework/'
|
||||
base: '/',
|
||||
base: '/ecs-framework/',
|
||||
cleanUrls: true,
|
||||
|
||||
markdown: {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: String,
|
||||
description: String,
|
||||
icon: String,
|
||||
link: String,
|
||||
image: String
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a :href="link" class="feature-card">
|
||||
<div class="card-image" v-if="image">
|
||||
<img :src="image" :alt="title" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-icon" v-if="icon && !image">{{ icon }}</div>
|
||||
<h3 class="card-title">{{ title }}</h3>
|
||||
<p class="card-description">{{ description }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feature-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--es-bg-elevated, #252526);
|
||||
border: 1px solid var(--es-border-default, #3e3e42);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--es-primary, #007acc);
|
||||
background: var(--es-bg-overlay, #2d2d2d);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #1e3a5f 0%, #1e1e1e 100%);
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover .card-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 12px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--es-bg-input, #3c3c3c);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--es-text-inverse, #ffffff);
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 12px;
|
||||
color: var(--es-text-secondary, #9d9d9d);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,422 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const canvasRef = ref(null)
|
||||
let animationId = null
|
||||
let particles = []
|
||||
let animationStartTime = null
|
||||
let glowStartTime = null
|
||||
|
||||
// ESEngine 粒子颜色 - VS Code 风格配色(与编辑器统一)
|
||||
const colors = ['#569CD6', '#4EC9B0', '#9CDCFE', '#C586C0', '#DCDCAA']
|
||||
|
||||
class Particle {
|
||||
constructor(x, y, targetX, targetY) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.targetX = targetX
|
||||
this.targetY = targetY
|
||||
this.size = Math.random() * 2 + 1.5
|
||||
this.alpha = Math.random() * 0.5 + 0.5
|
||||
this.color = colors[Math.floor(Math.random() * colors.length)]
|
||||
}
|
||||
}
|
||||
|
||||
function createParticles(canvas, text, fontSize) {
|
||||
const tempCanvas = document.createElement('canvas')
|
||||
const tempCtx = tempCanvas.getContext('2d')
|
||||
if (!tempCtx) return []
|
||||
|
||||
tempCtx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`
|
||||
const textMetrics = tempCtx.measureText(text)
|
||||
const textWidth = textMetrics.width
|
||||
const textHeight = fontSize
|
||||
|
||||
tempCanvas.width = textWidth + 40
|
||||
tempCanvas.height = textHeight + 40
|
||||
tempCtx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`
|
||||
tempCtx.textAlign = 'center'
|
||||
tempCtx.textBaseline = 'middle'
|
||||
tempCtx.fillStyle = '#ffffff'
|
||||
tempCtx.fillText(text, tempCanvas.width / 2, tempCanvas.height / 2)
|
||||
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height)
|
||||
const pixels = imageData.data
|
||||
const newParticles = []
|
||||
const gap = 3
|
||||
|
||||
const width = canvas.width / (window.devicePixelRatio || 1)
|
||||
const height = canvas.height / (window.devicePixelRatio || 1)
|
||||
const offsetX = (width - tempCanvas.width) / 2
|
||||
const offsetY = (height - tempCanvas.height) / 2
|
||||
|
||||
for (let y = 0; y < tempCanvas.height; y += gap) {
|
||||
for (let x = 0; x < tempCanvas.width; x += gap) {
|
||||
const index = (y * tempCanvas.width + x) * 4
|
||||
const alpha = pixels[index + 3] || 0
|
||||
|
||||
if (alpha > 128) {
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const distance = Math.random() * Math.max(width, height)
|
||||
|
||||
newParticles.push(new Particle(
|
||||
width / 2 + Math.cos(angle) * distance,
|
||||
height / 2 + Math.sin(angle) * distance,
|
||||
offsetX + x,
|
||||
offsetY + y
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newParticles
|
||||
}
|
||||
|
||||
function easeOutQuart(t) {
|
||||
return 1 - Math.pow(1 - t, 4)
|
||||
}
|
||||
|
||||
function easeOutCubic(t) {
|
||||
return 1 - Math.pow(1 - t, 3)
|
||||
}
|
||||
|
||||
function animate(canvas, ctx) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const width = canvas.width / dpr
|
||||
const height = canvas.height / dpr
|
||||
|
||||
const currentTime = performance.now()
|
||||
const duration = 2500
|
||||
const glowDuration = 600
|
||||
|
||||
const elapsed = currentTime - animationStartTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const easedProgress = easeOutQuart(progress)
|
||||
|
||||
// 透明背景
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
// 计算发光进度
|
||||
let glowProgress = 0
|
||||
if (progress >= 1) {
|
||||
if (glowStartTime === null) {
|
||||
glowStartTime = currentTime
|
||||
}
|
||||
glowProgress = Math.min((currentTime - glowStartTime) / glowDuration, 1)
|
||||
glowProgress = easeOutCubic(glowProgress)
|
||||
}
|
||||
|
||||
const text = 'ESEngine'
|
||||
const fontSize = Math.min(width / 4, height / 3, 80)
|
||||
const textY = height / 2
|
||||
|
||||
for (const particle of particles) {
|
||||
const moveProgress = Math.min(easedProgress * 1.2, 1)
|
||||
const currentX = particle.x + (particle.targetX - particle.x) * moveProgress
|
||||
const currentY = particle.y + (particle.targetY - particle.y) * moveProgress
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(currentX, currentY, particle.size, 0, Math.PI * 2)
|
||||
ctx.fillStyle = particle.color
|
||||
ctx.globalAlpha = particle.alpha * (1 - glowProgress * 0.3)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
if (glowProgress > 0) {
|
||||
ctx.save()
|
||||
ctx.shadowColor = '#3b9eff'
|
||||
ctx.shadowBlur = 30 * glowProgress
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${glowProgress})`
|
||||
ctx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(text, width / 2, textY)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
if (glowProgress >= 1) {
|
||||
const breathe = 0.8 + Math.sin(currentTime / 1000) * 0.2
|
||||
ctx.save()
|
||||
ctx.shadowColor = '#3b9eff'
|
||||
ctx.shadowBlur = 20 * breathe
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(text, width / 2, textY)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(() => animate(canvas, ctx))
|
||||
}
|
||||
|
||||
function initCanvas() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const container = canvas.parentElement
|
||||
const width = container.offsetWidth
|
||||
const height = container.offsetHeight
|
||||
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
const text = 'ESEngine'
|
||||
const fontSize = Math.min(width / 4, height / 3, 80)
|
||||
|
||||
particles = createParticles(canvas, text, fontSize)
|
||||
animationStartTime = performance.now()
|
||||
glowStartTime = null
|
||||
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId)
|
||||
}
|
||||
|
||||
animate(canvas, ctx)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas()
|
||||
window.addEventListener('resize', initCanvas)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId)
|
||||
}
|
||||
window.removeEventListener('resize', initCanvas)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hero-section">
|
||||
<div class="hero-container">
|
||||
<!-- 左侧文字区域 -->
|
||||
<div class="hero-text">
|
||||
<div class="hero-logo">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="14" stroke="#9147ff" stroke-width="2"/>
|
||||
<path d="M10 10h8v2h-6v3h5v2h-5v3h6v2h-8v-12z" fill="#9147ff"/>
|
||||
</svg>
|
||||
<span>ESENGINE</span>
|
||||
</div>
|
||||
<h1 class="hero-title">
|
||||
我们构建框架。<br/>
|
||||
而你将创造游戏。
|
||||
</h1>
|
||||
<p class="hero-description">
|
||||
ESEngine 是一个高性能的 TypeScript ECS 框架,为游戏开发者提供现代化的实体组件系统。
|
||||
无论是 2D 还是 3D 游戏,都能帮助你快速构建可扩展的游戏架构。
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="/guide/getting-started" class="btn-primary">开始使用</a>
|
||||
<a href="https://github.com/esengine/ecs-framework" class="btn-secondary" target="_blank">了解更多</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧粒子动画区域 -->
|
||||
<div class="hero-visual">
|
||||
<div class="visual-container">
|
||||
<canvas ref="canvasRef" class="particle-canvas"></canvas>
|
||||
<div class="visual-label">
|
||||
<span class="label-title">Entity Component System</span>
|
||||
<span class="label-subtitle">High Performance Framework</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-section {
|
||||
background: #0d0d0d;
|
||||
padding: 80px 0;
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 48px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
gap: 64px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 左侧文字 */
|
||||
.hero-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.hero-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 1.125rem;
|
||||
color: #707070;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 28px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b9eff;
|
||||
color: #ffffff;
|
||||
border: 1px solid #3b9eff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5aadff;
|
||||
border-color: #5aadff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #1a1a1a;
|
||||
color: #a0a0a0;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #252525;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-visual {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.visual-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: linear-gradient(135deg, #1a2a3a 0%, #1a1a1a 50%, #0d0d0d 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid #2a2a2a;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(59, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.particle-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.visual-label {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.label-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.label-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1024px) {
|
||||
.hero-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 48px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
padding: 48px 0;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.visual-container {
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,631 +0,0 @@
|
||||
/* ============================================
|
||||
ESEngine 文档站主题样式
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--vp-nav-height: 64px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0d0d0d !important;
|
||||
}
|
||||
|
||||
.VPContent.has-sidebar {
|
||||
background: linear-gradient(180deg, #1e3a5f 0%, #152540 30vh, #0d1a2a 50vh, #0d0d0d 70vh) !important;
|
||||
background-repeat: no-repeat !important;
|
||||
}
|
||||
|
||||
html,
|
||||
html.dark {
|
||||
--vp-c-bg: #0d0d0d;
|
||||
--vp-c-bg-soft: #1a1a1a;
|
||||
--vp-c-bg-mute: #1f1f1f;
|
||||
--vp-c-bg-alt: #1a1a1a;
|
||||
--vp-c-text-1: #e0e0e0;
|
||||
--vp-c-text-2: #a0a0a0;
|
||||
--vp-c-text-3: #707070;
|
||||
--vp-c-divider: #2a2a2a;
|
||||
--vp-c-divider-light: #222222;
|
||||
}
|
||||
|
||||
html:not(.dark) {
|
||||
--vp-c-bg: #0d0d0d !important;
|
||||
--vp-c-bg-soft: #1a1a1a !important;
|
||||
--vp-c-bg-mute: #1f1f1f !important;
|
||||
--vp-c-bg-alt: #1a1a1a !important;
|
||||
--vp-c-text-1: #e0e0e0 !important;
|
||||
--vp-c-text-2: #a0a0a0 !important;
|
||||
--vp-c-text-3: #707070 !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
--es-bg-base: #0d0d0d;
|
||||
--es-bg-sidebar: #1a1a1a;
|
||||
--es-bg-card: #1f1f1f;
|
||||
--es-bg-hover: #252525;
|
||||
--es-bg-selected: #0e4a7c;
|
||||
|
||||
--es-text-primary: #e0e0e0;
|
||||
--es-text-secondary: #a0a0a0;
|
||||
--es-text-tertiary: #707070;
|
||||
--es-text-inverse: #ffffff;
|
||||
|
||||
--es-border-default: #2a2a2a;
|
||||
--es-border-subtle: #222222;
|
||||
|
||||
--es-primary: #3b9eff;
|
||||
--es-warning: #c9a227;
|
||||
--es-info: #3b9eff;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
导航栏
|
||||
============================================ */
|
||||
.VPNav {
|
||||
background: #0d0d0d !important;
|
||||
border-bottom: 1px solid #2a2a2a !important;
|
||||
}
|
||||
|
||||
.VPNav .VPNavBar {
|
||||
background: #0d0d0d !important;
|
||||
}
|
||||
|
||||
.VPNav .VPNavBar .wrapper {
|
||||
background: #0d0d0d !important;
|
||||
}
|
||||
|
||||
.VPNav .VPNavBar::before,
|
||||
.VPNav .VPNavBar::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.VPNavBar {
|
||||
background: #0d0d0d !important;
|
||||
}
|
||||
|
||||
.VPNavBar::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.VPNavBarTitle .title {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.VPNavBarMenuLink {
|
||||
color: #a0a0a0 !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.VPNavBarMenuLink:hover {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.VPNavBarMenuLink.active {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.VPNavBarSearch .DocSearch-Button {
|
||||
background: #1a1a1a !important;
|
||||
border: 1px solid #2a2a2a !important;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
侧边栏
|
||||
============================================ */
|
||||
.VPSidebar {
|
||||
background: transparent !important;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
padding: 16px 0 !important;
|
||||
width: 280px !important;
|
||||
}
|
||||
|
||||
.VPSidebar .nav {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.VPSidebarItem.level-0 > .item {
|
||||
padding: 12px 0 6px 0;
|
||||
}
|
||||
|
||||
.VPSidebarItem.level-0 > .item > .text {
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
color: #808080;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.VPSidebarItem .link {
|
||||
padding: 6px 12px;
|
||||
margin: 1px 0;
|
||||
border-radius: 4px;
|
||||
color: #9d9d9d;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.VPSidebarItem .link:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.VPSidebarItem.is-active > .item > .link {
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
border-left: 2px solid #3b9eff;
|
||||
}
|
||||
|
||||
.VPSidebarItem.level-1 .link {
|
||||
padding-left: 24px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.VPSidebarItem.level-2 .link {
|
||||
padding-left: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.VPSidebarItem .caret {
|
||||
color: #606060;
|
||||
}
|
||||
|
||||
.VPSidebarItem .caret:hover {
|
||||
color: #9d9d9d;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
内容区域
|
||||
============================================ */
|
||||
.VPContent {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.VPDoc {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.VPDoc .container {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.VPDoc .content {
|
||||
max-width: 860px !important;
|
||||
padding: 80px 60px 48px !important;
|
||||
}
|
||||
|
||||
.VPNavBar {
|
||||
background: #0d0d0d !important;
|
||||
}
|
||||
|
||||
.VPNavBar .content {
|
||||
background: #0d0d0d !important;
|
||||
}
|
||||
|
||||
.VPNavBar .content-body {
|
||||
background: #0d0d0d !important;
|
||||
}
|
||||
|
||||
.VPNavBar .divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.VPLocalNav {
|
||||
background: #0d0d0d !important;
|
||||
border-bottom: 1px solid #2a2a2a !important;
|
||||
}
|
||||
|
||||
.VPNavScreenMenu {
|
||||
background: #0d0d0d !important;
|
||||
}
|
||||
|
||||
.VPNavScreen {
|
||||
background: #0d0d0d !important;
|
||||
}
|
||||
|
||||
.curtain {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.VPNav .curtain,
|
||||
.VPNavBar .curtain {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[class*="curtain"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.VPNav > div::before,
|
||||
.VPNav > div::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vp-doc {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.vp-doc h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.vp-doc h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
margin-top: 48px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.vp-doc h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vp-doc p {
|
||||
color: #a0a0a0;
|
||||
line-height: 1.8;
|
||||
font-size: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.vp-doc ul,
|
||||
.vp-doc ol {
|
||||
padding-left: 24px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.vp-doc li {
|
||||
line-height: 1.8;
|
||||
margin: 8px 0;
|
||||
color: #a0a0a0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.vp-doc li::marker {
|
||||
color: #707070;
|
||||
}
|
||||
|
||||
.vp-doc strong {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vp-doc a {
|
||||
color: #3b9eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.vp-doc a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
右侧大纲
|
||||
============================================ */
|
||||
.VPDocAside {
|
||||
padding-left: 32px;
|
||||
border-left: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.VPDocAsideOutline {
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.VPDocAsideOutline .content {
|
||||
border: none !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.VPDocAsideOutline .outline-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #ffffff;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.VPDocAsideOutline .outline-link {
|
||||
color: #707070;
|
||||
font-size: 13px;
|
||||
padding: 6px 0;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.VPDocAsideOutline .outline-link:hover {
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.VPDocAsideOutline .outline-link.active {
|
||||
color: #3b9eff;
|
||||
}
|
||||
|
||||
.VPDocAsideOutline .outline-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
代码块
|
||||
============================================ */
|
||||
div[class*='language-'] {
|
||||
background: #1a1a1a !important;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.vp-code-group .tabs {
|
||||
background: #1f1f1f;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.vp-doc :not(pre) > code {
|
||||
background: #1f1f1f;
|
||||
color: #3b9eff;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
表格
|
||||
============================================ */
|
||||
.vp-doc table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-collapse: collapse;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.vp-doc tr {
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.vp-doc tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.vp-doc th {
|
||||
background: #1f1f1f;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #a0a0a0;
|
||||
text-align: left;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.vp-doc td {
|
||||
font-size: 15px;
|
||||
color: #e0e0e0;
|
||||
padding: 14px 20px;
|
||||
vertical-align: top;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.vp-doc td:first-child {
|
||||
font-weight: 500;
|
||||
color: #a0a0a0;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
提示框
|
||||
============================================ */
|
||||
.vp-doc .warning,
|
||||
.vp-doc .custom-block.warning {
|
||||
background: rgba(201, 162, 39, 0.08);
|
||||
border: none;
|
||||
border-left: 4px solid #c9a227;
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 16px 24px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.vp-doc .warning .custom-block-title,
|
||||
.vp-doc .custom-block.warning .custom-block-title {
|
||||
color: #c9a227;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vp-doc .warning p {
|
||||
color: #a0a0a0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vp-doc .tip,
|
||||
.vp-doc .custom-block.tip {
|
||||
background: rgba(59, 158, 255, 0.08);
|
||||
border: none;
|
||||
border-left: 4px solid #3b9eff;
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 16px 24px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.vp-doc .tip .custom-block-title,
|
||||
.vp-doc .custom-block.tip .custom-block-title {
|
||||
color: #3b9eff;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vp-doc .tip p {
|
||||
color: #a0a0a0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vp-doc .info,
|
||||
.vp-doc .custom-block.info {
|
||||
background: rgba(78, 201, 176, 0.08);
|
||||
border: none;
|
||||
border-left: 4px solid #4ec9b0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 16px 24px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.vp-doc .info .custom-block-title,
|
||||
.vp-doc .custom-block.info .custom-block-title {
|
||||
color: #4ec9b0;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vp-doc .danger,
|
||||
.vp-doc .custom-block.danger {
|
||||
background: rgba(244, 135, 113, 0.08);
|
||||
border: none;
|
||||
border-left: 4px solid #f48771;
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 16px 24px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.vp-doc .danger .custom-block-title,
|
||||
.vp-doc .custom-block.danger .custom-block-title {
|
||||
color: #f48771;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
卡片样式
|
||||
============================================ */
|
||||
.vp-doc .card {
|
||||
background: #1f1f1f;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.vp-doc .card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vp-doc .card-description {
|
||||
font-size: 14px;
|
||||
color: #707070;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
标签样式
|
||||
============================================ */
|
||||
.vp-doc .tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 16px;
|
||||
color: #a0a0a0;
|
||||
font-size: 13px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
链接行样式
|
||||
============================================ */
|
||||
.vp-doc .link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
color: #a0a0a0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.vp-doc .link-row a {
|
||||
color: #3b9eff;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
页脚
|
||||
============================================ */
|
||||
.VPFooter {
|
||||
background: #1a1a1a !important;
|
||||
border-top: 1px solid #2a2a2a !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
滚动条
|
||||
============================================ */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3a3a3a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
首页专用样式
|
||||
============================================ */
|
||||
.home-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.home-section {
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
响应式
|
||||
============================================ */
|
||||
@media (max-width: 960px) {
|
||||
.VPDoc .content {
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
.VPSidebar {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import ParticleHero from './components/ParticleHero.vue'
|
||||
import FeatureCard from './components/FeatureCard.vue'
|
||||
import './custom.css'
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
enhanceApp({ app }) {
|
||||
app.component('ParticleHero', ParticleHero)
|
||||
app.component('FeatureCard', FeatureCard)
|
||||
}
|
||||
}
|
||||
+8
-75
@@ -55,94 +55,27 @@ class Health extends Component {
|
||||
}
|
||||
```
|
||||
|
||||
### @ECSComponent 装饰器
|
||||
### 组件装饰器
|
||||
|
||||
`@ECSComponent` 是组件类必须使用的装饰器,它为组件提供了类型标识和元数据管理。
|
||||
|
||||
#### 为什么必须使用
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **类型识别** | 提供稳定的类型名称,代码混淆后仍能正确识别 |
|
||||
| **序列化支持** | 序列化/反序列化时使用该名称作为类型标识 |
|
||||
| **组件注册** | 自动注册到 ComponentRegistry,分配唯一的位掩码 |
|
||||
| **调试支持** | 在调试工具和日志中显示可读的组件名称 |
|
||||
|
||||
#### 基本语法
|
||||
**必须使用 `@ECSComponent` 装饰器**,这确保了:
|
||||
- 组件在代码混淆后仍能正确识别
|
||||
- 提供稳定的类型名称用于序列化和调试
|
||||
- 框架能正确管理组件注册
|
||||
|
||||
```typescript
|
||||
@ECSComponent(typeName: string)
|
||||
```
|
||||
|
||||
- `typeName`: 组件的类型名称,建议使用与类名相同或相近的名称
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```typescript
|
||||
// ✅ 正确的用法
|
||||
// 正确的用法
|
||||
@ECSComponent('Velocity')
|
||||
class Velocity extends Component {
|
||||
dx: number = 0;
|
||||
dy: number = 0;
|
||||
}
|
||||
|
||||
// ✅ 推荐:类型名与类名保持一致
|
||||
@ECSComponent('PlayerController')
|
||||
class PlayerController extends Component {
|
||||
speed: number = 5;
|
||||
}
|
||||
|
||||
// ❌ 错误的用法 - 没有装饰器
|
||||
// 错误的用法 - 没有装饰器
|
||||
class BadComponent extends Component {
|
||||
// 这样定义的组件可能在生产环境出现问题:
|
||||
// 1. 代码压缩后类名变化,无法正确序列化
|
||||
// 2. 组件未注册到框架,查询和匹配可能失效
|
||||
// 这样定义的组件可能在生产环境出现问题
|
||||
}
|
||||
```
|
||||
|
||||
#### 与 @Serializable 配合使用
|
||||
|
||||
当组件需要支持序列化时,`@ECSComponent` 和 `@Serializable` 需要一起使用:
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Player')
|
||||
@Serializable({ version: 1 })
|
||||
class PlayerComponent extends Component {
|
||||
@Serialize()
|
||||
name: string = '';
|
||||
|
||||
@Serialize()
|
||||
level: number = 1;
|
||||
|
||||
// 不使用 @Serialize() 的字段不会被序列化
|
||||
private _cachedData: any = null;
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`@ECSComponent` 的 `typeName` 和 `@Serializable` 的 `typeId` 可以不同。如果 `@Serializable` 没有指定 `typeId`,则默认使用 `@ECSComponent` 的 `typeName`。
|
||||
|
||||
#### 组件类型名的唯一性
|
||||
|
||||
每个组件的类型名应该是唯一的:
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:两个组件使用相同的类型名
|
||||
@ECSComponent('Health')
|
||||
class HealthComponent extends Component { }
|
||||
|
||||
@ECSComponent('Health') // 冲突!
|
||||
class EnemyHealthComponent extends Component { }
|
||||
|
||||
// ✅ 正确:使用不同的类型名
|
||||
@ECSComponent('PlayerHealth')
|
||||
class PlayerHealthComponent extends Component { }
|
||||
|
||||
@ECSComponent('EnemyHealth')
|
||||
class EnemyHealthComponent extends Component { }
|
||||
```
|
||||
|
||||
## 组件生命周期
|
||||
|
||||
组件提供了生命周期钩子,可以重写来执行特定的逻辑:
|
||||
|
||||
@@ -121,65 +121,6 @@ class CombatSystem extends EntitySystem {
|
||||
}
|
||||
```
|
||||
|
||||
#### nothing() - 不匹配任何实体
|
||||
|
||||
用于创建只需要生命周期方法(`onBegin`、`onEnd`)但不需要处理实体的系统。
|
||||
|
||||
```typescript
|
||||
class FrameTimerSystem extends EntitySystem {
|
||||
constructor() {
|
||||
// 不匹配任何实体
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
|
||||
protected onBegin(): void {
|
||||
// 每帧开始时执行
|
||||
Performance.markFrameStart();
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
// 永远不会被调用,因为没有匹配的实体
|
||||
}
|
||||
|
||||
protected onEnd(): void {
|
||||
// 每帧结束时执行
|
||||
Performance.markFrameEnd();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### empty() vs nothing() 的区别
|
||||
|
||||
| 方法 | 行为 | 使用场景 |
|
||||
|------|------|----------|
|
||||
| `Matcher.empty()` | 匹配**所有**实体 | 需要处理场景中所有实体 |
|
||||
| `Matcher.nothing()` | 不匹配**任何**实体 | 只需要生命周期回调,不处理实体 |
|
||||
|
||||
```typescript
|
||||
// empty() - 返回场景中的所有实体
|
||||
class AllEntitiesSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty());
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
// entities 包含场景中的所有实体
|
||||
console.log(`场景中共有 ${entities.length} 个实体`);
|
||||
}
|
||||
}
|
||||
|
||||
// nothing() - 不返回任何实体
|
||||
class NoEntitiesSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
// entities 永远是空数组,此方法不会被调用
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 按标签查询
|
||||
|
||||
```typescript
|
||||
@@ -552,65 +493,6 @@ const matcher2 = matcher.any(VelocityComponent);
|
||||
console.log(matcher === matcher2); // false
|
||||
```
|
||||
|
||||
## Matcher API 快速参考
|
||||
|
||||
### 静态创建方法
|
||||
|
||||
| 方法 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `Matcher.all(...types)` | 必须包含所有指定组件 | `Matcher.all(Position, Velocity)` |
|
||||
| `Matcher.any(...types)` | 至少包含一个指定组件 | `Matcher.any(Health, Shield)` |
|
||||
| `Matcher.none(...types)` | 不能包含任何指定组件 | `Matcher.none(Dead)` |
|
||||
| `Matcher.byTag(tag)` | 按标签查询 | `Matcher.byTag(1)` |
|
||||
| `Matcher.byName(name)` | 按名称查询 | `Matcher.byName("Player")` |
|
||||
| `Matcher.byComponent(type)` | 按单个组件查询 | `Matcher.byComponent(Health)` |
|
||||
| `Matcher.empty()` | 创建空匹配器(匹配所有实体) | `Matcher.empty()` |
|
||||
| `Matcher.nothing()` | 不匹配任何实体 | `Matcher.nothing()` |
|
||||
| `Matcher.complex()` | 创建复杂查询构建器 | `Matcher.complex()` |
|
||||
|
||||
### 链式方法
|
||||
|
||||
| 方法 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `.all(...types)` | 添加必须包含的组件 | `.all(Position)` |
|
||||
| `.any(...types)` | 添加可选组件(至少一个) | `.any(Weapon, Magic)` |
|
||||
| `.none(...types)` | 添加排除的组件 | `.none(Dead)` |
|
||||
| `.exclude(...types)` | `.none()` 的别名 | `.exclude(Disabled)` |
|
||||
| `.one(...types)` | `.any()` 的别名 | `.one(Player, Enemy)` |
|
||||
| `.withTag(tag)` | 添加标签条件 | `.withTag(1)` |
|
||||
| `.withName(name)` | 添加名称条件 | `.withName("Boss")` |
|
||||
| `.withComponent(type)` | 添加单组件条件 | `.withComponent(Health)` |
|
||||
|
||||
### 实用方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `.getCondition()` | 获取查询条件(只读) |
|
||||
| `.isEmpty()` | 检查是否为空条件 |
|
||||
| `.isNothing()` | 检查是否为 nothing 匹配器 |
|
||||
| `.clone()` | 克隆匹配器 |
|
||||
| `.reset()` | 重置所有条件 |
|
||||
| `.toString()` | 获取字符串表示 |
|
||||
|
||||
### 常用组合示例
|
||||
|
||||
```typescript
|
||||
// 基础移动系统
|
||||
Matcher.all(Position, Velocity)
|
||||
|
||||
// 可攻击的活着的实体
|
||||
Matcher.all(Position, Health)
|
||||
.any(Weapon, Magic)
|
||||
.none(Dead, Disabled)
|
||||
|
||||
// 所有带标签的敌人
|
||||
Matcher.byTag(Tags.ENEMY)
|
||||
.all(AIComponent)
|
||||
|
||||
// 只需要生命周期的系统
|
||||
Matcher.nothing()
|
||||
```
|
||||
|
||||
## 相关 API
|
||||
|
||||
- [Matcher](../api/classes/Matcher.md) - 查询条件描述符 API 参考
|
||||
|
||||
+1
-13
@@ -9,12 +9,6 @@
|
||||
- 提供唯一标识(ID)
|
||||
- 管理组件的生命周期
|
||||
|
||||
::: tip 关于父子层级关系
|
||||
实体间的父子层级关系通过 `HierarchyComponent` 和 `HierarchySystem` 管理,而非 Entity 内置属性。这种设计遵循 ECS 组合原则 —— 只有需要层级关系的实体才添加此组件。
|
||||
|
||||
详见 [层级系统](./hierarchy.md) 文档。
|
||||
:::
|
||||
|
||||
## 创建实体
|
||||
|
||||
**重要提示:实体必须通过场景创建,不支持手动创建!**
|
||||
@@ -291,10 +285,4 @@ entity.components.forEach(component => {
|
||||
});
|
||||
```
|
||||
|
||||
实体是 ECS 架构的核心概念之一,理解如何正确使用实体将帮助你构建高效、可维护的游戏代码。
|
||||
|
||||
## 下一步
|
||||
|
||||
- 了解 [层级系统](./hierarchy.md) 建立实体间的父子关系
|
||||
- 了解 [组件系统](./component.md) 为实体添加功能
|
||||
- 了解 [场景管理](./scene.md) 组织和管理实体
|
||||
实体是 ECS 架构的核心概念之一,理解如何正确使用实体将帮助你构建高效、可维护的游戏代码。
|
||||
@@ -23,6 +23,7 @@ import { Core } from '@esengine/ecs-framework'
|
||||
// 方式1:使用配置对象(推荐)
|
||||
const core = Core.create({
|
||||
debug: true, // 启用调试模式,提供详细的日志和性能监控
|
||||
enableEntitySystems: true, // 启用实体系统,这是ECS的核心功能
|
||||
debugConfig: { // 可选:高级调试配置
|
||||
enabled: false, // 是否启用WebSocket调试服务器
|
||||
websocketUrl: 'ws://localhost:8080',
|
||||
@@ -38,11 +39,12 @@ const core = Core.create({
|
||||
});
|
||||
|
||||
// 方式2:简化创建(向后兼容)
|
||||
const core = Core.create(true); // 等同于 { debug: true }
|
||||
const core = Core.create(true); // 等同于 { debug: true, enableEntitySystems: true }
|
||||
|
||||
// 方式3:生产环境配置
|
||||
const core = Core.create({
|
||||
debug: false // 生产环境关闭调试
|
||||
debug: false, // 生产环境关闭调试
|
||||
enableEntitySystems: true
|
||||
});
|
||||
```
|
||||
|
||||
@@ -53,6 +55,9 @@ interface ICoreConfig {
|
||||
/** 是否启用调试模式 - 影响日志级别和性能监控 */
|
||||
debug?: boolean;
|
||||
|
||||
/** 是否启用实体系统 - 核心ECS功能开关 */
|
||||
enableEntitySystems?: boolean;
|
||||
|
||||
/** 高级调试配置 - 用于开发工具集成 */
|
||||
debugConfig?: {
|
||||
enabled: boolean; // 是否启用调试服务器
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
# 层级系统
|
||||
|
||||
在游戏开发中,实体间的父子层级关系是常见需求。ECS Framework 采用组件化方式管理层级关系,通过 `HierarchyComponent` 和 `HierarchySystem` 实现,完全遵循 ECS 组合原则。
|
||||
|
||||
## 设计理念
|
||||
|
||||
### 为什么不在 Entity 中内置层级?
|
||||
|
||||
传统的游戏对象模型(如 Unity 的 GameObject)将层级关系内置于实体中。ECS Framework 选择组件化方案的原因:
|
||||
|
||||
1. **ECS 组合原则**:层级是一种"功能",应该通过组件添加,而非所有实体都具备
|
||||
2. **按需使用**:只有需要层级关系的实体才添加 `HierarchyComponent`
|
||||
3. **数据与逻辑分离**:`HierarchyComponent` 存储数据,`HierarchySystem` 处理逻辑
|
||||
4. **序列化友好**:层级关系作为组件数据可以轻松序列化和反序列化
|
||||
|
||||
## 基本概念
|
||||
|
||||
### HierarchyComponent
|
||||
|
||||
存储层级关系数据的组件:
|
||||
|
||||
```typescript
|
||||
import { HierarchyComponent } from '@esengine/ecs-framework';
|
||||
|
||||
// HierarchyComponent 的核心属性
|
||||
interface HierarchyComponent {
|
||||
parentId: number | null; // 父实体 ID,null 表示根实体
|
||||
childIds: number[]; // 子实体 ID 列表
|
||||
depth: number; // 在层级中的深度(由系统维护)
|
||||
bActiveInHierarchy: boolean; // 在层级中是否激活(由系统维护)
|
||||
}
|
||||
```
|
||||
|
||||
### HierarchySystem
|
||||
|
||||
处理层级逻辑的系统,提供所有层级操作的 API:
|
||||
|
||||
```typescript
|
||||
import { HierarchySystem } from '@esengine/ecs-framework';
|
||||
|
||||
// 获取系统
|
||||
const hierarchySystem = scene.getEntityProcessor(HierarchySystem);
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 添加系统到场景
|
||||
|
||||
```typescript
|
||||
import { Scene, HierarchySystem } from '@esengine/ecs-framework';
|
||||
|
||||
class GameScene extends Scene {
|
||||
protected initialize(): void {
|
||||
// 添加层级系统
|
||||
this.addSystem(new HierarchySystem());
|
||||
|
||||
// 添加其他系统...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 建立父子关系
|
||||
|
||||
```typescript
|
||||
// 创建实体
|
||||
const parent = scene.createEntity("Parent");
|
||||
const child1 = scene.createEntity("Child1");
|
||||
const child2 = scene.createEntity("Child2");
|
||||
|
||||
// 获取层级系统
|
||||
const hierarchySystem = scene.getEntityProcessor(HierarchySystem);
|
||||
|
||||
// 设置父子关系(自动添加 HierarchyComponent)
|
||||
hierarchySystem.setParent(child1, parent);
|
||||
hierarchySystem.setParent(child2, parent);
|
||||
|
||||
// 现在 parent 有两个子实体
|
||||
```
|
||||
|
||||
### 查询层级
|
||||
|
||||
```typescript
|
||||
// 获取父实体
|
||||
const parentEntity = hierarchySystem.getParent(child1);
|
||||
|
||||
// 获取所有子实体
|
||||
const children = hierarchySystem.getChildren(parent);
|
||||
|
||||
// 获取子实体数量
|
||||
const count = hierarchySystem.getChildCount(parent);
|
||||
|
||||
// 检查是否有子实体
|
||||
const hasKids = hierarchySystem.hasChildren(parent);
|
||||
|
||||
// 获取在层级中的深度
|
||||
const depth = hierarchySystem.getDepth(child1); // 返回 1
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
### 父子关系操作
|
||||
|
||||
#### setParent
|
||||
|
||||
设置实体的父级:
|
||||
|
||||
```typescript
|
||||
// 设置父级
|
||||
hierarchySystem.setParent(child, parent);
|
||||
|
||||
// 移动到根级(无父级)
|
||||
hierarchySystem.setParent(child, null);
|
||||
```
|
||||
|
||||
#### insertChildAt
|
||||
|
||||
在指定位置插入子实体:
|
||||
|
||||
```typescript
|
||||
// 在第一个位置插入
|
||||
hierarchySystem.insertChildAt(parent, child, 0);
|
||||
|
||||
// 追加到末尾
|
||||
hierarchySystem.insertChildAt(parent, child, -1);
|
||||
```
|
||||
|
||||
#### removeChild
|
||||
|
||||
从父级移除子实体(子实体变为根级):
|
||||
|
||||
```typescript
|
||||
const success = hierarchySystem.removeChild(parent, child);
|
||||
```
|
||||
|
||||
#### removeAllChildren
|
||||
|
||||
移除所有子实体:
|
||||
|
||||
```typescript
|
||||
hierarchySystem.removeAllChildren(parent);
|
||||
```
|
||||
|
||||
### 层级查询
|
||||
|
||||
#### getParent / getChildren
|
||||
|
||||
```typescript
|
||||
const parent = hierarchySystem.getParent(entity);
|
||||
const children = hierarchySystem.getChildren(entity);
|
||||
```
|
||||
|
||||
#### getRoot
|
||||
|
||||
获取实体的根节点:
|
||||
|
||||
```typescript
|
||||
const root = hierarchySystem.getRoot(deepChild);
|
||||
```
|
||||
|
||||
#### getRootEntities
|
||||
|
||||
获取所有根实体(没有父级的实体):
|
||||
|
||||
```typescript
|
||||
const roots = hierarchySystem.getRootEntities();
|
||||
```
|
||||
|
||||
#### isAncestorOf / isDescendantOf
|
||||
|
||||
检查祖先/后代关系:
|
||||
|
||||
```typescript
|
||||
// grandparent -> parent -> child
|
||||
const isAncestor = hierarchySystem.isAncestorOf(grandparent, child); // true
|
||||
const isDescendant = hierarchySystem.isDescendantOf(child, grandparent); // true
|
||||
```
|
||||
|
||||
### 层级遍历
|
||||
|
||||
#### findChild
|
||||
|
||||
根据名称查找子实体:
|
||||
|
||||
```typescript
|
||||
// 直接子级中查找
|
||||
const child = hierarchySystem.findChild(parent, "ChildName");
|
||||
|
||||
// 递归查找所有后代
|
||||
const deepChild = hierarchySystem.findChild(parent, "DeepChild", true);
|
||||
```
|
||||
|
||||
#### findChildrenByTag
|
||||
|
||||
根据标签查找子实体:
|
||||
|
||||
```typescript
|
||||
// 查找直接子级
|
||||
const tagged = hierarchySystem.findChildrenByTag(parent, TAG_ENEMY);
|
||||
|
||||
// 递归查找
|
||||
const allTagged = hierarchySystem.findChildrenByTag(parent, TAG_ENEMY, true);
|
||||
```
|
||||
|
||||
#### forEachChild
|
||||
|
||||
遍历子实体:
|
||||
|
||||
```typescript
|
||||
// 遍历直接子级
|
||||
hierarchySystem.forEachChild(parent, (child) => {
|
||||
console.log(child.name);
|
||||
});
|
||||
|
||||
// 递归遍历所有后代
|
||||
hierarchySystem.forEachChild(parent, (child) => {
|
||||
console.log(child.name);
|
||||
}, true);
|
||||
```
|
||||
|
||||
### 层级状态
|
||||
|
||||
#### isActiveInHierarchy
|
||||
|
||||
检查实体在层级中是否激活(考虑所有祖先的激活状态):
|
||||
|
||||
```typescript
|
||||
// 如果 parent.active = false,即使 child.active = true
|
||||
// isActiveInHierarchy(child) 也会返回 false
|
||||
const activeInHierarchy = hierarchySystem.isActiveInHierarchy(child);
|
||||
```
|
||||
|
||||
#### getDepth
|
||||
|
||||
获取实体在层级中的深度(根实体深度为 0):
|
||||
|
||||
```typescript
|
||||
const depth = hierarchySystem.getDepth(entity);
|
||||
```
|
||||
|
||||
### 扁平化层级(用于 UI 渲染)
|
||||
|
||||
```typescript
|
||||
// 用于实现可展开/折叠的层级树视图
|
||||
const expandedIds = new Set([parent.id]);
|
||||
|
||||
const flatNodes = hierarchySystem.flattenHierarchy(expandedIds);
|
||||
// 返回 [{ entity, depth, bHasChildren, bIsExpanded }, ...]
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 创建游戏角色层级
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Scene,
|
||||
HierarchySystem,
|
||||
HierarchyComponent
|
||||
} from '@esengine/ecs-framework';
|
||||
|
||||
class GameScene extends Scene {
|
||||
private hierarchySystem!: HierarchySystem;
|
||||
|
||||
protected initialize(): void {
|
||||
// 添加层级系统
|
||||
this.hierarchySystem = new HierarchySystem();
|
||||
this.addSystem(this.hierarchySystem);
|
||||
|
||||
// 创建角色层级
|
||||
this.createPlayerHierarchy();
|
||||
}
|
||||
|
||||
private createPlayerHierarchy(): void {
|
||||
// 根实体
|
||||
const player = this.createEntity("Player");
|
||||
player.addComponent(new Transform(0, 0));
|
||||
|
||||
// 身体部件
|
||||
const body = this.createEntity("Body");
|
||||
body.addComponent(new Sprite("body.png"));
|
||||
this.hierarchySystem.setParent(body, player);
|
||||
|
||||
// 武器(挂载在身体上)
|
||||
const weapon = this.createEntity("Weapon");
|
||||
weapon.addComponent(new Sprite("sword.png"));
|
||||
this.hierarchySystem.setParent(weapon, body);
|
||||
|
||||
// 特效(挂载在武器上)
|
||||
const effect = this.createEntity("WeaponEffect");
|
||||
effect.addComponent(new ParticleEmitter());
|
||||
this.hierarchySystem.setParent(effect, weapon);
|
||||
|
||||
// 查询层级信息
|
||||
console.log(`Player 层级深度: ${this.hierarchySystem.getDepth(player)}`); // 0
|
||||
console.log(`Weapon 层级深度: ${this.hierarchySystem.getDepth(weapon)}`); // 2
|
||||
console.log(`Effect 层级深度: ${this.hierarchySystem.getDepth(effect)}`); // 3
|
||||
}
|
||||
|
||||
public equipNewWeapon(weaponName: string): void {
|
||||
const body = this.findEntity("Body");
|
||||
const oldWeapon = this.hierarchySystem.findChild(body!, "Weapon");
|
||||
|
||||
if (oldWeapon) {
|
||||
// 移除旧武器的所有子实体
|
||||
this.hierarchySystem.removeAllChildren(oldWeapon);
|
||||
oldWeapon.destroy();
|
||||
}
|
||||
|
||||
// 创建新武器
|
||||
const newWeapon = this.createEntity("Weapon");
|
||||
newWeapon.addComponent(new Sprite(`${weaponName}.png`));
|
||||
this.hierarchySystem.setParent(newWeapon, body!);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 层级变换系统
|
||||
|
||||
结合 Transform 组件实现层级变换:
|
||||
|
||||
```typescript
|
||||
import { EntitySystem, Matcher, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
|
||||
class HierarchyTransformSystem extends EntitySystem {
|
||||
private hierarchySystem!: HierarchySystem;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.empty().all(Transform, HierarchyComponent));
|
||||
}
|
||||
|
||||
public onAddedToScene(): void {
|
||||
// 获取层级系统引用
|
||||
this.hierarchySystem = this.scene!.getEntityProcessor(HierarchySystem)!;
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
// 按深度排序,确保父级先更新
|
||||
const sorted = [...entities].sort((a, b) => {
|
||||
return this.hierarchySystem.getDepth(a) - this.hierarchySystem.getDepth(b);
|
||||
});
|
||||
|
||||
for (const entity of sorted) {
|
||||
const transform = entity.getComponent(Transform)!;
|
||||
const parent = this.hierarchySystem.getParent(entity);
|
||||
|
||||
if (parent) {
|
||||
const parentTransform = parent.getComponent(Transform);
|
||||
if (parentTransform) {
|
||||
// 计算世界坐标
|
||||
transform.worldX = parentTransform.worldX + transform.localX;
|
||||
transform.worldY = parentTransform.worldY + transform.localY;
|
||||
}
|
||||
} else {
|
||||
// 根实体,本地坐标即世界坐标
|
||||
transform.worldX = transform.localX;
|
||||
transform.worldY = transform.localY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 缓存机制
|
||||
|
||||
`HierarchySystem` 内置了缓存机制:
|
||||
|
||||
- `depth` 和 `bActiveInHierarchy` 由系统自动维护
|
||||
- 使用 `bCacheDirty` 标记优化更新
|
||||
- 层级变化时自动标记所有子级缓存为脏
|
||||
|
||||
### 最佳实践
|
||||
|
||||
1. **避免深层嵌套**:系统限制最大深度为 32 层
|
||||
2. **批量操作**:构建复杂层级时,尽量一次性设置好所有父子关系
|
||||
3. **按需添加**:只有真正需要层级关系的实体才添加 `HierarchyComponent`
|
||||
4. **缓存系统引用**:避免每次调用都获取 `HierarchySystem`
|
||||
|
||||
```typescript
|
||||
// 好的做法
|
||||
class MySystem extends EntitySystem {
|
||||
private hierarchySystem!: HierarchySystem;
|
||||
|
||||
onAddedToScene() {
|
||||
this.hierarchySystem = this.scene!.getEntityProcessor(HierarchySystem)!;
|
||||
}
|
||||
|
||||
process() {
|
||||
// 使用缓存的引用
|
||||
const parent = this.hierarchySystem.getParent(entity);
|
||||
}
|
||||
}
|
||||
|
||||
// 避免的做法
|
||||
process() {
|
||||
// 每次都获取,性能较差
|
||||
const system = this.scene!.getEntityProcessor(HierarchySystem);
|
||||
}
|
||||
```
|
||||
|
||||
## 迁移指南
|
||||
|
||||
如果你之前使用的是旧版 Entity 内置的层级 API,请参考以下迁移指南:
|
||||
|
||||
| 旧 API (已移除) | 新 API |
|
||||
|----------------|--------|
|
||||
| `entity.parent` | `hierarchySystem.getParent(entity)` |
|
||||
| `entity.children` | `hierarchySystem.getChildren(entity)` |
|
||||
| `entity.addChild(child)` | `hierarchySystem.setParent(child, entity)` |
|
||||
| `entity.removeChild(child)` | `hierarchySystem.removeChild(entity, child)` |
|
||||
| `entity.findChild(name)` | `hierarchySystem.findChild(entity, name)` |
|
||||
| `entity.activeInHierarchy` | `hierarchySystem.isActiveInHierarchy(entity)` |
|
||||
|
||||
### 迁移示例
|
||||
|
||||
```typescript
|
||||
// 旧代码
|
||||
const parent = scene.createEntity("Parent");
|
||||
const child = scene.createEntity("Child");
|
||||
parent.addChild(child);
|
||||
const found = parent.findChild("Child");
|
||||
|
||||
// 新代码
|
||||
const hierarchySystem = scene.getEntityProcessor(HierarchySystem);
|
||||
|
||||
const parent = scene.createEntity("Parent");
|
||||
const child = scene.createEntity("Child");
|
||||
hierarchySystem.setParent(child, parent);
|
||||
const found = hierarchySystem.findChild(parent, "Child");
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- 了解 [实体类](./entity.md) 的其他功能
|
||||
- 了解 [场景管理](./scene.md) 如何组织实体和系统
|
||||
- 了解 [组件系统](./component.md) 如何定义和使用组件
|
||||
@@ -13,9 +13,6 @@
|
||||
### [系统架构 (System)](./system.md)
|
||||
掌握系统的编写方法,实现游戏逻辑的处理。
|
||||
|
||||
### [实体查询与 Matcher](./entity-query.md)
|
||||
学习使用 Matcher 进行实体筛选和查询,掌握 `all`、`any`、`none`、`nothing` 等匹配条件。
|
||||
|
||||
### [场景管理 (Scene)](./scene.md)
|
||||
了解场景的生命周期、系统管理和实体容器功能。
|
||||
|
||||
|
||||
@@ -190,106 +190,6 @@ class CollectionsComponent extends Component {
|
||||
}
|
||||
```
|
||||
|
||||
### 组件继承与序列化
|
||||
|
||||
框架完整支持组件类的继承,子类会自动继承父类的序列化字段,同时可以添加自己的字段。
|
||||
|
||||
#### 基础继承
|
||||
|
||||
```typescript
|
||||
// 基类组件
|
||||
@ECSComponent('Collider2DBase')
|
||||
@Serializable({ version: 1, typeId: 'Collider2DBase' })
|
||||
abstract class Collider2DBase extends Component {
|
||||
@Serialize()
|
||||
public friction: number = 0.5;
|
||||
|
||||
@Serialize()
|
||||
public restitution: number = 0.0;
|
||||
|
||||
@Serialize()
|
||||
public isTrigger: boolean = false;
|
||||
}
|
||||
|
||||
// 子类组件 - 自动继承父类的序列化字段
|
||||
@ECSComponent('BoxCollider2D')
|
||||
@Serializable({ version: 1, typeId: 'BoxCollider2D' })
|
||||
class BoxCollider2DComponent extends Collider2DBase {
|
||||
@Serialize()
|
||||
public width: number = 1.0;
|
||||
|
||||
@Serialize()
|
||||
public height: number = 1.0;
|
||||
}
|
||||
|
||||
// 另一个子类组件
|
||||
@ECSComponent('CircleCollider2D')
|
||||
@Serializable({ version: 1, typeId: 'CircleCollider2D' })
|
||||
class CircleCollider2DComponent extends Collider2DBase {
|
||||
@Serialize()
|
||||
public radius: number = 0.5;
|
||||
}
|
||||
```
|
||||
|
||||
#### 继承规则
|
||||
|
||||
1. **字段继承**:子类自动继承父类所有被 `@Serialize()` 标记的字段
|
||||
2. **独立元数据**:每个子类维护独立的序列化元数据,修改子类不会影响父类或其他子类
|
||||
3. **typeId 区分**:使用 `typeId` 选项为每个类指定唯一标识,确保反序列化时能正确识别组件类型
|
||||
|
||||
#### 使用 typeId 的重要性
|
||||
|
||||
当使用组件继承时,**强烈建议**为每个类设置唯一的 `typeId`:
|
||||
|
||||
```typescript
|
||||
// ✅ 推荐:明确指定 typeId
|
||||
@Serializable({ version: 1, typeId: 'BoxCollider2D' })
|
||||
class BoxCollider2DComponent extends Collider2DBase { }
|
||||
|
||||
@Serializable({ version: 1, typeId: 'CircleCollider2D' })
|
||||
class CircleCollider2DComponent extends Collider2DBase { }
|
||||
|
||||
// ⚠️ 不推荐:依赖类名作为 typeId
|
||||
// 在代码压缩后类名可能变化,导致反序列化失败
|
||||
@Serializable({ version: 1 })
|
||||
class BoxCollider2DComponent extends Collider2DBase { }
|
||||
```
|
||||
|
||||
#### 子类覆盖父类字段
|
||||
|
||||
子类可以重新声明父类的字段以修改其序列化选项:
|
||||
|
||||
```typescript
|
||||
@ECSComponent('SpecialCollider')
|
||||
@Serializable({ version: 1, typeId: 'SpecialCollider' })
|
||||
class SpecialColliderComponent extends Collider2DBase {
|
||||
// 覆盖父类字段,使用不同的别名
|
||||
@Serialize({ alias: 'fric' })
|
||||
public override friction: number = 0.8;
|
||||
|
||||
@Serialize()
|
||||
public specialProperty: string = '';
|
||||
}
|
||||
```
|
||||
|
||||
#### 忽略继承的字段
|
||||
|
||||
使用 `@IgnoreSerialization()` 可以在子类中忽略从父类继承的字段:
|
||||
|
||||
```typescript
|
||||
@ECSComponent('TriggerOnly')
|
||||
@Serializable({ version: 1, typeId: 'TriggerOnly' })
|
||||
class TriggerOnlyCollider extends Collider2DBase {
|
||||
// 忽略父类的 friction 和 restitution 字段
|
||||
// 因为 Trigger 不需要物理材质属性
|
||||
@IgnoreSerialization()
|
||||
public override friction: number = 0;
|
||||
|
||||
@IgnoreSerialization()
|
||||
public override restitution: number = 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 场景自定义数据
|
||||
|
||||
除了实体和组件,还可以序列化场景级别的配置数据:
|
||||
|
||||
+24
-263
@@ -33,26 +33,6 @@ class MyService implements IService {
|
||||
}
|
||||
```
|
||||
|
||||
#### 服务标识符(ServiceIdentifier)
|
||||
|
||||
服务标识符用于在容器中唯一标识一个服务,支持两种类型:
|
||||
|
||||
- **类构造函数**: 直接使用服务类作为标识符,适用于具体实现类
|
||||
- **Symbol**: 使用 Symbol 作为标识符,适用于接口抽象(推荐用于插件和跨包场景)
|
||||
|
||||
```typescript
|
||||
// 方式1: 使用类作为标识符
|
||||
Core.services.registerSingleton(DataService);
|
||||
const data = Core.services.resolve(DataService);
|
||||
|
||||
// 方式2: 使用 Symbol 作为标识符(推荐用于接口)
|
||||
const IFileSystem = Symbol.for('IFileSystem');
|
||||
Core.services.registerInstance(IFileSystem, new TauriFileSystem());
|
||||
const fs = Core.services.resolve<IFileSystem>(IFileSystem);
|
||||
```
|
||||
|
||||
> **提示**: 使用 `Symbol.for()` 而非 `Symbol()` 可确保跨包/跨模块共享同一个标识符。详见[高级用法 - 接口与 Symbol 标识符模式](#接口与-symbol-标识符模式)。
|
||||
|
||||
#### 生命周期
|
||||
|
||||
服务容器支持两种生命周期:
|
||||
@@ -64,13 +44,7 @@ const fs = Core.services.resolve<IFileSystem>(IFileSystem);
|
||||
|
||||
### 访问服务容器
|
||||
|
||||
ECS Framework 提供了三级服务容器:
|
||||
|
||||
> **版本说明**:World 服务容器功能在 v2.2.13+ 版本中可用
|
||||
|
||||
#### Core 级别服务容器
|
||||
|
||||
应用程序全局服务容器,可以通过 `Core.services` 访问:
|
||||
Core 类内置了服务容器,可以通过 `Core.services` 访问:
|
||||
|
||||
```typescript
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
@@ -78,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 系统、场景特定逻辑等)
|
||||
|
||||
### 注册服务
|
||||
|
||||
#### 注册单例服务
|
||||
@@ -353,20 +284,21 @@ class GameService implements IService {
|
||||
}
|
||||
```
|
||||
|
||||
### @InjectProperty 装饰器
|
||||
### @Inject 装饰器
|
||||
|
||||
通过属性装饰器注入依赖。注入时机是在构造函数执行后、`onInitialize()` 调用前完成:
|
||||
在构造函数中注入依赖:
|
||||
|
||||
```typescript
|
||||
import { Injectable, InjectProperty, IService } from '@esengine/ecs-framework';
|
||||
import { Injectable, Inject, IService } from '@esengine/ecs-framework';
|
||||
|
||||
@Injectable()
|
||||
class PlayerService implements IService {
|
||||
@InjectProperty(DataService)
|
||||
private data!: DataService;
|
||||
|
||||
@InjectProperty(GameService)
|
||||
private game!: GameService;
|
||||
constructor(
|
||||
@Inject(DataService) private data: DataService,
|
||||
@Inject(GameService) private game: GameService
|
||||
) {
|
||||
// data 和 game 会自动从容器中解析
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// 清理资源
|
||||
@@ -374,35 +306,6 @@ class PlayerService implements IService {
|
||||
}
|
||||
```
|
||||
|
||||
在 EntitySystem 中使用属性注入:
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
class CombatSystem extends EntitySystem {
|
||||
@InjectProperty(TimeService)
|
||||
private timeService!: TimeService;
|
||||
|
||||
@InjectProperty(AudioService)
|
||||
private audio!: AudioService;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.all(Health, Attack));
|
||||
}
|
||||
|
||||
onInitialize(): void {
|
||||
// 此时属性已注入完成,可以安全使用
|
||||
console.log('Delta time:', this.timeService.getDeltaTime());
|
||||
}
|
||||
|
||||
processEntity(entity: Entity): void {
|
||||
// 使用注入的服务
|
||||
this.audio.playSound('attack');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**: 属性声明时使用 `!` 断言(如 `private data!: DataService`),表示该属性会在使用前被注入。
|
||||
|
||||
### 注册可注入服务
|
||||
|
||||
使用 `registerInjectable` 自动处理依赖注入:
|
||||
@@ -410,10 +313,10 @@ class CombatSystem extends EntitySystem {
|
||||
```typescript
|
||||
import { registerInjectable } from '@esengine/ecs-framework';
|
||||
|
||||
// 注册服务(会自动解析 @InjectProperty 依赖)
|
||||
// 注册服务(会自动解析@Inject依赖)
|
||||
registerInjectable(Core.services, PlayerService);
|
||||
|
||||
// 解析时会自动注入属性依赖
|
||||
// 解析时会自动注入依赖
|
||||
const player = Core.services.resolve(PlayerService);
|
||||
```
|
||||
|
||||
@@ -541,164 +444,22 @@ registerInjectable(Core.services, NetworkService);
|
||||
|
||||
## 高级用法
|
||||
|
||||
### 接口与 Symbol 标识符模式
|
||||
### 服务替换(测试)
|
||||
|
||||
在大型项目或需要跨平台适配的游戏中,推荐使用"接口 + Symbol.for 标识符"模式。这种模式实现了真正的依赖倒置,让代码依赖于抽象而非具体实现。
|
||||
|
||||
#### 为什么使用 Symbol.for
|
||||
|
||||
- **跨包共享**: `Symbol.for('key')` 在全局 Symbol 注册表中创建/获取 Symbol,确保不同包中使用相同的标识符
|
||||
- **接口解耦**: 消费者只依赖接口定义,不依赖具体实现类
|
||||
- **可替换实现**: 可以在运行时注入不同的实现(如测试 Mock、不同平台适配)
|
||||
|
||||
#### 定义接口和标识符
|
||||
|
||||
以音频服务为例,游戏需要在不同平台(Web、微信小游戏、原生App)使用不同的音频实现:
|
||||
在测试中替换真实服务为模拟服务:
|
||||
|
||||
```typescript
|
||||
// IAudioService.ts - 定义接口和标识符
|
||||
export interface IAudioService {
|
||||
dispose(): void;
|
||||
playSound(id: string): void;
|
||||
playMusic(id: string, loop?: boolean): void;
|
||||
stopMusic(): void;
|
||||
setVolume(volume: number): void;
|
||||
preload(id: string, url: string): Promise<void>;
|
||||
// 测试代码
|
||||
class MockDataService implements IService {
|
||||
getData(key: string) {
|
||||
return 'mock data';
|
||||
}
|
||||
|
||||
dispose(): void {}
|
||||
}
|
||||
|
||||
// 使用 Symbol.for 确保跨包共享同一个 Symbol
|
||||
export const IAudioService = Symbol.for('IAudioService');
|
||||
```
|
||||
|
||||
#### 实现接口
|
||||
|
||||
```typescript
|
||||
// WebAudioService.ts - Web 平台实现
|
||||
import { IAudioService } from './IAudioService';
|
||||
|
||||
export class WebAudioService implements IAudioService {
|
||||
private audioContext: AudioContext;
|
||||
private sounds: Map<string, AudioBuffer> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
|
||||
playSound(id: string): void {
|
||||
const buffer = this.sounds.get(id);
|
||||
if (buffer) {
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(this.audioContext.destination);
|
||||
source.start();
|
||||
}
|
||||
}
|
||||
|
||||
async preload(id: string, url: string): Promise<void> {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
|
||||
this.sounds.set(id, audioBuffer);
|
||||
}
|
||||
|
||||
// ... 其他方法实现
|
||||
|
||||
dispose(): void {
|
||||
this.audioContext.close();
|
||||
this.sounds.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// WechatAudioService.ts - 微信小游戏平台实现
|
||||
export class WechatAudioService implements IAudioService {
|
||||
private innerAudioContexts: Map<string, WechatMinigame.InnerAudioContext> = new Map();
|
||||
|
||||
playSound(id: string): void {
|
||||
const ctx = this.innerAudioContexts.get(id);
|
||||
if (ctx) {
|
||||
ctx.play();
|
||||
}
|
||||
}
|
||||
|
||||
async preload(id: string, url: string): Promise<void> {
|
||||
const ctx = wx.createInnerAudioContext();
|
||||
ctx.src = url;
|
||||
this.innerAudioContexts.set(id, ctx);
|
||||
}
|
||||
|
||||
// ... 其他方法实现
|
||||
|
||||
dispose(): void {
|
||||
for (const ctx of this.innerAudioContexts.values()) {
|
||||
ctx.destroy();
|
||||
}
|
||||
this.innerAudioContexts.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 注册和使用
|
||||
|
||||
```typescript
|
||||
import { IAudioService } from './IAudioService';
|
||||
import { WebAudioService } from './WebAudioService';
|
||||
import { WechatAudioService } from './WechatAudioService';
|
||||
|
||||
// 根据平台注册不同实现
|
||||
if (typeof wx !== 'undefined') {
|
||||
Core.services.registerInstance(IAudioService, new WechatAudioService());
|
||||
} else {
|
||||
Core.services.registerInstance(IAudioService, new WebAudioService());
|
||||
}
|
||||
|
||||
// 业务代码中使用 - 不关心具体实现
|
||||
const audio = Core.services.resolve<IAudioService>(IAudioService);
|
||||
await audio.preload('explosion', '/sounds/explosion.mp3');
|
||||
audio.playSound('explosion');
|
||||
```
|
||||
|
||||
#### 跨模块使用
|
||||
|
||||
```typescript
|
||||
// 在游戏系统中使用
|
||||
import { IAudioService } from '@mygame/core';
|
||||
|
||||
class CombatSystem extends EntitySystem {
|
||||
private audio: IAudioService;
|
||||
|
||||
initialize(): void {
|
||||
// 获取音频服务,不需要知道具体实现
|
||||
this.audio = this.scene.services.resolve<IAudioService>(IAudioService);
|
||||
}
|
||||
|
||||
onEntityDeath(entity: Entity): void {
|
||||
this.audio.playSound('death');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Symbol vs Symbol.for
|
||||
|
||||
```typescript
|
||||
// Symbol() - 每次创建唯一的 Symbol
|
||||
const sym1 = Symbol('test');
|
||||
const sym2 = Symbol('test');
|
||||
console.log(sym1 === sym2); // false - 不同的 Symbol
|
||||
|
||||
// Symbol.for() - 在全局注册表中共享
|
||||
const sym3 = Symbol.for('test');
|
||||
const sym4 = Symbol.for('test');
|
||||
console.log(sym3 === sym4); // true - 同一个 Symbol
|
||||
|
||||
// 跨包场景
|
||||
// package-a/index.ts
|
||||
export const IMyService = Symbol.for('IMyService');
|
||||
|
||||
// package-b/index.ts (不同的包)
|
||||
const IMyService = Symbol.for('IMyService');
|
||||
// 与 package-a 中的是同一个 Symbol!
|
||||
// 注册模拟服务(用于测试)
|
||||
Core.services.registerInstance(DataService, new MockDataService());
|
||||
```
|
||||
|
||||
### 循环依赖检测
|
||||
|
||||
+3
-88
@@ -157,45 +157,8 @@ const nameMatcher = Matcher.byName("Player"); // 匹配名称为 "Player" 的实
|
||||
|
||||
// 单组件匹配
|
||||
const componentMatcher = Matcher.byComponent(Health); // 匹配拥有 Health 组件的实体
|
||||
|
||||
// 不匹配任何实体
|
||||
const nothingMatcher = Matcher.nothing(); // 用于只需要生命周期回调的系统
|
||||
```
|
||||
|
||||
### 空匹配器 vs Nothing 匹配器
|
||||
|
||||
```typescript
|
||||
// empty() - 空条件,匹配所有实体
|
||||
const emptyMatcher = Matcher.empty();
|
||||
|
||||
// nothing() - 不匹配任何实体,用于只需要生命周期方法的系统
|
||||
const nothingMatcher = Matcher.nothing();
|
||||
|
||||
// 使用场景:只需要 onBegin/onEnd 生命周期的系统
|
||||
@ECSSystem('FrameTimer')
|
||||
class FrameTimerSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.nothing()); // 不处理任何实体
|
||||
}
|
||||
|
||||
protected onBegin(): void {
|
||||
// 每帧开始时执行,例如:记录帧开始时间
|
||||
console.log('帧开始');
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
// 永远不会被调用,因为没有匹配的实体
|
||||
}
|
||||
|
||||
protected onEnd(): void {
|
||||
// 每帧结束时执行
|
||||
console.log('帧结束');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 **提示**:更多关于 Matcher 和实体查询的详细用法,请参考 [实体查询系统](/guide/entity-query) 文档。
|
||||
|
||||
## 系统生命周期
|
||||
|
||||
系统提供了完整的生命周期回调:
|
||||
@@ -600,28 +563,9 @@ class GameSystem extends EntitySystem {
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 使用 @ECSSystem 装饰器
|
||||
### 2. 使用装饰器
|
||||
|
||||
`@ECSSystem` 是系统类必须使用的装饰器,它为系统提供类型标识和元数据管理。
|
||||
|
||||
#### 为什么必须使用
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **类型识别** | 提供稳定的系统名称,代码混淆后仍能正确识别 |
|
||||
| **调试支持** | 在性能监控、日志和调试工具中显示可读的系统名称 |
|
||||
| **系统管理** | 通过名称查找和管理系统 |
|
||||
| **序列化支持** | 场景序列化时可以记录系统配置 |
|
||||
|
||||
#### 基本语法
|
||||
|
||||
```typescript
|
||||
@ECSSystem(systemName: string)
|
||||
```
|
||||
|
||||
- `systemName`: 系统的名称,建议使用描述性的名称
|
||||
|
||||
#### 使用示例
|
||||
**必须使用 `@ECSSystem` 装饰器**:
|
||||
|
||||
```typescript
|
||||
// ✅ 正确的用法
|
||||
@@ -630,41 +574,12 @@ class PhysicsSystem extends EntitySystem {
|
||||
// 系统实现
|
||||
}
|
||||
|
||||
// ✅ 推荐:使用描述性的名称
|
||||
@ECSSystem('PlayerMovement')
|
||||
class PlayerMovementSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.all(Player, Position, Velocity));
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 错误的用法 - 没有装饰器
|
||||
class BadSystem extends EntitySystem {
|
||||
// 这样定义的系统可能在生产环境出现问题:
|
||||
// 1. 代码压缩后类名变化,无法正确识别
|
||||
// 2. 性能监控和调试工具显示不正确的名称
|
||||
// 这样定义的系统可能在生产环境出现问题
|
||||
}
|
||||
```
|
||||
|
||||
#### 系统名称的作用
|
||||
|
||||
```typescript
|
||||
@ECSSystem('Combat')
|
||||
class CombatSystem extends EntitySystem {
|
||||
protected onInitialize(): void {
|
||||
// 使用 systemName 属性访问系统名称
|
||||
console.log(`系统 ${this.systemName} 已初始化`); // 输出: 系统 Combat 已初始化
|
||||
}
|
||||
}
|
||||
|
||||
// 通过名称查找系统
|
||||
const combat = scene.getSystemByName('Combat');
|
||||
|
||||
// 性能监控中会显示系统名称
|
||||
const perfData = combatSystem.getPerformanceData();
|
||||
console.log(`${combatSystem.systemName} 执行时间: ${perfData?.executionTime}ms`);
|
||||
```
|
||||
|
||||
### 3. 合理的更新顺序
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -435,7 +435,7 @@ const worldManager = Core.services.resolve(WorldManager);
|
||||
// {
|
||||
// maxWorlds: 50,
|
||||
// autoCleanup: true,
|
||||
// cleanupFrameInterval: 1800 // 间隔多少帧清理闲置 World
|
||||
// cleanupInterval: 30000 // 30 秒
|
||||
// }
|
||||
```
|
||||
|
||||
|
||||
+20
-326
@@ -1,329 +1,23 @@
|
||||
---
|
||||
layout: page
|
||||
title: ESEngine - 高性能 TypeScript ECS 框架
|
||||
---
|
||||
layout: home
|
||||
|
||||
<ParticleHero />
|
||||
hero:
|
||||
name: "ECS Framework"
|
||||
text: "高性能ECS框架"
|
||||
tagline: "为Javascript游戏开发而设计"
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 快速开始
|
||||
link: /guide/getting-started
|
||||
- theme: alt
|
||||
text: 查看示例
|
||||
link: https://github.com/esengine/lawn-mower-demo
|
||||
|
||||
<section class="news-section">
|
||||
<div class="news-container">
|
||||
<div class="news-header">
|
||||
<h2 class="news-title">快速入口</h2>
|
||||
<a href="/guide/" class="news-more">查看文档</a>
|
||||
</div>
|
||||
<div class="news-grid">
|
||||
<a href="/guide/getting-started" class="news-card">
|
||||
<div class="news-card-image" style="background: linear-gradient(135deg, #1e3a5f 0%, #1e1e1e 100%);">
|
||||
<div class="news-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"><path fill="#4fc1ff" d="M12 3L1 9l4 2.18v6L12 21l7-3.82v-6l2-1.09V17h2V9zm6.82 6L12 12.72L5.18 9L12 5.28zM17 16l-5 2.72L7 16v-3.73L12 15l5-2.73z"/></svg>
|
||||
</div>
|
||||
<span class="news-badge">快速开始</span>
|
||||
</div>
|
||||
<div class="news-card-content">
|
||||
<h3>5 分钟上手 ESEngine</h3>
|
||||
<p>从安装到创建第一个 ECS 应用,快速了解核心概念。</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/guide/behavior-tree/" class="news-card">
|
||||
<div class="news-card-image" style="background: linear-gradient(135deg, #1e3a5f 0%, #1e1e1e 100%);">
|
||||
<div class="news-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"><path fill="#4ec9b0" d="M12 2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m3 20h-1v-7l-2-2l-2 2v7H9v-7.5l-2 2V22H6v-6l3-3l1-3.5c-.3.4-.6.7-1 1L6 9v1H4V8l5-3c.5-.3 1.1-.5 1.7-.5H11c.6 0 1.2.2 1.7.5l5 3v2h-2V9l-3 1.5c-.4-.3-.7-.6-1-1l1 3.5l3 3v6Z"/></svg>
|
||||
</div>
|
||||
<span class="news-badge">AI 系统</span>
|
||||
</div>
|
||||
<div class="news-card-content">
|
||||
<h3>行为树可视化编辑器</h3>
|
||||
<p>内置 AI 行为树系统,支持可视化编辑和实时调试。</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features-section">
|
||||
<div class="features-container">
|
||||
<h2 class="features-title">核心特性</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#4fc1ff" d="M13 2.05v2.02c3.95.49 7 3.85 7 7.93c0 1.45-.39 2.79-1.06 3.95l1.59 1.09A9.94 9.94 0 0 0 22 12c0-5.18-3.95-9.45-9-9.95M12 19c-3.87 0-7-3.13-7-7c0-3.53 2.61-6.43 6-6.92V2.05c-5.06.5-9 4.76-9 9.95c0 5.52 4.47 10 9.99 10c3.31 0 6.24-1.61 8.06-4.09l-1.6-1.1A7.93 7.93 0 0 1 12 19"/><path fill="#4fc1ff" d="M12 6a6 6 0 0 0-6 6c0 3.31 2.69 6 6 6a6 6 0 0 0 0-12m0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4s4 1.79 4 4s-1.79 4-4 4"/></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">高性能 ECS 架构</h3>
|
||||
<p class="feature-desc">基于数据驱动的实体组件系统,支持大规模实体处理,缓存友好的内存布局。</p>
|
||||
<a href="/guide/entity" class="feature-link">了解更多 →</a>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#569cd6" d="M3 3h18v18H3zm16.525 13.707c0-.795-.272-1.425-.816-1.89c-.544-.465-1.404-.804-2.58-1.016l-1.704-.296c-.616-.104-1.052-.26-1.308-.468c-.256-.21-.384-.468-.384-.776c0-.392.168-.7.504-.924c.336-.224.8-.336 1.392-.336c.56 0 1.008.124 1.344.372c.336.248.536.584.6 1.008h2.016c-.08-.96-.464-1.716-1.152-2.268c-.688-.552-1.6-.828-2.736-.828c-1.2 0-2.148.3-2.844.9c-.696.6-1.044 1.38-1.044 2.34c0 .76.252 1.368.756 1.824c.504.456 1.308.792 2.412.996l1.704.312c.624.12 1.068.28 1.332.48c.264.2.396.46.396.78c0 .424-.192.756-.576.996c-.384.24-.9.36-1.548.36c-.672 0-1.2-.14-1.584-.42c-.384-.28-.608-.668-.672-1.164H8.868c.048 1.016.46 1.808 1.236 2.376c.776.568 1.796.852 3.06.852c1.24 0 2.22-.292 2.94-.876c.72-.584 1.08-1.364 1.08-2.34z"/></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">完整类型支持</h3>
|
||||
<p class="feature-desc">100% TypeScript 编写,完整的类型定义和编译时检查,提供最佳的开发体验。</p>
|
||||
<a href="/guide/component" class="feature-link">了解更多 →</a>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#4ec9b0" d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m-5-8l4-4v3h4v2h-4v3z"/></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">可视化行为树</h3>
|
||||
<p class="feature-desc">内置 AI 行为树系统,提供可视化编辑器,支持自定义节点和实时调试。</p>
|
||||
<a href="/guide/behavior-tree/" class="feature-link">了解更多 →</a>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#c586c0" d="M4 6h18V4H4c-1.1 0-2 .9-2 2v11H0v3h14v-3H4zm19 2h-6c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h6c.55 0 1-.45 1-1V9c0-.55-.45-1-1-1m-1 9h-4v-7h4z"/></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">多平台支持</h3>
|
||||
<p class="feature-desc">支持浏览器、Node.js、微信小游戏等多平台,可与主流游戏引擎无缝集成。</p>
|
||||
<a href="/guide/platform-adapter" class="feature-link">了解更多 →</a>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#dcdcaa" d="M4 3h6v2H4v14h6v2H4c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2m9 0h6c1.1 0 2 .9 2 2v14c0 1.1-.9 2-2 2h-6v-2h6V5h-6zm-1 7h4v2h-4z"/></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">模块化设计</h3>
|
||||
<p class="feature-desc">核心功能独立打包,按需引入。支持自定义插件扩展,灵活适配不同项目。</p>
|
||||
<a href="/guide/plugin-system" class="feature-link">了解更多 →</a>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#9cdcfe" d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9c-2-2-5-2.4-7.4-1.3L9 6L6 9L1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4"/></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">开发者工具</h3>
|
||||
<p class="feature-desc">内置性能监控、调试工具、序列化系统等,提供完整的开发工具链。</p>
|
||||
<a href="/guide/logging" class="feature-link">了解更多 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* 隐藏 VitePress 默认样式 */
|
||||
.VPDoc {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.vp-doc {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.VPContent {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.news-section {
|
||||
background: #0d0d0d;
|
||||
padding: 64px 0;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.news-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
|
||||
.news-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.news-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.news-more:hover {
|
||||
background: #252525;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.news-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
display: flex;
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.news-card:hover {
|
||||
border-color: #3b9eff;
|
||||
}
|
||||
|
||||
.news-card-image {
|
||||
width: 200px;
|
||||
min-height: 140px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.news-icon {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.news-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 16px;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.news-card-content {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.news-card-content h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.news-card-content p {
|
||||
font-size: 0.875rem;
|
||||
color: #707070;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.features-section {
|
||||
background: #0d0d0d;
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.features-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
|
||||
.features-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0 0 32px 0;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: #3b9eff;
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #0d0d0d;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 14px;
|
||||
color: #707070;
|
||||
line-height: 1.7;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.feature-link {
|
||||
font-size: 14px;
|
||||
color: #3b9eff;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.feature-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.news-container,
|
||||
.features-container {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.news-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.news-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.news-card-image {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
features:
|
||||
- title: 高性能
|
||||
details: 支持大规模实体处理
|
||||
- title: 类型安全
|
||||
details: 完整的TypeScript支持,编译时类型检查
|
||||
- title: 模块化设计
|
||||
details: 核心功能独立打包,支持多平台
|
||||
---
|
||||
@@ -1 +0,0 @@
|
||||
esengine.cn
|
||||
+24
-30
@@ -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'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
Generated
-352
@@ -1,352 +0,0 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@esengine/ecs-framework':
|
||||
specifier: file:../../packages/core
|
||||
version: file:../../packages/core
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^4.0.0
|
||||
version: 4.5.14
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/android-arm64@0.18.20':
|
||||
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.18.20':
|
||||
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.18.20':
|
||||
resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.18.20':
|
||||
resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.18.20':
|
||||
resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.18.20':
|
||||
resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.18.20':
|
||||
resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.18.20':
|
||||
resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.18.20':
|
||||
resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.18.20':
|
||||
resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.18.20':
|
||||
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.18.20':
|
||||
resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.18.20':
|
||||
resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.18.20':
|
||||
resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.18.20':
|
||||
resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.18.20':
|
||||
resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-x64@0.18.20':
|
||||
resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.18.20':
|
||||
resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/sunos-x64@0.18.20':
|
||||
resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.18.20':
|
||||
resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.18.20':
|
||||
resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.18.20':
|
||||
resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@esengine/ecs-framework@file:../../packages/core':
|
||||
resolution: {directory: ../../packages/core, type: directory}
|
||||
|
||||
esbuild@0.18.20:
|
||||
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
rollup@3.29.5:
|
||||
resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==}
|
||||
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
vite@4.5.14:
|
||||
resolution: {integrity: sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': '>= 14'
|
||||
less: '*'
|
||||
lightningcss: ^1.21.0
|
||||
sass: '*'
|
||||
stylus: '*'
|
||||
sugarss: '*'
|
||||
terser: ^5.4.0
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
lightningcss:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/android-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esengine/ecs-framework@file:../../packages/core':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
esbuild@0.18.20:
|
||||
optionalDependencies:
|
||||
'@esbuild/android-arm': 0.18.20
|
||||
'@esbuild/android-arm64': 0.18.20
|
||||
'@esbuild/android-x64': 0.18.20
|
||||
'@esbuild/darwin-arm64': 0.18.20
|
||||
'@esbuild/darwin-x64': 0.18.20
|
||||
'@esbuild/freebsd-arm64': 0.18.20
|
||||
'@esbuild/freebsd-x64': 0.18.20
|
||||
'@esbuild/linux-arm': 0.18.20
|
||||
'@esbuild/linux-arm64': 0.18.20
|
||||
'@esbuild/linux-ia32': 0.18.20
|
||||
'@esbuild/linux-loong64': 0.18.20
|
||||
'@esbuild/linux-mips64el': 0.18.20
|
||||
'@esbuild/linux-ppc64': 0.18.20
|
||||
'@esbuild/linux-riscv64': 0.18.20
|
||||
'@esbuild/linux-s390x': 0.18.20
|
||||
'@esbuild/linux-x64': 0.18.20
|
||||
'@esbuild/netbsd-x64': 0.18.20
|
||||
'@esbuild/openbsd-x64': 0.18.20
|
||||
'@esbuild/sunos-x64': 0.18.20
|
||||
'@esbuild/win32-arm64': 0.18.20
|
||||
'@esbuild/win32-ia32': 0.18.20
|
||||
'@esbuild/win32-x64': 0.18.20
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
postcss@8.5.6:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
rollup@3.29.5:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
vite@4.5.14:
|
||||
dependencies:
|
||||
esbuild: 0.18.20
|
||||
postcss: 8.5.6
|
||||
rollup: 3.29.5
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
Generated
+27061
File diff suppressed because it is too large
Load Diff
+24
-20
@@ -3,7 +3,6 @@
|
||||
"version": "2.1.29",
|
||||
"description": "ECS Framework Monorepo - 高性能ECS框架及其网络插件",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.22.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -18,26 +17,36 @@
|
||||
],
|
||||
"scripts": {
|
||||
"bootstrap": "lerna bootstrap",
|
||||
"clean": "turbo run clean",
|
||||
"build": "turbo run build",
|
||||
"build:filter": "turbo run build --filter",
|
||||
"build:core": "turbo run build --filter=@esengine/ecs-framework",
|
||||
"build:math": "turbo run build --filter=@esengine/ecs-framework-math",
|
||||
"build:editor": "turbo run build --filter=@esengine/editor-app...",
|
||||
"build:npm": "turbo run build:npm",
|
||||
"clean": "lerna run clean",
|
||||
"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: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",
|
||||
"test": "turbo run test",
|
||||
"test:coverage": "turbo run test:coverage",
|
||||
"test:ci": "turbo run test:ci",
|
||||
"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",
|
||||
@@ -54,18 +63,15 @@
|
||||
"copy:worker-demo": "node scripts/update-worker-demo.js",
|
||||
"format": "prettier --write \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
||||
"format:check": "prettier --check \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
||||
"type-check": "turbo run type-check",
|
||||
"lint": "turbo run lint",
|
||||
"lint:fix": "turbo run lint: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"
|
||||
"type-check": "lerna run type-check",
|
||||
"lint": "eslint \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
||||
"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,11 +101,9 @@
|
||||
"semver": "^7.6.3",
|
||||
"size-limit": "^11.0.2",
|
||||
"ts-jest": "^29.4.0",
|
||||
"turbo": "^2.6.1",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"name": "@esengine/asset-system",
|
||||
"version": "1.0.0",
|
||||
"description": "Asset management system for ES Engine",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"clean": "rimraf dist",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"asset",
|
||||
"resource",
|
||||
"bundle"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"rimraf": "^5.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/esengine/ecs-framework.git",
|
||||
"directory": "packages/asset-system"
|
||||
}
|
||||
}
|
||||
@@ -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,587 +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
|
||||
let loader = this._loaderFactory.createLoader(metadata.type);
|
||||
|
||||
// 如果没有找到 loader 且类型是 Custom,尝试重新解析类型
|
||||
// If no loader found and type is Custom, try to re-resolve the type
|
||||
if (!loader && metadata.type === AssetType.Custom) {
|
||||
const newType = this.resolveAssetType(metadata.path);
|
||||
if (newType !== AssetType.Custom) {
|
||||
// 更新 metadata 类型 / Update metadata type
|
||||
this._database.updateAsset(guid, { type: newType });
|
||||
loader = this._loaderFactory.createLoader(newType);
|
||||
}
|
||||
}
|
||||
|
||||
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 assetType = this.resolveAssetType(path);
|
||||
|
||||
// 生成唯一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 {
|
||||
// 如果之前缓存的类型是 Custom,检查是否现在有注册的 loader 可以处理
|
||||
// If previously cached as Custom, check if a registered loader can now handle it
|
||||
if (metadata.type === AssetType.Custom) {
|
||||
const newType = this.resolveAssetType(path);
|
||||
if (newType !== AssetType.Custom) {
|
||||
metadata.type = newType;
|
||||
}
|
||||
}
|
||||
this._pathToGuid.set(path, metadata.guid);
|
||||
}
|
||||
|
||||
return this.loadAsset<T>(metadata.guid, options);
|
||||
}
|
||||
|
||||
// 同样检查已缓存的资产,如果类型是 Custom 但现在有 loader 可以处理
|
||||
// Also check cached assets, if type is Custom but now a loader can handle it
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry && entry.metadata.type === AssetType.Custom) {
|
||||
const newType = this.resolveAssetType(path);
|
||||
if (newType !== AssetType.Custom) {
|
||||
entry.metadata.type = newType;
|
||||
}
|
||||
}
|
||||
|
||||
return this.loadAsset<T>(guid, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve asset type from path
|
||||
* 从路径解析资产类型
|
||||
*/
|
||||
private resolveAssetType(path: string): AssetType {
|
||||
// 首先尝试从已注册的加载器获取资产类型 / First try to get asset type from registered loaders
|
||||
const loaderType = (this._loaderFactory as AssetLoaderFactory).getAssetTypeByPath(path);
|
||||
if (loaderType !== null) {
|
||||
return loaderType;
|
||||
}
|
||||
|
||||
// 如果没有找到匹配的加载器,使用默认的扩展名映射 / Fallback to default extension mapping
|
||||
const fileExt = path.substring(path.lastIndexOf('.')).toLowerCase();
|
||||
|
||||
// 默认支持的基础类型 / Default supported basic types
|
||||
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(fileExt)) {
|
||||
return AssetType.Texture;
|
||||
} else if (['.json'].includes(fileExt)) {
|
||||
return AssetType.Json;
|
||||
} else if (['.txt', '.md', '.xml', '.yaml'].includes(fileExt)) {
|
||||
return AssetType.Text;
|
||||
}
|
||||
|
||||
return AssetType.Custom;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,241 +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);
|
||||
// Transformer output is trusted (may be absolute path or asset:// URL)
|
||||
// 转换器输出是可信的(可能是绝对路径或 asset:// URL)
|
||||
return path;
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Asset System for ECS Framework
|
||||
* ECS框架的资产系统
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types/AssetTypes';
|
||||
|
||||
// Interfaces
|
||||
export * from './interfaces/IAssetLoader';
|
||||
export * from './interfaces/IAssetManager';
|
||||
export * from './interfaces/IResourceComponent';
|
||||
|
||||
// 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';
|
||||
|
||||
// Services
|
||||
export { SceneResourceManager } from './services/SceneResourceManager';
|
||||
export type { IResourceLoader } from './services/SceneResourceManager';
|
||||
|
||||
// Utils
|
||||
export { UVHelper } from './utils/UVHelper';
|
||||
|
||||
// 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,248 +0,0 @@
|
||||
/**
|
||||
* Engine integration for asset system
|
||||
* 资产系统的引擎集成
|
||||
*/
|
||||
|
||||
import { AssetManager } from '../core/AssetManager';
|
||||
import { AssetGUID } from '../types/AssetTypes';
|
||||
import { ITextureAsset } from '../interfaces/IAssetLoader';
|
||||
import { globalPathResolver } from '../core/AssetPathResolver';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 为组件加载纹理
|
||||
*
|
||||
* 统一的路径解析入口:相对路径会被转换为 Tauri 可用的 asset:// URL
|
||||
* Unified path resolution entry: relative paths will be converted to Tauri-compatible asset:// URLs
|
||||
*/
|
||||
async loadTextureForComponent(texturePath: string): Promise<number> {
|
||||
// 检查缓存(使用原始路径作为键)
|
||||
// Check cache (using original path as key)
|
||||
const existingId = this._pathToTextureId.get(texturePath);
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
// 使用 globalPathResolver 转换路径
|
||||
// Use globalPathResolver to transform the path
|
||||
const resolvedPath = globalPathResolver.resolve(texturePath);
|
||||
|
||||
// 通过资产系统加载(使用解析后的路径)
|
||||
// Load through asset system (using resolved path)
|
||||
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(resolvedPath);
|
||||
const textureAsset = result.asset;
|
||||
|
||||
// 如果有引擎桥接,上传到GPU(使用解析后的路径)
|
||||
// Upload to GPU if bridge exists (using resolved path)
|
||||
if (this._engineBridge && textureAsset.data) {
|
||||
await this._engineBridge.loadTexture(textureAsset.textureId, resolvedPath);
|
||||
}
|
||||
|
||||
// 缓存映射(使用原始路径作为键,避免重复解析)
|
||||
// Cache mapping (using original path as key to avoid re-resolving)
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量加载资源(通用方法,支持 IResourceLoader 接口)
|
||||
* Load resources in batch (generic method for IResourceLoader interface)
|
||||
*
|
||||
* @param paths 资源路径数组 / Array of resource paths
|
||||
* @param type 资源类型 / Resource type
|
||||
* @returns 路径到运行时 ID 的映射 / Map of paths to runtime IDs
|
||||
*/
|
||||
async loadResourcesBatch(paths: string[], type: 'texture' | 'audio' | 'font' | 'data'): Promise<Map<string, number>> {
|
||||
// 目前只支持纹理 / Currently only supports textures
|
||||
if (type === 'texture') {
|
||||
return this.loadTexturesBatch(paths);
|
||||
}
|
||||
|
||||
// 其他资源类型暂未实现 / Other resource types not yet implemented
|
||||
console.warn(`[EngineIntegration] Resource type '${type}' not yet supported`);
|
||||
return new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,234 +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;
|
||||
|
||||
/**
|
||||
* Get asset type by file extension
|
||||
* 根据文件扩展名获取资产类型
|
||||
*/
|
||||
getAssetTypeByExtension(extension: string): AssetType | null;
|
||||
|
||||
/**
|
||||
* Get asset type by file path
|
||||
* 根据文件路径获取资产类型
|
||||
*/
|
||||
getAssetTypeByPath(path: string): AssetType | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,62 +0,0 @@
|
||||
/**
|
||||
* 资源组件接口 - 用于依赖运行时资源的组件(纹理、音频等)
|
||||
* Interface for components that depend on runtime resources (textures, audio, etc.)
|
||||
*
|
||||
* 实现此接口的组件可以参与 SceneResourceManager 管理的集中式资源加载
|
||||
* Components implementing this interface can participate in centralized resource loading managed by SceneResourceManager
|
||||
*/
|
||||
|
||||
/**
|
||||
* 资源引用 - 包含路径和运行时 ID
|
||||
* Resource reference with path and runtime ID
|
||||
*/
|
||||
export interface ResourceReference {
|
||||
/** 资源路径(例如 "assets/sprites/player.png")/ Asset path (e.g., "assets/sprites/player.png") */
|
||||
path: string;
|
||||
/** 引擎分配的运行时资源 ID(例如 GPU 上的纹理 ID)/ Runtime resource ID assigned by engine (e.g., texture ID on GPU) */
|
||||
runtimeId?: number;
|
||||
/** 资源类型标识符 / Resource type identifier */
|
||||
type: 'texture' | 'audio' | 'font' | 'data';
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源组件接口
|
||||
* Resource component interface
|
||||
*
|
||||
* 实现此接口的组件可以在场景启动前由 SceneResourceManager 集中加载资源
|
||||
* Components implementing this interface can have their resources loaded centrally by SceneResourceManager before the scene starts
|
||||
*/
|
||||
export interface IResourceComponent {
|
||||
/**
|
||||
* 获取此组件需要的所有资源引用
|
||||
* Get all resource references needed by this component
|
||||
*
|
||||
* 在场景加载期间调用以收集资源路径
|
||||
* Called during scene loading to collect resource paths
|
||||
*/
|
||||
getResourceReferences(): ResourceReference[];
|
||||
|
||||
/**
|
||||
* 设置已加载资源的运行时 ID
|
||||
* Set runtime IDs for loaded resources
|
||||
*
|
||||
* 在 SceneResourceManager 加载资源后调用
|
||||
* Called after resources are loaded by SceneResourceManager
|
||||
*
|
||||
* @param pathToId 资源路径到运行时 ID 的映射 / Map of resource paths to runtime IDs
|
||||
*/
|
||||
setResourceIds(pathToId: Map<string, number>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫 - 检查组件是否实现了 IResourceComponent
|
||||
* Type guard to check if a component implements IResourceComponent
|
||||
*/
|
||||
export function isResourceComponent(component: any): component is IResourceComponent {
|
||||
return (
|
||||
component !== null &&
|
||||
typeof component === 'object' &&
|
||||
typeof component.getResourceReferences === 'function' &&
|
||||
typeof component.setResourceIds === 'function'
|
||||
);
|
||||
}
|
||||
@@ -1,141 +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 asset type by file extension
|
||||
* 根据文件扩展名获取资产类型
|
||||
*
|
||||
* @param extension - File extension including dot (e.g., '.btree', '.png')
|
||||
* @returns Asset type if a loader supports this extension, null otherwise
|
||||
*/
|
||||
getAssetTypeByExtension(extension: string): AssetType | null {
|
||||
const ext = extension.toLowerCase();
|
||||
for (const [type, loader] of this._loaders) {
|
||||
if (loader.supportedExtensions.some(e => e.toLowerCase() === ext)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset type by file path
|
||||
* 根据文件路径获取资产类型
|
||||
*
|
||||
* Checks for compound extensions (like .tilemap.json) first, then simple extensions
|
||||
*
|
||||
* @param path - File path
|
||||
* @returns Asset type if a loader supports this file, null otherwise
|
||||
*/
|
||||
getAssetTypeByPath(path: string): AssetType | null {
|
||||
const lowerPath = path.toLowerCase();
|
||||
|
||||
// First check compound extensions (e.g., .tilemap.json)
|
||||
for (const [type, loader] of this._loaders) {
|
||||
for (const ext of loader.supportedExtensions) {
|
||||
if (ext.includes('.') && ext.split('.').length > 2) {
|
||||
// This is a compound extension like .tilemap.json
|
||||
if (lowerPath.endsWith(ext.toLowerCase())) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then check simple extensions
|
||||
const lastDot = path.lastIndexOf('.');
|
||||
if (lastDot !== -1) {
|
||||
const ext = path.substring(lastDot).toLowerCase();
|
||||
return this.getAssetTypeByExtension(ext);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,155 +0,0 @@
|
||||
/**
|
||||
* 场景资源管理器 - 集中式场景资源加载
|
||||
* SceneResourceManager - Centralized resource loading for scenes
|
||||
*
|
||||
* 扫描场景中所有组件,收集资源引用,批量加载资源,并将运行时 ID 分配回组件
|
||||
* Scans all components in a scene, collects resource references, batch-loads them, and assigns runtime IDs back to components
|
||||
*/
|
||||
|
||||
import type { Scene } from '@esengine/ecs-framework';
|
||||
import { isResourceComponent, type ResourceReference } from '../interfaces/IResourceComponent';
|
||||
|
||||
/**
|
||||
* 资源加载器接口
|
||||
* Resource loader interface
|
||||
*/
|
||||
export interface IResourceLoader {
|
||||
/**
|
||||
* 批量加载资源并返回路径到 ID 的映射
|
||||
* Load a batch of resources and return path-to-ID mapping
|
||||
* @param paths 资源路径数组 / Array of resource paths
|
||||
* @param type 资源类型 / Resource type
|
||||
* @returns 路径到运行时 ID 的映射 / Map of paths to runtime IDs
|
||||
*/
|
||||
loadResourcesBatch(paths: string[], type: ResourceReference['type']): Promise<Map<string, number>>;
|
||||
}
|
||||
|
||||
export class SceneResourceManager {
|
||||
private resourceLoader: IResourceLoader | null = null;
|
||||
|
||||
/**
|
||||
* 设置资源加载器实现
|
||||
* Set the resource loader implementation
|
||||
*
|
||||
* 应由引擎集成层调用
|
||||
* This should be called by the engine integration layer
|
||||
*
|
||||
* @param loader 资源加载器实例 / Resource loader instance
|
||||
*/
|
||||
setResourceLoader(loader: IResourceLoader): void {
|
||||
this.resourceLoader = loader;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载场景所需的所有资源
|
||||
* Load all resources required by a scene
|
||||
*
|
||||
* 流程 / Process:
|
||||
* 1. 扫描所有实体并从 IResourceComponent 实现中收集资源引用
|
||||
* Scan all entities and collect resource references from IResourceComponent implementations
|
||||
* 2. 按类型分组资源(纹理、音频等)
|
||||
* Group resources by type (texture, audio, etc.)
|
||||
* 3. 批量加载每种资源类型
|
||||
* Batch load each resource type
|
||||
* 4. 将运行时 ID 分配回组件
|
||||
* Assign runtime IDs back to components
|
||||
*
|
||||
* @param scene 要加载资源的场景 / The scene to load resources for
|
||||
* @returns 当所有资源加载完成时解析的 Promise / Promise that resolves when all resources are loaded
|
||||
*/
|
||||
async loadSceneResources(scene: Scene): Promise<void> {
|
||||
if (!this.resourceLoader) {
|
||||
console.warn('[SceneResourceManager] No resource loader set, skipping resource loading');
|
||||
return;
|
||||
}
|
||||
|
||||
// 从组件收集所有资源引用 / Collect all resource references from components
|
||||
const resourceRefs = this.collectResourceReferences(scene);
|
||||
|
||||
if (resourceRefs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按资源类型分组 / Group by resource type
|
||||
const resourcesByType = new Map<ResourceReference['type'], Set<string>>();
|
||||
for (const ref of resourceRefs) {
|
||||
if (!resourcesByType.has(ref.type)) {
|
||||
resourcesByType.set(ref.type, new Set());
|
||||
}
|
||||
resourcesByType.get(ref.type)!.add(ref.path);
|
||||
}
|
||||
|
||||
// 批量加载每种资源类型 / Load each resource type in batch
|
||||
const allResourceIds = new Map<string, number>();
|
||||
|
||||
for (const [type, paths] of resourcesByType) {
|
||||
const pathsArray = Array.from(paths);
|
||||
|
||||
try {
|
||||
const resourceIds = await this.resourceLoader.loadResourcesBatch(pathsArray, type);
|
||||
|
||||
// 合并到总映射表 / Merge into combined map
|
||||
for (const [path, id] of resourceIds) {
|
||||
allResourceIds.set(path, id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SceneResourceManager] Failed to load ${type} resources:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 将资源 ID 分配回组件 / Assign resource IDs back to components
|
||||
this.assignResourceIds(scene, allResourceIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从场景实体收集所有资源引用
|
||||
* Collect all resource references from scene entities
|
||||
*/
|
||||
private collectResourceReferences(scene: Scene): ResourceReference[] {
|
||||
const refs: ResourceReference[] = [];
|
||||
|
||||
for (const entity of scene.entities.buffer) {
|
||||
for (const component of entity.components) {
|
||||
if (isResourceComponent(component)) {
|
||||
const componentRefs = component.getResourceReferences();
|
||||
refs.push(...componentRefs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将已加载的资源 ID 分配回组件
|
||||
* Assign loaded resource IDs back to components
|
||||
*
|
||||
* @param scene 场景 / Scene
|
||||
* @param pathToId 路径到 ID 的映射 / Path to ID mapping
|
||||
*/
|
||||
private assignResourceIds(scene: Scene, pathToId: Map<string, number>): void {
|
||||
for (const entity of scene.entities.buffer) {
|
||||
for (const component of entity.components) {
|
||||
if (isResourceComponent(component)) {
|
||||
component.setResourceIds(pathToId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载场景使用的所有资源
|
||||
* Unload all resources used by a scene
|
||||
*
|
||||
* 在场景销毁时调用
|
||||
* Called when a scene is being destroyed
|
||||
*
|
||||
* @param scene 要卸载资源的场景 / The scene to unload resources for
|
||||
*/
|
||||
async unloadSceneResources(_scene: Scene): Promise<void> {
|
||||
// TODO: 实现资源卸载 / Implement resource unloading
|
||||
// 需要跟踪资源引用计数,仅在不再使用时卸载
|
||||
// Need to track resource reference counts and only unload when no longer used
|
||||
console.log('[SceneResourceManager] Scene resource unloading not yet implemented');
|
||||
}
|
||||
}
|
||||
@@ -1,413 +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 type - string based for extensibility
|
||||
* 资产类型 - 使用字符串以支持插件扩展
|
||||
*
|
||||
* Plugins can define their own asset types by using custom strings.
|
||||
* Built-in types are provided as constants below.
|
||||
* 插件可以通过使用自定义字符串定义自己的资产类型。
|
||||
* 内置类型作为常量提供如下。
|
||||
*/
|
||||
export type AssetType = string;
|
||||
|
||||
/**
|
||||
* Built-in asset types provided by asset-system
|
||||
* asset-system 提供的内置资产类型
|
||||
*/
|
||||
export const AssetType = {
|
||||
/** 纹理 */
|
||||
Texture: 'texture',
|
||||
/** 网格 */
|
||||
Mesh: 'mesh',
|
||||
/** 材质 */
|
||||
Material: 'material',
|
||||
/** 着色器 */
|
||||
Shader: 'shader',
|
||||
/** 音频 */
|
||||
Audio: 'audio',
|
||||
/** 字体 */
|
||||
Font: 'font',
|
||||
/** 预制体 */
|
||||
Prefab: 'prefab',
|
||||
/** 场景 */
|
||||
Scene: 'scene',
|
||||
/** 脚本 */
|
||||
Script: 'script',
|
||||
/** 动画片段 */
|
||||
AnimationClip: 'animation',
|
||||
/** JSON数据 */
|
||||
Json: 'json',
|
||||
/** 文本 */
|
||||
Text: 'text',
|
||||
/** 二进制 */
|
||||
Binary: 'binary',
|
||||
/** 自定义 */
|
||||
Custom: 'custom'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 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 '';
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* UV Coordinate Helper
|
||||
* UV 坐标辅助工具
|
||||
*
|
||||
* 引擎使用图像坐标系:
|
||||
* Engine uses image coordinate system:
|
||||
* - 原点 (0, 0) 在左上角 | Origin at top-left
|
||||
* - V 轴向下增长 | V-axis increases downward
|
||||
* - UV 格式:[u0, v0, u1, v1] 其中 v0 < v1
|
||||
*/
|
||||
export class UVHelper {
|
||||
/**
|
||||
* Calculate UV coordinates for a texture region
|
||||
* 计算纹理区域的 UV 坐标
|
||||
*/
|
||||
static calculateUV(
|
||||
imageRect: { x: number; y: number; width: number; height: number },
|
||||
textureSize: { width: number; height: number }
|
||||
): [number, number, number, number] {
|
||||
const { x, y, width, height } = imageRect;
|
||||
const { width: tw, height: th } = textureSize;
|
||||
|
||||
return [
|
||||
x / tw, // u0
|
||||
y / th, // v0
|
||||
(x + width) / tw, // u1
|
||||
(y + height) / th // v1
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate UV coordinates for a tile in a tileset
|
||||
* 计算 tileset 中某个 tile 的 UV 坐标
|
||||
*/
|
||||
static calculateTileUV(
|
||||
tileIndex: number,
|
||||
tilesetInfo: {
|
||||
columns: number;
|
||||
tileWidth: number;
|
||||
tileHeight: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
margin?: number;
|
||||
spacing?: number;
|
||||
}
|
||||
): [number, number, number, number] | null {
|
||||
if (tileIndex < 0) return null;
|
||||
|
||||
const {
|
||||
columns,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
margin = 0,
|
||||
spacing = 0
|
||||
} = tilesetInfo;
|
||||
|
||||
const col = tileIndex % columns;
|
||||
const row = Math.floor(tileIndex / columns);
|
||||
const x = margin + col * (tileWidth + spacing);
|
||||
const y = margin + row * (tileHeight + spacing);
|
||||
|
||||
return this.calculateUV(
|
||||
{ x, y, width: tileWidth, height: tileHeight },
|
||||
{ width: imageWidth, height: imageHeight }
|
||||
);
|
||||
}
|
||||
|
||||
static validateUV(uv: [number, number, number, number]): boolean {
|
||||
const [u0, v0, u1, v1] = uv;
|
||||
return u0 >= 0 && u0 <= 1 && u1 >= 0 && u1 <= 1 &&
|
||||
v0 >= 0 && v0 <= 1 && v1 >= 0 && v1 <= 1 &&
|
||||
u0 < u1 && v0 < v1;
|
||||
}
|
||||
|
||||
static debugPrint(uv: [number, number, number, number], label?: string): void {
|
||||
const prefix = label ? `[${label}] ` : '';
|
||||
console.log(`${prefix}UV: [${uv.map(n => n.toFixed(4)).join(', ')}]`);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"composite": true,
|
||||
"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,7 +0,0 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { runtimeOnlyPreset } from '../build-config/src/presets/plugin-tsup';
|
||||
|
||||
export default defineConfig({
|
||||
...runtimeOnlyPreset(),
|
||||
tsconfig: 'tsconfig.build.json'
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"name": "@esengine/audio",
|
||||
"version": "1.0.0",
|
||||
"description": "ECS-based audio system",
|
||||
"esengine": {
|
||||
"plugin": true,
|
||||
"pluginExport": "AudioPlugin",
|
||||
"category": "audio",
|
||||
"isEnginePlugin": true
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"audio",
|
||||
"sound",
|
||||
"music"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor } from '@esengine/engine-core';
|
||||
import { AudioSourceComponent } from './AudioSourceComponent';
|
||||
|
||||
class AudioRuntimeModule implements IRuntimeModule {
|
||||
registerComponents(registry: typeof ComponentRegistryType): void {
|
||||
registry.register(AudioSourceComponent);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/audio',
|
||||
name: 'Audio',
|
||||
version: '1.0.0',
|
||||
description: '音频组件',
|
||||
category: 'audio',
|
||||
enabledByDefault: true,
|
||||
isEnginePlugin: true
|
||||
};
|
||||
|
||||
export const AudioPlugin: IPlugin = {
|
||||
descriptor,
|
||||
runtimeModule: new AudioRuntimeModule()
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('AudioSource')
|
||||
@Serializable({ version: 1, typeId: 'AudioSource' })
|
||||
export class AudioSourceComponent extends Component {
|
||||
@Serialize()
|
||||
@Property({ type: 'asset', label: 'Audio Clip', assetType: 'audio' })
|
||||
clip: string = '';
|
||||
|
||||
/** 范围 [0, 1] */
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Volume', min: 0, max: 1, step: 0.01 })
|
||||
volume: number = 1;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Pitch', min: 0.1, max: 3, step: 0.1 })
|
||||
pitch: number = 1;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Loop' })
|
||||
loop: boolean = false;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Play On Awake' })
|
||||
playOnAwake: boolean = false;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Mute' })
|
||||
mute: boolean = false;
|
||||
|
||||
/** 0 = 2D, 1 = 3D */
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Spatial Blend', min: 0, max: 1, step: 0.1 })
|
||||
spatialBlend: number = 0;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Min Distance' })
|
||||
minDistance: number = 1;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Max Distance' })
|
||||
maxDistance: number = 500;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { AudioSourceComponent } from './AudioSourceComponent';
|
||||
export { AudioPlugin } from './AudioPlugin';
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"references": [
|
||||
{ "path": "../core" }
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { runtimeOnlyPreset } from '../build-config/src/presets/plugin-tsup';
|
||||
|
||||
export default defineConfig({
|
||||
...runtimeOnlyPreset(),
|
||||
tsconfig: 'tsconfig.build.json'
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"name": "@esengine/behavior-tree-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "Editor support for @esengine/behavior-tree - visual editor, inspectors, and tools",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/behavior-tree": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/editor-runtime": "workspace:*",
|
||||
"@esengine/node-editor": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^18.3.1",
|
||||
"zustand": "^5.0.8",
|
||||
"@types/react": "^18.3.12",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"behavior-tree",
|
||||
"editor"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Behavior Tree Plugin Descriptor
|
||||
* 行为树插件描述符
|
||||
*/
|
||||
|
||||
import type { PluginDescriptor } from '@esengine/editor-runtime';
|
||||
|
||||
/**
|
||||
* 插件描述符
|
||||
*/
|
||||
export const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/behavior-tree',
|
||||
name: 'Behavior Tree System',
|
||||
version: '1.0.0',
|
||||
description: 'AI 行为树系统,支持可视化编辑和运行时执行',
|
||||
category: 'ai',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: false,
|
||||
modules: [
|
||||
{
|
||||
name: 'BehaviorTreeRuntime',
|
||||
type: 'runtime',
|
||||
loadingPhase: 'default'
|
||||
},
|
||||
{
|
||||
name: 'BehaviorTreeEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'default'
|
||||
}
|
||||
],
|
||||
dependencies: [
|
||||
{ id: '@esengine/engine-core', version: '>=1.0.0', optional: true }
|
||||
],
|
||||
icon: 'GitBranch'
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { ServiceContainer } from '@esengine/editor-runtime';
|
||||
|
||||
/**
|
||||
* 插件上下文
|
||||
* 存储插件安装时传入的服务容器引用
|
||||
*/
|
||||
class PluginContextClass {
|
||||
private _services: ServiceContainer | null = null;
|
||||
|
||||
setServices(services: ServiceContainer): void {
|
||||
this._services = services;
|
||||
}
|
||||
|
||||
getServices(): ServiceContainer {
|
||||
if (!this._services) {
|
||||
throw new Error('PluginContext not initialized. Make sure the plugin is properly installed.');
|
||||
}
|
||||
return this._services;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._services = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const PluginContext = new PluginContextClass();
|
||||
@@ -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-runtime';
|
||||
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-runtime';
|
||||
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-runtime';
|
||||
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-runtime';
|
||||
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;
|
||||
}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
import { Connection } from '../../../domain/models/Connection';
|
||||
import { BaseCommand } from '@esengine/editor-runtime';
|
||||
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-runtime';
|
||||
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 { type GlobalBlackboardConfig, BlackboardValueType, type 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,557 +0,0 @@
|
||||
import { createStore } from '@esengine/editor-runtime';
|
||||
|
||||
const create = createStore;
|
||||
import { NodeTemplates, type 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-runtime';
|
||||
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 type { 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-runtime';
|
||||
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-runtime';
|
||||
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-runtime';
|
||||
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-runtime';
|
||||
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-runtime';
|
||||
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,667 +0,0 @@
|
||||
import {
|
||||
React,
|
||||
useState,
|
||||
useEffect,
|
||||
type ICompiler,
|
||||
type CompileResult,
|
||||
type CompilerContext,
|
||||
type IFileSystem,
|
||||
Icons,
|
||||
createLogger,
|
||||
} from '@esengine/editor-runtime';
|
||||
import { GlobalBlackboardTypeGenerator } from '../generators/GlobalBlackboardTypeGenerator';
|
||||
import { EditorFormatConverter, BehaviorTreeAssetSerializer } from '@esengine/behavior-tree';
|
||||
import { useBehaviorTreeDataStore } from '../application/state/BehaviorTreeDataStore';
|
||||
|
||||
const { File, FolderTree, FolderOpen } = Icons;
|
||||
|
||||
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,736 +0,0 @@
|
||||
import { React, useEffect, useMemo, useRef, useState, useCallback } from '@esengine/editor-runtime';
|
||||
import { BlackboardValueType, type NodeTemplate } 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 store(UI 交互状态)
|
||||
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();
|
||||
};
|
||||
|
||||
// 使用 useCallback 包装 getPortPosition,确保在 canvasScale/canvasOffset 变化时更新
|
||||
// Use useCallback to wrap getPortPosition to ensure updates when canvasScale/canvasOffset changes
|
||||
const getPortPosition = useCallback(
|
||||
(nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') =>
|
||||
getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType, draggingNodeId, dragDelta, selectedNodeIds),
|
||||
[canvasOffset, canvasScale, nodes, draggingNodeId, dragDelta, selectedNodeIds]
|
||||
);
|
||||
|
||||
stopExecutionRef.current = handleStop;
|
||||
|
||||
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,791 +0,0 @@
|
||||
import { React, useState, Icons } from '@esengine/editor-runtime';
|
||||
|
||||
const { Clipboard, Edit2, Trash2, ChevronDown, ChevronRight, Globe, GripVertical, ChevronLeft, Plus, Copy } = Icons;
|
||||
|
||||
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 '@esengine/editor-runtime';
|
||||
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 '@esengine/editor-runtime';
|
||||
|
||||
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,182 +0,0 @@
|
||||
import { React, useState, useRef, useEffect, type ReactNode, Icons } from '@esengine/editor-runtime';
|
||||
|
||||
const { GripVertical } = Icons;
|
||||
|
||||
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,86 +0,0 @@
|
||||
import { React, useMemo } from '@esengine/editor-runtime';
|
||||
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;
|
||||
/** 用于强制刷新连线(当 canvasScale 等变化时) */
|
||||
/** Used to force refresh connections (when canvasScale etc. changes) */
|
||||
refreshKey?: number;
|
||||
}
|
||||
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,154 +0,0 @@
|
||||
import { React } from '@esengine/editor-runtime';
|
||||
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;
|
||||
|
||||
// 直接计算路径数据,不使用 useMemo
|
||||
// getPortPosition 使用节点数据直接计算,不依赖缩放状态
|
||||
let fromPos, toPos;
|
||||
|
||||
if (connection.connectionType === 'property') {
|
||||
fromPos = getPortPosition(connection.from, connection.fromProperty);
|
||||
toPos = getPortPosition(connection.to, connection.toProperty);
|
||||
} else {
|
||||
fromPos = getPortPosition(connection.from, undefined, 'output');
|
||||
toPos = getPortPosition(connection.to, undefined, 'input');
|
||||
}
|
||||
|
||||
if (!fromPos || !toPos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
const midX = (x1 + x2) / 2;
|
||||
const midY = (y1 + y2) / 2;
|
||||
|
||||
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}`;
|
||||
|
||||
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={pathD}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={24}
|
||||
/>
|
||||
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={glowColor}
|
||||
strokeWidth={strokeWidth + 2}
|
||||
strokeLinecap="round"
|
||||
opacity={isSelected ? 0.4 : 0.2}
|
||||
/>
|
||||
|
||||
<path
|
||||
d={pathD}
|
||||
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={midX}
|
||||
cy={midY}
|
||||
r="8"
|
||||
fill={strokeColor}
|
||||
opacity="0.3"
|
||||
/>
|
||||
<circle
|
||||
cx={midX}
|
||||
cy={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,95 +0,0 @@
|
||||
import { React, Icons } from '@esengine/editor-runtime';
|
||||
|
||||
const { Trash2, Replace, Plus } = Icons;
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user