Feature/render pipeline (#232)
* refactor(engine): 重构2D渲染管线坐标系统 * feat(engine): 完善2D渲染管线和编辑器视口功能 * feat(editor): 实现Viewport变换工具系统 * feat(editor): 优化Inspector渲染性能并修复Gizmo变换工具显示 * feat(editor): 实现Run on Device移动预览功能 * feat(editor): 添加组件属性控制和依赖关系系统 * feat(editor): 实现动画预览功能和优化SpriteAnimator编辑器 * feat(editor): 修复SpriteAnimator动画预览功能并迁移CI到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(ci): 迁移项目到pnpm并修复CI构建问题 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 移除 network 相关包 * chore: 移除 network 相关包
This commit is contained in:
72
.github/workflows/ci.yml
vendored
72
.github/workflows/ci.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'tsconfig.json'
|
||||
- 'jest.config.*'
|
||||
- '.github/workflows/ci.yml'
|
||||
@@ -15,7 +15,7 @@ on:
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'tsconfig.json'
|
||||
- 'jest.config.*'
|
||||
- '.github/workflows/ci.yml'
|
||||
@@ -23,38 +23,69 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm install
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: cargo install wasm-pack
|
||||
|
||||
- name: Build core package first
|
||||
run: npm run build:core
|
||||
run: pnpm run build:core
|
||||
|
||||
- name: Build platform-common for type declarations
|
||||
run: cd packages/platform-common && npm run build
|
||||
- name: Build engine WASM package
|
||||
run: |
|
||||
cd packages/engine
|
||||
pnpm run build
|
||||
|
||||
- name: Copy WASM files to ecs-engine-bindgen
|
||||
run: |
|
||||
mkdir -p packages/ecs-engine-bindgen/src/wasm
|
||||
cp packages/engine/pkg/es_engine.js packages/ecs-engine-bindgen/src/wasm/
|
||||
cp packages/engine/pkg/es_engine.d.ts packages/ecs-engine-bindgen/src/wasm/
|
||||
cp packages/engine/pkg/es_engine_bg.wasm packages/ecs-engine-bindgen/src/wasm/
|
||||
cp packages/engine/pkg/es_engine_bg.wasm.d.ts packages/ecs-engine-bindgen/src/wasm/
|
||||
|
||||
- name: Build dependent packages for type declarations
|
||||
run: |
|
||||
cd packages/platform-common && pnpm run build
|
||||
cd ../asset-system && pnpm run build
|
||||
cd ../components && pnpm run build
|
||||
|
||||
- name: Build ecs-engine-bindgen
|
||||
run: |
|
||||
cd packages/ecs-engine-bindgen && pnpm run build
|
||||
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
run: pnpm run type-check
|
||||
|
||||
- name: Lint check
|
||||
run: npm run lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:ci
|
||||
run: pnpm run test:ci
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
continue-on-error: true # 即使失败也继续
|
||||
continue-on-error: true
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
flags: unittests
|
||||
@@ -64,25 +95,30 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm install
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Build npm package
|
||||
run: npm run build:npm
|
||||
run: pnpm run build:npm
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -91,4 +127,4 @@ jobs:
|
||||
path: |
|
||||
bin/
|
||||
dist/
|
||||
retention-days: 7
|
||||
retention-days: 7
|
||||
|
||||
15
.github/workflows/codecov.yml
vendored
15
.github/workflows/codecov.yml
vendored
@@ -14,29 +14,34 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
cd packages/core
|
||||
npm run test:coverage
|
||||
pnpm run test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
continue-on-error: true # 即使失败也继续
|
||||
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 # 不因为 Codecov 失败而失败 CI
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
|
||||
- name: Upload coverage artifact
|
||||
|
||||
9
.github/workflows/commitlint.yml
vendored
9
.github/workflows/commitlint.yml
vendored
@@ -17,15 +17,20 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install commitlint
|
||||
run: |
|
||||
npm install --save-dev @commitlint/config-conventional @commitlint/cli
|
||||
pnpm add -D @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
|
||||
|
||||
15
.github/workflows/docs.yml
vendored
15
.github/workflows/docs.yml
vendored
@@ -29,26 +29,31 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm install
|
||||
|
||||
- name: Build core package
|
||||
run: npm run build:core
|
||||
run: pnpm run build:core
|
||||
|
||||
- name: Generate API documentation
|
||||
run: npm run docs:api
|
||||
run: pnpm run docs:api
|
||||
|
||||
- name: Build documentation
|
||||
run: npm run docs:build
|
||||
run: pnpm run docs:build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
|
||||
20
.github/workflows/release-editor.yml
vendored
20
.github/workflows/release-editor.yml
vendored
@@ -33,11 +33,16 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -57,13 +62,12 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
run: pnpm install
|
||||
|
||||
- name: Update version in config files (for manual trigger)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
cd packages/editor-app
|
||||
# 临时更新版本号用于构建(不提交到仓库)
|
||||
npm version ${{ github.event.inputs.version }} --no-git-tag-version
|
||||
node scripts/sync-version.js
|
||||
|
||||
@@ -79,17 +83,17 @@ jobs:
|
||||
${{ runner.os }}-ts-build-
|
||||
|
||||
- name: Build core package
|
||||
run: npm run build:core
|
||||
run: pnpm run build:core
|
||||
|
||||
- name: Build editor-core package
|
||||
run: |
|
||||
cd packages/editor-core
|
||||
npm run build
|
||||
pnpm run build
|
||||
|
||||
- name: Build behavior-tree package
|
||||
run: |
|
||||
cd packages/behavior-tree
|
||||
npm run build
|
||||
pnpm run build
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: cargo install wasm-pack
|
||||
@@ -97,12 +101,12 @@ jobs:
|
||||
- name: Build engine package (Rust WASM)
|
||||
run: |
|
||||
cd packages/engine
|
||||
npm run build
|
||||
pnpm run build
|
||||
|
||||
- name: Build ecs-engine-bindgen package
|
||||
run: |
|
||||
cd packages/ecs-engine-bindgen
|
||||
npm run build
|
||||
pnpm run build
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0.5
|
||||
|
||||
13
.github/workflows/release.yml
vendored
13
.github/workflows/release.yml
vendored
@@ -41,21 +41,26 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'npm'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm install
|
||||
|
||||
- name: Build core package (if needed)
|
||||
if: ${{ github.event.inputs.package == 'behavior-tree' || github.event.inputs.package == 'editor-core' }}
|
||||
run: |
|
||||
cd packages/core
|
||||
npm run build
|
||||
pnpm run build
|
||||
|
||||
# - name: Run tests
|
||||
# run: |
|
||||
@@ -78,7 +83,7 @@ jobs:
|
||||
- name: Build package
|
||||
run: |
|
||||
cd packages/${{ github.event.inputs.package }}
|
||||
npm run build:npm
|
||||
pnpm run build:npm
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
|
||||
11
.github/workflows/size-limit.yml
vendored
11
.github/workflows/size-limit.yml
vendored
@@ -22,19 +22,24 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm install
|
||||
|
||||
- name: Build core package
|
||||
run: |
|
||||
cd packages/core
|
||||
npm run build:npm
|
||||
pnpm run build:npm
|
||||
|
||||
- name: Check bundle size
|
||||
uses: andresz1/size-limit-action@v1
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -49,9 +49,9 @@ logs/
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# 包管理器锁文件(保留npm的,忽略其他的)
|
||||
# 包管理器锁文件(忽略yarn,保留pnpm)
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
|
||||
# 文档生成
|
||||
docs/api/
|
||||
|
||||
@@ -65,7 +65,8 @@ export default [
|
||||
'examples/lawn-mower-demo/**',
|
||||
'extensions/**',
|
||||
'**/*.min.js',
|
||||
'**/*.d.ts'
|
||||
'**/*.d.ts',
|
||||
'**/wasm/**'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
30057
package-lock.json
generated
30057
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -18,35 +18,23 @@
|
||||
"scripts": {
|
||||
"bootstrap": "lerna bootstrap",
|
||||
"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": "npm run build:core && npm run build:math",
|
||||
"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": "npm run build:npm:core && npm run build:npm:math",
|
||||
"build:npm:core": "cd packages/core && npm run build:npm",
|
||||
"build:npm:math": "cd packages/math && npm run build:npm",
|
||||
"build:npm:network-shared": "cd packages/network-shared && npm run build:npm",
|
||||
"build:npm:network-client": "cd packages/network-client && npm run build:npm",
|
||||
"build:npm:network-server": "cd packages/network-server && npm run build:npm",
|
||||
"test": "lerna run test",
|
||||
"test:coverage": "lerna run test:coverage",
|
||||
"test:ci": "lerna run test:ci",
|
||||
"prepare:publish": "npm run build:npm && node scripts/pre-publish-check.cjs",
|
||||
"sync:versions": "node scripts/sync-versions.cjs",
|
||||
"publish:all": "npm run prepare:publish && npm run publish:all:dist",
|
||||
"publish:all:dist": "npm run publish:core && npm run publish:math && npm run publish:network-shared && npm run publish:network-client && npm run publish:network-server",
|
||||
"publish:all:dist": "npm run publish:core && npm run publish:math",
|
||||
"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",
|
||||
@@ -65,13 +53,16 @@
|
||||
"format:check": "prettier --check \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
||||
"type-check": "lerna run type-check",
|
||||
"lint": "eslint \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
||||
"lint:fix": "eslint \"packages/**/src/**/*.{ts,tsx,js,jsx}\" --fix"
|
||||
"lint:fix": "eslint \"packages/**/src/**/*.{ts,tsx,js,jsx}\" --fix",
|
||||
"build:wasm": "cd packages/engine && wasm-pack build --dev --out-dir pkg",
|
||||
"build:wasm:release": "cd packages/engine && wasm-pack build --release --out-dir pkg"
|
||||
},
|
||||
"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",
|
||||
@@ -104,6 +95,7 @@
|
||||
"typedoc": "^0.28.13",
|
||||
"typedoc-plugin-markdown": "^4.9.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.47.0",
|
||||
"unplugin-icons": "^22.3.0",
|
||||
"vitepress": "^1.6.4"
|
||||
},
|
||||
|
||||
52
packages/asset-system/package.json
Normal file
52
packages/asset-system/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@esengine/asset-system",
|
||||
"version": "1.0.0",
|
||||
"description": "Asset management system for ES Engine",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"build:npm": "npm run build",
|
||||
"clean": "rimraf dist",
|
||||
"type-check": "npx tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"asset",
|
||||
"resource",
|
||||
"bundle"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@esengine/ecs-framework": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^28.0.3",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"rimraf": "^5.0.0",
|
||||
"rollup": "^4.42.0",
|
||||
"rollup-plugin-dts": "^6.2.1",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/esengine/ecs-framework.git",
|
||||
"directory": "packages/asset-system"
|
||||
}
|
||||
}
|
||||
49
packages/asset-system/rollup.config.js
Normal file
49
packages/asset-system/rollup.config.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import dts from 'rollup-plugin-dts';
|
||||
|
||||
const external = ['@esengine/ecs-framework'];
|
||||
|
||||
export default [
|
||||
// ESM and CJS builds
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
output: [
|
||||
{
|
||||
file: 'dist/index.js',
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
exports: 'named'
|
||||
},
|
||||
{
|
||||
file: 'dist/index.mjs',
|
||||
format: 'es',
|
||||
sourcemap: true
|
||||
}
|
||||
],
|
||||
external,
|
||||
plugins: [
|
||||
resolve({
|
||||
preferBuiltins: false,
|
||||
browser: true
|
||||
}),
|
||||
commonjs(),
|
||||
typescript({
|
||||
tsconfig: './tsconfig.json',
|
||||
declaration: false,
|
||||
declarationMap: false
|
||||
})
|
||||
]
|
||||
},
|
||||
// Type definitions
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
file: 'dist/index.d.ts',
|
||||
format: 'es'
|
||||
},
|
||||
external,
|
||||
plugins: [dts()]
|
||||
}
|
||||
];
|
||||
130
packages/asset-system/src/core/AssetCache.ts
Normal file
130
packages/asset-system/src/core/AssetCache.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
}
|
||||
431
packages/asset-system/src/core/AssetDatabase.ts
Normal file
431
packages/asset-system/src/core/AssetDatabase.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
193
packages/asset-system/src/core/AssetLoadQueue.ts
Normal file
193
packages/asset-system/src/core/AssetLoadQueue.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
541
packages/asset-system/src/core/AssetManager.ts
Normal file
541
packages/asset-system/src/core/AssetManager.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* Asset manager implementation
|
||||
* 资产管理器实现
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetGUID,
|
||||
AssetHandle,
|
||||
AssetType,
|
||||
AssetState,
|
||||
IAssetLoadOptions,
|
||||
IAssetLoadResult,
|
||||
IAssetReferenceInfo,
|
||||
IAssetPreloadGroup,
|
||||
IAssetLoadProgress,
|
||||
IAssetMetadata,
|
||||
AssetLoadError,
|
||||
IAssetCatalog
|
||||
} from '../types/AssetTypes';
|
||||
import {
|
||||
IAssetManager,
|
||||
IAssetLoadQueue
|
||||
} from '../interfaces/IAssetManager';
|
||||
import { IAssetLoader, IAssetLoaderFactory } from '../interfaces/IAssetLoader';
|
||||
import { AssetCache } from './AssetCache';
|
||||
import { AssetLoadQueue } from './AssetLoadQueue';
|
||||
import { AssetLoaderFactory } from '../loaders/AssetLoaderFactory';
|
||||
import { AssetDatabase } from './AssetDatabase';
|
||||
|
||||
/**
|
||||
* Asset entry in the manager
|
||||
* 管理器中的资产条目
|
||||
*/
|
||||
interface AssetEntry {
|
||||
guid: AssetGUID;
|
||||
handle: AssetHandle;
|
||||
asset: unknown;
|
||||
metadata: IAssetMetadata;
|
||||
state: AssetState;
|
||||
referenceCount: number;
|
||||
lastAccessTime: number;
|
||||
loadPromise?: Promise<IAssetLoadResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset manager implementation
|
||||
* 资产管理器实现
|
||||
*/
|
||||
export class AssetManager implements IAssetManager {
|
||||
private readonly _assets = new Map<AssetGUID, AssetEntry>();
|
||||
private readonly _handleToGuid = new Map<AssetHandle, AssetGUID>();
|
||||
private readonly _pathToGuid = new Map<string, AssetGUID>();
|
||||
private readonly _cache: AssetCache;
|
||||
private readonly _loadQueue: IAssetLoadQueue;
|
||||
private readonly _loaderFactory: IAssetLoaderFactory;
|
||||
private readonly _database: AssetDatabase;
|
||||
|
||||
private _nextHandle: AssetHandle = 1;
|
||||
|
||||
private _statistics = {
|
||||
loadedCount: 0,
|
||||
failedCount: 0
|
||||
};
|
||||
|
||||
private _isDisposed = false;
|
||||
private _loadingCount = 0;
|
||||
|
||||
constructor(catalog?: IAssetCatalog) {
|
||||
this._cache = new AssetCache();
|
||||
this._loadQueue = new AssetLoadQueue();
|
||||
this._loaderFactory = new AssetLoaderFactory();
|
||||
this._database = new AssetDatabase();
|
||||
|
||||
// 如果提供了目录,初始化数据库 / Initialize database if catalog provided
|
||||
if (catalog) {
|
||||
this.initializeFromCatalog(catalog);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize from catalog
|
||||
* 从目录初始化
|
||||
*/
|
||||
private initializeFromCatalog(catalog: IAssetCatalog): void {
|
||||
catalog.entries.forEach((entry, guid) => {
|
||||
const metadata: IAssetMetadata = {
|
||||
guid,
|
||||
path: entry.path,
|
||||
type: entry.type,
|
||||
name: entry.path.split('/').pop() || '',
|
||||
size: entry.size,
|
||||
hash: entry.hash,
|
||||
dependencies: [],
|
||||
labels: [],
|
||||
tags: new Map(),
|
||||
lastModified: Date.now(),
|
||||
version: 1
|
||||
};
|
||||
|
||||
this._database.addAsset(metadata);
|
||||
this._pathToGuid.set(entry.path, guid);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load asset by GUID
|
||||
* 通过GUID加载资产
|
||||
*/
|
||||
async loadAsset<T = unknown>(
|
||||
guid: AssetGUID,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<T>> {
|
||||
// 检查是否已加载 / Check if already loaded
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry) {
|
||||
if (entry.state === AssetState.Loaded && !options?.forceReload) {
|
||||
entry.lastAccessTime = Date.now();
|
||||
return {
|
||||
asset: entry.asset as T,
|
||||
handle: entry.handle,
|
||||
metadata: entry.metadata,
|
||||
loadTime: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (entry.state === AssetState.Loading && entry.loadPromise) {
|
||||
return entry.loadPromise as Promise<IAssetLoadResult<T>>;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取元数据 / Get metadata
|
||||
const metadata = this._database.getMetadata(guid);
|
||||
if (!metadata) {
|
||||
throw AssetLoadError.fileNotFound(guid, 'Unknown');
|
||||
}
|
||||
|
||||
// 创建加载器 / Create loader
|
||||
const loader = this._loaderFactory.createLoader(metadata.type);
|
||||
if (!loader) {
|
||||
throw AssetLoadError.unsupportedType(guid, metadata.type);
|
||||
}
|
||||
|
||||
// 开始加载 / Start loading
|
||||
const loadStartTime = performance.now();
|
||||
const newEntry: AssetEntry = {
|
||||
guid,
|
||||
handle: this._nextHandle++,
|
||||
asset: null,
|
||||
metadata,
|
||||
state: AssetState.Loading,
|
||||
referenceCount: 0,
|
||||
lastAccessTime: Date.now()
|
||||
};
|
||||
|
||||
this._assets.set(guid, newEntry);
|
||||
this._handleToGuid.set(newEntry.handle, guid);
|
||||
this._loadingCount++;
|
||||
|
||||
// 创建加载Promise / Create loading promise
|
||||
const loadPromise = this.performLoad<T>(loader, metadata, options, loadStartTime, newEntry);
|
||||
newEntry.loadPromise = loadPromise;
|
||||
|
||||
try {
|
||||
const result = await loadPromise;
|
||||
return result;
|
||||
} catch (error) {
|
||||
this._statistics.failedCount++;
|
||||
newEntry.state = AssetState.Failed;
|
||||
throw error;
|
||||
} finally {
|
||||
this._loadingCount--;
|
||||
delete newEntry.loadPromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform asset loading
|
||||
* 执行资产加载
|
||||
*/
|
||||
private async performLoad<T>(
|
||||
loader: IAssetLoader,
|
||||
metadata: IAssetMetadata,
|
||||
options: IAssetLoadOptions | undefined,
|
||||
startTime: number,
|
||||
entry: AssetEntry
|
||||
): Promise<IAssetLoadResult<T>> {
|
||||
// 加载依赖 / Load dependencies
|
||||
if (metadata.dependencies.length > 0) {
|
||||
await this.loadDependencies(metadata.dependencies, options);
|
||||
}
|
||||
|
||||
// 执行加载 / Execute loading
|
||||
const result = await loader.load(metadata.path, metadata, options);
|
||||
|
||||
// 更新条目 / Update entry
|
||||
entry.asset = result.asset;
|
||||
entry.state = AssetState.Loaded;
|
||||
|
||||
// 缓存资产 / Cache asset
|
||||
this._cache.set(metadata.guid, result.asset);
|
||||
|
||||
// 更新统计 / Update statistics
|
||||
this._statistics.loadedCount++;
|
||||
|
||||
const loadResult: IAssetLoadResult<T> = {
|
||||
asset: result.asset as T,
|
||||
handle: entry.handle,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
|
||||
return loadResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load dependencies
|
||||
* 加载依赖
|
||||
*/
|
||||
private async loadDependencies(
|
||||
dependencies: AssetGUID[],
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<void> {
|
||||
const promises = dependencies.map((dep) => this.loadAsset(dep, options));
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load asset by path
|
||||
* 通过路径加载资产
|
||||
*/
|
||||
async loadAssetByPath<T = unknown>(
|
||||
path: string,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<T>> {
|
||||
const guid = this._pathToGuid.get(path);
|
||||
if (!guid) {
|
||||
// 尝试从数据库查找 / Try to find from database
|
||||
let metadata = this._database.getMetadataByPath(path);
|
||||
if (!metadata) {
|
||||
// 动态创建元数据 / Create metadata dynamically
|
||||
const fileExt = path.substring(path.lastIndexOf('.')).toLowerCase();
|
||||
let assetType = AssetType.Custom;
|
||||
|
||||
// 根据文件扩展名确定资产类型 / Determine asset type by file extension
|
||||
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(fileExt)) {
|
||||
assetType = AssetType.Texture;
|
||||
} else if (['.json'].includes(fileExt)) {
|
||||
assetType = AssetType.Json;
|
||||
} else if (['.txt', '.md', '.xml', '.yaml'].includes(fileExt)) {
|
||||
assetType = AssetType.Text;
|
||||
}
|
||||
|
||||
// 生成唯一GUID / Generate unique GUID
|
||||
const dynamicGuid = `dynamic_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
metadata = {
|
||||
guid: dynamicGuid,
|
||||
path: path,
|
||||
type: assetType,
|
||||
name: path.split('/').pop() || path.split('\\').pop() || 'unnamed',
|
||||
size: 0, // 动态加载时未知大小 / Unknown size for dynamic loading
|
||||
hash: '',
|
||||
dependencies: [],
|
||||
labels: [],
|
||||
tags: new Map(),
|
||||
lastModified: Date.now(),
|
||||
version: 1
|
||||
};
|
||||
|
||||
// 注册到数据库 / Register to database
|
||||
this._database.addAsset(metadata);
|
||||
this._pathToGuid.set(path, metadata.guid);
|
||||
} else {
|
||||
this._pathToGuid.set(path, metadata.guid);
|
||||
}
|
||||
|
||||
return this.loadAsset<T>(metadata.guid, options);
|
||||
}
|
||||
|
||||
return this.loadAsset<T>(guid, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load multiple assets
|
||||
* 批量加载资产
|
||||
*/
|
||||
async loadAssets(
|
||||
guids: AssetGUID[],
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<Map<AssetGUID, IAssetLoadResult>> {
|
||||
const results = new Map<AssetGUID, IAssetLoadResult>();
|
||||
|
||||
// 并行加载所有资产 / Load all assets in parallel
|
||||
const promises = guids.map(async (guid) => {
|
||||
try {
|
||||
const result = await this.loadAsset(guid, options);
|
||||
results.set(guid, result);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load asset ${guid}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload asset group
|
||||
* 预加载资产组
|
||||
*/
|
||||
async preloadGroup(
|
||||
group: IAssetPreloadGroup,
|
||||
onProgress?: (progress: IAssetLoadProgress) => void
|
||||
): Promise<void> {
|
||||
const totalCount = group.assets.length;
|
||||
let loadedCount = 0;
|
||||
let loadedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
// 计算总大小 / Calculate total size
|
||||
for (const guid of group.assets) {
|
||||
const metadata = this._database.getMetadata(guid);
|
||||
if (metadata) {
|
||||
totalBytes += metadata.size;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载每个资产 / Load each asset
|
||||
for (const guid of group.assets) {
|
||||
const metadata = this._database.getMetadata(guid);
|
||||
if (!metadata) continue;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentAsset: metadata.name,
|
||||
loadedCount,
|
||||
totalCount,
|
||||
loadedBytes,
|
||||
totalBytes,
|
||||
progress: loadedCount / totalCount
|
||||
});
|
||||
}
|
||||
|
||||
await this.loadAsset(guid, { priority: group.priority });
|
||||
|
||||
loadedCount++;
|
||||
loadedBytes += metadata.size;
|
||||
}
|
||||
|
||||
// 最终进度 / Final progress
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentAsset: '',
|
||||
loadedCount: totalCount,
|
||||
totalCount,
|
||||
loadedBytes: totalBytes,
|
||||
totalBytes,
|
||||
progress: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get loaded asset
|
||||
* 获取已加载的资产
|
||||
*/
|
||||
getAsset<T = unknown>(guid: AssetGUID): T | null {
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry && entry.state === AssetState.Loaded) {
|
||||
entry.lastAccessTime = Date.now();
|
||||
return entry.asset as T;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset by handle
|
||||
* 通过句柄获取资产
|
||||
*/
|
||||
getAssetByHandle<T = unknown>(handle: AssetHandle): T | null {
|
||||
const guid = this._handleToGuid.get(handle);
|
||||
if (!guid) return null;
|
||||
return this.getAsset<T>(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset is loaded
|
||||
* 检查资产是否已加载
|
||||
*/
|
||||
isLoaded(guid: AssetGUID): boolean {
|
||||
const entry = this._assets.get(guid);
|
||||
return entry?.state === AssetState.Loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset state
|
||||
* 获取资产状态
|
||||
*/
|
||||
getAssetState(guid: AssetGUID): AssetState {
|
||||
const entry = this._assets.get(guid);
|
||||
return entry?.state || AssetState.Unloaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload asset
|
||||
* 卸载资产
|
||||
*/
|
||||
unloadAsset(guid: AssetGUID): void {
|
||||
const entry = this._assets.get(guid);
|
||||
if (!entry) return;
|
||||
|
||||
// 检查引用计数 / Check reference count
|
||||
if (entry.referenceCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取加载器以释放资源 / Get loader to dispose resources
|
||||
const loader = this._loaderFactory.createLoader(entry.metadata.type);
|
||||
if (loader) {
|
||||
loader.dispose(entry.asset);
|
||||
}
|
||||
|
||||
// 清理条目 / Clean up entry
|
||||
this._handleToGuid.delete(entry.handle);
|
||||
this._assets.delete(guid);
|
||||
this._cache.remove(guid);
|
||||
|
||||
// 更新统计 / Update statistics
|
||||
this._statistics.loadedCount--;
|
||||
|
||||
entry.state = AssetState.Unloaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload all assets
|
||||
* 卸载所有资产
|
||||
*/
|
||||
unloadAllAssets(): void {
|
||||
const guids = Array.from(this._assets.keys());
|
||||
guids.forEach((guid) => this.unloadAsset(guid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload unused assets
|
||||
* 卸载未使用的资产
|
||||
*/
|
||||
unloadUnusedAssets(): void {
|
||||
const guids = Array.from(this._assets.keys());
|
||||
guids.forEach((guid) => {
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry && entry.referenceCount === 0) {
|
||||
this.unloadAsset(guid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add reference to asset
|
||||
* 增加资产引用
|
||||
*/
|
||||
addReference(guid: AssetGUID): void {
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry) {
|
||||
entry.referenceCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove reference from asset
|
||||
* 移除资产引用
|
||||
*/
|
||||
removeReference(guid: AssetGUID): void {
|
||||
const entry = this._assets.get(guid);
|
||||
if (entry && entry.referenceCount > 0) {
|
||||
entry.referenceCount--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reference info
|
||||
* 获取引用信息
|
||||
*/
|
||||
getReferenceInfo(guid: AssetGUID): IAssetReferenceInfo | null {
|
||||
const entry = this._assets.get(guid);
|
||||
if (!entry) return null;
|
||||
|
||||
return {
|
||||
guid,
|
||||
handle: entry.handle,
|
||||
referenceCount: entry.referenceCount,
|
||||
lastAccessTime: entry.lastAccessTime,
|
||||
state: entry.state
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Register custom loader
|
||||
* 注册自定义加载器
|
||||
*/
|
||||
registerLoader(type: AssetType, loader: IAssetLoader): void {
|
||||
this._loaderFactory.registerLoader(type, loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset statistics
|
||||
* 获取资产统计信息
|
||||
*/
|
||||
getStatistics(): { loadedCount: number; loadQueue: number; failedCount: number } {
|
||||
return {
|
||||
loadedCount: this._statistics.loadedCount,
|
||||
loadQueue: this._loadQueue.getSize(),
|
||||
failedCount: this._statistics.failedCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
* 清空缓存
|
||||
*/
|
||||
clearCache(): void {
|
||||
this._cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose manager
|
||||
* 释放管理器
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
|
||||
this.unloadAllAssets();
|
||||
this._cache.clear();
|
||||
this._loadQueue.clear();
|
||||
this._assets.clear();
|
||||
this._handleToGuid.clear();
|
||||
this._pathToGuid.clear();
|
||||
|
||||
this._isDisposed = true;
|
||||
}
|
||||
}
|
||||
243
packages/asset-system/src/core/AssetPathResolver.ts
Normal file
243
packages/asset-system/src/core/AssetPathResolver.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Asset path resolver for different platforms and protocols
|
||||
* 不同平台和协议的资产路径解析器
|
||||
*/
|
||||
|
||||
import { AssetPlatform } from '../types/AssetTypes';
|
||||
import { PathValidator } from '../utils/PathValidator';
|
||||
|
||||
/**
|
||||
* Asset path resolver configuration
|
||||
* 资产路径解析器配置
|
||||
*/
|
||||
export interface IAssetPathConfig {
|
||||
/** Base URL for web assets | Web资产的基础URL */
|
||||
baseUrl?: string;
|
||||
|
||||
/** Asset directory path | 资产目录路径 */
|
||||
assetDir?: string;
|
||||
|
||||
/** Asset host for asset:// protocol | 资产协议的主机名 */
|
||||
assetHost?: string;
|
||||
|
||||
/** Current platform | 当前平台 */
|
||||
platform?: AssetPlatform;
|
||||
|
||||
/** Custom path transformer | 自定义路径转换器 */
|
||||
pathTransformer?: (path: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset path resolver
|
||||
* 资产路径解析器
|
||||
*/
|
||||
export class AssetPathResolver {
|
||||
private config: IAssetPathConfig;
|
||||
|
||||
constructor(config: IAssetPathConfig = {}) {
|
||||
this.config = {
|
||||
baseUrl: '',
|
||||
assetDir: 'assets',
|
||||
platform: AssetPlatform.H5,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
* 更新配置
|
||||
*/
|
||||
updateConfig(config: Partial<IAssetPathConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve asset path to full URL
|
||||
* 解析资产路径为完整URL
|
||||
*/
|
||||
resolve(path: string): string {
|
||||
// Validate input path
|
||||
const validation = PathValidator.validate(path);
|
||||
if (!validation.valid) {
|
||||
console.warn(`Invalid asset path: ${path} - ${validation.reason}`);
|
||||
// Sanitize the path instead of throwing
|
||||
path = PathValidator.sanitize(path);
|
||||
if (!path) {
|
||||
throw new Error(`Cannot resolve invalid path: ${validation.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Already a full URL
|
||||
// 已经是完整URL
|
||||
if (this.isAbsoluteUrl(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Data URL
|
||||
// 数据URL
|
||||
if (path.startsWith('data:')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Normalize the path
|
||||
path = PathValidator.normalize(path);
|
||||
|
||||
// Apply custom transformer if provided
|
||||
// 应用自定义转换器(如果提供)
|
||||
if (this.config.pathTransformer) {
|
||||
path = this.config.pathTransformer(path);
|
||||
// Re-validate after transformation
|
||||
const postTransform = PathValidator.validate(path);
|
||||
if (!postTransform.valid) {
|
||||
throw new Error(`Path transformer produced invalid path: ${postTransform.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Platform-specific resolution
|
||||
// 平台特定解析
|
||||
switch (this.config.platform) {
|
||||
case AssetPlatform.H5:
|
||||
return this.resolveH5Path(path);
|
||||
|
||||
case AssetPlatform.WeChat:
|
||||
return this.resolveWeChatPath(path);
|
||||
|
||||
case AssetPlatform.Playable:
|
||||
return this.resolvePlayablePath(path);
|
||||
|
||||
case AssetPlatform.Android:
|
||||
case AssetPlatform.iOS:
|
||||
return this.resolveMobilePath(path);
|
||||
|
||||
case AssetPlatform.Editor:
|
||||
return this.resolveEditorPath(path);
|
||||
|
||||
default:
|
||||
return this.resolveH5Path(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path for H5 platform
|
||||
* 解析H5平台路径
|
||||
*/
|
||||
private resolveH5Path(path: string): string {
|
||||
// Remove leading slash if present
|
||||
// 移除开头的斜杠(如果存在)
|
||||
path = path.replace(/^\//, '');
|
||||
|
||||
// Combine with base URL and asset directory
|
||||
// 与基础URL和资产目录结合
|
||||
const base = this.config.baseUrl || (typeof window !== 'undefined' ? window.location.origin : '');
|
||||
const assetDir = this.config.assetDir || 'assets';
|
||||
|
||||
return `${base}/${assetDir}/${path}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path for WeChat Mini Game
|
||||
* 解析微信小游戏路径
|
||||
*/
|
||||
private resolveWeChatPath(path: string): string {
|
||||
// WeChat mini games use relative paths
|
||||
// 微信小游戏使用相对路径
|
||||
return `${this.config.assetDir}/${path}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path for Playable Ads platform
|
||||
* 解析试玩广告平台路径
|
||||
*/
|
||||
private resolvePlayablePath(path: string): string {
|
||||
// Playable ads typically use base64 embedded resources or relative paths
|
||||
// 试玩广告通常使用base64内嵌资源或相对路径
|
||||
|
||||
// If custom transformer is provided (e.g., for base64 encoding)
|
||||
// 如果提供了自定义转换器(例如用于base64编码)
|
||||
if (this.config.pathTransformer) {
|
||||
return this.config.pathTransformer(path);
|
||||
}
|
||||
|
||||
// Default to relative path without directory prefix
|
||||
// 默认使用不带目录前缀的相对路径
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path for mobile platform (Android/iOS)
|
||||
* 解析移动平台路径(Android/iOS)
|
||||
*/
|
||||
private resolveMobilePath(path: string): string {
|
||||
// Mobile platforms use relative paths or file:// protocol
|
||||
// 移动平台使用相对路径或file://协议
|
||||
return `./${this.config.assetDir}/${path}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path for Editor platform (Tauri)
|
||||
* 解析编辑器平台路径(Tauri)
|
||||
*/
|
||||
private resolveEditorPath(path: string): string {
|
||||
// For Tauri editor, use pathTransformer if provided
|
||||
// 对于Tauri编辑器,使用pathTransformer(如果提供)
|
||||
if (this.config.pathTransformer) {
|
||||
return this.config.pathTransformer(path);
|
||||
}
|
||||
|
||||
// Use configurable asset host or default to 'localhost'
|
||||
// 使用可配置的资产主机或默认为 'localhost'
|
||||
const host = this.config.assetHost || 'localhost';
|
||||
const sanitizedPath = PathValidator.sanitize(path);
|
||||
return `asset://${host}/${sanitizedPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is absolute URL
|
||||
* 检查路径是否为绝对URL
|
||||
*/
|
||||
private isAbsoluteUrl(path: string): boolean {
|
||||
return /^(https?:\/\/|file:\/\/|asset:\/\/)/.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset directory from path
|
||||
* 从路径获取资产目录
|
||||
*/
|
||||
getAssetDirectory(path: string): string {
|
||||
const resolved = this.resolve(path);
|
||||
const lastSlash = resolved.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? resolved.substring(0, lastSlash) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset filename from path
|
||||
* 从路径获取资产文件名
|
||||
*/
|
||||
getAssetFilename(path: string): string {
|
||||
const resolved = this.resolve(path);
|
||||
const lastSlash = resolved.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? resolved.substring(lastSlash + 1) : resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join paths
|
||||
* 连接路径
|
||||
*/
|
||||
join(...paths: string[]): string {
|
||||
return paths.join('/').replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path
|
||||
* 规范化路径
|
||||
*/
|
||||
normalize(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/\/+/g, '/');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global asset path resolver instance
|
||||
* 全局资产路径解析器实例
|
||||
*/
|
||||
export const globalPathResolver = new AssetPathResolver();
|
||||
338
packages/asset-system/src/core/AssetReference.ts
Normal file
338
packages/asset-system/src/core/AssetReference.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Asset reference for lazy loading
|
||||
* 用于懒加载的资产引用
|
||||
*/
|
||||
|
||||
import { AssetGUID, IAssetLoadOptions, AssetState } from '../types/AssetTypes';
|
||||
import { IAssetManager } from '../interfaces/IAssetManager';
|
||||
|
||||
/**
|
||||
* Asset reference class for lazy loading
|
||||
* 懒加载资产引用类
|
||||
*/
|
||||
export class AssetReference<T = unknown> {
|
||||
private _guid: AssetGUID;
|
||||
private _asset?: T;
|
||||
private _loadPromise?: Promise<T>;
|
||||
private _manager?: IAssetManager;
|
||||
private _isReleased = false;
|
||||
private _autoRelease = false;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
* 构造函数
|
||||
*/
|
||||
constructor(guid: AssetGUID, manager?: IAssetManager) {
|
||||
this._guid = guid;
|
||||
this._manager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset GUID
|
||||
* 获取资产GUID
|
||||
*/
|
||||
get guid(): AssetGUID {
|
||||
return this._guid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset is loaded
|
||||
* 检查资产是否已加载
|
||||
*/
|
||||
get isLoaded(): boolean {
|
||||
return this._asset !== undefined && !this._isReleased;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset synchronously (returns null if not loaded)
|
||||
* 同步获取资产(如果未加载则返回null)
|
||||
*/
|
||||
get asset(): T | null {
|
||||
return this._asset ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set asset manager
|
||||
* 设置资产管理器
|
||||
*/
|
||||
setManager(manager: IAssetManager): void {
|
||||
this._manager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load asset asynchronously
|
||||
* 异步加载资产
|
||||
*/
|
||||
async loadAsync(options?: IAssetLoadOptions): Promise<T> {
|
||||
if (this._isReleased) {
|
||||
throw new Error(`Asset reference ${this._guid} has been released`);
|
||||
}
|
||||
|
||||
// 如果已经加载,直接返回 / Return if already loaded
|
||||
if (this._asset !== undefined) {
|
||||
return this._asset;
|
||||
}
|
||||
|
||||
// 如果正在加载,返回加载Promise / Return loading promise if loading
|
||||
if (this._loadPromise) {
|
||||
return this._loadPromise;
|
||||
}
|
||||
|
||||
if (!this._manager) {
|
||||
throw new Error('Asset manager not set for AssetReference');
|
||||
}
|
||||
|
||||
// 开始加载 / Start loading
|
||||
this._loadPromise = this.performLoad(options);
|
||||
|
||||
try {
|
||||
const asset = await this._loadPromise;
|
||||
return asset;
|
||||
} finally {
|
||||
this._loadPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform asset loading
|
||||
* 执行资产加载
|
||||
*/
|
||||
private async performLoad(options?: IAssetLoadOptions): Promise<T> {
|
||||
if (!this._manager) {
|
||||
throw new Error('Asset manager not set');
|
||||
}
|
||||
|
||||
const result = await this._manager.loadAsset<T>(this._guid, options);
|
||||
this._asset = result.asset;
|
||||
|
||||
// 增加引用计数 / Increase reference count
|
||||
this._manager.addReference(this._guid);
|
||||
|
||||
return this._asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release asset reference
|
||||
* 释放资产引用
|
||||
*/
|
||||
release(): void {
|
||||
if (this._isReleased) return;
|
||||
|
||||
if (this._manager && this._asset !== undefined) {
|
||||
// 减少引用计数 / Decrease reference count
|
||||
this._manager.removeReference(this._guid);
|
||||
|
||||
// 如果引用计数为0,可以考虑卸载 / Consider unloading if reference count is 0
|
||||
const refInfo = this._manager.getReferenceInfo(this._guid);
|
||||
if (refInfo && refInfo.referenceCount === 0 && this._autoRelease) {
|
||||
this._manager.unloadAsset(this._guid);
|
||||
}
|
||||
}
|
||||
|
||||
this._asset = undefined;
|
||||
this._isReleased = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auto-release mode
|
||||
* 设置自动释放模式
|
||||
*/
|
||||
setAutoRelease(autoRelease: boolean): void {
|
||||
this._autoRelease = autoRelease;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate reference
|
||||
* 验证引用
|
||||
*/
|
||||
validate(): boolean {
|
||||
if (!this._manager) return false;
|
||||
|
||||
const state = this._manager.getAssetState(this._guid);
|
||||
return state !== AssetState.Failed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset state
|
||||
* 获取资产状态
|
||||
*/
|
||||
getState(): AssetState {
|
||||
if (this._isReleased) return AssetState.Unloaded;
|
||||
if (!this._manager) return AssetState.Unloaded;
|
||||
|
||||
return this._manager.getAssetState(this._guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone reference
|
||||
* 克隆引用
|
||||
*/
|
||||
clone(): AssetReference<T> {
|
||||
const newRef = new AssetReference<T>(this._guid, this._manager);
|
||||
newRef.setAutoRelease(this._autoRelease);
|
||||
return newRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to JSON
|
||||
* 转换为JSON
|
||||
*/
|
||||
toJSON(): { guid: AssetGUID } {
|
||||
return { guid: this._guid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from JSON
|
||||
* 从JSON创建
|
||||
*/
|
||||
static fromJSON<T = unknown>(
|
||||
json: { guid: AssetGUID },
|
||||
manager?: IAssetManager
|
||||
): AssetReference<T> {
|
||||
return new AssetReference<T>(json.guid, manager);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Weak asset reference that doesn't prevent unloading
|
||||
* 不阻止卸载的弱资产引用
|
||||
*/
|
||||
export class WeakAssetReference<T = unknown> {
|
||||
private _guid: AssetGUID;
|
||||
private _manager?: IAssetManager;
|
||||
|
||||
constructor(guid: AssetGUID, manager?: IAssetManager) {
|
||||
this._guid = guid;
|
||||
this._manager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset GUID
|
||||
* 获取资产GUID
|
||||
*/
|
||||
get guid(): AssetGUID {
|
||||
return this._guid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try get asset without loading
|
||||
* 尝试获取资产而不加载
|
||||
*/
|
||||
tryGet(): T | null {
|
||||
if (!this._manager) return null;
|
||||
return this._manager.getAsset<T>(this._guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load asset if not loaded
|
||||
* 如果未加载则加载资产
|
||||
*/
|
||||
async loadAsync(options?: IAssetLoadOptions): Promise<T> {
|
||||
if (!this._manager) {
|
||||
throw new Error('Asset manager not set');
|
||||
}
|
||||
|
||||
const result = await this._manager.loadAsset<T>(this._guid, options);
|
||||
// 不增加引用计数 / Don't increase reference count for weak reference
|
||||
return result.asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset is loaded
|
||||
* 检查资产是否已加载
|
||||
*/
|
||||
isLoaded(): boolean {
|
||||
if (!this._manager) return false;
|
||||
return this._manager.isLoaded(this._guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set asset manager
|
||||
* 设置资产管理器
|
||||
*/
|
||||
setManager(manager: IAssetManager): void {
|
||||
this._manager = manager;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset reference array for managing multiple references
|
||||
* 用于管理多个引用的资产引用数组
|
||||
*/
|
||||
export class AssetReferenceArray<T = unknown> {
|
||||
private _references: AssetReference<T>[] = [];
|
||||
private _manager?: IAssetManager;
|
||||
|
||||
constructor(guids: AssetGUID[] = [], manager?: IAssetManager) {
|
||||
this._manager = manager;
|
||||
this._references = guids.map((guid) => new AssetReference<T>(guid, manager));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add reference
|
||||
* 添加引用
|
||||
*/
|
||||
add(guid: AssetGUID): void {
|
||||
this._references.push(new AssetReference<T>(guid, this._manager));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove reference
|
||||
* 移除引用
|
||||
*/
|
||||
remove(guid: AssetGUID): boolean {
|
||||
const index = this._references.findIndex((ref) => ref.guid === guid);
|
||||
if (index >= 0) {
|
||||
this._references[index].release();
|
||||
this._references.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all assets
|
||||
* 加载所有资产
|
||||
*/
|
||||
async loadAllAsync(options?: IAssetLoadOptions): Promise<T[]> {
|
||||
const promises = this._references.map((ref) => ref.loadAsync(options));
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all references
|
||||
* 释放所有引用
|
||||
*/
|
||||
releaseAll(): void {
|
||||
this._references.forEach((ref) => ref.release());
|
||||
this._references = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded assets
|
||||
* 获取所有已加载的资产
|
||||
*/
|
||||
getLoadedAssets(): T[] {
|
||||
return this._references
|
||||
.filter((ref) => ref.isLoaded)
|
||||
.map((ref) => ref.asset!)
|
||||
.filter((asset) => asset !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reference count
|
||||
* 获取引用数量
|
||||
*/
|
||||
get count(): number {
|
||||
return this._references.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set asset manager
|
||||
* 设置资产管理器
|
||||
*/
|
||||
setManager(manager: IAssetManager): void {
|
||||
this._manager = manager;
|
||||
this._references.forEach((ref) => ref.setManager(manager));
|
||||
}
|
||||
}
|
||||
51
packages/asset-system/src/index.ts
Normal file
51
packages/asset-system/src/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Asset System for ECS Framework
|
||||
* ECS框架的资产系统
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types/AssetTypes';
|
||||
|
||||
// Interfaces
|
||||
export * from './interfaces/IAssetLoader';
|
||||
export * from './interfaces/IAssetManager';
|
||||
|
||||
// Core
|
||||
export { AssetManager } from './core/AssetManager';
|
||||
export { AssetCache } from './core/AssetCache';
|
||||
export { AssetDatabase } from './core/AssetDatabase';
|
||||
export { AssetLoadQueue } from './core/AssetLoadQueue';
|
||||
export { AssetReference, WeakAssetReference, AssetReferenceArray } from './core/AssetReference';
|
||||
export { AssetPathResolver, globalPathResolver } from './core/AssetPathResolver';
|
||||
export type { IAssetPathConfig } from './core/AssetPathResolver';
|
||||
|
||||
// Loaders
|
||||
export { AssetLoaderFactory } from './loaders/AssetLoaderFactory';
|
||||
export { TextureLoader } from './loaders/TextureLoader';
|
||||
export { JsonLoader } from './loaders/JsonLoader';
|
||||
export { TextLoader } from './loaders/TextLoader';
|
||||
export { BinaryLoader } from './loaders/BinaryLoader';
|
||||
|
||||
// Integration
|
||||
export { EngineIntegration } from './integration/EngineIntegration';
|
||||
export type { IEngineBridge } from './integration/EngineIntegration';
|
||||
|
||||
// Default instance
|
||||
import { AssetManager } from './core/AssetManager';
|
||||
|
||||
/**
|
||||
* Default asset manager instance
|
||||
* 默认资产管理器实例
|
||||
*/
|
||||
export const assetManager = new AssetManager();
|
||||
|
||||
/**
|
||||
* Initialize asset system with catalog
|
||||
* 使用目录初始化资产系统
|
||||
*/
|
||||
export function initializeAssetSystem(catalog?: any): AssetManager {
|
||||
if (catalog) {
|
||||
return new AssetManager(catalog);
|
||||
}
|
||||
return assetManager;
|
||||
}
|
||||
217
packages/asset-system/src/integration/EngineIntegration.ts
Normal file
217
packages/asset-system/src/integration/EngineIntegration.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Engine integration for asset system
|
||||
* 资产系统的引擎集成
|
||||
*/
|
||||
|
||||
import { AssetManager } from '../core/AssetManager';
|
||||
import { AssetGUID } from '../types/AssetTypes';
|
||||
import { ITextureAsset } from '../interfaces/IAssetLoader';
|
||||
|
||||
/**
|
||||
* Engine bridge interface
|
||||
* 引擎桥接接口
|
||||
*/
|
||||
export interface IEngineBridge {
|
||||
/**
|
||||
* Load texture to GPU
|
||||
* 加载纹理到GPU
|
||||
*/
|
||||
loadTexture(id: number, url: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Load multiple textures
|
||||
* 批量加载纹理
|
||||
*/
|
||||
loadTextures(requests: Array<{ id: number; url: string }>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Unload texture from GPU
|
||||
* 从GPU卸载纹理
|
||||
*/
|
||||
unloadTexture(id: number): void;
|
||||
|
||||
/**
|
||||
* Get texture info
|
||||
* 获取纹理信息
|
||||
*/
|
||||
getTextureInfo(id: number): { width: number; height: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset system engine integration
|
||||
* 资产系统引擎集成
|
||||
*/
|
||||
export class EngineIntegration {
|
||||
private _assetManager: AssetManager;
|
||||
private _engineBridge?: IEngineBridge;
|
||||
private _textureIdMap = new Map<AssetGUID, number>();
|
||||
private _pathToTextureId = new Map<string, number>();
|
||||
|
||||
constructor(assetManager: AssetManager, engineBridge?: IEngineBridge) {
|
||||
this._assetManager = assetManager;
|
||||
this._engineBridge = engineBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set engine bridge
|
||||
* 设置引擎桥接
|
||||
*/
|
||||
setEngineBridge(bridge: IEngineBridge): void {
|
||||
this._engineBridge = bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load texture for component
|
||||
* 为组件加载纹理
|
||||
*/
|
||||
async loadTextureForComponent(texturePath: string): Promise<number> {
|
||||
// 检查是否已有纹理ID / Check if texture ID exists
|
||||
const existingId = this._pathToTextureId.get(texturePath);
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
// 通过资产系统加载 / Load through asset system
|
||||
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(texturePath);
|
||||
const textureAsset = result.asset;
|
||||
|
||||
// 如果有引擎桥接,上传到GPU / Upload to GPU if bridge exists
|
||||
if (this._engineBridge && textureAsset.data) {
|
||||
await this._engineBridge.loadTexture(textureAsset.textureId, texturePath);
|
||||
}
|
||||
|
||||
// 缓存映射 / Cache mapping
|
||||
this._pathToTextureId.set(texturePath, textureAsset.textureId);
|
||||
|
||||
return textureAsset.textureId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load texture by GUID
|
||||
* 通过GUID加载纹理
|
||||
*/
|
||||
async loadTextureByGuid(guid: AssetGUID): Promise<number> {
|
||||
// 检查是否已有纹理ID / Check if texture ID exists
|
||||
const existingId = this._textureIdMap.get(guid);
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
// 通过资产系统加载 / Load through asset system
|
||||
const result = await this._assetManager.loadAsset<ITextureAsset>(guid);
|
||||
const textureAsset = result.asset;
|
||||
|
||||
// 如果有引擎桥接,上传到GPU / Upload to GPU if bridge exists
|
||||
if (this._engineBridge && textureAsset.data) {
|
||||
const metadata = result.metadata;
|
||||
await this._engineBridge.loadTexture(textureAsset.textureId, metadata.path);
|
||||
}
|
||||
|
||||
// 缓存映射 / Cache mapping
|
||||
this._textureIdMap.set(guid, textureAsset.textureId);
|
||||
|
||||
return textureAsset.textureId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch load textures
|
||||
* 批量加载纹理
|
||||
*/
|
||||
async loadTexturesBatch(paths: string[]): Promise<Map<string, number>> {
|
||||
const results = new Map<string, number>();
|
||||
|
||||
// 收集需要加载的纹理 / Collect textures to load
|
||||
const toLoad: string[] = [];
|
||||
for (const path of paths) {
|
||||
const existingId = this._pathToTextureId.get(path);
|
||||
if (existingId) {
|
||||
results.set(path, existingId);
|
||||
} else {
|
||||
toLoad.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (toLoad.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// 并行加载所有纹理 / Load all textures in parallel
|
||||
const loadPromises = toLoad.map(async (path) => {
|
||||
try {
|
||||
const id = await this.loadTextureForComponent(path);
|
||||
results.set(path, id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load texture: ${path}`, error);
|
||||
results.set(path, 0); // 使用默认纹理ID / Use default texture ID
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(loadPromises);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload texture
|
||||
* 卸载纹理
|
||||
*/
|
||||
unloadTexture(textureId: number): void {
|
||||
// 从引擎卸载 / Unload from engine
|
||||
if (this._engineBridge) {
|
||||
this._engineBridge.unloadTexture(textureId);
|
||||
}
|
||||
|
||||
// 清理映射 / Clean up mappings
|
||||
for (const [path, id] of this._pathToTextureId.entries()) {
|
||||
if (id === textureId) {
|
||||
this._pathToTextureId.delete(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [guid, id] of this._textureIdMap.entries()) {
|
||||
if (id === textureId) {
|
||||
this._textureIdMap.delete(guid);
|
||||
// 也从资产管理器卸载 / Also unload from asset manager
|
||||
this._assetManager.unloadAsset(guid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get texture ID for path
|
||||
* 获取路径的纹理ID
|
||||
*/
|
||||
getTextureId(path: string): number | null {
|
||||
return this._pathToTextureId.get(path) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload textures for scene
|
||||
* 为场景预加载纹理
|
||||
*/
|
||||
async preloadSceneTextures(texturePaths: string[]): Promise<void> {
|
||||
await this.loadTexturesBatch(texturePaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all texture mappings
|
||||
* 清空所有纹理映射
|
||||
*/
|
||||
clearTextureMappings(): void {
|
||||
this._textureIdMap.clear();
|
||||
this._pathToTextureId.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
* 获取统计信息
|
||||
*/
|
||||
getStatistics(): {
|
||||
loadedTextures: number;
|
||||
} {
|
||||
return {
|
||||
loadedTextures: this._pathToTextureId.size
|
||||
};
|
||||
}
|
||||
}
|
||||
222
packages/asset-system/src/interfaces/IAssetLoader.ts
Normal file
222
packages/asset-system/src/interfaces/IAssetLoader.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Asset loader interfaces
|
||||
* 资产加载器接口
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetType,
|
||||
AssetGUID,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult
|
||||
} from '../types/AssetTypes';
|
||||
|
||||
/**
|
||||
* Base asset loader interface
|
||||
* 基础资产加载器接口
|
||||
*/
|
||||
export interface IAssetLoader<T = unknown> {
|
||||
/** 支持的资产类型 / Supported asset type */
|
||||
readonly supportedType: AssetType;
|
||||
|
||||
/** 支持的文件扩展名 / Supported file extensions */
|
||||
readonly supportedExtensions: string[];
|
||||
|
||||
/**
|
||||
* Load an asset from the given path
|
||||
* 从指定路径加载资产
|
||||
*/
|
||||
load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<T>>;
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
*/
|
||||
canLoad(path: string, metadata: IAssetMetadata): boolean;
|
||||
|
||||
/**
|
||||
* Dispose loaded asset and free resources
|
||||
* 释放已加载的资产并释放资源
|
||||
*/
|
||||
dispose(asset: T): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loader factory interface
|
||||
* 资产加载器工厂接口
|
||||
*/
|
||||
export interface IAssetLoaderFactory {
|
||||
/**
|
||||
* Create loader for specific asset type
|
||||
* 为特定资产类型创建加载器
|
||||
*/
|
||||
createLoader(type: AssetType): IAssetLoader | null;
|
||||
|
||||
/**
|
||||
* Register custom loader
|
||||
* 注册自定义加载器
|
||||
*/
|
||||
registerLoader(type: AssetType, loader: IAssetLoader): void;
|
||||
|
||||
/**
|
||||
* Unregister loader
|
||||
* 注销加载器
|
||||
*/
|
||||
unregisterLoader(type: AssetType): void;
|
||||
|
||||
/**
|
||||
* Check if loader exists for type
|
||||
* 检查类型是否有加载器
|
||||
*/
|
||||
hasLoader(type: AssetType): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Texture asset interface
|
||||
* 纹理资产接口
|
||||
*/
|
||||
export interface ITextureAsset {
|
||||
/** WebGL纹理ID / WebGL texture ID */
|
||||
textureId: number;
|
||||
/** 宽度 / Width */
|
||||
width: number;
|
||||
/** 高度 / Height */
|
||||
height: number;
|
||||
/** 格式 / Format */
|
||||
format: 'rgba' | 'rgb' | 'alpha';
|
||||
/** 是否有Mipmap / Has mipmaps */
|
||||
hasMipmaps: boolean;
|
||||
/** 原始数据(如果可用) / Raw image data if available */
|
||||
data?: ImageData | HTMLImageElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mesh asset interface
|
||||
* 网格资产接口
|
||||
*/
|
||||
export interface IMeshAsset {
|
||||
/** 顶点数据 / Vertex data */
|
||||
vertices: Float32Array;
|
||||
/** 索引数据 / Index data */
|
||||
indices: Uint16Array | Uint32Array;
|
||||
/** 法线数据 / Normal data */
|
||||
normals?: Float32Array;
|
||||
/** UV坐标 / UV coordinates */
|
||||
uvs?: Float32Array;
|
||||
/** 切线数据 / Tangent data */
|
||||
tangents?: Float32Array;
|
||||
/** 边界盒 / Axis-aligned bounding box */
|
||||
bounds: {
|
||||
min: [number, number, number];
|
||||
max: [number, number, number];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio asset interface
|
||||
* 音频资产接口
|
||||
*/
|
||||
export interface IAudioAsset {
|
||||
/** 音频缓冲区 / Audio buffer */
|
||||
buffer: AudioBuffer;
|
||||
/** 时长(秒) / Duration in seconds */
|
||||
duration: number;
|
||||
/** 采样率 / Sample rate */
|
||||
sampleRate: number;
|
||||
/** 声道数 / Number of channels */
|
||||
channels: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Material asset interface
|
||||
* 材质资产接口
|
||||
*/
|
||||
export interface IMaterialAsset {
|
||||
/** 着色器名称 / Shader name */
|
||||
shader: string;
|
||||
/** 材质属性 / Material properties */
|
||||
properties: Map<string, unknown>;
|
||||
/** 纹理映射 / Texture slot mappings */
|
||||
textures: Map<string, AssetGUID>;
|
||||
/** 渲染状态 / Render states */
|
||||
renderStates: {
|
||||
cullMode?: 'none' | 'front' | 'back';
|
||||
blendMode?: 'none' | 'alpha' | 'additive' | 'multiply';
|
||||
depthTest?: boolean;
|
||||
depthWrite?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefab asset interface
|
||||
* 预制体资产接口
|
||||
*/
|
||||
export interface IPrefabAsset {
|
||||
/** 根实体数据 / Serialized entity hierarchy */
|
||||
root: unknown;
|
||||
/** 包含的组件类型 / Component types used in prefab */
|
||||
componentTypes: string[];
|
||||
/** 引用的资产 / All referenced assets */
|
||||
referencedAssets: AssetGUID[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene asset interface
|
||||
* 场景资产接口
|
||||
*/
|
||||
export interface ISceneAsset {
|
||||
/** 场景名称 / Scene name */
|
||||
name: string;
|
||||
/** 实体列表 / Serialized entity list */
|
||||
entities: unknown[];
|
||||
/** 场景设置 / Scene settings */
|
||||
settings: {
|
||||
/** 环境光 / Ambient light */
|
||||
ambientLight?: [number, number, number];
|
||||
/** 雾效 / Fog settings */
|
||||
fog?: {
|
||||
enabled: boolean;
|
||||
color: [number, number, number];
|
||||
density: number;
|
||||
};
|
||||
/** 天空盒 / Skybox asset */
|
||||
skybox?: AssetGUID;
|
||||
};
|
||||
/** 引用的资产 / All referenced assets */
|
||||
referencedAssets: AssetGUID[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON asset interface
|
||||
* JSON资产接口
|
||||
*/
|
||||
export interface IJsonAsset {
|
||||
/** JSON数据 / JSON data */
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text asset interface
|
||||
* 文本资产接口
|
||||
*/
|
||||
export interface ITextAsset {
|
||||
/** 文本内容 / Text content */
|
||||
content: string;
|
||||
/** 编码格式 / Encoding */
|
||||
encoding: 'utf8' | 'utf16' | 'ascii';
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary asset interface
|
||||
* 二进制资产接口
|
||||
*/
|
||||
export interface IBinaryAsset {
|
||||
/** 二进制数据 / Binary data */
|
||||
data: ArrayBuffer;
|
||||
/** MIME类型 / MIME type */
|
||||
mimeType?: string;
|
||||
}
|
||||
328
packages/asset-system/src/interfaces/IAssetManager.ts
Normal file
328
packages/asset-system/src/interfaces/IAssetManager.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
90
packages/asset-system/src/loaders/AssetLoaderFactory.ts
Normal file
90
packages/asset-system/src/loaders/AssetLoaderFactory.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Asset loader factory implementation
|
||||
* 资产加载器工厂实现
|
||||
*/
|
||||
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import { IAssetLoader, IAssetLoaderFactory } from '../interfaces/IAssetLoader';
|
||||
import { TextureLoader } from './TextureLoader';
|
||||
import { JsonLoader } from './JsonLoader';
|
||||
import { TextLoader } from './TextLoader';
|
||||
import { BinaryLoader } from './BinaryLoader';
|
||||
|
||||
/**
|
||||
* Asset loader factory
|
||||
* 资产加载器工厂
|
||||
*/
|
||||
export class AssetLoaderFactory implements IAssetLoaderFactory {
|
||||
private readonly _loaders = new Map<AssetType, IAssetLoader>();
|
||||
|
||||
constructor() {
|
||||
// 注册默认加载器 / Register default loaders
|
||||
this.registerDefaultLoaders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register default loaders
|
||||
* 注册默认加载器
|
||||
*/
|
||||
private registerDefaultLoaders(): void {
|
||||
// 纹理加载器 / Texture loader
|
||||
this._loaders.set(AssetType.Texture, new TextureLoader());
|
||||
|
||||
// JSON加载器 / JSON loader
|
||||
this._loaders.set(AssetType.Json, new JsonLoader());
|
||||
|
||||
// 文本加载器 / Text loader
|
||||
this._loaders.set(AssetType.Text, new TextLoader());
|
||||
|
||||
// 二进制加载器 / Binary loader
|
||||
this._loaders.set(AssetType.Binary, new BinaryLoader());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create loader for specific asset type
|
||||
* 为特定资产类型创建加载器
|
||||
*/
|
||||
createLoader(type: AssetType): IAssetLoader | null {
|
||||
return this._loaders.get(type) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register custom loader
|
||||
* 注册自定义加载器
|
||||
*/
|
||||
registerLoader(type: AssetType, loader: IAssetLoader): void {
|
||||
this._loaders.set(type, loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister loader
|
||||
* 注销加载器
|
||||
*/
|
||||
unregisterLoader(type: AssetType): void {
|
||||
this._loaders.delete(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if loader exists for type
|
||||
* 检查类型是否有加载器
|
||||
*/
|
||||
hasLoader(type: AssetType): boolean {
|
||||
return this._loaders.has(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered loaders
|
||||
* 获取所有注册的加载器
|
||||
*/
|
||||
getRegisteredTypes(): AssetType[] {
|
||||
return Array.from(this._loaders.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all loaders
|
||||
* 清空所有加载器
|
||||
*/
|
||||
clear(): void {
|
||||
this._loaders.clear();
|
||||
}
|
||||
}
|
||||
165
packages/asset-system/src/loaders/BinaryLoader.ts
Normal file
165
packages/asset-system/src/loaders/BinaryLoader.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
162
packages/asset-system/src/loaders/JsonLoader.ts
Normal file
162
packages/asset-system/src/loaders/JsonLoader.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
172
packages/asset-system/src/loaders/TextLoader.ts
Normal file
172
packages/asset-system/src/loaders/TextLoader.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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 = '';
|
||||
}
|
||||
}
|
||||
216
packages/asset-system/src/loaders/TextureLoader.ts
Normal file
216
packages/asset-system/src/loaders/TextureLoader.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
404
packages/asset-system/src/types/AssetTypes.ts
Normal file
404
packages/asset-system/src/types/AssetTypes.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Core asset system types and enums
|
||||
* 核心资产系统类型和枚举
|
||||
*/
|
||||
|
||||
/**
|
||||
* Unique identifier for assets across the project
|
||||
* 项目中资产的唯一标识符
|
||||
*/
|
||||
export type AssetGUID = string;
|
||||
|
||||
/**
|
||||
* Runtime asset handle for efficient access
|
||||
* 运行时资产句柄,用于高效访问
|
||||
*/
|
||||
export type AssetHandle = number;
|
||||
|
||||
/**
|
||||
* Asset loading state
|
||||
* 资产加载状态
|
||||
*/
|
||||
export enum AssetState {
|
||||
/** 未加载 */
|
||||
Unloaded = 'unloaded',
|
||||
/** 加载中 */
|
||||
Loading = 'loading',
|
||||
/** 已加载 */
|
||||
Loaded = 'loaded',
|
||||
/** 加载失败 */
|
||||
Failed = 'failed',
|
||||
/** 释放中 */
|
||||
Disposing = 'disposing'
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset types supported by the system
|
||||
* 系统支持的资产类型
|
||||
*/
|
||||
export enum AssetType {
|
||||
/** 纹理 */
|
||||
Texture = 'texture',
|
||||
/** 网格 */
|
||||
Mesh = 'mesh',
|
||||
/** 材质 */
|
||||
Material = 'material',
|
||||
/** 着色器 */
|
||||
Shader = 'shader',
|
||||
/** 音频 */
|
||||
Audio = 'audio',
|
||||
/** 字体 */
|
||||
Font = 'font',
|
||||
/** 预制体 */
|
||||
Prefab = 'prefab',
|
||||
/** 场景 */
|
||||
Scene = 'scene',
|
||||
/** 脚本 */
|
||||
Script = 'script',
|
||||
/** 动画片段 */
|
||||
AnimationClip = 'animation',
|
||||
/** 行为树 */
|
||||
BehaviorTree = 'behaviortree',
|
||||
/** JSON数据 */
|
||||
Json = 'json',
|
||||
/** 文本 */
|
||||
Text = 'text',
|
||||
/** 二进制 */
|
||||
Binary = 'binary',
|
||||
/** 自定义 */
|
||||
Custom = 'custom'
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform variants for assets
|
||||
* 资产的平台变体
|
||||
*/
|
||||
export enum AssetPlatform {
|
||||
/** H5平台(浏览器) */
|
||||
H5 = 'h5',
|
||||
/** 微信小游戏 */
|
||||
WeChat = 'wechat',
|
||||
/** 试玩广告(Playable Ads) */
|
||||
Playable = 'playable',
|
||||
/** Android平台 */
|
||||
Android = 'android',
|
||||
/** iOS平台 */
|
||||
iOS = 'ios',
|
||||
/** 编辑器(Tauri桌面) */
|
||||
Editor = 'editor'
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality levels for asset variants
|
||||
* 资产变体的质量级别
|
||||
*/
|
||||
export enum AssetQuality {
|
||||
/** 低质量 */
|
||||
Low = 'low',
|
||||
/** 中等质量 */
|
||||
Medium = 'medium',
|
||||
/** 高质量 */
|
||||
High = 'high',
|
||||
/** 超高质量 */
|
||||
Ultra = 'ultra'
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset metadata stored in the database
|
||||
* 存储在数据库中的资产元数据
|
||||
*/
|
||||
export interface IAssetMetadata {
|
||||
/** 全局唯一标识符 */
|
||||
guid: AssetGUID;
|
||||
/** 资产路径 */
|
||||
path: string;
|
||||
/** 资产类型 */
|
||||
type: AssetType;
|
||||
/** 资产名称 */
|
||||
name: string;
|
||||
/** 文件大小(字节) / File size in bytes */
|
||||
size: number;
|
||||
/** 内容哈希值 / Content hash for versioning */
|
||||
hash: string;
|
||||
/** 依赖的其他资产 / Dependencies on other assets */
|
||||
dependencies: AssetGUID[];
|
||||
/** 资产标签 / User-defined labels for categorization */
|
||||
labels: string[];
|
||||
/** 自定义标签 / Custom metadata tags */
|
||||
tags: Map<string, string>;
|
||||
/** 导入设置 / Import-time settings */
|
||||
importSettings?: Record<string, unknown>;
|
||||
/** 最后修改时间 / Unix timestamp of last modification */
|
||||
lastModified: number;
|
||||
/** 版本号 / Asset version number */
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset variant descriptor
|
||||
* 资产变体描述符
|
||||
*/
|
||||
export interface IAssetVariant {
|
||||
/** 目标平台 */
|
||||
platform: AssetPlatform;
|
||||
/** 质量级别 */
|
||||
quality: AssetQuality;
|
||||
/** 本地化语言 / Language code for localized assets */
|
||||
locale?: string;
|
||||
/** 主题变体 / Theme identifier (e.g., 'dark', 'light') */
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset load options
|
||||
* 资产加载选项
|
||||
*/
|
||||
export interface IAssetLoadOptions {
|
||||
/** 加载优先级(0-100,越高越优先) / Priority level 0-100, higher loads first */
|
||||
priority?: number;
|
||||
/** 是否异步加载 / Use async loading */
|
||||
async?: boolean;
|
||||
/** 指定加载的变体 / Specific variant to load */
|
||||
variant?: IAssetVariant;
|
||||
/** 强制重新加载 / Force reload even if cached */
|
||||
forceReload?: boolean;
|
||||
/** 超时时间(毫秒) / Timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** 进度回调 / Progress callback (0-1) */
|
||||
onProgress?: (progress: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset bundle manifest
|
||||
* 资产包清单
|
||||
*/
|
||||
export interface IAssetBundleManifest {
|
||||
/** 包名称 */
|
||||
name: string;
|
||||
/** 版本号 */
|
||||
version: string;
|
||||
/** 内容哈希 / Content hash for integrity check */
|
||||
hash: string;
|
||||
/** 压缩类型 */
|
||||
compression?: 'none' | 'gzip' | 'brotli';
|
||||
/** 包含的资产列表 / Assets contained in this bundle */
|
||||
assets: AssetGUID[];
|
||||
/** 依赖的其他包 / Other bundles this depends on */
|
||||
dependencies: string[];
|
||||
/** 包大小(字节) / Bundle size in bytes */
|
||||
size: number;
|
||||
/** 创建时间戳 / Creation timestamp */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loading result
|
||||
* 资产加载结果
|
||||
*/
|
||||
export interface IAssetLoadResult<T = unknown> {
|
||||
/** 加载的资产实例 */
|
||||
asset: T;
|
||||
/** 资产句柄 */
|
||||
handle: AssetHandle;
|
||||
/** 资产元数据 */
|
||||
metadata: IAssetMetadata;
|
||||
/** 加载耗时(毫秒) / Load time in milliseconds */
|
||||
loadTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loading error
|
||||
* 资产加载错误
|
||||
*/
|
||||
export class AssetLoadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly guid: AssetGUID,
|
||||
public readonly type: AssetType,
|
||||
public readonly cause?: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AssetLoadError';
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for file not found error
|
||||
* 文件未找到错误的工厂方法
|
||||
*/
|
||||
static fileNotFound(guid: AssetGUID, path: string): AssetLoadError {
|
||||
return new AssetLoadError(`Asset file not found: ${path}`, guid, AssetType.Custom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for unsupported type error
|
||||
* 不支持的类型错误的工厂方法
|
||||
*/
|
||||
static unsupportedType(guid: AssetGUID, type: AssetType): AssetLoadError {
|
||||
return new AssetLoadError(`Unsupported asset type: ${type}`, guid, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for load timeout error
|
||||
* 加载超时错误的工厂方法
|
||||
*/
|
||||
static loadTimeout(guid: AssetGUID, type: AssetType, timeout: number): AssetLoadError {
|
||||
return new AssetLoadError(`Asset load timeout after ${timeout}ms`, guid, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for dependency failed error
|
||||
* 依赖加载失败错误的工厂方法
|
||||
*/
|
||||
static dependencyFailed(guid: AssetGUID, type: AssetType, depGuid: AssetGUID): AssetLoadError {
|
||||
return new AssetLoadError(`Dependency failed to load: ${depGuid}`, guid, type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset reference counting info
|
||||
* 资产引用计数信息
|
||||
*/
|
||||
export interface IAssetReferenceInfo {
|
||||
/** 资产GUID */
|
||||
guid: AssetGUID;
|
||||
/** 资产句柄 */
|
||||
handle: AssetHandle;
|
||||
/** 引用计数 */
|
||||
referenceCount: number;
|
||||
/** 最后访问时间 / Unix timestamp of last access */
|
||||
lastAccessTime: number;
|
||||
/** 当前状态 */
|
||||
state: AssetState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset import options
|
||||
* 资产导入选项
|
||||
*/
|
||||
export interface IAssetImportOptions {
|
||||
/** 资产类型 */
|
||||
type: AssetType;
|
||||
/** 生成Mipmap / Generate mipmaps for textures */
|
||||
generateMipmaps?: boolean;
|
||||
/** 纹理压缩格式 / Texture compression format */
|
||||
compression?: 'none' | 'dxt' | 'etc2' | 'astc';
|
||||
/** 最大纹理尺寸 / Maximum texture dimension */
|
||||
maxTextureSize?: number;
|
||||
/** 生成LOD / Generate LODs for meshes */
|
||||
generateLODs?: boolean;
|
||||
/** 优化网格 / Optimize mesh geometry */
|
||||
optimizeMesh?: boolean;
|
||||
/** 音频格式 / Audio encoding format */
|
||||
audioFormat?: 'mp3' | 'ogg' | 'wav';
|
||||
/** 自定义处理器 / Custom processor plugin name */
|
||||
customProcessor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset usage statistics
|
||||
* 资产使用统计
|
||||
*/
|
||||
export interface IAssetUsageStats {
|
||||
/** 资产GUID */
|
||||
guid: AssetGUID;
|
||||
/** 加载次数 */
|
||||
loadCount: number;
|
||||
/** 总加载时间(毫秒) / Total time spent loading in ms */
|
||||
totalLoadTime: number;
|
||||
/** 平均加载时间(毫秒) / Average load time in ms */
|
||||
averageLoadTime: number;
|
||||
/** 最后使用时间 / Unix timestamp of last use */
|
||||
lastUsedTime: number;
|
||||
/** 被引用的资产列表 / Assets that reference this one */
|
||||
referencedBy: AssetGUID[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset preload group
|
||||
* 资产预加载组
|
||||
*/
|
||||
export interface IAssetPreloadGroup {
|
||||
/** 组名称 */
|
||||
name: string;
|
||||
/** 包含的资产 */
|
||||
assets: AssetGUID[];
|
||||
/** 加载优先级 / Load priority 0-100 */
|
||||
priority: number;
|
||||
/** 是否必需 / Must be loaded before scene start */
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loading progress info
|
||||
* 资产加载进度信息
|
||||
*/
|
||||
export interface IAssetLoadProgress {
|
||||
/** 当前加载的资产 */
|
||||
currentAsset: string;
|
||||
/** 已加载数量 */
|
||||
loadedCount: number;
|
||||
/** 总数量 */
|
||||
totalCount: number;
|
||||
/** 已加载字节数 */
|
||||
loadedBytes: number;
|
||||
/** 总字节数 */
|
||||
totalBytes: number;
|
||||
/** 进度百分比(0-1) / Progress value 0-1 */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset catalog entry for runtime lookups
|
||||
* 运行时查找的资产目录条目
|
||||
*/
|
||||
export interface IAssetCatalogEntry {
|
||||
/** 资产GUID */
|
||||
guid: AssetGUID;
|
||||
/** 资产路径 */
|
||||
path: string;
|
||||
/** 资产类型 */
|
||||
type: AssetType;
|
||||
/** 所在包名称 / Bundle containing this asset */
|
||||
bundleName?: string;
|
||||
/** 可用变体 / Available variants */
|
||||
variants?: IAssetVariant[];
|
||||
/** 大小(字节) / Size in bytes */
|
||||
size: number;
|
||||
/** 内容哈希 / Content hash */
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime asset catalog
|
||||
* 运行时资产目录
|
||||
*/
|
||||
export interface IAssetCatalog {
|
||||
/** 版本号 */
|
||||
version: string;
|
||||
/** 创建时间戳 / Creation timestamp */
|
||||
createdAt: number;
|
||||
/** 所有目录条目 / All catalog entries */
|
||||
entries: Map<AssetGUID, IAssetCatalogEntry>;
|
||||
/** 此目录中的包 / Bundles in this catalog */
|
||||
bundles: Map<string, IAssetBundleManifest>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset hot-reload event
|
||||
* 资产热重载事件
|
||||
*/
|
||||
export interface IAssetHotReloadEvent {
|
||||
/** 资产GUID */
|
||||
guid: AssetGUID;
|
||||
/** 资产路径 */
|
||||
path: string;
|
||||
/** 资产类型 */
|
||||
type: AssetType;
|
||||
/** 旧版本哈希 / Previous version hash */
|
||||
oldHash: string;
|
||||
/** 新版本哈希 / New version hash */
|
||||
newHash: string;
|
||||
/** 时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
165
packages/asset-system/src/utils/PathValidator.ts
Normal file
165
packages/asset-system/src/utils/PathValidator.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Path Validator
|
||||
* 路径验证器
|
||||
*
|
||||
* Validates and sanitizes asset paths for security
|
||||
* 验证并清理资产路径以确保安全
|
||||
*/
|
||||
|
||||
export class PathValidator {
|
||||
// Dangerous path patterns
|
||||
private static readonly DANGEROUS_PATTERNS = [
|
||||
/\.\.[/\\]/g, // Path traversal attempts (..)
|
||||
/^[/\\]/, // Absolute paths on Unix
|
||||
/^[a-zA-Z]:[/\\]/, // Absolute paths on Windows
|
||||
/[<>:"|?*]/, // Invalid characters for Windows paths
|
||||
/\0/, // Null bytes
|
||||
/%00/, // URL encoded null bytes
|
||||
/\.\.%2[fF]/ // URL encoded path traversal
|
||||
];
|
||||
|
||||
// Valid path characters (alphanumeric, dash, underscore, dot, slash)
|
||||
private static readonly VALID_PATH_REGEX = /^[a-zA-Z0-9\-_./\\@]+$/;
|
||||
|
||||
// Maximum path length
|
||||
private static readonly MAX_PATH_LENGTH = 260;
|
||||
|
||||
/**
|
||||
* Validate if a path is safe
|
||||
* 验证路径是否安全
|
||||
*/
|
||||
static validate(path: string): { valid: boolean; reason?: string } {
|
||||
// Check for null/undefined/empty
|
||||
if (!path || typeof path !== 'string') {
|
||||
return { valid: false, reason: 'Path is empty or invalid' };
|
||||
}
|
||||
|
||||
// Check length
|
||||
if (path.length > this.MAX_PATH_LENGTH) {
|
||||
return { valid: false, reason: `Path exceeds maximum length of ${this.MAX_PATH_LENGTH} characters` };
|
||||
}
|
||||
|
||||
// Check for dangerous patterns
|
||||
for (const pattern of this.DANGEROUS_PATTERNS) {
|
||||
if (pattern.test(path)) {
|
||||
return { valid: false, reason: 'Path contains dangerous pattern' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for valid characters
|
||||
if (!this.VALID_PATH_REGEX.test(path)) {
|
||||
return { valid: false, reason: 'Path contains invalid characters' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a path
|
||||
* 清理路径
|
||||
*/
|
||||
static sanitize(path: string): string {
|
||||
if (!path || typeof path !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove dangerous patterns
|
||||
let sanitized = path;
|
||||
|
||||
// Remove path traversal (apply repeatedly until fully removed)
|
||||
let prev;
|
||||
do {
|
||||
prev = sanitized;
|
||||
sanitized = sanitized.replace(/\.\.[/\\]/g, '');
|
||||
} while (sanitized !== prev);
|
||||
|
||||
// Remove leading slashes
|
||||
sanitized = sanitized.replace(/^[/\\]+/, '');
|
||||
|
||||
// Remove null bytes
|
||||
sanitized = sanitized.replace(/\0/g, '');
|
||||
sanitized = sanitized.replace(/%00/g, '');
|
||||
|
||||
// Remove invalid Windows characters
|
||||
sanitized = sanitized.replace(/[<>:"|?*]/g, '_');
|
||||
|
||||
// Normalize slashes
|
||||
sanitized = sanitized.replace(/\\/g, '/');
|
||||
|
||||
// Remove double slashes
|
||||
sanitized = sanitized.replace(/\/+/g, '/');
|
||||
|
||||
// Trim whitespace
|
||||
sanitized = sanitized.trim();
|
||||
|
||||
// Truncate if too long
|
||||
if (sanitized.length > this.MAX_PATH_LENGTH) {
|
||||
sanitized = sanitized.substring(0, this.MAX_PATH_LENGTH);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is trying to escape the base directory
|
||||
* 检查路径是否试图逃离基础目录
|
||||
*/
|
||||
static isPathTraversal(path: string): boolean {
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
return normalized.includes('../') || normalized.includes('..\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a path for consistent handling
|
||||
* 规范化路径以便一致处理
|
||||
*/
|
||||
static normalize(path: string): string {
|
||||
if (!path) return '';
|
||||
|
||||
// Sanitize first
|
||||
let normalized = this.sanitize(path);
|
||||
|
||||
// Convert backslashes to forward slashes
|
||||
normalized = normalized.replace(/\\/g, '/');
|
||||
|
||||
// Remove trailing slash (except for root)
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join path segments safely
|
||||
* 安全地连接路径段
|
||||
*/
|
||||
static join(...segments: string[]): string {
|
||||
const validSegments = segments
|
||||
.filter((s) => s && typeof s === 'string')
|
||||
.map((s) => this.sanitize(s))
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (validSegments.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.normalize(validSegments.join('/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension safely
|
||||
* 安全地获取文件扩展名
|
||||
*/
|
||||
static getExtension(path: string): string {
|
||||
const sanitized = this.sanitize(path);
|
||||
const lastDot = sanitized.lastIndexOf('.');
|
||||
const lastSlash = sanitized.lastIndexOf('/');
|
||||
|
||||
if (lastDot > lastSlash && lastDot > 0) {
|
||||
return sanitized.substring(lastDot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
36
packages/asset-system/tsconfig.json
Normal file
36
packages/asset-system/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": false,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -24,9 +24,9 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@esengine/behavior-tree": "file:../behavior-tree",
|
||||
"@esengine/ecs-framework": "file:../core",
|
||||
"@esengine/editor-core": "file:../editor-core",
|
||||
"@esengine/behavior-tree": "workspace:*",
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@rollup/plugin-commonjs": "^28.0.1",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@types/react": "^18.3.18",
|
||||
|
||||
@@ -347,414 +347,414 @@ export const BlackboardPanel: React.FC<BlackboardPanelProps> = ({
|
||||
overflowY: 'auto',
|
||||
padding: '10px'
|
||||
}}>
|
||||
{variableEntries.length === 0 && !isAdding && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
padding: '20px'
|
||||
}}>
|
||||
{variableEntries.length === 0 && !isAdding && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
padding: '20px'
|
||||
}}>
|
||||
No variables yet
|
||||
</div>
|
||||
)}
|
||||
</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)}
|
||||
{/* 添加新变量表单 */}
|
||||
{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={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 6px',
|
||||
backgroundColor: '#252525',
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '4px',
|
||||
userSelect: 'none'
|
||||
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'
|
||||
}}
|
||||
>
|
||||
{isGroupCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: '#888'
|
||||
}}>
|
||||
{groupName} ({groupVars.length})
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!isGroupCollapsed && groupVars.map(({ fullKey: key, varName, value }) => {
|
||||
const type = getVariableType(value);
|
||||
const isEditing = editingKey === key;
|
||||
{/* 分组显示变量 */}
|
||||
{groupNames.map((groupName) => {
|
||||
const isGroupCollapsed = collapsedGroups.has(groupName);
|
||||
const groupVars = groupedVariables[groupName];
|
||||
if (!groupVars) return null;
|
||||
|
||||
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';
|
||||
};
|
||||
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>
|
||||
)}
|
||||
|
||||
const typeColor =
|
||||
{!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 displayValue = type === 'object' ?
|
||||
JSON.stringify(value) :
|
||||
String(value);
|
||||
|
||||
const truncatedValue = displayValue.length > 30 ?
|
||||
displayValue.substring(0, 30) + '...' :
|
||||
displayValue;
|
||||
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'
|
||||
}}
|
||||
>
|
||||
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'
|
||||
}}
|
||||
>
|
||||
</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 }}>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#9cdcfe',
|
||||
fontWeight: 'bold',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
gap: '8px'
|
||||
}}>
|
||||
<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 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 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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 底部信息栏 */}
|
||||
<div style={{
|
||||
|
||||
@@ -65,7 +65,7 @@ export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
||||
{onDeleteNode && (
|
||||
<div
|
||||
onClick={onDeleteNode}
|
||||
style={{...menuItemStyle, color: '#f48771'}}
|
||||
style={{ ...menuItemStyle, color: '#f48771' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#5a1a1a'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
|
||||
@@ -69,13 +69,13 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
}, [filteredTemplates, expandedCategories, searchTextLower]);
|
||||
|
||||
const flattenedTemplates = React.useMemo(() => {
|
||||
return categoryGroups.flatMap(group =>
|
||||
return categoryGroups.flatMap((group) =>
|
||||
group.isExpanded ? group.templates : []
|
||||
);
|
||||
}, [categoryGroups]);
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
setExpandedCategories((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
@@ -88,7 +88,7 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (allTemplates.length > 0 && expandedCategories.size === 0) {
|
||||
const categories = new Set(allTemplates.map(t => t.category || '未分类'));
|
||||
const categories = new Set(allTemplates.map((t) => t.category || '未分类'));
|
||||
setExpandedCategories(categories);
|
||||
}
|
||||
}, [allTemplates, expandedCategories.size]);
|
||||
|
||||
@@ -256,30 +256,30 @@ const BehaviorTreeNodeComponent: React.FC<BehaviorTreeNodeProps> = ({
|
||||
!nodes.some((n) =>
|
||||
connections.some((c) => c.from === node.id && c.to === n.id)
|
||||
) && (
|
||||
<div
|
||||
className="bt-node-empty-warning-container"
|
||||
<div
|
||||
className="bt-node-empty-warning-container"
|
||||
style={{
|
||||
marginLeft: ((!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) || isUncommitted) ? '4px' : 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
style={{
|
||||
marginLeft: ((!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) || isUncommitted) ? '4px' : 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative'
|
||||
color: '#ff9800',
|
||||
flexShrink: 0
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
style={{
|
||||
color: '#ff9800',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<div className="bt-node-empty-warning-tooltip">
|
||||
/>
|
||||
<div className="bt-node-empty-warning-tooltip">
|
||||
空节点:没有子节点,执行时会直接跳过
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bt-node-body">
|
||||
@@ -341,16 +341,16 @@ const BehaviorTreeNodeComponent: React.FC<BehaviorTreeNodeProps> = ({
|
||||
|
||||
{(isRoot || node.template.type === 'composite' || node.template.type === 'decorator') &&
|
||||
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) && (
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-port-type="node-output"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id)}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id)}
|
||||
className="bt-node-port bt-node-port-output"
|
||||
title="Output"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-port-type="node-output"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id)}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id)}
|
||||
className="bt-node-port bt-node-port-output"
|
||||
title="Output"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -99,7 +99,7 @@ export const BehaviorTreeEditorPanel: React.FC<BehaviorTreeEditorPanelProps> = (
|
||||
);
|
||||
|
||||
// 更新树
|
||||
const nodes = state.getNodes().map(n =>
|
||||
const nodes = state.getNodes().map((n) =>
|
||||
n.id === data.nodeId ? updatedNode : n
|
||||
);
|
||||
|
||||
|
||||
@@ -44,4 +44,4 @@ export const NODE_COLORS = {
|
||||
[NodeType.Decorator]: '#4dffb8',
|
||||
[NodeType.Action]: '#ff4d4d',
|
||||
[NodeType.Condition]: '#4dff9e'
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,17 +7,17 @@ import { useBehaviorTreeDataStore, useUIStore } from '../stores';
|
||||
*/
|
||||
export function useCanvasInteraction() {
|
||||
// 从数据 store 获取画布状态
|
||||
const canvasOffset = useBehaviorTreeDataStore(state => state.canvasOffset);
|
||||
const canvasScale = useBehaviorTreeDataStore(state => state.canvasScale);
|
||||
const setCanvasOffset = useBehaviorTreeDataStore(state => state.setCanvasOffset);
|
||||
const setCanvasScale = useBehaviorTreeDataStore(state => state.setCanvasScale);
|
||||
const resetView = useBehaviorTreeDataStore(state => state.resetView);
|
||||
const canvasOffset = useBehaviorTreeDataStore((state) => state.canvasOffset);
|
||||
const canvasScale = useBehaviorTreeDataStore((state) => state.canvasScale);
|
||||
const setCanvasOffset = useBehaviorTreeDataStore((state) => state.setCanvasOffset);
|
||||
const setCanvasScale = useBehaviorTreeDataStore((state) => state.setCanvasScale);
|
||||
const resetView = useBehaviorTreeDataStore((state) => state.resetView);
|
||||
|
||||
// 从 UI store 获取平移状态
|
||||
const isPanning = useUIStore(state => state.isPanning);
|
||||
const panStart = useUIStore(state => state.panStart);
|
||||
const setIsPanning = useUIStore(state => state.setIsPanning);
|
||||
const setPanStart = useUIStore(state => state.setPanStart);
|
||||
const isPanning = useUIStore((state) => state.isPanning);
|
||||
const panStart = useUIStore((state) => state.panStart);
|
||||
const setIsPanning = useUIStore((state) => state.setIsPanning);
|
||||
const setPanStart = useUIStore((state) => state.setPanStart);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -181,7 +181,7 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceNode = nodes.find(n => n.id === connectingFrom);
|
||||
const sourceNode = nodes.find((n) => n.id === connectingFrom);
|
||||
if (sourceNode && !sourceNode.canAddChild()) {
|
||||
const maxChildren = sourceNode.template.maxChildren ?? Infinity;
|
||||
showToast?.(
|
||||
|
||||
@@ -92,6 +92,6 @@ export class NodeFactory implements INodeFactory {
|
||||
*/
|
||||
getTemplateByImplementationType(implementationType: string): NodeTemplate | null {
|
||||
const allTemplates = this.getAllTemplates();
|
||||
return allTemplates.find(t => t.className === implementationType) || null;
|
||||
return allTemplates.find((t) => t.className === implementationType) || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export class NodeRegistryService {
|
||||
const template = this.createTemplate(config, metadata);
|
||||
this.customTemplates.set(config.implementationType, template);
|
||||
|
||||
this.registrationCallbacks.forEach(cb => cb(template));
|
||||
this.registrationCallbacks.forEach((cb) => cb(template));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,7 +139,7 @@ export class NodeRegistryService {
|
||||
description: prop.description,
|
||||
min: prop.min,
|
||||
max: prop.max,
|
||||
options: prop.options?.map(o => o.value)
|
||||
options: prop.options?.map((o) => o.value)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ export class NodeRegistryService {
|
||||
color: config.color || this.getDefaultColor(config.type),
|
||||
className: config.implementationType,
|
||||
defaultConfig,
|
||||
properties: (config.properties || []).map(p => ({
|
||||
properties: (config.properties || []).map((p) => ({
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
label: p.label,
|
||||
|
||||
@@ -201,7 +201,7 @@ const NodePropertiesPanel: React.FC<NodePropertiesPanelProps> = ({ node, onPrope
|
||||
const [localData, setLocalData] = useState<Record<string, any>>(node.data);
|
||||
|
||||
const handlePropertyChange = useCallback((name: string, value: any) => {
|
||||
setLocalData(prev => ({ ...prev, [name]: value }));
|
||||
setLocalData((prev) => ({ ...prev, [name]: value }));
|
||||
onPropertyChange?.(node.id, name, value);
|
||||
}, [node.id, onPropertyChange]);
|
||||
|
||||
|
||||
@@ -58,4 +58,4 @@ export class NotificationService {
|
||||
// 导出单例实例的便捷方法
|
||||
export const showToast = (message: string, type?: 'success' | 'error' | 'warning' | 'info') => {
|
||||
NotificationService.getInstance().showToast(message, type);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -451,15 +451,15 @@ export class BehaviorTreeExecutor {
|
||||
|
||||
// 检查断点
|
||||
logger.info(`[Breakpoint Debug] Node ${nodeData.name} (${nodeId}) started running`);
|
||||
logger.info(`[Breakpoint Debug] Breakpoints count:`, this.breakpoints.size);
|
||||
logger.info(`[Breakpoint Debug] Has breakpoint:`, this.breakpoints.has(nodeId));
|
||||
logger.info('[Breakpoint Debug] Breakpoints count:', this.breakpoints.size);
|
||||
logger.info('[Breakpoint Debug] Has breakpoint:', this.breakpoints.has(nodeId));
|
||||
|
||||
const breakpoint = this.breakpoints.get(nodeId);
|
||||
if (breakpoint) {
|
||||
logger.info(`[Breakpoint Debug] Breakpoint found, enabled:`, breakpoint.enabled);
|
||||
logger.info('[Breakpoint Debug] Breakpoint found, enabled:', breakpoint.enabled);
|
||||
if (breakpoint.enabled) {
|
||||
this.addLog(`断点触发: ${nodeData.name}`, 'warning', nodeId);
|
||||
logger.info(`[Breakpoint Debug] Calling onBreakpointHit callback:`, !!this.onBreakpointHit);
|
||||
logger.info('[Breakpoint Debug] Calling onBreakpointHit callback:', !!this.onBreakpointHit);
|
||||
if (this.onBreakpointHit) {
|
||||
this.onBreakpointHit(nodeId, nodeData.name);
|
||||
}
|
||||
|
||||
@@ -58,4 +58,4 @@ export function getPortPosition(
|
||||
const worldY = (screenY - canvasOffset.y) / canvasScale;
|
||||
|
||||
return { x: worldX, y: worldY };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,4 +245,4 @@ export const BlackboardTypes: Record<BlackboardValueType, BlackboardTypeDefiniti
|
||||
defaultValue: '',
|
||||
editorComponent: 'NodePathPicker'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ export class EditorToBehaviorTreeDataConverter {
|
||||
*/
|
||||
static convert(editorData: EditorBehaviorTreeData): BehaviorTreeData {
|
||||
// 查找根节点
|
||||
const rootNode = editorData.nodes.find(n =>
|
||||
const rootNode = editorData.nodes.find((n) =>
|
||||
n.template.type === 'root' || n.data['nodeType'] === 'root'
|
||||
);
|
||||
|
||||
|
||||
52
packages/components/package.json
Normal file
52
packages/components/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@esengine/ecs-components",
|
||||
"version": "1.0.0",
|
||||
"description": "Standard component library for ECS Framework",
|
||||
"main": "bin/index.js",
|
||||
"types": "bin/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./bin/index.d.ts",
|
||||
"import": "./bin/index.js",
|
||||
"development": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"bin/**/*"
|
||||
],
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"components",
|
||||
"game-engine",
|
||||
"typescript"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "rimraf bin dist tsconfig.tsbuildinfo",
|
||||
"build:ts": "tsc",
|
||||
"prebuild": "npm run clean",
|
||||
"build": "npm run build:ts",
|
||||
"build:watch": "tsc --watch",
|
||||
"rebuild": "npm run clean && npm run build"
|
||||
},
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@esengine/ecs-framework": "^2.2.8",
|
||||
"@esengine/asset-system": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/esengine/ecs-framework.git",
|
||||
"directory": "packages/components"
|
||||
}
|
||||
}
|
||||
39
packages/components/src/AudioSourceComponent.ts
Normal file
39
packages/components/src/AudioSourceComponent.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 音频源组件 - 管理音频播放
|
||||
*/
|
||||
@ECSComponent('AudioSource')
|
||||
@Serializable({ version: 1, typeId: 'AudioSource' })
|
||||
export class AudioSourceComponent extends Component {
|
||||
/** 音频资源路径 */
|
||||
@Serialize() public clip: string = '';
|
||||
|
||||
/** 音量 (0-1) */
|
||||
@Serialize() public volume: number = 1;
|
||||
|
||||
/** 音调 */
|
||||
@Serialize() public pitch: number = 1;
|
||||
|
||||
/** 是否循环 */
|
||||
@Serialize() public loop: boolean = false;
|
||||
|
||||
/** 是否启动时播放 */
|
||||
@Serialize() public playOnAwake: boolean = false;
|
||||
|
||||
/** 是否静音 */
|
||||
@Serialize() public mute: boolean = false;
|
||||
|
||||
/** 空间混合 (0=2D, 1=3D) */
|
||||
@Serialize() public spatialBlend: number = 0;
|
||||
|
||||
/** 最小距离(3D音效) */
|
||||
@Serialize() public minDistance: number = 1;
|
||||
|
||||
/** 最大距离(3D音效) */
|
||||
@Serialize() public maxDistance: number = 500;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
33
packages/components/src/BoxColliderComponent.ts
Normal file
33
packages/components/src/BoxColliderComponent.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 盒型碰撞器组件
|
||||
*/
|
||||
@ECSComponent('BoxCollider')
|
||||
@Serializable({ version: 1, typeId: 'BoxCollider' })
|
||||
export class BoxColliderComponent extends Component {
|
||||
/** 是否为触发器 */
|
||||
@Serialize() public isTrigger: boolean = false;
|
||||
|
||||
/** 中心点X偏移 */
|
||||
@Serialize() public centerX: number = 0;
|
||||
|
||||
/** 中心点Y偏移 */
|
||||
@Serialize() public centerY: number = 0;
|
||||
|
||||
/** 中心点Z偏移 */
|
||||
@Serialize() public centerZ: number = 0;
|
||||
|
||||
/** 宽度 */
|
||||
@Serialize() public width: number = 1;
|
||||
|
||||
/** 高度 */
|
||||
@Serialize() public height: number = 1;
|
||||
|
||||
/** 深度 */
|
||||
@Serialize() public depth: number = 1;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
82
packages/components/src/CameraComponent.ts
Normal file
82
packages/components/src/CameraComponent.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 相机投影类型
|
||||
*/
|
||||
export enum CameraProjection {
|
||||
Perspective = 'perspective',
|
||||
Orthographic = 'orthographic'
|
||||
}
|
||||
|
||||
/**
|
||||
* 相机组件 - 管理视图和投影
|
||||
*/
|
||||
@ECSComponent('Camera')
|
||||
@Serializable({ version: 1, typeId: 'Camera' })
|
||||
export class CameraComponent extends Component {
|
||||
/** 投影类型 */
|
||||
@Serialize()
|
||||
@Property({
|
||||
type: 'enum',
|
||||
label: 'Projection',
|
||||
options: [
|
||||
{ label: 'Orthographic', value: CameraProjection.Orthographic },
|
||||
{ label: 'Perspective', value: CameraProjection.Perspective }
|
||||
]
|
||||
})
|
||||
public projection: CameraProjection = CameraProjection.Orthographic;
|
||||
|
||||
/** 视野角度(透视模式) */
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Field of View', min: 1, max: 179 })
|
||||
public fieldOfView: number = 60;
|
||||
|
||||
/** 正交尺寸(正交模式) */
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Orthographic Size', min: 0.1, step: 0.1 })
|
||||
public orthographicSize: number = 5;
|
||||
|
||||
/** 近裁剪面 */
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Near Clip', min: 0.01, step: 0.1 })
|
||||
public nearClipPlane: number = 0.1;
|
||||
|
||||
/** 远裁剪面 */
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Far Clip', min: 1, step: 10 })
|
||||
public farClipPlane: number = 1000;
|
||||
|
||||
/** 视口X */
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Viewport X', min: 0, max: 1, step: 0.01 })
|
||||
public viewportX: number = 0;
|
||||
|
||||
/** 视口Y */
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Viewport Y', min: 0, max: 1, step: 0.01 })
|
||||
public viewportY: number = 0;
|
||||
|
||||
/** 视口宽度 */
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Viewport Width', min: 0, max: 1, step: 0.01 })
|
||||
public viewportWidth: number = 1;
|
||||
|
||||
/** 视口高度 */
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Viewport Height', min: 0, max: 1, step: 0.01 })
|
||||
public viewportHeight: number = 1;
|
||||
|
||||
/** 渲染优先级 */
|
||||
@Serialize()
|
||||
@Property({ type: 'integer', label: 'Depth' })
|
||||
public depth: number = 0;
|
||||
|
||||
/** 背景颜色 */
|
||||
@Serialize()
|
||||
@Property({ type: 'color', label: 'Background Color' })
|
||||
public backgroundColor: string = '#000000';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
24
packages/components/src/CircleColliderComponent.ts
Normal file
24
packages/components/src/CircleColliderComponent.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 圆形碰撞器组件
|
||||
*/
|
||||
@ECSComponent('CircleCollider')
|
||||
@Serializable({ version: 1, typeId: 'CircleCollider' })
|
||||
export class CircleColliderComponent extends Component {
|
||||
/** 是否为触发器 */
|
||||
@Serialize() public isTrigger: boolean = false;
|
||||
|
||||
/** 中心点X偏移 */
|
||||
@Serialize() public centerX: number = 0;
|
||||
|
||||
/** 中心点Y偏移 */
|
||||
@Serialize() public centerY: number = 0;
|
||||
|
||||
/** 半径 */
|
||||
@Serialize() public radius: number = 0.5;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
57
packages/components/src/RigidBodyComponent.ts
Normal file
57
packages/components/src/RigidBodyComponent.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 刚体类型
|
||||
*/
|
||||
export enum BodyType {
|
||||
Static = 'static',
|
||||
Dynamic = 'dynamic',
|
||||
Kinematic = 'kinematic'
|
||||
}
|
||||
|
||||
/**
|
||||
* 刚体组件 - 管理物理模拟
|
||||
*/
|
||||
@ECSComponent('RigidBody')
|
||||
@Serializable({ version: 1, typeId: 'RigidBody' })
|
||||
export class RigidBodyComponent extends Component {
|
||||
/** 刚体类型 */
|
||||
@Serialize() public bodyType: BodyType = BodyType.Dynamic;
|
||||
|
||||
/** 质量 */
|
||||
@Serialize() public mass: number = 1;
|
||||
|
||||
/** 线性阻尼 */
|
||||
@Serialize() public linearDamping: number = 0;
|
||||
|
||||
/** 角阻尼 */
|
||||
@Serialize() public angularDamping: number = 0.05;
|
||||
|
||||
/** 重力缩放 */
|
||||
@Serialize() public gravityScale: number = 1;
|
||||
|
||||
/** 是否使用连续碰撞检测 */
|
||||
@Serialize() public continuousDetection: boolean = false;
|
||||
|
||||
/** 是否冻结X轴旋转 */
|
||||
@Serialize() public freezeRotationX: boolean = false;
|
||||
|
||||
/** 是否冻结Y轴旋转 */
|
||||
@Serialize() public freezeRotationY: boolean = false;
|
||||
|
||||
/** 是否冻结Z轴旋转 */
|
||||
@Serialize() public freezeRotationZ: boolean = false;
|
||||
|
||||
/** X轴速度 */
|
||||
@Serialize() public velocityX: number = 0;
|
||||
|
||||
/** Y轴速度 */
|
||||
@Serialize() public velocityY: number = 0;
|
||||
|
||||
/** Z轴速度 */
|
||||
@Serialize() public velocityZ: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
369
packages/components/src/SpriteAnimatorComponent.ts
Normal file
369
packages/components/src/SpriteAnimatorComponent.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 动画帧数据
|
||||
* Animation frame data
|
||||
*/
|
||||
export interface AnimationFrame {
|
||||
/** 纹理路径 | Texture path */
|
||||
texture: string;
|
||||
/** 帧持续时间(秒) | Frame duration in seconds */
|
||||
duration: number;
|
||||
/** UV坐标 [u0, v0, u1, v1] | UV coordinates */
|
||||
uv?: [number, number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* 动画剪辑数据
|
||||
* Animation clip data
|
||||
*/
|
||||
export interface AnimationClip {
|
||||
/** 动画名称 | Animation name */
|
||||
name: string;
|
||||
/** 动画帧列表 | Animation frames */
|
||||
frames: AnimationFrame[];
|
||||
/** 是否循环 | Whether to loop */
|
||||
loop: boolean;
|
||||
/** 播放速度倍数 | Playback speed multiplier */
|
||||
speed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 精灵动画组件 - 管理精灵帧动画
|
||||
* Sprite animator component - manages sprite frame animation
|
||||
*/
|
||||
@ECSComponent('SpriteAnimator', { requires: ['Sprite'] })
|
||||
@Serializable({ version: 1, typeId: 'SpriteAnimator' })
|
||||
export class SpriteAnimatorComponent extends Component {
|
||||
/**
|
||||
* 动画剪辑列表
|
||||
* Animation clips
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({
|
||||
type: 'animationClips',
|
||||
label: 'Animation Clips',
|
||||
controls: [{ component: 'Sprite', property: 'texture' }]
|
||||
})
|
||||
public clips: AnimationClip[] = [];
|
||||
|
||||
/**
|
||||
* 当前播放的动画名称
|
||||
* Currently playing animation name
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'string', label: 'Default Animation' })
|
||||
public defaultAnimation: string = '';
|
||||
|
||||
/**
|
||||
* 是否自动播放
|
||||
* Auto play on start
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Auto Play' })
|
||||
public autoPlay: boolean = true;
|
||||
|
||||
/**
|
||||
* 全局播放速度
|
||||
* Global playback speed
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Speed', min: 0, max: 10, step: 0.1 })
|
||||
public speed: number = 1;
|
||||
|
||||
// Runtime state (not serialized)
|
||||
private _currentClip: AnimationClip | null = null;
|
||||
private _currentFrameIndex: number = 0;
|
||||
private _frameTimer: number = 0;
|
||||
private _isPlaying: boolean = false;
|
||||
private _isPaused: boolean = false;
|
||||
|
||||
// Callbacks
|
||||
private _onAnimationComplete?: (clipName: string) => void;
|
||||
private _onFrameChange?: (frameIndex: number, frame: AnimationFrame) => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加动画剪辑
|
||||
* Add animation clip
|
||||
*/
|
||||
addClip(clip: AnimationClip): void {
|
||||
// Remove existing clip with same name
|
||||
this.clips = this.clips.filter((c) => c.name !== clip.name);
|
||||
this.clips.push(clip);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从精灵图集创建动画剪辑
|
||||
* Create animation clip from sprite atlas
|
||||
*
|
||||
* @param name - 动画名称 | Animation name
|
||||
* @param texture - 纹理路径 | Texture path
|
||||
* @param frameCount - 帧数 | Number of frames
|
||||
* @param frameWidth - 每帧宽度 | Frame width
|
||||
* @param frameHeight - 每帧高度 | Frame height
|
||||
* @param atlasWidth - 图集宽度 | Atlas width
|
||||
* @param atlasHeight - 图集高度 | Atlas height
|
||||
* @param fps - 帧率 | Frames per second
|
||||
* @param loop - 是否循环 | Whether to loop
|
||||
*/
|
||||
createClipFromAtlas(
|
||||
name: string,
|
||||
texture: string,
|
||||
frameCount: number,
|
||||
frameWidth: number,
|
||||
frameHeight: number,
|
||||
atlasWidth: number,
|
||||
atlasHeight: number,
|
||||
fps: number = 12,
|
||||
loop: boolean = true
|
||||
): AnimationClip {
|
||||
const frames: AnimationFrame[] = [];
|
||||
const duration = 1 / fps;
|
||||
const cols = Math.floor(atlasWidth / frameWidth);
|
||||
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const x = col * frameWidth;
|
||||
const y = row * frameHeight;
|
||||
|
||||
frames.push({
|
||||
texture,
|
||||
duration,
|
||||
uv: [
|
||||
x / atlasWidth,
|
||||
y / atlasHeight,
|
||||
(x + frameWidth) / atlasWidth,
|
||||
(y + frameHeight) / atlasHeight
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const clip: AnimationClip = {
|
||||
name,
|
||||
frames,
|
||||
loop,
|
||||
speed: 1
|
||||
};
|
||||
|
||||
this.addClip(clip);
|
||||
return clip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从帧序列创建动画剪辑
|
||||
* Create animation clip from frame sequence
|
||||
*
|
||||
* @param name - 动画名称 | Animation name
|
||||
* @param textures - 纹理路径数组 | Array of texture paths
|
||||
* @param fps - 帧率 | Frames per second
|
||||
* @param loop - 是否循环 | Whether to loop
|
||||
*/
|
||||
createClipFromSequence(
|
||||
name: string,
|
||||
textures: string[],
|
||||
fps: number = 12,
|
||||
loop: boolean = true
|
||||
): AnimationClip {
|
||||
const duration = 1 / fps;
|
||||
const frames: AnimationFrame[] = textures.map((texture) => ({
|
||||
texture,
|
||||
duration
|
||||
}));
|
||||
|
||||
const clip: AnimationClip = {
|
||||
name,
|
||||
frames,
|
||||
loop,
|
||||
speed: 1
|
||||
};
|
||||
|
||||
this.addClip(clip);
|
||||
return clip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放动画
|
||||
* Play animation
|
||||
*/
|
||||
play(clipName?: string): void {
|
||||
const name = clipName || this.defaultAnimation;
|
||||
if (!name) return;
|
||||
|
||||
const clip = this.clips.find((c) => c.name === name);
|
||||
if (!clip || clip.frames.length === 0) {
|
||||
console.warn(`Animation clip not found: ${name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._currentClip = clip;
|
||||
this._currentFrameIndex = 0;
|
||||
this._frameTimer = 0;
|
||||
this._isPlaying = true;
|
||||
this._isPaused = false;
|
||||
|
||||
this._notifyFrameChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止动画
|
||||
* Stop animation
|
||||
*/
|
||||
stop(): void {
|
||||
this._isPlaying = false;
|
||||
this._isPaused = false;
|
||||
this._currentFrameIndex = 0;
|
||||
this._frameTimer = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停动画
|
||||
* Pause animation
|
||||
*/
|
||||
pause(): void {
|
||||
if (this._isPlaying) {
|
||||
this._isPaused = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复动画
|
||||
* Resume animation
|
||||
*/
|
||||
resume(): void {
|
||||
if (this._isPlaying && this._isPaused) {
|
||||
this._isPaused = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新动画(由系统调用)
|
||||
* Update animation (called by system)
|
||||
*/
|
||||
update(deltaTime: number): void {
|
||||
if (!this._isPlaying || this._isPaused || !this._currentClip) return;
|
||||
|
||||
const clip = this._currentClip;
|
||||
const frame = clip.frames[this._currentFrameIndex];
|
||||
if (!frame) return;
|
||||
|
||||
this._frameTimer += deltaTime * this.speed * clip.speed;
|
||||
|
||||
if (this._frameTimer >= frame.duration) {
|
||||
this._frameTimer -= frame.duration;
|
||||
this._currentFrameIndex++;
|
||||
|
||||
if (this._currentFrameIndex >= clip.frames.length) {
|
||||
if (clip.loop) {
|
||||
this._currentFrameIndex = 0;
|
||||
} else {
|
||||
this._currentFrameIndex = clip.frames.length - 1;
|
||||
this._isPlaying = false;
|
||||
this._onAnimationComplete?.(clip.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._notifyFrameChange();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前帧
|
||||
* Get current frame
|
||||
*/
|
||||
getCurrentFrame(): AnimationFrame | null {
|
||||
if (!this._currentClip) return null;
|
||||
return this._currentClip.frames[this._currentFrameIndex] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前帧索引
|
||||
* Get current frame index
|
||||
*/
|
||||
getCurrentFrameIndex(): number {
|
||||
return this._currentFrameIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前帧
|
||||
* Set current frame
|
||||
*/
|
||||
setFrame(index: number): void {
|
||||
if (!this._currentClip) return;
|
||||
this._currentFrameIndex = Math.max(0, Math.min(index, this._currentClip.frames.length - 1));
|
||||
this._frameTimer = 0;
|
||||
this._notifyFrameChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否正在播放
|
||||
* Whether animation is playing
|
||||
*/
|
||||
isPlaying(): boolean {
|
||||
return this._isPlaying && !this._isPaused;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前动画名称
|
||||
* Get current animation name
|
||||
*/
|
||||
getCurrentClipName(): string | null {
|
||||
return this._currentClip?.name || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置动画完成回调
|
||||
* Set animation complete callback
|
||||
*/
|
||||
onAnimationComplete(callback: (clipName: string) => void): void {
|
||||
this._onAnimationComplete = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置帧变化回调
|
||||
* Set frame change callback
|
||||
*/
|
||||
onFrameChange(callback: (frameIndex: number, frame: AnimationFrame) => void): void {
|
||||
this._onFrameChange = callback;
|
||||
}
|
||||
|
||||
private _notifyFrameChange(): void {
|
||||
const frame = this.getCurrentFrame();
|
||||
if (frame) {
|
||||
this._onFrameChange?.(this._currentFrameIndex, frame);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动画剪辑
|
||||
* Get animation clip by name
|
||||
*/
|
||||
getClip(name: string): AnimationClip | undefined {
|
||||
return this.clips.find((c) => c.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除动画剪辑
|
||||
* Remove animation clip
|
||||
*/
|
||||
removeClip(name: string): void {
|
||||
this.clips = this.clips.filter((c) => c.name !== name);
|
||||
if (this._currentClip?.name === name) {
|
||||
this.stop();
|
||||
this._currentClip = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有动画名称
|
||||
* Get all animation names
|
||||
*/
|
||||
getClipNames(): string[] {
|
||||
return this.clips.map((c) => c.name);
|
||||
}
|
||||
}
|
||||
243
packages/components/src/SpriteComponent.ts
Normal file
243
packages/components/src/SpriteComponent.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
|
||||
import type { AssetReference } from '@esengine/asset-system';
|
||||
|
||||
/**
|
||||
* 精灵组件 - 管理2D图像渲染
|
||||
* Sprite component - manages 2D image rendering
|
||||
*/
|
||||
@ECSComponent('Sprite')
|
||||
@Serializable({ version: 2, typeId: 'Sprite' })
|
||||
export class SpriteComponent extends Component {
|
||||
/** 纹理路径或资源ID | Texture path or asset ID */
|
||||
@Serialize()
|
||||
@Property({ type: 'asset', label: 'Texture', fileExtension: '.png' })
|
||||
public texture: string = '';
|
||||
|
||||
/**
|
||||
* 资产GUID(新的资产系统)
|
||||
* Asset GUID for new asset system
|
||||
*/
|
||||
@Serialize()
|
||||
public assetGuid?: string;
|
||||
|
||||
/**
|
||||
* 纹理ID(运行时使用)
|
||||
* Texture ID for runtime rendering
|
||||
*/
|
||||
public textureId: number = 0;
|
||||
|
||||
/**
|
||||
* 资产引用(运行时,不序列化)
|
||||
* Asset reference (runtime only, not serialized)
|
||||
*/
|
||||
private _assetReference?: AssetReference<HTMLImageElement>;
|
||||
|
||||
/**
|
||||
* 精灵宽度(像素)
|
||||
* Sprite width in pixels
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({
|
||||
type: 'number',
|
||||
label: 'Width',
|
||||
min: 0,
|
||||
actions: [{
|
||||
id: 'nativeSize',
|
||||
label: 'Native',
|
||||
tooltip: 'Set to texture native size',
|
||||
icon: 'Maximize2'
|
||||
}]
|
||||
})
|
||||
public width: number = 64;
|
||||
|
||||
/**
|
||||
* 精灵高度(像素)
|
||||
* Sprite height in pixels
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({
|
||||
type: 'number',
|
||||
label: 'Height',
|
||||
min: 0,
|
||||
actions: [{
|
||||
id: 'nativeSize',
|
||||
label: 'Native',
|
||||
tooltip: 'Set to texture native size',
|
||||
icon: 'Maximize2'
|
||||
}]
|
||||
})
|
||||
public height: number = 64;
|
||||
|
||||
/**
|
||||
* UV坐标 [u0, v0, u1, v1]
|
||||
* UV coordinates [u0, v0, u1, v1]
|
||||
* 默认为完整纹理 [0, 0, 1, 1]
|
||||
* Default is full texture [0, 0, 1, 1]
|
||||
*/
|
||||
@Serialize()
|
||||
public uv: [number, number, number, number] = [0, 0, 1, 1];
|
||||
|
||||
/** 颜色(十六进制)| Color (hex string) */
|
||||
@Serialize()
|
||||
@Property({ type: 'color', label: 'Color' })
|
||||
public color: string = '#ffffff';
|
||||
|
||||
/** 透明度 (0-1) | Alpha (0-1) */
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Alpha', min: 0, max: 1, step: 0.01 })
|
||||
public alpha: number = 1;
|
||||
|
||||
/**
|
||||
* 原点X (0-1, 0.5为中心)
|
||||
* Origin point X (0-1, where 0.5 is center)
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Origin X', min: 0, max: 1, step: 0.01 })
|
||||
public originX: number = 0.5;
|
||||
|
||||
/**
|
||||
* 原点Y (0-1, 0.5为中心)
|
||||
* Origin point Y (0-1, where 0.5 is center)
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Origin Y', min: 0, max: 1, step: 0.01 })
|
||||
public originY: number = 0.5;
|
||||
|
||||
/**
|
||||
* 精灵是否可见
|
||||
* Whether sprite is visible
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Visible' })
|
||||
public visible: boolean = true;
|
||||
|
||||
/** 是否水平翻转 | Flip sprite horizontally */
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Flip X' })
|
||||
public flipX: boolean = false;
|
||||
|
||||
/** 是否垂直翻转 | Flip sprite vertically */
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Flip Y' })
|
||||
public flipY: boolean = false;
|
||||
|
||||
/**
|
||||
* 渲染层级/顺序(越高越在上面)
|
||||
* Render layer/order (higher = rendered on top)
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'integer', label: 'Sorting Order' })
|
||||
public sortingOrder: number = 0;
|
||||
|
||||
/** 锚点X (0-1) - 别名为originX | Anchor X (0-1) - alias for originX */
|
||||
get anchorX(): number {
|
||||
return this.originX;
|
||||
}
|
||||
set anchorX(value: number) {
|
||||
this.originX = value;
|
||||
}
|
||||
|
||||
/** 锚点Y (0-1) - 别名为originY | Anchor Y (0-1) - alias for originY */
|
||||
get anchorY(): number {
|
||||
return this.originY;
|
||||
}
|
||||
set anchorY(value: number) {
|
||||
this.originY = value;
|
||||
}
|
||||
|
||||
constructor(texture: string = '') {
|
||||
super();
|
||||
this.texture = texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从精灵图集区域设置UV
|
||||
* Set UV from a sprite atlas region
|
||||
*
|
||||
* @param x - 区域X(像素)| Region X in pixels
|
||||
* @param y - 区域Y(像素)| Region Y in pixels
|
||||
* @param w - 区域宽度(像素)| Region width in pixels
|
||||
* @param h - 区域高度(像素)| Region height in pixels
|
||||
* @param atlasWidth - 图集总宽度 | Atlas total width
|
||||
* @param atlasHeight - 图集总高度 | Atlas total height
|
||||
*/
|
||||
setAtlasRegion(
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
atlasWidth: number,
|
||||
atlasHeight: number
|
||||
): void {
|
||||
this.uv = [
|
||||
x / atlasWidth,
|
||||
y / atlasHeight,
|
||||
(x + w) / atlasWidth,
|
||||
(y + h) / atlasHeight
|
||||
];
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置资产引用
|
||||
* Set asset reference
|
||||
*/
|
||||
setAssetReference(reference: AssetReference<HTMLImageElement>): void {
|
||||
// 释放旧引用 / Release old reference
|
||||
if (this._assetReference) {
|
||||
this._assetReference.release();
|
||||
}
|
||||
this._assetReference = reference;
|
||||
if (reference) {
|
||||
this.assetGuid = reference.guid;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资产引用
|
||||
* Get asset reference
|
||||
*/
|
||||
getAssetReference(): AssetReference<HTMLImageElement> | undefined {
|
||||
return this._assetReference;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步加载纹理
|
||||
* Load texture asynchronously
|
||||
*/
|
||||
async loadTextureAsync(): Promise<void> {
|
||||
if (this._assetReference) {
|
||||
try {
|
||||
const textureAsset = await this._assetReference.loadAsync();
|
||||
// 如果纹理资产有 textureId 属性,使用它
|
||||
// If texture asset has textureId property, use it
|
||||
if (textureAsset && typeof textureAsset === 'object' && 'textureId' in textureAsset) {
|
||||
this.textureId = (textureAsset as any).textureId;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load texture:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有效的纹理源
|
||||
* Get effective texture source
|
||||
*/
|
||||
getTextureSource(): string {
|
||||
return this.assetGuid || this.texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件销毁时调用
|
||||
* Called when component is destroyed
|
||||
*/
|
||||
onDestroy(): void {
|
||||
// 释放资产引用 / Release asset reference
|
||||
if (this._assetReference) {
|
||||
this._assetReference.release();
|
||||
this._assetReference = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
packages/components/src/TextComponent.ts
Normal file
46
packages/components/src/TextComponent.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 文本对齐方式
|
||||
*/
|
||||
export enum TextAlignment {
|
||||
Left = 'left',
|
||||
Center = 'center',
|
||||
Right = 'right'
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本组件 - 管理文本渲染
|
||||
*/
|
||||
@ECSComponent('Text')
|
||||
@Serializable({ version: 1, typeId: 'Text' })
|
||||
export class TextComponent extends Component {
|
||||
/** 文本内容 */
|
||||
@Serialize() public text: string = '';
|
||||
|
||||
/** 字体 */
|
||||
@Serialize() public font: string = 'Arial';
|
||||
|
||||
/** 字体大小 */
|
||||
@Serialize() public fontSize: number = 16;
|
||||
|
||||
/** 颜色 */
|
||||
@Serialize() public color: string = '#ffffff';
|
||||
|
||||
/** 对齐方式 */
|
||||
@Serialize() public alignment: TextAlignment = TextAlignment.Left;
|
||||
|
||||
/** 行高 */
|
||||
@Serialize() public lineHeight: number = 1.2;
|
||||
|
||||
/** 是否加粗 */
|
||||
@Serialize() public bold: boolean = false;
|
||||
|
||||
/** 是否斜体 */
|
||||
@Serialize() public italic: boolean = false;
|
||||
|
||||
constructor(text: string = '') {
|
||||
super();
|
||||
this.text = text;
|
||||
}
|
||||
}
|
||||
61
packages/components/src/TransformComponent.ts
Normal file
61
packages/components/src/TransformComponent.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 3D向量
|
||||
*/
|
||||
export interface Vector3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 变换组件 - 管理实体的位置、旋转和缩放
|
||||
*/
|
||||
@ECSComponent('Transform')
|
||||
@Serializable({ version: 1, typeId: 'Transform' })
|
||||
export class TransformComponent extends Component {
|
||||
/** 位置 */
|
||||
@Serialize()
|
||||
@Property({ type: 'vector3', label: 'Position' })
|
||||
public position: Vector3 = { x: 0, y: 0, z: 0 };
|
||||
|
||||
/** 旋转(欧拉角,度) */
|
||||
@Serialize()
|
||||
@Property({ type: 'vector3', label: 'Rotation' })
|
||||
public rotation: Vector3 = { x: 0, y: 0, z: 0 };
|
||||
|
||||
/** 缩放 */
|
||||
@Serialize()
|
||||
@Property({ type: 'vector3', label: 'Scale' })
|
||||
public scale: Vector3 = { x: 1, y: 1, z: 1 };
|
||||
|
||||
constructor(x: number = 0, y: number = 0, z: number = 0) {
|
||||
super();
|
||||
this.position = { x, y, z };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置位置
|
||||
*/
|
||||
public setPosition(x: number, y: number, z: number = 0): this {
|
||||
this.position = { x, y, z };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置旋转
|
||||
*/
|
||||
public setRotation(x: number, y: number, z: number): this {
|
||||
this.rotation = { x, y, z };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缩放
|
||||
*/
|
||||
public setScale(x: number, y: number, z: number = 1): this {
|
||||
this.scale = { x, y, z };
|
||||
return this;
|
||||
}
|
||||
}
|
||||
19
packages/components/src/index.ts
Normal file
19
packages/components/src/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// 变换
|
||||
export { TransformComponent, Vector3 } from './TransformComponent';
|
||||
|
||||
// 渲染
|
||||
export { SpriteComponent } from './SpriteComponent';
|
||||
export { SpriteAnimatorComponent, AnimationFrame, AnimationClip } from './SpriteAnimatorComponent';
|
||||
export { TextComponent, TextAlignment } from './TextComponent';
|
||||
export { CameraComponent, CameraProjection } from './CameraComponent';
|
||||
|
||||
// 系统
|
||||
export { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem';
|
||||
|
||||
// 物理
|
||||
export { RigidBodyComponent, BodyType } from './RigidBodyComponent';
|
||||
export { BoxColliderComponent } from './BoxColliderComponent';
|
||||
export { CircleColliderComponent } from './CircleColliderComponent';
|
||||
|
||||
// 音频
|
||||
export { AudioSourceComponent } from './AudioSourceComponent';
|
||||
86
packages/components/src/systems/SpriteAnimatorSystem.ts
Normal file
86
packages/components/src/systems/SpriteAnimatorSystem.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { EntitySystem, Matcher, ECSSystem, Time, Entity } from '@esengine/ecs-framework';
|
||||
import { SpriteAnimatorComponent } from '../SpriteAnimatorComponent';
|
||||
import { SpriteComponent } from '../SpriteComponent';
|
||||
|
||||
/**
|
||||
* 精灵动画系统 - 更新所有精灵动画
|
||||
* Sprite animator system - updates all sprite animations
|
||||
*/
|
||||
@ECSSystem('SpriteAnimator', { updateOrder: 50 })
|
||||
export class SpriteAnimatorSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(SpriteAnimatorComponent));
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统初始化时调用
|
||||
* Called when system is initialized
|
||||
*/
|
||||
protected override onInitialize(): void {
|
||||
// System initialized
|
||||
}
|
||||
|
||||
/**
|
||||
* 每帧开始时调用
|
||||
* Called at the beginning of each frame
|
||||
*/
|
||||
protected override onBegin(): void {
|
||||
// Frame begin
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理匹配的实体
|
||||
* Process matched entities
|
||||
*/
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
const deltaTime = Time.deltaTime;
|
||||
|
||||
for (const entity of entities) {
|
||||
if (!entity.enabled) continue;
|
||||
|
||||
const animator = entity.getComponent(SpriteAnimatorComponent) as SpriteAnimatorComponent | null;
|
||||
if (!animator) continue;
|
||||
|
||||
// Only call update if playing
|
||||
if (animator.isPlaying()) {
|
||||
animator.update(deltaTime);
|
||||
}
|
||||
|
||||
// Sync current frame to sprite component (always, even if not playing)
|
||||
const sprite = entity.getComponent(SpriteComponent) as SpriteComponent | null;
|
||||
if (sprite) {
|
||||
const frame = animator.getCurrentFrame();
|
||||
if (frame) {
|
||||
sprite.texture = frame.texture;
|
||||
|
||||
// Update UV if specified
|
||||
if (frame.uv) {
|
||||
sprite.uv = frame.uv;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体添加到系统时调用
|
||||
* Called when entity is added to system
|
||||
*/
|
||||
protected override onAdded(entity: Entity): void {
|
||||
const animator = entity.getComponent(SpriteAnimatorComponent) as SpriteAnimatorComponent | null;
|
||||
if (animator && animator.autoPlay && animator.defaultAnimation) {
|
||||
animator.play();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体从系统移除时调用
|
||||
* Called when entity is removed from system
|
||||
*/
|
||||
protected override onRemoved(entity: Entity): void {
|
||||
const animator = entity.getComponent(SpriteAnimatorComponent) as SpriteAnimatorComponent | null;
|
||||
if (animator) {
|
||||
animator.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"importHelpers": false,
|
||||
"importHelpers": true,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": false,
|
||||
"allowJs": true,
|
||||
@@ -48,4 +48,4 @@
|
||||
"path": "../core"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@
|
||||
"@rollup/plugin-commonjs": "^28.0.3",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.19.17",
|
||||
"@eslint/js": "^9.37.0",
|
||||
|
||||
@@ -255,7 +255,7 @@ export class SoAStorage<T extends Component> {
|
||||
for (const key of uint8ClampedFields) decoratedFields.set(key, 'uint8clamped');
|
||||
|
||||
// 只遍历实例自身的属性(不包括原型链),跳过 id
|
||||
const instanceKeys = Object.keys(instance).filter(key => key !== 'id');
|
||||
const instanceKeys = Object.keys(instance).filter((key) => key !== 'id');
|
||||
|
||||
for (const key of instanceKeys) {
|
||||
const value = (instance as Record<string, unknown>)[key];
|
||||
@@ -264,31 +264,31 @@ export class SoAStorage<T extends Component> {
|
||||
// 跳过函数(通常不会出现在 Object.keys 中,但以防万一)
|
||||
if (type === 'function') continue;
|
||||
|
||||
// 检查装饰器类型
|
||||
const decoratorType = decoratedFields.get(key);
|
||||
const effectiveType = decoratorType ? 'number' : type;
|
||||
this.fieldTypes.set(key, effectiveType);
|
||||
// 检查装饰器类型
|
||||
const decoratorType = decoratedFields.get(key);
|
||||
const effectiveType = decoratorType ? 'number' : type;
|
||||
this.fieldTypes.set(key, effectiveType);
|
||||
|
||||
if (decoratorType) {
|
||||
// 有装饰器标记的数字字段
|
||||
const ArrayConstructor = SoATypeRegistry.getConstructor(decoratorType as TypedArrayTypeName);
|
||||
this.fields.set(key, new ArrayConstructor(this._capacity));
|
||||
} else if (type === 'number') {
|
||||
// 无装饰器的数字字段,默认使用 Float32Array
|
||||
this.fields.set(key, new Float32Array(this._capacity));
|
||||
} else if (type === 'boolean') {
|
||||
// 布尔值使用 Uint8Array 存储为 0/1
|
||||
this.fields.set(key, new Uint8Array(this._capacity));
|
||||
} else if (type === 'string') {
|
||||
// 字符串专门处理
|
||||
this.stringFields.set(key, new Array(this._capacity));
|
||||
} else if (type === 'object' && value !== null) {
|
||||
// 处理集合类型
|
||||
if (this.serializeMapFields.has(key) || this.serializeSetFields.has(key) || this.serializeArrayFields.has(key)) {
|
||||
// 序列化存储
|
||||
this.serializedFields.set(key, new Array(this._capacity));
|
||||
}
|
||||
// 其他对象类型会在updateComponentAtIndex中作为复杂对象处理
|
||||
if (decoratorType) {
|
||||
// 有装饰器标记的数字字段
|
||||
const ArrayConstructor = SoATypeRegistry.getConstructor(decoratorType as TypedArrayTypeName);
|
||||
this.fields.set(key, new ArrayConstructor(this._capacity));
|
||||
} else if (type === 'number') {
|
||||
// 无装饰器的数字字段,默认使用 Float32Array
|
||||
this.fields.set(key, new Float32Array(this._capacity));
|
||||
} else if (type === 'boolean') {
|
||||
// 布尔值使用 Uint8Array 存储为 0/1
|
||||
this.fields.set(key, new Uint8Array(this._capacity));
|
||||
} else if (type === 'string') {
|
||||
// 字符串专门处理
|
||||
this.stringFields.set(key, new Array(this._capacity));
|
||||
} else if (type === 'object' && value !== null) {
|
||||
// 处理集合类型
|
||||
if (this.serializeMapFields.has(key) || this.serializeSetFields.has(key) || this.serializeArrayFields.has(key)) {
|
||||
// 序列化存储
|
||||
this.serializedFields.set(key, new Array(this._capacity));
|
||||
}
|
||||
// 其他对象类型会在updateComponentAtIndex中作为复杂对象处理
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,7 +515,7 @@ export class SoAStorage<T extends Component> {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
// entityId 是只读的
|
||||
writable: propStr !== 'entityId',
|
||||
writable: propStr !== 'entityId'
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
|
||||
94
packages/core/src/ECS/Decorators/PropertyDecorator.ts
Normal file
94
packages/core/src/ECS/Decorators/PropertyDecorator.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'enum' | 'asset' | 'animationClips';
|
||||
|
||||
/**
|
||||
* Action button configuration for property fields
|
||||
* 属性字段的操作按钮配置
|
||||
*/
|
||||
export interface PropertyAction {
|
||||
/** Action identifier | 操作标识符 */
|
||||
id: string;
|
||||
/** Button label | 按钮标签 */
|
||||
label: string;
|
||||
/** Button tooltip | 按钮提示 */
|
||||
tooltip?: string;
|
||||
/** Icon name from Lucide | Lucide图标名称 */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制关系声明
|
||||
* Control relationship declaration
|
||||
*/
|
||||
export interface PropertyControl {
|
||||
/** 被控制的组件名称 | Target component name */
|
||||
component: string;
|
||||
/** 被控制的属性名称 | Target property name */
|
||||
property: string;
|
||||
}
|
||||
|
||||
export interface PropertyOptions {
|
||||
/** 属性类型 */
|
||||
type: PropertyType;
|
||||
/** 显示标签 */
|
||||
label?: string;
|
||||
/** 最小值 (number/integer) */
|
||||
min?: number;
|
||||
/** 最大值 (number/integer) */
|
||||
max?: number;
|
||||
/** 步进值 (number/integer) */
|
||||
step?: number;
|
||||
/** 枚举选项 (enum) */
|
||||
options?: Array<{ label: string; value: any }>;
|
||||
/** 是否只读 */
|
||||
readOnly?: boolean;
|
||||
/** 资源文件扩展名 (asset) */
|
||||
fileExtension?: string;
|
||||
/** Action buttons for this property | 属性的操作按钮 */
|
||||
actions?: PropertyAction[];
|
||||
/** 此属性控制的其他组件属性 | Properties this field controls */
|
||||
controls?: PropertyControl[];
|
||||
}
|
||||
|
||||
export const PROPERTY_METADATA = Symbol('property:metadata');
|
||||
|
||||
/**
|
||||
* 属性装饰器 - 声明组件属性的编辑器元数据
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @ECSComponent('Transform')
|
||||
* export class TransformComponent extends Component {
|
||||
* @Property({ type: 'vector3', label: 'Position' })
|
||||
* public position: Vector3 = { x: 0, y: 0, z: 0 };
|
||||
*
|
||||
* @Property({ type: 'number', label: 'Speed', min: 0, max: 100 })
|
||||
* public speed: number = 10;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function Property(options: PropertyOptions): PropertyDecorator {
|
||||
return (target: object, propertyKey: string | symbol) => {
|
||||
const constructor = target.constructor;
|
||||
const existingMetadata = Reflect.getMetadata(PROPERTY_METADATA, constructor) || {};
|
||||
|
||||
existingMetadata[propertyKey as string] = options;
|
||||
|
||||
Reflect.defineMetadata(PROPERTY_METADATA, existingMetadata, constructor);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取组件类的所有属性元数据
|
||||
*/
|
||||
export function getPropertyMetadata(target: Function): Record<string, PropertyOptions> | undefined {
|
||||
return Reflect.getMetadata(PROPERTY_METADATA, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查组件类是否有属性元数据
|
||||
*/
|
||||
export function hasPropertyMetadata(target: Function): boolean {
|
||||
return Reflect.hasMetadata(PROPERTY_METADATA, target);
|
||||
}
|
||||
@@ -7,16 +7,30 @@ import { ComponentType } from '../../Types';
|
||||
*/
|
||||
export const COMPONENT_TYPE_NAME = Symbol('ComponentTypeName');
|
||||
|
||||
/**
|
||||
* 存储组件依赖的Symbol键
|
||||
*/
|
||||
export const COMPONENT_DEPENDENCIES = Symbol('ComponentDependencies');
|
||||
|
||||
/**
|
||||
* 存储系统类型名称的Symbol键
|
||||
*/
|
||||
export const SYSTEM_TYPE_NAME = Symbol('SystemTypeName');
|
||||
|
||||
/**
|
||||
* 组件装饰器配置选项
|
||||
*/
|
||||
export interface ComponentOptions {
|
||||
/** 依赖的其他组件名称列表 */
|
||||
requires?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件类型装饰器
|
||||
* 用于为组件类指定固定的类型名称,避免在代码混淆后失效
|
||||
*
|
||||
* @param typeName 组件类型名称
|
||||
* @param options 组件配置选项
|
||||
* @example
|
||||
* ```typescript
|
||||
* @ECSComponent('Position')
|
||||
@@ -24,9 +38,15 @@ export const SYSTEM_TYPE_NAME = Symbol('SystemTypeName');
|
||||
* x: number = 0;
|
||||
* y: number = 0;
|
||||
* }
|
||||
*
|
||||
* // 带依赖声明
|
||||
* @ECSComponent('SpriteAnimator', { requires: ['Sprite'] })
|
||||
* class SpriteAnimatorComponent extends Component {
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function ECSComponent(typeName: string) {
|
||||
export function ECSComponent(typeName: string, options?: ComponentOptions) {
|
||||
return function <T extends new (...args: any[]) => Component>(target: T): T {
|
||||
if (!typeName || typeof typeName !== 'string') {
|
||||
throw new Error('ECSComponent装饰器必须提供有效的类型名称');
|
||||
@@ -35,10 +55,22 @@ export function ECSComponent(typeName: string) {
|
||||
// 在构造函数上存储类型名称
|
||||
(target as any)[COMPONENT_TYPE_NAME] = typeName;
|
||||
|
||||
// 存储依赖关系
|
||||
if (options?.requires) {
|
||||
(target as any)[COMPONENT_DEPENDENCIES] = options.requires;
|
||||
}
|
||||
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取组件的依赖列表
|
||||
*/
|
||||
export function getComponentDependencies(componentType: ComponentType): string[] | undefined {
|
||||
return (componentType as any)[COMPONENT_DEPENDENCIES];
|
||||
}
|
||||
|
||||
/**
|
||||
* System元数据配置
|
||||
*/
|
||||
|
||||
@@ -6,11 +6,13 @@ export {
|
||||
getComponentInstanceTypeName,
|
||||
getSystemInstanceTypeName,
|
||||
getSystemMetadata,
|
||||
getComponentDependencies,
|
||||
COMPONENT_TYPE_NAME,
|
||||
COMPONENT_DEPENDENCIES,
|
||||
SYSTEM_TYPE_NAME
|
||||
} from './TypeDecorators';
|
||||
|
||||
export type { SystemMetadata } from './TypeDecorators';
|
||||
export type { SystemMetadata, ComponentOptions } from './TypeDecorators';
|
||||
|
||||
export {
|
||||
EntityRef,
|
||||
@@ -20,3 +22,12 @@ export {
|
||||
} from './EntityRefDecorator';
|
||||
|
||||
export type { EntityRefMetadata } from './EntityRefDecorator';
|
||||
|
||||
export {
|
||||
Property,
|
||||
getPropertyMetadata,
|
||||
hasPropertyMetadata,
|
||||
PROPERTY_METADATA
|
||||
} from './PropertyDecorator';
|
||||
|
||||
export type { PropertyOptions, PropertyType, PropertyControl } from './PropertyDecorator';
|
||||
|
||||
@@ -495,8 +495,6 @@ export interface ISceneDebugData {
|
||||
sceneEntityCount: number;
|
||||
/** 场景系统数 */
|
||||
sceneSystemCount: number;
|
||||
/** 场景内存使用量 */
|
||||
sceneMemory: number;
|
||||
/** 场景启动时间 */
|
||||
sceneUptime: number;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ export class SceneDataCollector {
|
||||
sceneRunTime: 0,
|
||||
sceneEntityCount: 0,
|
||||
sceneSystemCount: 0,
|
||||
sceneMemory: 0,
|
||||
sceneUptime: 0
|
||||
};
|
||||
}
|
||||
@@ -36,7 +35,6 @@ export class SceneDataCollector {
|
||||
sceneRunTime: runTime,
|
||||
sceneEntityCount: entityList?.buffer?.length || 0,
|
||||
sceneSystemCount: entityProcessors?.processors?.length || 0,
|
||||
sceneMemory: 0, // TODO: 计算实际场景内存
|
||||
sceneUptime: runTime
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Component } from '../../../src/ECS/Component';
|
||||
import { EntitySystem } from '../../../src/ECS/Systems/EntitySystem';
|
||||
import {
|
||||
ECSComponent,
|
||||
ECSSystem,
|
||||
getComponentTypeName,
|
||||
import {
|
||||
ECSComponent,
|
||||
ECSSystem,
|
||||
getComponentTypeName,
|
||||
getSystemTypeName,
|
||||
getComponentInstanceTypeName,
|
||||
getSystemInstanceTypeName
|
||||
getSystemInstanceTypeName,
|
||||
getComponentDependencies,
|
||||
Property,
|
||||
getPropertyMetadata,
|
||||
hasPropertyMetadata
|
||||
} from '../../../src/ECS/Decorators';
|
||||
|
||||
describe('TypeDecorators', () => {
|
||||
@@ -121,4 +125,106 @@ describe('TypeDecorators', () => {
|
||||
}).toThrow('ECSSystem装饰器必须提供有效的类型名称');
|
||||
});
|
||||
});
|
||||
|
||||
describe('组件依赖', () => {
|
||||
test('应该存储和获取组件依赖关系', () => {
|
||||
@ECSComponent('BaseComponent')
|
||||
class BaseComponent extends Component {}
|
||||
|
||||
@ECSComponent('DependentComponent', { requires: ['BaseComponent'] })
|
||||
class DependentComponent extends Component {}
|
||||
|
||||
const dependencies = getComponentDependencies(DependentComponent);
|
||||
expect(dependencies).toEqual(['BaseComponent']);
|
||||
});
|
||||
|
||||
test('没有依赖的组件应该返回undefined', () => {
|
||||
@ECSComponent('IndependentComponent')
|
||||
class IndependentComponent extends Component {}
|
||||
|
||||
const dependencies = getComponentDependencies(IndependentComponent);
|
||||
expect(dependencies).toBeUndefined();
|
||||
});
|
||||
|
||||
test('应该支持多个依赖', () => {
|
||||
@ECSComponent('MultiDependentComponent', { requires: ['ComponentA', 'ComponentB', 'ComponentC'] })
|
||||
class MultiDependentComponent extends Component {}
|
||||
|
||||
const dependencies = getComponentDependencies(MultiDependentComponent);
|
||||
expect(dependencies).toEqual(['ComponentA', 'ComponentB', 'ComponentC']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('@Property 装饰器', () => {
|
||||
test('应该为属性设置元数据', () => {
|
||||
@ECSComponent('PropertyTestComponent')
|
||||
class PropertyTestComponent extends Component {
|
||||
@Property({ type: 'number', label: 'Speed' })
|
||||
public speed: number = 10;
|
||||
}
|
||||
|
||||
const metadata = getPropertyMetadata(PropertyTestComponent);
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata!['speed']).toEqual({ type: 'number', label: 'Speed' });
|
||||
});
|
||||
|
||||
test('应该支持多个属性装饰器', () => {
|
||||
@ECSComponent('MultiPropertyComponent')
|
||||
class MultiPropertyComponent extends Component {
|
||||
@Property({ type: 'number', label: 'X Position' })
|
||||
public x: number = 0;
|
||||
|
||||
@Property({ type: 'number', label: 'Y Position' })
|
||||
public y: number = 0;
|
||||
|
||||
@Property({ type: 'string', label: 'Name' })
|
||||
public name: string = '';
|
||||
}
|
||||
|
||||
const metadata = getPropertyMetadata(MultiPropertyComponent);
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata!['x']).toEqual({ type: 'number', label: 'X Position' });
|
||||
expect(metadata!['y']).toEqual({ type: 'number', label: 'Y Position' });
|
||||
expect(metadata!['name']).toEqual({ type: 'string', label: 'Name' });
|
||||
});
|
||||
|
||||
test('hasPropertyMetadata 应该正确检测属性元数据', () => {
|
||||
@ECSComponent('HasMetadataComponent')
|
||||
class HasMetadataComponent extends Component {
|
||||
@Property({ type: 'boolean' })
|
||||
public active: boolean = true;
|
||||
}
|
||||
|
||||
@ECSComponent('NoMetadataComponent')
|
||||
class NoMetadataComponent extends Component {
|
||||
public value: number = 0;
|
||||
}
|
||||
|
||||
expect(hasPropertyMetadata(HasMetadataComponent)).toBe(true);
|
||||
expect(hasPropertyMetadata(NoMetadataComponent)).toBe(false);
|
||||
});
|
||||
|
||||
test('应该支持完整的属性选项', () => {
|
||||
@ECSComponent('FullOptionsComponent')
|
||||
class FullOptionsComponent extends Component {
|
||||
@Property({
|
||||
type: 'number',
|
||||
label: 'Health',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
})
|
||||
public health: number = 100;
|
||||
}
|
||||
|
||||
const metadata = getPropertyMetadata(FullOptionsComponent);
|
||||
expect(metadata!['health']).toEqual({
|
||||
type: 'number',
|
||||
label: 'Health',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,9 +37,11 @@
|
||||
"author": "ESEngine Team",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@esengine/ecs-framework": "file:../core"
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/ecs-components": "workspace:*",
|
||||
"@esengine/asset-system": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"optionalDependencies": {
|
||||
"es-engine": "file:../engine/pkg"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Sprite component for ECS entities.
|
||||
* 用于ECS实体的精灵组件。
|
||||
*/
|
||||
|
||||
import { Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* Sprite component data.
|
||||
* 精灵组件数据。
|
||||
*
|
||||
* Attach this component to entities that should be rendered as sprites.
|
||||
* 将此组件附加到应作为精灵渲染的实体。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const entity = scene.createEntity('player');
|
||||
* entity.addComponent(SpriteComponent);
|
||||
* const sprite = entity.getComponent(SpriteComponent);
|
||||
* sprite.textureId = 1;
|
||||
* sprite.width = 64;
|
||||
* sprite.height = 64;
|
||||
* ```
|
||||
*/
|
||||
@ECSComponent('Sprite')
|
||||
export class SpriteComponent extends Component {
|
||||
/**
|
||||
* Texture ID for this sprite.
|
||||
* 此精灵的纹理ID。
|
||||
*/
|
||||
textureId: number = 0;
|
||||
|
||||
/**
|
||||
* Sprite width in pixels.
|
||||
* 精灵宽度(像素)。
|
||||
*/
|
||||
width: number = 0;
|
||||
|
||||
/**
|
||||
* Sprite height in pixels.
|
||||
* 精灵高度(像素)。
|
||||
*/
|
||||
height: number = 0;
|
||||
|
||||
/**
|
||||
* UV coordinates [u0, v0, u1, v1].
|
||||
* UV坐标。
|
||||
* Default is full texture [0, 0, 1, 1].
|
||||
* 默认为完整纹理。
|
||||
*/
|
||||
uv: [number, number, number, number] = [0, 0, 1, 1];
|
||||
|
||||
/**
|
||||
* Packed RGBA color (0xAABBGGRR format for WebGL).
|
||||
* 打包的RGBA颜色。
|
||||
* Default is white (0xFFFFFFFF).
|
||||
* 默认为白色。
|
||||
*/
|
||||
color: number = 0xFFFFFFFF;
|
||||
|
||||
/**
|
||||
* Origin point X (0-1, where 0.5 is center).
|
||||
* 原点X(0-1,0.5为中心)。
|
||||
*/
|
||||
originX: number = 0.5;
|
||||
|
||||
/**
|
||||
* Origin point Y (0-1, where 0.5 is center).
|
||||
* 原点Y(0-1,0.5为中心)。
|
||||
*/
|
||||
originY: number = 0.5;
|
||||
|
||||
/**
|
||||
* Whether sprite is visible.
|
||||
* 精灵是否可见。
|
||||
*/
|
||||
visible: boolean = true;
|
||||
|
||||
/**
|
||||
* Render layer/order (higher = rendered on top).
|
||||
* 渲染层级/顺序(越高越在上面)。
|
||||
*/
|
||||
layer: number = 0;
|
||||
|
||||
/**
|
||||
* Flip sprite horizontally.
|
||||
* 水平翻转精灵。
|
||||
*/
|
||||
flipX: boolean = false;
|
||||
|
||||
/**
|
||||
* Flip sprite vertically.
|
||||
* 垂直翻转精灵。
|
||||
*/
|
||||
flipY: boolean = false;
|
||||
|
||||
/**
|
||||
* Set UV from a sprite atlas region.
|
||||
* 从精灵图集区域设置UV。
|
||||
*
|
||||
* @param x - Region X in pixels | 区域X(像素)
|
||||
* @param y - Region Y in pixels | 区域Y(像素)
|
||||
* @param w - Region width in pixels | 区域宽度(像素)
|
||||
* @param h - Region height in pixels | 区域高度(像素)
|
||||
* @param atlasWidth - Atlas total width | 图集总宽度
|
||||
* @param atlasHeight - Atlas total height | 图集总高度
|
||||
*/
|
||||
setAtlasRegion(
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
atlasWidth: number,
|
||||
atlasHeight: number
|
||||
): void {
|
||||
this.uv = [
|
||||
x / atlasWidth,
|
||||
y / atlasHeight,
|
||||
(x + w) / atlasWidth,
|
||||
(y + h) / atlasHeight
|
||||
];
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set color from RGBA values (0-255).
|
||||
* 从RGBA值设置颜色(0-255)。
|
||||
*
|
||||
* @param r - Red | 红色
|
||||
* @param g - Green | 绿色
|
||||
* @param b - Blue | 蓝色
|
||||
* @param a - Alpha | 透明度
|
||||
*/
|
||||
setColorRGBA(r: number, g: number, b: number, a: number = 255): void {
|
||||
this.color = ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set color from hex value (0xRRGGBB or 0xRRGGBBAA).
|
||||
* 从十六进制值设置颜色。
|
||||
*
|
||||
* @param hex - Hex color value | 十六进制颜色值
|
||||
*/
|
||||
setColorHex(hex: number): void {
|
||||
if (hex > 0xFFFFFF) {
|
||||
// 0xRRGGBBAA format
|
||||
const r = (hex >> 24) & 0xFF;
|
||||
const g = (hex >> 16) & 0xFF;
|
||||
const b = (hex >> 8) & 0xFF;
|
||||
const a = hex & 0xFF;
|
||||
this.color = (a << 24) | (b << 16) | (g << 8) | r;
|
||||
} else {
|
||||
// 0xRRGGBB format
|
||||
const r = (hex >> 16) & 0xFF;
|
||||
const g = (hex >> 8) & 0xFF;
|
||||
const b = hex & 0xFF;
|
||||
this.color = (0xFF << 24) | (b << 16) | (g << 8) | r;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
* TypeScript ECS与Rust引擎之间的主桥接层。
|
||||
*/
|
||||
|
||||
import type { SpriteRenderData, TextureLoadRequest, EngineStats } from '../types';
|
||||
import type { SpriteRenderData, TextureLoadRequest, EngineStats, CameraConfig } from '../types';
|
||||
import type { IEngineBridge } from '@esengine/asset-system';
|
||||
import type { GameEngine } from '../wasm/es_engine';
|
||||
|
||||
/**
|
||||
* Engine bridge configuration.
|
||||
@@ -41,11 +43,15 @@ export interface EngineBridgeConfig {
|
||||
* bridge.render();
|
||||
* ```
|
||||
*/
|
||||
export class EngineBridge {
|
||||
private engine: any; // GameEngine from WASM
|
||||
export class EngineBridge implements IEngineBridge {
|
||||
private engine: GameEngine | null = null;
|
||||
private config: Required<EngineBridgeConfig>;
|
||||
private initialized = false;
|
||||
|
||||
// Path resolver for converting file paths to URLs
|
||||
// 用于将文件路径转换为URL的路径解析器
|
||||
private pathResolver: ((path: string) => string) | null = null;
|
||||
|
||||
// Pre-allocated typed arrays for batch submission
|
||||
// 预分配的类型数组用于批量提交
|
||||
private transformBuffer: Float32Array;
|
||||
@@ -64,6 +70,7 @@ export class EngineBridge {
|
||||
private lastFrameTime = 0;
|
||||
private frameCount = 0;
|
||||
private fpsAccumulator = 0;
|
||||
private debugLogged = false;
|
||||
|
||||
/**
|
||||
* Create a new engine bridge.
|
||||
@@ -136,7 +143,7 @@ export class EngineBridge {
|
||||
|
||||
try {
|
||||
// Dynamic import of WASM module | 动态导入WASM模块
|
||||
const wasmModule = await import(/* webpackIgnore: true */ wasmPath);
|
||||
const wasmModule = await import(/* @vite-ignore */ wasmPath);
|
||||
await this.initializeWithModule(wasmModule);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to initialize engine: ${error} | 引擎初始化失败: ${error}`);
|
||||
@@ -167,6 +174,17 @@ export class EngineBridge {
|
||||
return this.engine?.height ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engine instance (throws if not initialized)
|
||||
* 获取引擎实例(未初始化时抛出异常)
|
||||
*/
|
||||
private getEngine(): GameEngine {
|
||||
if (!this.engine) {
|
||||
throw new Error('Engine not initialized. Call initialize() first.');
|
||||
}
|
||||
return this.engine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the screen.
|
||||
* 清除屏幕。
|
||||
@@ -178,7 +196,7 @@ export class EngineBridge {
|
||||
*/
|
||||
clear(r: number, g: number, b: number, a: number): void {
|
||||
if (!this.initialized) return;
|
||||
this.engine.clear(r, g, b, a);
|
||||
this.getEngine().clear(r, g, b, a);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,8 +238,15 @@ export class EngineBridge {
|
||||
this.colorBuffer[i] = sprite.color;
|
||||
}
|
||||
|
||||
// Debug: log texture IDs only once when we have 2+ sprites (for multi-texture test)
|
||||
if (!this.debugLogged && count >= 2) {
|
||||
const textureIds = Array.from(this.textureIdBuffer.subarray(0, count));
|
||||
console.log(`TS submitSprites: ${count} sprites, textureIds: [${textureIds.join(', ')}]`);
|
||||
this.debugLogged = true;
|
||||
}
|
||||
|
||||
// Submit to engine (single WASM call) | 提交到引擎(单次WASM调用)
|
||||
this.engine.submitSpriteBatch(
|
||||
this.getEngine().submitSpriteBatch(
|
||||
this.transformBuffer.subarray(0, count * 7),
|
||||
this.textureIdBuffer.subarray(0, count),
|
||||
this.uvBuffer.subarray(0, count * 4),
|
||||
@@ -239,7 +264,7 @@ export class EngineBridge {
|
||||
if (!this.initialized) return;
|
||||
|
||||
const startTime = performance.now();
|
||||
this.engine.render();
|
||||
this.getEngine().render();
|
||||
const endTime = performance.now();
|
||||
|
||||
// Update statistics | 更新统计信息
|
||||
@@ -265,9 +290,12 @@ export class EngineBridge {
|
||||
* @param id - Texture ID | 纹理ID
|
||||
* @param url - Image URL | 图片URL
|
||||
*/
|
||||
loadTexture(id: number, url: string): void {
|
||||
if (!this.initialized) return;
|
||||
this.engine.loadTexture(id, url);
|
||||
loadTexture(id: number, url: string): Promise<void> {
|
||||
if (!this.initialized) return Promise.resolve();
|
||||
this.getEngine().loadTexture(id, url);
|
||||
// Currently synchronous, but return Promise for interface compatibility
|
||||
// 目前是同步的,但返回Promise以兼容接口
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,12 +304,89 @@ export class EngineBridge {
|
||||
*
|
||||
* @param requests - Texture load requests | 纹理加载请求
|
||||
*/
|
||||
loadTextures(requests: TextureLoadRequest[]): void {
|
||||
async loadTextures(requests: Array<{ id: number; url: string }>): Promise<void> {
|
||||
for (const req of requests) {
|
||||
this.loadTexture(req.id, req.url);
|
||||
await this.loadTexture(req.id, req.url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load texture by path, returning texture ID.
|
||||
* 按路径加载纹理,返回纹理ID。
|
||||
*
|
||||
* @param path - Image path/URL | 图片路径/URL
|
||||
* @returns Texture ID | 纹理ID
|
||||
*/
|
||||
loadTextureByPath(path: string): number {
|
||||
if (!this.initialized) return 0;
|
||||
return this.getEngine().loadTextureByPath(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get texture ID by path.
|
||||
* 按路径获取纹理ID。
|
||||
*
|
||||
* @param path - Image path | 图片路径
|
||||
* @returns Texture ID or undefined | 纹理ID或undefined
|
||||
*/
|
||||
getTextureIdByPath(path: string): number | undefined {
|
||||
if (!this.initialized) return undefined;
|
||||
return this.getEngine().getTextureIdByPath(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set path resolver for converting file paths to URLs.
|
||||
* 设置路径解析器用于将文件路径转换为URL。
|
||||
*
|
||||
* @param resolver - Function to resolve paths | 解析路径的函数
|
||||
*/
|
||||
setPathResolver(resolver: (path: string) => string): void {
|
||||
this.pathResolver = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or load texture by path.
|
||||
* 按路径获取或加载纹理。
|
||||
*
|
||||
* @param path - Image path/URL | 图片路径/URL
|
||||
* @returns Texture ID | 纹理ID
|
||||
*/
|
||||
getOrLoadTextureByPath(path: string): number {
|
||||
if (!this.initialized) return 0;
|
||||
|
||||
// Resolve path if resolver is set
|
||||
// 如果设置了解析器,则解析路径
|
||||
const resolvedPath = this.pathResolver ? this.pathResolver(path) : path;
|
||||
return this.getEngine().getOrLoadTextureByPath(resolvedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload texture from GPU.
|
||||
* 从GPU卸载纹理。
|
||||
*
|
||||
* @param id - Texture ID | 纹理ID
|
||||
*/
|
||||
unloadTexture(id: number): void {
|
||||
if (!this.initialized) return;
|
||||
// TODO: Implement in Rust engine
|
||||
// TODO: 在Rust引擎中实现
|
||||
console.warn('unloadTexture not yet implemented in engine');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get texture information.
|
||||
* 获取纹理信息。
|
||||
*
|
||||
* @param id - Texture ID | 纹理ID
|
||||
*/
|
||||
getTextureInfo(id: number): { width: number; height: number } | null {
|
||||
if (!this.initialized) return null;
|
||||
// TODO: Implement in Rust engine
|
||||
// TODO: 在Rust引擎中实现
|
||||
// Return default values for now / 暂时返回默认值
|
||||
return { width: 64, height: 64 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key is pressed.
|
||||
* 检查按键是否按下。
|
||||
@@ -290,7 +395,7 @@ export class EngineBridge {
|
||||
*/
|
||||
isKeyDown(keyCode: string): boolean {
|
||||
if (!this.initialized) return false;
|
||||
return this.engine.isKeyDown(keyCode);
|
||||
return this.getEngine().isKeyDown(keyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,7 +404,7 @@ export class EngineBridge {
|
||||
*/
|
||||
updateInput(): void {
|
||||
if (!this.initialized) return;
|
||||
this.engine.updateInput();
|
||||
this.getEngine().updateInput();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,11 +424,212 @@ export class EngineBridge {
|
||||
*/
|
||||
resize(width: number, height: number): void {
|
||||
if (!this.initialized) return;
|
||||
if (this.engine.resize) {
|
||||
this.engine.resize(width, height);
|
||||
const engine = this.getEngine();
|
||||
if (engine.resize) {
|
||||
engine.resize(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera position, zoom, and rotation.
|
||||
* 设置相机位置、缩放和旋转。
|
||||
*
|
||||
* @param config - Camera configuration | 相机配置
|
||||
*/
|
||||
setCamera(config: CameraConfig): void {
|
||||
if (!this.initialized) return;
|
||||
this.getEngine().setCamera(config.x, config.y, config.zoom, config.rotation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get camera state.
|
||||
* 获取相机状态。
|
||||
*/
|
||||
getCamera(): CameraConfig {
|
||||
if (!this.initialized) {
|
||||
return { x: 0, y: 0, zoom: 1, rotation: 0 };
|
||||
}
|
||||
const state = this.getEngine().getCamera();
|
||||
return {
|
||||
x: state[0],
|
||||
y: state[1],
|
||||
zoom: state[2],
|
||||
rotation: state[3]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set grid visibility.
|
||||
* 设置网格可见性。
|
||||
*/
|
||||
setShowGrid(show: boolean): void {
|
||||
if (!this.initialized) return;
|
||||
this.getEngine().setShowGrid(show);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set clear color (background color).
|
||||
* 设置清除颜色(背景颜色)。
|
||||
*
|
||||
* @param r - Red component (0.0-1.0) | 红色分量
|
||||
* @param g - Green component (0.0-1.0) | 绿色分量
|
||||
* @param b - Blue component (0.0-1.0) | 蓝色分量
|
||||
* @param a - Alpha component (0.0-1.0) | 透明度分量
|
||||
*/
|
||||
setClearColor(r: number, g: number, b: number, a: number): void {
|
||||
if (!this.initialized) return;
|
||||
this.getEngine().setClearColor(r, g, b, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a rectangle gizmo outline.
|
||||
* 添加矩形Gizmo边框。
|
||||
*
|
||||
* @param x - Center X position | 中心X位置
|
||||
* @param y - Center Y position | 中心Y位置
|
||||
* @param width - Rectangle width | 矩形宽度
|
||||
* @param height - Rectangle height | 矩形高度
|
||||
* @param rotation - Rotation in radians | 旋转角度(弧度)
|
||||
* @param originX - Origin X (0-1) | 原点X (0-1)
|
||||
* @param originY - Origin Y (0-1) | 原点Y (0-1)
|
||||
* @param r - Red (0-1) | 红色
|
||||
* @param g - Green (0-1) | 绿色
|
||||
* @param b - Blue (0-1) | 蓝色
|
||||
* @param a - Alpha (0-1) | 透明度
|
||||
* @param showHandles - Whether to show transform handles | 是否显示变换手柄
|
||||
*/
|
||||
addGizmoRect(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
rotation: number,
|
||||
originX: number,
|
||||
originY: number,
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
a: number,
|
||||
showHandles: boolean = true
|
||||
): void {
|
||||
if (!this.initialized) return;
|
||||
this.getEngine().addGizmoRect(x, y, width, height, rotation, originX, originY, r, g, b, a, showHandles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set transform tool mode.
|
||||
* 设置变换工具模式。
|
||||
*
|
||||
* @param mode - 0=Select, 1=Move, 2=Rotate, 3=Scale
|
||||
*/
|
||||
setTransformMode(mode: number): void {
|
||||
if (!this.initialized) return;
|
||||
this.getEngine().setTransformMode(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set gizmo visibility.
|
||||
* 设置辅助工具可见性。
|
||||
*/
|
||||
setShowGizmos(show: boolean): void {
|
||||
if (!this.initialized) return;
|
||||
this.getEngine().setShowGizmos(show);
|
||||
}
|
||||
|
||||
// ===== Multi-viewport API =====
|
||||
// ===== 多视口 API =====
|
||||
|
||||
/**
|
||||
* Register a new viewport.
|
||||
* 注册新视口。
|
||||
*
|
||||
* @param id - Unique viewport identifier | 唯一视口标识符
|
||||
* @param canvasId - HTML canvas element ID | HTML canvas元素ID
|
||||
*/
|
||||
registerViewport(id: string, canvasId: string): void {
|
||||
if (!this.initialized) return;
|
||||
this.getEngine().registerViewport(id, canvasId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a viewport.
|
||||
* 注销视口。
|
||||
*/
|
||||
unregisterViewport(id: string): void {
|
||||
if (!this.initialized) return;
|
||||
this.getEngine().unregisterViewport(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active viewport.
|
||||
* 设置活动视口。
|
||||
*/
|
||||
setActiveViewport(id: string): boolean {
|
||||
if (!this.initialized) return false;
|
||||
return this.getEngine().setActiveViewport(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera for a specific viewport.
|
||||
* 为特定视口设置相机。
|
||||
*/
|
||||
setViewportCamera(viewportId: string, config: CameraConfig): void {
|
||||
if (!this.initialized) return;
|
||||
this.getEngine().setViewportCamera(viewportId, config.x, config.y, config.zoom, config.rotation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get camera for a specific viewport.
|
||||
* 获取特定视口的相机。
|
||||
*/
|
||||
getViewportCamera(viewportId: string): CameraConfig | null {
|
||||
if (!this.initialized) return null;
|
||||
const state = this.getEngine().getViewportCamera(viewportId);
|
||||
if (!state) return null;
|
||||
return {
|
||||
x: state[0],
|
||||
y: state[1],
|
||||
zoom: state[2],
|
||||
rotation: state[3]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set viewport configuration.
|
||||
* 设置视口配置。
|
||||
*/
|
||||
setViewportConfig(viewportId: string, showGrid: boolean, showGizmos: boolean): void {
|
||||
if (!this.initialized) return;
|
||||
this.getEngine().setViewportConfig(viewportId, showGrid, showGizmos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a specific viewport.
|
||||
* 调整特定视口大小。
|
||||
*/
|
||||
resizeViewport(viewportId: string, width: number, height: number): void {
|
||||
if (!this.initialized) return;
|
||||
this.getEngine().resizeViewport(viewportId, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render to a specific viewport.
|
||||
* 渲染到特定视口。
|
||||
*/
|
||||
renderToViewport(viewportId: string): void {
|
||||
if (!this.initialized) return;
|
||||
this.getEngine().renderToViewport(viewportId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered viewport IDs.
|
||||
* 获取所有已注册的视口ID。
|
||||
*/
|
||||
getViewportIds(): string[] {
|
||||
if (!this.initialized) return [];
|
||||
return this.getEngine().getViewportIds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the bridge and release resources.
|
||||
* 销毁桥接并释放资源。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import type { EngineBridge } from './EngineBridge';
|
||||
import { RenderBatcher } from './RenderBatcher';
|
||||
import { SpriteComponent } from '../components/SpriteComponent';
|
||||
import { SpriteComponent } from '@esengine/ecs-components';
|
||||
import type { SpriteRenderData } from '../types';
|
||||
|
||||
/**
|
||||
@@ -17,9 +17,9 @@ import type { SpriteRenderData } from '../types';
|
||||
* 你的变换组件应该实现此接口。
|
||||
*/
|
||||
export interface ITransformComponent {
|
||||
position: { x: number; y: number };
|
||||
rotation: number;
|
||||
scale: { x: number; y: number };
|
||||
position: { x: number; y: number; z?: number };
|
||||
rotation: number | { x: number; y: number; z: number };
|
||||
scale: { x: number; y: number; z?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,17 +94,25 @@ export class SpriteRenderHelper {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle rotation as number or Vector3 (use z for 2D)
|
||||
const rotation = typeof transform.rotation === 'number'
|
||||
? transform.rotation
|
||||
: transform.rotation.z;
|
||||
|
||||
// Convert hex color string to packed RGBA
|
||||
const color = this.hexToPackedColor(sprite.color, sprite.alpha);
|
||||
|
||||
const renderData: SpriteRenderData = {
|
||||
x: transform.position.x,
|
||||
y: transform.position.y,
|
||||
rotation: transform.rotation,
|
||||
rotation,
|
||||
scaleX: transform.scale.x,
|
||||
scaleY: transform.scale.y,
|
||||
originX: sprite.originX,
|
||||
originY: sprite.originY,
|
||||
textureId: sprite.textureId,
|
||||
uv,
|
||||
color: sprite.color
|
||||
color
|
||||
};
|
||||
|
||||
this.batcher.addSprite(renderData);
|
||||
@@ -137,4 +145,26 @@ export class SpriteRenderHelper {
|
||||
clear(): void {
|
||||
this.batcher.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color string to packed RGBA.
|
||||
* 将十六进制颜色字符串转换为打包的RGBA。
|
||||
*/
|
||||
private hexToPackedColor(hex: string, alpha: number): number {
|
||||
let r = 255, g = 255, b = 255;
|
||||
if (hex.startsWith('#')) {
|
||||
const hexValue = hex.slice(1);
|
||||
if (hexValue.length === 3) {
|
||||
r = parseInt(hexValue[0] + hexValue[0], 16);
|
||||
g = parseInt(hexValue[1] + hexValue[1], 16);
|
||||
b = parseInt(hexValue[2] + hexValue[2], 16);
|
||||
} else if (hexValue.length === 6) {
|
||||
r = parseInt(hexValue.slice(0, 2), 16);
|
||||
g = parseInt(hexValue.slice(2, 4), 16);
|
||||
b = parseInt(hexValue.slice(4, 6), 16);
|
||||
}
|
||||
}
|
||||
const a = Math.round(alpha * 255);
|
||||
return ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@ export { EngineBridge, EngineBridgeConfig } from './core/EngineBridge';
|
||||
export { RenderBatcher } from './core/RenderBatcher';
|
||||
export { SpriteRenderHelper, ITransformComponent } from './core/SpriteRenderHelper';
|
||||
export { EngineRenderSystem, type TransformComponentType } from './systems/EngineRenderSystem';
|
||||
export { SpriteComponent } from './components/SpriteComponent';
|
||||
export { CameraSystem } from './systems/CameraSystem';
|
||||
export * from './types';
|
||||
|
||||
52
packages/ecs-engine-bindgen/src/systems/CameraSystem.ts
Normal file
52
packages/ecs-engine-bindgen/src/systems/CameraSystem.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Camera System
|
||||
* 相机系统
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { CameraComponent } from '@esengine/ecs-components';
|
||||
import type { EngineBridge } from '../core/EngineBridge';
|
||||
|
||||
@ECSSystem('Camera', { updateOrder: -100 })
|
||||
export class CameraSystem extends EntitySystem {
|
||||
private bridge: EngineBridge;
|
||||
private lastAppliedCameraId: number | null = null;
|
||||
|
||||
constructor(bridge: EngineBridge) {
|
||||
// Match entities with CameraComponent
|
||||
super(Matcher.empty().all(CameraComponent));
|
||||
this.bridge = bridge;
|
||||
}
|
||||
|
||||
protected override onBegin(): void {
|
||||
// Will process cameras in process()
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
// Use first enabled camera
|
||||
for (const entity of entities) {
|
||||
if (!entity.enabled) continue;
|
||||
|
||||
const camera = entity.getComponent(CameraComponent);
|
||||
if (!camera) continue;
|
||||
|
||||
// Only apply if camera changed
|
||||
if (this.lastAppliedCameraId !== entity.id) {
|
||||
this.applyCamera(camera);
|
||||
this.lastAppliedCameraId = entity.id;
|
||||
}
|
||||
|
||||
// Only use first active camera
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private applyCamera(camera: CameraComponent): void {
|
||||
// Apply background color
|
||||
const bgColor = camera.backgroundColor || '#000000';
|
||||
const r = parseInt(bgColor.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(bgColor.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(bgColor.slice(5, 7), 16) / 255;
|
||||
this.bridge.setClearColor(r, g, b, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@
|
||||
* 用于ECS的引擎渲染系统。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ComponentType, ECSSystem, Component } from '@esengine/ecs-framework';
|
||||
import { EntitySystem, Matcher, Entity, ComponentType, ECSSystem, Component, Core } from '@esengine/ecs-framework';
|
||||
import { SpriteComponent, CameraComponent, TransformComponent } from '@esengine/ecs-components';
|
||||
import type { EngineBridge } from '../core/EngineBridge';
|
||||
import { RenderBatcher } from '../core/RenderBatcher';
|
||||
import { SpriteComponent } from '../components/SpriteComponent';
|
||||
import type { SpriteRenderData } from '../types';
|
||||
import type { ITransformComponent } from '../core/SpriteRenderHelper';
|
||||
|
||||
@@ -47,6 +47,13 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
private bridge: EngineBridge;
|
||||
private batcher: RenderBatcher;
|
||||
private transformType: TransformComponentType;
|
||||
private showGizmos = true;
|
||||
private selectedEntityIds: Set<number> = new Set();
|
||||
private transformMode: 'select' | 'move' | 'rotate' | 'scale' = 'select';
|
||||
|
||||
// Reusable map to avoid allocation per frame
|
||||
// 可重用的映射以避免每帧分配
|
||||
private entityRenderMap: Map<number, SpriteRenderData> = new Map();
|
||||
|
||||
/**
|
||||
* Create a new engine render system.
|
||||
@@ -78,12 +85,13 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
* Called before processing entities.
|
||||
* 处理实体之前调用。
|
||||
*/
|
||||
protected begin(): void {
|
||||
protected override onBegin(): void {
|
||||
|
||||
// Clear the batch | 清空批处理
|
||||
this.batcher.clear();
|
||||
|
||||
// Clear screen | 清屏
|
||||
this.bridge.clear(0, 0, 0, 1);
|
||||
// Clear screen with dark background | 用深色背景清屏
|
||||
this.bridge.clear(0.1, 0.1, 0.12, 1);
|
||||
|
||||
// Update input | 更新输入
|
||||
this.bridge.updateInput();
|
||||
@@ -95,19 +103,22 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
*
|
||||
* @param entities - Entities to process | 要处理的实体
|
||||
*/
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
// Clear and reuse map for gizmo drawing
|
||||
// 清空并重用映射用于绘制gizmo
|
||||
this.entityRenderMap.clear();
|
||||
|
||||
for (const entity of entities) {
|
||||
const sprite = entity.getComponent(SpriteComponent);
|
||||
const transform = entity.getComponent(this.transformType) as unknown as ITransformComponent | null;
|
||||
|
||||
if (!sprite || !transform || !sprite.visible) {
|
||||
if (!sprite || !transform) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate UV with flip | 计算带翻转的UV
|
||||
let uv = sprite.uv;
|
||||
const uv: [number, number, number, number] = [0, 0, 1, 1];
|
||||
if (sprite.flipX || sprite.flipY) {
|
||||
uv = [...sprite.uv] as [number, number, number, number];
|
||||
if (sprite.flipX) {
|
||||
[uv[0], uv[2]] = [uv[2], uv[0]];
|
||||
}
|
||||
@@ -116,35 +127,213 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle rotation as number or Vector3 (use z for 2D)
|
||||
const rotation = typeof transform.rotation === 'number'
|
||||
? transform.rotation
|
||||
: transform.rotation.z;
|
||||
|
||||
// Convert hex color string to packed RGBA | 将十六进制颜色字符串转换为打包的RGBA
|
||||
const color = this.hexToPackedColor(sprite.color, sprite.alpha);
|
||||
|
||||
// Get texture ID from sprite component
|
||||
// 从精灵组件获取纹理ID
|
||||
// Use Rust engine's path-based texture loading for automatic caching
|
||||
// 使用Rust引擎的基于路径的纹理加载实现自动缓存
|
||||
let textureId = 0;
|
||||
if (sprite.texture) {
|
||||
textureId = this.bridge.getOrLoadTextureByPath(sprite.texture);
|
||||
} else {
|
||||
// Debug: sprite has no texture
|
||||
console.warn(`[EngineRenderSystem] Entity ${entity.id} has no texture`);
|
||||
}
|
||||
|
||||
// Pass actual display dimensions (sprite size * transform scale)
|
||||
// 传递实际显示尺寸(sprite尺寸 * 变换缩放)
|
||||
const renderData: SpriteRenderData = {
|
||||
x: transform.position.x,
|
||||
y: transform.position.y,
|
||||
rotation: transform.rotation,
|
||||
scaleX: transform.scale.x,
|
||||
scaleY: transform.scale.y,
|
||||
originX: sprite.originX,
|
||||
originY: sprite.originY,
|
||||
textureId: sprite.textureId,
|
||||
rotation,
|
||||
scaleX: sprite.width * transform.scale.x,
|
||||
scaleY: sprite.height * transform.scale.y,
|
||||
originX: sprite.anchorX,
|
||||
originY: sprite.anchorY,
|
||||
textureId,
|
||||
uv,
|
||||
color: sprite.color
|
||||
color
|
||||
};
|
||||
|
||||
this.batcher.addSprite(renderData);
|
||||
this.entityRenderMap.set(entity.id, renderData);
|
||||
}
|
||||
|
||||
// Submit batch and render at the end of process | 在process结束时提交批处理并渲染
|
||||
if (!this.batcher.isEmpty) {
|
||||
const sprites = this.batcher.getSprites();
|
||||
this.bridge.submitSprites(sprites);
|
||||
}
|
||||
|
||||
// Draw gizmos for selected entities (always, even if no sprites)
|
||||
// 为选中的实体绘制Gizmo(始终绘制,即使没有精灵)
|
||||
if (this.showGizmos && this.selectedEntityIds.size > 0) {
|
||||
for (const entityId of this.selectedEntityIds) {
|
||||
const renderData = this.entityRenderMap.get(entityId);
|
||||
if (renderData) {
|
||||
this.bridge.addGizmoRect(
|
||||
renderData.x,
|
||||
renderData.y,
|
||||
renderData.scaleX,
|
||||
renderData.scaleY,
|
||||
renderData.rotation,
|
||||
renderData.originX,
|
||||
renderData.originY,
|
||||
0.0, 1.0, 0.5, 1.0, // Green color | 绿色
|
||||
true // Show transform handles for selection gizmo
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw camera frustum gizmos
|
||||
// 绘制相机视锥体 gizmo
|
||||
if (this.showGizmos) {
|
||||
this.drawCameraFrustums();
|
||||
}
|
||||
|
||||
this.bridge.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw camera frustum gizmos for all cameras in scene.
|
||||
* 为场景中所有相机绘制视锥体 gizmo。
|
||||
*/
|
||||
private drawCameraFrustums(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const cameraEntities = scene.entities.findEntitiesWithComponent(CameraComponent);
|
||||
|
||||
for (const entity of cameraEntities) {
|
||||
const camera = entity.getComponent(CameraComponent);
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
|
||||
if (!camera || !transform) continue;
|
||||
|
||||
// Calculate frustum size based on canvas size and orthographicSize
|
||||
// 根据 canvas 尺寸和 orthographicSize 计算视锥体大小
|
||||
// At runtime, zoom = 1 / orthographicSize
|
||||
// So visible area = canvas size * orthographicSize
|
||||
const canvas = document.getElementById('viewport-canvas') as HTMLCanvasElement;
|
||||
if (!canvas) continue;
|
||||
|
||||
// The actual visible world units when running
|
||||
// 运行时实际可见的世界单位
|
||||
const zoom = camera.orthographicSize > 0 ? 1 / camera.orthographicSize : 1;
|
||||
const width = canvas.width / zoom;
|
||||
const height = canvas.height / zoom;
|
||||
|
||||
// Handle rotation
|
||||
const rotation = typeof transform.rotation === 'number'
|
||||
? transform.rotation
|
||||
: transform.rotation.z;
|
||||
|
||||
// Draw frustum rectangle (white color for camera)
|
||||
// 绘制视锥体矩形(相机用白色)
|
||||
this.bridge.addGizmoRect(
|
||||
transform.position.x,
|
||||
transform.position.y,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
0.5, // origin center
|
||||
0.5,
|
||||
1.0, 1.0, 1.0, 0.8, // White color with some transparency
|
||||
false // Don't show transform handles for camera frustum
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after processing entities.
|
||||
* 处理实体之后调用。
|
||||
* Set gizmo visibility.
|
||||
* 设置Gizmo可见性。
|
||||
*/
|
||||
protected end(): void {
|
||||
// Submit batch and render | 提交批处理并渲染
|
||||
if (!this.batcher.isEmpty) {
|
||||
this.bridge.submitSprites(this.batcher.getSprites());
|
||||
}
|
||||
this.bridge.render();
|
||||
setShowGizmos(show: boolean): void {
|
||||
this.showGizmos = show;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gizmo visibility.
|
||||
* 获取Gizmo可见性。
|
||||
*/
|
||||
getShowGizmos(): boolean {
|
||||
return this.showGizmos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set selected entity IDs.
|
||||
* 设置选中的实体ID。
|
||||
*/
|
||||
setSelectedEntityIds(ids: number[]): void {
|
||||
this.selectedEntityIds = new Set(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected entity IDs.
|
||||
* 获取选中的实体ID。
|
||||
*/
|
||||
getSelectedEntityIds(): number[] {
|
||||
return Array.from(this.selectedEntityIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set transform tool mode.
|
||||
* 设置变换工具模式。
|
||||
*/
|
||||
setTransformMode(mode: 'select' | 'move' | 'rotate' | 'scale'): void {
|
||||
this.transformMode = mode;
|
||||
|
||||
// Convert string mode to u8 for Rust engine
|
||||
const modeMap: Record<string, number> = {
|
||||
'select': 0,
|
||||
'move': 1,
|
||||
'rotate': 2,
|
||||
'scale': 3
|
||||
};
|
||||
this.bridge.setTransformMode(modeMap[mode]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transform tool mode.
|
||||
* 获取变换工具模式。
|
||||
*/
|
||||
getTransformMode(): 'select' | 'move' | 'rotate' | 'scale' {
|
||||
return this.transformMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color string to packed RGBA.
|
||||
* 将十六进制颜色字符串转换为打包的RGBA。
|
||||
*/
|
||||
private hexToPackedColor(hex: string, alpha: number): number {
|
||||
// Parse hex color like "#ffffff" or "#fff"
|
||||
let r = 255, g = 255, b = 255;
|
||||
if (hex.startsWith('#')) {
|
||||
const hexValue = hex.slice(1);
|
||||
if (hexValue.length === 3) {
|
||||
r = parseInt(hexValue[0] + hexValue[0], 16);
|
||||
g = parseInt(hexValue[1] + hexValue[1], 16);
|
||||
b = parseInt(hexValue[2] + hexValue[2], 16);
|
||||
} else if (hexValue.length === 6) {
|
||||
r = parseInt(hexValue.slice(0, 2), 16);
|
||||
g = parseInt(hexValue.slice(2, 4), 16);
|
||||
b = parseInt(hexValue.slice(4, 6), 16);
|
||||
}
|
||||
}
|
||||
const a = Math.round(alpha * 255);
|
||||
// Pack as 0xAABBGGRR for WebGL
|
||||
return ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the number of sprites rendered.
|
||||
* 获取渲染的精灵数量。
|
||||
|
||||
@@ -11,20 +11,23 @@
|
||||
"tauri": "tauri",
|
||||
"kill-dev": "node scripts/kill-dev-server.js",
|
||||
"tauri:dev": "npm run kill-dev && tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"bundle:runtime": "node scripts/bundle-runtime.mjs",
|
||||
"tauri:build": "npm run bundle:runtime && tauri build",
|
||||
"version": "node scripts/sync-version.js && git add src-tauri/tauri.conf.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/behavior-tree": "file:../behavior-tree",
|
||||
"@esengine/ecs-engine-bindgen": "file:../ecs-engine-bindgen",
|
||||
"@esengine/ecs-framework": "file:../core",
|
||||
"@esengine/editor-core": "file:../editor-core",
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/behavior-tree": "workspace:*",
|
||||
"@esengine/ecs-engine-bindgen": "workspace:*",
|
||||
"@esengine/ecs-components": "workspace:*",
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.4",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@esengine/engine": "file:../engine",
|
||||
"@esengine/engine": "workspace:*",
|
||||
"flexlayout-react": "^0.8.17",
|
||||
"i18next": "^25.6.0",
|
||||
"json5": "^2.2.3",
|
||||
|
||||
27
packages/editor-app/runtime.config.json
Normal file
27
packages/editor-app/runtime.config.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"runtime": {
|
||||
"version": "1.0.0",
|
||||
"modules": {
|
||||
"platform-web": {
|
||||
"type": "javascript",
|
||||
"main": "runtime.browser.js",
|
||||
"development": {
|
||||
"path": "../platform-web/dist"
|
||||
},
|
||||
"production": {
|
||||
"bundled": true
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
"type": "wasm",
|
||||
"files": ["es_engine_bg.wasm", "es_engine.js"],
|
||||
"development": {
|
||||
"path": "../engine/pkg"
|
||||
},
|
||||
"production": {
|
||||
"bundled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
packages/editor-app/scripts/bundle-runtime.mjs
Normal file
86
packages/editor-app/scripts/bundle-runtime.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Bundle runtime files for production build
|
||||
* 为生产构建打包运行时文件
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const editorPath = path.resolve(__dirname, '..');
|
||||
const rootPath = path.resolve(editorPath, '../..');
|
||||
const bundleDir = path.join(editorPath, 'src-tauri', 'runtime');
|
||||
|
||||
// Create bundle directory
|
||||
if (!fs.existsSync(bundleDir)) {
|
||||
fs.mkdirSync(bundleDir, { recursive: true });
|
||||
console.log(`Created bundle directory: ${bundleDir}`);
|
||||
}
|
||||
|
||||
// Files to bundle
|
||||
const filesToBundle = [
|
||||
{
|
||||
src: path.join(rootPath, 'packages/platform-web/dist/runtime.browser.js'),
|
||||
dst: path.join(bundleDir, 'runtime.browser.js')
|
||||
},
|
||||
{
|
||||
src: path.join(rootPath, 'packages/engine/pkg/es_engine_bg.wasm'),
|
||||
dst: path.join(bundleDir, 'es_engine_bg.wasm')
|
||||
},
|
||||
{
|
||||
src: path.join(rootPath, 'packages/engine/pkg/es_engine.js'),
|
||||
dst: path.join(bundleDir, 'es_engine.js')
|
||||
}
|
||||
];
|
||||
|
||||
// Copy files
|
||||
let success = true;
|
||||
for (const { src, dst } of filesToBundle) {
|
||||
try {
|
||||
if (!fs.existsSync(src)) {
|
||||
console.error(`Source file not found: ${src}`);
|
||||
console.log('Please build the runtime modules first:');
|
||||
console.log(' npm run build --workspace=@esengine/platform-web');
|
||||
console.log(' cd packages/engine && wasm-pack build --target web');
|
||||
success = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.copyFileSync(src, dst);
|
||||
const stats = fs.statSync(dst);
|
||||
console.log(`✓ Bundled ${path.basename(dst)} (${(stats.size / 1024).toFixed(2)} KB)`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to bundle ${path.basename(src)}: ${error.message}`);
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update tauri.conf.json to include runtime directory
|
||||
if (success) {
|
||||
const tauriConfigPath = path.join(editorPath, 'src-tauri', 'tauri.conf.json');
|
||||
const config = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8'));
|
||||
|
||||
// Add runtime directory to resources
|
||||
if (!config.bundle) {
|
||||
config.bundle = {};
|
||||
}
|
||||
if (!config.bundle.resources) {
|
||||
config.bundle.resources = [];
|
||||
}
|
||||
if (!config.bundle.resources.includes('runtime/**/*')) {
|
||||
config.bundle.resources.push('runtime/**/*');
|
||||
fs.writeFileSync(tauriConfigPath, JSON.stringify(config, null, 2));
|
||||
console.log('✓ Updated tauri.conf.json with runtime resources');
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
console.error('Runtime bundling failed');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Runtime files bundled successfully!');
|
||||
684
packages/editor-app/src-tauri/Cargo.lock
generated
684
packages/editor-app/src-tauri/Cargo.lock
generated
@@ -28,6 +28,24 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aligned"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "377e4c0ba83e4431b10df45c1d4666f178ea9c552cac93e60c3a88bf32785923"
|
||||
dependencies = [
|
||||
"as-slice",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aligned-vec"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
|
||||
dependencies = [
|
||||
"equator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
@@ -67,6 +85,38 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arg_enum_proc_macro"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "as-slice"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.0"
|
||||
@@ -157,6 +207,49 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "av-scenechange"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
|
||||
dependencies = [
|
||||
"aligned",
|
||||
"anyhow",
|
||||
"arg_enum_proc_macro",
|
||||
"arrayvec",
|
||||
"log",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"pastey",
|
||||
"rayon",
|
||||
"thiserror 2.0.17",
|
||||
"v_frame",
|
||||
"y4m",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "av1-grain"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"log",
|
||||
"nom",
|
||||
"num-rational",
|
||||
"v_frame",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "avif-serialize"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -175,6 +268,12 @@ version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -190,6 +289,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitstream-io"
|
||||
version = "4.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757"
|
||||
dependencies = [
|
||||
"core2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -238,6 +346,12 @@ dependencies = [
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "built"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.0"
|
||||
@@ -256,6 +370,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.10.1"
|
||||
@@ -417,6 +537,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chunked_transfer"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -427,6 +553,12 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -537,6 +669,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core2"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -564,12 +705,37 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
@@ -850,6 +1016,9 @@ dependencies = [
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"glob",
|
||||
"image",
|
||||
"once_cell",
|
||||
"qrcode",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -859,11 +1028,19 @@ dependencies = [
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-updater",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"urlencoding",
|
||||
"zip 0.6.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
@@ -920,6 +1097,26 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
|
||||
dependencies = [
|
||||
"equator-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equator-macro"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -968,12 +1165,47 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exr"
|
||||
version = "1.74.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
|
||||
dependencies = [
|
||||
"bit_field",
|
||||
"half",
|
||||
"lebe",
|
||||
"miniz_oxide",
|
||||
"rayon-core",
|
||||
"smallvec",
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
|
||||
dependencies = [
|
||||
"fax_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fax_derive"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -1314,6 +1546,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f954a9e9159ec994f73a30a12b96a702dde78f5547bcb561174597924f7d4162"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
@@ -1481,6 +1723,17 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1578,6 +1831,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.7.0"
|
||||
@@ -1674,7 +1933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1790,6 +2049,46 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"exr",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.0",
|
||||
"qoi",
|
||||
"ravif",
|
||||
"rayon",
|
||||
"rgb",
|
||||
"tiff",
|
||||
"zune-core 0.5.0",
|
||||
"zune-jpeg 0.5.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imgref"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -1831,6 +2130,17 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interpolate_name"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -1866,6 +2176,15 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
@@ -1988,6 +2307,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "lebe"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator"
|
||||
version = "0.9.0"
|
||||
@@ -2018,6 +2343,16 @@ version = "0.2.177"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.7.4"
|
||||
@@ -2072,6 +2407,15 @@ version = "0.4.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
|
||||
[[package]]
|
||||
name = "loop9"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
|
||||
dependencies = [
|
||||
"imgref",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -2115,6 +2459,16 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
@@ -2163,6 +2517,16 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
@@ -2178,7 +2542,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.17",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -2239,12 +2603,68 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "noop_proc_macro"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2675,6 +3095,18 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -2877,6 +3309,19 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.3"
|
||||
@@ -2975,6 +3420,25 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "profiling"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
|
||||
dependencies = [
|
||||
"profiling-procmacros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "profiling-procmacros"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
@@ -2991,6 +3455,39 @@ dependencies = [
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||
dependencies = [
|
||||
"image",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
@@ -3189,12 +3686,82 @@ dependencies = [
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rav1e"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
|
||||
dependencies = [
|
||||
"aligned-vec",
|
||||
"arbitrary",
|
||||
"arg_enum_proc_macro",
|
||||
"arrayvec",
|
||||
"av-scenechange",
|
||||
"av1-grain",
|
||||
"bitstream-io",
|
||||
"built",
|
||||
"cfg-if",
|
||||
"interpolate_name",
|
||||
"itertools",
|
||||
"libc",
|
||||
"libfuzzer-sys",
|
||||
"log",
|
||||
"maybe-rayon",
|
||||
"new_debug_unreachable",
|
||||
"noop_proc_macro",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"profiling",
|
||||
"rand 0.9.2",
|
||||
"rand_chacha 0.9.0",
|
||||
"simd_helpers",
|
||||
"thiserror 2.0.17",
|
||||
"v_frame",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ravif"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"imgref",
|
||||
"loop9",
|
||||
"quick-error",
|
||||
"rav1e",
|
||||
"rayon",
|
||||
"rgb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -3335,6 +3902,12 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rgb"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3769,6 +4342,15 @@ version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
|
||||
|
||||
[[package]]
|
||||
name = "simd_helpers"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
@@ -4141,7 +4723,7 @@ dependencies = [
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
@@ -4469,6 +5051,20 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.44"
|
||||
@@ -4500,6 +5096,18 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny_http"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||
dependencies = [
|
||||
"ascii",
|
||||
"chunked_transfer",
|
||||
"httpdate",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.1"
|
||||
@@ -4777,7 +5385,7 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.2",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.17",
|
||||
"windows-sys 0.59.0",
|
||||
@@ -4902,6 +5510,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
@@ -4938,6 +5552,17 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "v_frame"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
|
||||
dependencies = [
|
||||
"aligned-vec",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.0"
|
||||
@@ -5273,6 +5898,12 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -5834,6 +6465,12 @@ dependencies = [
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "y4m"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.0"
|
||||
@@ -6054,6 +6691,45 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773"
|
||||
|
||||
[[package]]
|
||||
name = "zune-inflate"
|
||||
version = "0.2.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.4.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
||||
dependencies = [
|
||||
"zune-core 0.4.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fb7703e32e9a07fb3f757360338b3a567a5054f21b5f52a666752e333d58e"
|
||||
dependencies = [
|
||||
"zune-core 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.7.0"
|
||||
|
||||
@@ -28,6 +28,11 @@ futures-util = "0.3"
|
||||
chrono = "0.4"
|
||||
zip = "0.6"
|
||||
base64 = "0.22"
|
||||
tiny_http = "0.12"
|
||||
once_cell = "1.19"
|
||||
urlencoding = "2.1"
|
||||
qrcode = "0.14"
|
||||
image = "0.25"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true
|
||||
|
||||
@@ -203,3 +203,20 @@ pub fn read_file_as_base64(file_path: String) -> Result<String, String> {
|
||||
|
||||
Ok(general_purpose::STANDARD.encode(&file_content))
|
||||
}
|
||||
|
||||
/// Copy file from source to destination
|
||||
#[tauri::command]
|
||||
pub fn copy_file(src: String, dst: String) -> Result<(), String> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = Path::new(&dst).parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
}
|
||||
}
|
||||
|
||||
fs::copy(&src, &dst)
|
||||
.map_err(|e| format!("Failed to copy file {} to {}: {}", src, dst, e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,8 +2,21 @@
|
||||
//!
|
||||
//! OS-level operations like opening files, showing in folder, devtools, etc.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
use std::net::UdpSocket;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tiny_http::{Server, Response};
|
||||
use qrcode::QrCode;
|
||||
use image::Luma;
|
||||
|
||||
// Global server state
|
||||
static SERVER_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
static SERVER_STOP_FLAG: once_cell::sync::Lazy<Arc<AtomicBool>> =
|
||||
once_cell::sync::Lazy::new(|| Arc::new(AtomicBool::new(false)));
|
||||
|
||||
/// Toggle developer tools (debug mode only)
|
||||
#[tauri::command]
|
||||
@@ -94,3 +107,221 @@ pub fn show_in_folder(file_path: String) -> Result<(), String> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get system temp directory
|
||||
#[tauri::command]
|
||||
pub fn get_temp_dir() -> Result<String, String> {
|
||||
std::env::temp_dir()
|
||||
.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Failed to get temp directory".to_string())
|
||||
}
|
||||
|
||||
/// Get application resource directory
|
||||
#[tauri::command]
|
||||
pub fn get_app_resource_dir(app: AppHandle) -> Result<String, String> {
|
||||
app.path()
|
||||
.resource_dir()
|
||||
.map_err(|e| format!("Failed to get resource directory: {}", e))
|
||||
.and_then(|p| {
|
||||
p.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Invalid path encoding".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get current working directory
|
||||
#[tauri::command]
|
||||
pub fn get_current_dir() -> Result<String, String> {
|
||||
std::env::current_dir()
|
||||
.and_then(|p| Ok(p.to_string_lossy().to_string()))
|
||||
.map_err(|e| format!("Failed to get current directory: {}", e))
|
||||
}
|
||||
|
||||
/// Start a local HTTP server for runtime preview
|
||||
#[tauri::command]
|
||||
pub fn start_local_server(root_path: String, port: u16) -> Result<String, String> {
|
||||
// If server already running, just return the URL (server persists)
|
||||
if SERVER_RUNNING.load(Ordering::SeqCst) {
|
||||
return Ok(format!("http://127.0.0.1:{}", port));
|
||||
}
|
||||
|
||||
SERVER_STOP_FLAG.store(false, Ordering::SeqCst);
|
||||
SERVER_RUNNING.store(true, Ordering::SeqCst);
|
||||
|
||||
// Bind to 0.0.0.0 to allow LAN access
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
let server = Server::http(&addr)
|
||||
.map_err(|e| format!("Failed to start server: {}", e))?;
|
||||
|
||||
let root = root_path.clone();
|
||||
let stop_flag = Arc::clone(&SERVER_STOP_FLAG);
|
||||
|
||||
thread::spawn(move || {
|
||||
loop {
|
||||
if stop_flag.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Use recv_timeout to allow checking stop flag periodically
|
||||
match server.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||
Ok(Some(request)) => {
|
||||
let url = request.url().to_string();
|
||||
|
||||
// Split URL and query string
|
||||
let url_without_query = url.split('?').next().unwrap_or(&url);
|
||||
|
||||
// Handle different request types
|
||||
let file_path = if url.starts_with("/asset?path=") {
|
||||
// Asset proxy - extract and decode path parameter
|
||||
let query = &url[7..]; // Skip "/asset?"
|
||||
if let Some(path_value) = query.strip_prefix("path=") {
|
||||
urlencoding::decode(path_value)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else if url_without_query == "/" || url_without_query.is_empty() {
|
||||
// Root - serve index.html
|
||||
PathBuf::from(&root).join("index.html")
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
} else {
|
||||
// Static files - remove leading slash and append to root
|
||||
let path = url_without_query.trim_start_matches('/');
|
||||
PathBuf::from(&root).join(path)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
println!("[DevServer] Request: {} -> {}", url, file_path);
|
||||
|
||||
let response = match std::fs::read(&file_path) {
|
||||
Ok(content) => {
|
||||
let content_type = if file_path.ends_with(".html") {
|
||||
"text/html; charset=utf-8"
|
||||
} else if file_path.ends_with(".js") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".wasm") {
|
||||
"application/wasm"
|
||||
} else if file_path.ends_with(".css") {
|
||||
"text/css"
|
||||
} else if file_path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else if file_path.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if file_path.ends_with(".jpg") || file_path.ends_with(".jpeg") {
|
||||
"image/jpeg"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
|
||||
Response::from_data(content)
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())
|
||||
.unwrap(),
|
||||
)
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..])
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
Err(_) => Response::from_string("Not Found")
|
||||
.with_status_code(404),
|
||||
};
|
||||
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
Ok(None) => {
|
||||
// Timeout, continue loop
|
||||
}
|
||||
Err(_) => {
|
||||
// Error, exit loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SERVER_RUNNING.store(false, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
Ok(format!("http://127.0.0.1:{}", port))
|
||||
}
|
||||
|
||||
/// Stop the local HTTP server
|
||||
#[tauri::command]
|
||||
pub fn stop_local_server() -> Result<(), String> {
|
||||
SERVER_STOP_FLAG.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get local IP address for LAN access
|
||||
#[tauri::command]
|
||||
pub fn get_local_ip() -> Result<String, String> {
|
||||
// Use ipconfig on Windows to get the real LAN IP
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", "ipconfig"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run ipconfig: {}", e))?;
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse ipconfig output to find IPv4 addresses
|
||||
let mut found_ips: Vec<String> = Vec::new();
|
||||
|
||||
for line in output_str.lines() {
|
||||
if line.contains("IPv4") || line.contains("IP Address") {
|
||||
// Extract IP from line like " IPv4 Address. . . . . . . . . . . : 192.168.1.100"
|
||||
if let Some(ip_part) = line.split(':').nth(1) {
|
||||
let ip = ip_part.trim().to_string();
|
||||
// Prefer 192.168.x.x or 10.x.x.x addresses
|
||||
if ip.starts_with("192.168.") || ip.starts_with("10.") {
|
||||
return Ok(ip);
|
||||
}
|
||||
// Collect other IPs as fallback, skip virtual ones
|
||||
if !ip.starts_with("172.") && !ip.starts_with("127.") && !ip.starts_with("169.254.") {
|
||||
found_ips.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return first non-virtual IP found
|
||||
if let Some(ip) = found_ips.first() {
|
||||
return Ok(ip.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for non-Windows or if ipconfig fails
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")
|
||||
.map_err(|e| format!("Failed to bind socket: {}", e))?;
|
||||
|
||||
socket.connect("8.8.8.8:80")
|
||||
.map_err(|e| format!("Failed to connect: {}", e))?;
|
||||
|
||||
let local_addr = socket.local_addr()
|
||||
.map_err(|e| format!("Failed to get local address: {}", e))?;
|
||||
|
||||
Ok(local_addr.ip().to_string())
|
||||
}
|
||||
|
||||
/// Generate QR code as base64 PNG
|
||||
#[tauri::command]
|
||||
pub fn generate_qrcode(text: String) -> Result<String, String> {
|
||||
let code = QrCode::new(text.as_bytes())
|
||||
.map_err(|e| format!("Failed to create QR code: {}", e))?;
|
||||
|
||||
let image = code.render::<Luma<u8>>()
|
||||
.min_dimensions(200, 200)
|
||||
.build();
|
||||
|
||||
let mut png_data = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut png_data);
|
||||
image.write_to(&mut cursor, image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Failed to encode PNG: {}", e))?;
|
||||
|
||||
Ok(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png_data))
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ fn main() {
|
||||
commands::list_directory,
|
||||
commands::scan_directory,
|
||||
commands::read_file_as_base64,
|
||||
commands::copy_file,
|
||||
// Dialog operations
|
||||
commands::open_folder_dialog,
|
||||
commands::open_file_dialog,
|
||||
@@ -76,6 +77,13 @@ fn main() {
|
||||
commands::toggle_devtools,
|
||||
commands::open_file_with_default_app,
|
||||
commands::show_in_folder,
|
||||
commands::get_temp_dir,
|
||||
commands::get_app_resource_dir,
|
||||
commands::get_current_dir,
|
||||
commands::start_local_server,
|
||||
commands::stop_local_server,
|
||||
commands::get_local_ip,
|
||||
commands::generate_qrcode,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -225,7 +225,6 @@ function App() {
|
||||
(services.dialog as IDialogExtended).setConfirmCallback(setConfirmDialog);
|
||||
|
||||
services.messageHub.subscribe('ui:openWindow', (data: any) => {
|
||||
console.log('[App] Received ui:openWindow:', data);
|
||||
const { windowId } = data;
|
||||
|
||||
if (windowId === 'profiler') {
|
||||
@@ -504,18 +503,14 @@ function App() {
|
||||
};
|
||||
|
||||
const handleOpenSceneByPath = useCallback(async (scenePath: string) => {
|
||||
console.log('[App] handleOpenSceneByPath called with:', scenePath);
|
||||
|
||||
if (!sceneManager) {
|
||||
console.error('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[App] Opening scene:', scenePath);
|
||||
await sceneManager.openScene(scenePath);
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
console.log('[App] Scene opened, state:', sceneState);
|
||||
setStatus(locale === 'zh' ? `已打开场景: ${sceneState.sceneName}` : `Scene opened: ${sceneState.sceneName}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to open scene:', error);
|
||||
@@ -615,38 +610,30 @@ function App() {
|
||||
const handleReloadPlugins = async () => {
|
||||
if (currentProjectPath && pluginManager) {
|
||||
try {
|
||||
console.log('[App] Starting plugin hot reload...');
|
||||
|
||||
// 1. 关闭所有动态面板
|
||||
console.log('[App] Closing all dynamic panels');
|
||||
setActiveDynamicPanels([]);
|
||||
|
||||
// 2. 清空当前面板列表(强制卸载插件面板组件)
|
||||
console.log('[App] Clearing plugin panels');
|
||||
setPanels((prev) => prev.filter((p) =>
|
||||
['scene-hierarchy', 'inspector', 'console', 'asset-browser'].includes(p.id)
|
||||
));
|
||||
|
||||
// 3. 等待React完成卸载
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// 4. 卸载所有项目插件(清理UIRegistry、调用uninstall)
|
||||
console.log('[App] Unloading all project plugins');
|
||||
await pluginLoader.unloadProjectPlugins(pluginManager);
|
||||
|
||||
// 5. 等待卸载完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// 6. 重新加载插件
|
||||
console.log('[App] Reloading project plugins');
|
||||
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
|
||||
|
||||
// 7. 触发面板重新渲染
|
||||
console.log('[App] Triggering panel re-render');
|
||||
setPluginUpdateTrigger((prev) => prev + 1);
|
||||
|
||||
showToast(locale === 'zh' ? '插件已重新加载' : 'Plugins reloaded', 'success');
|
||||
console.log('[App] Plugin hot reload completed');
|
||||
} catch (error) {
|
||||
console.error('Failed to reload plugins:', error);
|
||||
showToast(locale === 'zh' ? '重新加载插件失败' : 'Failed to reload plugins', 'error');
|
||||
@@ -690,7 +677,7 @@ function App() {
|
||||
{
|
||||
id: 'viewport',
|
||||
title: locale === 'zh' ? '视口' : 'Viewport',
|
||||
content: <Viewport locale={locale} />,
|
||||
content: <Viewport locale={locale} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
@@ -765,8 +752,6 @@ function App() {
|
||||
};
|
||||
});
|
||||
|
||||
console.log('[App] Loading plugin panels:', pluginPanels);
|
||||
console.log('[App] Loading dynamic panels:', dynamicPanels);
|
||||
setPanels([...corePanels, ...pluginPanels, ...dynamicPanels]);
|
||||
}
|
||||
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, isProfilerMode, handleOpenSceneByPath, activeDynamicPanels, dynamicPanelTitles]);
|
||||
|
||||
@@ -231,6 +231,73 @@ export class TauriAPI {
|
||||
static async readFileAsBase64(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_as_base64', { filePath: path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文件
|
||||
* @param src 源文件路径
|
||||
* @param dst 目标文件路径
|
||||
*/
|
||||
static async copyFile(src: string, dst: string): Promise<void> {
|
||||
return await invoke<void>('copy_file', { src, dst });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取临时目录路径
|
||||
* @returns 临时目录路径
|
||||
*/
|
||||
static async getTempDir(): Promise<string> {
|
||||
return await invoke<string>('get_temp_dir');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用资源目录
|
||||
* @returns 资源目录路径
|
||||
*/
|
||||
static async getAppResourceDir(): Promise<string> {
|
||||
return await invoke<string>('get_app_resource_dir');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前工作目录
|
||||
* @returns 当前工作目录路径
|
||||
*/
|
||||
static async getCurrentDir(): Promise<string> {
|
||||
return await invoke<string>('get_current_dir');
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动本地HTTP服务器
|
||||
* @param rootPath 服务器根目录
|
||||
* @param port 端口号
|
||||
* @returns 服务器URL
|
||||
*/
|
||||
static async startLocalServer(rootPath: string, port: number): Promise<string> {
|
||||
return await invoke<string>('start_local_server', { rootPath, port });
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止本地HTTP服务器
|
||||
*/
|
||||
static async stopLocalServer(): Promise<void> {
|
||||
return await invoke<void>('stop_local_server');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本机局域网IP地址
|
||||
* @returns 局域网IP地址
|
||||
*/
|
||||
static async getLocalIp(): Promise<string> {
|
||||
return await invoke<string>('get_local_ip');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码
|
||||
* @param text 要编码的文本
|
||||
* @returns base64编码的PNG图片
|
||||
*/
|
||||
static async generateQRCode(text: string): Promise<string> {
|
||||
return await invoke<string>('generate_qrcode', { text });
|
||||
}
|
||||
}
|
||||
|
||||
export interface DirectoryEntry {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { Core, ComponentRegistry as CoreComponentRegistry } from '@esengine/ecs-framework';
|
||||
import {
|
||||
UIRegistry,
|
||||
MessageHub,
|
||||
@@ -17,6 +17,17 @@ import {
|
||||
PropertyRendererRegistry,
|
||||
FieldEditorRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
TransformComponent,
|
||||
SpriteComponent,
|
||||
SpriteAnimatorComponent,
|
||||
TextComponent,
|
||||
CameraComponent,
|
||||
RigidBodyComponent,
|
||||
BoxColliderComponent,
|
||||
CircleColliderComponent,
|
||||
AudioSourceComponent
|
||||
} from '@esengine/ecs-components';
|
||||
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
||||
import { DIContainer } from '../../core/di/DIContainer';
|
||||
import { TypedEventBus } from '../../core/events/TypedEventBus';
|
||||
@@ -43,7 +54,8 @@ import {
|
||||
Vector2FieldEditor,
|
||||
Vector3FieldEditor,
|
||||
Vector4FieldEditor,
|
||||
ColorFieldEditor
|
||||
ColorFieldEditor,
|
||||
AnimationClipsFieldEditor
|
||||
} from '../../infrastructure/field-editors';
|
||||
|
||||
export interface EditorServices {
|
||||
@@ -81,6 +93,34 @@ export class ServiceRegistry {
|
||||
const serializerRegistry = new SerializerRegistry();
|
||||
const entityStore = new EntityStoreService(messageHub);
|
||||
const componentRegistry = new ComponentRegistry();
|
||||
|
||||
// 注册标准组件到编辑器和核心注册表
|
||||
// Register to both editor registry (for UI) and core registry (for serialization)
|
||||
const standardComponents = [
|
||||
{ name: 'TransformComponent', type: TransformComponent, editorName: 'Transform', category: 'components.category.core', description: 'components.transform.description' },
|
||||
{ name: 'SpriteComponent', type: SpriteComponent, editorName: 'Sprite', category: 'components.category.rendering', description: 'components.sprite.description' },
|
||||
{ name: 'SpriteAnimatorComponent', type: SpriteAnimatorComponent, editorName: 'SpriteAnimator', category: 'components.category.rendering', description: 'components.spriteAnimator.description' },
|
||||
{ name: 'TextComponent', type: TextComponent, editorName: 'Text', category: 'components.category.rendering', description: 'components.text.description' },
|
||||
{ name: 'CameraComponent', type: CameraComponent, editorName: 'Camera', category: 'components.category.rendering', description: 'components.camera.description' },
|
||||
{ name: 'RigidBodyComponent', type: RigidBodyComponent, editorName: 'RigidBody', category: 'components.category.physics', description: 'components.rigidBody.description' },
|
||||
{ name: 'BoxColliderComponent', type: BoxColliderComponent, editorName: 'BoxCollider', category: 'components.category.physics', description: 'components.boxCollider.description' },
|
||||
{ name: 'CircleColliderComponent', type: CircleColliderComponent, editorName: 'CircleCollider', category: 'components.category.physics', description: 'components.circleCollider.description' },
|
||||
{ name: 'AudioSourceComponent', type: AudioSourceComponent, editorName: 'AudioSource', category: 'components.category.audio', description: 'components.audioSource.description' }
|
||||
];
|
||||
|
||||
for (const comp of standardComponents) {
|
||||
// Register to editor registry for UI
|
||||
componentRegistry.register({
|
||||
name: comp.editorName,
|
||||
type: comp.type,
|
||||
category: comp.category,
|
||||
description: comp.description
|
||||
});
|
||||
|
||||
// Register to core registry for serialization/deserialization
|
||||
CoreComponentRegistry.register(comp.type as any);
|
||||
}
|
||||
|
||||
const projectService = new ProjectService(messageHub, fileAPI);
|
||||
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
||||
const propertyMetadata = new PropertyMetadataService();
|
||||
@@ -114,6 +154,8 @@ export class ServiceRegistry {
|
||||
const fileSystem = new TauriFileSystemService();
|
||||
const dialog = new TauriDialogService();
|
||||
const notification = new NotificationService();
|
||||
Core.services.registerInstance(NotificationService, notification);
|
||||
|
||||
const inspectorRegistry = new InspectorRegistry();
|
||||
|
||||
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
|
||||
@@ -140,6 +182,7 @@ export class ServiceRegistry {
|
||||
fieldEditorRegistry.register(new Vector3FieldEditor());
|
||||
fieldEditorRegistry.register(new Vector4FieldEditor());
|
||||
fieldEditorRegistry.register(new ColorFieldEditor());
|
||||
fieldEditorRegistry.register(new AnimationClipsFieldEditor());
|
||||
|
||||
return {
|
||||
uiRegistry,
|
||||
|
||||
@@ -23,7 +23,7 @@ export class UpdateComponentCommand extends BaseCommand {
|
||||
execute(): void {
|
||||
(this.component as any)[this.propertyName] = this.newValue;
|
||||
|
||||
this.messageHub.publish('component:updated', {
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName: this.propertyName,
|
||||
@@ -34,7 +34,7 @@ export class UpdateComponentCommand extends BaseCommand {
|
||||
undo(): void {
|
||||
(this.component as any)[this.propertyName] = this.oldValue;
|
||||
|
||||
this.messageHub.publish('component:updated', {
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName: this.propertyName,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorComponent } from '@esengine/ecs-components';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建带动画组件的Sprite实体命令
|
||||
*/
|
||||
export class CreateAnimatedSpriteEntityCommand extends BaseCommand {
|
||||
private entity: Entity | null = null;
|
||||
private entityId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entityName: string,
|
||||
private parentEntity?: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加Transform、Sprite和Animator组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new SpriteComponent());
|
||||
this.entity.addComponent(new SpriteAnimatorComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
this.entityStore.selectEntity(this.entity);
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: this.entity });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.entity) return;
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
|
||||
this.entity = null;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建动画Sprite实体: ${this.entityName}`;
|
||||
}
|
||||
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, CameraComponent } from '@esengine/ecs-components';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建带Camera组件的实体命令
|
||||
*/
|
||||
export class CreateCameraEntityCommand extends BaseCommand {
|
||||
private entity: Entity | null = null;
|
||||
private entityId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entityName: string,
|
||||
private parentEntity?: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加Transform和Camera组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new CameraComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
this.entityStore.selectEntity(this.entity);
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: this.entity });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.entity) return;
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
|
||||
this.entity = null;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建Camera实体: ${this.entityName}`;
|
||||
}
|
||||
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.entity;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/ecs-components';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
@@ -27,6 +28,9 @@ export class CreateEntityCommand extends BaseCommand {
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 自动添加Transform组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, SpriteComponent } from '@esengine/ecs-components';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建带Sprite组件的实体命令
|
||||
*/
|
||||
export class CreateSpriteEntityCommand extends BaseCommand {
|
||||
private entity: Entity | null = null;
|
||||
private entityId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entityName: string,
|
||||
private parentEntity?: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加Transform和Sprite组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new SpriteComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
this.entityStore.selectEntity(this.entity);
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: this.entity });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.entity) return;
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
|
||||
this.entity = null;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建Sprite实体: ${this.entityName}`;
|
||||
}
|
||||
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.entity;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
export { CreateEntityCommand } from './CreateEntityCommand';
|
||||
export { CreateSpriteEntityCommand } from './CreateSpriteEntityCommand';
|
||||
export { CreateAnimatedSpriteEntityCommand } from './CreateAnimatedSpriteEntityCommand';
|
||||
export { CreateCameraEntityCommand } from './CreateCameraEntityCommand';
|
||||
export { DeleteEntityCommand } from './DeleteEntityCommand';
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
const detailViewFileTreeRef = useRef<FileTreeHandle>(null);
|
||||
const treeOnlyViewFileTreeRef = useRef<FileTreeHandle>(null);
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedPath, setLastSelectedPath] = useState<string | null>(null);
|
||||
const [assets, setAssets] = useState<AssetItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<AssetItem[]>([]);
|
||||
@@ -83,7 +84,7 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
} else {
|
||||
setAssets([]);
|
||||
setCurrentPath(null);
|
||||
setSelectedPath(null);
|
||||
setSelectedPaths(new Set());
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
@@ -92,21 +93,29 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('asset:reveal', (data: any) => {
|
||||
const unsubscribe = messageHub.subscribe('asset:reveal', async (data: any) => {
|
||||
const filePath = data.path;
|
||||
if (filePath) {
|
||||
setSelectedPath(filePath);
|
||||
const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
||||
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : null;
|
||||
if (dirPath) {
|
||||
setCurrentPath(dirPath);
|
||||
loadAssets(dirPath);
|
||||
// Load assets first, then set selection after list is populated
|
||||
await loadAssets(dirPath);
|
||||
setSelectedPaths(new Set([filePath]));
|
||||
|
||||
// Expand tree to reveal the file
|
||||
if (showDetailView) {
|
||||
detailViewFileTreeRef.current?.revealPath(filePath);
|
||||
} else {
|
||||
treeOnlyViewFileTreeRef.current?.revealPath(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
}, [showDetailView]);
|
||||
|
||||
const loadAssets = async (path: string) => {
|
||||
setLoading(true);
|
||||
@@ -211,8 +220,57 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
loadAssets(path);
|
||||
};
|
||||
|
||||
const handleAssetClick = (asset: AssetItem) => {
|
||||
setSelectedPath(asset.path);
|
||||
const handleTreeMultiSelect = (paths: string[], modifiers: { ctrlKey: boolean; shiftKey: boolean }) => {
|
||||
if (paths.length === 0) return;
|
||||
const path = paths[0];
|
||||
if (!path) return;
|
||||
|
||||
if (modifiers.shiftKey && paths.length > 1) {
|
||||
// Range select - paths already contains the range from FileTree
|
||||
setSelectedPaths(new Set(paths));
|
||||
} else if (modifiers.ctrlKey) {
|
||||
const newSelected = new Set(selectedPaths);
|
||||
if (newSelected.has(path)) {
|
||||
newSelected.delete(path);
|
||||
} else {
|
||||
newSelected.add(path);
|
||||
}
|
||||
setSelectedPaths(newSelected);
|
||||
setLastSelectedPath(path);
|
||||
} else {
|
||||
setSelectedPaths(new Set([path]));
|
||||
setLastSelectedPath(path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssetClick = (asset: AssetItem, e: React.MouseEvent) => {
|
||||
const filteredAssets = searchQuery.trim() ? searchResults : assets;
|
||||
|
||||
if (e.shiftKey && lastSelectedPath) {
|
||||
// Range select with Shift
|
||||
const lastIndex = filteredAssets.findIndex((a) => a.path === lastSelectedPath);
|
||||
const currentIndex = filteredAssets.findIndex((a) => a.path === asset.path);
|
||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||
const start = Math.min(lastIndex, currentIndex);
|
||||
const end = Math.max(lastIndex, currentIndex);
|
||||
const rangePaths = filteredAssets.slice(start, end + 1).map((a) => a.path);
|
||||
setSelectedPaths(new Set(rangePaths));
|
||||
}
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
// Multi-select with Ctrl/Cmd
|
||||
const newSelected = new Set(selectedPaths);
|
||||
if (newSelected.has(asset.path)) {
|
||||
newSelected.delete(asset.path);
|
||||
} else {
|
||||
newSelected.add(asset.path);
|
||||
}
|
||||
setSelectedPaths(newSelected);
|
||||
setLastSelectedPath(asset.path);
|
||||
} else {
|
||||
// Single select
|
||||
setSelectedPaths(new Set([asset.path]));
|
||||
setLastSelectedPath(asset.path);
|
||||
}
|
||||
|
||||
messageHub?.publish('asset-file:selected', {
|
||||
fileInfo: {
|
||||
@@ -275,8 +333,11 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
}
|
||||
|
||||
// 更新选中路径
|
||||
if (selectedPath === asset.path) {
|
||||
setSelectedPath(newPath);
|
||||
if (selectedPaths.has(asset.path)) {
|
||||
const newSelected = new Set(selectedPaths);
|
||||
newSelected.delete(asset.path);
|
||||
newSelected.add(newPath);
|
||||
setSelectedPaths(newSelected);
|
||||
}
|
||||
|
||||
setRenameDialog(null);
|
||||
@@ -300,8 +361,10 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
}
|
||||
|
||||
// 清除选中状态
|
||||
if (selectedPath === asset.path) {
|
||||
setSelectedPath(null);
|
||||
if (selectedPaths.has(asset.path)) {
|
||||
const newSelected = new Set(selectedPaths);
|
||||
newSelected.delete(asset.path);
|
||||
setSelectedPaths(newSelected);
|
||||
}
|
||||
|
||||
setDeleteConfirmDialog(null);
|
||||
@@ -637,100 +700,120 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
</div>
|
||||
}
|
||||
rightOrBottom={
|
||||
<div className="asset-browser-list">
|
||||
<div className="asset-browser-breadcrumb">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={crumb.path}>
|
||||
<span
|
||||
className="breadcrumb-item"
|
||||
onClick={() => {
|
||||
setCurrentPath(crumb.path);
|
||||
loadAssets(crumb.path);
|
||||
}}
|
||||
>
|
||||
{crumb.name}
|
||||
</span>
|
||||
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{(loading || isSearching) ? (
|
||||
<div className="asset-browser-loading">
|
||||
<p>{isSearching ? '搜索中...' : t.loading}</p>
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-browser-empty">
|
||||
<p>{searchQuery.trim() ? '未找到匹配的资产' : t.empty}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="asset-list">
|
||||
{filteredAssets.map((asset, index) => {
|
||||
const relativePath = getRelativePath(asset.path);
|
||||
const showPath = searchQuery.trim() && relativePath;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
|
||||
onClick={() => handleAssetClick(asset)}
|
||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
draggable={asset.type === 'file'}
|
||||
onDragStart={(e) => {
|
||||
if (asset.type === 'file') {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
// 设置拖拽的数据
|
||||
e.dataTransfer.setData('asset-path', asset.path);
|
||||
e.dataTransfer.setData('asset-name', asset.name);
|
||||
e.dataTransfer.setData('asset-extension', asset.extension || '');
|
||||
e.dataTransfer.setData('text/plain', asset.path);
|
||||
|
||||
// 设置拖拽时的视觉效果
|
||||
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
|
||||
dragImage.style.position = 'absolute';
|
||||
dragImage.style.top = '-9999px';
|
||||
dragImage.style.opacity = '0.8';
|
||||
document.body.appendChild(dragImage);
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
setTimeout(() => document.body.removeChild(dragImage), 0);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: asset.type === 'file' ? 'grab' : 'pointer'
|
||||
<div className="asset-browser-list">
|
||||
<div className="asset-browser-breadcrumb">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={crumb.path}>
|
||||
<span
|
||||
className="breadcrumb-item"
|
||||
onClick={() => {
|
||||
setCurrentPath(crumb.path);
|
||||
loadAssets(crumb.path);
|
||||
}}
|
||||
>
|
||||
{getFileIcon(asset)}
|
||||
<div className="asset-info">
|
||||
<div className="asset-name" title={asset.path}>
|
||||
{asset.name}
|
||||
</div>
|
||||
{showPath && (
|
||||
<div className="asset-path" style={{
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
{relativePath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-type">
|
||||
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{crumb.name}
|
||||
</span>
|
||||
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{(loading || isSearching) ? (
|
||||
<div className="asset-browser-loading">
|
||||
<p>{isSearching ? '搜索中...' : t.loading}</p>
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-browser-empty">
|
||||
<p>{searchQuery.trim() ? '未找到匹配的资产' : t.empty}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="asset-list">
|
||||
{filteredAssets.map((asset, index) => {
|
||||
const relativePath = getRelativePath(asset.path);
|
||||
const showPath = searchQuery.trim() && relativePath;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''}`}
|
||||
onClick={(e) => handleAssetClick(asset, e)}
|
||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
draggable={asset.type === 'file'}
|
||||
onDragStart={(e) => {
|
||||
if (asset.type === 'file') {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
|
||||
// Get all selected file assets
|
||||
const selectedFiles = selectedPaths.has(asset.path) && selectedPaths.size > 1
|
||||
? Array.from(selectedPaths)
|
||||
.filter((p) => {
|
||||
const a = assets?.find((item) => item.path === p);
|
||||
return a && a.type === 'file';
|
||||
})
|
||||
.map((p) => {
|
||||
const a = assets?.find((item) => item.path === p);
|
||||
return { type: 'file', path: p, name: a?.name, extension: a?.extension };
|
||||
})
|
||||
: [{ type: 'file', path: asset.path, name: asset.name, extension: asset.extension }];
|
||||
|
||||
// Set drag data as JSON array for multi-file support
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(selectedFiles));
|
||||
e.dataTransfer.setData('asset-path', asset.path);
|
||||
e.dataTransfer.setData('asset-name', asset.name);
|
||||
e.dataTransfer.setData('asset-extension', asset.extension || '');
|
||||
e.dataTransfer.setData('text/plain', asset.path);
|
||||
|
||||
// 设置拖拽时的视觉效果
|
||||
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
|
||||
dragImage.style.position = 'absolute';
|
||||
dragImage.style.top = '-9999px';
|
||||
dragImage.style.opacity = '0.8';
|
||||
if (selectedFiles.length > 1) {
|
||||
dragImage.textContent = `${selectedFiles.length} files`;
|
||||
}
|
||||
document.body.appendChild(dragImage);
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
setTimeout(() => document.body.removeChild(dragImage), 0);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: asset.type === 'file' ? 'grab' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{getFileIcon(asset)}
|
||||
<div className="asset-info">
|
||||
<div className="asset-name" title={asset.path}>
|
||||
{asset.name}
|
||||
</div>
|
||||
{showPath && (
|
||||
<div className="asset-path" style={{
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
{relativePath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-type">
|
||||
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="asset-browser-tree-only">
|
||||
<FileTree
|
||||
ref={treeOnlyViewFileTreeRef}
|
||||
rootPath={projectPath}
|
||||
onSelectFile={handleFolderSelect}
|
||||
selectedPath={currentPath}
|
||||
onSelectFiles={handleTreeMultiSelect}
|
||||
selectedPath={Array.from(selectedPaths)[0] || currentPath}
|
||||
selectedPaths={selectedPaths}
|
||||
messageHub={messageHub}
|
||||
searchQuery={searchQuery}
|
||||
showFiles={true}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
|
||||
try {
|
||||
const registry = Core.services.resolve(CompilerRegistry);
|
||||
console.log('[CompilerConfigDialog] Registry resolved:', registry);
|
||||
console.log('[CompilerConfigDialog] Available compilers:', registry.getAll().map(c => c.id));
|
||||
console.log('[CompilerConfigDialog] Available compilers:', registry.getAll().map((c) => c.id));
|
||||
const comp = registry.get(compilerId);
|
||||
console.log(`[CompilerConfigDialog] Looking for compiler: ${compilerId}, found:`, comp);
|
||||
setCompiler(comp || null);
|
||||
@@ -74,7 +74,7 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
|
||||
},
|
||||
listDirectory: async (path: string): Promise<FileEntry[]> => {
|
||||
const entries = await invoke<DirectoryEntry[]>('list_directory', { path });
|
||||
return entries.map(e => ({
|
||||
return entries.map((e) => ({
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
isDirectory: e.is_dir
|
||||
@@ -96,8 +96,8 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
|
||||
const entries = await invoke<DirectoryEntry[]>('list_directory', { path: dir });
|
||||
const ext = pattern.replace(/\*/g, '');
|
||||
return entries
|
||||
.filter(e => !e.is_dir && e.name.endsWith(ext))
|
||||
.map(e => e.name.replace(ext, ''));
|
||||
.filter((e) => !e.is_dir && e.name.endsWith(ext))
|
||||
.map((e) => e.name.replace(ext, ''));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -19,10 +19,20 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelection = (data: { entity: Entity | null }) => {
|
||||
setSelectedEntity(data.entity);
|
||||
setSelectedEntity((prev) => {
|
||||
// Only reset version when selecting a different entity
|
||||
// 只在选择不同实体时重置版本
|
||||
if (prev?.id !== data.entity?.id) {
|
||||
setComponentVersion(0);
|
||||
} else {
|
||||
// Same entity re-selected, trigger refresh
|
||||
// 同一实体重新选择,触发刷新
|
||||
setComponentVersion((v) => v + 1);
|
||||
}
|
||||
return data.entity;
|
||||
});
|
||||
setRemoteEntity(null);
|
||||
setRemoteEntityDetails(null);
|
||||
setComponentVersion(0);
|
||||
};
|
||||
|
||||
const handleRemoteSelection = (data: { entity: any }) => {
|
||||
@@ -45,6 +55,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
|
||||
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteSelection);
|
||||
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
|
||||
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
|
||||
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', handleComponentChange);
|
||||
|
||||
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||
|
||||
@@ -53,6 +64,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
|
||||
unsubRemoteSelect();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
unsubPropertyChanged();
|
||||
window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||
};
|
||||
}, [messageHub]);
|
||||
@@ -80,6 +92,11 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
|
||||
|
||||
const handlePropertyChange = (component: any, propertyName: string, value: any) => {
|
||||
if (!selectedEntity) return;
|
||||
|
||||
// Actually update the component property
|
||||
// 实际更新组件属性
|
||||
component[propertyName] = value;
|
||||
|
||||
messageHub.publish('component:property:changed', {
|
||||
entity: selectedEntity,
|
||||
component,
|
||||
@@ -500,6 +517,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
|
||||
{isExpanded && (
|
||||
<div className="component-properties animate-slideDown">
|
||||
<PropertyInspector
|
||||
key={`${index}-${componentVersion}`}
|
||||
component={component}
|
||||
onChange={(propertyName, value) => handlePropertyChange(component, propertyName, value)}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,9 @@ interface TreeNode {
|
||||
interface FileTreeProps {
|
||||
rootPath: string | null;
|
||||
onSelectFile?: (path: string) => void;
|
||||
onSelectFiles?: (paths: string[], modifiers: { ctrlKey: boolean; shiftKey: boolean }) => void;
|
||||
selectedPath?: string | null;
|
||||
selectedPaths?: Set<string>;
|
||||
messageHub?: MessageHub;
|
||||
searchQuery?: string;
|
||||
showFiles?: boolean;
|
||||
@@ -31,12 +33,30 @@ interface FileTreeProps {
|
||||
export interface FileTreeHandle {
|
||||
collapseAll: () => void;
|
||||
refresh: () => void;
|
||||
revealPath: (targetPath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, onSelectFile, selectedPath, messageHub, searchQuery, showFiles = true }, ref) => {
|
||||
export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, onSelectFile, onSelectFiles, selectedPath, selectedPaths, messageHub, searchQuery, showFiles = true }, ref) => {
|
||||
const [tree, setTree] = useState<TreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [internalSelectedPath, setInternalSelectedPath] = useState<string | null>(null);
|
||||
const [lastSelectedFilePath, setLastSelectedFilePath] = useState<string | null>(null);
|
||||
|
||||
// Flatten visible file nodes for range selection
|
||||
const getVisibleFilePaths = (nodes: TreeNode[]): string[] => {
|
||||
const paths: string[] = [];
|
||||
const traverse = (nodeList: TreeNode[]) => {
|
||||
for (const node of nodeList) {
|
||||
if (node.type === 'file') {
|
||||
paths.push(node.path);
|
||||
} else if (node.type === 'folder' && node.expanded && node.children) {
|
||||
traverse(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
traverse(nodes);
|
||||
return paths;
|
||||
};
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
position: { x: number; y: number };
|
||||
node: TreeNode | null;
|
||||
@@ -49,7 +69,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
parentPath: string;
|
||||
templateExtension?: string;
|
||||
templateContent?: (fileName: string) => Promise<string>;
|
||||
} | null>(null);
|
||||
} | null>(null);
|
||||
const [filteredTree, setFilteredTree] = useState<TreeNode[]>([]);
|
||||
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
|
||||
|
||||
@@ -65,13 +85,84 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
return node;
|
||||
};
|
||||
|
||||
const collapsedTree = tree.map(node => collapseNode(node));
|
||||
const collapsedTree = tree.map((node) => collapseNode(node));
|
||||
setTree(collapsedTree);
|
||||
};
|
||||
|
||||
// Expand tree to reveal a specific file path
|
||||
const revealPath = async (targetPath: string) => {
|
||||
if (!rootPath || !targetPath.startsWith(rootPath)) return;
|
||||
|
||||
// Get path segments between root and target
|
||||
const relativePath = targetPath.substring(rootPath.length).replace(/^[/\\]/, '');
|
||||
const segments = relativePath.split(/[/\\]/);
|
||||
|
||||
// Build list of folder paths to expand
|
||||
const pathsToExpand: string[] = [];
|
||||
let currentPath = rootPath;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
currentPath = `${currentPath}/${segments[i]}`;
|
||||
pathsToExpand.push(currentPath.replace(/\//g, '\\'));
|
||||
}
|
||||
|
||||
// Recursively expand nodes and load children
|
||||
const expandToPath = async (nodes: TreeNode[], pathSet: Set<string>): Promise<TreeNode[]> => {
|
||||
const result: TreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const normalizedPath = node.path.replace(/\//g, '\\');
|
||||
if (node.type === 'folder' && pathSet.has(normalizedPath)) {
|
||||
// Load children if not loaded
|
||||
let children = node.children;
|
||||
if (!node.loaded || !children) {
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(node.path);
|
||||
children = entries.map((entry: DirectoryEntry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
||||
size: entry.size,
|
||||
modified: entry.modified,
|
||||
expanded: false,
|
||||
loaded: false
|
||||
})).sort((a, b) => {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
});
|
||||
} catch (error) {
|
||||
children = [];
|
||||
}
|
||||
}
|
||||
// Recursively expand children
|
||||
const expandedChildren = await expandToPath(children, pathSet);
|
||||
result.push({
|
||||
...node,
|
||||
expanded: true,
|
||||
loaded: true,
|
||||
children: expandedChildren
|
||||
});
|
||||
} else if (node.type === 'folder' && node.children) {
|
||||
// Keep existing state for non-target folders
|
||||
result.push({
|
||||
...node,
|
||||
children: await expandToPath(node.children, pathSet)
|
||||
});
|
||||
} else {
|
||||
result.push(node);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const pathSet = new Set(pathsToExpand);
|
||||
const expandedTree = await expandToPath(tree, pathSet);
|
||||
setTree(expandedTree);
|
||||
setInternalSelectedPath(targetPath);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
collapseAll,
|
||||
refresh: refreshTree
|
||||
refresh: refreshTree,
|
||||
revealPath
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -92,8 +183,8 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
const performSearch = async () => {
|
||||
const filterByFileType = (nodes: TreeNode[]): TreeNode[] => {
|
||||
return nodes
|
||||
.filter(node => showFiles || node.type === 'folder')
|
||||
.map(node => ({
|
||||
.filter((node) => showFiles || node.type === 'folder')
|
||||
.map((node) => ({
|
||||
...node,
|
||||
children: node.children ? filterByFileType(node.children) : node.children
|
||||
}));
|
||||
@@ -280,7 +371,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
children = await loadChildren(node);
|
||||
}
|
||||
const restoredChildren = await Promise.all(
|
||||
children.map(child => restoreExpandedState(child))
|
||||
children.map((child) => restoreExpandedState(child))
|
||||
);
|
||||
return {
|
||||
...node,
|
||||
@@ -290,7 +381,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
};
|
||||
} else if (node.type === 'folder' && node.children) {
|
||||
const restoredChildren = await Promise.all(
|
||||
node.children.map(child => restoreExpandedState(child))
|
||||
node.children.map((child) => restoreExpandedState(child))
|
||||
);
|
||||
return {
|
||||
...node,
|
||||
@@ -325,7 +416,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
}
|
||||
|
||||
const expandedChildren = await Promise.all(
|
||||
children.map(child => expandNode(child))
|
||||
children.map((child) => expandNode(child))
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -338,7 +429,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
return node;
|
||||
};
|
||||
|
||||
const expandedTree = await Promise.all(tree.map(node => expandNode(node)));
|
||||
const expandedTree = await Promise.all(tree.map((node) => expandNode(node)));
|
||||
setTree(expandedTree);
|
||||
};
|
||||
|
||||
@@ -574,13 +665,39 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
return items;
|
||||
};
|
||||
|
||||
const handleNodeClick = (node: TreeNode) => {
|
||||
const handleNodeClick = (node: TreeNode, e: React.MouseEvent) => {
|
||||
if (node.type === 'folder') {
|
||||
setInternalSelectedPath(node.path);
|
||||
onSelectFile?.(node.path);
|
||||
toggleNode(node.path);
|
||||
} else {
|
||||
setInternalSelectedPath(node.path);
|
||||
|
||||
// Support multi-select with Ctrl/Cmd or Shift
|
||||
if (onSelectFiles) {
|
||||
if (e.shiftKey && lastSelectedFilePath) {
|
||||
// Range select with Shift
|
||||
const treeToUse = searchQuery ? filteredTree : tree;
|
||||
const visiblePaths = getVisibleFilePaths(treeToUse);
|
||||
const lastIndex = visiblePaths.indexOf(lastSelectedFilePath);
|
||||
const currentIndex = visiblePaths.indexOf(node.path);
|
||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||
const start = Math.min(lastIndex, currentIndex);
|
||||
const end = Math.max(lastIndex, currentIndex);
|
||||
const rangePaths = visiblePaths.slice(start, end + 1);
|
||||
onSelectFiles(rangePaths, { ctrlKey: false, shiftKey: true });
|
||||
} else {
|
||||
onSelectFiles([node.path], { ctrlKey: false, shiftKey: false });
|
||||
setLastSelectedFilePath(node.path);
|
||||
}
|
||||
} else {
|
||||
onSelectFiles([node.path], { ctrlKey: e.ctrlKey || e.metaKey, shiftKey: false });
|
||||
setLastSelectedFilePath(node.path);
|
||||
}
|
||||
} else {
|
||||
setLastSelectedFilePath(node.path);
|
||||
}
|
||||
|
||||
const extension = node.name.includes('.') ? node.name.split('.').pop() : undefined;
|
||||
messageHub?.publish('asset-file:selected', {
|
||||
fileInfo: {
|
||||
@@ -622,7 +739,9 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
};
|
||||
|
||||
const renderNode = (node: TreeNode, level: number = 0) => {
|
||||
const isSelected = (internalSelectedPath || selectedPath) === node.path;
|
||||
const isSelected = selectedPaths
|
||||
? selectedPaths.has(node.path)
|
||||
: (internalSelectedPath || selectedPath) === node.path;
|
||||
const isRenaming = renamingNode === node.path;
|
||||
const indent = level * 16;
|
||||
|
||||
@@ -631,14 +750,30 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
<div
|
||||
className={`tree-node ${isSelected ? 'selected' : ''}`}
|
||||
style={{ paddingLeft: `${indent}px`, cursor: node.type === 'file' ? 'grab' : 'pointer' }}
|
||||
onClick={() => !isRenaming && handleNodeClick(node)}
|
||||
onClick={(e) => !isRenaming && handleNodeClick(node, e)}
|
||||
onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)}
|
||||
onContextMenu={(e) => handleContextMenu(e, node)}
|
||||
draggable={node.type === 'file' && !isRenaming}
|
||||
onDragStart={(e) => {
|
||||
if (node.type === 'file' && !isRenaming) {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
// 设置拖拽的数据
|
||||
|
||||
// Get all selected files for multi-file drag
|
||||
const selectedFiles = selectedPaths && selectedPaths.has(node.path) && selectedPaths.size > 1
|
||||
? Array.from(selectedPaths).map((p) => {
|
||||
const name = p.split(/[/\\]/).pop() || '';
|
||||
const ext = name.includes('.') ? name.split('.').pop() : '';
|
||||
return { type: 'file', path: p, name, extension: ext };
|
||||
})
|
||||
: [{
|
||||
type: 'file',
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
extension: node.name.includes('.') ? node.name.split('.').pop() : ''
|
||||
}];
|
||||
|
||||
// Set drag data as JSON array for multi-file support
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(selectedFiles));
|
||||
e.dataTransfer.setData('asset-path', node.path);
|
||||
e.dataTransfer.setData('asset-name', node.name);
|
||||
const ext = node.name.includes('.') ? node.name.split('.').pop() : '';
|
||||
@@ -748,18 +883,18 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
<PromptDialog
|
||||
title={
|
||||
promptDialog.type === 'create-file' ? '新建文件' :
|
||||
promptDialog.type === 'create-folder' ? '新建文件夹' :
|
||||
'新建文件'
|
||||
promptDialog.type === 'create-folder' ? '新建文件夹' :
|
||||
'新建文件'
|
||||
}
|
||||
message={
|
||||
promptDialog.type === 'create-file' ? '请输入文件名:' :
|
||||
promptDialog.type === 'create-folder' ? '请输入文件夹名:' :
|
||||
`请输入文件名 (将自动添加 .${promptDialog.templateExtension} 扩展名):`
|
||||
promptDialog.type === 'create-folder' ? '请输入文件夹名:' :
|
||||
`请输入文件名 (将自动添加 .${promptDialog.templateExtension} 扩展名):`
|
||||
}
|
||||
placeholder={
|
||||
promptDialog.type === 'create-file' ? '例如: config.json' :
|
||||
promptDialog.type === 'create-folder' ? '例如: assets' :
|
||||
'例如: MyFile'
|
||||
promptDialog.type === 'create-folder' ? '例如: assets' :
|
||||
'例如: MyFile'
|
||||
}
|
||||
confirmText="创建"
|
||||
cancelText="取消"
|
||||
|
||||
@@ -33,11 +33,11 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
useEffect(() => {
|
||||
try {
|
||||
// 检查面板ID列表是否真的变化了(而不只是标题等属性变化)
|
||||
const currentPanelIds = panels.map(p => p.id).sort().join(',');
|
||||
const currentPanelIds = panels.map((p) => p.id).sort().join(',');
|
||||
const previousIds = previousPanelIdsRef.current;
|
||||
|
||||
// 检查标题是否变化
|
||||
const currentTitles = new Map(panels.map(p => [p.id, p.title]));
|
||||
const currentTitles = new Map(panels.map((p) => [p.id, p.title]));
|
||||
const titleChanges: Array<{ id: string; newTitle: string }> = [];
|
||||
|
||||
for (const panel of panels) {
|
||||
@@ -66,17 +66,17 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
}
|
||||
|
||||
// 计算新增和移除的面板
|
||||
const prevSet = new Set(previousIds.split(',').filter(id => id));
|
||||
const currSet = new Set(currentPanelIds.split(',').filter(id => id));
|
||||
const newPanelIds = Array.from(currSet).filter(id => !prevSet.has(id));
|
||||
const removedPanelIds = Array.from(prevSet).filter(id => !currSet.has(id));
|
||||
const prevSet = new Set(previousIds.split(',').filter((id) => id));
|
||||
const currSet = new Set(currentPanelIds.split(',').filter((id) => id));
|
||||
const newPanelIds = Array.from(currSet).filter((id) => !prevSet.has(id));
|
||||
const removedPanelIds = Array.from(prevSet).filter((id) => !currSet.has(id));
|
||||
|
||||
previousPanelIdsRef.current = currentPanelIds;
|
||||
|
||||
// 如果已经有布局且只是添加新面板,使用Action动态添加
|
||||
if (model && newPanelIds.length > 0 && removedPanelIds.length === 0 && previousIds) {
|
||||
// 找到要添加的面板
|
||||
const newPanels = panels.filter(p => newPanelIds.includes(p.id));
|
||||
const newPanels = panels.filter((p) => newPanelIds.includes(p.id));
|
||||
|
||||
// 找到中心区域的tabset ID
|
||||
let centerTabsetId: string | null = null;
|
||||
@@ -101,7 +101,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
|
||||
if (centerTabsetId) {
|
||||
// 动态添加tab到中心tabset
|
||||
newPanels.forEach(panel => {
|
||||
newPanels.forEach((panel) => {
|
||||
model.doAction(Actions.addNode(
|
||||
{
|
||||
type: 'tab',
|
||||
|
||||
@@ -868,7 +868,7 @@ function PropertyValueRenderer({ name, value, depth, decimalPlaces = 4 }: Proper
|
||||
const keys = Object.keys(val);
|
||||
if (keys.length === 0) return '{}';
|
||||
if (keys.length <= 2) {
|
||||
const preview = keys.map(k => `${k}: ${typeof val[k] === 'object' ? '...' : (typeof val[k] === 'number' ? formatNumber(val[k], decimalPlaces) : val[k])}`).join(', ');
|
||||
const preview = keys.map((k) => `${k}: ${typeof val[k] === 'object' ? '...' : (typeof val[k] === 'number' ? formatNumber(val[k], decimalPlaces) : val[k])}`).join(', ');
|
||||
return `{${preview}}`;
|
||||
}
|
||||
return `{${keys.slice(0, 2).join(', ')}...}`;
|
||||
@@ -996,7 +996,7 @@ function ImagePreview({ src, alt }: ImagePreviewProps) {
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
setScale(prev => Math.min(Math.max(prev * delta, 0.1), 10));
|
||||
setScale((prev) => Math.min(Math.max(prev * delta, 0.1), 10));
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
|
||||
@@ -83,12 +83,10 @@ export function MenuBar({
|
||||
});
|
||||
|
||||
setPluginMenuItems(filteredItems);
|
||||
console.log('[MenuBar] Updated menu items:', filteredItems);
|
||||
} else if (uiRegistry) {
|
||||
// 如果没有 pluginManager,显示所有菜单项
|
||||
const items = uiRegistry.getChildMenus('window');
|
||||
setPluginMenuItems(items);
|
||||
console.log('[MenuBar] Updated menu items (no filter):', items);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,17 +97,14 @@ export function MenuBar({
|
||||
useEffect(() => {
|
||||
if (messageHub) {
|
||||
const unsubscribeInstalled = messageHub.subscribe('plugin:installed', () => {
|
||||
console.log('[MenuBar] Plugin installed, updating menu items');
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
|
||||
console.log('[MenuBar] Plugin enabled, updating menu items');
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
|
||||
console.log('[MenuBar] Plugin disabled, updating menu items');
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user