From a768b890fd052de075f7ad04e9b06df542728d77 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Fri, 21 Nov 2025 10:03:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90Rust=20WASM=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=BC=95=E6=93=8E=E4=B8=8ETypeScript=20ECS=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=20(#228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 集成Rust WASM渲染引擎与TypeScript ECS框架 * feat: 增强编辑器UI功能与跨平台支持 * fix: 修复CI测试和类型检查问题 * fix: 修复CI问题并提高测试覆盖率 * fix: 修复CI问题并提高测试覆盖率 --- .github/workflows/ci.yml | 9 +- package-lock.json | 189 ++++- packages/core/package.json | 2 +- packages/core/src/ECS/Component.ts | 2 + packages/core/src/ECS/Core/SoASerializer.ts | 108 +++ packages/core/src/ECS/Core/SoAStorage.ts | 657 +++++++----------- packages/core/src/ECS/Core/SoATypeRegistry.ts | 208 ++++++ packages/core/src/ECS/Core/Storage/index.ts | 2 +- .../core/src/ECS/Core/StorageDecorators.ts | 5 - .../core/tests/ECS/Core/SoASerializer.test.ts | 183 +++++ .../ECS/Core/SoAStorage.complete.test.ts | 9 +- .../tests/ECS/Core/SoATypeRegistry.test.ts | 233 +++++++ packages/ecs-engine-bindgen/package.json | 49 ++ .../src/components/SpriteComponent.ts | 161 +++++ .../src/core/EngineBridge.ts | 335 +++++++++ .../src/core/RenderBatcher.ts | 108 +++ .../src/core/SpriteRenderHelper.ts | 140 ++++ packages/ecs-engine-bindgen/src/index.ts | 13 + .../src/systems/EngineRenderSystem.ts | 171 +++++ .../ecs-engine-bindgen/src/types/index.ts | 72 ++ packages/ecs-engine-bindgen/tsconfig.json | 22 + packages/editor-app/package.json | 7 +- packages/editor-app/src/App.tsx | 21 +- .../commands/component/AddComponentCommand.ts | 54 ++ .../component/RemoveComponentCommand.ts | 57 ++ .../component/UpdateComponentCommand.ts | 76 ++ .../application/commands/component/index.ts | 3 + .../commands/entity/CreateEntityCommand.ts | 58 ++ .../commands/entity/DeleteEntityCommand.ts | 91 +++ .../src/application/commands/entity/index.ts | 2 + .../src/components/SceneHierarchy.tsx | 131 +++- .../editor-app/src/components/Viewport.tsx | 51 +- .../src/components/inspectors/Inspector.tsx | 4 +- .../src/components/inspectors/types.ts | 3 +- .../inspectors/views/EntityInspector.tsx | 112 ++- .../src/core/events/EditorEventMap.ts | 1 - packages/editor-app/src/hooks/useEngine.ts | 127 ++++ .../editor-app/src/services/EngineService.ts | 254 +++++++ .../editor-app/src/styles/SceneHierarchy.css | 88 ++- .../editor-app/src/styles/design-tokens.css | 2 +- packages/editor-app/vite.config.ts | 115 ++- packages/engine/.gitignore | 16 + packages/engine/Cargo.toml | 86 +++ packages/engine/package.json | 37 + packages/engine/src/core/context.rs | 153 ++++ packages/engine/src/core/engine.rs | 187 +++++ packages/engine/src/core/error.rs | 58 ++ packages/engine/src/core/mod.rs | 10 + packages/engine/src/input/input_manager.rs | 61 ++ packages/engine/src/input/keyboard.rs | 82 +++ packages/engine/src/input/mod.rs | 12 + packages/engine/src/input/mouse.rs | 136 ++++ packages/engine/src/input/touch.rs | 164 +++++ packages/engine/src/lib.rs | 195 ++++++ packages/engine/src/math/color.rs | 184 +++++ packages/engine/src/math/mod.rs | 19 + packages/engine/src/math/rect.rs | 148 ++++ packages/engine/src/math/transform.rs | 164 +++++ packages/engine/src/math/vec2.rs | 214 ++++++ packages/engine/src/platform/mod.rs | 51 ++ packages/engine/src/platform/web.rs | 146 ++++ packages/engine/src/renderer/batch/mod.rs | 8 + .../engine/src/renderer/batch/sprite_batch.rs | 401 +++++++++++ packages/engine/src/renderer/batch/vertex.rs | 60 ++ packages/engine/src/renderer/camera.rs | 144 ++++ packages/engine/src/renderer/mod.rs | 14 + packages/engine/src/renderer/renderer2d.rs | 134 ++++ .../engine/src/renderer/shader/builtin.rs | 63 ++ packages/engine/src/renderer/shader/mod.rs | 8 + .../engine/src/renderer/shader/program.rs | 154 ++++ packages/engine/src/renderer/texture/mod.rs | 8 + .../engine/src/renderer/texture/texture.rs | 39 ++ .../src/renderer/texture/texture_manager.rs | 217 ++++++ packages/engine/src/resource/handle.rs | 55 ++ packages/engine/src/resource/mod.rs | 7 + packages/platform-common/README.md | 48 ++ packages/platform-common/package.json | 50 ++ packages/platform-common/rollup.config.js | 40 ++ .../src/IPlatformSubsystems.ts | 632 +++++++++++++++++ packages/platform-common/src/index.ts | 42 ++ packages/platform-common/tsconfig.json | 24 + packages/platform-web/package.json | 54 ++ packages/platform-web/rollup.config.js | 42 ++ packages/platform-web/src/EngineBridge.ts | 254 +++++++ packages/platform-web/src/index.ts | 19 + .../src/subsystems/WebCanvasSubsystem.ts | 174 +++++ .../src/subsystems/WebInputSubsystem.ts | 102 +++ .../src/subsystems/WebStorageSubsystem.ts | 77 ++ .../src/subsystems/WebWASMSubsystem.ts | 44 ++ packages/platform-web/tsconfig.json | 24 + packages/platform-wechat/README.md | 64 ++ packages/platform-wechat/package.json | 56 ++ packages/platform-wechat/rollup.config.js | 44 ++ packages/platform-wechat/src/EngineBridge.ts | 235 +++++++ packages/platform-wechat/src/WeChatAdapter.ts | 289 ++++++++ packages/platform-wechat/src/index.ts | 23 + .../src/subsystems/WeChatAudioSubsystem.ts | 88 +++ .../src/subsystems/WeChatCanvasSubsystem.ts | 209 ++++++ .../src/subsystems/WeChatFileSubsystem.ts | 204 ++++++ .../src/subsystems/WeChatInputSubsystem.ts | 77 ++ .../src/subsystems/WeChatNetworkSubsystem.ts | 181 +++++ .../src/subsystems/WeChatStorageSubsystem.ts | 67 ++ .../src/subsystems/WeChatWASMSubsystem.ts | 65 ++ .../src/types/wx-extensions.d.ts | 27 + packages/platform-wechat/src/utils.ts | 46 ++ packages/platform-wechat/tsconfig.json | 25 + thirdparty/BehaviourTree-ai | 2 +- 107 files changed, 10221 insertions(+), 477 deletions(-) create mode 100644 packages/core/src/ECS/Core/SoASerializer.ts create mode 100644 packages/core/src/ECS/Core/SoATypeRegistry.ts create mode 100644 packages/core/tests/ECS/Core/SoASerializer.test.ts create mode 100644 packages/core/tests/ECS/Core/SoATypeRegistry.test.ts create mode 100644 packages/ecs-engine-bindgen/package.json create mode 100644 packages/ecs-engine-bindgen/src/components/SpriteComponent.ts create mode 100644 packages/ecs-engine-bindgen/src/core/EngineBridge.ts create mode 100644 packages/ecs-engine-bindgen/src/core/RenderBatcher.ts create mode 100644 packages/ecs-engine-bindgen/src/core/SpriteRenderHelper.ts create mode 100644 packages/ecs-engine-bindgen/src/index.ts create mode 100644 packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts create mode 100644 packages/ecs-engine-bindgen/src/types/index.ts create mode 100644 packages/ecs-engine-bindgen/tsconfig.json create mode 100644 packages/editor-app/src/application/commands/component/AddComponentCommand.ts create mode 100644 packages/editor-app/src/application/commands/component/RemoveComponentCommand.ts create mode 100644 packages/editor-app/src/application/commands/component/UpdateComponentCommand.ts create mode 100644 packages/editor-app/src/application/commands/component/index.ts create mode 100644 packages/editor-app/src/application/commands/entity/CreateEntityCommand.ts create mode 100644 packages/editor-app/src/application/commands/entity/DeleteEntityCommand.ts create mode 100644 packages/editor-app/src/application/commands/entity/index.ts create mode 100644 packages/editor-app/src/hooks/useEngine.ts create mode 100644 packages/editor-app/src/services/EngineService.ts create mode 100644 packages/engine/.gitignore create mode 100644 packages/engine/Cargo.toml create mode 100644 packages/engine/package.json create mode 100644 packages/engine/src/core/context.rs create mode 100644 packages/engine/src/core/engine.rs create mode 100644 packages/engine/src/core/error.rs create mode 100644 packages/engine/src/core/mod.rs create mode 100644 packages/engine/src/input/input_manager.rs create mode 100644 packages/engine/src/input/keyboard.rs create mode 100644 packages/engine/src/input/mod.rs create mode 100644 packages/engine/src/input/mouse.rs create mode 100644 packages/engine/src/input/touch.rs create mode 100644 packages/engine/src/lib.rs create mode 100644 packages/engine/src/math/color.rs create mode 100644 packages/engine/src/math/mod.rs create mode 100644 packages/engine/src/math/rect.rs create mode 100644 packages/engine/src/math/transform.rs create mode 100644 packages/engine/src/math/vec2.rs create mode 100644 packages/engine/src/platform/mod.rs create mode 100644 packages/engine/src/platform/web.rs create mode 100644 packages/engine/src/renderer/batch/mod.rs create mode 100644 packages/engine/src/renderer/batch/sprite_batch.rs create mode 100644 packages/engine/src/renderer/batch/vertex.rs create mode 100644 packages/engine/src/renderer/camera.rs create mode 100644 packages/engine/src/renderer/mod.rs create mode 100644 packages/engine/src/renderer/renderer2d.rs create mode 100644 packages/engine/src/renderer/shader/builtin.rs create mode 100644 packages/engine/src/renderer/shader/mod.rs create mode 100644 packages/engine/src/renderer/shader/program.rs create mode 100644 packages/engine/src/renderer/texture/mod.rs create mode 100644 packages/engine/src/renderer/texture/texture.rs create mode 100644 packages/engine/src/renderer/texture/texture_manager.rs create mode 100644 packages/engine/src/resource/handle.rs create mode 100644 packages/engine/src/resource/mod.rs create mode 100644 packages/platform-common/README.md create mode 100644 packages/platform-common/package.json create mode 100644 packages/platform-common/rollup.config.js create mode 100644 packages/platform-common/src/IPlatformSubsystems.ts create mode 100644 packages/platform-common/src/index.ts create mode 100644 packages/platform-common/tsconfig.json create mode 100644 packages/platform-web/package.json create mode 100644 packages/platform-web/rollup.config.js create mode 100644 packages/platform-web/src/EngineBridge.ts create mode 100644 packages/platform-web/src/index.ts create mode 100644 packages/platform-web/src/subsystems/WebCanvasSubsystem.ts create mode 100644 packages/platform-web/src/subsystems/WebInputSubsystem.ts create mode 100644 packages/platform-web/src/subsystems/WebStorageSubsystem.ts create mode 100644 packages/platform-web/src/subsystems/WebWASMSubsystem.ts create mode 100644 packages/platform-web/tsconfig.json create mode 100644 packages/platform-wechat/README.md create mode 100644 packages/platform-wechat/package.json create mode 100644 packages/platform-wechat/rollup.config.js create mode 100644 packages/platform-wechat/src/EngineBridge.ts create mode 100644 packages/platform-wechat/src/WeChatAdapter.ts create mode 100644 packages/platform-wechat/src/index.ts create mode 100644 packages/platform-wechat/src/subsystems/WeChatAudioSubsystem.ts create mode 100644 packages/platform-wechat/src/subsystems/WeChatCanvasSubsystem.ts create mode 100644 packages/platform-wechat/src/subsystems/WeChatFileSubsystem.ts create mode 100644 packages/platform-wechat/src/subsystems/WeChatInputSubsystem.ts create mode 100644 packages/platform-wechat/src/subsystems/WeChatNetworkSubsystem.ts create mode 100644 packages/platform-wechat/src/subsystems/WeChatStorageSubsystem.ts create mode 100644 packages/platform-wechat/src/subsystems/WeChatWASMSubsystem.ts create mode 100644 packages/platform-wechat/src/types/wx-extensions.d.ts create mode 100644 packages/platform-wechat/src/utils.ts create mode 100644 packages/platform-wechat/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e8520a1..403938d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,15 +37,18 @@ jobs: - name: Install dependencies run: npm ci + - name: Build core package first + run: npm run build:core + + - name: Build platform-common for type declarations + run: cd packages/platform-common && npm run build + - name: Type check run: npm run type-check - name: Lint check run: npm run lint - - name: Build core package first - run: npm run build:core - - name: Run tests with coverage run: npm run test:ci diff --git a/package-lock.json b/package-lock.json index 58cf5ec7..9709dd5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3256,6 +3256,10 @@ "resolved": "packages/behavior-tree-editor", "link": true }, + "node_modules/@esengine/ecs-engine-bindgen": { + "resolved": "packages/ecs-engine-bindgen", + "link": true + }, "node_modules/@esengine/ecs-framework": { "resolved": "packages/core", "link": true @@ -3272,6 +3276,10 @@ "resolved": "packages/editor-core", "link": true }, + "node_modules/@esengine/engine": { + "resolved": "packages/engine", + "link": true + }, "node_modules/@esengine/network-client": { "resolved": "packages/network-client", "link": true @@ -3284,6 +3292,18 @@ "resolved": "packages/network-shared", "link": true }, + "node_modules/@esengine/platform-common": { + "resolved": "packages/platform-common", + "link": true + }, + "node_modules/@esengine/platform-web": { + "resolved": "packages/platform-web", + "link": true + }, + "node_modules/@esengine/platform-wechat": { + "resolved": "packages/platform-wechat", + "link": true + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -6010,6 +6030,51 @@ } } }, + "node_modules/@rollup/plugin-typescript": { + "version": "11.1.6", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.6.tgz", + "integrity": "sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0||^4.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-virtual": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz", + "integrity": "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", @@ -8211,6 +8276,13 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@swc/wasm": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.2.tgz", + "integrity": "sha512-m9uPmG1M4uHCKN2hMKGWH+wy1S/ULoP8ojH967GIFPjSvxqm32rw7DGAIP0vBLc4UKBux9hJtTiwkgFqM79XhQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@tauri-apps/api": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.8.0.tgz", @@ -12541,6 +12613,10 @@ "node": ">= 0.4" } }, + "node_modules/es-engine": { + "resolved": "packages/engine/pkg", + "link": true + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -28384,6 +28460,32 @@ "vite": "^7 || ^6 || ^5" } }, + "node_modules/vite-plugin-top-level-await": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz", + "integrity": "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-virtual": "^3.0.2", + "@swc/core": "^1.12.14", + "@swc/wasm": "^1.12.14", + "uuid": "10.0.0" + }, + "peerDependencies": { + "vite": ">=2.8" + } + }, + "node_modules/vite-plugin-wasm": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.5.0.tgz", + "integrity": "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" + } + }, "node_modules/vitepress": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", @@ -29184,14 +29286,30 @@ "typescript-eslint": "^8.46.1" } }, + "packages/ecs-engine-bindgen": { + "name": "@esengine/ecs-engine-bindgen", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@esengine/ecs-framework": "file:../core" + }, + "devDependencies": { + "rimraf": "^5.0.0", + "typescript": "^5.8.0" + }, + "peerDependencies": { + "es-engine": "file:../engine/pkg" + } + }, "packages/editor-app": { "name": "@esengine/editor-app", "version": "1.0.5", "dependencies": { "@esengine/behavior-tree": "file:../behavior-tree", - "@esengine/behavior-tree-editor": "file:../behavior-tree-editor", + "@esengine/ecs-engine-bindgen": "file:../ecs-engine-bindgen", "@esengine/ecs-framework": "file:../core", "@esengine/editor-core": "file:../editor-core", + "@esengine/engine": "file:../engine", "@tauri-apps/api": "^2.2.0", "@tauri-apps/plugin-dialog": "^2.4.0", "@tauri-apps/plugin-fs": "^2.4.2", @@ -29224,7 +29342,9 @@ "sharp": "^0.34.4", "typescript": "^5.8.3", "vite": "^6.0.7", - "vite-plugin-swc-transform": "^1.1.1" + "vite-plugin-swc-transform": "^1.1.1", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0" } }, "packages/editor-app/node_modules/@esbuild/aix-ppc64": { @@ -29784,6 +29904,20 @@ "@esengine/ecs-framework": "^2.2.8" } }, + "packages/engine": { + "name": "@esengine/engine", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "rimraf": "^5.0.0" + } + }, + "packages/engine/pkg": { + "name": "es-engine", + "version": "0.1.0", + "license": "MIT", + "peer": true + }, "packages/math": { "name": "@esengine/ecs-framework-math", "version": "1.0.5", @@ -29895,6 +30029,57 @@ "ts-jest": "^29.4.0", "typescript": "^5.8.3" } + }, + "packages/platform-common": { + "name": "@esengine/platform-common", + "version": "1.0.0", + "license": "MIT", + "peer": true, + "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" + } + }, + "packages/platform-web": { + "name": "@esengine/platform-web", + "version": "1.0.0", + "license": "MIT", + "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" + }, + "peerDependencies": { + "@esengine/platform-common": "^1.0.0" + } + }, + "packages/platform-wechat": { + "name": "@esengine/platform-wechat", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@rollup/plugin-commonjs": "^28.0.3", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^11.1.6", + "minigame-api-typings": "^3.8.12", + "rimraf": "^5.0.0", + "rollup": "^4.42.0", + "rollup-plugin-dts": "^6.2.1", + "typescript": "^5.8.3" + }, + "peerDependencies": { + "@esengine/ecs-framework": "^2.0.0", + "@esengine/platform-common": "^1.0.0" + } } } } diff --git a/packages/core/package.json b/packages/core/package.json index b18287ec..0c9c29a5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,7 +40,7 @@ "test:coverage": "jest --coverage --config jest.config.cjs", "test:ci": "jest --ci --coverage --config jest.config.cjs", "test:clear": "jest --clearCache", - "type-check": "tsc --noEmit", + "type-check": "npx tsc --noEmit", "lint": "eslint \"src/**/*.{ts,tsx}\"", "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix" }, diff --git a/packages/core/src/ECS/Component.ts b/packages/core/src/ECS/Component.ts index 233ec2ed..dda0824a 100644 --- a/packages/core/src/ECS/Component.ts +++ b/packages/core/src/ECS/Component.ts @@ -1,4 +1,5 @@ import type { IComponent } from '../Types'; +import { Int32 } from './Core/SoAStorage'; /** * 游戏组件基类 @@ -50,6 +51,7 @@ export abstract class Component implements IComponent { * * 存储实体ID而非引用,避免循环引用,符合ECS数据导向设计。 */ + @Int32 public entityId: number | null = null; /** diff --git a/packages/core/src/ECS/Core/SoASerializer.ts b/packages/core/src/ECS/Core/SoASerializer.ts new file mode 100644 index 00000000..e3da99cb --- /dev/null +++ b/packages/core/src/ECS/Core/SoASerializer.ts @@ -0,0 +1,108 @@ +import { createLogger } from '../../Utils/Logger'; + +/** + * SoA 序列化器 + * 负责复杂类型的序列化/反序列化和深拷贝 + */ +export class SoASerializer { + private static readonly _logger = createLogger('SoASerializer'); + + /** + * 序列化值为 JSON 字符串 + */ + public static serialize( + value: unknown, + fieldName: string, + options: { + isMap?: boolean; + isSet?: boolean; + isArray?: boolean; + } = {} + ): string { + try { + if (options.isMap && value instanceof Map) { + return JSON.stringify(Array.from(value.entries())); + } + if (options.isSet && value instanceof Set) { + return JSON.stringify(Array.from(value)); + } + if (options.isArray && Array.isArray(value)) { + return JSON.stringify(value); + } + return JSON.stringify(value); + } catch (error) { + this._logger.warn(`SoA序列化字段 ${fieldName} 失败:`, error); + return '{}'; + } + } + + /** + * 反序列化 JSON 字符串为值 + */ + public static deserialize( + serialized: string, + fieldName: string, + options: { + isMap?: boolean; + isSet?: boolean; + isArray?: boolean; + } = {} + ): unknown { + try { + const parsed = JSON.parse(serialized); + + if (options.isMap) { + return new Map(parsed); + } + if (options.isSet) { + return new Set(parsed); + } + return parsed; + } catch (error) { + this._logger.warn(`SoA反序列化字段 ${fieldName} 失败:`, error); + return null; + } + } + + /** + * 深拷贝对象 + */ + public static deepClone(obj: T): T { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (obj instanceof Date) { + return new Date(obj.getTime()) as T; + } + + if (Array.isArray(obj)) { + return obj.map((item) => this.deepClone(item)) as T; + } + + if (obj instanceof Map) { + const cloned = new Map(); + for (const [key, value] of obj.entries()) { + cloned.set(key, this.deepClone(value)); + } + return cloned as T; + } + + if (obj instanceof Set) { + const cloned = new Set(); + for (const value of obj.values()) { + cloned.add(this.deepClone(value)); + } + return cloned as T; + } + + // 普通对象 + const cloned = {} as Record; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + cloned[key] = this.deepClone((obj as Record)[key]); + } + } + return cloned as T; + } +} diff --git a/packages/core/src/ECS/Core/SoAStorage.ts b/packages/core/src/ECS/Core/SoAStorage.ts index 4644f3b6..784a07f4 100644 --- a/packages/core/src/ECS/Core/SoAStorage.ts +++ b/packages/core/src/ECS/Core/SoAStorage.ts @@ -1,6 +1,16 @@ import { Component } from '../Component'; import { ComponentType } from './ComponentStorage'; -import { createLogger } from '../../Utils/Logger'; +import { + SoATypeRegistry, + SupportedTypedArray, + TypedArrayTypeName +} from './SoATypeRegistry'; +import { SoASerializer } from './SoASerializer'; + +// 重新导出类型,保持向后兼容 +export { SupportedTypedArray, TypedArrayTypeName } from './SoATypeRegistry'; +export { SoATypeRegistry } from './SoATypeRegistry'; +export { SoASerializer } from './SoASerializer'; /** * 启用SoA优化装饰器 @@ -12,18 +22,6 @@ export function EnableSoA(target: T): T { } -/** - * 高精度数值装饰器 - * 标记字段需要保持完整精度,存储为复杂对象而非TypedArray - */ -export function HighPrecision(target: any, propertyKey: string | symbol): void { - const key = String(propertyKey); - if (!target.constructor.__highPrecisionFields) { - target.constructor.__highPrecisionFields = new Set(); - } - target.constructor.__highPrecisionFields.add(key); -} - /** * 64位浮点数装饰器 * 标记字段使用Float64Array存储(更高精度但更多内存) @@ -181,164 +179,16 @@ export function DeepCopy(target: any, propertyKey: string | symbol): void { target.constructor.__deepCopyFields.add(key); } -/** - * 自动类型推断装饰器 - * 根据字段的默认值和数值范围自动选择最优的TypedArray类型 - * - * @param options 类型推断选项 - * @param options.minValue 数值的最小值(用于范围优化) - * @param options.maxValue 数值的最大值(用于范围优化) - * @param options.precision 是否需要浮点精度(true: 使用浮点数组, false: 使用整数数组) - * @param options.signed 是否需要符号位(仅在整数模式下有效) - */ -export function AutoTyped(options?: { - minValue?: number; - maxValue?: number; - precision?: boolean; - signed?: boolean; -}) { - return function (target: any, propertyKey: string | symbol): void { - const key = String(propertyKey); - if (!target.constructor.__autoTypedFields) { - target.constructor.__autoTypedFields = new Map(); - } - target.constructor.__autoTypedFields.set(key, options || {}); - }; -} - -/** - * 自动类型推断器 - * 根据数值类型和范围自动选择最优的TypedArray类型 - */ -export class TypeInference { - /** - * 根据数值范围推断最优的TypedArray类型 - */ - public static inferOptimalType(value: any, options: { - minValue?: number; - maxValue?: number; - precision?: boolean; - signed?: boolean; - } = {}): string { - const type = typeof value; - - if (type === 'boolean') { - return 'uint8'; // 布尔值使用最小的无符号整数 - } - - if (type !== 'number') { - return 'float32'; // 非数值类型默认使用Float32 - } - - const { minValue, maxValue, precision, signed } = options; - - // 如果显式要求精度,使用浮点数 - if (precision === true) { - // 检查是否需要双精度 - if (Math.abs(value) > 3.4028235e+38 || (minValue !== undefined && Math.abs(minValue) > 3.4028235e+38) || (maxValue !== undefined && Math.abs(maxValue) > 3.4028235e+38)) { - return 'float64'; - } - return 'float32'; - } - - // 如果显式禁用精度,或者是整数值,尝试使用整数数组 - if (precision === false || Number.isInteger(value)) { - const actualMin = minValue !== undefined ? minValue : value; - const actualMax = maxValue !== undefined ? maxValue : value; - const needsSigned = signed !== false && (actualMin < 0 || value < 0); - - // 根据范围选择最小的整数类型 - if (needsSigned) { - // 有符号整数 - if (actualMin >= -128 && actualMax <= 127) { - return 'int8'; - } else if (actualMin >= -32768 && actualMax <= 32767) { - return 'int16'; - } else if (actualMin >= -2147483648 && actualMax <= 2147483647) { - return 'int32'; - } else { - return 'float64'; // 超出int32范围,使用双精度浮点 - } - } else { - // 无符号整数 - if (actualMax <= 255) { - return 'uint8'; - } else if (actualMax <= 65535) { - return 'uint16'; - } else if (actualMax <= 4294967295) { - return 'uint32'; - } else { - return 'float64'; // 超出uint32范围,使用双精度浮点 - } - } - } - - // 默认情况:检查是否为小数 - if (!Number.isInteger(value)) { - return 'float32'; - } - - // 整数值,但没有指定范围,根据值的大小选择 - if (value >= 0 && value <= 255) { - return 'uint8'; - } else if (value >= -128 && value <= 127) { - return 'int8'; - } else if (value >= 0 && value <= 65535) { - return 'uint16'; - } else if (value >= -32768 && value <= 32767) { - return 'int16'; - } else if (value >= 0 && value <= 4294967295) { - return 'uint32'; - } else if (value >= -2147483648 && value <= 2147483647) { - return 'int32'; - } else { - return 'float64'; - } - } - - /** - * 根据推断的类型名创建对应的TypedArray构造函数 - */ - public static getTypedArrayConstructor(typeName: string): typeof Float32Array | typeof Float64Array | typeof Int32Array | typeof Uint32Array | typeof Int16Array | typeof Uint16Array | typeof Int8Array | typeof Uint8Array | typeof Uint8ClampedArray { - switch (typeName) { - case 'float32': return Float32Array; - case 'float64': return Float64Array; - case 'int32': return Int32Array; - case 'uint32': return Uint32Array; - case 'int16': return Int16Array; - case 'uint16': return Uint16Array; - case 'int8': return Int8Array; - case 'uint8': return Uint8Array; - case 'uint8clamped': return Uint8ClampedArray; - default: return Float32Array; - } - } -} - -/** - * SoA存储器支持的TypedArray类型 - */ -export type SupportedTypedArray = - | Float32Array - | Float64Array - | Int32Array - | Uint32Array - | Int16Array - | Uint16Array - | Int8Array - | Uint8Array - | Uint8ClampedArray; /** * SoA存储器(需要装饰器启用) * 使用Structure of Arrays存储模式,在大规模批量操作时提供优异性能 */ export class SoAStorage { - private static readonly _logger = createLogger('SoAStorage'); private fields = new Map(); - private stringFields = new Map(); // 专门存储字符串 - private serializedFields = new Map(); // 序列化存储Map/Set/Array - private complexFields = new Map>(); // 存储复杂对象 + private stringFields = new Map(); + private serializedFields = new Map(); + private complexFields = new Map>(); private entityToIndex = new Map(); private indexToEntity: number[] = []; private freeIndices: number[] = []; @@ -346,6 +196,13 @@ export class SoAStorage { private _capacity = 1000; public readonly type: ComponentType; + // 缓存字段类型信息,避免重复创建实例 + private fieldTypes = new Map(); + // 缓存装饰器元数据 + private serializeMapFields: Set = new Set(); + private serializeSetFields: Set = new Set(); + private serializeArrayFields: Set = new Set(); + constructor(componentType: ComponentType) { this.type = componentType; this.initializeFields(componentType); @@ -353,88 +210,85 @@ export class SoAStorage { private initializeFields(componentType: ComponentType): void { const instance = new componentType(); - const highPrecisionFields = (componentType as any).__highPrecisionFields || new Set(); - const float64Fields = (componentType as any).__float64Fields || new Set(); - const float32Fields = (componentType as any).__float32Fields || new Set(); - const int32Fields = (componentType as any).__int32Fields || new Set(); - const uint32Fields = (componentType as any).__uint32Fields || new Set(); - const int16Fields = (componentType as any).__int16Fields || new Set(); - const uint16Fields = (componentType as any).__uint16Fields || new Set(); - const int8Fields = (componentType as any).__int8Fields || new Set(); - const uint8Fields = (componentType as any).__uint8Fields || new Set(); - const uint8ClampedFields = (componentType as any).__uint8ClampedFields || new Set(); - const autoTypedFields = (componentType as any).__autoTypedFields || new Map(); - const serializeMapFields = (componentType as any).__serializeMapFields || new Set(); - const serializeSetFields = (componentType as any).__serializeSetFields || new Set(); - const serializeArrayFields = (componentType as any).__serializeArrayFields || new Set(); - // const deepCopyFields = (componentType as any).__deepCopyFields || new Set(); // 未使用,但保留供future使用 + const typeWithMeta = componentType as ComponentType & { + __float64Fields?: Set; + __float32Fields?: Set; + __int32Fields?: Set; + __uint32Fields?: Set; + __int16Fields?: Set; + __uint16Fields?: Set; + __int8Fields?: Set; + __uint8Fields?: Set; + __uint8ClampedFields?: Set; + __serializeMapFields?: Set; + __serializeSetFields?: Set; + __serializeArrayFields?: Set; + }; - for (const key in instance) { - if (instance.hasOwnProperty(key) && key !== 'id') { - const value = (instance as any)[key]; - const type = typeof value; + const float64Fields = typeWithMeta.__float64Fields || new Set(); + const float32Fields = typeWithMeta.__float32Fields || new Set(); + const int32Fields = typeWithMeta.__int32Fields || new Set(); + const uint32Fields = typeWithMeta.__uint32Fields || new Set(); + const int16Fields = typeWithMeta.__int16Fields || new Set(); + const uint16Fields = typeWithMeta.__uint16Fields || new Set(); + const int8Fields = typeWithMeta.__int8Fields || new Set(); + const uint8Fields = typeWithMeta.__uint8Fields || new Set(); + const uint8ClampedFields = typeWithMeta.__uint8ClampedFields || new Set(); - if (type === 'number') { - if (highPrecisionFields.has(key)) { - // 标记为高精度,作为复杂对象处理 - // 不添加到fields,会在updateComponentAtIndex中自动添加到complexFields - } else if (autoTypedFields.has(key)) { - // 使用自动类型推断 - const options = autoTypedFields.get(key); - const inferredType = TypeInference.inferOptimalType(value, options); - const ArrayConstructor = TypeInference.getTypedArrayConstructor(inferredType); - this.fields.set(key, new ArrayConstructor(this._capacity)); - SoAStorage._logger.info(`字段 ${key} 自动推断为 ${inferredType} 类型,值: ${value}, 选项:`, options); - } else if (float64Fields.has(key)) { - // 使用Float64Array存储(高精度浮点数) - this.fields.set(key, new Float64Array(this._capacity)); - } else if (int32Fields.has(key)) { - // 使用Int32Array存储(32位有符号整数) - this.fields.set(key, new Int32Array(this._capacity)); - } else if (uint32Fields.has(key)) { - // 使用Uint32Array存储(32位无符号整数) - this.fields.set(key, new Uint32Array(this._capacity)); - } else if (int16Fields.has(key)) { - // 使用Int16Array存储(16位有符号整数) - this.fields.set(key, new Int16Array(this._capacity)); - } else if (uint16Fields.has(key)) { - // 使用Uint16Array存储(16位无符号整数) - this.fields.set(key, new Uint16Array(this._capacity)); - } else if (int8Fields.has(key)) { - // 使用Int8Array存储(8位有符号整数) - this.fields.set(key, new Int8Array(this._capacity)); - } else if (uint8Fields.has(key)) { - // 使用Uint8Array存储(8位无符号整数) - this.fields.set(key, new Uint8Array(this._capacity)); - } else if (uint8ClampedFields.has(key)) { - // 使用Uint8ClampedArray存储(8位夹紧无符号整数) - this.fields.set(key, new Uint8ClampedArray(this._capacity)); - } else if (float32Fields.has(key)) { - // 使用Float32Array存储(32位浮点数) - this.fields.set(key, new Float32Array(this._capacity)); - } else { - // 默认使用Float32Array - this.fields.set(key, new Float32Array(this._capacity)); - } + // 缓存装饰器元数据 + this.serializeMapFields = typeWithMeta.__serializeMapFields || new Set(); + this.serializeSetFields = typeWithMeta.__serializeSetFields || new Set(); + this.serializeArrayFields = typeWithMeta.__serializeArrayFields || new Set(); + + // 先收集所有有装饰器的字段,避免重复遍历 + const decoratedFields = new Map(); // fieldName -> arrayType + + // 处理各类型装饰器标记的字段 + for (const key of float64Fields) decoratedFields.set(key, 'float64'); + for (const key of float32Fields) decoratedFields.set(key, 'float32'); + for (const key of int32Fields) decoratedFields.set(key, 'int32'); + for (const key of uint32Fields) decoratedFields.set(key, 'uint32'); + for (const key of int16Fields) decoratedFields.set(key, 'int16'); + for (const key of uint16Fields) decoratedFields.set(key, 'uint16'); + for (const key of int8Fields) decoratedFields.set(key, 'int8'); + for (const key of uint8Fields) decoratedFields.set(key, 'uint8'); + for (const key of uint8ClampedFields) decoratedFields.set(key, 'uint8clamped'); + + // 只遍历实例自身的属性(不包括原型链),跳过 id + const instanceKeys = Object.keys(instance).filter(key => key !== 'id'); + + for (const key of instanceKeys) { + const value = (instance as Record)[key]; + const type = typeof value; + + // 跳过函数(通常不会出现在 Object.keys 中,但以防万一) + if (type === 'function') continue; + + // 检查装饰器类型 + 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(更节省内存) - if (uint8Fields.has(key) || (!float32Fields.has(key) && !float64Fields.has(key))) { - this.fields.set(key, new Uint8Array(this._capacity)); - } else { - // 兼容性:如果显式指定浮点类型则使用原有方式 - this.fields.set(key, new Float32Array(this._capacity)); - } + // 布尔值使用 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 (serializeMapFields.has(key) || serializeSetFields.has(key) || serializeArrayFields.has(key)) { + if (this.serializeMapFields.has(key) || this.serializeSetFields.has(key) || this.serializeArrayFields.has(key)) { // 序列化存储 this.serializedFields.set(key, new Array(this._capacity)); } // 其他对象类型会在updateComponentAtIndex中作为复杂对象处理 - } } } } @@ -497,12 +351,16 @@ export class SoAStorage { } else if (this.serializedFields.has(key)) { // 序列化字段处理 const serializedArray = this.serializedFields.get(key)!; - serializedArray[index] = this.serializeValue(value, key, serializeMapFields, serializeSetFields, serializeArrayFields); + serializedArray[index] = SoASerializer.serialize(value, key, { + isMap: serializeMapFields.has(key), + isSet: serializeSetFields.has(key), + isArray: serializeArrayFields.has(key) + }); } else { // 复杂字段单独存储 if (deepCopyFields.has(key)) { // 深拷贝处理 - complexFieldMap.set(key, this.deepClone(value)); + complexFieldMap.set(key, SoASerializer.deepClone(value)); } else { complexFieldMap.set(key, value); } @@ -516,96 +374,6 @@ export class SoAStorage { } } - /** - * 序列化值为JSON字符串 - */ - private serializeValue(value: any, key: string, mapFields: Set, setFields: Set, arrayFields: Set): string { - try { - if (mapFields.has(key) && value instanceof Map) { - // Map序列化为数组形式 - return JSON.stringify(Array.from(value.entries())); - } else if (setFields.has(key) && value instanceof Set) { - // Set序列化为数组形式 - return JSON.stringify(Array.from(value)); - } else if (arrayFields.has(key) && Array.isArray(value)) { - // Array直接序列化 - return JSON.stringify(value); - } else { - // 其他对象直接序列化 - return JSON.stringify(value); - } - } catch (error) { - SoAStorage._logger.warn(`SoA序列化字段 ${key} 失败:`, error); - return '{}'; - } - } - - /** - * 反序列化JSON字符串为值 - */ - private deserializeValue(serialized: string, key: string, mapFields: Set, setFields: Set, arrayFields: Set): any { - try { - const parsed = JSON.parse(serialized); - - if (mapFields.has(key)) { - // 恢复Map - return new Map(parsed); - } else if (setFields.has(key)) { - // 恢复Set - return new Set(parsed); - } else if (arrayFields.has(key)) { - // 恢复Array - return parsed; - } else { - return parsed; - } - } catch (error) { - SoAStorage._logger.warn(`SoA反序列化字段 ${key} 失败:`, error); - return null; - } - } - - /** - * 深拷贝对象 - */ - private deepClone(obj: any): any { - if (obj === null || typeof obj !== 'object') { - return obj; - } - - if (obj instanceof Date) { - return new Date(obj.getTime()); - } - - if (obj instanceof Array) { - return obj.map((item) => this.deepClone(item)); - } - - if (obj instanceof Map) { - const cloned = new Map(); - for (const [key, value] of obj.entries()) { - cloned.set(key, this.deepClone(value)); - } - return cloned; - } - - if (obj instanceof Set) { - const cloned = new Set(); - for (const value of obj.values()) { - cloned.add(this.deepClone(value)); - } - return cloned; - } - - // 普通对象 - const cloned: any = {}; - for (const key in obj) { - if (obj.hasOwnProperty(key)) { - cloned[key] = this.deepClone(obj[key]); - } - } - return cloned; - } public getComponent(entityId: number): T | null { const index = this.entityToIndex.get(entityId); @@ -613,11 +381,162 @@ export class SoAStorage { return null; } - // 创建真正的组件实例以保持兼容性 + // 返回 Proxy,直接操作底层 TypedArray + return this.createProxyView(entityId, index); + } + + /** + * 创建组件的 Proxy 视图 + * 读写操作直接映射到底层 TypedArray,无数据复制 + */ + private createProxyView(entityId: number, index: number): T { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + // Proxy handler 类型定义 + const handler: ProxyHandler> = { + get(_, prop: string | symbol) { + const propStr = String(prop); + + // TypedArray 字段 + const array = self.fields.get(propStr); + if (array) { + const fieldType = self.getFieldType(propStr); + if (fieldType === 'boolean') { + return array[index] === 1; + } + return array[index]; + } + + // 字符串字段 + const stringArray = self.stringFields.get(propStr); + if (stringArray) { + return stringArray[index]; + } + + // 序列化字段 + const serializedArray = self.serializedFields.get(propStr); + if (serializedArray) { + const serialized = serializedArray[index]; + if (serialized) { + return SoASerializer.deserialize(serialized, propStr, { + isMap: self.serializeMapFields.has(propStr), + isSet: self.serializeSetFields.has(propStr), + isArray: self.serializeArrayFields.has(propStr) + }); + } + return undefined; + } + + // 复杂字段 + const complexFieldMap = self.complexFields.get(entityId); + if (complexFieldMap?.has(propStr)) { + return complexFieldMap.get(propStr); + } + + return undefined; + }, + + set(_, prop: string | symbol, value) { + const propStr = String(prop); + + // entityId 是只读的 + if (propStr === 'entityId') { + return false; + } + + // TypedArray 字段 + const array = self.fields.get(propStr); + if (array) { + const fieldType = self.getFieldType(propStr); + if (fieldType === 'boolean') { + array[index] = value ? 1 : 0; + } else { + array[index] = value; + } + return true; + } + + // 字符串字段 + const stringArray = self.stringFields.get(propStr); + if (stringArray) { + stringArray[index] = String(value); + return true; + } + + // 序列化字段 + if (self.serializedFields.has(propStr)) { + const serializedArray = self.serializedFields.get(propStr)!; + serializedArray[index] = SoASerializer.serialize(value, propStr, { + isMap: self.serializeMapFields.has(propStr), + isSet: self.serializeSetFields.has(propStr), + isArray: self.serializeArrayFields.has(propStr) + }); + return true; + } + + // 复杂字段 + let complexFieldMap = self.complexFields.get(entityId); + if (!complexFieldMap) { + complexFieldMap = new Map(); + self.complexFields.set(entityId, complexFieldMap); + } + complexFieldMap.set(propStr, value); + return true; + }, + + has(_, prop) { + const propStr = String(prop); + return self.fields.has(propStr) || + self.stringFields.has(propStr) || + self.serializedFields.has(propStr) || + self.complexFields.get(entityId)?.has(propStr) || false; + }, + + ownKeys() { + const keys: string[] = []; + for (const key of self.fields.keys()) keys.push(key); + for (const key of self.stringFields.keys()) keys.push(key); + for (const key of self.serializedFields.keys()) keys.push(key); + const complexFieldMap = self.complexFields.get(entityId); + if (complexFieldMap) { + for (const key of complexFieldMap.keys()) keys.push(key); + } + return keys; + }, + + getOwnPropertyDescriptor(_, prop) { + const propStr = String(prop); + if (self.fields.has(propStr) || + self.stringFields.has(propStr) || + self.serializedFields.has(propStr) || + self.complexFields.get(entityId)?.has(propStr)) { + return { + enumerable: true, + configurable: true, + // entityId 是只读的 + writable: propStr !== 'entityId', + }; + } + return undefined; + } + }; + + return new Proxy({} as Record, handler) as unknown as T; + } + + /** + * 获取组件的快照副本(用于序列化等需要独立副本的场景) + */ + public getComponentSnapshot(entityId: number): T | null { + const index = this.entityToIndex.get(entityId); + if (index === undefined) { + return null; + } + + // 需要 any 因为要动态写入泛型 T 的属性 + // eslint-disable-next-line @typescript-eslint/no-explicit-any const component = new this.type() as any; - const serializeMapFields = (this.type as any).__serializeMapFields || new Set(); - const serializeSetFields = (this.type as any).__serializeSetFields || new Set(); - const serializeArrayFields = (this.type as any).__serializeArrayFields || new Set(); // 恢复数值字段 for (const [fieldName, array] of this.fields.entries()) { @@ -640,7 +559,11 @@ export class SoAStorage { for (const [fieldName, serializedArray] of this.serializedFields.entries()) { const serialized = serializedArray[index]; if (serialized) { - component[fieldName] = this.deserializeValue(serialized, fieldName, serializeMapFields, serializeSetFields, serializeArrayFields); + component[fieldName] = SoASerializer.deserialize(serialized, fieldName, { + isMap: this.serializeMapFields.has(fieldName), + isSet: this.serializeSetFields.has(fieldName), + isArray: this.serializeArrayFields.has(fieldName) + }); } } @@ -656,10 +579,8 @@ export class SoAStorage { } private getFieldType(fieldName: string): string { - // 通过创建临时实例检查字段类型 - const tempInstance = new this.type(); - const value = (tempInstance as any)[fieldName]; - return typeof value; + // 使用缓存的字段类型 + return this.fieldTypes.get(fieldName) || 'unknown'; } public hasComponent(entityId: number): boolean { @@ -687,32 +608,7 @@ export class SoAStorage { private resize(newCapacity: number): void { // 调整数值字段的TypedArray for (const [fieldName, oldArray] of this.fields.entries()) { - let newArray: SupportedTypedArray; - - if (oldArray instanceof Float32Array) { - newArray = new Float32Array(newCapacity); - } else if (oldArray instanceof Float64Array) { - newArray = new Float64Array(newCapacity); - } else if (oldArray instanceof Int32Array) { - newArray = new Int32Array(newCapacity); - } else if (oldArray instanceof Uint32Array) { - newArray = new Uint32Array(newCapacity); - } else if (oldArray instanceof Int16Array) { - newArray = new Int16Array(newCapacity); - } else if (oldArray instanceof Uint16Array) { - newArray = new Uint16Array(newCapacity); - } else if (oldArray instanceof Int8Array) { - newArray = new Int8Array(newCapacity); - } else if (oldArray instanceof Uint8Array) { - newArray = new Uint8Array(newCapacity); - } else if (oldArray instanceof Uint8ClampedArray) { - newArray = new Uint8ClampedArray(newCapacity); - } else { - // 默认回退到Float32Array - newArray = new Float32Array(newCapacity); - SoAStorage._logger.warn(`未知的TypedArray类型用于字段 ${fieldName},回退到Float32Array`); - } - + const newArray = SoATypeRegistry.createSameType(oldArray, newCapacity); newArray.set(oldArray); this.fields.set(fieldName, newArray); } @@ -849,42 +745,8 @@ export class SoAStorage { const fieldStats = new Map(); for (const [fieldName, array] of this.fields.entries()) { - let bytesPerElement: number; - let typeName: string; - - if (array instanceof Float32Array) { - bytesPerElement = 4; - typeName = 'float32'; - } else if (array instanceof Float64Array) { - bytesPerElement = 8; - typeName = 'float64'; - } else if (array instanceof Int32Array) { - bytesPerElement = 4; - typeName = 'int32'; - } else if (array instanceof Uint32Array) { - bytesPerElement = 4; - typeName = 'uint32'; - } else if (array instanceof Int16Array) { - bytesPerElement = 2; - typeName = 'int16'; - } else if (array instanceof Uint16Array) { - bytesPerElement = 2; - typeName = 'uint16'; - } else if (array instanceof Int8Array) { - bytesPerElement = 1; - typeName = 'int8'; - } else if (array instanceof Uint8Array) { - bytesPerElement = 1; - typeName = 'uint8'; - } else if (array instanceof Uint8ClampedArray) { - bytesPerElement = 1; - typeName = 'uint8clamped'; - } else { - // 默认回退 - bytesPerElement = 4; - typeName = 'unknown'; - } - + const typeName = SoATypeRegistry.getTypeName(array); + const bytesPerElement = SoATypeRegistry.getBytesPerElement(typeName); const memory = array.length * bytesPerElement; totalMemory += memory; @@ -914,4 +776,5 @@ export class SoAStorage { const activeIndices = this.getActiveIndices(); operation(this.fields, activeIndices); } + } diff --git a/packages/core/src/ECS/Core/SoATypeRegistry.ts b/packages/core/src/ECS/Core/SoATypeRegistry.ts new file mode 100644 index 00000000..593bf8e9 --- /dev/null +++ b/packages/core/src/ECS/Core/SoATypeRegistry.ts @@ -0,0 +1,208 @@ +/** + * SoA存储器支持的TypedArray类型 + */ +export type SupportedTypedArray = + | Float32Array + | Float64Array + | Int32Array + | Uint32Array + | Int16Array + | Uint16Array + | Int8Array + | Uint8Array + | Uint8ClampedArray; + +export type TypedArrayConstructor = + | typeof Float32Array + | typeof Float64Array + | typeof Int32Array + | typeof Uint32Array + | typeof Int16Array + | typeof Uint16Array + | typeof Int8Array + | typeof Uint8Array + | typeof Uint8ClampedArray; + +/** + * TypedArray 类型名称 + */ +export type TypedArrayTypeName = + | 'float32' + | 'float64' + | 'int32' + | 'uint32' + | 'int16' + | 'uint16' + | 'int8' + | 'uint8' + | 'uint8clamped'; + +/** + * 字段元数据 + */ +export interface FieldMetadata { + name: string; + type: 'number' | 'boolean' | 'string' | 'object'; + arrayType?: TypedArrayTypeName; + isSerializedMap?: boolean; + isSerializedSet?: boolean; + isSerializedArray?: boolean; + isDeepCopy?: boolean; +} + +/** + * SoA 类型注册器 + * 负责类型推断、TypedArray 创建和元数据管理 + */ +export class SoATypeRegistry { + private static readonly TYPE_CONSTRUCTORS: Record = { + float32: Float32Array, + float64: Float64Array, + int32: Int32Array, + uint32: Uint32Array, + int16: Int16Array, + uint16: Uint16Array, + int8: Int8Array, + uint8: Uint8Array, + uint8clamped: Uint8ClampedArray + }; + + private static readonly TYPE_BYTES: Record = { + float32: 4, + float64: 8, + int32: 4, + uint32: 4, + int16: 2, + uint16: 2, + int8: 1, + uint8: 1, + uint8clamped: 1 + }; + + /** + * 获取 TypedArray 构造函数 + */ + public static getConstructor(typeName: TypedArrayTypeName): TypedArrayConstructor { + return this.TYPE_CONSTRUCTORS[typeName] || Float32Array; + } + + /** + * 获取每个元素的字节数 + */ + public static getBytesPerElement(typeName: TypedArrayTypeName): number { + return this.TYPE_BYTES[typeName] || 4; + } + + /** + * 从 TypedArray 实例获取类型名称 + */ + public static getTypeName(array: SupportedTypedArray): TypedArrayTypeName { + if (array instanceof Float32Array) return 'float32'; + if (array instanceof Float64Array) return 'float64'; + if (array instanceof Int32Array) return 'int32'; + if (array instanceof Uint32Array) return 'uint32'; + if (array instanceof Int16Array) return 'int16'; + if (array instanceof Uint16Array) return 'uint16'; + if (array instanceof Int8Array) return 'int8'; + if (array instanceof Uint8Array) return 'uint8'; + if (array instanceof Uint8ClampedArray) return 'uint8clamped'; + return 'float32'; + } + + /** + * 创建新的 TypedArray(与原数组同类型) + */ + public static createSameType(source: SupportedTypedArray, capacity: number): SupportedTypedArray { + const typeName = this.getTypeName(source); + const Constructor = this.getConstructor(typeName); + return new Constructor(capacity); + } + + /** + * 从组件类型提取字段元数据 + */ + public static extractFieldMetadata( + componentType: new () => T + ): Map { + const instance = new componentType(); + const metadata = new Map(); + + const typeWithMeta = componentType as typeof componentType & { + __float64Fields?: Set; + __float32Fields?: Set; + __int32Fields?: Set; + __uint32Fields?: Set; + __int16Fields?: Set; + __uint16Fields?: Set; + __int8Fields?: Set; + __uint8Fields?: Set; + __uint8ClampedFields?: Set; + __serializeMapFields?: Set; + __serializeSetFields?: Set; + __serializeArrayFields?: Set; + __deepCopyFields?: Set; + }; + + // 收集装饰器标记 + const decoratorMap = new Map(); + + const addDecorators = (fields: Set | undefined, type: TypedArrayTypeName) => { + if (fields) { + for (const key of fields) decoratorMap.set(key, type); + } + }; + + addDecorators(typeWithMeta.__float64Fields, 'float64'); + addDecorators(typeWithMeta.__float32Fields, 'float32'); + addDecorators(typeWithMeta.__int32Fields, 'int32'); + addDecorators(typeWithMeta.__uint32Fields, 'uint32'); + addDecorators(typeWithMeta.__int16Fields, 'int16'); + addDecorators(typeWithMeta.__uint16Fields, 'uint16'); + addDecorators(typeWithMeta.__int8Fields, 'int8'); + addDecorators(typeWithMeta.__uint8Fields, 'uint8'); + addDecorators(typeWithMeta.__uint8ClampedFields, 'uint8clamped'); + + // 遍历实例属性 + const instanceKeys = Object.keys(instance as object).filter((key) => key !== 'id'); + + for (const key of instanceKeys) { + const value = (instance as Record)[key]; + const type = typeof value; + + if (type === 'function') continue; + + const fieldMeta: FieldMetadata = { + name: key, + type: type as 'number' | 'boolean' | 'string' | 'object' + }; + + const decoratorType = decoratorMap.get(key); + + if (decoratorType) { + fieldMeta.arrayType = decoratorType; + } else if (type === 'number') { + fieldMeta.arrayType = 'float32'; + } else if (type === 'boolean') { + fieldMeta.arrayType = 'uint8'; + } + + // 序列化标记 + if (typeWithMeta.__serializeMapFields?.has(key)) { + fieldMeta.isSerializedMap = true; + } + if (typeWithMeta.__serializeSetFields?.has(key)) { + fieldMeta.isSerializedSet = true; + } + if (typeWithMeta.__serializeArrayFields?.has(key)) { + fieldMeta.isSerializedArray = true; + } + if (typeWithMeta.__deepCopyFields?.has(key)) { + fieldMeta.isDeepCopy = true; + } + + metadata.set(key, fieldMeta); + } + + return metadata; + } +} diff --git a/packages/core/src/ECS/Core/Storage/index.ts b/packages/core/src/ECS/Core/Storage/index.ts index 37edc40e..6dc45ec0 100644 --- a/packages/core/src/ECS/Core/Storage/index.ts +++ b/packages/core/src/ECS/Core/Storage/index.ts @@ -1,3 +1,3 @@ export { ComponentPool, ComponentPoolManager } from '../ComponentPool'; export { ComponentStorage, ComponentRegistry } from '../ComponentStorage'; -export { EnableSoA, HighPrecision, Float64, Float32, Int32, SerializeMap, SoAStorage } from '../SoAStorage'; +export { EnableSoA, Float64, Float32, Int32, SerializeMap, SoAStorage } from '../SoAStorage'; diff --git a/packages/core/src/ECS/Core/StorageDecorators.ts b/packages/core/src/ECS/Core/StorageDecorators.ts index 31ebbe55..d2d270f7 100644 --- a/packages/core/src/ECS/Core/StorageDecorators.ts +++ b/packages/core/src/ECS/Core/StorageDecorators.ts @@ -23,7 +23,6 @@ export { EnableSoA, // 数值类型装饰器 - HighPrecision, Float64, Float32, Int32, @@ -34,10 +33,6 @@ export { Uint8, Uint8Clamped, - // 自动类型推断 - AutoTyped, - TypeInference, - // 序列化装饰器 SerializeMap, SerializeSet, diff --git a/packages/core/tests/ECS/Core/SoASerializer.test.ts b/packages/core/tests/ECS/Core/SoASerializer.test.ts new file mode 100644 index 00000000..68777e44 --- /dev/null +++ b/packages/core/tests/ECS/Core/SoASerializer.test.ts @@ -0,0 +1,183 @@ +import { SoASerializer } from '../../../src/ECS/Core/SoASerializer'; + +describe('SoASerializer', () => { + describe('serialize', () => { + test('should serialize Map to JSON string', () => { + const map = new Map([['key1', 'value1'], ['key2', 'value2']]); + const result = SoASerializer.serialize(map, 'testMap', { isMap: true }); + expect(result).toBe('[["key1","value1"],["key2","value2"]]'); + }); + + test('should serialize Set to JSON string', () => { + const set = new Set([1, 2, 3]); + const result = SoASerializer.serialize(set, 'testSet', { isSet: true }); + expect(result).toBe('[1,2,3]'); + }); + + test('should serialize Array to JSON string', () => { + const arr = [1, 2, 3]; + const result = SoASerializer.serialize(arr, 'testArray', { isArray: true }); + expect(result).toBe('[1,2,3]'); + }); + + test('should serialize plain object to JSON string', () => { + const obj = { a: 1, b: 'test' }; + const result = SoASerializer.serialize(obj, 'testObj'); + expect(result).toBe('{"a":1,"b":"test"}'); + }); + + test('should serialize primitive values', () => { + expect(SoASerializer.serialize(42, 'num')).toBe('42'); + expect(SoASerializer.serialize('hello', 'str')).toBe('"hello"'); + expect(SoASerializer.serialize(true, 'bool')).toBe('true'); + expect(SoASerializer.serialize(null, 'null')).toBe('null'); + }); + + test('should return empty object on serialization error', () => { + const circular: Record = {}; + circular.self = circular; + const result = SoASerializer.serialize(circular, 'circular'); + expect(result).toBe('{}'); + }); + }); + + describe('deserialize', () => { + test('should deserialize JSON string to Map', () => { + const json = '[["key1","value1"],["key2","value2"]]'; + const result = SoASerializer.deserialize(json, 'testMap', { isMap: true }); + expect(result).toBeInstanceOf(Map); + expect((result as Map).get('key1')).toBe('value1'); + expect((result as Map).get('key2')).toBe('value2'); + }); + + test('should deserialize JSON string to Set', () => { + const json = '[1,2,3]'; + const result = SoASerializer.deserialize(json, 'testSet', { isSet: true }); + expect(result).toBeInstanceOf(Set); + expect((result as Set).has(1)).toBe(true); + expect((result as Set).has(2)).toBe(true); + expect((result as Set).has(3)).toBe(true); + }); + + test('should deserialize JSON string to Array', () => { + const json = '[1,2,3]'; + const result = SoASerializer.deserialize(json, 'testArray', { isArray: true }); + expect(result).toEqual([1, 2, 3]); + }); + + test('should deserialize JSON string to object', () => { + const json = '{"a":1,"b":"test"}'; + const result = SoASerializer.deserialize(json, 'testObj'); + expect(result).toEqual({ a: 1, b: 'test' }); + }); + + test('should deserialize primitive values', () => { + expect(SoASerializer.deserialize('42', 'num')).toBe(42); + expect(SoASerializer.deserialize('"hello"', 'str')).toBe('hello'); + expect(SoASerializer.deserialize('true', 'bool')).toBe(true); + expect(SoASerializer.deserialize('null', 'null')).toBe(null); + }); + + test('should return null on deserialization error', () => { + const result = SoASerializer.deserialize('invalid json', 'field'); + expect(result).toBe(null); + }); + }); + + describe('deepClone', () => { + test('should return primitive values as-is', () => { + expect(SoASerializer.deepClone(42)).toBe(42); + expect(SoASerializer.deepClone('hello')).toBe('hello'); + expect(SoASerializer.deepClone(true)).toBe(true); + expect(SoASerializer.deepClone(null)).toBe(null); + expect(SoASerializer.deepClone(undefined)).toBe(undefined); + }); + + test('should clone Date objects', () => { + const date = new Date('2023-01-01'); + const cloned = SoASerializer.deepClone(date); + expect(cloned).toBeInstanceOf(Date); + expect(cloned.getTime()).toBe(date.getTime()); + expect(cloned).not.toBe(date); + }); + + test('should clone arrays deeply', () => { + const arr = [1, [2, 3], { a: 4 }]; + const cloned = SoASerializer.deepClone(arr); + expect(cloned).toEqual(arr); + expect(cloned).not.toBe(arr); + expect(cloned[1]).not.toBe(arr[1]); + expect(cloned[2]).not.toBe(arr[2]); + }); + + test('should clone Map objects deeply', () => { + const map = new Map([ + ['key1', { value: 1 }], + ['key2', { value: 2 }] + ]); + const cloned = SoASerializer.deepClone(map); + expect(cloned).toBeInstanceOf(Map); + expect(cloned.size).toBe(2); + expect(cloned.get('key1')).toEqual({ value: 1 }); + expect(cloned.get('key1')).not.toBe(map.get('key1')); + }); + + test('should clone Set objects deeply', () => { + const obj1 = { a: 1 }; + const obj2 = { b: 2 }; + const set = new Set([obj1, obj2]); + const cloned = SoASerializer.deepClone(set); + expect(cloned).toBeInstanceOf(Set); + expect(cloned.size).toBe(2); + + const clonedArray = Array.from(cloned); + expect(clonedArray[0]).toEqual(obj1); + expect(clonedArray[0]).not.toBe(obj1); + }); + + test('should clone nested objects deeply', () => { + const obj = { + a: 1, + b: { + c: 2, + d: { + e: 3 + } + } + }; + const cloned = SoASerializer.deepClone(obj); + expect(cloned).toEqual(obj); + expect(cloned).not.toBe(obj); + expect(cloned.b).not.toBe(obj.b); + expect(cloned.b.d).not.toBe(obj.b.d); + }); + + test('should clone complex nested structures', () => { + const complex = { + array: [1, 2, 3], + map: new Map([['a', 1]]), + set: new Set([1, 2]), + date: new Date('2023-01-01'), + nested: { + value: 'test' + } + }; + const cloned = SoASerializer.deepClone(complex); + + expect(cloned.array).toEqual(complex.array); + expect(cloned.array).not.toBe(complex.array); + + expect(cloned.map).toBeInstanceOf(Map); + expect(cloned.map.get('a')).toBe(1); + + expect(cloned.set).toBeInstanceOf(Set); + expect(cloned.set.has(1)).toBe(true); + + expect(cloned.date).toBeInstanceOf(Date); + expect(cloned.date.getTime()).toBe(complex.date.getTime()); + + expect(cloned.nested).toEqual(complex.nested); + expect(cloned.nested).not.toBe(complex.nested); + }); + }); +}); diff --git a/packages/core/tests/ECS/Core/SoAStorage.complete.test.ts b/packages/core/tests/ECS/Core/SoAStorage.complete.test.ts index e6dd301a..1c42cc63 100644 --- a/packages/core/tests/ECS/Core/SoAStorage.complete.test.ts +++ b/packages/core/tests/ECS/Core/SoAStorage.complete.test.ts @@ -2,7 +2,6 @@ import { Component } from '../../../src/ECS/Component'; import { ComponentStorageManager } from '../../../src/ECS/Core/ComponentStorage'; import { EnableSoA, - HighPrecision, Float64, Int32, SerializeMap, @@ -50,7 +49,7 @@ class BasicTypesComponent extends Component { class DecoratedNumberComponent extends Component { public normalFloat: number; - @HighPrecision + @Float64 public highPrecisionNumber: number; @Float64 @@ -143,7 +142,7 @@ class ComplexObjectComponent extends Component { @EnableSoA class MixedComponent extends Component { - @HighPrecision + @Float64 public bigIntId: number; @Float64 @@ -288,7 +287,7 @@ describe('SoAStorage - SoA存储测试', () => { }); describe('数值类型装饰器', () => { - test('@HighPrecision应该保持高精度数值', () => { + test('@Float64应该保持高精度数值', () => { const component = new DecoratedNumberComponent( 0, Number.MAX_SAFE_INTEGER @@ -336,7 +335,7 @@ describe('SoAStorage - SoA存储测试', () => { expect(storage.getFieldArray('normalFloat')).toBeInstanceOf(Float32Array); expect(storage.getFieldArray('preciseFloat')).toBeInstanceOf(Float64Array); expect(storage.getFieldArray('integerValue')).toBeInstanceOf(Int32Array); - expect(storage.getFieldArray('highPrecisionNumber')).toBeNull(); + expect(storage.getFieldArray('highPrecisionNumber')).toBeInstanceOf(Float64Array); }); }); diff --git a/packages/core/tests/ECS/Core/SoATypeRegistry.test.ts b/packages/core/tests/ECS/Core/SoATypeRegistry.test.ts new file mode 100644 index 00000000..a53c2395 --- /dev/null +++ b/packages/core/tests/ECS/Core/SoATypeRegistry.test.ts @@ -0,0 +1,233 @@ +import { Component } from '../../../src/ECS/Component'; +import { + SoATypeRegistry, + TypedArrayTypeName +} from '../../../src/ECS/Core/SoATypeRegistry'; + +// Test components +class SimpleComponent extends Component { + public value: number = 0; + public flag: boolean = false; + public name: string = ''; +} + +describe('SoATypeRegistry', () => { + describe('getConstructor', () => { + test('should return Float32Array constructor for float32', () => { + expect(SoATypeRegistry.getConstructor('float32')).toBe(Float32Array); + }); + + test('should return Float64Array constructor for float64', () => { + expect(SoATypeRegistry.getConstructor('float64')).toBe(Float64Array); + }); + + test('should return Int32Array constructor for int32', () => { + expect(SoATypeRegistry.getConstructor('int32')).toBe(Int32Array); + }); + + test('should return Uint32Array constructor for uint32', () => { + expect(SoATypeRegistry.getConstructor('uint32')).toBe(Uint32Array); + }); + + test('should return Int16Array constructor for int16', () => { + expect(SoATypeRegistry.getConstructor('int16')).toBe(Int16Array); + }); + + test('should return Uint16Array constructor for uint16', () => { + expect(SoATypeRegistry.getConstructor('uint16')).toBe(Uint16Array); + }); + + test('should return Int8Array constructor for int8', () => { + expect(SoATypeRegistry.getConstructor('int8')).toBe(Int8Array); + }); + + test('should return Uint8Array constructor for uint8', () => { + expect(SoATypeRegistry.getConstructor('uint8')).toBe(Uint8Array); + }); + + test('should return Uint8ClampedArray constructor for uint8clamped', () => { + expect(SoATypeRegistry.getConstructor('uint8clamped')).toBe(Uint8ClampedArray); + }); + + test('should return Float32Array as default for unknown type', () => { + expect(SoATypeRegistry.getConstructor('unknown' as TypedArrayTypeName)).toBe(Float32Array); + }); + }); + + describe('getBytesPerElement', () => { + test('should return 4 for float32', () => { + expect(SoATypeRegistry.getBytesPerElement('float32')).toBe(4); + }); + + test('should return 8 for float64', () => { + expect(SoATypeRegistry.getBytesPerElement('float64')).toBe(8); + }); + + test('should return 4 for int32', () => { + expect(SoATypeRegistry.getBytesPerElement('int32')).toBe(4); + }); + + test('should return 4 for uint32', () => { + expect(SoATypeRegistry.getBytesPerElement('uint32')).toBe(4); + }); + + test('should return 2 for int16', () => { + expect(SoATypeRegistry.getBytesPerElement('int16')).toBe(2); + }); + + test('should return 2 for uint16', () => { + expect(SoATypeRegistry.getBytesPerElement('uint16')).toBe(2); + }); + + test('should return 1 for int8', () => { + expect(SoATypeRegistry.getBytesPerElement('int8')).toBe(1); + }); + + test('should return 1 for uint8', () => { + expect(SoATypeRegistry.getBytesPerElement('uint8')).toBe(1); + }); + + test('should return 1 for uint8clamped', () => { + expect(SoATypeRegistry.getBytesPerElement('uint8clamped')).toBe(1); + }); + + test('should return 4 as default for unknown type', () => { + expect(SoATypeRegistry.getBytesPerElement('unknown' as TypedArrayTypeName)).toBe(4); + }); + }); + + describe('getTypeName', () => { + test('should return float32 for Float32Array', () => { + expect(SoATypeRegistry.getTypeName(new Float32Array(1))).toBe('float32'); + }); + + test('should return float64 for Float64Array', () => { + expect(SoATypeRegistry.getTypeName(new Float64Array(1))).toBe('float64'); + }); + + test('should return int32 for Int32Array', () => { + expect(SoATypeRegistry.getTypeName(new Int32Array(1))).toBe('int32'); + }); + + test('should return uint32 for Uint32Array', () => { + expect(SoATypeRegistry.getTypeName(new Uint32Array(1))).toBe('uint32'); + }); + + test('should return int16 for Int16Array', () => { + expect(SoATypeRegistry.getTypeName(new Int16Array(1))).toBe('int16'); + }); + + test('should return uint16 for Uint16Array', () => { + expect(SoATypeRegistry.getTypeName(new Uint16Array(1))).toBe('uint16'); + }); + + test('should return int8 for Int8Array', () => { + expect(SoATypeRegistry.getTypeName(new Int8Array(1))).toBe('int8'); + }); + + test('should return uint8 for Uint8Array', () => { + expect(SoATypeRegistry.getTypeName(new Uint8Array(1))).toBe('uint8'); + }); + + test('should return uint8clamped for Uint8ClampedArray', () => { + expect(SoATypeRegistry.getTypeName(new Uint8ClampedArray(1))).toBe('uint8clamped'); + }); + }); + + describe('createSameType', () => { + test('should create Float32Array from Float32Array source', () => { + const source = new Float32Array(10); + const result = SoATypeRegistry.createSameType(source, 20); + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(20); + }); + + test('should create Float64Array from Float64Array source', () => { + const source = new Float64Array(10); + const result = SoATypeRegistry.createSameType(source, 20); + expect(result).toBeInstanceOf(Float64Array); + expect(result.length).toBe(20); + }); + + test('should create Int32Array from Int32Array source', () => { + const source = new Int32Array(10); + const result = SoATypeRegistry.createSameType(source, 20); + expect(result).toBeInstanceOf(Int32Array); + expect(result.length).toBe(20); + }); + + test('should create Uint32Array from Uint32Array source', () => { + const source = new Uint32Array(10); + const result = SoATypeRegistry.createSameType(source, 20); + expect(result).toBeInstanceOf(Uint32Array); + expect(result.length).toBe(20); + }); + + test('should create Int16Array from Int16Array source', () => { + const source = new Int16Array(10); + const result = SoATypeRegistry.createSameType(source, 15); + expect(result).toBeInstanceOf(Int16Array); + expect(result.length).toBe(15); + }); + + test('should create Uint16Array from Uint16Array source', () => { + const source = new Uint16Array(10); + const result = SoATypeRegistry.createSameType(source, 15); + expect(result).toBeInstanceOf(Uint16Array); + expect(result.length).toBe(15); + }); + + test('should create Int8Array from Int8Array source', () => { + const source = new Int8Array(10); + const result = SoATypeRegistry.createSameType(source, 15); + expect(result).toBeInstanceOf(Int8Array); + expect(result.length).toBe(15); + }); + + test('should create Uint8Array from Uint8Array source', () => { + const source = new Uint8Array(10); + const result = SoATypeRegistry.createSameType(source, 15); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(15); + }); + + test('should create Uint8ClampedArray from Uint8ClampedArray source', () => { + const source = new Uint8ClampedArray(10); + const result = SoATypeRegistry.createSameType(source, 15); + expect(result).toBeInstanceOf(Uint8ClampedArray); + expect(result.length).toBe(15); + }); + }); + + describe('extractFieldMetadata', () => { + test('should extract metadata for simple component', () => { + const metadata = SoATypeRegistry.extractFieldMetadata(SimpleComponent); + + expect(metadata.has('value')).toBe(true); + expect(metadata.get('value')?.type).toBe('number'); + expect(metadata.get('value')?.arrayType).toBe('float32'); + + expect(metadata.has('flag')).toBe(true); + expect(metadata.get('flag')?.type).toBe('boolean'); + expect(metadata.get('flag')?.arrayType).toBe('uint8'); + + expect(metadata.has('name')).toBe(true); + expect(metadata.get('name')?.type).toBe('string'); + }); + + test('should not include id field in metadata', () => { + const metadata = SoATypeRegistry.extractFieldMetadata(SimpleComponent); + expect(metadata.has('id')).toBe(false); + }); + + test('should handle component with object fields', () => { + class ObjectComponent extends Component { + public data: object = {}; + } + + const metadata = SoATypeRegistry.extractFieldMetadata(ObjectComponent); + expect(metadata.has('data')).toBe(true); + expect(metadata.get('data')?.type).toBe('object'); + }); + }); +}); diff --git a/packages/ecs-engine-bindgen/package.json b/packages/ecs-engine-bindgen/package.json new file mode 100644 index 00000000..ae031c7b --- /dev/null +++ b/packages/ecs-engine-bindgen/package.json @@ -0,0 +1,49 @@ +{ + "name": "@esengine/ecs-engine-bindgen", + "version": "0.1.0", + "description": "Bridge layer between ECS Framework and Rust Engine | ECS框架与Rust引擎之间的桥接层", + "main": "bin/index.js", + "module": "bin/index.js", + "types": "bin/index.d.ts", + "exports": { + ".": { + "types": "./bin/index.d.ts", + "import": "./bin/index.js", + "require": "./bin/index.js", + "default": "./bin/index.js" + } + }, + "files": [ + "bin", + "dist" + ], + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "clean": "rimraf bin dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/esengine/ecs-framework.git", + "directory": "packages/ecs-engine-bindgen" + }, + "keywords": [ + "ecs", + "game-engine", + "bridge", + "wasm", + "typescript" + ], + "author": "ESEngine Team", + "license": "MIT", + "dependencies": { + "@esengine/ecs-framework": "file:../core" + }, + "peerDependencies": { + "es-engine": "file:../engine/pkg" + }, + "devDependencies": { + "typescript": "^5.8.0", + "rimraf": "^5.0.0" + } +} diff --git a/packages/ecs-engine-bindgen/src/components/SpriteComponent.ts b/packages/ecs-engine-bindgen/src/components/SpriteComponent.ts new file mode 100644 index 00000000..af4804c9 --- /dev/null +++ b/packages/ecs-engine-bindgen/src/components/SpriteComponent.ts @@ -0,0 +1,161 @@ +/** + * 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; + } + } +} diff --git a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts new file mode 100644 index 00000000..d4bf4a1d --- /dev/null +++ b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts @@ -0,0 +1,335 @@ +/** + * Main bridge between TypeScript ECS and Rust Engine. + * TypeScript ECS与Rust引擎之间的主桥接层。 + */ + +import type { SpriteRenderData, TextureLoadRequest, EngineStats } from '../types'; + +/** + * Engine bridge configuration. + * 引擎桥接配置。 + */ +export interface EngineBridgeConfig { + /** Canvas element ID. | Canvas元素ID。 */ + canvasId: string; + /** Initial canvas width. | 初始画布宽度。 */ + width?: number; + /** Initial canvas height. | 初始画布高度。 */ + height?: number; + /** Maximum sprites per batch. | 每批次最大精灵数。 */ + maxSprites?: number; + /** Enable debug mode. | 启用调试模式。 */ + debug?: boolean; +} + +/** + * Bridge for communication between ECS Framework and Rust Engine. + * ECS框架与Rust引擎之间的通信桥接。 + * + * This class manages data transfer between the TypeScript ECS layer + * and the WebAssembly-based Rust rendering engine. + * 此类管理TypeScript ECS层与基于WebAssembly的Rust渲染引擎之间的数据传输。 + * + * @example + * ```typescript + * const bridge = new EngineBridge({ canvasId: 'game-canvas' }); + * await bridge.initialize(); + * + * // In game loop | 在游戏循环中 + * bridge.clear(0, 0, 0, 1); + * bridge.submitSprites(spriteDataArray); + * bridge.render(); + * ``` + */ +export class EngineBridge { + private engine: any; // GameEngine from WASM + private config: Required; + private initialized = false; + + // Pre-allocated typed arrays for batch submission + // 预分配的类型数组用于批量提交 + private transformBuffer: Float32Array; + private textureIdBuffer: Uint32Array; + private uvBuffer: Float32Array; + private colorBuffer: Uint32Array; + + // Statistics | 统计信息 + private stats: EngineStats = { + fps: 0, + drawCalls: 0, + spriteCount: 0, + frameTime: 0 + }; + + private lastFrameTime = 0; + private frameCount = 0; + private fpsAccumulator = 0; + + /** + * Create a new engine bridge. + * 创建新的引擎桥接。 + * + * @param config - Bridge configuration | 桥接配置 + */ + constructor(config: EngineBridgeConfig) { + this.config = { + canvasId: config.canvasId, + width: config.width ?? 800, + height: config.height ?? 600, + maxSprites: config.maxSprites ?? 10000, + debug: config.debug ?? false + }; + + // Pre-allocate buffers | 预分配缓冲区 + const maxSprites = this.config.maxSprites; + this.transformBuffer = new Float32Array(maxSprites * 7); // x, y, rot, sx, sy, ox, oy + this.textureIdBuffer = new Uint32Array(maxSprites); + this.uvBuffer = new Float32Array(maxSprites * 4); // u0, v0, u1, v1 + this.colorBuffer = new Uint32Array(maxSprites); + } + + /** + * Initialize the engine bridge with WASM module. + * 使用WASM模块初始化引擎桥接。 + * + * @param wasmModule - Pre-imported WASM module | 预导入的WASM模块 + */ + async initializeWithModule(wasmModule: any): Promise { + if (this.initialized) { + console.warn('EngineBridge already initialized | EngineBridge已初始化'); + return; + } + + try { + // Initialize WASM | 初始化WASM + if (wasmModule.default) { + await wasmModule.default(); + } + + // Create engine instance | 创建引擎实例 + this.engine = new wasmModule.GameEngine(this.config.canvasId); + this.initialized = true; + + if (this.config.debug) { + console.log('EngineBridge initialized | EngineBridge初始化完成'); + } + } catch (error) { + throw new Error(`Failed to initialize engine: ${error} | 引擎初始化失败: ${error}`); + } + } + + /** + * Initialize the engine bridge. + * 初始化引擎桥接。 + * + * Loads the WASM module and creates the engine instance. + * 加载WASM模块并创建引擎实例。 + * + * @param wasmPath - Path to WASM package | WASM包路径 + * @deprecated Use initializeWithModule instead | 请使用 initializeWithModule 代替 + */ + async initialize(wasmPath = '@esengine/engine'): Promise { + if (this.initialized) { + console.warn('EngineBridge already initialized | EngineBridge已初始化'); + return; + } + + try { + // Dynamic import of WASM module | 动态导入WASM模块 + const wasmModule = await import(/* webpackIgnore: true */ wasmPath); + await this.initializeWithModule(wasmModule); + } catch (error) { + throw new Error(`Failed to initialize engine: ${error} | 引擎初始化失败: ${error}`); + } + } + + /** + * Check if bridge is initialized. + * 检查桥接是否已初始化。 + */ + get isInitialized(): boolean { + return this.initialized; + } + + /** + * Get canvas width. + * 获取画布宽度。 + */ + get width(): number { + return this.engine?.width ?? 0; + } + + /** + * Get canvas height. + * 获取画布高度。 + */ + get height(): number { + return this.engine?.height ?? 0; + } + + /** + * Clear the screen. + * 清除屏幕。 + * + * @param r - Red (0-1) | 红色 + * @param g - Green (0-1) | 绿色 + * @param b - Blue (0-1) | 蓝色 + * @param a - Alpha (0-1) | 透明度 + */ + clear(r: number, g: number, b: number, a: number): void { + if (!this.initialized) return; + this.engine.clear(r, g, b, a); + } + + /** + * Submit sprite data for rendering. + * 提交精灵数据进行渲染。 + * + * @param sprites - Array of sprite render data | 精灵渲染数据数组 + */ + submitSprites(sprites: SpriteRenderData[]): void { + if (!this.initialized || sprites.length === 0) return; + + const count = Math.min(sprites.length, this.config.maxSprites); + + // Fill typed arrays | 填充类型数组 + for (let i = 0; i < count; i++) { + const sprite = sprites[i]; + const tOffset = i * 7; + const uvOffset = i * 4; + + // Transform data | 变换数据 + this.transformBuffer[tOffset] = sprite.x; + this.transformBuffer[tOffset + 1] = sprite.y; + this.transformBuffer[tOffset + 2] = sprite.rotation; + this.transformBuffer[tOffset + 3] = sprite.scaleX; + this.transformBuffer[tOffset + 4] = sprite.scaleY; + this.transformBuffer[tOffset + 5] = sprite.originX; + this.transformBuffer[tOffset + 6] = sprite.originY; + + // Texture ID | 纹理ID + this.textureIdBuffer[i] = sprite.textureId; + + // UV coordinates | UV坐标 + this.uvBuffer[uvOffset] = sprite.uv[0]; + this.uvBuffer[uvOffset + 1] = sprite.uv[1]; + this.uvBuffer[uvOffset + 2] = sprite.uv[2]; + this.uvBuffer[uvOffset + 3] = sprite.uv[3]; + + // Color | 颜色 + this.colorBuffer[i] = sprite.color; + } + + // Submit to engine (single WASM call) | 提交到引擎(单次WASM调用) + this.engine.submitSpriteBatch( + this.transformBuffer.subarray(0, count * 7), + this.textureIdBuffer.subarray(0, count), + this.uvBuffer.subarray(0, count * 4), + this.colorBuffer.subarray(0, count) + ); + + this.stats.spriteCount = count; + } + + /** + * Render the current frame. + * 渲染当前帧。 + */ + render(): void { + if (!this.initialized) return; + + const startTime = performance.now(); + this.engine.render(); + const endTime = performance.now(); + + // Update statistics | 更新统计信息 + this.stats.frameTime = endTime - startTime; + this.stats.drawCalls = 1; // Currently single batch | 当前单批次 + + // Calculate FPS | 计算FPS + this.frameCount++; + this.fpsAccumulator += endTime - this.lastFrameTime; + this.lastFrameTime = endTime; + + if (this.fpsAccumulator >= 1000) { + this.stats.fps = this.frameCount; + this.frameCount = 0; + this.fpsAccumulator = 0; + } + } + + /** + * Load a texture. + * 加载纹理。 + * + * @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); + } + + /** + * Load multiple textures. + * 加载多个纹理。 + * + * @param requests - Texture load requests | 纹理加载请求 + */ + loadTextures(requests: TextureLoadRequest[]): void { + for (const req of requests) { + this.loadTexture(req.id, req.url); + } + } + + /** + * Check if a key is pressed. + * 检查按键是否按下。 + * + * @param keyCode - Key code | 键码 + */ + isKeyDown(keyCode: string): boolean { + if (!this.initialized) return false; + return this.engine.isKeyDown(keyCode); + } + + /** + * Update input state (call once per frame). + * 更新输入状态(每帧调用一次)。 + */ + updateInput(): void { + if (!this.initialized) return; + this.engine.updateInput(); + } + + /** + * Get engine statistics. + * 获取引擎统计信息。 + */ + getStats(): EngineStats { + return { ...this.stats }; + } + + /** + * Resize the viewport. + * 调整视口大小。 + * + * @param width - New width | 新宽度 + * @param height - New height | 新高度 + */ + resize(width: number, height: number): void { + if (!this.initialized) return; + if (this.engine.resize) { + this.engine.resize(width, height); + } + } + + /** + * Dispose the bridge and release resources. + * 销毁桥接并释放资源。 + */ + dispose(): void { + this.engine = null; + this.initialized = false; + } +} diff --git a/packages/ecs-engine-bindgen/src/core/RenderBatcher.ts b/packages/ecs-engine-bindgen/src/core/RenderBatcher.ts new file mode 100644 index 00000000..68d9bff0 --- /dev/null +++ b/packages/ecs-engine-bindgen/src/core/RenderBatcher.ts @@ -0,0 +1,108 @@ +/** + * Render batcher for collecting sprite data. + * 用于收集精灵数据的渲染批处理器。 + */ + +import type { SpriteRenderData } from '../types'; + +/** + * Collects and sorts sprite render data for batch submission. + * 收集和排序精灵渲染数据用于批量提交。 + * + * This class is used to collect sprites during the ECS update loop + * and then submit them all at once to the engine. + * 此类用于在ECS更新循环中收集精灵,然后一次性提交到引擎。 + * + * @example + * ```typescript + * const batcher = new RenderBatcher(); + * + * // During ECS update | 在ECS更新期间 + * batcher.addSprite({ + * x: 100, y: 200, + * rotation: 0, + * scaleX: 1, scaleY: 1, + * originX: 0.5, originY: 0.5, + * textureId: 1, + * uv: [0, 0, 1, 1], + * color: 0xFFFFFFFF + * }); + * + * // At end of frame | 在帧结束时 + * bridge.submitSprites(batcher.getSprites()); + * batcher.clear(); + * ``` + */ +export class RenderBatcher { + private sprites: SpriteRenderData[] = []; + private sortByZ = false; + + /** + * Create a new render batcher. + * 创建新的渲染批处理器。 + * + * @param sortByZ - Whether to sort sprites by Z order | 是否按Z顺序排序精灵 + */ + constructor(sortByZ = false) { + this.sortByZ = sortByZ; + } + + /** + * Add a sprite to the batch. + * 将精灵添加到批处理。 + * + * @param sprite - Sprite render data | 精灵渲染数据 + */ + addSprite(sprite: SpriteRenderData): void { + this.sprites.push(sprite); + } + + /** + * Add multiple sprites to the batch. + * 将多个精灵添加到批处理。 + * + * @param sprites - Array of sprite render data | 精灵渲染数据数组 + */ + addSprites(sprites: SpriteRenderData[]): void { + this.sprites.push(...sprites); + } + + /** + * Get all sprites in the batch. + * 获取批处理中的所有精灵。 + * + * @returns Sorted array of sprites | 排序后的精灵数组 + */ + getSprites(): SpriteRenderData[] { + // Sort by texture ID for better batching (fewer texture switches) + // 按纹理ID排序以获得更好的批处理效果(减少纹理切换) + if (!this.sortByZ) { + this.sprites.sort((a, b) => a.textureId - b.textureId); + } + return this.sprites; + } + + /** + * Get sprite count. + * 获取精灵数量。 + */ + get count(): number { + return this.sprites.length; + } + + /** + * Clear all sprites from the batch. + * 清除批处理中的所有精灵。 + */ + clear(): void { + this.sprites.length = 0; + } + + /** + * Check if batch is empty. + * 检查批处理是否为空。 + */ + get isEmpty(): boolean { + return this.sprites.length === 0; + } +} diff --git a/packages/ecs-engine-bindgen/src/core/SpriteRenderHelper.ts b/packages/ecs-engine-bindgen/src/core/SpriteRenderHelper.ts new file mode 100644 index 00000000..b01ab07e --- /dev/null +++ b/packages/ecs-engine-bindgen/src/core/SpriteRenderHelper.ts @@ -0,0 +1,140 @@ +/** + * Sprite render helper utilities. + * 精灵渲染辅助工具。 + */ + +import { Entity, Component } from '@esengine/ecs-framework'; +import type { EngineBridge } from './EngineBridge'; +import { RenderBatcher } from './RenderBatcher'; +import { SpriteComponent } from '../components/SpriteComponent'; +import type { SpriteRenderData } from '../types'; + +/** + * Transform component interface. + * 变换组件接口。 + * + * Your transform component should implement this interface. + * 你的变换组件应该实现此接口。 + */ +export interface ITransformComponent { + position: { x: number; y: number }; + rotation: number; + scale: { x: number; y: number }; +} + +/** + * Helper class for rendering sprites (not an ECS System). + * 精灵渲染辅助类(非ECS系统)。 + * + * Use this for manual control over rendering, or use EngineRenderSystem + * for automatic ECS integration. + * 用于手动控制渲染,或使用EngineRenderSystem进行自动ECS集成。 + * + * @example + * ```typescript + * const bridge = new EngineBridge({ canvasId: 'canvas' }); + * await bridge.initialize(); + * + * const helper = new SpriteRenderHelper(bridge); + * + * // In game loop | 在游戏循环中 + * helper.collectSprites(entities, Transform); + * helper.render(); + * ``` + */ +export class SpriteRenderHelper { + private bridge: EngineBridge; + private batcher: RenderBatcher; + + /** + * Create a new sprite render helper. + * 创建新的精灵渲染辅助类。 + * + * @param bridge - Engine bridge instance | 引擎桥接实例 + */ + constructor(bridge: EngineBridge) { + this.bridge = bridge; + this.batcher = new RenderBatcher(); + } + + /** + * Collect sprite data from entities. + * 从实体收集精灵数据。 + * + * @param entities - Entities to process | 要处理的实体 + * @param transformType - Transform component class | 变换组件类 + */ + collectSprites( + entities: Entity[], + transformType: new () => T + ): void { + this.batcher.clear(); + + for (const entity of entities) { + const sprite = entity.getComponent(SpriteComponent); + const transform = entity.getComponent(transformType); + + if (!sprite || !transform || !sprite.visible) { + continue; + } + + // Calculate UV with flip | 计算带翻转的UV + let uv = sprite.uv; + if (sprite.flipX || sprite.flipY) { + uv = [...sprite.uv] as [number, number, number, number]; + if (sprite.flipX) { + const temp = uv[0]; + uv[0] = uv[2]; + uv[2] = temp; + } + if (sprite.flipY) { + const temp = uv[1]; + uv[1] = uv[3]; + uv[3] = temp; + } + } + + 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, + uv, + color: sprite.color + }; + + this.batcher.addSprite(renderData); + } + } + + /** + * Submit batched sprites and render. + * 提交批处理的精灵并渲染。 + */ + render(): void { + if (!this.batcher.isEmpty) { + this.bridge.submitSprites(this.batcher.getSprites()); + } + this.bridge.render(); + } + + /** + * Get the number of sprites to be rendered. + * 获取要渲染的精灵数量。 + */ + get spriteCount(): number { + return this.batcher.count; + } + + /** + * Clear the current batch. + * 清除当前批处理。 + */ + clear(): void { + this.batcher.clear(); + } +} diff --git a/packages/ecs-engine-bindgen/src/index.ts b/packages/ecs-engine-bindgen/src/index.ts new file mode 100644 index 00000000..e760a07d --- /dev/null +++ b/packages/ecs-engine-bindgen/src/index.ts @@ -0,0 +1,13 @@ +/** + * ECS Engine Bindgen - Bridge layer between ECS Framework and Rust Engine. + * ECS引擎桥接层 - ECS框架与Rust引擎之间的桥接层。 + * + * @packageDocumentation + */ + +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 * from './types'; diff --git a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts new file mode 100644 index 00000000..322355a2 --- /dev/null +++ b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts @@ -0,0 +1,171 @@ +/** + * Engine render system for ECS. + * 用于ECS的引擎渲染系统。 + */ + +import { EntitySystem, Matcher, Entity, ComponentType, ECSSystem, Component } from '@esengine/ecs-framework'; +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'; + +/** + * Type for transform component constructor. + * 变换组件构造函数类型。 + */ +export type TransformComponentType = ComponentType & (new (...args: any[]) => Component & ITransformComponent); + +/** + * ECS System for rendering sprites using the Rust engine. + * 使用Rust引擎渲染精灵的ECS系统。 + * + * This system extends EntitySystem and integrates with the ECS lifecycle. + * 此系统扩展EntitySystem并与ECS生命周期集成。 + * + * @example + * ```typescript + * // Create transform component | 创建变换组件 + * @ECSComponent('Transform') + * class Transform extends Component implements ITransformComponent { + * position = { x: 0, y: 0 }; + * rotation = 0; + * scale = { x: 1, y: 1 }; + * } + * + * // Initialize bridge | 初始化桥接 + * const bridge = new EngineBridge({ canvasId: 'canvas' }); + * await bridge.initialize(); + * + * // Add system to scene | 将系统添加到场景 + * const renderSystem = new EngineRenderSystem(bridge, Transform); + * scene.addSystem(renderSystem); + * ``` + */ +@ECSSystem('EngineRender', { updateOrder: 1000 }) // Render system executes last | 渲染系统最后执行 +export class EngineRenderSystem extends EntitySystem { + private bridge: EngineBridge; + private batcher: RenderBatcher; + private transformType: TransformComponentType; + + /** + * Create a new engine render system. + * 创建新的引擎渲染系统。 + * + * @param bridge - Engine bridge instance | 引擎桥接实例 + * @param transformType - Transform component class (must implement ITransformComponent) | 变换组件类(必须实现ITransformComponent) + */ + constructor(bridge: EngineBridge, transformType: TransformComponentType) { + // Match entities with both Sprite and Transform components + // 匹配同时具有Sprite和Transform组件的实体 + super(Matcher.empty().all(SpriteComponent, transformType)); + + this.bridge = bridge; + this.batcher = new RenderBatcher(); + this.transformType = transformType; + } + + /** + * Called when system is initialized. + * 系统初始化时调用。 + */ + public override initialize(): void { + super.initialize(); + this.logger.info('EngineRenderSystem initialized | 引擎渲染系统初始化完成'); + } + + /** + * Called before processing entities. + * 处理实体之前调用。 + */ + protected begin(): void { + // Clear the batch | 清空批处理 + this.batcher.clear(); + + // Clear screen | 清屏 + this.bridge.clear(0, 0, 0, 1); + + // Update input | 更新输入 + this.bridge.updateInput(); + } + + /** + * Process all matched entities. + * 处理所有匹配的实体。 + * + * @param entities - Entities to process | 要处理的实体 + */ + protected process(entities: readonly Entity[]): void { + 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) { + continue; + } + + // Calculate UV with flip | 计算带翻转的UV + let uv = sprite.uv; + if (sprite.flipX || sprite.flipY) { + uv = [...sprite.uv] as [number, number, number, number]; + if (sprite.flipX) { + [uv[0], uv[2]] = [uv[2], uv[0]]; + } + if (sprite.flipY) { + [uv[1], uv[3]] = [uv[3], uv[1]]; + } + } + + 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, + uv, + color: sprite.color + }; + + this.batcher.addSprite(renderData); + } + } + + /** + * Called after processing entities. + * 处理实体之后调用。 + */ + protected end(): void { + // Submit batch and render | 提交批处理并渲染 + if (!this.batcher.isEmpty) { + this.bridge.submitSprites(this.batcher.getSprites()); + } + this.bridge.render(); + } + + /** + * Get the number of sprites rendered. + * 获取渲染的精灵数量。 + */ + get spriteCount(): number { + return this.batcher.count; + } + + /** + * Get engine statistics. + * 获取引擎统计信息。 + */ + getStats() { + return this.bridge.getStats(); + } + + /** + * Load a texture. + * 加载纹理。 + */ + loadTexture(id: number, url: string): void { + this.bridge.loadTexture(id, url); + } +} diff --git a/packages/ecs-engine-bindgen/src/types/index.ts b/packages/ecs-engine-bindgen/src/types/index.ts new file mode 100644 index 00000000..38669cba --- /dev/null +++ b/packages/ecs-engine-bindgen/src/types/index.ts @@ -0,0 +1,72 @@ +/** + * Type definitions for engine bridge. + * 引擎桥接层的类型定义。 + */ + +/** + * Sprite render data for batch submission. + * 用于批量提交的精灵渲染数据。 + */ +export interface SpriteRenderData { + /** Position X. | X位置。 */ + x: number; + /** Position Y. | Y位置。 */ + y: number; + /** Rotation in radians. | 旋转角度(弧度)。 */ + rotation: number; + /** Scale X. | X缩放。 */ + scaleX: number; + /** Scale Y. | Y缩放。 */ + scaleY: number; + /** Origin X (0-1). | 原点X(0-1)。 */ + originX: number; + /** Origin Y (0-1). | 原点Y(0-1)。 */ + originY: number; + /** Texture ID. | 纹理ID。 */ + textureId: number; + /** UV coordinates [u0, v0, u1, v1]. | UV坐标。 */ + uv: [number, number, number, number]; + /** Packed RGBA color. | 打包的RGBA颜色。 */ + color: number; +} + +/** + * Texture load request. + * 纹理加载请求。 + */ +export interface TextureLoadRequest { + /** Unique texture ID. | 唯一纹理ID。 */ + id: number; + /** Image URL. | 图片URL。 */ + url: string; +} + +/** + * Engine statistics. + * 引擎统计信息。 + */ +export interface EngineStats { + /** Frames per second. | 每秒帧数。 */ + fps: number; + /** Number of draw calls. | 绘制调用次数。 */ + drawCalls: number; + /** Number of sprites rendered. | 渲染的精灵数量。 */ + spriteCount: number; + /** Frame time in milliseconds. | 帧时间(毫秒)。 */ + frameTime: number; +} + +/** + * Camera configuration. + * 相机配置。 + */ +export interface CameraConfig { + /** Camera X position. | 相机X位置。 */ + x: number; + /** Camera Y position. | 相机Y位置。 */ + y: number; + /** Zoom level. | 缩放级别。 */ + zoom: number; + /** Rotation in radians. | 旋转角度(弧度)。 */ + rotation: number; +} diff --git a/packages/ecs-engine-bindgen/tsconfig.json b/packages/ecs-engine-bindgen/tsconfig.json new file mode 100644 index 00000000..fa5c609b --- /dev/null +++ b/packages/ecs-engine-bindgen/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "lib": ["ES2020", "DOM"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./bin", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "bin", "dist"] +} diff --git a/packages/editor-app/package.json b/packages/editor-app/package.json index 8ea69103..1ed9ce98 100644 --- a/packages/editor-app/package.json +++ b/packages/editor-app/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@esengine/behavior-tree": "file:../behavior-tree", - "@esengine/behavior-tree-editor": "file:../behavior-tree-editor", + "@esengine/ecs-engine-bindgen": "file:../ecs-engine-bindgen", "@esengine/ecs-framework": "file:../core", "@esengine/editor-core": "file:../editor-core", "@tauri-apps/api": "^2.2.0", @@ -24,6 +24,7 @@ "@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", "flexlayout-react": "^0.8.17", "i18next": "^25.6.0", "json5": "^2.2.3", @@ -51,6 +52,8 @@ "sharp": "^0.34.4", "typescript": "^5.8.3", "vite": "^6.0.7", - "vite-plugin-swc-transform": "^1.1.1" + "vite-plugin-swc-transform": "^1.1.1", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0" } } diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index fb760364..48a5a8a0 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -14,16 +14,18 @@ import { ProjectService, CompilerRegistry, InspectorRegistry, - INotification + INotification, + CommandManager } from '@esengine/editor-core'; import type { IDialogExtended } from './services/TauriDialogService'; import { GlobalBlackboardService } from '@esengine/behavior-tree'; import { ServiceRegistry, PluginInstaller, useDialogStore } from './app/managers'; import { StartupPage } from './components/StartupPage'; import { SceneHierarchy } from './components/SceneHierarchy'; -import { Inspector } from './components/Inspector'; +import { Inspector } from './components/inspectors/Inspector'; import { AssetBrowser } from './components/AssetBrowser'; import { ConsolePanel } from './components/ConsolePanel'; +import { Viewport } from './components/Viewport'; import { PluginManagerWindow } from './components/PluginManagerWindow'; import { ProfilerWindow } from './components/ProfilerWindow'; import { PortManager } from './components/PortManager'; @@ -82,6 +84,7 @@ function App() { const [sceneManager, setSceneManager] = useState(null); const [notification, setNotification] = useState(null); const [dialog, setDialog] = useState(null); + const [commandManager] = useState(() => new CommandManager()); const { t, locale, changeLocale } = useLocale(); // 同步 locale 到 TauriDialogService @@ -660,13 +663,13 @@ function App() { { id: 'scene-hierarchy', title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy', - content: , + content: , closable: false }, { id: 'inspector', title: locale === 'zh' ? '检视器' : 'Inspector', - content: , + content: , closable: false }, { @@ -681,13 +684,19 @@ function App() { { id: 'scene-hierarchy', title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy', - content: , + content: , + closable: false + }, + { + id: 'viewport', + title: locale === 'zh' ? '视口' : 'Viewport', + content: , closable: false }, { id: 'inspector', title: locale === 'zh' ? '检视器' : 'Inspector', - content: , + content: , closable: false }, { diff --git a/packages/editor-app/src/application/commands/component/AddComponentCommand.ts b/packages/editor-app/src/application/commands/component/AddComponentCommand.ts new file mode 100644 index 00000000..9dc228d2 --- /dev/null +++ b/packages/editor-app/src/application/commands/component/AddComponentCommand.ts @@ -0,0 +1,54 @@ +import { Entity, Component } from '@esengine/ecs-framework'; +import { MessageHub } from '@esengine/editor-core'; +import { BaseCommand } from '../BaseCommand'; + +/** + * 添加组件命令 + */ +export class AddComponentCommand extends BaseCommand { + private component: Component | null = null; + + constructor( + private messageHub: MessageHub, + private entity: Entity, + private ComponentClass: new () => Component, + private initialData?: Record + ) { + super(); + } + + execute(): void { + this.component = new this.ComponentClass(); + + // 应用初始数据 + if (this.initialData) { + for (const [key, value] of Object.entries(this.initialData)) { + (this.component as any)[key] = value; + } + } + + this.entity.addComponent(this.component); + + this.messageHub.publish('component:added', { + entity: this.entity, + component: this.component + }); + } + + undo(): void { + if (!this.component) return; + + this.entity.removeComponent(this.component); + + this.messageHub.publish('component:removed', { + entity: this.entity, + componentType: this.ComponentClass.name + }); + + this.component = null; + } + + getDescription(): string { + return `添加组件: ${this.ComponentClass.name}`; + } +} diff --git a/packages/editor-app/src/application/commands/component/RemoveComponentCommand.ts b/packages/editor-app/src/application/commands/component/RemoveComponentCommand.ts new file mode 100644 index 00000000..c0f54a51 --- /dev/null +++ b/packages/editor-app/src/application/commands/component/RemoveComponentCommand.ts @@ -0,0 +1,57 @@ +import { Entity, Component } from '@esengine/ecs-framework'; +import { MessageHub } from '@esengine/editor-core'; +import { BaseCommand } from '../BaseCommand'; + +/** + * 移除组件命令 + */ +export class RemoveComponentCommand extends BaseCommand { + private componentData: Record = {}; + private ComponentClass: new () => Component; + + constructor( + private messageHub: MessageHub, + private entity: Entity, + private component: Component + ) { + super(); + this.ComponentClass = component.constructor as new () => Component; + + // 保存组件数据用于撤销 + for (const key of Object.keys(component)) { + if (key !== 'entity' && key !== 'id') { + this.componentData[key] = (component as any)[key]; + } + } + } + + execute(): void { + this.entity.removeComponent(this.component); + + this.messageHub.publish('component:removed', { + entity: this.entity, + componentType: this.ComponentClass.name + }); + } + + undo(): void { + const newComponent = new this.ComponentClass(); + + // 恢复数据 + for (const [key, value] of Object.entries(this.componentData)) { + (newComponent as any)[key] = value; + } + + this.entity.addComponent(newComponent); + this.component = newComponent; + + this.messageHub.publish('component:added', { + entity: this.entity, + component: newComponent + }); + } + + getDescription(): string { + return `移除组件: ${this.ComponentClass.name}`; + } +} diff --git a/packages/editor-app/src/application/commands/component/UpdateComponentCommand.ts b/packages/editor-app/src/application/commands/component/UpdateComponentCommand.ts new file mode 100644 index 00000000..0c7af3ea --- /dev/null +++ b/packages/editor-app/src/application/commands/component/UpdateComponentCommand.ts @@ -0,0 +1,76 @@ +import { Entity, Component } from '@esengine/ecs-framework'; +import { MessageHub } from '@esengine/editor-core'; +import { BaseCommand } from '../BaseCommand'; +import { ICommand } from '../ICommand'; + +/** + * 更新组件属性命令 + */ +export class UpdateComponentCommand extends BaseCommand { + private oldValue: unknown; + + constructor( + private messageHub: MessageHub, + private entity: Entity, + private component: Component, + private propertyName: string, + private newValue: unknown + ) { + super(); + this.oldValue = (component as any)[propertyName]; + } + + execute(): void { + (this.component as any)[this.propertyName] = this.newValue; + + this.messageHub.publish('component:updated', { + entity: this.entity, + component: this.component, + propertyName: this.propertyName, + value: this.newValue + }); + } + + undo(): void { + (this.component as any)[this.propertyName] = this.oldValue; + + this.messageHub.publish('component:updated', { + entity: this.entity, + component: this.component, + propertyName: this.propertyName, + value: this.oldValue + }); + } + + getDescription(): string { + return `更新 ${this.component.constructor.name}.${this.propertyName}`; + } + + canMergeWith(other: ICommand): boolean { + if (!(other instanceof UpdateComponentCommand)) return false; + + return ( + this.entity === other.entity && + this.component === other.component && + this.propertyName === other.propertyName + ); + } + + mergeWith(other: ICommand): ICommand { + if (!(other instanceof UpdateComponentCommand)) { + throw new Error('无法合并不同类型的命令'); + } + + // 保留原始值,使用新命令的新值 + const merged = new UpdateComponentCommand( + this.messageHub, + this.entity, + this.component, + this.propertyName, + other.newValue + ); + merged.oldValue = this.oldValue; + + return merged; + } +} diff --git a/packages/editor-app/src/application/commands/component/index.ts b/packages/editor-app/src/application/commands/component/index.ts new file mode 100644 index 00000000..ac481cf9 --- /dev/null +++ b/packages/editor-app/src/application/commands/component/index.ts @@ -0,0 +1,3 @@ +export { AddComponentCommand } from './AddComponentCommand'; +export { RemoveComponentCommand } from './RemoveComponentCommand'; +export { UpdateComponentCommand } from './UpdateComponentCommand'; diff --git a/packages/editor-app/src/application/commands/entity/CreateEntityCommand.ts b/packages/editor-app/src/application/commands/entity/CreateEntityCommand.ts new file mode 100644 index 00000000..e8e65c5a --- /dev/null +++ b/packages/editor-app/src/application/commands/entity/CreateEntityCommand.ts @@ -0,0 +1,58 @@ +import { Core, Entity } from '@esengine/ecs-framework'; +import { EntityStoreService, MessageHub } from '@esengine/editor-core'; +import { BaseCommand } from '../BaseCommand'; + +/** + * 创建实体命令 + */ +export class CreateEntityCommand 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; + + 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 `创建实体: ${this.entityName}`; + } + + getCreatedEntity(): Entity | null { + return this.entity; + } +} diff --git a/packages/editor-app/src/application/commands/entity/DeleteEntityCommand.ts b/packages/editor-app/src/application/commands/entity/DeleteEntityCommand.ts new file mode 100644 index 00000000..fb904754 --- /dev/null +++ b/packages/editor-app/src/application/commands/entity/DeleteEntityCommand.ts @@ -0,0 +1,91 @@ +import { Core, Entity, Component } from '@esengine/ecs-framework'; +import { EntityStoreService, MessageHub } from '@esengine/editor-core'; +import { BaseCommand } from '../BaseCommand'; + +/** + * 删除实体命令 + */ +export class DeleteEntityCommand extends BaseCommand { + private entityId: number; + private entityName: string; + private parentEntity: Entity | null; + private components: Component[] = []; + private childEntities: Entity[] = []; + + constructor( + private entityStore: EntityStoreService, + private messageHub: MessageHub, + private entity: Entity + ) { + super(); + this.entityId = entity.id; + this.entityName = entity.name; + this.parentEntity = entity.parent; + + // 保存组件状态用于撤销 + this.components = [...entity.components]; + // 保存子实体 + this.childEntities = [...entity.children]; + } + + execute(): void { + // 先移除子实体 + for (const child of this.childEntities) { + this.entityStore.removeEntity(child); + } + + this.entityStore.removeEntity(this.entity); + this.entity.destroy(); + + this.messageHub.publish('entity:removed', { entityId: this.entityId }); + } + + undo(): void { + const scene = Core.scene; + if (!scene) { + throw new Error('场景未初始化'); + } + + // 重新创建实体 + const newEntity = scene.createEntity(this.entityName); + + // 设置父实体 + if (this.parentEntity) { + this.parentEntity.addChild(newEntity); + } + + // 恢复组件 + for (const component of this.components) { + // 创建组件副本 + const ComponentClass = component.constructor as new () => Component; + const newComponent = new ComponentClass(); + + // 复制属性 + for (const key of Object.keys(component)) { + if (key !== 'entity' && key !== 'id') { + (newComponent as any)[key] = (component as any)[key]; + } + } + + newEntity.addComponent(newComponent); + } + + // 恢复子实体 + for (const child of this.childEntities) { + newEntity.addChild(child); + this.entityStore.addEntity(child, newEntity); + } + + this.entityStore.addEntity(newEntity, this.parentEntity ?? undefined); + this.entityStore.selectEntity(newEntity); + + // 更新引用 + this.entity = newEntity; + + this.messageHub.publish('entity:added', { entity: newEntity }); + } + + getDescription(): string { + return `删除实体: ${this.entityName}`; + } +} diff --git a/packages/editor-app/src/application/commands/entity/index.ts b/packages/editor-app/src/application/commands/entity/index.ts new file mode 100644 index 00000000..13ac69f6 --- /dev/null +++ b/packages/editor-app/src/application/commands/entity/index.ts @@ -0,0 +1,2 @@ +export { CreateEntityCommand } from './CreateEntityCommand'; +export { DeleteEntityCommand } from './DeleteEntityCommand'; diff --git a/packages/editor-app/src/components/SceneHierarchy.tsx b/packages/editor-app/src/components/SceneHierarchy.tsx index 3580c571..7e62eb02 100644 --- a/packages/editor-app/src/components/SceneHierarchy.tsx +++ b/packages/editor-app/src/components/SceneHierarchy.tsx @@ -1,29 +1,37 @@ import { useState, useEffect } from 'react'; import { Entity, Core } from '@esengine/ecs-framework'; -import { EntityStoreService, MessageHub, SceneManagerService } from '@esengine/editor-core'; +import { EntityStoreService, MessageHub, SceneManagerService, CommandManager } from '@esengine/editor-core'; import { useLocale } from '../hooks/useLocale'; -import { Box, Layers, Wifi, Search, Plus, Trash2 } from 'lucide-react'; +import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe } from 'lucide-react'; import { ProfilerService, RemoteEntity } from '../services/ProfilerService'; import { confirm } from '@tauri-apps/plugin-dialog'; +import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity'; import '../styles/SceneHierarchy.css'; +type ViewMode = 'local' | 'remote'; + interface SceneHierarchyProps { entityStore: EntityStoreService; messageHub: MessageHub; + commandManager: CommandManager; } -export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps) { +export function SceneHierarchy({ entityStore, messageHub, commandManager }: SceneHierarchyProps) { const [entities, setEntities] = useState([]); const [remoteEntities, setRemoteEntities] = useState([]); const [isRemoteConnected, setIsRemoteConnected] = useState(false); + const [viewMode, setViewMode] = useState('local'); const [selectedId, setSelectedId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [sceneName, setSceneName] = useState('Untitled'); const [remoteSceneName, setRemoteSceneName] = useState(null); const [sceneFilePath, setSceneFilePath] = useState(null); const [isSceneModified, setIsSceneModified] = useState(false); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entityId: number | null } | null>(null); const { t, locale } = useLocale(); + const isShowingRemote = viewMode === 'remote' && isRemoteConnected; + // Subscribe to scene changes useEffect(() => { const sceneManager = Core.services.resolve(SceneManagerService); @@ -182,14 +190,15 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps) }; const handleCreateEntity = () => { - const scene = Core.scene; - if (!scene) return; - const entityCount = entityStore.getAllEntities().length; const entityName = `Entity ${entityCount + 1}`; - const entity = scene.createEntity(entityName); - entityStore.addEntity(entity); - entityStore.selectEntity(entity); + + const command = new CreateEntityCommand( + entityStore, + messageHub, + entityName + ); + commandManager.execute(command); }; const handleDeleteEntity = async () => { @@ -200,8 +209,8 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps) const confirmed = await confirm( locale === 'zh' - ? `确定要删除实体 "${entity.name}" 吗?此操作无法撤销。` - : `Are you sure you want to delete entity "${entity.name}"? This action cannot be undone.`, + ? `确定要删除实体 "${entity.name}" 吗?` + : `Are you sure you want to delete entity "${entity.name}"?`, { title: locale === 'zh' ? '删除实体' : 'Delete Entity', kind: 'warning' @@ -209,22 +218,44 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps) ); if (confirmed) { - entity.destroy(); - entityStore.removeEntity(entity); + const command = new DeleteEntityCommand( + entityStore, + messageHub, + entity + ); + commandManager.execute(command); } }; + const handleContextMenu = (e: React.MouseEvent, entityId: number | null) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, entityId }); + }; + + const closeContextMenu = () => { + setContextMenu(null); + }; + + // Close context menu on click outside + useEffect(() => { + const handleClick = () => closeContextMenu(); + if (contextMenu) { + window.addEventListener('click', handleClick); + return () => window.removeEventListener('click', handleClick); + } + }, [contextMenu]); + // Listen for Delete key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Delete' && selectedId && !isRemoteConnected) { + if (e.key === 'Delete' && selectedId && !isShowingRemote) { handleDeleteEntity(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [selectedId, isRemoteConnected]); + }, [selectedId, isShowingRemote]); // Filter entities based on search query const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => { @@ -262,11 +293,11 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps) }; // Determine which entities to display - const displayEntities = isRemoteConnected + const displayEntities = isShowingRemote ? filterRemoteEntities(remoteEntities) : filterLocalEntities(entities); - const showRemoteIndicator = isRemoteConnected && remoteEntities.length > 0; - const displaySceneName = isRemoteConnected && remoteSceneName ? remoteSceneName : sceneName; + const showRemoteIndicator = isShowingRemote && remoteEntities.length > 0; + const displaySceneName = isShowingRemote && remoteSceneName ? remoteSceneName : sceneName; return (
@@ -282,6 +313,24 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps) {displaySceneName}{!isRemoteConnected && isSceneModified ? '*' : ''}
+ {isRemoteConnected && ( +
+ + +
+ )} {showRemoteIndicator && (
@@ -297,18 +346,17 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps) onChange={(e) => setSearchQuery(e.target.value)} />
- {!isRemoteConnected && ( + {!isShowingRemote && (
)} -
+
!isShowingRemote && handleContextMenu(e, null)}> {displayEntities.length === 0 ? (
{t('hierarchy.empty')}
- {isRemoteConnected + {isShowingRemote ? 'No entities in remote game' : 'Create an entity to get started'}
- ) : isRemoteConnected ? ( + ) : isShowingRemote ? (
    {(displayEntities as RemoteEntity[]).map((entity) => (
  • handleEntityClick(entity)} + onContextMenu={(e) => { + e.stopPropagation(); + handleEntityClick(entity); + handleContextMenu(e, entity.id); + }} > - Entity {entity.id} + {entity.name || `Entity ${entity.id}`}
  • ))}
)}
+ + {contextMenu && !isShowingRemote && ( +
+ + {contextMenu.entityId && ( + <> +
+ + + )} +
+ )}
); } diff --git a/packages/editor-app/src/components/Viewport.tsx b/packages/editor-app/src/components/Viewport.tsx index 8c746c08..09cdfb9b 100644 --- a/packages/editor-app/src/components/Viewport.tsx +++ b/packages/editor-app/src/components/Viewport.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; -import { Play, Pause, RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity, Box, Square } from 'lucide-react'; +import { Play, Pause, RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity, Box, Square, Zap } from 'lucide-react'; import '../styles/Viewport.css'; +import { useEngine } from '../hooks/useEngine'; interface ViewportProps { locale?: string; @@ -14,6 +15,11 @@ export function Viewport({ locale = 'en' }: ViewportProps) { const [showGizmos, setShowGizmos] = useState(true); const [showStats, setShowStats] = useState(false); const [is3D, setIs3D] = useState(true); + const [useRustEngine, setUseRustEngine] = useState(false); + + // Rust engine hook (only active in 2D mode with engine enabled) + // Rust引擎钩子(仅在2D模式且启用引擎时激活) + const engine = useEngine('viewport-canvas', useRustEngine && !is3D); const animationFrameRef = useRef(); const glRef = useRef(null); const gridProgramRef = useRef(null); @@ -573,7 +579,17 @@ export function Viewport({ locale = 'en' }: ViewportProps) { }; const handlePlayPause = () => { - setIsPlaying(!isPlaying); + const newPlaying = !isPlaying; + setIsPlaying(newPlaying); + + // Control Rust engine if active | 控制Rust引擎(如果激活) + if (useRustEngine && !is3D && engine.state.initialized) { + if (newPlaying) { + engine.start(); + } else { + engine.stop(); + } + } }; const handleReset = () => { @@ -634,6 +650,15 @@ export function Viewport({ locale = 'en' }: ViewportProps) { > {is3D ? : } + {!is3D && ( + + )}
- + {showStats && (
FPS: - {fps} + + {useRustEngine && !is3D ? engine.state.fps : fps} +
Draw Calls: - {drawCalls} + + {useRustEngine && !is3D ? engine.state.drawCalls : drawCalls} +
+ {useRustEngine && !is3D && ( +
+ Sprites: + {engine.state.spriteCount} +
+ )} + {useRustEngine && !is3D && engine.state.error && ( +
+ Error: + {engine.state.error} +
+ )}
)} diff --git a/packages/editor-app/src/components/inspectors/Inspector.tsx b/packages/editor-app/src/components/inspectors/Inspector.tsx index 4ad4ea7c..545f97a5 100644 --- a/packages/editor-app/src/components/inspectors/Inspector.tsx +++ b/packages/editor-app/src/components/inspectors/Inspector.tsx @@ -12,7 +12,7 @@ import { EntityInspector } from './views'; -export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath }: InspectorProps) { +export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath, commandManager }: InspectorProps) { const [target, setTarget] = useState(null); const [componentVersion, setComponentVersion] = useState(0); const [autoRefresh, setAutoRefresh] = useState(true); @@ -196,7 +196,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi } if (target.type === 'entity') { - return ; + return ; } return null; diff --git a/packages/editor-app/src/components/inspectors/types.ts b/packages/editor-app/src/components/inspectors/types.ts index fbda20f5..0de1fbe9 100644 --- a/packages/editor-app/src/components/inspectors/types.ts +++ b/packages/editor-app/src/components/inspectors/types.ts @@ -1,11 +1,12 @@ import { Entity } from '@esengine/ecs-framework'; -import { EntityStoreService, MessageHub, InspectorRegistry } from '@esengine/editor-core'; +import { EntityStoreService, MessageHub, InspectorRegistry, CommandManager } from '@esengine/editor-core'; export interface InspectorProps { entityStore: EntityStoreService; messageHub: MessageHub; inspectorRegistry: InspectorRegistry; projectPath?: string | null; + commandManager: CommandManager; } export interface AssetFileInfo { diff --git a/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx b/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx index a418b234..f4a4f78e 100644 --- a/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx +++ b/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx @@ -1,18 +1,24 @@ import { useState } from 'react'; -import { Settings, ChevronDown, ChevronRight, X } from 'lucide-react'; -import { Entity, Component } from '@esengine/ecs-framework'; -import { MessageHub } from '@esengine/editor-core'; +import { Settings, ChevronDown, ChevronRight, X, Plus } from 'lucide-react'; +import { Entity, Component, Core } from '@esengine/ecs-framework'; +import { MessageHub, CommandManager, ComponentRegistry } from '@esengine/editor-core'; import { PropertyInspector } from '../../PropertyInspector'; +import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component'; import '../../../styles/EntityInspector.css'; interface EntityInspectorProps { entity: Entity; messageHub: MessageHub; + commandManager: CommandManager; componentVersion: number; } -export function EntityInspector({ entity, messageHub, componentVersion }: EntityInspectorProps) { +export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) { const [expandedComponents, setExpandedComponents] = useState>(new Set()); + const [showComponentMenu, setShowComponentMenu] = useState(false); + + const componentRegistry = Core.services.resolve(ComponentRegistry); + const availableComponents = componentRegistry?.getAllComponents() || []; const toggleComponentExpanded = (index: number) => { setExpandedComponents((prev) => { @@ -26,21 +32,33 @@ export function EntityInspector({ entity, messageHub, componentVersion }: Entity }); }; + const handleAddComponent = (ComponentClass: new () => Component) => { + const command = new AddComponentCommand(messageHub, entity, ComponentClass); + commandManager.execute(command); + setShowComponentMenu(false); + }; + const handleRemoveComponent = (index: number) => { const component = entity.components[index]; if (component) { - entity.removeComponent(component); - messageHub.publish('component:removed', { entity, component }); + const command = new RemoveComponentCommand( + messageHub, + entity, + component + ); + commandManager.execute(command); } }; const handlePropertyChange = (component: Component, propertyName: string, value: unknown) => { - messageHub.publish('component:property:changed', { + const command = new UpdateComponentCommand( + messageHub, entity, component, propertyName, value - }); + ); + commandManager.execute(command); }; return ( @@ -63,10 +81,77 @@ export function EntityInspector({ entity, messageHub, componentVersion }: Entity - {entity.components.length > 0 && ( -
-
组件
- {entity.components.map((component: Component, index: number) => { +
+
+ 组件 +
+ + {showComponentMenu && ( +
+ {availableComponents.length === 0 ? ( +
+ 没有可用组件 +
+ ) : ( + availableComponents.map((info) => ( + + )) + )} +
+ )} +
+
+ {entity.components.map((component: Component, index: number) => { const isExpanded = expandedComponents.has(index); const componentName = component.constructor?.name || 'Component'; @@ -140,8 +225,7 @@ export function EntityInspector({ entity, messageHub, componentVersion }: Entity
); })} -
- )} + ); diff --git a/packages/editor-app/src/core/events/EditorEventMap.ts b/packages/editor-app/src/core/events/EditorEventMap.ts index b9b42ac2..39509138 100644 --- a/packages/editor-app/src/core/events/EditorEventMap.ts +++ b/packages/editor-app/src/core/events/EditorEventMap.ts @@ -1,5 +1,4 @@ import type { Entity, Component } from '@esengine/ecs-framework'; -import type { Node } from '@esengine/behavior-tree-editor'; export interface PluginEvent { name: string; diff --git a/packages/editor-app/src/hooks/useEngine.ts b/packages/editor-app/src/hooks/useEngine.ts new file mode 100644 index 00000000..2ec99c6b --- /dev/null +++ b/packages/editor-app/src/hooks/useEngine.ts @@ -0,0 +1,127 @@ +/** + * React hook for using the Rust game engine. + * 使用Rust游戏引擎的React钩子。 + */ + +import { useEffect, useRef, useState, useCallback } from 'react'; +import { EngineService } from '../services/EngineService'; + +export interface EngineState { + initialized: boolean; + running: boolean; + fps: number; + drawCalls: number; + spriteCount: number; + error: string | null; +} + +export interface UseEngineReturn { + state: EngineState; + start: () => void; + stop: () => void; + createSprite: (name: string, options?: { + x?: number; + y?: number; + textureId?: number; + width?: number; + height?: number; + }) => void; + loadTexture: (id: number, url: string) => void; +} + +/** + * Hook for managing engine lifecycle in React components. + * 用于在React组件中管理引擎生命周期的钩子。 + * + * @param canvasId - Canvas element ID | Canvas元素ID + * @param autoInit - Whether to auto-initialize | 是否自动初始化 + */ +export function useEngine(canvasId: string, autoInit = true): UseEngineReturn { + const engineRef = useRef(EngineService.getInstance()); + const statsIntervalRef = useRef(null); + + const [state, setState] = useState({ + initialized: false, + running: false, + fps: 0, + drawCalls: 0, + spriteCount: 0, + error: null + }); + + // Initialize engine | 初始化引擎 + useEffect(() => { + if (!autoInit) return; + + const init = async () => { + try { + await engineRef.current.initialize(canvasId); + setState(prev => ({ ...prev, initialized: true, error: null })); + + // Start stats update interval | 启动统计更新间隔 + statsIntervalRef.current = window.setInterval(() => { + const stats = engineRef.current.getStats(); + setState(prev => ({ + ...prev, + fps: stats.fps, + drawCalls: stats.drawCalls, + spriteCount: stats.spriteCount + })); + }, 100); + } catch (error) { + console.error('Failed to initialize engine | 引擎初始化失败:', error); + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : String(error) + })); + } + }; + + init(); + + return () => { + if (statsIntervalRef.current) { + clearInterval(statsIntervalRef.current); + } + engineRef.current.dispose(); + }; + }, [canvasId, autoInit]); + + // Start engine | 启动引擎 + const start = useCallback(() => { + engineRef.current.start(); + setState(prev => ({ ...prev, running: true })); + }, []); + + // Stop engine | 停止引擎 + const stop = useCallback(() => { + engineRef.current.stop(); + setState(prev => ({ ...prev, running: false })); + }, []); + + // Create sprite entity | 创建精灵实体 + const createSprite = useCallback((name: string, options?: { + x?: number; + y?: number; + textureId?: number; + width?: number; + height?: number; + }) => { + engineRef.current.createSpriteEntity(name, options); + }, []); + + // Load texture | 加载纹理 + const loadTexture = useCallback((id: number, url: string) => { + engineRef.current.loadTexture(id, url); + }, []); + + return { + state, + start, + stop, + createSprite, + loadTexture + }; +} + +export default useEngine; diff --git a/packages/editor-app/src/services/EngineService.ts b/packages/editor-app/src/services/EngineService.ts new file mode 100644 index 00000000..4c65fa00 --- /dev/null +++ b/packages/editor-app/src/services/EngineService.ts @@ -0,0 +1,254 @@ +/** + * Engine service for managing Rust engine lifecycle. + * 管理Rust引擎生命周期的服务。 + */ + +import { EngineBridge, SpriteComponent, EngineRenderSystem, ITransformComponent } from '@esengine/ecs-engine-bindgen'; +import { Core, Scene, Entity, Component, ECSComponent } from '@esengine/ecs-framework'; +import * as esEngine from '@esengine/engine'; + +/** + * Transform component for editor entities. + * 编辑器实体的变换组件。 + */ +@ECSComponent('Transform') +export class TransformComponent extends Component implements ITransformComponent { + position = { x: 0, y: 0 }; + rotation = 0; + scale = { x: 1, y: 1 }; +} + +/** + * Engine service singleton for editor integration. + * 用于编辑器集成的引擎服务单例。 + */ +export class EngineService { + private static instance: EngineService | null = null; + + private bridge: EngineBridge | null = null; + private scene: Scene | null = null; + private renderSystem: EngineRenderSystem | null = null; + private initialized = false; + private running = false; + private animationFrameId: number | null = null; + private lastTime = 0; + + private constructor() {} + + /** + * Get singleton instance. + * 获取单例实例。 + */ + static getInstance(): EngineService { + if (!EngineService.instance) { + EngineService.instance = new EngineService(); + } + return EngineService.instance; + } + + /** + * Initialize the engine with canvas. + * 使用canvas初始化引擎。 + */ + async initialize(canvasId: string): Promise { + if (this.initialized) { + return; + } + + try { + // Create engine bridge | 创建引擎桥接 + this.bridge = new EngineBridge({ + canvasId + }); + + // Initialize WASM with pre-imported module | 使用预导入模块初始化WASM + await this.bridge.initializeWithModule(esEngine); + + // Initialize Core if not already | 初始化Core(如果尚未初始化) + if (!Core.scene) { + Core.create({ debug: false }); + } + + // Create ECS scene and set it via Core | 通过Core创建并设置ECS场景 + this.scene = new Scene({ name: 'EditorScene' }); + + // Add render system | 添加渲染系统 + this.renderSystem = new EngineRenderSystem(this.bridge, TransformComponent); + this.scene.addSystem(this.renderSystem); + + // Set scene via Core | 通过Core设置场景 + Core.setScene(this.scene); + + this.initialized = true; + console.log('EngineService initialized | 引擎服务初始化完成'); + } catch (error) { + console.error('Failed to initialize engine | 引擎初始化失败:', error); + throw error; + } + } + + /** + * Check if engine is initialized. + * 检查引擎是否已初始化。 + */ + isInitialized(): boolean { + return this.initialized; + } + + /** + * Check if engine is running. + * 检查引擎是否正在运行。 + */ + isRunning(): boolean { + return this.running; + } + + /** + * Start the game loop. + * 启动游戏循环。 + */ + start(): void { + if (!this.initialized || this.running) { + return; + } + + this.running = true; + this.lastTime = performance.now(); + this.gameLoop(); + } + + /** + * Stop the game loop. + * 停止游戏循环。 + */ + stop(): void { + this.running = false; + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + } + + /** + * Main game loop. + * 主游戏循环。 + */ + private gameLoop = (): void => { + if (!this.running) { + return; + } + + const currentTime = performance.now(); + const deltaTime = (currentTime - this.lastTime) / 1000; + this.lastTime = currentTime; + + // Update via Core | 通过Core更新 + Core.update(deltaTime); + + this.animationFrameId = requestAnimationFrame(this.gameLoop); + }; + + /** + * Create entity with sprite and transform. + * 创建带精灵和变换的实体。 + */ + createSpriteEntity(name: string, options?: { + x?: number; + y?: number; + textureId?: number; + width?: number; + height?: number; + }): Entity | null { + if (!this.scene) { + return null; + } + + const entity = this.scene.createEntity(name); + + // Add transform | 添加变换组件 + const transform = new TransformComponent(); + if (options) { + transform.position.x = options.x ?? 0; + transform.position.y = options.y ?? 0; + } + entity.addComponent(transform); + + // Add sprite | 添加精灵组件 + const sprite = new SpriteComponent(); + if (options) { + sprite.textureId = options.textureId ?? 0; + sprite.width = options.width ?? 64; + sprite.height = options.height ?? 64; + } + entity.addComponent(sprite); + + return entity; + } + + /** + * Load texture. + * 加载纹理。 + */ + loadTexture(id: number, url: string): void { + if (this.renderSystem) { + this.renderSystem.loadTexture(id, url); + } + } + + /** + * Get engine statistics. + * 获取引擎统计信息。 + */ + getStats(): { fps: number; drawCalls: number; spriteCount: number } { + if (!this.renderSystem) { + return { fps: 0, drawCalls: 0, spriteCount: 0 }; + } + + const engineStats = this.renderSystem.getStats(); + return { + fps: engineStats?.fps ?? 0, + drawCalls: engineStats?.drawCalls ?? 0, + spriteCount: this.renderSystem.spriteCount + }; + } + + /** + * Get the ECS scene. + * 获取ECS场景。 + */ + getScene(): Scene | null { + return this.scene; + } + + /** + * Resize the engine viewport. + * 调整引擎视口大小。 + */ + resize(width: number, height: number): void { + if (this.bridge) { + this.bridge.resize(width, height); + } + } + + /** + * Dispose engine resources. + * 释放引擎资源。 + */ + dispose(): void { + this.stop(); + + // Scene doesn't have a destroy method, just clear reference + // 场景没有destroy方法,只需清除引用 + this.scene = null; + + if (this.bridge) { + this.bridge.dispose(); + this.bridge = null; + } + + this.renderSystem = null; + this.initialized = false; + } +} + +export default EngineService; diff --git a/packages/editor-app/src/styles/SceneHierarchy.css b/packages/editor-app/src/styles/SceneHierarchy.css index 700a51f2..0146c590 100644 --- a/packages/editor-app/src/styles/SceneHierarchy.css +++ b/packages/editor-app/src/styles/SceneHierarchy.css @@ -67,6 +67,51 @@ transition: color var(--transition-fast); } +.view-mode-toggle { + display: flex; + align-items: center; + gap: 2px; + padding: 2px; + background-color: var(--color-bg-base); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-sm); +} + +.mode-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + min-width: 24px; + height: 20px; + padding: 0; + background: transparent; + border: none; + border-radius: var(--radius-xs); + color: #cccccc; + cursor: pointer; + transition: all var(--transition-fast); +} + +.mode-btn svg { + width: 14px; + height: 14px; + min-width: 14px; + min-height: 14px; + color: inherit; + stroke: currentColor; +} + +.mode-btn:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.mode-btn.active { + background-color: var(--color-primary); + color: white; +} + .remote-indicator { display: flex; align-items: center; @@ -144,8 +189,14 @@ } .toolbar-btn:disabled { - opacity: 0.5; cursor: not-allowed; + color: #666; +} + +.toolbar-btn.icon-only { + padding: var(--spacing-xs); + min-width: 28px; + justify-content: center; } .hierarchy-content { @@ -340,3 +391,38 @@ transform: scale(1.05); } } + +/* Context menu styles */ +.context-menu { + background-color: var(--color-bg-elevated); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + padding: var(--spacing-xs); + min-width: 150px; +} + +.context-menu button { + display: flex; + align-items: center; + gap: var(--spacing-sm); + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + cursor: pointer; + text-align: left; +} + +.context-menu button:hover { + background-color: var(--color-bg-hover); +} + +.context-menu-divider { + height: 1px; + background-color: var(--color-border-default); + margin: var(--spacing-xs) 0; +} diff --git a/packages/editor-app/src/styles/design-tokens.css b/packages/editor-app/src/styles/design-tokens.css index 5a6f7ae4..9a01107f 100644 --- a/packages/editor-app/src/styles/design-tokens.css +++ b/packages/editor-app/src/styles/design-tokens.css @@ -12,7 +12,7 @@ --color-text-primary: #cccccc; --color-text-secondary: #9d9d9d; --color-text-tertiary: #6a6a6a; - --color-text-disabled: #4d4d4d; + --color-text-disabled: #aaaaaa; --color-text-inverse: #ffffff; /* 颜色系统 - 边框 */ diff --git a/packages/editor-app/vite.config.ts b/packages/editor-app/vite.config.ts index e4f52e01..2da8047f 100644 --- a/packages/editor-app/vite.config.ts +++ b/packages/editor-app/vite.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; +import wasm from 'vite-plugin-wasm'; +import topLevelAwait from 'vite-plugin-top-level-await'; import fs from 'fs'; import path from 'path'; @@ -8,6 +10,34 @@ const host = process.env.TAURI_DEV_HOST; const userProjectPathMap = new Map(); const editorPackageMapping = new Map(); const editorPackageVersions = new Map(); +const wasmPackages: string[] = []; // Auto-detected WASM packages + +/** + * Check if a package directory contains WASM files (non-recursive, only check root and pkg folder). + * 检查包目录是否包含WASM文件(非递归,只检查根目录和pkg文件夹)。 + */ +function hasWasmFiles(dirPath: string): boolean { + try { + const files = fs.readdirSync(dirPath); + for (const file of files) { + // Only check .wasm files in root or pkg folder + if (file.endsWith('.wasm')) { + return true; + } + // Only check pkg folder (common wasm-pack output) + if (file === 'pkg') { + const pkgPath = path.join(dirPath, file); + const pkgFiles = fs.readdirSync(pkgPath); + if (pkgFiles.some(f => f.endsWith('.wasm'))) { + return true; + } + } + } + } catch { + // Ignore errors + } + return false; +} function loadEditorPackages() { const packagesDir = path.resolve(__dirname, '..'); @@ -25,23 +55,89 @@ function loadEditorPackages() { if (fs.existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); - if (packageJson.name && packageJson.name.startsWith('@esengine/')) { + const packageName = packageJson.name; + + if (packageName && packageName.startsWith('@esengine/')) { const mainFile = packageJson.module || packageJson.main; if (mainFile) { const entryPath = path.join(packagesDir, dir, mainFile); if (fs.existsSync(entryPath)) { - editorPackageMapping.set(packageJson.name, entryPath); + editorPackageMapping.set(packageName, entryPath); } } if (packageJson.version) { - editorPackageVersions.set(packageJson.name, packageJson.version); + editorPackageVersions.set(packageName, packageJson.version); } } + + // Check for WASM files and add to wasmPackages + // 检查WASM文件并添加到wasmPackages + const packageDir = path.join(packagesDir, dir); + if (packageName && hasWasmFiles(packageDir)) { + wasmPackages.push(packageName); + console.log(`[Vite] Detected WASM package: ${packageName}`); + } } catch (e) { console.error(`[Vite] Failed to read package.json for ${dir}:`, e); } } } + + // Also scan node_modules for WASM packages + // 也扫描node_modules中的WASM包 + const nodeModulesDir = path.resolve(__dirname, 'node_modules'); + if (fs.existsSync(nodeModulesDir)) { + scanNodeModulesForWasm(nodeModulesDir); + } +} + +/** + * Scan node_modules for WASM packages. + * 扫描node_modules中的WASM包。 + */ +function scanNodeModulesForWasm(nodeModulesDir: string) { + try { + const entries = fs.readdirSync(nodeModulesDir); + for (const entry of entries) { + // Skip .pnpm and other hidden/internal directories + if (entry.startsWith('.')) continue; + + const entryPath = path.join(nodeModulesDir, entry); + const stat = fs.statSync(entryPath); + + if (!stat.isDirectory()) continue; + + // Handle scoped packages (@scope/package) + if (entry.startsWith('@')) { + const scopedPackages = fs.readdirSync(entryPath); + for (const scopedPkg of scopedPackages) { + const scopedPath = path.join(entryPath, scopedPkg); + const scopedStat = fs.statSync(scopedPath); + if (scopedStat.isDirectory()) { + checkAndAddWasmPackage(scopedPath, `${entry}/${scopedPkg}`); + } + } + } else { + checkAndAddWasmPackage(entryPath, entry); + } + } + } catch { + // Ignore errors + } +} + +/** + * Check if a package has WASM files and add to wasmPackages. + * 检查包是否有WASM文件并添加到wasmPackages。 + */ +function checkAndAddWasmPackage(packagePath: string, packageName: string) { + // Skip if already added + if (wasmPackages.includes(packageName)) return; + + if (hasWasmFiles(packagePath)) { + wasmPackages.push(packageName); + console.log(`[Vite] Detected WASM package in node_modules: ${packageName}`); + } } loadEditorPackages(); @@ -581,6 +677,8 @@ module.exports = [ inlineDynamicImports: true }, plugins: [ + wasm(), + topLevelAwait(), resolve({ extensions: ['.js', '.jsx'] }), @@ -607,6 +705,8 @@ module.exports = [ format: 'es' }, plugins: [ + wasm(), + topLevelAwait(), dts({ respectExternal: true }) @@ -638,6 +738,8 @@ module.exports = [ export default defineConfig({ plugins: [ + wasm(), + topLevelAwait(), ...react({ tsDecorators: true, }), @@ -665,4 +767,11 @@ export default defineConfig({ minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, sourcemap: !!process.env.TAURI_DEBUG, }, + optimizeDeps: { + // Pre-bundle common dependencies to avoid runtime re-optimization | 预打包常用依赖以避免运行时重新优化 + include: ['tslib', 'react', 'react-dom', 'zustand', 'lucide-react'], + // Exclude WASM packages from pre-bundling | 排除 WASM 包不进行预打包 + // Add user WASM plugins to wasmPackages array at top of file | 将用户 WASM 插件添加到文件顶部的 wasmPackages 数组 + exclude: wasmPackages, + }, }); diff --git a/packages/engine/.gitignore b/packages/engine/.gitignore new file mode 100644 index 00000000..6122922d --- /dev/null +++ b/packages/engine/.gitignore @@ -0,0 +1,16 @@ +# Build output | 构建输出 +/target +/pkg + +# Cargo lock (library) | Cargo锁文件(库) +Cargo.lock + +# IDE | 开发环境 +.idea/ +.vscode/ +*.swp +*.swo + +# OS files | 系统文件 +.DS_Store +Thumbs.db diff --git a/packages/engine/Cargo.toml b/packages/engine/Cargo.toml new file mode 100644 index 00000000..c548bf8f --- /dev/null +++ b/packages/engine/Cargo.toml @@ -0,0 +1,86 @@ +[package] +name = "es-engine" +version = "0.1.0" +edition = "2021" +authors = ["ESEngine Team"] +description = "High-performance 2D game engine for web and mobile platforms | 高性能2D游戏引擎,支持Web和移动平台" +license = "MIT" +repository = "https://github.com/esengine/ecs-framework" +keywords = ["game-engine", "2d", "webgl", "wasm", "ecs"] +categories = ["game-engines", "wasm", "graphics"] + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["console_error_panic_hook"] + +[dependencies] +# WASM bindings | WASM绑定 +wasm-bindgen = "0.2" +js-sys = "0.3" + +# Web APIs | Web API +web-sys = { version = "0.3", features = [ + # Core | 核心 + "Window", + "Document", + "Element", + "HtmlCanvasElement", + "HtmlCollection", + "Navigator", + "Screen", + "Performance", + "console", + + # WebGL2 | WebGL2渲染 + "WebGl2RenderingContext", + "WebGlProgram", + "WebGlShader", + "WebGlBuffer", + "WebGlTexture", + "WebGlUniformLocation", + "WebGlVertexArrayObject", + "WebGlFramebuffer", + + # Events | 事件 + "KeyboardEvent", + "MouseEvent", + "TouchEvent", + "TouchList", + "Touch", + + # Image | 图像 + "HtmlImageElement", + "ImageData", +]} + +# Math library | 数学库 +glam = { version = "0.24", features = ["bytemuck"] } + +# Error handling | 错误处理 +thiserror = "1.0" + +# Logging | 日志 +log = "0.4" +console_log = { version = "1.0", features = ["color"] } + +# Panic hook for better error messages | 更好的错误信息 +console_error_panic_hook = { version = "0.1", optional = true } + +# Serialization | 序列化 +serde = { version = "1.0", features = ["derive"] } + +# Byte manipulation | 字节操作 +bytemuck = { version = "1.14", features = ["derive"] } + +[dev-dependencies] +wasm-bindgen-test = "0.3" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 + +[profile.dev] +opt-level = 1 diff --git a/packages/engine/package.json b/packages/engine/package.json new file mode 100644 index 00000000..999a1ed2 --- /dev/null +++ b/packages/engine/package.json @@ -0,0 +1,37 @@ +{ + "name": "@esengine/engine", + "version": "0.1.0", + "description": "High-performance 2D game engine built with Rust and WebAssembly | 使用Rust和WebAssembly构建的高性能2D游戏引擎", + "main": "pkg/es_engine.js", + "types": "pkg/es_engine.d.ts", + "files": [ + "pkg" + ], + "scripts": { + "build": "wasm-pack build --target web --out-dir pkg", + "build:release": "wasm-pack build --target web --out-dir pkg --release", + "build:bundler": "wasm-pack build --target bundler --out-dir pkg", + "clean": "rimraf pkg target", + "test": "wasm-pack test --headless --firefox" + }, + "repository": { + "type": "git", + "url": "https://github.com/esengine/ecs-framework.git", + "directory": "packages/engine" + }, + "keywords": [ + "game-engine", + "2d", + "webgl", + "wasm", + "rust", + "ecs", + "webassembly" + ], + "author": "ESEngine Team", + "license": "MIT", + "devDependencies": { + "rimraf": "^5.0.0" + }, + "peerDependencies": {} +} diff --git a/packages/engine/src/core/context.rs b/packages/engine/src/core/context.rs new file mode 100644 index 00000000..abe4a880 --- /dev/null +++ b/packages/engine/src/core/context.rs @@ -0,0 +1,153 @@ +//! WebGL context management. +//! WebGL上下文管理。 + +use web_sys::{HtmlCanvasElement, WebGl2RenderingContext}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +use super::error::{EngineError, Result}; + +/// WebGL2 rendering context wrapper. +/// WebGL2渲染上下文包装器。 +/// +/// Manages the WebGL2 context and provides helper methods for common operations. +/// 管理WebGL2上下文并提供常用操作的辅助方法。 +pub struct WebGLContext { + /// The WebGL2 rendering context. + /// WebGL2渲染上下文。 + gl: WebGl2RenderingContext, + + /// The canvas element. + /// Canvas元素。 + canvas: HtmlCanvasElement, +} + +impl WebGLContext { + /// Create a new WebGL context from a canvas ID. + /// 从canvas ID创建新的WebGL上下文。 + /// + /// # Arguments | 参数 + /// * `canvas_id` - The ID of the canvas element | canvas元素的ID + /// + /// # Returns | 返回 + /// A new WebGLContext or an error | 新的WebGLContext或错误 + pub fn new(canvas_id: &str) -> Result { + // Get document and canvas | 获取document和canvas + let window = web_sys::window().expect("No window found | 未找到window"); + let document = window.document().expect("No document found | 未找到document"); + + let canvas = document + .get_element_by_id(canvas_id) + .ok_or_else(|| EngineError::CanvasNotFound(canvas_id.to_string()))? + .dyn_into::() + .map_err(|_| EngineError::CanvasNotFound(canvas_id.to_string()))?; + + // Create WebGL2 context | 创建WebGL2上下文 + let gl = canvas + .get_context("webgl2") + .map_err(|_| EngineError::ContextCreationFailed)? + .ok_or(EngineError::ContextCreationFailed)? + .dyn_into::() + .map_err(|_| EngineError::ContextCreationFailed)?; + + log::info!( + "WebGL2 context created | WebGL2上下文已创建: {}x{}", + canvas.width(), + canvas.height() + ); + + Ok(Self { gl, canvas }) + } + + /// Create a new WebGL context from external JavaScript objects. + /// 从外部 JavaScript 对象创建 WebGL 上下文。 + /// + /// This method is designed for environments like WeChat MiniGame + /// where the canvas is not a standard HTML element. + /// 此方法适用于微信小游戏等环境,其中 canvas 不是标准 HTML 元素。 + pub fn from_external( + gl_context: JsValue, + canvas_width: u32, + canvas_height: u32, + ) -> Result { + // Convert JsValue to WebGl2RenderingContext + let gl = gl_context + .dyn_into::() + .map_err(|_| EngineError::ContextCreationFailed)?; + + // Create a dummy canvas for compatibility + // In MiniGame environment, we don't have HtmlCanvasElement + let window = web_sys::window().ok_or(EngineError::ContextCreationFailed)?; + let document = window.document().ok_or(EngineError::ContextCreationFailed)?; + let canvas = document + .create_element("canvas") + .map_err(|_| EngineError::ContextCreationFailed)? + .dyn_into::() + .map_err(|_| EngineError::ContextCreationFailed)?; + + canvas.set_width(canvas_width); + canvas.set_height(canvas_height); + + log::info!( + "WebGL2 context created from external | 从外部创建WebGL2上下文: {}x{}", + canvas_width, + canvas_height + ); + + Ok(Self { gl, canvas }) + } + + /// Get a reference to the WebGL2 context. + /// 获取WebGL2上下文的引用。 + #[inline] + pub fn gl(&self) -> &WebGl2RenderingContext { + &self.gl + } + + /// Get a reference to the canvas element. + /// 获取canvas元素的引用。 + #[inline] + pub fn canvas(&self) -> &HtmlCanvasElement { + &self.canvas + } + + /// Get canvas width. + /// 获取canvas宽度。 + #[inline] + pub fn width(&self) -> u32 { + self.canvas.width() + } + + /// Get canvas height. + /// 获取canvas高度。 + #[inline] + pub fn height(&self) -> u32 { + self.canvas.height() + } + + /// Clear the canvas with specified color. + /// 使用指定颜色清除canvas。 + pub fn clear(&self, r: f32, g: f32, b: f32, a: f32) { + self.gl.clear_color(r, g, b, a); + self.gl.clear( + WebGl2RenderingContext::COLOR_BUFFER_BIT | WebGl2RenderingContext::DEPTH_BUFFER_BIT, + ); + } + + /// Set the viewport to match canvas size. + /// 设置视口以匹配canvas大小。 + pub fn set_viewport(&self) { + self.gl + .viewport(0, 0, self.width() as i32, self.height() as i32); + } + + /// Enable alpha blending for transparency. + /// 启用透明度的alpha混合。 + pub fn enable_blend(&self) { + self.gl.enable(WebGl2RenderingContext::BLEND); + self.gl.blend_func( + WebGl2RenderingContext::SRC_ALPHA, + WebGl2RenderingContext::ONE_MINUS_SRC_ALPHA, + ); + } +} diff --git a/packages/engine/src/core/engine.rs b/packages/engine/src/core/engine.rs new file mode 100644 index 00000000..6a169f2b --- /dev/null +++ b/packages/engine/src/core/engine.rs @@ -0,0 +1,187 @@ +//! Main engine implementation. +//! 主引擎实现。 + +use wasm_bindgen::prelude::*; + +use super::context::WebGLContext; +use super::error::Result; +use crate::input::InputManager; +use crate::renderer::Renderer2D; +use crate::resource::TextureManager; + +/// Engine configuration options. +/// 引擎配置选项。 +#[derive(Debug, Clone)] +pub struct EngineConfig { + /// Maximum sprites per batch. + /// 每批次最大精灵数。 + pub max_sprites: usize, + + /// Enable debug mode. + /// 启用调试模式。 + pub debug: bool, +} + +impl Default for EngineConfig { + fn default() -> Self { + Self { + max_sprites: 10000, + debug: false, + } + } +} + +/// Main game engine. +/// 主游戏引擎。 +/// +/// Coordinates all engine subsystems including rendering, input, and resources. +/// 协调所有引擎子系统,包括渲染、输入和资源。 +pub struct Engine { + /// WebGL context. + /// WebGL上下文。 + context: WebGLContext, + + /// 2D renderer. + /// 2D渲染器。 + renderer: Renderer2D, + + /// Texture manager. + /// 纹理管理器。 + texture_manager: TextureManager, + + /// Input manager. + /// 输入管理器。 + input_manager: InputManager, + + /// Engine configuration. + /// 引擎配置。 + #[allow(dead_code)] + config: EngineConfig, +} + +impl Engine { + /// Create a new engine instance. + /// 创建新的引擎实例。 + /// + /// # Arguments | 参数 + /// * `canvas_id` - The HTML canvas element ID | HTML canvas元素ID + /// * `config` - Engine configuration | 引擎配置 + /// + /// # Returns | 返回 + /// A new Engine instance or an error | 新的Engine实例或错误 + pub fn new(canvas_id: &str, config: EngineConfig) -> Result { + let context = WebGLContext::new(canvas_id)?; + + // Initialize WebGL state | 初始化WebGL状态 + context.set_viewport(); + context.enable_blend(); + + // Create subsystems | 创建子系统 + let renderer = Renderer2D::new(context.gl(), config.max_sprites)?; + let texture_manager = TextureManager::new(context.gl().clone()); + let input_manager = InputManager::new(); + + log::info!("Engine created successfully | 引擎创建成功"); + + Ok(Self { + context, + renderer, + texture_manager, + input_manager, + config, + }) + } + + /// Create a new engine instance from external WebGL context. + /// 从外部 WebGL 上下文创建引擎实例。 + /// + /// This is designed for environments like WeChat MiniGame. + /// 适用于微信小游戏等环境。 + pub fn from_external( + gl_context: JsValue, + width: u32, + height: u32, + config: EngineConfig, + ) -> Result { + let context = WebGLContext::from_external(gl_context, width, height)?; + + context.set_viewport(); + context.enable_blend(); + + let renderer = Renderer2D::new(context.gl(), config.max_sprites)?; + let texture_manager = TextureManager::new(context.gl().clone()); + let input_manager = InputManager::new(); + + log::info!("Engine created from external context | 从外部上下文创建引擎"); + + Ok(Self { + context, + renderer, + texture_manager, + input_manager, + config, + }) + } + + /// Clear the screen with specified color. + /// 使用指定颜色清除屏幕。 + pub fn clear(&self, r: f32, g: f32, b: f32, a: f32) { + self.context.clear(r, g, b, a); + } + + /// Get canvas width. + /// 获取画布宽度。 + #[inline] + pub fn width(&self) -> u32 { + self.context.width() + } + + /// Get canvas height. + /// 获取画布高度。 + #[inline] + pub fn height(&self) -> u32 { + self.context.height() + } + + /// Submit sprite batch data for rendering. + /// 提交精灵批次数据进行渲染。 + pub fn submit_sprite_batch( + &mut self, + transforms: &[f32], + texture_ids: &[u32], + uvs: &[f32], + colors: &[u32], + ) -> Result<()> { + self.renderer.submit_batch( + transforms, + texture_ids, + uvs, + colors, + &self.texture_manager, + ) + } + + /// Render the current frame. + /// 渲染当前帧。 + pub fn render(&mut self) -> Result<()> { + self.renderer.render(self.context.gl()) + } + + /// Load a texture from URL. + /// 从URL加载纹理。 + pub fn load_texture(&mut self, id: u32, url: &str) -> Result<()> { + self.texture_manager.load_texture(id, url) + } + + /// Check if a key is currently pressed. + /// 检查某个键是否当前被按下。 + pub fn is_key_down(&self, key_code: &str) -> bool { + self.input_manager.is_key_down(key_code) + } + + /// Update input state. + /// 更新输入状态。 + pub fn update_input(&mut self) { + self.input_manager.update(); + } +} diff --git a/packages/engine/src/core/error.rs b/packages/engine/src/core/error.rs new file mode 100644 index 00000000..f6e719a7 --- /dev/null +++ b/packages/engine/src/core/error.rs @@ -0,0 +1,58 @@ +//! Error types for the engine. +//! 引擎的错误类型定义。 + +use thiserror::Error; + +/// Engine error types. +/// 引擎错误类型。 +#[derive(Error, Debug)] +pub enum EngineError { + /// Canvas element not found. + /// 未找到Canvas元素。 + #[error("Canvas element not found: {0} | 未找到Canvas元素: {0}")] + CanvasNotFound(String), + + /// WebGL context creation failed. + /// WebGL上下文创建失败。 + #[error("WebGL2 context creation failed | WebGL2上下文创建失败")] + ContextCreationFailed, + + /// Shader compilation failed. + /// Shader编译失败。 + #[error("Shader compilation failed: {0} | Shader编译失败: {0}")] + ShaderCompileFailed(String), + + /// Shader program linking failed. + /// Shader程序链接失败。 + #[error("Shader program linking failed: {0} | Shader程序链接失败: {0}")] + ProgramLinkFailed(String), + + /// Texture loading failed. + /// 纹理加载失败。 + #[error("Texture loading failed: {0} | 纹理加载失败: {0}")] + TextureLoadFailed(String), + + /// Texture not found. + /// 未找到纹理。 + #[error("Texture not found: {0} | 未找到纹理: {0}")] + TextureNotFound(u32), + + /// Invalid batch data. + /// 无效的批处理数据。 + #[error("Invalid batch data: {0} | 无效的批处理数据: {0}")] + InvalidBatchData(String), + + /// Buffer creation failed. + /// 缓冲区创建失败。 + #[error("Buffer creation failed | 缓冲区创建失败")] + BufferCreationFailed, + + /// WebGL operation failed. + /// WebGL操作失败。 + #[error("WebGL operation failed: {0} | WebGL操作失败: {0}")] + WebGLError(String), +} + +/// Result type alias for engine operations. +/// 引擎操作的Result类型别名。 +pub type Result = std::result::Result; diff --git a/packages/engine/src/core/mod.rs b/packages/engine/src/core/mod.rs new file mode 100644 index 00000000..937e1826 --- /dev/null +++ b/packages/engine/src/core/mod.rs @@ -0,0 +1,10 @@ +//! Core engine module containing lifecycle management and context. +//! 核心引擎模块,包含生命周期管理和上下文。 + +pub mod error; +pub mod context; +mod engine; + +pub use engine::{Engine, EngineConfig}; +pub use context::WebGLContext; +pub use error::{EngineError, Result}; diff --git a/packages/engine/src/input/input_manager.rs b/packages/engine/src/input/input_manager.rs new file mode 100644 index 00000000..9aabe683 --- /dev/null +++ b/packages/engine/src/input/input_manager.rs @@ -0,0 +1,61 @@ +//! Unified input manager. +//! 统一输入管理器。 + +use super::{KeyboardState, MouseState, TouchState}; + +/// Unified input manager handling keyboard, mouse, and touch. +/// 处理键盘、鼠标和触摸的统一输入管理器。 +/// +/// Provides a single interface for all input types. +/// 为所有输入类型提供单一接口。 +#[derive(Debug, Default)] +pub struct InputManager { + /// Keyboard state. + /// 键盘状态。 + pub keyboard: KeyboardState, + + /// Mouse state. + /// 鼠标状态。 + pub mouse: MouseState, + + /// Touch state. + /// 触摸状态。 + pub touch: TouchState, +} + +impl InputManager { + /// Create a new input manager. + /// 创建新的输入管理器。 + pub fn new() -> Self { + Self::default() + } + + /// Update all input states for a new frame. + /// 为新帧更新所有输入状态。 + pub fn update(&mut self) { + self.keyboard.update(); + self.mouse.update(); + self.touch.update(); + } + + /// Check if a key is currently pressed. + /// 检查某个键是否当前被按下。 + #[inline] + pub fn is_key_down(&self, key: &str) -> bool { + self.keyboard.is_key_down(key) + } + + /// Check if a key was just pressed this frame. + /// 检查某个键是否在本帧刚被按下。 + #[inline] + pub fn is_key_just_pressed(&self, key: &str) -> bool { + self.keyboard.is_key_just_pressed(key) + } + + /// Clear all input states. + /// 清除所有输入状态。 + pub fn clear(&mut self) { + self.keyboard.clear(); + self.touch.clear(); + } +} diff --git a/packages/engine/src/input/keyboard.rs b/packages/engine/src/input/keyboard.rs new file mode 100644 index 00000000..c1cbcb96 --- /dev/null +++ b/packages/engine/src/input/keyboard.rs @@ -0,0 +1,82 @@ +//! Keyboard input handling. +//! 键盘输入处理。 + +use std::collections::HashSet; + +/// Keyboard input state. +/// 键盘输入状态。 +#[derive(Debug, Default)] +pub struct KeyboardState { + /// Currently pressed keys. + /// 当前按下的键。 + pressed: HashSet, + + /// Keys pressed this frame. + /// 本帧按下的键。 + just_pressed: HashSet, + + /// Keys released this frame. + /// 本帧释放的键。 + just_released: HashSet, +} + +impl KeyboardState { + /// Create new keyboard state. + /// 创建新的键盘状态。 + pub fn new() -> Self { + Self::default() + } + + /// Handle key down event. + /// 处理按键按下事件。 + pub fn key_down(&mut self, key: String) { + if !self.pressed.contains(&key) { + self.just_pressed.insert(key.clone()); + } + self.pressed.insert(key); + } + + /// Handle key up event. + /// 处理按键释放事件。 + pub fn key_up(&mut self, key: String) { + if self.pressed.remove(&key) { + self.just_released.insert(key); + } + } + + /// Check if a key is currently pressed. + /// 检查某个键是否当前被按下。 + #[inline] + pub fn is_key_down(&self, key: &str) -> bool { + self.pressed.contains(key) + } + + /// Check if a key was just pressed this frame. + /// 检查某个键是否在本帧刚被按下。 + #[inline] + pub fn is_key_just_pressed(&self, key: &str) -> bool { + self.just_pressed.contains(key) + } + + /// Check if a key was just released this frame. + /// 检查某个键是否在本帧刚被释放。 + #[inline] + pub fn is_key_just_released(&self, key: &str) -> bool { + self.just_released.contains(key) + } + + /// Update state for new frame. + /// 为新帧更新状态。 + pub fn update(&mut self) { + self.just_pressed.clear(); + self.just_released.clear(); + } + + /// Clear all input state. + /// 清除所有输入状态。 + pub fn clear(&mut self) { + self.pressed.clear(); + self.just_pressed.clear(); + self.just_released.clear(); + } +} diff --git a/packages/engine/src/input/mod.rs b/packages/engine/src/input/mod.rs new file mode 100644 index 00000000..6aee84db --- /dev/null +++ b/packages/engine/src/input/mod.rs @@ -0,0 +1,12 @@ +//! Input handling system. +//! 输入处理系统。 + +mod keyboard; +mod mouse; +mod touch; +mod input_manager; + +pub use input_manager::InputManager; +pub use keyboard::KeyboardState; +pub use mouse::{MouseState, MouseButton}; +pub use touch::{TouchState, TouchPoint}; diff --git a/packages/engine/src/input/mouse.rs b/packages/engine/src/input/mouse.rs new file mode 100644 index 00000000..2b060a9f --- /dev/null +++ b/packages/engine/src/input/mouse.rs @@ -0,0 +1,136 @@ +//! Mouse input handling. +//! 鼠标输入处理。 + +use crate::math::Vec2; + +/// Mouse button identifiers. +/// 鼠标按钮标识符。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MouseButton { + /// Left mouse button. + /// 鼠标左键。 + Left, + /// Middle mouse button (scroll wheel). + /// 鼠标中键(滚轮)。 + Middle, + /// Right mouse button. + /// 鼠标右键。 + Right, +} + +impl MouseButton { + /// Convert from button index. + /// 从按钮索引转换。 + pub fn from_index(index: i16) -> Option { + match index { + 0 => Some(MouseButton::Left), + 1 => Some(MouseButton::Middle), + 2 => Some(MouseButton::Right), + _ => None, + } + } +} + +/// Mouse input state. +/// 鼠标输入状态。 +#[derive(Debug, Default)] +pub struct MouseState { + /// Current mouse position. + /// 当前鼠标位置。 + pub position: Vec2, + + /// Mouse movement delta since last frame. + /// 自上一帧以来的鼠标移动增量。 + pub delta: Vec2, + + /// Scroll wheel delta. + /// 滚轮增量。 + pub scroll_delta: f32, + + /// Button states (left, middle, right). + /// 按钮状态(左、中、右)。 + buttons: [bool; 3], + + /// Buttons just pressed this frame. + /// 本帧刚按下的按钮。 + just_pressed: [bool; 3], + + /// Buttons just released this frame. + /// 本帧刚释放的按钮。 + just_released: [bool; 3], + + /// Previous position for delta calculation. + /// 用于计算增量的上一位置。 + prev_position: Vec2, +} + +impl MouseState { + /// Create new mouse state. + /// 创建新的鼠标状态。 + pub fn new() -> Self { + Self::default() + } + + /// Handle mouse move event. + /// 处理鼠标移动事件。 + pub fn mouse_move(&mut self, x: f32, y: f32) { + self.position = Vec2::new(x, y); + } + + /// Handle mouse button down event. + /// 处理鼠标按钮按下事件。 + pub fn button_down(&mut self, button: MouseButton) { + let index = button as usize; + if !self.buttons[index] { + self.just_pressed[index] = true; + } + self.buttons[index] = true; + } + + /// Handle mouse button up event. + /// 处理鼠标按钮释放事件。 + pub fn button_up(&mut self, button: MouseButton) { + let index = button as usize; + if self.buttons[index] { + self.just_released[index] = true; + } + self.buttons[index] = false; + } + + /// Handle scroll wheel event. + /// 处理滚轮事件。 + pub fn scroll(&mut self, delta: f32) { + self.scroll_delta = delta; + } + + /// Check if a button is currently pressed. + /// 检查某个按钮是否当前被按下。 + #[inline] + pub fn is_button_down(&self, button: MouseButton) -> bool { + self.buttons[button as usize] + } + + /// Check if a button was just pressed this frame. + /// 检查某个按钮是否在本帧刚被按下。 + #[inline] + pub fn is_button_just_pressed(&self, button: MouseButton) -> bool { + self.just_pressed[button as usize] + } + + /// Check if a button was just released this frame. + /// 检查某个按钮是否在本帧刚被释放。 + #[inline] + pub fn is_button_just_released(&self, button: MouseButton) -> bool { + self.just_released[button as usize] + } + + /// Update state for new frame. + /// 为新帧更新状态。 + pub fn update(&mut self) { + self.delta = self.position - self.prev_position; + self.prev_position = self.position; + self.scroll_delta = 0.0; + self.just_pressed = [false; 3]; + self.just_released = [false; 3]; + } +} diff --git a/packages/engine/src/input/touch.rs b/packages/engine/src/input/touch.rs new file mode 100644 index 00000000..807349ee --- /dev/null +++ b/packages/engine/src/input/touch.rs @@ -0,0 +1,164 @@ +//! Touch input handling. +//! 触摸输入处理。 + +use crate::math::Vec2; +use std::collections::HashMap; + +/// Single touch point. +/// 单个触摸点。 +#[derive(Debug, Clone, Copy)] +pub struct TouchPoint { + /// Touch identifier. + /// 触摸标识符。 + pub id: i32, + + /// Current position. + /// 当前位置。 + pub position: Vec2, + + /// Starting position. + /// 起始位置。 + pub start_position: Vec2, + + /// Movement delta since last frame. + /// 自上一帧以来的移动增量。 + pub delta: Vec2, + + /// Previous position. + /// 上一位置。 + prev_position: Vec2, +} + +impl TouchPoint { + /// Create a new touch point. + /// 创建新的触摸点。 + pub fn new(id: i32, x: f32, y: f32) -> Self { + let pos = Vec2::new(x, y); + Self { + id, + position: pos, + start_position: pos, + delta: Vec2::ZERO, + prev_position: pos, + } + } + + /// Update touch position. + /// 更新触摸位置。 + pub fn update_position(&mut self, x: f32, y: f32) { + self.prev_position = self.position; + self.position = Vec2::new(x, y); + self.delta = self.position - self.prev_position; + } +} + +/// Touch input state. +/// 触摸输入状态。 +#[derive(Debug, Default)] +pub struct TouchState { + /// Active touch points. + /// 活动的触摸点。 + touches: HashMap, + + /// Touch IDs that started this frame. + /// 本帧开始的触摸ID。 + just_started: Vec, + + /// Touch IDs that ended this frame. + /// 本帧结束的触摸ID。 + just_ended: Vec, +} + +impl TouchState { + /// Create new touch state. + /// 创建新的触摸状态。 + pub fn new() -> Self { + Self::default() + } + + /// Handle touch start event. + /// 处理触摸开始事件。 + pub fn touch_start(&mut self, id: i32, x: f32, y: f32) { + let touch = TouchPoint::new(id, x, y); + self.touches.insert(id, touch); + self.just_started.push(id); + } + + /// Handle touch move event. + /// 处理触摸移动事件。 + pub fn touch_move(&mut self, id: i32, x: f32, y: f32) { + if let Some(touch) = self.touches.get_mut(&id) { + touch.update_position(x, y); + } + } + + /// Handle touch end event. + /// 处理触摸结束事件。 + pub fn touch_end(&mut self, id: i32) { + if self.touches.remove(&id).is_some() { + self.just_ended.push(id); + } + } + + /// Get a touch point by ID. + /// 按ID获取触摸点。 + #[inline] + pub fn get_touch(&self, id: i32) -> Option<&TouchPoint> { + self.touches.get(&id) + } + + /// Get all active touch points. + /// 获取所有活动的触摸点。 + #[inline] + pub fn get_touches(&self) -> impl Iterator { + self.touches.values() + } + + /// Get number of active touches. + /// 获取活动触摸数量。 + #[inline] + pub fn touch_count(&self) -> usize { + self.touches.len() + } + + /// Check if any touch is active. + /// 检查是否有任何触摸活动。 + #[inline] + pub fn is_touching(&self) -> bool { + !self.touches.is_empty() + } + + /// Get touches that started this frame. + /// 获取本帧开始的触摸。 + #[inline] + pub fn just_started(&self) -> &[i32] { + &self.just_started + } + + /// Get touches that ended this frame. + /// 获取本帧结束的触摸。 + #[inline] + pub fn just_ended(&self) -> &[i32] { + &self.just_ended + } + + /// Update state for new frame. + /// 为新帧更新状态。 + pub fn update(&mut self) { + self.just_started.clear(); + self.just_ended.clear(); + + // Reset deltas | 重置增量 + for touch in self.touches.values_mut() { + touch.delta = Vec2::ZERO; + } + } + + /// Clear all touch state. + /// 清除所有触摸状态。 + pub fn clear(&mut self) { + self.touches.clear(); + self.just_started.clear(); + self.just_ended.clear(); + } +} diff --git a/packages/engine/src/lib.rs b/packages/engine/src/lib.rs new file mode 100644 index 00000000..6a2a67a8 --- /dev/null +++ b/packages/engine/src/lib.rs @@ -0,0 +1,195 @@ +//! ES Engine - High-performance 2D game engine for web and mobile platforms. +//! ES引擎 - 高性能2D游戏引擎,支持Web和移动平台。 +//! +//! # Architecture | 架构 +//! +//! The engine is designed with a modular architecture: +//! 引擎采用模块化架构设计: +//! +//! - `core` - Engine lifecycle and context management | 引擎生命周期和上下文管理 +//! - `renderer` - 2D rendering with batch optimization | 2D渲染与批处理优化 +//! - `math` - Mathematical primitives (vectors, matrices) | 数学基元(向量、矩阵) +//! - `resource` - Asset loading and management | 资源加载和管理 +//! - `input` - Keyboard, mouse, and touch input | 键盘、鼠标和触摸输入 +//! - `platform` - Platform abstraction layer | 平台抽象层 +//! +//! # Example | 示例 +//! +//! ```typescript +//! import { GameEngine } from 'es-engine'; +//! +//! const engine = new GameEngine('canvas'); +//! engine.loadTexture('player', 'assets/player.png'); +//! +//! function gameLoop() { +//! engine.clear(0.0, 0.0, 0.0, 1.0); +//! engine.submitSpriteBatch(transforms, textureIds, uvs, colors); +//! engine.render(); +//! requestAnimationFrame(gameLoop); +//! } +//! ``` + +#![warn(missing_docs)] +#![warn(rustdoc::missing_crate_level_docs)] + +use wasm_bindgen::prelude::*; + +// Module declarations | 模块声明 +pub mod core; +pub mod math; +pub mod platform; +pub mod renderer; +pub mod resource; +pub mod input; + +// Re-exports | 重新导出 +pub use crate::core::{Engine, EngineConfig}; +pub use crate::core::error::{EngineError, Result}; + +/// Initialize panic hook for better error messages in console. +/// 初始化panic hook以在控制台显示更好的错误信息。 +#[wasm_bindgen(start)] +pub fn init() { + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); + + // Initialize logger | 初始化日志 + console_log::init_with_level(log::Level::Debug) + .expect("Failed to initialize logger | 日志初始化失败"); + + log::info!("ES Engine initialized | ES引擎初始化完成"); +} + +/// Game engine main interface exposed to JavaScript. +/// 暴露给JavaScript的游戏引擎主接口。 +/// +/// This is the primary entry point for the engine from TypeScript/JavaScript. +/// 这是从TypeScript/JavaScript访问引擎的主要入口点。 +#[wasm_bindgen] +pub struct GameEngine { + engine: Engine, +} + +#[wasm_bindgen] +impl GameEngine { + /// Create a new game engine instance. + /// 创建新的游戏引擎实例。 + /// + /// # Arguments | 参数 + /// * `canvas_id` - The HTML canvas element ID | HTML canvas元素ID + /// + /// # Returns | 返回 + /// A new GameEngine instance or an error | 新的GameEngine实例或错误 + #[wasm_bindgen(constructor)] + pub fn new(canvas_id: &str) -> std::result::Result { + let config = EngineConfig::default(); + let engine = Engine::new(canvas_id, config) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + Ok(GameEngine { engine }) + } + + /// Create a new game engine from external WebGL context. + /// 从外部 WebGL 上下文创建引擎。 + /// + /// This is designed for WeChat MiniGame and similar environments. + /// 适用于微信小游戏等环境。 + #[wasm_bindgen(js_name = fromExternal)] + pub fn from_external( + gl_context: JsValue, + width: u32, + height: u32, + ) -> std::result::Result { + let config = EngineConfig::default(); + let engine = Engine::from_external(gl_context, width, height, config) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + Ok(GameEngine { engine }) + } + + /// Clear the screen with specified color. + /// 使用指定颜色清除屏幕。 + /// + /// # Arguments | 参数 + /// * `r` - Red component (0.0-1.0) | 红色分量 + /// * `g` - Green component (0.0-1.0) | 绿色分量 + /// * `b` - Blue component (0.0-1.0) | 蓝色分量 + /// * `a` - Alpha component (0.0-1.0) | 透明度分量 + pub fn clear(&self, r: f32, g: f32, b: f32, a: f32) { + self.engine.clear(r, g, b, a); + } + + /// Get canvas width. + /// 获取画布宽度。 + #[wasm_bindgen(getter)] + pub fn width(&self) -> u32 { + self.engine.width() + } + + /// Get canvas height. + /// 获取画布高度。 + #[wasm_bindgen(getter)] + pub fn height(&self) -> u32 { + self.engine.height() + } + + /// Submit sprite batch data for rendering. + /// 提交精灵批次数据进行渲染。 + /// + /// # Arguments | 参数 + /// * `transforms` - Float32Array [x, y, rotation, scaleX, scaleY, originX, originY] per sprite + /// 每个精灵的变换数据 + /// * `texture_ids` - Uint32Array of texture IDs | 纹理ID数组 + /// * `uvs` - Float32Array [u0, v0, u1, v1] per sprite | 每个精灵的UV坐标 + /// * `colors` - Uint32Array of packed RGBA colors | 打包的RGBA颜色数组 + #[wasm_bindgen(js_name = submitSpriteBatch)] + pub fn submit_sprite_batch( + &mut self, + transforms: &[f32], + texture_ids: &[u32], + uvs: &[f32], + colors: &[u32], + ) -> std::result::Result<(), JsValue> { + self.engine + .submit_sprite_batch(transforms, texture_ids, uvs, colors) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Render the current frame. + /// 渲染当前帧。 + pub fn render(&mut self) -> std::result::Result<(), JsValue> { + self.engine + .render() + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Load a texture from URL. + /// 从URL加载纹理。 + /// + /// # Arguments | 参数 + /// * `id` - Unique texture identifier | 唯一纹理标识符 + /// * `url` - Image URL to load | 要加载的图片URL + #[wasm_bindgen(js_name = loadTexture)] + pub fn load_texture(&mut self, id: u32, url: &str) -> std::result::Result<(), JsValue> { + self.engine + .load_texture(id, url) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Check if a key is currently pressed. + /// 检查某个键是否当前被按下。 + /// + /// # Arguments | 参数 + /// * `key_code` - The key code to check | 要检查的键码 + #[wasm_bindgen(js_name = isKeyDown)] + pub fn is_key_down(&self, key_code: &str) -> bool { + self.engine.is_key_down(key_code) + } + + /// Update input state. Should be called once per frame. + /// 更新输入状态。应该每帧调用一次。 + #[wasm_bindgen(js_name = updateInput)] + pub fn update_input(&mut self) { + self.engine.update_input(); + } +} diff --git a/packages/engine/src/math/color.rs b/packages/engine/src/math/color.rs new file mode 100644 index 00000000..ed181b9a --- /dev/null +++ b/packages/engine/src/math/color.rs @@ -0,0 +1,184 @@ +//! Color utilities. +//! 颜色工具。 + +use bytemuck::{Pod, Zeroable}; + +/// RGBA color representation. +/// RGBA颜色表示。 +/// +/// Colors are stored as normalized floats (0.0-1.0) and can be converted +/// to packed u32 format for efficient GPU transfer. +/// 颜色以归一化浮点数(0.0-1.0)存储,可转换为打包的u32格式以高效传输到GPU。 +/// +/// # Examples | 示例 +/// ```rust +/// let red = Color::RED; +/// let custom = Color::new(0.5, 0.7, 0.3, 1.0); +/// let packed = custom.to_packed(); // For GPU +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)] +#[repr(C)] +pub struct Color { + /// Red component (0.0-1.0). + /// 红色分量。 + pub r: f32, + /// Green component (0.0-1.0). + /// 绿色分量。 + pub g: f32, + /// Blue component (0.0-1.0). + /// 蓝色分量。 + pub b: f32, + /// Alpha component (0.0-1.0). + /// 透明度分量。 + pub a: f32, +} + +impl Color { + /// White (1, 1, 1, 1). + /// 白色。 + pub const WHITE: Self = Self { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; + + /// Black (0, 0, 0, 1). + /// 黑色。 + pub const BLACK: Self = Self { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }; + + /// Red (1, 0, 0, 1). + /// 红色。 + pub const RED: Self = Self { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }; + + /// Green (0, 1, 0, 1). + /// 绿色。 + pub const GREEN: Self = Self { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }; + + /// Blue (0, 0, 1, 1). + /// 蓝色。 + pub const BLUE: Self = Self { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }; + + /// Transparent (0, 0, 0, 0). + /// 透明。 + pub const TRANSPARENT: Self = Self { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }; + + /// Create a new color. + /// 创建新颜色。 + #[inline] + pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self { + Self { r, g, b, a } + } + + /// Create a color from RGB values (alpha = 1.0). + /// 从RGB值创建颜色(alpha = 1.0)。 + #[inline] + pub const fn rgb(r: f32, g: f32, b: f32) -> Self { + Self { r, g, b, a: 1.0 } + } + + /// Create from u8 values (0-255). + /// 从u8值创建(0-255)。 + #[inline] + pub fn from_rgba8(r: u8, g: u8, b: u8, a: u8) -> Self { + Self { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: a as f32 / 255.0, + } + } + + /// Create from hex value (0xRRGGBB or 0xRRGGBBAA). + /// 从十六进制值创建。 + #[inline] + pub fn from_hex(hex: u32) -> Self { + if hex > 0xFFFFFF { + // 0xRRGGBBAA format + Self::from_rgba8( + ((hex >> 24) & 0xFF) as u8, + ((hex >> 16) & 0xFF) as u8, + ((hex >> 8) & 0xFF) as u8, + (hex & 0xFF) as u8, + ) + } else { + // 0xRRGGBB format + Self::from_rgba8( + ((hex >> 16) & 0xFF) as u8, + ((hex >> 8) & 0xFF) as u8, + (hex & 0xFF) as u8, + 255, + ) + } + } + + /// Convert to packed u32 (ABGR format for WebGL). + /// 转换为打包的u32(WebGL的ABGR格式)。 + #[inline] + pub fn to_packed(&self) -> u32 { + let r = (self.r.clamp(0.0, 1.0) * 255.0) as u32; + let g = (self.g.clamp(0.0, 1.0) * 255.0) as u32; + let b = (self.b.clamp(0.0, 1.0) * 255.0) as u32; + let a = (self.a.clamp(0.0, 1.0) * 255.0) as u32; + + (a << 24) | (b << 16) | (g << 8) | r + } + + /// Create from packed u32 (ABGR format). + /// 从打包的u32创建(ABGR格式)。 + #[inline] + pub fn from_packed(packed: u32) -> Self { + Self::from_rgba8( + (packed & 0xFF) as u8, + ((packed >> 8) & 0xFF) as u8, + ((packed >> 16) & 0xFF) as u8, + ((packed >> 24) & 0xFF) as u8, + ) + } + + /// Linear interpolation between two colors. + /// 两个颜色之间的线性插值。 + #[inline] + pub fn lerp(&self, other: &Self, t: f32) -> Self { + Self { + r: self.r + (other.r - self.r) * t, + g: self.g + (other.g - self.g) * t, + b: self.b + (other.b - self.b) * t, + a: self.a + (other.a - self.a) * t, + } + } + + /// Multiply color by alpha (premultiplied alpha). + /// 颜色乘以alpha(预乘alpha)。 + #[inline] + pub fn premultiply(&self) -> Self { + Self { + r: self.r * self.a, + g: self.g * self.a, + b: self.b * self.a, + a: self.a, + } + } + + /// Set the alpha value. + /// 设置alpha值。 + #[inline] + pub fn with_alpha(self, a: f32) -> Self { + Self { a, ..self } + } +} + +impl Default for Color { + fn default() -> Self { + Self::WHITE + } +} + +impl From<[f32; 4]> for Color { + #[inline] + fn from([r, g, b, a]: [f32; 4]) -> Self { + Self { r, g, b, a } + } +} + +impl From for [f32; 4] { + #[inline] + fn from(c: Color) -> Self { + [c.r, c.g, c.b, c.a] + } +} diff --git a/packages/engine/src/math/mod.rs b/packages/engine/src/math/mod.rs new file mode 100644 index 00000000..ba221504 --- /dev/null +++ b/packages/engine/src/math/mod.rs @@ -0,0 +1,19 @@ +//! Mathematical primitives for 2D game development. +//! 用于2D游戏开发的数学基元。 +//! +//! This module provides wrappers around `glam` types with additional +//! game-specific functionality. +//! 此模块提供对`glam`类型的封装,并添加游戏特定的功能。 + +mod vec2; +mod transform; +mod rect; +mod color; + +pub use vec2::Vec2; +pub use transform::Transform2D; +pub use rect::Rect; +pub use color::Color; + +// Re-export glam types for internal use | 重新导出glam类型供内部使用 +pub use glam::{Mat3, Mat4, Vec3, Vec4}; diff --git a/packages/engine/src/math/rect.rs b/packages/engine/src/math/rect.rs new file mode 100644 index 00000000..d1382019 --- /dev/null +++ b/packages/engine/src/math/rect.rs @@ -0,0 +1,148 @@ +//! Rectangle implementation. +//! 矩形实现。 + +use super::Vec2; + +/// Axis-aligned rectangle. +/// 轴对齐矩形。 +/// +/// # Examples | 示例 +/// ```rust +/// let rect = Rect::new(10.0, 20.0, 100.0, 50.0); +/// let point = Vec2::new(50.0, 40.0); +/// assert!(rect.contains_point(point)); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Rect { + /// X position (left edge). + /// X位置(左边缘)。 + pub x: f32, + /// Y position (top edge). + /// Y位置(上边缘)。 + pub y: f32, + /// Width. + /// 宽度。 + pub width: f32, + /// Height. + /// 高度。 + pub height: f32, +} + +impl Rect { + /// Create a new rectangle. + /// 创建新矩形。 + #[inline] + pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self { + Self { x, y, width, height } + } + + /// Create a rectangle from two corner points. + /// 从两个角点创建矩形。 + #[inline] + pub fn from_corners(min: Vec2, max: Vec2) -> Self { + Self { + x: min.x, + y: min.y, + width: max.x - min.x, + height: max.y - min.y, + } + } + + /// Create a rectangle centered at a point. + /// 创建以某点为中心的矩形。 + #[inline] + pub fn from_center(center: Vec2, width: f32, height: f32) -> Self { + Self { + x: center.x - width * 0.5, + y: center.y - height * 0.5, + width, + height, + } + } + + /// Get the minimum (top-left) corner. + /// 获取最小(左上)角点。 + #[inline] + pub fn min(&self) -> Vec2 { + Vec2::new(self.x, self.y) + } + + /// Get the maximum (bottom-right) corner. + /// 获取最大(右下)角点。 + #[inline] + pub fn max(&self) -> Vec2 { + Vec2::new(self.x + self.width, self.y + self.height) + } + + /// Get the center point. + /// 获取中心点。 + #[inline] + pub fn center(&self) -> Vec2 { + Vec2::new(self.x + self.width * 0.5, self.y + self.height * 0.5) + } + + /// Get the size as a vector. + /// 获取尺寸向量。 + #[inline] + pub fn size(&self) -> Vec2 { + Vec2::new(self.width, self.height) + } + + /// Check if the rectangle contains a point. + /// 检查矩形是否包含某点。 + #[inline] + pub fn contains_point(&self, point: Vec2) -> bool { + point.x >= self.x + && point.x <= self.x + self.width + && point.y >= self.y + && point.y <= self.y + self.height + } + + /// Check if this rectangle intersects with another. + /// 检查此矩形是否与另一个相交。 + #[inline] + pub fn intersects(&self, other: &Rect) -> bool { + self.x < other.x + other.width + && self.x + self.width > other.x + && self.y < other.y + other.height + && self.y + self.height > other.y + } + + /// Get the intersection of two rectangles. + /// 获取两个矩形的交集。 + pub fn intersection(&self, other: &Rect) -> Option { + let x = self.x.max(other.x); + let y = self.y.max(other.y); + let right = (self.x + self.width).min(other.x + other.width); + let bottom = (self.y + self.height).min(other.y + other.height); + + if right > x && bottom > y { + Some(Rect::new(x, y, right - x, bottom - y)) + } else { + None + } + } + + /// Get the union of two rectangles (bounding box). + /// 获取两个矩形的并集(包围盒)。 + pub fn union(&self, other: &Rect) -> Rect { + let x = self.x.min(other.x); + let y = self.y.min(other.y); + let right = (self.x + self.width).max(other.x + other.width); + let bottom = (self.y + self.height).max(other.y + other.height); + + Rect::new(x, y, right - x, bottom - y) + } + + /// Expand the rectangle by a margin. + /// 按边距扩展矩形。 + #[inline] + pub fn expand(&self, margin: f32) -> Rect { + Rect::new( + self.x - margin, + self.y - margin, + self.width + margin * 2.0, + self.height + margin * 2.0, + ) + } +} diff --git a/packages/engine/src/math/transform.rs b/packages/engine/src/math/transform.rs new file mode 100644 index 00000000..ea6d3969 --- /dev/null +++ b/packages/engine/src/math/transform.rs @@ -0,0 +1,164 @@ +//! 2D transform implementation. +//! 2D变换实现。 + +use super::Vec2; +use glam::Mat3; + +/// 2D transformation combining position, rotation, and scale. +/// 组合位置、旋转和缩放的2D变换。 +/// +/// # Examples | 示例 +/// ```rust +/// let mut transform = Transform2D::new(); +/// transform.position = Vec2::new(100.0, 200.0); +/// transform.rotation = std::f32::consts::PI / 4.0; // 45 degrees +/// transform.scale = Vec2::new(2.0, 2.0); +/// +/// let matrix = transform.to_matrix(); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Transform2D { + /// Position in world space. + /// 世界空间中的位置。 + pub position: Vec2, + + /// Rotation in radians. + /// 旋转角度(弧度)。 + pub rotation: f32, + + /// Scale factor. + /// 缩放因子。 + pub scale: Vec2, + + /// Origin point for rotation and scaling (0-1 range, relative to size). + /// 旋转和缩放的原点(0-1范围,相对于尺寸)。 + pub origin: Vec2, +} + +impl Default for Transform2D { + fn default() -> Self { + Self { + position: Vec2::ZERO, + rotation: 0.0, + scale: Vec2::new(1.0, 1.0), + origin: Vec2::new(0.5, 0.5), // Center by default | 默认居中 + } + } +} + +impl Transform2D { + /// Create a new transform with default values. + /// 使用默认值创建新变换。 + #[inline] + pub fn new() -> Self { + Self::default() + } + + /// Create a transform with specified position. + /// 使用指定位置创建变换。 + #[inline] + pub fn from_position(x: f32, y: f32) -> Self { + Self { + position: Vec2::new(x, y), + ..Default::default() + } + } + + /// Create a transform with position, rotation, and scale. + /// 使用位置、旋转和缩放创建变换。 + #[inline] + pub fn from_pos_rot_scale(position: Vec2, rotation: f32, scale: Vec2) -> Self { + Self { + position, + rotation, + scale, + ..Default::default() + } + } + + /// Convert to a 3x3 transformation matrix. + /// 转换为3x3变换矩阵。 + /// + /// The matrix is constructed as: T * R * S (translate, rotate, scale). + /// 矩阵构造顺序为:T * R * S(平移、旋转、缩放)。 + pub fn to_matrix(&self) -> Mat3 { + let cos = self.rotation.cos(); + let sin = self.rotation.sin(); + + // Construct TRS matrix directly for performance + // 直接构造TRS矩阵以提高性能 + Mat3::from_cols( + glam::Vec3::new(cos * self.scale.x, sin * self.scale.x, 0.0), + glam::Vec3::new(-sin * self.scale.y, cos * self.scale.y, 0.0), + glam::Vec3::new(self.position.x, self.position.y, 1.0), + ) + } + + /// Convert to a 3x3 matrix with origin offset applied. + /// 转换为应用原点偏移的3x3矩阵。 + /// + /// # Arguments | 参数 + /// * `width` - Sprite width | 精灵宽度 + /// * `height` - Sprite height | 精灵高度 + pub fn to_matrix_with_origin(&self, width: f32, height: f32) -> Mat3 { + let ox = -self.origin.x * width * self.scale.x; + let oy = -self.origin.y * height * self.scale.y; + + let cos = self.rotation.cos(); + let sin = self.rotation.sin(); + + // Apply origin offset after rotation + // 在旋转后应用原点偏移 + let tx = self.position.x + ox * cos - oy * sin; + let ty = self.position.y + ox * sin + oy * cos; + + Mat3::from_cols( + glam::Vec3::new(cos * self.scale.x, sin * self.scale.x, 0.0), + glam::Vec3::new(-sin * self.scale.y, cos * self.scale.y, 0.0), + glam::Vec3::new(tx, ty, 1.0), + ) + } + + /// Transform a local point to world space. + /// 将局部点变换到世界空间。 + #[inline] + pub fn transform_point(&self, point: Vec2) -> Vec2 { + let rotated = point.rotate(self.rotation); + Vec2::new( + rotated.x * self.scale.x + self.position.x, + rotated.y * self.scale.y + self.position.y, + ) + } + + /// Inverse transform a world point to local space. + /// 将世界点反变换到局部空间。 + #[inline] + pub fn inverse_transform_point(&self, point: Vec2) -> Vec2 { + let local = Vec2::new( + (point.x - self.position.x) / self.scale.x, + (point.y - self.position.y) / self.scale.y, + ); + local.rotate(-self.rotation) + } + + /// Translate the transform by a delta. + /// 按增量平移变换。 + #[inline] + pub fn translate(&mut self, delta: Vec2) { + self.position = self.position + delta; + } + + /// Rotate the transform by an angle (in radians). + /// 按角度旋转变换(弧度)。 + #[inline] + pub fn rotate(&mut self, angle: f32) { + self.rotation += angle; + } + + /// Scale the transform by a factor. + /// 按因子缩放变换。 + #[inline] + pub fn scale_by(&mut self, factor: Vec2) { + self.scale = Vec2::new(self.scale.x * factor.x, self.scale.y * factor.y); + } +} diff --git a/packages/engine/src/math/vec2.rs b/packages/engine/src/math/vec2.rs new file mode 100644 index 00000000..254a3e9f --- /dev/null +++ b/packages/engine/src/math/vec2.rs @@ -0,0 +1,214 @@ +//! 2D vector implementation. +//! 2D向量实现。 + +use bytemuck::{Pod, Zeroable}; + +/// 2D vector for positions, velocities, and directions. +/// 用于位置、速度和方向的2D向量。 +/// +/// # Examples | 示例 +/// ```rust +/// let pos = Vec2::new(100.0, 200.0); +/// let velocity = Vec2::new(1.0, 0.0); +/// let new_pos = pos + velocity * 16.0; +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Default, Pod, Zeroable)] +#[repr(C)] +pub struct Vec2 { + /// X component. + /// X分量。 + pub x: f32, + /// Y component. + /// Y分量。 + pub y: f32, +} + +impl Vec2 { + /// Zero vector (0, 0). + /// 零向量。 + pub const ZERO: Self = Self { x: 0.0, y: 0.0 }; + + /// Unit vector pointing right (1, 0). + /// 指向右的单位向量。 + pub const RIGHT: Self = Self { x: 1.0, y: 0.0 }; + + /// Unit vector pointing up (0, 1). + /// 指向上的单位向量。 + pub const UP: Self = Self { x: 0.0, y: 1.0 }; + + /// Create a new 2D vector. + /// 创建新的2D向量。 + #[inline] + pub const fn new(x: f32, y: f32) -> Self { + Self { x, y } + } + + /// Create a vector with both components set to the same value. + /// 创建两个分量相同的向量。 + #[inline] + pub const fn splat(v: f32) -> Self { + Self { x: v, y: v } + } + + /// Calculate the length (magnitude) of the vector. + /// 计算向量的长度(模)。 + #[inline] + pub fn length(&self) -> f32 { + (self.x * self.x + self.y * self.y).sqrt() + } + + /// Calculate the squared length (avoids sqrt). + /// 计算长度的平方(避免开方运算)。 + #[inline] + pub fn length_squared(&self) -> f32 { + self.x * self.x + self.y * self.y + } + + /// Normalize the vector (make it unit length). + /// 归一化向量(使其成为单位长度)。 + #[inline] + pub fn normalize(&self) -> Self { + let len = self.length(); + if len > 0.0 { + Self { + x: self.x / len, + y: self.y / len, + } + } else { + Self::ZERO + } + } + + /// Calculate dot product with another vector. + /// 计算与另一个向量的点积。 + #[inline] + pub fn dot(&self, other: &Self) -> f32 { + self.x * other.x + self.y * other.y + } + + /// Calculate cross product (returns scalar for 2D). + /// 计算叉积(2D返回标量)。 + #[inline] + pub fn cross(&self, other: &Self) -> f32 { + self.x * other.y - self.y * other.x + } + + /// Calculate distance to another point. + /// 计算到另一点的距离。 + #[inline] + pub fn distance(&self, other: &Self) -> f32 { + (*self - *other).length() + } + + /// Linear interpolation between two vectors. + /// 两个向量之间的线性插值。 + #[inline] + pub fn lerp(&self, other: &Self, t: f32) -> Self { + Self { + x: self.x + (other.x - self.x) * t, + y: self.y + (other.y - self.y) * t, + } + } + + /// Rotate the vector by an angle (in radians). + /// 按角度旋转向量(弧度)。 + #[inline] + pub fn rotate(&self, angle: f32) -> Self { + let cos = angle.cos(); + let sin = angle.sin(); + Self { + x: self.x * cos - self.y * sin, + y: self.x * sin + self.y * cos, + } + } + + /// Convert to glam Vec2. + /// 转换为glam Vec2。 + #[inline] + pub fn to_glam(&self) -> glam::Vec2 { + glam::Vec2::new(self.x, self.y) + } + + /// Create from glam Vec2. + /// 从glam Vec2创建。 + #[inline] + pub fn from_glam(v: glam::Vec2) -> Self { + Self { x: v.x, y: v.y } + } +} + +// Operator implementations | 运算符实现 + +impl std::ops::Add for Vec2 { + type Output = Self; + + #[inline] + fn add(self, rhs: Self) -> Self::Output { + Self { + x: self.x + rhs.x, + y: self.y + rhs.y, + } + } +} + +impl std::ops::Sub for Vec2 { + type Output = Self; + + #[inline] + fn sub(self, rhs: Self) -> Self::Output { + Self { + x: self.x - rhs.x, + y: self.y - rhs.y, + } + } +} + +impl std::ops::Mul for Vec2 { + type Output = Self; + + #[inline] + fn mul(self, rhs: f32) -> Self::Output { + Self { + x: self.x * rhs, + y: self.y * rhs, + } + } +} + +impl std::ops::Div for Vec2 { + type Output = Self; + + #[inline] + fn div(self, rhs: f32) -> Self::Output { + Self { + x: self.x / rhs, + y: self.y / rhs, + } + } +} + +impl std::ops::Neg for Vec2 { + type Output = Self; + + #[inline] + fn neg(self) -> Self::Output { + Self { + x: -self.x, + y: -self.y, + } + } +} + +impl From<(f32, f32)> for Vec2 { + #[inline] + fn from((x, y): (f32, f32)) -> Self { + Self { x, y } + } +} + +impl From<[f32; 2]> for Vec2 { + #[inline] + fn from([x, y]: [f32; 2]) -> Self { + Self { x, y } + } +} diff --git a/packages/engine/src/platform/mod.rs b/packages/engine/src/platform/mod.rs new file mode 100644 index 00000000..2832df32 --- /dev/null +++ b/packages/engine/src/platform/mod.rs @@ -0,0 +1,51 @@ +//! Platform abstraction layer. +//! 平台抽象层。 +//! +//! Provides abstractions for platform-specific functionality. +//! 提供平台特定功能的抽象。 + +mod web; + +pub use web::WebPlatform; + +/// Platform capabilities and information. +/// 平台能力和信息。 +#[derive(Debug, Clone)] +pub struct PlatformInfo { + /// Platform name. + /// 平台名称。 + pub name: String, + + /// Whether WebGL2 is supported. + /// 是否支持WebGL2。 + pub webgl2_supported: bool, + + /// Whether touch input is supported. + /// 是否支持触摸输入。 + pub touch_supported: bool, + + /// Device pixel ratio. + /// 设备像素比。 + pub pixel_ratio: f32, + + /// Screen width. + /// 屏幕宽度。 + pub screen_width: u32, + + /// Screen height. + /// 屏幕高度。 + pub screen_height: u32, +} + +impl Default for PlatformInfo { + fn default() -> Self { + Self { + name: "Unknown".to_string(), + webgl2_supported: false, + touch_supported: false, + pixel_ratio: 1.0, + screen_width: 0, + screen_height: 0, + } + } +} diff --git a/packages/engine/src/platform/web.rs b/packages/engine/src/platform/web.rs new file mode 100644 index 00000000..b615257f --- /dev/null +++ b/packages/engine/src/platform/web.rs @@ -0,0 +1,146 @@ +//! Web platform implementation. +//! Web平台实现。 + +use wasm_bindgen::JsCast; +use web_sys::Window; + +use super::PlatformInfo; + +/// Web platform utilities. +/// Web平台工具。 +pub struct WebPlatform; + +impl WebPlatform { + /// Get platform information. + /// 获取平台信息。 + pub fn get_info() -> PlatformInfo { + let window = match web_sys::window() { + Some(w) => w, + None => return PlatformInfo::default(), + }; + + let navigator = window.navigator(); + let user_agent = navigator.user_agent().unwrap_or_default(); + + // Detect platform name | 检测平台名称 + let name = Self::detect_platform_name(&user_agent); + + // Check WebGL2 support | 检查WebGL2支持 + let webgl2_supported = Self::check_webgl2_support(&window); + + // Check touch support | 检查触摸支持 + let touch_supported = Self::check_touch_support(&window); + + // Get device pixel ratio | 获取设备像素比 + let pixel_ratio = window.device_pixel_ratio() as f32; + + // Get screen size | 获取屏幕尺寸 + let screen = window.screen().ok(); + let (screen_width, screen_height) = screen + .map(|s| { + ( + s.width().unwrap_or(0) as u32, + s.height().unwrap_or(0) as u32, + ) + }) + .unwrap_or((0, 0)); + + PlatformInfo { + name, + webgl2_supported, + touch_supported, + pixel_ratio, + screen_width, + screen_height, + } + } + + /// Detect platform name from user agent. + /// 从用户代理检测平台名称。 + fn detect_platform_name(user_agent: &str) -> String { + let ua = user_agent.to_lowercase(); + + if ua.contains("micromessenger") { + "WeChat MiniGame".to_string() + } else if ua.contains("bytedance") || ua.contains("toutiao") { + "ByteDance MiniGame".to_string() + } else if ua.contains("alipay") { + "Alipay MiniGame".to_string() + } else if ua.contains("iphone") || ua.contains("ipad") { + "iOS Web".to_string() + } else if ua.contains("android") { + "Android Web".to_string() + } else if ua.contains("windows") { + "Windows Web".to_string() + } else if ua.contains("macintosh") { + "macOS Web".to_string() + } else { + "Web".to_string() + } + } + + /// Check if WebGL2 is supported. + /// 检查是否支持WebGL2。 + fn check_webgl2_support(window: &Window) -> bool { + let document = match window.document() { + Some(d) => d, + None => return false, + }; + + let canvas = match document.create_element("canvas") { + Ok(c) => c, + Err(_) => return false, + }; + + let canvas = match canvas.dyn_into::() { + Ok(c) => c, + Err(_) => return false, + }; + + canvas.get_context("webgl2").ok().flatten().is_some() + } + + /// Check if touch input is supported. + /// 检查是否支持触摸输入。 + fn check_touch_support(window: &Window) -> bool { + // Check for touch events | 检查触摸事件 + let has_touch_event = js_sys::Reflect::has( + window, + &wasm_bindgen::JsValue::from_str("ontouchstart"), + ) + .unwrap_or(false); + + if has_touch_event { + return true; + } + + // Check navigator.maxTouchPoints | 检查navigator.maxTouchPoints + let navigator = window.navigator(); + navigator.max_touch_points() > 0 + } + + /// Request animation frame. + /// 请求动画帧。 + pub fn request_animation_frame(callback: &wasm_bindgen::closure::Closure) -> i32 { + let window = web_sys::window().expect("No window found"); + window + .request_animation_frame(callback.as_ref().unchecked_ref()) + .expect("Failed to request animation frame") + } + + /// Get current timestamp in milliseconds. + /// 获取当前时间戳(毫秒)。 + pub fn now() -> f64 { + let window = web_sys::window().expect("No window found"); + window + .performance() + .expect("No performance object") + .now() + } + + /// Log a message to the console. + /// 向控制台输出消息。 + pub fn console_log(message: &str) { + web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(message)); + } +} diff --git a/packages/engine/src/renderer/batch/mod.rs b/packages/engine/src/renderer/batch/mod.rs new file mode 100644 index 00000000..752b34a0 --- /dev/null +++ b/packages/engine/src/renderer/batch/mod.rs @@ -0,0 +1,8 @@ +//! Sprite batch rendering system. +//! 精灵批处理渲染系统。 + +mod sprite_batch; +mod vertex; + +pub use sprite_batch::SpriteBatch; +pub use vertex::{SpriteVertex, VERTEX_SIZE}; diff --git a/packages/engine/src/renderer/batch/sprite_batch.rs b/packages/engine/src/renderer/batch/sprite_batch.rs new file mode 100644 index 00000000..3c07bf01 --- /dev/null +++ b/packages/engine/src/renderer/batch/sprite_batch.rs @@ -0,0 +1,401 @@ +//! Sprite batch renderer for efficient 2D rendering. +//! 用于高效2D渲染的精灵批处理渲染器。 + +use web_sys::{ + WebGl2RenderingContext, WebGlBuffer, WebGlVertexArrayObject, +}; + +use crate::core::error::{EngineError, Result}; +use crate::math::Color; +use crate::resource::TextureManager; +use super::vertex::FLOATS_PER_VERTEX; + +/// Number of vertices per sprite (quad). +/// 每个精灵的顶点数(四边形)。 +const VERTICES_PER_SPRITE: usize = 4; + +/// Number of indices per sprite (2 triangles). +/// 每个精灵的索引数(2个三角形)。 +const INDICES_PER_SPRITE: usize = 6; + +/// Transform data stride (x, y, rotation, scaleX, scaleY, originX, originY). +/// 变换数据步长。 +const TRANSFORM_STRIDE: usize = 7; + +/// UV data stride (u0, v0, u1, v1). +/// UV数据步长。 +const UV_STRIDE: usize = 4; + +/// Sprite batch renderer. +/// 精灵批处理渲染器。 +/// +/// Batches multiple sprites into a single draw call for optimal performance. +/// 将多个精灵合并为单次绘制调用以获得最佳性能。 +/// +/// # Performance | 性能 +/// - Uses dynamic vertex buffer for efficient updates | 使用动态顶点缓冲区以高效更新 +/// - Minimizes state changes and draw calls | 最小化状态更改和绘制调用 +/// - Supports up to 10000+ sprites per batch | 每批次支持10000+精灵 +pub struct SpriteBatch { + /// Vertex array object. + /// 顶点数组对象。 + vao: WebGlVertexArrayObject, + + /// Vertex buffer object. + /// 顶点缓冲区对象。 + vbo: WebGlBuffer, + + /// Index buffer object. + /// 索引缓冲区对象。 + ibo: WebGlBuffer, + + /// Maximum number of sprites. + /// 最大精灵数。 + max_sprites: usize, + + /// Vertex data buffer. + /// 顶点数据缓冲区。 + vertices: Vec, + + /// Current number of sprites in batch. + /// 当前批次中的精灵数。 + sprite_count: usize, + + /// Current texture ID being batched. + /// 当前正在批处理的纹理ID。 + current_texture: Option, +} + +impl SpriteBatch { + /// Create a new sprite batch. + /// 创建新的精灵批处理器。 + /// + /// # Arguments | 参数 + /// * `gl` - WebGL2 context | WebGL2上下文 + /// * `max_sprites` - Maximum sprites per batch | 每批次最大精灵数 + pub fn new(gl: &WebGl2RenderingContext, max_sprites: usize) -> Result { + // Create VAO | 创建VAO + let vao = gl + .create_vertex_array() + .ok_or(EngineError::BufferCreationFailed)?; + gl.bind_vertex_array(Some(&vao)); + + // Create vertex buffer | 创建顶点缓冲区 + let vbo = gl + .create_buffer() + .ok_or(EngineError::BufferCreationFailed)?; + gl.bind_buffer(WebGl2RenderingContext::ARRAY_BUFFER, Some(&vbo)); + + // Allocate vertex buffer memory | 分配顶点缓冲区内存 + let vertex_buffer_size = max_sprites * VERTICES_PER_SPRITE * FLOATS_PER_VERTEX * 4; + gl.buffer_data_with_i32( + WebGl2RenderingContext::ARRAY_BUFFER, + vertex_buffer_size as i32, + WebGl2RenderingContext::DYNAMIC_DRAW, + ); + + // Create and populate index buffer | 创建并填充索引缓冲区 + let ibo = gl + .create_buffer() + .ok_or(EngineError::BufferCreationFailed)?; + gl.bind_buffer(WebGl2RenderingContext::ELEMENT_ARRAY_BUFFER, Some(&ibo)); + + let indices = Self::generate_indices(max_sprites); + unsafe { + let index_array = js_sys::Uint16Array::view(&indices); + gl.buffer_data_with_array_buffer_view( + WebGl2RenderingContext::ELEMENT_ARRAY_BUFFER, + &index_array, + WebGl2RenderingContext::STATIC_DRAW, + ); + } + + // Set up vertex attributes | 设置顶点属性 + Self::setup_vertex_attributes(gl); + + // Unbind VAO | 解绑VAO + gl.bind_vertex_array(None); + + log::debug!( + "SpriteBatch created with capacity: {} sprites | SpriteBatch创建完成,容量: {}个精灵", + max_sprites, + max_sprites + ); + + Ok(Self { + vao, + vbo, + ibo, + max_sprites, + vertices: Vec::with_capacity(max_sprites * VERTICES_PER_SPRITE * FLOATS_PER_VERTEX), + sprite_count: 0, + current_texture: None, + }) + } + + /// Generate index buffer data. + /// 生成索引缓冲区数据。 + fn generate_indices(max_sprites: usize) -> Vec { + let mut indices = Vec::with_capacity(max_sprites * INDICES_PER_SPRITE); + + for i in 0..max_sprites { + let base = (i * VERTICES_PER_SPRITE) as u16; + // Two triangles per sprite | 每个精灵两个三角形 + // Triangle 1: 0, 1, 2 | 三角形1 + // Triangle 2: 2, 3, 0 | 三角形2 + indices.push(base); + indices.push(base + 1); + indices.push(base + 2); + indices.push(base + 2); + indices.push(base + 3); + indices.push(base); + } + + indices + } + + /// Set up vertex attribute pointers. + /// 设置顶点属性指针。 + fn setup_vertex_attributes(gl: &WebGl2RenderingContext) { + let stride = (FLOATS_PER_VERTEX * 4) as i32; + + // Position attribute (location = 0) | 位置属性 + gl.enable_vertex_attrib_array(0); + gl.vertex_attrib_pointer_with_i32( + 0, + 2, + WebGl2RenderingContext::FLOAT, + false, + stride, + 0, + ); + + // Texture coordinate attribute (location = 1) | 纹理坐标属性 + gl.enable_vertex_attrib_array(1); + gl.vertex_attrib_pointer_with_i32( + 1, + 2, + WebGl2RenderingContext::FLOAT, + false, + stride, + 8, // 2 floats * 4 bytes + ); + + // Color attribute (location = 2) | 颜色属性 + gl.enable_vertex_attrib_array(2); + gl.vertex_attrib_pointer_with_i32( + 2, + 4, + WebGl2RenderingContext::FLOAT, + false, + stride, + 16, // 4 floats * 4 bytes + ); + } + + /// Clear the batch for a new frame. + /// 为新帧清空批处理。 + pub fn clear(&mut self) { + self.vertices.clear(); + self.sprite_count = 0; + self.current_texture = None; + } + + /// Add sprites from batch data. + /// 从批处理数据添加精灵。 + /// + /// # Arguments | 参数 + /// * `transforms` - [x, y, rotation, scaleX, scaleY, originX, originY] per sprite + /// * `texture_ids` - Texture ID for each sprite | 每个精灵的纹理ID + /// * `uvs` - [u0, v0, u1, v1] per sprite | 每个精灵的UV坐标 + /// * `colors` - Packed RGBA color per sprite | 每个精灵的打包RGBA颜色 + /// * `texture_manager` - Texture manager for getting texture sizes | 纹理管理器 + pub fn add_sprites( + &mut self, + transforms: &[f32], + texture_ids: &[u32], + uvs: &[f32], + colors: &[u32], + texture_manager: &TextureManager, + ) -> Result<()> { + let sprite_count = texture_ids.len(); + + // Validate input data | 验证输入数据 + if transforms.len() != sprite_count * TRANSFORM_STRIDE { + return Err(EngineError::InvalidBatchData(format!( + "Transform data length mismatch: expected {}, got {}", + sprite_count * TRANSFORM_STRIDE, + transforms.len() + ))); + } + + if uvs.len() != sprite_count * UV_STRIDE { + return Err(EngineError::InvalidBatchData(format!( + "UV data length mismatch: expected {}, got {}", + sprite_count * UV_STRIDE, + uvs.len() + ))); + } + + if colors.len() != sprite_count { + return Err(EngineError::InvalidBatchData(format!( + "Color data length mismatch: expected {}, got {}", + sprite_count, + colors.len() + ))); + } + + // Check capacity | 检查容量 + if self.sprite_count + sprite_count > self.max_sprites { + return Err(EngineError::InvalidBatchData(format!( + "Batch capacity exceeded: {} + {} > {}", + self.sprite_count, sprite_count, self.max_sprites + ))); + } + + // Add each sprite | 添加每个精灵 + for i in 0..sprite_count { + let t_offset = i * TRANSFORM_STRIDE; + let uv_offset = i * UV_STRIDE; + + let x = transforms[t_offset]; + let y = transforms[t_offset + 1]; + let rotation = transforms[t_offset + 2]; + let scale_x = transforms[t_offset + 3]; + let scale_y = transforms[t_offset + 4]; + let origin_x = transforms[t_offset + 5]; + let origin_y = transforms[t_offset + 6]; + + let u0 = uvs[uv_offset]; + let v0 = uvs[uv_offset + 1]; + let u1 = uvs[uv_offset + 2]; + let v1 = uvs[uv_offset + 3]; + + let color = Color::from_packed(colors[i]); + let color_arr = [color.r, color.g, color.b, color.a]; + + // Get texture size for this sprite | 获取此精灵的纹理尺寸 + let (tex_width, tex_height) = texture_manager + .get_texture_size(texture_ids[i]) + .unwrap_or((64.0, 64.0)); + + let width = tex_width * scale_x; + let height = tex_height * scale_y; + + // Calculate transformed vertices | 计算变换后的顶点 + self.add_sprite_vertices( + x, y, width, height, rotation, origin_x, origin_y, + u0, v0, u1, v1, color_arr, + ); + } + + self.sprite_count += sprite_count; + Ok(()) + } + + /// Add vertices for a single sprite. + /// 为单个精灵添加顶点。 + #[inline] + fn add_sprite_vertices( + &mut self, + x: f32, + y: f32, + width: f32, + height: f32, + rotation: f32, + origin_x: f32, + origin_y: f32, + u0: f32, + v0: f32, + u1: f32, + v1: f32, + color: [f32; 4], + ) { + let cos = rotation.cos(); + let sin = rotation.sin(); + + // Origin offset | 原点偏移 + let ox = origin_x * width; + let oy = origin_y * height; + + // Local corner positions (relative to origin) | 局部角点位置(相对于原点) + let corners = [ + (-ox, -oy), // Top-left | 左上 + (width - ox, -oy), // Top-right | 右上 + (width - ox, height - oy), // Bottom-right | 右下 + (-ox, height - oy), // Bottom-left | 左下 + ]; + + let tex_coords = [ + [u0, v0], // Top-left + [u1, v0], // Top-right + [u1, v1], // Bottom-right + [u0, v1], // Bottom-left + ]; + + // Transform and add each vertex | 变换并添加每个顶点 + for i in 0..4 { + let (lx, ly) = corners[i]; + + // Apply rotation | 应用旋转 + let rx = lx * cos - ly * sin; + let ry = lx * sin + ly * cos; + + // Apply translation | 应用平移 + let px = rx + x; + let py = ry + y; + + // Position | 位置 + self.vertices.push(px); + self.vertices.push(py); + + // Texture coordinates | 纹理坐标 + self.vertices.push(tex_coords[i][0]); + self.vertices.push(tex_coords[i][1]); + + // Color | 颜色 + self.vertices.extend_from_slice(&color); + } + } + + /// Flush the batch to GPU and render. + /// 将批处理刷新到GPU并渲染。 + pub fn flush(&mut self, gl: &WebGl2RenderingContext) { + if self.sprite_count == 0 { + return; + } + + // Bind VAO | 绑定VAO + gl.bind_vertex_array(Some(&self.vao)); + + // Upload vertex data | 上传顶点数据 + gl.bind_buffer(WebGl2RenderingContext::ARRAY_BUFFER, Some(&self.vbo)); + unsafe { + let vertex_array = js_sys::Float32Array::view(&self.vertices); + gl.buffer_sub_data_with_i32_and_array_buffer_view( + WebGl2RenderingContext::ARRAY_BUFFER, + 0, + &vertex_array, + ); + } + + // Draw | 绘制 + let index_count = (self.sprite_count * INDICES_PER_SPRITE) as i32; + gl.draw_elements_with_i32( + WebGl2RenderingContext::TRIANGLES, + index_count, + WebGl2RenderingContext::UNSIGNED_SHORT, + 0, + ); + + // Unbind VAO | 解绑VAO + gl.bind_vertex_array(None); + } + + /// Get current sprite count. + /// 获取当前精灵数量。 + #[inline] + pub fn sprite_count(&self) -> usize { + self.sprite_count + } +} diff --git a/packages/engine/src/renderer/batch/vertex.rs b/packages/engine/src/renderer/batch/vertex.rs new file mode 100644 index 00000000..1cd30d6c --- /dev/null +++ b/packages/engine/src/renderer/batch/vertex.rs @@ -0,0 +1,60 @@ +//! Vertex data structures for sprite rendering. +//! 用于精灵渲染的顶点数据结构。 + +use bytemuck::{Pod, Zeroable}; + +/// Size of a single sprite vertex in bytes. +/// 单个精灵顶点的字节大小。 +pub const VERTEX_SIZE: usize = std::mem::size_of::(); + +/// Number of floats per vertex. +/// 每个顶点的浮点数数量。 +pub const FLOATS_PER_VERTEX: usize = 8; + +/// Sprite vertex data. +/// 精灵顶点数据。 +/// +/// Each sprite requires 4 vertices (quad), each with position, UV, and color. +/// 每个精灵需要4个顶点(四边形),每个顶点包含位置、UV和颜色。 +#[derive(Debug, Clone, Copy, Pod, Zeroable)] +#[repr(C)] +pub struct SpriteVertex { + /// Position (x, y). + /// 位置。 + pub position: [f32; 2], + + /// Texture coordinates (u, v). + /// 纹理坐标。 + pub tex_coord: [f32; 2], + + /// Color (r, g, b, a). + /// 颜色。 + pub color: [f32; 4], +} + +impl SpriteVertex { + /// Create a new sprite vertex. + /// 创建新的精灵顶点。 + #[inline] + pub const fn new( + position: [f32; 2], + tex_coord: [f32; 2], + color: [f32; 4], + ) -> Self { + Self { + position, + tex_coord, + color, + } + } +} + +impl Default for SpriteVertex { + fn default() -> Self { + Self { + position: [0.0, 0.0], + tex_coord: [0.0, 0.0], + color: [1.0, 1.0, 1.0, 1.0], + } + } +} diff --git a/packages/engine/src/renderer/camera.rs b/packages/engine/src/renderer/camera.rs new file mode 100644 index 00000000..95b09bbc --- /dev/null +++ b/packages/engine/src/renderer/camera.rs @@ -0,0 +1,144 @@ +//! 2D camera implementation. +//! 2D相机实现。 + +use crate::math::Vec2; +use glam::Mat3; + +/// 2D orthographic camera. +/// 2D正交相机。 +/// +/// Provides view and projection matrices for 2D rendering. +/// 提供用于2D渲染的视图和投影矩阵。 +#[derive(Debug, Clone)] +pub struct Camera2D { + /// Camera position in world space. + /// 相机在世界空间中的位置。 + pub position: Vec2, + + /// Rotation in radians. + /// 旋转角度(弧度)。 + pub rotation: f32, + + /// Zoom level (1.0 = normal). + /// 缩放级别(1.0 = 正常)。 + pub zoom: f32, + + /// Viewport width. + /// 视口宽度。 + width: f32, + + /// Viewport height. + /// 视口高度。 + height: f32, +} + +impl Camera2D { + /// Create a new 2D camera. + /// 创建新的2D相机。 + /// + /// # Arguments | 参数 + /// * `width` - Viewport width | 视口宽度 + /// * `height` - Viewport height | 视口高度 + pub fn new(width: f32, height: f32) -> Self { + Self { + position: Vec2::ZERO, + rotation: 0.0, + zoom: 1.0, + width, + height, + } + } + + /// Update viewport size. + /// 更新视口大小。 + pub fn set_viewport(&mut self, width: f32, height: f32) { + self.width = width; + self.height = height; + } + + /// Get the projection matrix. + /// 获取投影矩阵。 + /// + /// Creates an orthographic projection that maps screen coordinates + /// to normalized device coordinates. + /// 创建将屏幕坐标映射到标准化设备坐标的正交投影。 + pub fn projection_matrix(&self) -> Mat3 { + // Orthographic projection | 正交投影 + // Maps [0, width] x [0, height] to [-1, 1] x [-1, 1] + let sx = 2.0 / self.width * self.zoom; + let sy = -2.0 / self.height * self.zoom; // Flip Y axis | 翻转Y轴 + + let cos = self.rotation.cos(); + let sin = self.rotation.sin(); + + // Apply zoom, rotation, and translation + // 应用缩放、旋转和平移 + let tx = -self.position.x * sx * cos - self.position.y * sy * sin - 1.0; + let ty = -self.position.x * sx * sin + self.position.y * sy * cos + 1.0; + + Mat3::from_cols( + glam::Vec3::new(sx * cos, sx * sin, 0.0), + glam::Vec3::new(sy * -sin, sy * cos, 0.0), + glam::Vec3::new(tx, ty, 1.0), + ) + } + + /// Convert screen coordinates to world coordinates. + /// 将屏幕坐标转换为世界坐标。 + pub fn screen_to_world(&self, screen: Vec2) -> Vec2 { + let x = (screen.x / self.zoom) + self.position.x; + let y = (screen.y / self.zoom) + self.position.y; + + if self.rotation != 0.0 { + let dx = x - self.position.x; + let dy = y - self.position.y; + let cos = (-self.rotation).cos(); + let sin = (-self.rotation).sin(); + + Vec2::new( + dx * cos - dy * sin + self.position.x, + dx * sin + dy * cos + self.position.y, + ) + } else { + Vec2::new(x, y) + } + } + + /// Convert world coordinates to screen coordinates. + /// 将世界坐标转换为屏幕坐标。 + pub fn world_to_screen(&self, world: Vec2) -> Vec2 { + let dx = world.x - self.position.x; + let dy = world.y - self.position.y; + + if self.rotation != 0.0 { + let cos = self.rotation.cos(); + let sin = self.rotation.sin(); + let rx = dx * cos - dy * sin; + let ry = dx * sin + dy * cos; + + Vec2::new(rx * self.zoom, ry * self.zoom) + } else { + Vec2::new(dx * self.zoom, dy * self.zoom) + } + } + + /// Move camera by delta. + /// 按增量移动相机。 + #[inline] + pub fn translate(&mut self, delta: Vec2) { + self.position = self.position + delta; + } + + /// Set zoom level with clamping. + /// 设置缩放级别并限制范围。 + #[inline] + pub fn set_zoom(&mut self, zoom: f32) { + self.zoom = zoom.clamp(0.1, 10.0); + } +} + +impl Default for Camera2D { + fn default() -> Self { + Self::new(800.0, 600.0) + } +} diff --git a/packages/engine/src/renderer/mod.rs b/packages/engine/src/renderer/mod.rs new file mode 100644 index 00000000..7bbe82c3 --- /dev/null +++ b/packages/engine/src/renderer/mod.rs @@ -0,0 +1,14 @@ +//! 2D rendering system with batch optimization. +//! 带批处理优化的2D渲染系统。 + +pub mod batch; +pub mod shader; +pub mod texture; + +mod renderer2d; +mod camera; + +pub use renderer2d::Renderer2D; +pub use camera::Camera2D; +pub use batch::SpriteBatch; +pub use texture::{Texture, TextureManager}; diff --git a/packages/engine/src/renderer/renderer2d.rs b/packages/engine/src/renderer/renderer2d.rs new file mode 100644 index 00000000..7609fed2 --- /dev/null +++ b/packages/engine/src/renderer/renderer2d.rs @@ -0,0 +1,134 @@ +//! Main 2D renderer implementation. +//! 主2D渲染器实现。 + +use wasm_bindgen::JsCast; +use web_sys::WebGl2RenderingContext; + +use crate::core::error::Result; +use crate::resource::TextureManager; +use super::batch::SpriteBatch; +use super::camera::Camera2D; +use super::shader::{ShaderProgram, SPRITE_VERTEX_SHADER, SPRITE_FRAGMENT_SHADER}; + +/// 2D renderer with batched sprite rendering. +/// 带批处理精灵渲染的2D渲染器。 +/// +/// Coordinates sprite batching, shader management, and camera transforms. +/// 协调精灵批处理、Shader管理和相机变换。 +pub struct Renderer2D { + /// Sprite batch renderer. + /// 精灵批处理渲染器。 + sprite_batch: SpriteBatch, + + /// Sprite shader program. + /// 精灵Shader程序。 + shader: ShaderProgram, + + /// 2D camera. + /// 2D相机。 + camera: Camera2D, +} + +impl Renderer2D { + /// Create a new 2D renderer. + /// 创建新的2D渲染器。 + /// + /// # Arguments | 参数 + /// * `gl` - WebGL2 context | WebGL2上下文 + /// * `max_sprites` - Maximum sprites per batch | 每批次最大精灵数 + pub fn new(gl: &WebGl2RenderingContext, max_sprites: usize) -> Result { + let sprite_batch = SpriteBatch::new(gl, max_sprites)?; + let shader = ShaderProgram::new(gl, SPRITE_VERTEX_SHADER, SPRITE_FRAGMENT_SHADER)?; + + // Get canvas size for camera | 获取canvas尺寸用于相机 + let canvas = gl.canvas() + .and_then(|c| c.dyn_into::().ok()) + .map(|c| (c.width() as f32, c.height() as f32)) + .unwrap_or((800.0, 600.0)); + + let camera = Camera2D::new(canvas.0, canvas.1); + + log::info!( + "Renderer2D initialized | Renderer2D初始化完成: {}x{}, max sprites: {}", + canvas.0, canvas.1, max_sprites + ); + + Ok(Self { + sprite_batch, + shader, + camera, + }) + } + + /// Submit sprite batch data for rendering. + /// 提交精灵批次数据进行渲染。 + /// + /// # Arguments | 参数 + /// * `transforms` - Transform data for each sprite | 每个精灵的变换数据 + /// * `texture_ids` - Texture ID for each sprite | 每个精灵的纹理ID + /// * `uvs` - UV coordinates for each sprite | 每个精灵的UV坐标 + /// * `colors` - Packed color for each sprite | 每个精灵的打包颜色 + /// * `texture_manager` - Texture manager | 纹理管理器 + pub fn submit_batch( + &mut self, + transforms: &[f32], + texture_ids: &[u32], + uvs: &[f32], + colors: &[u32], + texture_manager: &TextureManager, + ) -> Result<()> { + self.sprite_batch.add_sprites( + transforms, + texture_ids, + uvs, + colors, + texture_manager, + ) + } + + /// Render the current frame. + /// 渲染当前帧。 + pub fn render(&mut self, gl: &WebGl2RenderingContext) -> Result<()> { + if self.sprite_batch.sprite_count() == 0 { + return Ok(()); + } + + // Bind shader | 绑定Shader + self.shader.bind(gl); + + // Set projection matrix | 设置投影矩阵 + let projection = self.camera.projection_matrix(); + self.shader.set_uniform_mat3(gl, "u_projection", &projection.to_cols_array()); + + // Set texture sampler | 设置纹理采样器 + self.shader.set_uniform_i32(gl, "u_texture", 0); + + // Flush sprite batch | 刷新精灵批处理 + self.sprite_batch.flush(gl); + + // Clear batch for next frame | 清空批处理以供下一帧使用 + self.sprite_batch.clear(); + + Ok(()) + } + + /// Get mutable reference to camera. + /// 获取相机的可变引用。 + #[inline] + pub fn camera_mut(&mut self) -> &mut Camera2D { + &mut self.camera + } + + /// Get reference to camera. + /// 获取相机的引用。 + #[inline] + pub fn camera(&self) -> &Camera2D { + &self.camera + } + + /// Update camera viewport size. + /// 更新相机视口大小。 + pub fn resize(&mut self, width: f32, height: f32) { + self.camera.set_viewport(width, height); + } +} diff --git a/packages/engine/src/renderer/shader/builtin.rs b/packages/engine/src/renderer/shader/builtin.rs new file mode 100644 index 00000000..5ca56fcd --- /dev/null +++ b/packages/engine/src/renderer/shader/builtin.rs @@ -0,0 +1,63 @@ +//! Built-in shader source code. +//! 内置Shader源代码。 + +/// Sprite vertex shader source. +/// 精灵顶点着色器源代码。 +/// +/// Handles sprite transformation with position, UV, and color attributes. +/// 处理带有位置、UV和颜色属性的精灵变换。 +pub const SPRITE_VERTEX_SHADER: &str = r#"#version 300 es +precision highp float; + +// Vertex attributes | 顶点属性 +layout(location = 0) in vec2 a_position; +layout(location = 1) in vec2 a_texCoord; +layout(location = 2) in vec4 a_color; + +// Uniforms | 统一变量 +uniform mat3 u_projection; + +// Outputs to fragment shader | 输出到片段着色器 +out vec2 v_texCoord; +out vec4 v_color; + +void main() { + // Apply projection matrix | 应用投影矩阵 + vec3 pos = u_projection * vec3(a_position, 1.0); + gl_Position = vec4(pos.xy, 0.0, 1.0); + + // Pass through to fragment shader | 传递到片段着色器 + v_texCoord = a_texCoord; + v_color = a_color; +} +"#; + +/// Sprite fragment shader source. +/// 精灵片段着色器源代码。 +/// +/// Samples texture and applies vertex color tinting. +/// 采样纹理并应用顶点颜色着色。 +pub const SPRITE_FRAGMENT_SHADER: &str = r#"#version 300 es +precision highp float; + +// Inputs from vertex shader | 来自顶点着色器的输入 +in vec2 v_texCoord; +in vec4 v_color; + +// Texture sampler | 纹理采样器 +uniform sampler2D u_texture; + +// Output color | 输出颜色 +out vec4 fragColor; + +void main() { + // Sample texture and multiply by vertex color | 采样纹理并乘以顶点颜色 + vec4 texColor = texture(u_texture, v_texCoord); + fragColor = texColor * v_color; + + // Discard fully transparent pixels | 丢弃完全透明的像素 + if (fragColor.a < 0.01) { + discard; + } +} +"#; diff --git a/packages/engine/src/renderer/shader/mod.rs b/packages/engine/src/renderer/shader/mod.rs new file mode 100644 index 00000000..dfbd5f42 --- /dev/null +++ b/packages/engine/src/renderer/shader/mod.rs @@ -0,0 +1,8 @@ +//! Shader management system. +//! Shader管理系统。 + +mod program; +mod builtin; + +pub use program::ShaderProgram; +pub use builtin::{SPRITE_VERTEX_SHADER, SPRITE_FRAGMENT_SHADER}; diff --git a/packages/engine/src/renderer/shader/program.rs b/packages/engine/src/renderer/shader/program.rs new file mode 100644 index 00000000..225fc6c0 --- /dev/null +++ b/packages/engine/src/renderer/shader/program.rs @@ -0,0 +1,154 @@ +//! Shader program compilation and management. +//! Shader程序编译和管理。 + +use web_sys::{WebGl2RenderingContext, WebGlProgram, WebGlShader, WebGlUniformLocation}; +use crate::core::error::{EngineError, Result}; + +/// Compiled shader program. +/// 已编译的Shader程序。 +/// +/// Manages vertex and fragment shaders, including compilation and linking. +/// 管理顶点和片段着色器,包括编译和链接。 +pub struct ShaderProgram { + program: WebGlProgram, +} + +impl ShaderProgram { + /// Create and compile a new shader program. + /// 创建并编译新的Shader程序。 + /// + /// # Arguments | 参数 + /// * `gl` - WebGL2 context | WebGL2上下文 + /// * `vertex_source` - Vertex shader source code | 顶点着色器源代码 + /// * `fragment_source` - Fragment shader source code | 片段着色器源代码 + /// + /// # Returns | 返回 + /// A compiled shader program or an error | 已编译的Shader程序或错误 + pub fn new( + gl: &WebGl2RenderingContext, + vertex_source: &str, + fragment_source: &str, + ) -> Result { + // Compile shaders | 编译着色器 + let vertex_shader = Self::compile_shader( + gl, + WebGl2RenderingContext::VERTEX_SHADER, + vertex_source, + )?; + + let fragment_shader = Self::compile_shader( + gl, + WebGl2RenderingContext::FRAGMENT_SHADER, + fragment_source, + )?; + + // Create and link program | 创建并链接程序 + let program = gl + .create_program() + .ok_or_else(|| EngineError::ProgramLinkFailed("Failed to create program".into()))?; + + gl.attach_shader(&program, &vertex_shader); + gl.attach_shader(&program, &fragment_shader); + gl.link_program(&program); + + // Check for linking errors | 检查链接错误 + let success = gl + .get_program_parameter(&program, WebGl2RenderingContext::LINK_STATUS) + .as_bool() + .unwrap_or(false); + + if !success { + let log = gl.get_program_info_log(&program).unwrap_or_default(); + return Err(EngineError::ProgramLinkFailed(log)); + } + + // Clean up shaders (they're linked to the program now) + // 清理着色器(它们现在已链接到程序) + gl.delete_shader(Some(&vertex_shader)); + gl.delete_shader(Some(&fragment_shader)); + + log::debug!("Shader program compiled successfully | Shader程序编译成功"); + + Ok(Self { program }) + } + + /// Compile a single shader. + /// 编译单个着色器。 + fn compile_shader( + gl: &WebGl2RenderingContext, + shader_type: u32, + source: &str, + ) -> Result { + let shader = gl + .create_shader(shader_type) + .ok_or_else(|| EngineError::ShaderCompileFailed("Failed to create shader".into()))?; + + gl.shader_source(&shader, source); + gl.compile_shader(&shader); + + // Check for compilation errors | 检查编译错误 + let success = gl + .get_shader_parameter(&shader, WebGl2RenderingContext::COMPILE_STATUS) + .as_bool() + .unwrap_or(false); + + if !success { + let log = gl.get_shader_info_log(&shader).unwrap_or_default(); + let shader_type_name = if shader_type == WebGl2RenderingContext::VERTEX_SHADER { + "Vertex" + } else { + "Fragment" + }; + return Err(EngineError::ShaderCompileFailed(format!( + "{} shader: {}", + shader_type_name, log + ))); + } + + Ok(shader) + } + + /// Use this shader program for rendering. + /// 使用此Shader程序进行渲染。 + #[inline] + pub fn bind(&self, gl: &WebGl2RenderingContext) { + gl.use_program(Some(&self.program)); + } + + /// Get uniform location by name. + /// 按名称获取uniform位置。 + #[inline] + pub fn get_uniform_location( + &self, + gl: &WebGl2RenderingContext, + name: &str, + ) -> Option { + gl.get_uniform_location(&self.program, name) + } + + /// Set a mat3 uniform. + /// 设置mat3 uniform。 + pub fn set_uniform_mat3( + &self, + gl: &WebGl2RenderingContext, + name: &str, + value: &[f32; 9], + ) { + if let Some(location) = self.get_uniform_location(gl, name) { + gl.uniform_matrix3fv_with_f32_array(Some(&location), false, value); + } + } + + /// Set an i32 uniform (for texture samplers). + /// 设置i32 uniform(用于纹理采样器)。 + pub fn set_uniform_i32( + &self, + gl: &WebGl2RenderingContext, + name: &str, + value: i32, + ) { + if let Some(location) = self.get_uniform_location(gl, name) { + gl.uniform1i(Some(&location), value); + } + } +} diff --git a/packages/engine/src/renderer/texture/mod.rs b/packages/engine/src/renderer/texture/mod.rs new file mode 100644 index 00000000..e3be5892 --- /dev/null +++ b/packages/engine/src/renderer/texture/mod.rs @@ -0,0 +1,8 @@ +//! Texture management system. +//! 纹理管理系统。 + +mod texture; +mod texture_manager; + +pub use texture::Texture; +pub use texture_manager::TextureManager; diff --git a/packages/engine/src/renderer/texture/texture.rs b/packages/engine/src/renderer/texture/texture.rs new file mode 100644 index 00000000..a695b2d0 --- /dev/null +++ b/packages/engine/src/renderer/texture/texture.rs @@ -0,0 +1,39 @@ +//! Texture representation. +//! 纹理表示。 + +use web_sys::WebGlTexture; + +/// 2D texture. +/// 2D纹理。 +pub struct Texture { + /// WebGL texture handle. + /// WebGL纹理句柄。 + pub(crate) handle: WebGlTexture, + + /// Texture width in pixels. + /// 纹理宽度(像素)。 + pub width: u32, + + /// Texture height in pixels. + /// 纹理高度(像素)。 + pub height: u32, +} + +impl Texture { + /// Create a new texture. + /// 创建新纹理。 + pub fn new(handle: WebGlTexture, width: u32, height: u32) -> Self { + Self { + handle, + width, + height, + } + } + + /// Get the WebGL texture handle. + /// 获取WebGL纹理句柄。 + #[inline] + pub fn handle(&self) -> &WebGlTexture { + &self.handle + } +} diff --git a/packages/engine/src/renderer/texture/texture_manager.rs b/packages/engine/src/renderer/texture/texture_manager.rs new file mode 100644 index 00000000..0dbc1616 --- /dev/null +++ b/packages/engine/src/renderer/texture/texture_manager.rs @@ -0,0 +1,217 @@ +//! Texture loading and management. +//! 纹理加载和管理。 + +use std::collections::HashMap; +use std::cell::RefCell; +use std::rc::Rc; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use web_sys::{HtmlImageElement, WebGl2RenderingContext, WebGlTexture}; + +use crate::core::error::{EngineError, Result}; +use super::Texture; + +/// Texture manager for loading and caching textures. +/// 用于加载和缓存纹理的纹理管理器。 +pub struct TextureManager { + /// WebGL context. + /// WebGL上下文。 + gl: WebGl2RenderingContext, + + /// Loaded textures. + /// 已加载的纹理。 + textures: HashMap, + + /// Default white texture for untextured rendering. + /// 用于无纹理渲染的默认白色纹理。 + default_texture: Option, +} + +impl TextureManager { + /// Create a new texture manager. + /// 创建新的纹理管理器。 + pub fn new(gl: WebGl2RenderingContext) -> Self { + let mut manager = Self { + gl, + textures: HashMap::new(), + default_texture: None, + }; + + // Create default white texture | 创建默认白色纹理 + manager.create_default_texture(); + + manager + } + + /// Create a 1x1 white texture as default. + /// 创建1x1白色纹理作为默认纹理。 + fn create_default_texture(&mut self) { + let texture = self.gl.create_texture(); + if let Some(tex) = &texture { + self.gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(tex)); + + let white_pixel: [u8; 4] = [255, 255, 255, 255]; + let _ = self.gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array( + WebGl2RenderingContext::TEXTURE_2D, + 0, + WebGl2RenderingContext::RGBA as i32, + 1, + 1, + 0, + WebGl2RenderingContext::RGBA, + WebGl2RenderingContext::UNSIGNED_BYTE, + Some(&white_pixel), + ); + + self.gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_MIN_FILTER, + WebGl2RenderingContext::NEAREST as i32, + ); + self.gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_MAG_FILTER, + WebGl2RenderingContext::NEAREST as i32, + ); + } + + self.default_texture = texture; + } + + /// Load a texture from URL. + /// 从URL加载纹理。 + /// + /// Note: This is an async operation. The texture will be available + /// after the image loads. + /// 注意:这是一个异步操作。纹理在图片加载后可用。 + pub fn load_texture(&mut self, id: u32, url: &str) -> Result<()> { + // Create placeholder texture | 创建占位纹理 + let texture = self.gl + .create_texture() + .ok_or_else(|| EngineError::TextureLoadFailed("Failed to create texture".into()))?; + + // Set up temporary 1x1 texture | 设置临时1x1纹理 + self.gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture)); + let placeholder: [u8; 4] = [128, 128, 128, 255]; + let _ = self.gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array( + WebGl2RenderingContext::TEXTURE_2D, + 0, + WebGl2RenderingContext::RGBA as i32, + 1, + 1, + 0, + WebGl2RenderingContext::RGBA, + WebGl2RenderingContext::UNSIGNED_BYTE, + Some(&placeholder), + ); + + // Store texture with placeholder size | 存储带占位符尺寸的纹理 + self.textures.insert(id, Texture::new(texture.clone(), 1, 1)); + + // Load actual image asynchronously | 异步加载实际图片 + let gl = self.gl.clone(); + let texture_rc = Rc::new(RefCell::new(texture)); + let texture_clone = Rc::clone(&texture_rc); + + // We need to update the stored texture size after loading + // For MVP, we'll handle this through a callback mechanism + // 加载后需要更新存储的纹理尺寸 + // 对于MVP,我们通过回调机制处理 + + let image = HtmlImageElement::new() + .map_err(|_| EngineError::TextureLoadFailed("Failed to create image element".into()))?; + + // Clone image for use in closure | 克隆图片用于闭包 + let image_clone = image.clone(); + + // Set up load callback | 设置加载回调 + let onload = Closure::wrap(Box::new(move || { + let tex = texture_clone.borrow(); + gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&tex)); + + // Use the captured image element | 使用捕获的图片元素 + let _ = gl.tex_image_2d_with_u32_and_u32_and_html_image_element( + WebGl2RenderingContext::TEXTURE_2D, + 0, + WebGl2RenderingContext::RGBA as i32, + WebGl2RenderingContext::RGBA, + WebGl2RenderingContext::UNSIGNED_BYTE, + &image_clone, + ); + + // Set texture parameters | 设置纹理参数 + gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_WRAP_S, + WebGl2RenderingContext::CLAMP_TO_EDGE as i32, + ); + gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_WRAP_T, + WebGl2RenderingContext::CLAMP_TO_EDGE as i32, + ); + gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_MIN_FILTER, + WebGl2RenderingContext::LINEAR as i32, + ); + gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_MAG_FILTER, + WebGl2RenderingContext::LINEAR as i32, + ); + + log::debug!("Texture loaded | 纹理加载完成"); + }) as Box); + + image.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); // Prevent closure from being dropped | 防止闭包被销毁 + + image.set_src(url); + + Ok(()) + } + + /// Get texture by ID. + /// 按ID获取纹理。 + #[inline] + pub fn get_texture(&self, id: u32) -> Option<&Texture> { + self.textures.get(&id) + } + + /// Get texture size by ID. + /// 按ID获取纹理尺寸。 + #[inline] + pub fn get_texture_size(&self, id: u32) -> Option<(f32, f32)> { + self.textures + .get(&id) + .map(|t| (t.width as f32, t.height as f32)) + } + + /// Bind texture for rendering. + /// 绑定纹理用于渲染。 + pub fn bind_texture(&self, id: u32, slot: u32) { + self.gl.active_texture(WebGl2RenderingContext::TEXTURE0 + slot); + + if let Some(texture) = self.textures.get(&id) { + self.gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture.handle)); + } else if let Some(default) = &self.default_texture { + self.gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(default)); + } + } + + /// Check if texture is loaded. + /// 检查纹理是否已加载。 + #[inline] + pub fn has_texture(&self, id: u32) -> bool { + self.textures.contains_key(&id) + } + + /// Remove texture. + /// 移除纹理。 + pub fn remove_texture(&mut self, id: u32) { + if let Some(texture) = self.textures.remove(&id) { + self.gl.delete_texture(Some(&texture.handle)); + } + } +} diff --git a/packages/engine/src/resource/handle.rs b/packages/engine/src/resource/handle.rs new file mode 100644 index 00000000..798de6fc --- /dev/null +++ b/packages/engine/src/resource/handle.rs @@ -0,0 +1,55 @@ +//! Resource handle types. +//! 资源句柄类型。 + +/// Type alias for resource handle IDs. +/// 资源句柄ID的类型别名。 +pub type HandleId = u32; + +/// Generic resource handle. +/// 通用资源句柄。 +/// +/// A lightweight identifier for referencing loaded resources. +/// 用于引用已加载资源的轻量级标识符。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Handle { + /// Unique identifier. + /// 唯一标识符。 + id: HandleId, + + /// Phantom data for type safety. + /// 用于类型安全的幻象数据。 + _marker: std::marker::PhantomData, +} + +impl Handle { + /// Create a new handle with the given ID. + /// 使用给定ID创建新句柄。 + #[inline] + pub const fn new(id: HandleId) -> Self { + Self { + id, + _marker: std::marker::PhantomData, + } + } + + /// Get the handle ID. + /// 获取句柄ID。 + #[inline] + pub const fn id(&self) -> HandleId { + self.id + } +} + +impl From for Handle { + #[inline] + fn from(id: HandleId) -> Self { + Self::new(id) + } +} + +impl From> for HandleId { + #[inline] + fn from(handle: Handle) -> Self { + handle.id + } +} diff --git a/packages/engine/src/resource/mod.rs b/packages/engine/src/resource/mod.rs new file mode 100644 index 00000000..5344c971 --- /dev/null +++ b/packages/engine/src/resource/mod.rs @@ -0,0 +1,7 @@ +//! Resource management system. +//! 资源管理系统。 + +mod handle; + +pub use handle::{Handle, HandleId}; +pub use crate::renderer::texture::{Texture, TextureManager}; diff --git a/packages/platform-common/README.md b/packages/platform-common/README.md new file mode 100644 index 00000000..c6c4465e --- /dev/null +++ b/packages/platform-common/README.md @@ -0,0 +1,48 @@ +# @esengine/platform-common + +平台通用接口定义包,定义了所有平台子系统的接口规范。 + +## 安装 + +```bash +npm install @esengine/platform-common +``` + +## 用途 + +此包仅包含 TypeScript 接口定义,供各平台适配器包实现: + +- `@esengine/platform-wechat` - 微信小游戏 +- `@esengine/platform-web` - Web 浏览器 +- `@esengine/platform-bytedance` - 抖音小游戏 + +## 接口列表 + +### Canvas/渲染 +- `IPlatformCanvasSubsystem` +- `IPlatformCanvas` +- `IPlatformImage` + +### 音频 +- `IPlatformAudioSubsystem` +- `IPlatformAudioContext` + +### 存储 +- `IPlatformStorageSubsystem` + +### 网络 +- `IPlatformNetworkSubsystem` +- `IPlatformWebSocket` + +### 输入 +- `IPlatformInputSubsystem` + +### 文件系统 +- `IPlatformFileSubsystem` + +### WASM +- `IPlatformWASMSubsystem` + +## License + +MIT diff --git a/packages/platform-common/package.json b/packages/platform-common/package.json new file mode 100644 index 00000000..7444dafd --- /dev/null +++ b/packages/platform-common/package.json @@ -0,0 +1,50 @@ +{ + "name": "@esengine/platform-common", + "version": "1.0.0", + "description": "平台通用接口定义", + "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", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "ecs", + "platform", + "interface", + "common" + ], + "author": "yhh", + "license": "MIT", + "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/platform-common" + } +} diff --git a/packages/platform-common/rollup.config.js b/packages/platform-common/rollup.config.js new file mode 100644 index 00000000..c07bd0d2 --- /dev/null +++ b/packages/platform-common/rollup.config.js @@ -0,0 +1,40 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import typescript from '@rollup/plugin-typescript'; +import dts from 'rollup-plugin-dts'; + +export default [ + // ESM and CJS builds + { + input: 'src/index.ts', + output: [ + { + file: 'dist/index.mjs', + format: 'esm', + sourcemap: true + }, + { + file: 'dist/index.js', + format: 'cjs', + sourcemap: true + } + ], + plugins: [ + resolve(), + commonjs(), + typescript({ + tsconfig: './tsconfig.json', + declaration: false + }) + ] + }, + // Type declarations + { + input: 'src/index.ts', + output: { + file: 'dist/index.d.ts', + format: 'esm' + }, + plugins: [dts()] + } +]; diff --git a/packages/platform-common/src/IPlatformSubsystems.ts b/packages/platform-common/src/IPlatformSubsystems.ts new file mode 100644 index 00000000..e3723583 --- /dev/null +++ b/packages/platform-common/src/IPlatformSubsystems.ts @@ -0,0 +1,632 @@ +/** + * 平台子系统接口定义 + * 将平台能力分解为独立的子系统,支持按需实现和代码裁剪 + */ + +// ============================================================================ +// Canvas/渲染子系统 +// ============================================================================ + +/** + * 平台 Canvas 对象抽象 + */ +/** + * Canvas 上下文属性(兼容 Web 和小游戏平台) + */ +export interface CanvasContextAttributes { + alpha?: boolean | number; + antialias?: boolean; + depth?: boolean; + stencil?: boolean; + premultipliedAlpha?: boolean; + preserveDrawingBuffer?: boolean; + failIfMajorPerformanceCaveat?: boolean; + powerPreference?: 'default' | 'high-performance' | 'low-power'; + antialiasSamples?: number; +} + +export interface IPlatformCanvas { + width: number; + height: number; + getContext(contextType: '2d' | 'webgl' | 'webgl2', contextAttributes?: CanvasContextAttributes): RenderingContext | null; + toDataURL(): string; + toTempFilePath?(options: TempFilePathOptions): void; +} + +/** + * 平台 Image 对象抽象 + */ +export interface IPlatformImage { + src: string; + width: number; + height: number; + onload: (() => void) | null; + onerror: ((error: any) => void) | null; +} + +/** + * 临时文件路径选项 + */ +export interface TempFilePathOptions { + x?: number; + y?: number; + width?: number; + height?: number; + destWidth?: number; + destHeight?: number; + fileType?: 'png' | 'jpg'; + quality?: number; + success?: (res: { tempFilePath: string }) => void; + fail?: (error: any) => void; + complete?: () => void; +} + +/** + * Canvas 子系统接口 + */ +export interface IPlatformCanvasSubsystem { + /** + * 创建主 Canvas(首次调用)或离屏 Canvas + */ + createCanvas(width?: number, height?: number): IPlatformCanvas; + + /** + * 创建图片对象 + */ + createImage(): IPlatformImage; + + /** + * 创建 ImageData + */ + createImageData?(width: number, height: number): ImageData; + + /** + * 获取屏幕宽度 + */ + getScreenWidth(): number; + + /** + * 获取屏幕高度 + */ + getScreenHeight(): number; + + /** + * 获取设备像素比 + */ + getDevicePixelRatio(): number; +} + +// ============================================================================ +// 音频子系统 +// ============================================================================ + +/** + * 平台音频上下文抽象 + */ +export interface IPlatformAudioContext { + src: string; + autoplay: boolean; + loop: boolean; + volume: number; + duration: number; + currentTime: number; + paused: boolean; + buffered: number; + + play(): void; + pause(): void; + stop(): void; + seek(position: number): void; + destroy(): void; + + onPlay(callback: () => void): void; + onPause(callback: () => void): void; + onStop(callback: () => void): void; + onEnded(callback: () => void): void; + onError(callback: (error: { errCode: number; errMsg: string }) => void): void; + onTimeUpdate(callback: () => void): void; + onCanplay(callback: () => void): void; + onSeeking(callback: () => void): void; + onSeeked(callback: () => void): void; + + offPlay(callback: () => void): void; + offPause(callback: () => void): void; + offStop(callback: () => void): void; + offEnded(callback: () => void): void; + offError(callback: (error: { errCode: number; errMsg: string }) => void): void; + offTimeUpdate(callback: () => void): void; +} + +/** + * 音频子系统接口 + */ +export interface IPlatformAudioSubsystem { + /** + * 创建音频上下文 + */ + createAudioContext(options?: { useWebAudioImplement?: boolean }): IPlatformAudioContext; + + /** + * 获取支持的音频格式 + */ + getSupportedFormats(): string[]; + + /** + * 设置静音模式下是否可以播放音频 + */ + setInnerAudioOption?(options: { + mixWithOther?: boolean; + obeyMuteSwitch?: boolean; + speakerOn?: boolean; + }): Promise; +} + +// ============================================================================ +// 存储子系统 +// ============================================================================ + +/** + * 存储信息 + */ +export interface StorageInfo { + keys: string[]; + currentSize: number; + limitSize: number; +} + +/** + * 存储子系统接口 + */ +export interface IPlatformStorageSubsystem { + /** + * 同步获取存储 + */ + getStorageSync(key: string): T | undefined; + + /** + * 同步设置存储 + */ + setStorageSync(key: string, value: T): void; + + /** + * 同步移除存储 + */ + removeStorageSync(key: string): void; + + /** + * 同步清空存储 + */ + clearStorageSync(): void; + + /** + * 获取存储信息 + */ + getStorageInfoSync(): StorageInfo; + + /** + * 异步获取存储 + */ + getStorage(key: string): Promise; + + /** + * 异步设置存储 + */ + setStorage(key: string, value: T): Promise; + + /** + * 异步移除存储 + */ + removeStorage(key: string): Promise; + + /** + * 异步清空存储 + */ + clearStorage(): Promise; +} + +// ============================================================================ +// 网络子系统 +// ============================================================================ + +/** + * 请求配置 + */ +export interface RequestConfig { + url: string; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'TRACE' | 'CONNECT'; + data?: any; + header?: Record; + timeout?: number; + dataType?: 'json' | 'text' | 'arraybuffer'; + responseType?: 'text' | 'arraybuffer'; +} + +/** + * 请求响应 + */ +export interface RequestResponse { + data: T; + statusCode: number; + header: Record; +} + +/** + * 下载任务 + */ +export interface IDownloadTask { + abort(): void; + onProgressUpdate(callback: (res: { + progress: number; + totalBytesWritten: number; + totalBytesExpectedToWrite: number; + }) => void): void; + offProgressUpdate(callback: Function): void; +} + +/** + * 上传任务 + */ +export interface IUploadTask { + abort(): void; + onProgressUpdate(callback: (res: { + progress: number; + totalBytesSent: number; + totalBytesExpectedToSend: number; + }) => void): void; + offProgressUpdate(callback: Function): void; +} + +/** + * WebSocket 接口 + */ +export interface IPlatformWebSocket { + send(data: string | ArrayBuffer): void; + close(code?: number, reason?: string): void; + onOpen(callback: (res: { header: Record }) => void): void; + onClose(callback: (res: { code: number; reason: string }) => void): void; + onError(callback: (error: any) => void): void; + onMessage(callback: (res: { data: string | ArrayBuffer }) => void): void; +} + +/** + * 网络子系统接口 + */ +export interface IPlatformNetworkSubsystem { + /** + * 发起请求 + */ + request(config: RequestConfig): Promise>; + + /** + * 下载文件 + */ + downloadFile(options: { + url: string; + filePath?: string; + header?: Record; + timeout?: number; + }): Promise<{ tempFilePath: string; filePath?: string; statusCode: number }> & IDownloadTask; + + /** + * 上传文件 + */ + uploadFile(options: { + url: string; + filePath: string; + name: string; + header?: Record; + formData?: Record; + timeout?: number; + }): Promise<{ data: string; statusCode: number }> & IUploadTask; + + /** + * 创建 WebSocket 连接 + */ + connectSocket(options: { + url: string; + header?: Record; + protocols?: string[]; + timeout?: number; + }): IPlatformWebSocket; + + /** + * 获取网络类型 + */ + getNetworkType(): Promise<'wifi' | '2g' | '3g' | '4g' | '5g' | 'unknown' | 'none'>; + + /** + * 监听网络状态变化 + */ + onNetworkStatusChange(callback: (res: { + isConnected: boolean; + networkType: string; + }) => void): void; + + /** + * 取消监听网络状态变化 + */ + offNetworkStatusChange(callback: Function): void; +} + +// ============================================================================ +// 输入子系统 +// ============================================================================ + +/** + * 触摸点信息 + */ +export interface TouchInfo { + identifier: number; + x: number; + y: number; + force?: number; +} + +/** + * 触摸事件 + */ +export interface TouchEvent { + touches: TouchInfo[]; + changedTouches: TouchInfo[]; + timeStamp: number; +} + +/** + * 触摸事件处理函数 + */ +export type TouchHandler = (event: TouchEvent) => void; + +/** + * 输入子系统接口 + */ +export interface IPlatformInputSubsystem { + /** + * 监听触摸开始 + */ + onTouchStart(handler: TouchHandler): void; + + /** + * 监听触摸移动 + */ + onTouchMove(handler: TouchHandler): void; + + /** + * 监听触摸结束 + */ + onTouchEnd(handler: TouchHandler): void; + + /** + * 监听触摸取消 + */ + onTouchCancel(handler: TouchHandler): void; + + /** + * 取消监听触摸开始 + */ + offTouchStart(handler: TouchHandler): void; + + /** + * 取消监听触摸移动 + */ + offTouchMove(handler: TouchHandler): void; + + /** + * 取消监听触摸结束 + */ + offTouchEnd(handler: TouchHandler): void; + + /** + * 取消监听触摸取消 + */ + offTouchCancel(handler: TouchHandler): void; + + /** + * 获取触摸点是否支持压感 + */ + supportsPressure?(): boolean; +} + +// ============================================================================ +// 文件系统子系统 +// ============================================================================ + +/** + * 文件信息 + */ +export interface FileInfo { + size: number; + createTime: number; + modifyTime?: number; + isDirectory: boolean; + isFile: boolean; +} + +/** + * 文件系统子系统接口 + */ +export interface IPlatformFileSubsystem { + /** + * 读取文件 + */ + readFile(options: { + filePath: string; + encoding?: 'ascii' | 'base64' | 'binary' | 'hex' | 'ucs2' | 'utf-8' | 'utf8'; + position?: number; + length?: number; + }): Promise; + + /** + * 同步读取文件 + */ + readFileSync( + filePath: string, + encoding?: 'ascii' | 'base64' | 'binary' | 'hex' | 'ucs2' | 'utf-8' | 'utf8', + position?: number, + length?: number + ): string | ArrayBuffer; + + /** + * 写入文件 + */ + writeFile(options: { + filePath: string; + data: string | ArrayBuffer; + encoding?: 'ascii' | 'base64' | 'binary' | 'hex' | 'ucs2' | 'utf-8' | 'utf8'; + }): Promise; + + /** + * 同步写入文件 + */ + writeFileSync( + filePath: string, + data: string | ArrayBuffer, + encoding?: 'ascii' | 'base64' | 'binary' | 'hex' | 'ucs2' | 'utf-8' | 'utf8' + ): void; + + /** + * 追加文件内容 + */ + appendFile(options: { + filePath: string; + data: string | ArrayBuffer; + encoding?: 'ascii' | 'base64' | 'binary' | 'hex' | 'ucs2' | 'utf-8' | 'utf8'; + }): Promise; + + /** + * 删除文件 + */ + unlink(filePath: string): Promise; + + /** + * 创建目录 + */ + mkdir(options: { + dirPath: string; + recursive?: boolean; + }): Promise; + + /** + * 删除目录 + */ + rmdir(options: { + dirPath: string; + recursive?: boolean; + }): Promise; + + /** + * 读取目录 + */ + readdir(dirPath: string): Promise; + + /** + * 获取文件信息 + */ + stat(path: string): Promise; + + /** + * 检查文件/目录是否存在 + */ + access(path: string): Promise; + + /** + * 重命名文件 + */ + rename(oldPath: string, newPath: string): Promise; + + /** + * 复制文件 + */ + copyFile(srcPath: string, destPath: string): Promise; + + /** + * 获取用户数据目录路径 + */ + getUserDataPath(): string; + + /** + * 解压文件 + */ + unzip?(options: { + zipFilePath: string; + targetPath: string; + }): Promise; +} + +// ============================================================================ +// WASM 子系统 +// ============================================================================ + +/** + * WASM 模块导出 + */ +export type WASMExports = Record; + +/** + * WASM 导入值类型(兼容 Web 和小游戏平台) + */ +export type WASMImportValue = WebAssembly.ExportValue | number; + +/** + * WASM 模块导入 + */ +export type WASMImports = Record>; + +/** + * WASM 实例 + */ +export interface IWASMInstance { + exports: WASMExports; +} + +/** + * WASM 子系统接口 + */ +export interface IPlatformWASMSubsystem { + /** + * 实例化 WASM 模块 + * @param path WASM 文件路径 + * @param imports 导入对象 + */ + instantiate(path: string, imports?: WASMImports): Promise; + + /** + * 检查是否支持 WASM + */ + isSupported(): boolean; +} + +// ============================================================================ +// 系统信息 +// ============================================================================ + +/** + * 系统信息 + */ +export interface SystemInfo { + /** 设备品牌 */ + brand: string; + /** 设备型号 */ + model: string; + /** 设备像素比 */ + pixelRatio: number; + /** 屏幕宽度 */ + screenWidth: number; + /** 屏幕高度 */ + screenHeight: number; + /** 可使用窗口宽度 */ + windowWidth: number; + /** 可使用窗口高度 */ + windowHeight: number; + /** 状态栏高度 */ + statusBarHeight: number; + /** 操作系统及版本 */ + system: string; + /** 客户端平台 */ + platform: 'ios' | 'android' | 'windows' | 'mac' | 'devtools'; + /** 客户端基础库版本 */ + SDKVersion: string; + /** 设备性能等级 */ + benchmarkLevel: number; + /** 设备内存大小 (MB) */ + memorySize?: number; +} diff --git a/packages/platform-common/src/index.ts b/packages/platform-common/src/index.ts new file mode 100644 index 00000000..7ecbd62d --- /dev/null +++ b/packages/platform-common/src/index.ts @@ -0,0 +1,42 @@ +/** + * 平台通用接口定义包 + * @packageDocumentation + */ + +// 导出所有平台子系统接口 +export type { + // Canvas/渲染 + IPlatformCanvas, + IPlatformImage, + IPlatformCanvasSubsystem, + TempFilePathOptions, + CanvasContextAttributes, + // 音频 + IPlatformAudioContext, + IPlatformAudioSubsystem, + // 存储 + IPlatformStorageSubsystem, + StorageInfo, + // 网络 + IPlatformNetworkSubsystem, + RequestConfig, + RequestResponse, + IDownloadTask, + IUploadTask, + IPlatformWebSocket, + // 输入 + IPlatformInputSubsystem, + TouchInfo, + TouchEvent, + TouchHandler, + // 文件系统 + IPlatformFileSubsystem, + FileInfo, + // WASM + IPlatformWASMSubsystem, + IWASMInstance, + WASMExports, + WASMImports, + // 系统信息 + SystemInfo +} from './IPlatformSubsystems'; diff --git a/packages/platform-common/tsconfig.json b/packages/platform-common/tsconfig.json new file mode 100644 index 00000000..ddda78d4 --- /dev/null +++ b/packages/platform-common/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json new file mode 100644 index 00000000..0cfc254c --- /dev/null +++ b/packages/platform-web/package.json @@ -0,0 +1,54 @@ +{ + "name": "@esengine/platform-web", + "version": "1.0.0", + "description": "Web/H5 平台适配器", + "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", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "ecs", + "web", + "h5", + "platform", + "adapter" + ], + "author": "yhh", + "license": "MIT", + "peerDependencies": { + "@esengine/platform-common": "^1.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/platform-web" + } +} diff --git a/packages/platform-web/rollup.config.js b/packages/platform-web/rollup.config.js new file mode 100644 index 00000000..29a85fba --- /dev/null +++ b/packages/platform-web/rollup.config.js @@ -0,0 +1,42 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import typescript from '@rollup/plugin-typescript'; +import dts from 'rollup-plugin-dts'; + +const external = ['@esengine/platform-common']; + +export default [ + { + input: 'src/index.ts', + output: [ + { + file: 'dist/index.mjs', + format: 'esm', + sourcemap: true + }, + { + file: 'dist/index.js', + format: 'cjs', + sourcemap: true + } + ], + external, + plugins: [ + resolve(), + commonjs(), + typescript({ + tsconfig: './tsconfig.json', + declaration: false + }) + ] + }, + { + input: 'src/index.ts', + output: { + file: 'dist/index.d.ts', + format: 'esm' + }, + external, + plugins: [dts()] + } +]; diff --git a/packages/platform-web/src/EngineBridge.ts b/packages/platform-web/src/EngineBridge.ts new file mode 100644 index 00000000..0c810acf --- /dev/null +++ b/packages/platform-web/src/EngineBridge.ts @@ -0,0 +1,254 @@ +/** + * Rust 引擎桥接层 + * 负责在 Web 环境中初始化和管理 Rust WASM 引擎 + */ + +import type { IPlatformCanvas, CanvasContextAttributes } from '@esengine/platform-common'; +import { WebCanvasSubsystem } from './subsystems/WebCanvasSubsystem'; + +/** + * 引擎配置 + */ +export interface EngineBridgeConfig { + wasmPath: string; + canvasId?: string; + canvasWidth?: number; + canvasHeight?: number; + contextAttributes?: CanvasContextAttributes; +} + +/** + * GameEngine WASM 模块导出接口 + */ +interface GameEngineExports { + memory: WebAssembly.Memory; + new: (canvasIdPtr: number, canvasIdLen: number) => number; + fromExternal: (glContext: any, width: number, height: number) => any; + clear: (engine: any, r: number, g: number, b: number, a: number) => void; + render: (engine: any) => void; + width: (engine: any) => number; + height: (engine: any) => number; + submitSpriteBatch: ( + engine: any, + transforms: any, + textureIds: any, + uvs: any, + colors: any + ) => void; + loadTexture: (engine: any, id: number, urlPtr: number, urlLen: number) => void; + isKeyDown: (engine: any, keyCodePtr: number, keyCodeLen: number) => boolean; + updateInput: (engine: any) => void; +} + +/** + * 引擎桥接层 + * 将 Web 平台能力桥接到 Rust WASM 引擎 + */ +export class EngineBridge { + private _canvasSubsystem: WebCanvasSubsystem; + private _canvas: IPlatformCanvas; + private _gl: WebGL2RenderingContext | null = null; + private _wasmModule: WebAssembly.Module | null = null; + private _wasmInstance: WebAssembly.Instance | null = null; + private _gameEngine: any = null; + private _config: EngineBridgeConfig; + + constructor(config: EngineBridgeConfig) { + this._config = config; + this._canvasSubsystem = new WebCanvasSubsystem(); + + const width = config.canvasWidth ?? window.innerWidth; + const height = config.canvasHeight ?? window.innerHeight; + + if (config.canvasId) { + const existingCanvas = document.getElementById(config.canvasId) as HTMLCanvasElement; + if (existingCanvas) { + existingCanvas.width = width; + existingCanvas.height = height; + this._canvas = this._wrapExistingCanvas(existingCanvas); + } else { + this._canvas = this._canvasSubsystem.createCanvas(width, height); + } + } else { + this._canvas = this._canvasSubsystem.createCanvas(width, height); + } + } + + private _wrapExistingCanvas(canvas: HTMLCanvasElement): IPlatformCanvas { + return { + width: canvas.width, + height: canvas.height, + getContext: (type: string, attrs: any) => canvas.getContext(type, attrs as WebGLContextAttributes), + toDataURL: () => canvas.toDataURL(), + toTempFilePath: () => { + throw new Error('Not supported'); + } + } as IPlatformCanvas; + } + + /** + * 初始化引擎 + */ + async initialize(): Promise { + this._gl = this._getWebGLContext(); + if (!this._gl) { + throw new Error('无法获取 WebGL2 上下文'); + } + + const imports = this._createWASMImports(); + const response = await fetch(this._config.wasmPath); + const buffer = await response.arrayBuffer(); + + const result = await WebAssembly.instantiate(buffer, imports); + this._wasmModule = result.module; + this._wasmInstance = result.instance; + + const exports = this._wasmInstance.exports as unknown as GameEngineExports; + + if (typeof exports.fromExternal === 'function') { + this._gameEngine = exports.fromExternal( + this._gl, + this._canvas.width, + this._canvas.height + ); + } + } + + /** + * 获取 WebGL2 上下文 + */ + private _getWebGLContext(): WebGL2RenderingContext | null { + const attrs = this._config.contextAttributes ?? { + alpha: false, + antialias: false, + depth: false, + stencil: false, + premultipliedAlpha: true, + preserveDrawingBuffer: false + }; + + return this._canvas.getContext('webgl2', attrs) as WebGL2RenderingContext | null; + } + + /** + * 创建 WASM 导入对象 + */ + private _createWASMImports(): WebAssembly.Imports { + return { + env: { + memory: new WebAssembly.Memory({ initial: 256, maximum: 16384 }), + + platform_log: (ptr: number, len: number) => { + const message = this._readString(ptr, len); + console.log('[Engine]', message); + }, + + platform_error: (ptr: number, len: number) => { + const message = this._readString(ptr, len); + console.error('[Engine]', message); + }, + + platform_now: () => { + return performance.now(); + } + }, + wbg: {} + }; + } + + /** + * 从 WASM 内存读取字符串 + */ + private _readString(ptr: number, len: number): string { + if (!this._wasmInstance) return ''; + + const memory = this._wasmInstance.exports.memory as WebAssembly.Memory; + const bytes = new Uint8Array(memory.buffer, ptr, len); + return new TextDecoder().decode(bytes); + } + + /** + * 获取 Canvas + */ + get canvas(): IPlatformCanvas { + return this._canvas; + } + + /** + * 获取 WebGL 上下文 + */ + get gl(): WebGL2RenderingContext | null { + return this._gl; + } + + /** + * 获取 WASM 实例 + */ + get wasmInstance(): WebAssembly.Instance | null { + return this._wasmInstance; + } + + /** + * 获取 GameEngine 实例 + */ + get gameEngine(): any { + return this._gameEngine; + } + + /** + * 清屏 + */ + clear(r: number, g: number, b: number, a: number): void { + if (this._gl) { + this._gl.clearColor(r, g, b, a); + this._gl.clear(this._gl.COLOR_BUFFER_BIT); + } + } + + /** + * 渲染一帧 + */ + render(): void { + if (this._wasmInstance && this._gameEngine) { + const exports = this._wasmInstance.exports as unknown as GameEngineExports; + if (exports.render) { + exports.render(this._gameEngine); + } + } + } + + /** + * 获取画布宽度 + */ + get width(): number { + return this._canvas.width; + } + + /** + * 获取画布高度 + */ + get height(): number { + return this._canvas.height; + } + + /** + * 调整画布大小 + */ + resize(width: number, height: number): void { + this._canvas.width = width; + this._canvas.height = height; + if (this._gl) { + this._gl.viewport(0, 0, width, height); + } + } + + /** + * 销毁引擎 + */ + dispose(): void { + this._gameEngine = null; + this._wasmInstance = null; + this._wasmModule = null; + this._gl = null; + } +} diff --git a/packages/platform-web/src/index.ts b/packages/platform-web/src/index.ts new file mode 100644 index 00000000..4749077e --- /dev/null +++ b/packages/platform-web/src/index.ts @@ -0,0 +1,19 @@ +/** + * Web/H5 平台适配器包 + * @packageDocumentation + */ + +// 引擎桥接 +export { EngineBridge } from './EngineBridge'; +export type { EngineBridgeConfig } from './EngineBridge'; + +// 子系统 +export { WebCanvasSubsystem } from './subsystems/WebCanvasSubsystem'; +export { WebInputSubsystem } from './subsystems/WebInputSubsystem'; +export { WebStorageSubsystem } from './subsystems/WebStorageSubsystem'; +export { WebWASMSubsystem } from './subsystems/WebWASMSubsystem'; + +// 工具 +export function isWebPlatform(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined'; +} diff --git a/packages/platform-web/src/subsystems/WebCanvasSubsystem.ts b/packages/platform-web/src/subsystems/WebCanvasSubsystem.ts new file mode 100644 index 00000000..a6640a07 --- /dev/null +++ b/packages/platform-web/src/subsystems/WebCanvasSubsystem.ts @@ -0,0 +1,174 @@ +/** + * Web 平台 Canvas 子系统 + */ + +import type { + IPlatformCanvasSubsystem, + IPlatformCanvas, + IPlatformImage, + TempFilePathOptions, + CanvasContextAttributes +} from '@esengine/platform-common'; + +/** + * Web Canvas 包装 + */ +class WebCanvas implements IPlatformCanvas { + private _canvas: HTMLCanvasElement; + + constructor(canvas: HTMLCanvasElement) { + this._canvas = canvas; + } + + get width(): number { + return this._canvas.width; + } + + set width(value: number) { + this._canvas.width = value; + } + + get height(): number { + return this._canvas.height; + } + + set height(value: number) { + this._canvas.height = value; + } + + getContext( + contextType: '2d' | 'webgl' | 'webgl2', + contextAttributes?: CanvasContextAttributes + ): RenderingContext | null { + const attrs: WebGLContextAttributes | undefined = contextAttributes ? { + alpha: typeof contextAttributes.alpha === 'number' + ? contextAttributes.alpha > 0 + : contextAttributes.alpha, + antialias: contextAttributes.antialias, + depth: contextAttributes.depth, + stencil: contextAttributes.stencil, + premultipliedAlpha: contextAttributes.premultipliedAlpha, + preserveDrawingBuffer: contextAttributes.preserveDrawingBuffer, + failIfMajorPerformanceCaveat: contextAttributes.failIfMajorPerformanceCaveat, + powerPreference: contextAttributes.powerPreference + } : undefined; + return this._canvas.getContext(contextType, attrs); + } + + toDataURL(): string { + return this._canvas.toDataURL(); + } + + toTempFilePath(_options: TempFilePathOptions): void { + throw new Error('toTempFilePath is not supported on Web platform'); + } + + getNativeCanvas(): HTMLCanvasElement { + return this._canvas; + } +} + +/** + * Web Image 包装 + */ +class WebImage implements IPlatformImage { + private _image: HTMLImageElement; + + constructor() { + this._image = new Image(); + } + + get src(): string { + return this._image.src; + } + + set src(value: string) { + this._image.src = value; + } + + get width(): number { + return this._image.width; + } + + get height(): number { + return this._image.height; + } + + get onload(): (() => void) | null { + return this._image.onload as (() => void) | null; + } + + set onload(value: (() => void) | null) { + this._image.onload = value; + } + + get onerror(): ((error: any) => void) | null { + return this._image.onerror as ((error: any) => void) | null; + } + + set onerror(value: ((error: any) => void) | null) { + this._image.onerror = value; + } + + getNativeImage(): HTMLImageElement { + return this._image; + } +} + +/** + * Web 平台 Canvas 子系统实现 + */ +export class WebCanvasSubsystem implements IPlatformCanvasSubsystem { + private _mainCanvas: WebCanvas | null = null; + + createCanvas(width?: number, height?: number): IPlatformCanvas { + const canvas = document.createElement('canvas'); + + if (width !== undefined) { + canvas.width = width; + } + if (height !== undefined) { + canvas.height = height; + } + + const wrappedCanvas = new WebCanvas(canvas); + + if (!this._mainCanvas) { + this._mainCanvas = wrappedCanvas; + } + + return wrappedCanvas; + } + + createImage(): IPlatformImage { + return new WebImage(); + } + + createImageData(width: number, height: number): ImageData { + return new ImageData(width, height); + } + + getScreenWidth(): number { + return window.screen.width; + } + + getScreenHeight(): number { + return window.screen.height; + } + + getDevicePixelRatio(): number { + return window.devicePixelRatio || 1; + } + + getMainCanvas(): IPlatformCanvas | null { + return this._mainCanvas; + } + + getWindowWidth(): number { + return window.innerWidth; + } + + getWindowHeight(): number { + return window.innerHeight; + } +} diff --git a/packages/platform-web/src/subsystems/WebInputSubsystem.ts b/packages/platform-web/src/subsystems/WebInputSubsystem.ts new file mode 100644 index 00000000..7de56f9d --- /dev/null +++ b/packages/platform-web/src/subsystems/WebInputSubsystem.ts @@ -0,0 +1,102 @@ +/** + * Web 平台输入子系统 + */ + +import type { + IPlatformInputSubsystem, + TouchHandler, + TouchEvent +} from '@esengine/platform-common'; + +/** + * Web 平台输入子系统实现 + */ +export class WebInputSubsystem implements IPlatformInputSubsystem { + private _touchStartHandlers: Map void> = new Map(); + private _touchMoveHandlers: Map void> = new Map(); + private _touchEndHandlers: Map void> = new Map(); + private _touchCancelHandlers: Map void> = new Map(); + + onTouchStart(handler: TouchHandler): void { + const nativeHandler = (e: globalThis.TouchEvent) => { + handler(this.convertTouchEvent(e)); + }; + this._touchStartHandlers.set(handler, nativeHandler); + window.addEventListener('touchstart', nativeHandler); + } + + onTouchMove(handler: TouchHandler): void { + const nativeHandler = (e: globalThis.TouchEvent) => { + handler(this.convertTouchEvent(e)); + }; + this._touchMoveHandlers.set(handler, nativeHandler); + window.addEventListener('touchmove', nativeHandler); + } + + onTouchEnd(handler: TouchHandler): void { + const nativeHandler = (e: globalThis.TouchEvent) => { + handler(this.convertTouchEvent(e)); + }; + this._touchEndHandlers.set(handler, nativeHandler); + window.addEventListener('touchend', nativeHandler); + } + + onTouchCancel(handler: TouchHandler): void { + const nativeHandler = (e: globalThis.TouchEvent) => { + handler(this.convertTouchEvent(e)); + }; + this._touchCancelHandlers.set(handler, nativeHandler); + window.addEventListener('touchcancel', nativeHandler); + } + + offTouchStart(handler: TouchHandler): void { + const nativeHandler = this._touchStartHandlers.get(handler); + if (nativeHandler) { + window.removeEventListener('touchstart', nativeHandler); + this._touchStartHandlers.delete(handler); + } + } + + offTouchMove(handler: TouchHandler): void { + const nativeHandler = this._touchMoveHandlers.get(handler); + if (nativeHandler) { + window.removeEventListener('touchmove', nativeHandler); + this._touchMoveHandlers.delete(handler); + } + } + + offTouchEnd(handler: TouchHandler): void { + const nativeHandler = this._touchEndHandlers.get(handler); + if (nativeHandler) { + window.removeEventListener('touchend', nativeHandler); + this._touchEndHandlers.delete(handler); + } + } + + offTouchCancel(handler: TouchHandler): void { + const nativeHandler = this._touchCancelHandlers.get(handler); + if (nativeHandler) { + window.removeEventListener('touchcancel', nativeHandler); + this._touchCancelHandlers.delete(handler); + } + } + + supportsPressure(): boolean { + return 'force' in Touch.prototype; + } + + private convertTouchEvent(e: globalThis.TouchEvent): TouchEvent { + const convertTouch = (touch: globalThis.Touch) => ({ + identifier: touch.identifier, + x: touch.clientX, + y: touch.clientY, + force: (touch as any).force + }); + + return { + touches: Array.from(e.touches).map(convertTouch), + changedTouches: Array.from(e.changedTouches).map(convertTouch), + timeStamp: e.timeStamp + }; + } +} diff --git a/packages/platform-web/src/subsystems/WebStorageSubsystem.ts b/packages/platform-web/src/subsystems/WebStorageSubsystem.ts new file mode 100644 index 00000000..d3756195 --- /dev/null +++ b/packages/platform-web/src/subsystems/WebStorageSubsystem.ts @@ -0,0 +1,77 @@ +/** + * Web 平台存储子系统 + */ + +import type { + IPlatformStorageSubsystem, + StorageInfo +} from '@esengine/platform-common'; + +/** + * Web 平台存储子系统实现 + */ +export class WebStorageSubsystem implements IPlatformStorageSubsystem { + getStorageSync(key: string): T | undefined { + try { + const value = localStorage.getItem(key); + if (value === null) { + return undefined; + } + return JSON.parse(value) as T; + } catch { + return undefined; + } + } + + setStorageSync(key: string, value: T): void { + localStorage.setItem(key, JSON.stringify(value)); + } + + removeStorageSync(key: string): void { + localStorage.removeItem(key); + } + + clearStorageSync(): void { + localStorage.clear(); + } + + getStorageInfoSync(): StorageInfo { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key) { + keys.push(key); + } + } + + let currentSize = 0; + for (const key of keys) { + const value = localStorage.getItem(key); + if (value) { + currentSize += key.length + value.length; + } + } + + return { + keys, + currentSize: Math.ceil(currentSize / 1024), + limitSize: 5 * 1024 + }; + } + + async getStorage(key: string): Promise { + return this.getStorageSync(key); + } + + async setStorage(key: string, value: T): Promise { + this.setStorageSync(key, value); + } + + async removeStorage(key: string): Promise { + this.removeStorageSync(key); + } + + async clearStorage(): Promise { + this.clearStorageSync(); + } +} diff --git a/packages/platform-web/src/subsystems/WebWASMSubsystem.ts b/packages/platform-web/src/subsystems/WebWASMSubsystem.ts new file mode 100644 index 00000000..b4ca75db --- /dev/null +++ b/packages/platform-web/src/subsystems/WebWASMSubsystem.ts @@ -0,0 +1,44 @@ +/** + * Web 平台 WASM 子系统 + */ + +import type { + IPlatformWASMSubsystem, + IWASMInstance, + WASMImports, + WASMExports +} from '@esengine/platform-common'; + +/** + * Web 平台 WASM 子系统实现 + */ +export class WebWASMSubsystem implements IPlatformWASMSubsystem { + async instantiate(path: string, imports?: WASMImports): Promise { + const response = await fetch(path); + const buffer = await response.arrayBuffer(); + const result = await WebAssembly.instantiate(buffer, imports); + + return { + exports: result.instance.exports as WASMExports + }; + } + + isSupported(): boolean { + return typeof WebAssembly !== 'undefined'; + } + + createMemory(initial: number, maximum?: number): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial, + maximum + }); + } + + createTable(initial: number, maximum?: number): WebAssembly.Table { + return new WebAssembly.Table({ + element: 'anyfunc', + initial, + maximum + }); + } +} diff --git a/packages/platform-web/tsconfig.json b/packages/platform-web/tsconfig.json new file mode 100644 index 00000000..ddda78d4 --- /dev/null +++ b/packages/platform-web/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/platform-wechat/README.md b/packages/platform-wechat/README.md new file mode 100644 index 00000000..75a329a9 --- /dev/null +++ b/packages/platform-wechat/README.md @@ -0,0 +1,64 @@ +# @esengine/platform-wechat + +微信小游戏平台适配器,为 ECS Framework 提供微信小游戏环境支持。 + +## 安装 + +```bash +npm install @esengine/platform-wechat +``` + +## 使用 + +```typescript +import { PlatformManager } from '@esengine/ecs-framework'; +import { WeChatAdapter } from '@esengine/platform-wechat'; + +// 注册微信小游戏适配器 +const adapter = new WeChatAdapter(); +PlatformManager.getInstance().registerAdapter(adapter); + +// 使用子系统 +const canvas = adapter.canvas.createCanvas(); +const ctx = canvas.getContext('webgl'); + +// 加载 WASM 模块 +const instance = await adapter.wasm.instantiate('path/to/module.wasm'); +``` + +## 子系统 + +| 子系统 | 描述 | +|--------|------| +| `canvas` | Canvas 创建、WebGL 上下文 | +| `audio` | 音频播放、音量控制 | +| `storage` | 本地存储 | +| `network` | 网络请求、WebSocket | +| `input` | 触摸输入 | +| `file` | 文件系统操作 | +| `wasm` | WebAssembly 加载 | + +## 平台限制 + +- **SharedArrayBuffer**: 不支持 +- **Worker**: 支持,但有限制(需独立文件,最多 1 个) +- **eval**: 不支持 +- **WASM**: 支持,使用 `WXWebAssembly` + +## game.json 配置 + +```json +{ + "workers": "workers", + "subpackages": [ + { + "name": "wasm", + "root": "wasm/" + } + ] +} +``` + +## License + +MIT diff --git a/packages/platform-wechat/package.json b/packages/platform-wechat/package.json new file mode 100644 index 00000000..3810412a --- /dev/null +++ b/packages/platform-wechat/package.json @@ -0,0 +1,56 @@ +{ + "name": "@esengine/platform-wechat", + "version": "1.0.0", + "description": "微信小游戏平台适配器", + "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", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "ecs", + "wechat", + "minigame", + "platform", + "adapter" + ], + "author": "yhh", + "license": "MIT", + "peerDependencies": { + "@esengine/ecs-framework": "^2.0.0", + "@esengine/platform-common": "^1.0.0" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^28.0.3", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^11.1.6", + "minigame-api-typings": "^3.8.12", + "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/platform-wechat" + } +} diff --git a/packages/platform-wechat/rollup.config.js b/packages/platform-wechat/rollup.config.js new file mode 100644 index 00000000..75be6898 --- /dev/null +++ b/packages/platform-wechat/rollup.config.js @@ -0,0 +1,44 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import typescript from '@rollup/plugin-typescript'; +import dts from 'rollup-plugin-dts'; + +const external = ['@esengine/ecs-framework', '@esengine/platform-common']; + +export default [ + // ESM and CJS builds + { + input: 'src/index.ts', + output: [ + { + file: 'dist/index.mjs', + format: 'esm', + sourcemap: true + }, + { + file: 'dist/index.js', + format: 'cjs', + sourcemap: true + } + ], + external, + plugins: [ + resolve(), + commonjs(), + typescript({ + tsconfig: './tsconfig.json', + declaration: false + }) + ] + }, + // Type declarations + { + input: 'src/index.ts', + output: { + file: 'dist/index.d.ts', + format: 'esm' + }, + external, + plugins: [dts()] + } +]; diff --git a/packages/platform-wechat/src/EngineBridge.ts b/packages/platform-wechat/src/EngineBridge.ts new file mode 100644 index 00000000..32c33cdb --- /dev/null +++ b/packages/platform-wechat/src/EngineBridge.ts @@ -0,0 +1,235 @@ +/** + * Rust 引擎桥接层 + * 负责在微信小游戏环境中初始化和管理 Rust WASM 引擎 + */ + +import type { IPlatformCanvas } from '@esengine/platform-common'; +import { WeChatAdapter } from './WeChatAdapter'; + +/** + * 引擎配置 + */ +export interface EngineBridgeConfig { + wasmPath: string; + canvasWidth?: number; + canvasHeight?: number; + enableWebGL2?: boolean; +} + +/** + * 引擎桥接层 + * 将微信平台能力桥接到 Rust WASM 引擎 + */ +export class EngineBridge { + private _adapter: WeChatAdapter; + private _canvas: IPlatformCanvas; + private _gl: WebGLRenderingContext | WebGL2RenderingContext | null = null; + private _wasmInstance: any = null; + private _config: EngineBridgeConfig; + + constructor(adapter: WeChatAdapter, config: EngineBridgeConfig) { + this._adapter = adapter; + this._config = config; + + // 创建主 Canvas + const windowInfo = adapter.getSystemInfo(); + const width = config.canvasWidth ?? windowInfo.windowWidth; + const height = config.canvasHeight ?? windowInfo.windowHeight; + + this._canvas = adapter.canvas.createCanvas(width, height); + } + + /** + * 初始化引擎 + */ + async initialize(): Promise { + // 获取 WebGL 上下文 + this._gl = this._getWebGLContext(); + if (!this._gl) { + throw new Error('无法获取 WebGL 上下文'); + } + + // 加载 WASM 模块 + const imports = this._createWASMImports(); + this._wasmInstance = await this._adapter.wasm.instantiate( + this._config.wasmPath, + imports + ); + + // 初始化引擎 + if (this._wasmInstance.exports.init) { + this._wasmInstance.exports.init(); + } + } + + /** + * 获取 WebGL 上下文 + */ + private _getWebGLContext(): WebGLRenderingContext | WebGL2RenderingContext | null { + const contextType = this._config.enableWebGL2 ? 'webgl2' : 'webgl'; + const gl = this._canvas.getContext(contextType, { + alpha: false, + antialias: false, + depth: false, + stencil: false, + premultipliedAlpha: true, + preserveDrawingBuffer: false + }); + + return gl as WebGLRenderingContext | WebGL2RenderingContext | null; + } + + /** + * 创建 WASM 导入对象 + */ + private _createWASMImports(): Record> { + return { + env: { + // 内存 + memory: this._adapter.wasm.createMemory(256, 16384), + + // 平台桥接函数 + platform_log: (ptr: number, len: number) => { + const message = this._readString(ptr, len); + console.log('[Engine]', message); + }, + + platform_error: (ptr: number, len: number) => { + const message = this._readString(ptr, len); + console.error('[Engine]', message); + }, + + platform_now: () => { + return performance.now(); + }, + + // WebGL 桥接 + gl_bindBuffer: (target: number, buffer: number) => { + this._gl?.bindBuffer(target, this._getGLObject(buffer)); + }, + + gl_bufferData: (target: number, dataPtr: number, dataLen: number, usage: number) => { + const data = this._readBuffer(dataPtr, dataLen); + this._gl?.bufferData(target, data, usage); + }, + + gl_clear: (mask: number) => { + this._gl?.clear(mask); + }, + + gl_clearColor: (r: number, g: number, b: number, a: number) => { + this._gl?.clearColor(r, g, b, a); + }, + + gl_drawArrays: (mode: number, first: number, count: number) => { + this._gl?.drawArrays(mode, first, count); + }, + + gl_drawElements: (mode: number, count: number, type: number, offset: number) => { + this._gl?.drawElements(mode, count, type, offset); + }, + + gl_enable: (cap: number) => { + this._gl?.enable(cap); + }, + + gl_disable: (cap: number) => { + this._gl?.disable(cap); + }, + + gl_viewport: (x: number, y: number, width: number, height: number) => { + this._gl?.viewport(x, y, width, height); + } + } + }; + } + + /** + * 从 WASM 内存读取字符串 + */ + private _readString(ptr: number, len: number): string { + const memory = this._wasmInstance?.exports.memory as WebAssembly.Memory; + if (!memory) return ''; + + const bytes = new Uint8Array(memory.buffer, ptr, len); + return new TextDecoder().decode(bytes); + } + + /** + * 从 WASM 内存读取缓冲区 + */ + private _readBuffer(ptr: number, len: number): ArrayBuffer { + const memory = this._wasmInstance?.exports.memory as WebAssembly.Memory; + if (!memory) return new ArrayBuffer(0); + + return memory.buffer.slice(ptr, ptr + len); + } + + /** + * 获取 WebGL 对象(暂时简化实现) + */ + private _getGLObject(_id: number): WebGLBuffer | null { + // TODO: 实现 WebGL 对象管理 + return null; + } + + /** + * 获取 Canvas + */ + get canvas(): IPlatformCanvas { + return this._canvas; + } + + /** + * 获取 WebGL 上下文 + */ + get gl(): WebGLRenderingContext | WebGL2RenderingContext | null { + return this._gl; + } + + /** + * 获取 WASM 实例 + */ + get wasmInstance(): any { + return this._wasmInstance; + } + + /** + * 清屏 + */ + clear(r: number, g: number, b: number, a: number): void { + if (this._gl) { + this._gl.clearColor(r, g, b, a); + this._gl.clear(this._gl.COLOR_BUFFER_BIT); + } + } + + /** + * 渲染一帧 + */ + render(): void { + if (this._wasmInstance?.exports.render) { + this._wasmInstance.exports.render(); + } + } + + /** + * 更新逻辑 + */ + update(deltaTime: number): void { + if (this._wasmInstance?.exports.update) { + this._wasmInstance.exports.update(deltaTime); + } + } + + /** + * 销毁引擎 + */ + dispose(): void { + if (this._wasmInstance?.exports.dispose) { + this._wasmInstance.exports.dispose(); + } + this._wasmInstance = null; + this._gl = null; + } +} diff --git a/packages/platform-wechat/src/WeChatAdapter.ts b/packages/platform-wechat/src/WeChatAdapter.ts new file mode 100644 index 00000000..f2a36416 --- /dev/null +++ b/packages/platform-wechat/src/WeChatAdapter.ts @@ -0,0 +1,289 @@ +/** + * 微信小游戏平台适配器 + */ + +import type { + IPlatformAdapter, + PlatformWorker, + WorkerCreationOptions, + PlatformConfig +} from '@esengine/ecs-framework'; + +import type { SystemInfo } from '@esengine/platform-common'; + +import { WeChatCanvasSubsystem } from './subsystems/WeChatCanvasSubsystem'; +import { WeChatAudioSubsystem } from './subsystems/WeChatAudioSubsystem'; +import { WeChatStorageSubsystem } from './subsystems/WeChatStorageSubsystem'; +import { WeChatNetworkSubsystem } from './subsystems/WeChatNetworkSubsystem'; +import { WeChatInputSubsystem } from './subsystems/WeChatInputSubsystem'; +import { WeChatFileSubsystem } from './subsystems/WeChatFileSubsystem'; +import { WeChatWASMSubsystem } from './subsystems/WeChatWASMSubsystem'; +import { getWx, isWeChatMiniGame } from './utils'; + +/** + * 微信小游戏 Worker 包装 + */ +class WeChatWorker implements PlatformWorker { + private _worker: WechatMinigame.Worker; + private _state: 'running' | 'terminated' = 'running'; + + constructor(worker: WechatMinigame.Worker) { + this._worker = worker; + } + + get state(): 'running' | 'terminated' { + return this._state; + } + + postMessage(message: any, _transfer?: Transferable[]): void { + this._worker.postMessage(message); + } + + onMessage(handler: (event: { data: any }) => void): void { + this._worker.onMessage((res) => { + handler({ data: res }); + }); + } + + onError(handler: (error: ErrorEvent) => void): void { + this._worker.onError((error) => { + handler(error as unknown as ErrorEvent); + }); + } + + terminate(): void { + this._worker.terminate(); + this._state = 'terminated'; + } +} + +/** + * 微信小游戏平台适配器 + */ +export class WeChatAdapter implements IPlatformAdapter { + readonly name = 'wechat-minigame'; + readonly version: string; + + // 子系统实例 + private _canvas: WeChatCanvasSubsystem | null = null; + private _audio: WeChatAudioSubsystem | null = null; + private _storage: WeChatStorageSubsystem | null = null; + private _network: WeChatNetworkSubsystem | null = null; + private _input: WeChatInputSubsystem | null = null; + private _file: WeChatFileSubsystem | null = null; + private _wasm: WeChatWASMSubsystem | null = null; + + private _deviceInfo: WechatMinigame.DeviceInfo | null = null; + private _windowInfo: WechatMinigame.WindowInfo | null = null; + private _appBaseInfo: WechatMinigame.AppBaseInfo | null = null; + + constructor() { + if (!isWeChatMiniGame()) { + throw new Error('当前环境不是微信小游戏环境'); + } + + // 使用新的分离 API 获取系统信息 + const wxApi = getWx(); + this._deviceInfo = wxApi.getDeviceInfo(); + this._windowInfo = wxApi.getWindowInfo(); + this._appBaseInfo = wxApi.getAppBaseInfo(); + this.version = this._appBaseInfo.SDKVersion; + } + + // ======================================================================== + // IPlatformAdapter 基础实现 + // ======================================================================== + + isWorkerSupported(): boolean { + // 微信小游戏支持 Worker,但有限制 + return typeof getWx().createWorker === 'function'; + } + + isSharedArrayBufferSupported(): boolean { + // 微信小游戏不支持 SharedArrayBuffer + return false; + } + + getHardwareConcurrency(): number { + // 微信小游戏无法获取真实核心数,返回保守值 + return 2; + } + + createWorker(script: string, options?: WorkerCreationOptions): PlatformWorker { + // 微信小游戏 Worker 需要指定文件路径,不支持内联脚本 + // script 参数应该是 worker 文件的路径 + const worker = getWx().createWorker(script, { + useExperimentalWorker: true + }); + + return new WeChatWorker(worker); + } + + createSharedArrayBuffer(_length: number): SharedArrayBuffer | null { + // 微信小游戏不支持 SharedArrayBuffer + return null; + } + + getHighResTimestamp(): number { + return Date.now(); + } + + getPlatformConfig(): PlatformConfig { + return { + maxWorkerCount: 1, + supportsModuleWorker: false, + supportsTransferableObjects: true, + maxSharedArrayBufferSize: 0, + workerScriptPrefix: '', + limitations: { + noEval: true, + requiresWorkerInit: true, + memoryLimit: this._deviceInfo?.memorySize + ? parseInt(String(this._deviceInfo.memorySize)) * 1024 * 1024 + : 256 * 1024 * 1024, + workerNotSupported: false, + workerLimitations: [ + 'Worker 必须使用独立文件,不支持内联脚本', + '仅支持 1 个 Worker 实例', + '不支持 SharedArrayBuffer', + 'Worker 文件需要在 game.json 中配置' + ] + }, + extensions: { + platform: 'wechat-minigame', + sdkVersion: this._appBaseInfo?.SDKVersion + } + }; + } + + async getPlatformConfigAsync(): Promise { + return this.getPlatformConfig(); + } + + // ======================================================================== + // 子系统访问器 + // ======================================================================== + + /** + * 获取 Canvas 子系统 + */ + get canvas(): WeChatCanvasSubsystem { + if (!this._canvas) { + this._canvas = new WeChatCanvasSubsystem(); + } + return this._canvas; + } + + /** + * 获取音频子系统 + */ + get audio(): WeChatAudioSubsystem { + if (!this._audio) { + this._audio = new WeChatAudioSubsystem(); + } + return this._audio; + } + + /** + * 获取存储子系统 + */ + get storage(): WeChatStorageSubsystem { + if (!this._storage) { + this._storage = new WeChatStorageSubsystem(); + } + return this._storage; + } + + /** + * 获取网络子系统 + */ + get network(): WeChatNetworkSubsystem { + if (!this._network) { + this._network = new WeChatNetworkSubsystem(); + } + return this._network; + } + + /** + * 获取输入子系统 + */ + get input(): WeChatInputSubsystem { + if (!this._input) { + this._input = new WeChatInputSubsystem(); + } + return this._input; + } + + /** + * 获取文件系统子系统 + */ + get file(): WeChatFileSubsystem { + if (!this._file) { + this._file = new WeChatFileSubsystem(); + } + return this._file; + } + + /** + * 获取 WASM 子系统 + */ + get wasm(): WeChatWASMSubsystem { + if (!this._wasm) { + this._wasm = new WeChatWASMSubsystem(); + } + return this._wasm; + } + + // ======================================================================== + // 系统信息 + // ======================================================================== + + /** + * 获取系统信息 + */ + getSystemInfo(): SystemInfo { + const device = this._deviceInfo!; + const window = this._windowInfo!; + const app = this._appBaseInfo!; + + return { + brand: device.brand, + model: device.model, + pixelRatio: window.pixelRatio, + screenWidth: window.screenWidth, + screenHeight: window.screenHeight, + windowWidth: window.windowWidth, + windowHeight: window.windowHeight, + statusBarHeight: window.statusBarHeight || 0, + system: device.system, + platform: device.platform as SystemInfo['platform'], + SDKVersion: app.SDKVersion, + benchmarkLevel: device.benchmarkLevel || 0, + memorySize: device.memorySize ? parseInt(String(device.memorySize)) : undefined + }; + } + + /** + * 比较基础库版本 + */ + compareVersion(v1: string, v2: string): number { + const a1 = v1.split('.').map(Number); + const a2 = v2.split('.').map(Number); + const len = Math.max(a1.length, a2.length); + + for (let i = 0; i < len; i++) { + const n1 = a1[i] || 0; + const n2 = a2[i] || 0; + if (n1 > n2) return 1; + if (n1 < n2) return -1; + } + return 0; + } + + /** + * 检查是否支持某个 API + */ + canIUse(schema: string): boolean { + return getWx().canIUse(schema); + } +} diff --git a/packages/platform-wechat/src/index.ts b/packages/platform-wechat/src/index.ts new file mode 100644 index 00000000..e890cb16 --- /dev/null +++ b/packages/platform-wechat/src/index.ts @@ -0,0 +1,23 @@ +/** + * 微信小游戏平台适配器包 + * @packageDocumentation + */ + +// 主适配器 +export { WeChatAdapter } from './WeChatAdapter'; + +// 引擎桥接 +export { EngineBridge } from './EngineBridge'; +export type { EngineBridgeConfig } from './EngineBridge'; + +// 子系统 +export { WeChatCanvasSubsystem } from './subsystems/WeChatCanvasSubsystem'; +export { WeChatAudioSubsystem } from './subsystems/WeChatAudioSubsystem'; +export { WeChatStorageSubsystem } from './subsystems/WeChatStorageSubsystem'; +export { WeChatNetworkSubsystem } from './subsystems/WeChatNetworkSubsystem'; +export { WeChatInputSubsystem } from './subsystems/WeChatInputSubsystem'; +export { WeChatFileSubsystem } from './subsystems/WeChatFileSubsystem'; +export { WeChatWASMSubsystem } from './subsystems/WeChatWASMSubsystem'; + +// 工具 +export { getWx, isWeChatMiniGame } from './utils'; diff --git a/packages/platform-wechat/src/subsystems/WeChatAudioSubsystem.ts b/packages/platform-wechat/src/subsystems/WeChatAudioSubsystem.ts new file mode 100644 index 00000000..fea56265 --- /dev/null +++ b/packages/platform-wechat/src/subsystems/WeChatAudioSubsystem.ts @@ -0,0 +1,88 @@ +/** + * 微信小游戏音频子系统 + */ + +import type { + IPlatformAudioSubsystem, + IPlatformAudioContext +} from '@esengine/platform-common'; +import { getWx, promisify } from '../utils'; + +/** + * 微信音频上下文包装 + */ +class WeChatAudioContext implements IPlatformAudioContext { + private _ctx: WechatMinigame.InnerAudioContext; + + constructor(ctx: WechatMinigame.InnerAudioContext) { + this._ctx = ctx; + } + + get src(): string { return this._ctx.src; } + set src(value: string) { this._ctx.src = value; } + + get autoplay(): boolean { return this._ctx.autoplay; } + set autoplay(value: boolean) { this._ctx.autoplay = value; } + + get loop(): boolean { return this._ctx.loop; } + set loop(value: boolean) { this._ctx.loop = value; } + + get volume(): number { return this._ctx.volume; } + set volume(value: number) { this._ctx.volume = value; } + + get duration(): number { return this._ctx.duration; } + get currentTime(): number { return this._ctx.currentTime; } + get paused(): boolean { return this._ctx.paused; } + get buffered(): number { return this._ctx.buffered; } + + play(): void { this._ctx.play(); } + pause(): void { this._ctx.pause(); } + stop(): void { this._ctx.stop(); } + seek(position: number): void { this._ctx.seek(position); } + destroy(): void { this._ctx.destroy(); } + + onPlay(callback: () => void): void { this._ctx.onPlay(callback); } + onPause(callback: () => void): void { this._ctx.onPause(callback); } + onStop(callback: () => void): void { this._ctx.onStop(callback); } + onEnded(callback: () => void): void { this._ctx.onEnded(callback); } + onError(callback: (error: { errCode: number; errMsg: string }) => void): void { + this._ctx.onError(callback as any); + } + onTimeUpdate(callback: () => void): void { this._ctx.onTimeUpdate(callback); } + onCanplay(callback: () => void): void { this._ctx.onCanplay(callback); } + onSeeking(callback: () => void): void { this._ctx.onSeeking(callback); } + onSeeked(callback: () => void): void { this._ctx.onSeeked(callback); } + + offPlay(callback: () => void): void { this._ctx.offPlay(callback); } + offPause(callback: () => void): void { this._ctx.offPause(callback); } + offStop(callback: () => void): void { this._ctx.offStop(callback); } + offEnded(callback: () => void): void { this._ctx.offEnded(callback); } + offError(callback: (error: { errCode: number; errMsg: string }) => void): void { + this._ctx.offError(callback as any); + } + offTimeUpdate(callback: () => void): void { this._ctx.offTimeUpdate(callback); } +} + +/** + * 微信小游戏音频子系统实现 + */ +export class WeChatAudioSubsystem implements IPlatformAudioSubsystem { + createAudioContext(_options?: { useWebAudioImplement?: boolean }): IPlatformAudioContext { + const ctx = getWx().createInnerAudioContext({ + useWebAudioImplement: _options?.useWebAudioImplement + }); + return new WeChatAudioContext(ctx); + } + + getSupportedFormats(): string[] { + return ['mp3', 'wav', 'aac', 'm4a']; + } + + async setInnerAudioOption(options: { + mixWithOther?: boolean; + obeyMuteSwitch?: boolean; + speakerOn?: boolean; + }): Promise { + return promisify(getWx().setInnerAudioOption.bind(getWx()), options); + } +} diff --git a/packages/platform-wechat/src/subsystems/WeChatCanvasSubsystem.ts b/packages/platform-wechat/src/subsystems/WeChatCanvasSubsystem.ts new file mode 100644 index 00000000..e46f0314 --- /dev/null +++ b/packages/platform-wechat/src/subsystems/WeChatCanvasSubsystem.ts @@ -0,0 +1,209 @@ +/** + * 微信小游戏 Canvas 子系统 + */ + +import type { + IPlatformCanvasSubsystem, + IPlatformCanvas, + IPlatformImage, + TempFilePathOptions, + CanvasContextAttributes +} from '@esengine/platform-common'; +import { getWx } from '../utils'; + +/** + * 微信小游戏 Canvas 包装 + */ +class WeChatCanvas implements IPlatformCanvas { + private _canvas: WechatMinigame.Canvas; + + constructor(canvas: WechatMinigame.Canvas) { + this._canvas = canvas; + } + + get width(): number { + return this._canvas.width; + } + + set width(value: number) { + this._canvas.width = value; + } + + get height(): number { + return this._canvas.height; + } + + set height(value: number) { + this._canvas.height = value; + } + + getContext( + contextType: '2d' | 'webgl' | 'webgl2', + contextAttributes?: CanvasContextAttributes + ): RenderingContext | null { + const wxAttributes: WechatMinigame.ContextAttributes | undefined = contextAttributes ? { + alpha: typeof contextAttributes.alpha === 'boolean' + ? (contextAttributes.alpha ? 1 : 0) + : contextAttributes.alpha, + antialias: contextAttributes.antialias, + preserveDrawingBuffer: contextAttributes.preserveDrawingBuffer, + antialiasSamples: contextAttributes.antialiasSamples + } : undefined; + return this._canvas.getContext(contextType, wxAttributes); + } + + toDataURL(): string { + return this._canvas.toDataURL(); + } + + toTempFilePath(options: TempFilePathOptions): void { + this._canvas.toTempFilePath({ + x: options.x, + y: options.y, + width: options.width, + height: options.height, + destWidth: options.destWidth, + destHeight: options.destHeight, + fileType: options.fileType, + quality: options.quality, + success: options.success, + fail: options.fail, + complete: options.complete + }); + } + + /** + * 获取原始微信 Canvas 对象 + */ + getNativeCanvas(): WechatMinigame.Canvas { + return this._canvas; + } +} + +/** + * 微信小游戏 Image 包装 + */ +class WeChatImage implements IPlatformImage { + private _image: WechatMinigame.Image; + + constructor(image: WechatMinigame.Image) { + this._image = image; + } + + get src(): string { + return this._image.src; + } + + set src(value: string) { + this._image.src = value; + } + + get width(): number { + return this._image.width; + } + + get height(): number { + return this._image.height; + } + + get onload(): (() => void) | null { + return this._image.onload as (() => void) | null; + } + + set onload(value: (() => void) | null) { + this._image.onload = value as any; + } + + get onerror(): ((error: any) => void) | null { + return this._image.onerror as ((error: any) => void) | null; + } + + set onerror(value: ((error: any) => void) | null) { + this._image.onerror = value as any; + } + + /** + * 获取原始微信 Image 对象 + */ + getNativeImage(): WechatMinigame.Image { + return this._image; + } +} + +/** + * 微信小游戏 Canvas 子系统实现 + */ +export class WeChatCanvasSubsystem implements IPlatformCanvasSubsystem { + private _mainCanvas: WeChatCanvas | null = null; + private _windowInfo: WechatMinigame.WindowInfo; + + constructor() { + this._windowInfo = getWx().getWindowInfo(); + } + + createCanvas(width?: number, height?: number): IPlatformCanvas { + const canvas = getWx().createCanvas(); + + // 设置尺寸 + if (width !== undefined) { + canvas.width = width; + } + if (height !== undefined) { + canvas.height = height; + } + + const wrappedCanvas = new WeChatCanvas(canvas); + + // 首次创建的是主 Canvas + if (!this._mainCanvas) { + this._mainCanvas = wrappedCanvas; + } + + return wrappedCanvas; + } + + createImage(): IPlatformImage { + const image = getWx().createImage(); + return new WeChatImage(image); + } + + createImageData(width: number, height: number): ImageData { + // 微信小游戏 3.4.10+ 支持 createImageData + if (typeof getWx().createImageData === 'function') { + return getWx().createImageData(width, height) as unknown as ImageData; + } + + // 降级方案:创建标准 ImageData + const data = new Uint8ClampedArray(width * height * 4); + return { + data, + width, + height, + colorSpace: 'srgb' + } as ImageData; + } + + getScreenWidth(): number { + return this._windowInfo.screenWidth; + } + + getScreenHeight(): number { + return this._windowInfo.screenHeight; + } + + getDevicePixelRatio(): number { + return this._windowInfo.pixelRatio; + } + + getMainCanvas(): IPlatformCanvas | null { + return this._mainCanvas; + } + + getWindowWidth(): number { + return this._windowInfo.windowWidth; + } + + getWindowHeight(): number { + return this._windowInfo.windowHeight; + } +} diff --git a/packages/platform-wechat/src/subsystems/WeChatFileSubsystem.ts b/packages/platform-wechat/src/subsystems/WeChatFileSubsystem.ts new file mode 100644 index 00000000..64316e92 --- /dev/null +++ b/packages/platform-wechat/src/subsystems/WeChatFileSubsystem.ts @@ -0,0 +1,204 @@ +/** + * 微信小游戏文件系统子系统 + */ + +import type { + IPlatformFileSubsystem, + FileInfo +} from '@esengine/platform-common'; +import { getWx } from '../utils'; + +/** + * 微信小游戏文件系统子系统实现 + */ +export class WeChatFileSubsystem implements IPlatformFileSubsystem { + private _fs: WechatMinigame.FileSystemManager; + + constructor() { + this._fs = getWx().getFileSystemManager(); + } + + async readFile(options: { + filePath: string; + encoding?: 'ascii' | 'base64' | 'binary' | 'hex' | 'ucs2' | 'utf-8' | 'utf8'; + position?: number; + length?: number; + }): Promise { + return new Promise((resolve, reject) => { + this._fs.readFile({ + filePath: options.filePath, + encoding: options.encoding as any, + position: options.position, + length: options.length, + success: (res) => resolve(res.data), + fail: reject + }); + }); + } + + readFileSync( + filePath: string, + encoding?: 'ascii' | 'base64' | 'binary' | 'hex' | 'ucs2' | 'utf-8' | 'utf8', + position?: number, + length?: number + ): string | ArrayBuffer { + return this._fs.readFileSync(filePath, encoding as any, position, length); + } + + async writeFile(options: { + filePath: string; + data: string | ArrayBuffer; + encoding?: 'ascii' | 'base64' | 'binary' | 'hex' | 'ucs2' | 'utf-8' | 'utf8'; + }): Promise { + return new Promise((resolve, reject) => { + this._fs.writeFile({ + filePath: options.filePath, + data: options.data, + encoding: options.encoding as any, + success: () => resolve(), + fail: reject + }); + }); + } + + writeFileSync( + filePath: string, + data: string | ArrayBuffer, + encoding?: 'ascii' | 'base64' | 'binary' | 'hex' | 'ucs2' | 'utf-8' | 'utf8' + ): void { + this._fs.writeFileSync(filePath, data, encoding as any); + } + + async appendFile(options: { + filePath: string; + data: string | ArrayBuffer; + encoding?: 'ascii' | 'base64' | 'binary' | 'hex' | 'ucs2' | 'utf-8' | 'utf8'; + }): Promise { + return new Promise((resolve, reject) => { + this._fs.appendFile({ + filePath: options.filePath, + data: options.data, + encoding: options.encoding as any, + success: () => resolve(), + fail: reject + }); + }); + } + + async unlink(filePath: string): Promise { + return new Promise((resolve, reject) => { + this._fs.unlink({ + filePath, + success: () => resolve(), + fail: reject + }); + }); + } + + async mkdir(options: { + dirPath: string; + recursive?: boolean; + }): Promise { + return new Promise((resolve, reject) => { + this._fs.mkdir({ + dirPath: options.dirPath, + recursive: options.recursive, + success: () => resolve(), + fail: reject + }); + }); + } + + async rmdir(options: { + dirPath: string; + recursive?: boolean; + }): Promise { + return new Promise((resolve, reject) => { + this._fs.rmdir({ + dirPath: options.dirPath, + recursive: options.recursive, + success: () => resolve(), + fail: reject + }); + }); + } + + async readdir(dirPath: string): Promise { + return new Promise((resolve, reject) => { + this._fs.readdir({ + dirPath, + success: (res) => resolve(res.files), + fail: reject + }); + }); + } + + async stat(path: string): Promise { + return new Promise((resolve, reject) => { + this._fs.stat({ + path, + success: (res) => { + const stats = res.stats as WechatMinigame.Stats; + resolve({ + size: stats.size, + createTime: stats.lastAccessedTime, + modifyTime: stats.lastModifiedTime, + isDirectory: stats.isDirectory(), + isFile: stats.isFile() + }); + }, + fail: reject + }); + }); + } + + async access(path: string): Promise { + return new Promise((resolve, reject) => { + this._fs.access({ + path, + success: () => resolve(), + fail: reject + }); + }); + } + + async rename(oldPath: string, newPath: string): Promise { + return new Promise((resolve, reject) => { + this._fs.rename({ + oldPath, + newPath, + success: () => resolve(), + fail: reject + }); + }); + } + + async copyFile(srcPath: string, destPath: string): Promise { + return new Promise((resolve, reject) => { + this._fs.copyFile({ + srcPath, + destPath, + success: () => resolve(), + fail: reject + }); + }); + } + + getUserDataPath(): string { + return `${getWx().env.USER_DATA_PATH}`; + } + + async unzip(options: { + zipFilePath: string; + targetPath: string; + }): Promise { + return new Promise((resolve, reject) => { + this._fs.unzip({ + zipFilePath: options.zipFilePath, + targetPath: options.targetPath, + success: () => resolve(), + fail: reject + }); + }); + } +} diff --git a/packages/platform-wechat/src/subsystems/WeChatInputSubsystem.ts b/packages/platform-wechat/src/subsystems/WeChatInputSubsystem.ts new file mode 100644 index 00000000..0fec4322 --- /dev/null +++ b/packages/platform-wechat/src/subsystems/WeChatInputSubsystem.ts @@ -0,0 +1,77 @@ +/** + * 微信小游戏输入子系统 + */ + +import type { + IPlatformInputSubsystem, + TouchHandler, + TouchEvent +} from '@esengine/platform-common'; +import { getWx } from '../utils'; + +/** + * 微信小游戏输入子系统实现 + */ +export class WeChatInputSubsystem implements IPlatformInputSubsystem { + onTouchStart(handler: TouchHandler): void { + getWx().onTouchStart((res) => { + handler(this.convertTouchEvent(res)); + }); + } + + onTouchMove(handler: TouchHandler): void { + getWx().onTouchMove((res) => { + handler(this.convertTouchEvent(res)); + }); + } + + onTouchEnd(handler: TouchHandler): void { + getWx().onTouchEnd((res) => { + handler(this.convertTouchEvent(res)); + }); + } + + onTouchCancel(handler: TouchHandler): void { + getWx().onTouchCancel((res) => { + handler(this.convertTouchEvent(res)); + }); + } + + offTouchStart(handler: TouchHandler): void { + getWx().offTouchStart(handler as any); + } + + offTouchMove(handler: TouchHandler): void { + getWx().offTouchMove(handler as any); + } + + offTouchEnd(handler: TouchHandler): void { + getWx().offTouchEnd(handler as any); + } + + offTouchCancel(handler: TouchHandler): void { + getWx().offTouchCancel(handler as any); + } + + supportsPressure(): boolean { + return true; + } + + private convertTouchEvent(res: WechatMinigame.OnTouchStartListenerResult): TouchEvent { + return { + touches: res.touches.map((t: WechatMinigame.Touch) => ({ + identifier: t.identifier, + x: t.clientX, + y: t.clientY, + force: t.force + })), + changedTouches: res.changedTouches.map((t: WechatMinigame.Touch) => ({ + identifier: t.identifier, + x: t.clientX, + y: t.clientY, + force: t.force + })), + timeStamp: res.timeStamp + }; + } +} diff --git a/packages/platform-wechat/src/subsystems/WeChatNetworkSubsystem.ts b/packages/platform-wechat/src/subsystems/WeChatNetworkSubsystem.ts new file mode 100644 index 00000000..d77f7dd0 --- /dev/null +++ b/packages/platform-wechat/src/subsystems/WeChatNetworkSubsystem.ts @@ -0,0 +1,181 @@ +/** + * 微信小游戏网络子系统 + */ + +import type { + IPlatformNetworkSubsystem, + RequestConfig, + RequestResponse, + IDownloadTask, + IUploadTask, + IPlatformWebSocket +} from '@esengine/platform-common'; +import { getWx, promisify } from '../utils'; + +/** + * 微信 WebSocket 包装 + */ +class WeChatWebSocket implements IPlatformWebSocket { + private _task: WechatMinigame.SocketTask; + + constructor(task: WechatMinigame.SocketTask) { + this._task = task; + } + + send(data: string | ArrayBuffer): void { + this._task.send({ data }); + } + + close(code?: number, reason?: string): void { + this._task.close({ code, reason }); + } + + onOpen(callback: (res: { header: Record }) => void): void { + this._task.onOpen(callback as any); + } + + onClose(callback: (res: { code: number; reason: string }) => void): void { + this._task.onClose(callback as any); + } + + onError(callback: (error: any) => void): void { + this._task.onError(callback); + } + + onMessage(callback: (res: { data: string | ArrayBuffer }) => void): void { + this._task.onMessage(callback as any); + } +} + +/** + * 微信小游戏网络子系统实现 + */ +export class WeChatNetworkSubsystem implements IPlatformNetworkSubsystem { + async request(config: RequestConfig): Promise> { + return new Promise((resolve, reject) => { + getWx().request({ + url: config.url, + method: config.method as any, + data: config.data, + header: config.header, + timeout: config.timeout, + dataType: config.dataType as any, + responseType: config.responseType as any, + success: (res) => { + resolve({ + data: res.data as T, + statusCode: res.statusCode, + header: res.header as Record + }); + }, + fail: reject + }); + }); + } + + downloadFile(options: { + url: string; + filePath?: string; + header?: Record; + timeout?: number; + }): Promise<{ tempFilePath: string; filePath?: string; statusCode: number }> & IDownloadTask { + const task = getWx().downloadFile({ + url: options.url, + filePath: options.filePath, + header: options.header, + timeout: options.timeout, + success: () => {}, + fail: () => {} + }); + + const promise = new Promise<{ tempFilePath: string; filePath?: string; statusCode: number }>((resolve, reject) => { + task.onProgressUpdate(() => {}); + getWx().downloadFile({ + ...options, + success: (res) => resolve({ + tempFilePath: res.tempFilePath, + filePath: res.filePath, + statusCode: res.statusCode + }), + fail: reject + }); + }); + + return Object.assign(promise, { + abort: () => task.abort(), + onProgressUpdate: (callback: any) => task.onProgressUpdate(callback), + offProgressUpdate: (callback: any) => task.offProgressUpdate(callback) + }); + } + + uploadFile(options: { + url: string; + filePath: string; + name: string; + header?: Record; + formData?: Record; + timeout?: number; + }): Promise<{ data: string; statusCode: number }> & IUploadTask { + const task = getWx().uploadFile({ + url: options.url, + filePath: options.filePath, + name: options.name, + header: options.header, + formData: options.formData, + timeout: options.timeout, + success: () => {}, + fail: () => {} + }); + + const promise = new Promise<{ data: string; statusCode: number }>((resolve, reject) => { + getWx().uploadFile({ + ...options, + success: (res) => resolve({ + data: res.data, + statusCode: res.statusCode + }), + fail: reject + }); + }); + + return Object.assign(promise, { + abort: () => task.abort(), + onProgressUpdate: (callback: any) => task.onProgressUpdate(callback), + offProgressUpdate: (callback: any) => task.offProgressUpdate(callback) + }); + } + + connectSocket(options: { + url: string; + header?: Record; + protocols?: string[]; + timeout?: number; + }): IPlatformWebSocket { + const task = getWx().connectSocket({ + url: options.url, + header: options.header, + protocols: options.protocols, + timeout: options.timeout + }); + return new WeChatWebSocket(task); + } + + async getNetworkType(): Promise<'wifi' | '2g' | '3g' | '4g' | '5g' | 'unknown' | 'none'> { + const res = await promisify<{ networkType: string }>( + getWx().getNetworkType.bind(getWx()), + {} + ); + return res.networkType as any; + } + + onNetworkStatusChange(callback: (res: { + isConnected: boolean; + networkType: string; + }) => void): void { + getWx().onNetworkStatusChange(callback); + } + + offNetworkStatusChange(callback: Function): void { + getWx().offNetworkStatusChange(callback as any); + } +} diff --git a/packages/platform-wechat/src/subsystems/WeChatStorageSubsystem.ts b/packages/platform-wechat/src/subsystems/WeChatStorageSubsystem.ts new file mode 100644 index 00000000..b54e1ddf --- /dev/null +++ b/packages/platform-wechat/src/subsystems/WeChatStorageSubsystem.ts @@ -0,0 +1,67 @@ +/** + * 微信小游戏存储子系统 + */ + +import type { + IPlatformStorageSubsystem, + StorageInfo +} from '@esengine/platform-common'; +import { getWx, promisify } from '../utils'; + +/** + * 微信小游戏存储子系统实现 + */ +export class WeChatStorageSubsystem implements IPlatformStorageSubsystem { + getStorageSync(key: string): T | undefined { + try { + return getWx().getStorageSync(key); + } catch { + return undefined; + } + } + + setStorageSync(key: string, value: T): void { + getWx().setStorageSync(key, value); + } + + removeStorageSync(key: string): void { + getWx().removeStorageSync(key); + } + + clearStorageSync(): void { + getWx().clearStorageSync(); + } + + getStorageInfoSync(): StorageInfo { + const info = getWx().getStorageInfoSync(); + return { + keys: info.keys, + currentSize: info.currentSize, + limitSize: info.limitSize + }; + } + + async getStorage(key: string): Promise { + try { + const res = await promisify<{ data: T }>( + getWx().getStorage.bind(getWx()), + { key } + ); + return res.data; + } catch { + return undefined; + } + } + + async setStorage(key: string, value: T): Promise { + await promisify(getWx().setStorage.bind(getWx()), { key, data: value }); + } + + async removeStorage(key: string): Promise { + await promisify(getWx().removeStorage.bind(getWx()), { key }); + } + + async clearStorage(): Promise { + await promisify(getWx().clearStorage.bind(getWx()), {}); + } +} diff --git a/packages/platform-wechat/src/subsystems/WeChatWASMSubsystem.ts b/packages/platform-wechat/src/subsystems/WeChatWASMSubsystem.ts new file mode 100644 index 00000000..ecc7711f --- /dev/null +++ b/packages/platform-wechat/src/subsystems/WeChatWASMSubsystem.ts @@ -0,0 +1,65 @@ +/** + * 微信小游戏 WASM 子系统 + */ + +import type { + IPlatformWASMSubsystem, + IWASMInstance, + WASMImports, + WASMExports +} from '@esengine/platform-common'; + +/** + * 微信小游戏 WASM 子系统实现 + */ +export class WeChatWASMSubsystem implements IPlatformWASMSubsystem { + async instantiate(path: string, imports?: WASMImports): Promise { + // 微信小游戏使用 WXWebAssembly.instantiate + // path 应该是相对于小游戏根目录的 .wasm 文件路径 + if (typeof WXWebAssembly === 'undefined') { + throw new Error('当前微信基础库版本不支持 WebAssembly'); + } + + const wxImports: WXWebAssembly.Imports | undefined = imports as WXWebAssembly.Imports | undefined; + const instance = await WXWebAssembly.instantiate(path, wxImports); + + return { + exports: instance.exports as WASMExports + }; + } + + isSupported(): boolean { + return typeof WXWebAssembly !== 'undefined'; + } + + /** + * 获取 WASM 内存 + * 用于 Rust/WASM 引擎的内存交互 + */ + createMemory(initial: number, maximum?: number): WebAssembly.Memory { + if (typeof WXWebAssembly === 'undefined') { + throw new Error('当前微信基础库版本不支持 WebAssembly'); + } + + return new WXWebAssembly.Memory({ + initial, + maximum, + shared: false // 微信小游戏不支持 shared memory + }); + } + + /** + * 创建 WASM Table + */ + createTable(initial: number, maximum?: number): WebAssembly.Table { + if (typeof WXWebAssembly === 'undefined') { + throw new Error('当前微信基础库版本不支持 WebAssembly'); + } + + return new WXWebAssembly.Table({ + element: 'anyfunc', + initial, + maximum + }); + } +} diff --git a/packages/platform-wechat/src/types/wx-extensions.d.ts b/packages/platform-wechat/src/types/wx-extensions.d.ts new file mode 100644 index 00000000..36edb3f7 --- /dev/null +++ b/packages/platform-wechat/src/types/wx-extensions.d.ts @@ -0,0 +1,27 @@ +/** + * 微信小游戏类型定义扩展 + * 补充官方类型定义包缺失的 API + */ + +declare namespace WechatMinigame { + interface Wx { + /** + * 判断小程序的 API,回调,参数,组件等是否在当前版本可用 + * @param schema 使用 ${API}.${method}.${param}.${option} 或者 ${component}.${attribute}.${option} 方式来调用 + * @returns 当前版本是否可用 + * + * @example + * ```typescript + * // 对象的属性或方法 + * wx.canIUse('console.log') + * wx.canIUse('CameraContext.onCameraFrame') + * + * // wx接口参数、回调或者返回值 + * wx.canIUse('openBluetoothAdapter') + * wx.canIUse('getSystemInfoSync.return.safeArea.left') + * wx.canIUse('showToast.object.image') + * ``` + */ + canIUse(schema: string): boolean; + } +} diff --git a/packages/platform-wechat/src/utils.ts b/packages/platform-wechat/src/utils.ts new file mode 100644 index 00000000..c0ede045 --- /dev/null +++ b/packages/platform-wechat/src/utils.ts @@ -0,0 +1,46 @@ +/** + * 微信小游戏工具函数 + */ + +/** + * 获取微信全局对象 + */ +export function getWx(): WechatMinigame.Wx { + if (typeof wx === 'undefined') { + throw new Error('当前环境不是微信小游戏环境'); + } + return wx; +} + +/** + * 检测当前是否为微信小游戏环境 + */ +export function isWeChatMiniGame(): boolean { + try { + if (typeof wx === 'undefined') { + return false; + } + const wxObj = wx as WechatMinigame.Wx; + return typeof wxObj.getWindowInfo === 'function' && + typeof wxObj.createCanvas === 'function' && + typeof wxObj.createImage === 'function'; + } catch { + return false; + } +} + +/** + * 将微信回调风格 API 转换为 Promise + */ +export function promisify( + fn: (options: any) => void, + options: any = {} +): Promise { + return new Promise((resolve, reject) => { + fn({ + ...options, + success: (res: T) => resolve(res), + fail: (err: any) => reject(err) + }); + }); +} diff --git a/packages/platform-wechat/tsconfig.json b/packages/platform-wechat/tsconfig.json new file mode 100644 index 00000000..7459b959 --- /dev/null +++ b/packages/platform-wechat/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": false, + "types": ["minigame-api-typings"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/thirdparty/BehaviourTree-ai b/thirdparty/BehaviourTree-ai index 429961dd..4ec6edb5 160000 --- a/thirdparty/BehaviourTree-ai +++ b/thirdparty/BehaviourTree-ai @@ -1 +1 @@ -Subproject commit 429961ddf754bedd5f9a68299577520488a903c8 +Subproject commit 4ec6edb5429e05026615977a0fed97eff11760a4